From a5424e021db33fc0bb604efc41936234fc98537c Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Sun, 6 Sep 2026 21:35:45 +0330 Subject: [PATCH 01/87] Delete the whole project to start refactoring, rename `_lib` to `_native`, and add workspace crate `rapidquery-macros` --- Cargo.lock | 521 +-------- Cargo.toml | 53 +- benchmarks.py | 230 ---- develop.sh | 90 -- pyproject.toml | 53 +- rapidquery-macros/Cargo.toml | 23 + rapidquery-macros/lib.rs | 44 + rapidquery/__init__.py | 60 -- rapidquery/_lib/__init__.pyi | 23 - rapidquery/_lib/common.pyi | 873 --------------- rapidquery/_lib/mysql.pyi | 3 - rapidquery/_lib/postgres.pyi | 3 - rapidquery/_lib/query.pyi | 1836 -------------------------------- rapidquery/_lib/schema.pyi | 857 --------------- rapidquery/_lib/sqlite.pyi | 3 - rapidquery/_lib/sqltypes.pyi | 797 -------------- src/common/column.rs | 427 -------- src/common/column_ref.rs | 340 ------ src/common/expression/expr.rs | 700 ------------ src/common/expression/func.rs | 331 ------ src/common/expression/mod.rs | 57 - src/common/foreign_key.rs | 434 -------- src/common/mod.rs | 35 - src/common/table_ref.rs | 319 ------ src/common/value.rs | 172 --- src/internal/macro_rules.rs | 203 ---- src/internal/mod.rs | 76 -- src/internal/parameters.rs | 12 - src/internal/repr.rs | 309 ------ src/internal/type_engine.rs | 436 -------- src/internal/uninitialized.rs | 189 ---- src/lib.rs | 46 +- src/mysql.rs | 5 - src/postgres.rs | 5 - src/query/base.rs | 42 - src/query/case.rs | 136 --- src/query/delete.rs | 220 ---- src/query/insert.rs | 463 -------- src/query/mod.rs | 56 - src/query/on_conflict.rs | 326 ------ src/query/ordering.rs | 114 -- src/query/returning.rs | 92 -- src/query/select.rs | 1241 --------------------- src/query/update.rs | 289 ----- src/query/window.rs | 218 ---- src/query/with.rs | 522 --------- src/schema/alter_table.rs | 620 ----------- src/schema/base.rs | 27 - src/schema/index.rs | 684 ------------ src/schema/mod.rs | 46 - src/schema/table.rs | 510 --------- src/schema/table_operations.rs | 364 ------- src/sqlite.rs | 5 - src/sqltypes/abstracts.rs | 138 --- src/sqltypes/binary.rs | 352 ------ src/sqltypes/datetimes.rs | 353 ------ src/sqltypes/json.rs | 213 ---- src/sqltypes/mod.rs | 105 -- src/sqltypes/others.rs | 491 --------- src/sqltypes/primitives.rs | 556 ---------- src/sqltypes/vector.rs | 337 ------ src/typeref.rs | 172 --- tests/mixin.py | 70 -- tests/test_common.py | 343 ------ tests/test_query.py | 427 -------- tests/test_schema.py | 272 ----- tests/test_sqltypes.py | 262 ----- uv.lock | 818 ++------------ 68 files changed, 203 insertions(+), 20246 deletions(-) delete mode 100644 benchmarks.py delete mode 100755 develop.sh create mode 100644 rapidquery-macros/Cargo.toml create mode 100644 rapidquery-macros/lib.rs delete mode 100644 rapidquery/_lib/__init__.pyi delete mode 100644 rapidquery/_lib/common.pyi delete mode 100644 rapidquery/_lib/mysql.pyi delete mode 100644 rapidquery/_lib/postgres.pyi delete mode 100644 rapidquery/_lib/query.pyi delete mode 100644 rapidquery/_lib/schema.pyi delete mode 100644 rapidquery/_lib/sqlite.pyi delete mode 100644 rapidquery/_lib/sqltypes.pyi delete mode 100644 src/common/column.rs delete mode 100644 src/common/column_ref.rs delete mode 100644 src/common/expression/expr.rs delete mode 100644 src/common/expression/func.rs delete mode 100644 src/common/expression/mod.rs delete mode 100644 src/common/foreign_key.rs delete mode 100644 src/common/mod.rs delete mode 100644 src/common/table_ref.rs delete mode 100644 src/common/value.rs delete mode 100644 src/internal/macro_rules.rs delete mode 100644 src/internal/mod.rs delete mode 100644 src/internal/parameters.rs delete mode 100644 src/internal/repr.rs delete mode 100644 src/internal/type_engine.rs delete mode 100644 src/internal/uninitialized.rs delete mode 100644 src/mysql.rs delete mode 100644 src/postgres.rs delete mode 100644 src/query/base.rs delete mode 100644 src/query/case.rs delete mode 100644 src/query/delete.rs delete mode 100644 src/query/insert.rs delete mode 100644 src/query/mod.rs delete mode 100644 src/query/on_conflict.rs delete mode 100644 src/query/ordering.rs delete mode 100644 src/query/returning.rs delete mode 100644 src/query/select.rs delete mode 100644 src/query/update.rs delete mode 100644 src/query/window.rs delete mode 100644 src/query/with.rs delete mode 100644 src/schema/alter_table.rs delete mode 100644 src/schema/base.rs delete mode 100644 src/schema/index.rs delete mode 100644 src/schema/mod.rs delete mode 100644 src/schema/table.rs delete mode 100644 src/schema/table_operations.rs delete mode 100644 src/sqlite.rs delete mode 100644 src/sqltypes/abstracts.rs delete mode 100644 src/sqltypes/binary.rs delete mode 100644 src/sqltypes/datetimes.rs delete mode 100644 src/sqltypes/json.rs delete mode 100644 src/sqltypes/mod.rs delete mode 100644 src/sqltypes/others.rs delete mode 100644 src/sqltypes/primitives.rs delete mode 100644 src/sqltypes/vector.rs delete mode 100644 src/typeref.rs delete mode 100644 tests/mixin.py delete mode 100644 tests/test_common.py delete mode 100644 tests/test_query.py delete mode 100644 tests/test_schema.py delete mode 100644 tests/test_sqltypes.py diff --git a/Cargo.lock b/Cargo.lock index b3a81d7..4edef35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,21 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - [[package]] name = "autocfg" version = "1.5.0" @@ -29,147 +14,27 @@ version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" -[[package]] -name = "bumpalo" -version = "3.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" - -[[package]] -name = "cc" -version = "1.2.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ee0f8803222ba5a7e2777dd72ca451868909b1ac410621b676adf07280e9b5f" -dependencies = [ - "shlex", -] - [[package]] name = "cfg-if" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chrono" version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "iana-time-zone", "num-traits", - "windows-link 0.2.0", ] -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "getrandom" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasi", -] - -[[package]] -name = "hashbrown" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" - [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "iana-time-zone" -version = "0.1.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "indexmap" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "inherent" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "ipnetwork" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" - -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "js-sys" -version = "0.3.80" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - [[package]] name = "libc" version = "0.2.175" @@ -186,50 +51,6 @@ dependencies = [ "scopeguard", ] -[[package]] -name = "log" -version = "0.4.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" - -[[package]] -name = "mac_address" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" -dependencies = [ - "nix", - "winapi", -] - -[[package]] -name = "memchr" -version = "2.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -245,15 +66,6 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "ordered-float" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] - [[package]] name = "parking_lot" version = "0.12.4" @@ -277,12 +89,6 @@ dependencies = [ "windows-targets", ] -[[package]] -name = "pgvector" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc58e2d255979a31caa7cabfa7aac654af0354220719ab7a68520ae7a91e8c0b" - [[package]] name = "portable-atomic" version = "1.11.1" @@ -300,9 +106,9 @@ dependencies = [ [[package]] name = "pyo3" -version = "0.28.1" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c738662e2181be11cb82487628404254902bb3225d8e9e99c31f3ef82a405c" +checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b" dependencies = [ "chrono", "libc", @@ -311,25 +117,22 @@ dependencies = [ "pyo3-build-config", "pyo3-ffi", "pyo3-macros", - "rust_decimal", - "uuid", ] [[package]] name = "pyo3-build-config" -version = "0.28.1" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9ca0864a7dd3c133a7f3f020cbff2e12e88420da854c35540fd20ce2d60e435" +checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9" dependencies = [ - "python3-dll-a", "target-lexicon", ] [[package]] name = "pyo3-ffi" -version = "0.28.1" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dfc1956b709823164763a34cc42bbfd26b8730afa77809a3df8b94a3ae3b059" +checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327" dependencies = [ "libc", "pyo3-build-config", @@ -337,9 +140,9 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.28.1" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29dc660ad948bae134d579661d08033fbb1918f4529c3bbe3257a68f2009ddf2" +checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -349,26 +152,16 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.28.1" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e78cd6c6d718acfcedf26c3d21fe0f053624368b0d44298c55d7138fde9331f7" +checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952" dependencies = [ "heck", "proc-macro2", - "pyo3-build-config", "quote", "syn", ] -[[package]] -name = "python3-dll-a" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d381ef313ae70b4da5f95f8a4de773c6aa5cd28f73adec4b4a31df70b66780d8" -dependencies = [ - "cc", -] - [[package]] name = "quote" version = "1.0.44" @@ -378,106 +171,18 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "rapidquery" -version = "0.1.0" +version = "1.0.0-alpha1" dependencies = [ - "chrono", - "indexmap", - "ipnetwork", - "mac_address", "parking_lot", - "pgvector", "pyo3", - "rust_decimal", - "sea-query", - "serde_json", - "uuid", + "rapidquery-macros", ] [[package]] -name = "redox_syscall" -version = "0.5.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" -dependencies = [ - "bitflags", -] - -[[package]] -name = "rust_decimal" -version = "1.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8975fc98059f365204d635119cf9c5a60ae67b841ed49b5422a9a7e56cdfac0" -dependencies = [ - "arrayvec", - "num-traits", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "sea-query" -version = "0.32.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c" -dependencies = [ - "chrono", - "inherent", - "ipnetwork", - "mac_address", - "ordered-float", - "pgvector", - "rust_decimal", - "serde_json", - "uuid", -] - -[[package]] -name = "serde" -version = "1.0.226" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.226" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.226" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" +name = "rapidquery-macros" +version = "0.1.0" dependencies = [ "proc-macro2", "quote", @@ -485,22 +190,19 @@ dependencies = [ ] [[package]] -name = "serde_json" -version = "1.0.142" +name = "redox_syscall" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", + "bitflags", ] [[package]] -name = "shlex" -version = "1.3.0" +name = "scopeguard" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "smallvec" @@ -531,181 +233,6 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" -[[package]] -name = "uuid" -version = "1.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" -dependencies = [ - "getrandom", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-link" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -769,9 +296,3 @@ name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" diff --git a/Cargo.toml b/Cargo.toml index cf4e1e4..d00114c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,13 @@ +[workspace] +members = ["rapidquery-macros"] + [package] name = "rapidquery" -version = "0.1.0" -edition = "2021" +version = "1.0.0-alpha1" +edition = "2024" description = "RapidQuery is the fastest, full-feature, and easy-to-use Python SQL query builder written in Rust." readme = "README.md" -license = "GNU GPLv3" +license = "MIT" homepage = "https://github.com/awolverp/rapidquery" repository = "https://github.com/awolverp/rapidquery.git" authors = ["Ali Pooralijan "] @@ -16,52 +19,26 @@ crate-type = ["cdylib", "rlib"] [profile.dev] lto = true panic = "unwind" -strip = true +strip = false [profile.release] codegen-units = 1 debug = false incremental = false lto = true -panic = "unwind" -strip = true +panic = "abort" +strip = false [dependencies] -pyo3 = { version = "0.28", default-features = false, features = ["macros", "extension-module", "chrono", "uuid", "rust_decimal", "generate-import-lib"] } -pgvector = { version = "~0.4", default-features = false } -ipnetwork = { version = "0.20", default-features = false } -mac_address = { version = "1.1", default-features = false } -uuid = { version = "1.18.1", default-features = false, features = ["v4"] } -chrono = { version = "0.4.27", default-features = false, features = ["clock"] } -serde_json = { version = "1", default-features = false, features = ["std"] } -rust_decimal = { version = "1.38.0", default-features = false } -indexmap = { version = "2.12.0", default-features = false, features = ["std"]} -parking_lot = { version = "0.12.4", default-features = false } - -[dependencies.sea-query] -version = "0.32.7" -default-features = false -features = [ - "backend-mysql", - "backend-postgres", - "backend-sqlite", - "thread-safe", - "with-chrono", - "with-json", - "with-rust_decimal", - "with-uuid", - "with-ipnetwork", - "with-mac_address", - "postgres-array", - "postgres-interval", - "postgres-vector", - "hashable-value" -] +rapidquery-macros = { path = "rapidquery-macros" } +pyo3 = { version = "0.29", default-features = false, features = [ "macros", "chrono", "py-clone" ] } +parking_lot = { version = "0.12", default-features = false } [features] -default = ["optimize"] -optimize = [] +default = ["extension-module"] +extension-module = ["pyo3/extension-module"] [lints.clippy] dbg_macro = "warn" +print_stderr = "warn" print_stdout = "warn" diff --git a/benchmarks.py b/benchmarks.py deleted file mode 100644 index e6b8c80..0000000 --- a/benchmarks.py +++ /dev/null @@ -1,230 +0,0 @@ -import sys -import time -import typing - -import pypika -import sqlalchemy as sa -from sqlalchemy.dialects.postgresql import dialect - -import rapidquery as rq - -# Postgres dialect is faster than other dialects (according to benchmarks) -# and also providing dialect in SQLALchemy can make it faster -SA_DIALECT = dialect() - -# Benchmark configuration -ITERATIONS = 100_000 -WARMUP_ITERATIONS = 100 - - -def benchmark(func: typing.Callable, number=ITERATIONS) -> float: - for _ in range(min(WARMUP_ITERATIONS, number // 10)): - func() - - perf = time.perf_counter_ns() - for _ in range(number): - func() - perf = time.perf_counter_ns() - perf - - return perf / 1000000 - - -def format_results(results: typing.Dict[str, float]) -> str: - if not results: - return "No results to display" - - # Find fastest time - fastest = min(results.values()) - - lines = [] - lines.append("-" * 70) - lines.append(f"{'Library':<20} {'Time (ms)':<15} {'vs Fastest':<15} {'Status':<20}") - lines.append("-" * 70) - - for lib, time_ms in sorted(results.items(), key=lambda x: x[1]): - if time_ms == fastest: - ratio = "1.00x (FASTEST)" - status = "šŸ†" - else: - ratio = f"{time_ms / fastest:.2f}x slower" - status = "" - - lines.append(f"{lib:<20} {time_ms:>10.2f} {ratio:<15} {status}") - - lines.append("-" * 70) - return "\n".join(lines) - - -# SELECT Query Benchmarks - - -def bench_select_rapidquery(): - query = ( - rq.SelectStatement(rq.Expr.asterisk()) - .from_table("users") - .where(rq.Expr.col("name").like(r"%linus%")) - .offset(20) - .limit(20) - ) - query.to_sql("postgresql") - - -_users_tb = sa.table("users") - - -def bench_select_sqlalchemy(): - query = ( - sa.select(sa.text("*")) - .select_from(_users_tb) - .where(sa.column("name").like(r"%linus%")) - .offset(20) - .limit(20) - ) - str(query.compile(dialect=SA_DIALECT, compile_kwargs={"literal_binds": True})) - - -def bench_select_pypika(): - query = ( - pypika.Query.from_("users") - .where(pypika.Field("name").like(r"%linus%")) - .offset(20) - .limit(20) - .select("*") - ) - str(query) - - -# INSERT Query Benchmarks - - -def bench_insert_rapidquery(): - query = ( - rq.InsertStatement("glyph") - .columns("aspect", "image") - .values(5.15, "12A") - .values(16, "14A") - .returning(rq.query.Returning("id")) - ) - query.to_sql("postgresql") - - -sa_glyph = sa.table("glyph", sa.column("aspect", sa.Float), sa.column("image", sa.String)) - - -def bench_insert_sqlalchemy(): - query = sa.insert(sa_glyph).values( - [{"aspect": 5.15, "image": "12A"}, {"aspect": 16, "image": "14A"}] - ) - str(query.compile(dialect=SA_DIALECT, compile_kwargs={"literal_binds": True})) - - -def bench_insert_pypika(): - query = ( - pypika.Query.into("glyph").columns("aspect", "image").insert(5.15, "12A").insert(16, "14A") - ) - str(query) - - -# UPDATE Query Benchmarks - - -def bench_update_rapidquery(): - query = ( - rq.UpdateStatement("wallets") - .values(amount=rq.Expr.col("amount") + 10) - .where(rq.Expr.col("id").between(10, 30)) - ) - query.to_sql("postgresql") - - -sa_wallets = sa.table("wallets", sa.column("amount", sa.Integer), sa.column("id", sa.Integer)) - - -def bench_update_sqlalchemy(): - query = ( - sa.update(sa_wallets) - .values(amount=sa_wallets.c.amount + 10) - .where(sa.between(sa_wallets.c.id, 10, 30)) - ) - str(query.compile(dialect=SA_DIALECT, compile_kwargs={"literal_binds": True})) - - -def bench_update_pypika(): - query = ( - pypika.Query.update("wallets") - .set("amount", pypika.Field("amount") + 10) - .where(pypika.Field("id").between(10, 30)) - ) - str(query) - - -# DELETE Query Benchmarks - - -def bench_delete_rapidquery(): - query = rq.DeleteStatement("users").where( - rq.all( - rq.Expr.col("id") > 10, - rq.Expr.col("id") < 30, - ) - ) - query.to_sql("postgresql") - - -sa_users = sa.table("users", sa.column("id", sa.Integer)) - - -def bench_delete_sqlalchemy(): - query = sa.delete(sa_users).where(sa.and_(sa_users.c.id > 10, sa_users.c.id < 30)) - str(query.compile(dialect=SA_DIALECT, compile_kwargs={"literal_binds": True})) - - -def bench_delete_pypika(): - query = ( - pypika.Query.from_("users") - .where((pypika.Field("id") > 10) & (pypika.Field("id") < 30)) - .delete() - ) - str(query) - - -def run_benchmarks(): - print(f"Iterations per test: {ITERATIONS:,}") - print(f"Python version: {sys.version.split()[0]}") - print() - - print("\nšŸ“Š SELECT Query Benchmark") - results = { - "RapidQuery": benchmark(bench_select_rapidquery), - "SQLAlchemy": benchmark(bench_select_sqlalchemy), - "PyPika": benchmark(bench_select_pypika), - } - print(format_results(results)) - - print("\nšŸ“Š INSERT Query Benchmark") - results = { - "RapidQuery": benchmark(bench_insert_rapidquery), - "SQLAlchemy": benchmark(bench_insert_sqlalchemy), - "PyPika": benchmark(bench_insert_pypika), - } - print(format_results(results)) - - print("\nšŸ“Š UPDATE Query Benchmark") - results = { - "RapidQuery": benchmark(bench_update_rapidquery), - "SQLAlchemy": benchmark(bench_update_sqlalchemy), - "PyPika": benchmark(bench_update_pypika), - } - print(format_results(results)) - - print("\nšŸ“Š DELETE Query Benchmark") - results = { - "RapidQuery": benchmark(bench_delete_rapidquery), - "SQLAlchemy": benchmark(bench_delete_sqlalchemy), - "PyPika": benchmark(bench_delete_pypika), - } - print(format_results(results)) - - -if __name__ == "__main__": - run_benchmarks() diff --git a/develop.sh b/develop.sh deleted file mode 100755 index 4a7b75f..0000000 --- a/develop.sh +++ /dev/null @@ -1,90 +0,0 @@ -function help() { - echo -e "RapidQuery Project Management\n" - echo -e " build build source" - echo -e " test run mypy & python tests" - echo -e " clippy run rust tests (check, clippy)" - echo -e " fmt format rust & python codes" - echo -e " bench run benchmarks" - echo -e "" - echo -e "Uses debug mode on default, use -p command to switch to production mode" -} - -function build() { - if [[ "$1" == "-p" ]]; then - echo "Building sources (production mode) ..." - UV_OFFLINE=1 maturin develop --uv --release - else - echo "Building sources (debug mode) ..." - UV_OFFLINE=1 maturin develop --uv - fi -} - -function test() { - echo "Running tests ..." - - pytest -s -vv - rm -rf .pytest_cache 2>/dev/null -} - -function clippy() { - if [[ "$1" == "-p" ]]; then - echo "Running checks (production mode) ..." - - cargo check --release - cargo clippy --release - ruff check . - mypy rapidquery --disable-error-code type-arg --strict - else - echo "Running checks (debug mode) ..." - - cargo check - cargo clippy - ruff check . - mypy rapidquery --disable-error-code type-arg --strict - fi -} - -function fmt() { - echo "Formatting codes ..." - - cargo fmt - ruff format --line-length=100 . - ruff clean -} - -function bench() { - echo "Running benchmark ..." - - build -p - python3 benchmarks.py -} - -case "${1:-help}" in - help) - help - ;; - build) - shift - build $@ - ;; - test) - shift - test $@ - ;; - clippy) - shift - clippy $@ - ;; - fmt) - shift - fmt $@ - ;; - bench) - shift - bench $@ - ;; - *) - echo -e "Unknown command: $1. Use 'help' command to see help menu." - exit 1 - ;; -esac diff --git a/pyproject.toml b/pyproject.toml index 3c34094..4df63c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,49 +1,60 @@ [build-system] -requires = ["maturin>=1.9,<2.0"] +requires = ["maturin == 1.15.0"] build-backend = "maturin" +[tool.maturin] +features = ["pyo3/extension-module"] +module-name = "rapidquery._native" + [project] name = "rapidquery" -requires-python = ">=3.8" -keywords = ["SQL", "dynamic-sql", "sql-builder", "rapidorm", "seaql", "pypika", "orm", "rapidquery"] -authors = [ - {name = "awolverp", email = "awolverp@gmail.com"} +requires-python = ">=3.10" +keywords = [ + "SQL", + "dynamic-sql", + "sql-builder", + "rapidorm", + "pypika", + "orm", + "rapidquery", ] +authors = [{ name = "Ali Pooralijan", email = "awolverp@gmail.com" }] classifiers = [ - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", + "Programming Language :: Rust", + "Programming Language :: Python", + "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", - "Programming Language :: Python", - "Programming Language :: Rust", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", + "Programming Language :: Python :: Implementation :: PyPy", + "Programming Language :: Python :: Implementation :: CPython", "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", "Operating System :: POSIX :: Linux", "Operating System :: Microsoft :: Windows", "Operating System :: MacOS", "Typing :: Typed", + "Topic :: Software Development :: Libraries", ] dynamic = [ - 'description', - 'license', - 'readme', - 'version' + "description", + "license", + "readme", + "version" ] [project.urls] -Homepage = 'https://github.com/awolverp/rapidquery' - -[tool.maturin] -features = ["pyo3/extension-module"] -module-name = "rapidquery._lib" +Homepage = "https://github.com/awolverp/rapidquery" +Repository = "https://github.com/awolverp/rapidquery" +Issues = "https://github.com/awolverp/rapidquery/issues" [dependency-groups] dev = [ - "maturin>=1.9.4", - "pytest>=8.3.5", + "maturin == 1.15.0", ] diff --git a/rapidquery-macros/Cargo.toml b/rapidquery-macros/Cargo.toml new file mode 100644 index 0000000..23cef75 --- /dev/null +++ b/rapidquery-macros/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "rapidquery-macros" +version = "0.1.0" +edition = "2024" +description = "Procedural macros for RapidQuery." +license = "GNU GPLv3" +homepage = "https://github.com/awolverp/rapidquery" +repository = "https://github.com/awolverp/rapidquery.git" +authors = ["Ali Pooralijan "] + +[lib] +path = "lib.rs" +proc-macro = true + +[dependencies] +syn = { version = "2", default-features = false, features = [ + "parsing", + "printing", + "proc-macro", + "derive", +] } +quote = "1" +proc-macro2 = "1" diff --git a/rapidquery-macros/lib.rs b/rapidquery-macros/lib.rs new file mode 100644 index 0000000..2d48e7e --- /dev/null +++ b/rapidquery-macros/lib.rs @@ -0,0 +1,44 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::parse::{Parse, ParseStream}; +use syn::{ItemStruct, LitStr, Meta, Token, parse_macro_input}; + +struct PyclassArgs { + name: LitStr, + extra: Vec, +} + +impl Parse for PyclassArgs { + fn parse(input: ParseStream) -> syn::Result { + let name: LitStr = input.parse()?; + + let mut extra = Vec::new(); + while input.peek(Token![,]) { + input.parse::()?; + if input.is_empty() { + break; + } + extra.push(input.parse::()?); + } + + Ok(PyclassArgs { name, extra }) + } +} + +#[proc_macro_attribute] +pub fn pyclass_shortcut(attr: TokenStream, item: TokenStream) -> TokenStream { + let PyclassArgs { name, extra } = parse_macro_input!(attr as PyclassArgs); + let item = parse_macro_input!(item as ItemStruct); + + let extra_tokens = if extra.is_empty() { + quote! {} + } else { + quote! { , #(#extra),* } + }; + + quote! { + #[pyo3::pyclass(module="rapidquery._native", name=#name, immutable_type, frozen #extra_tokens)] + #item + } + .into() +} diff --git a/rapidquery/__init__.py b/rapidquery/__init__.py index 13d5942..e69de29 100644 --- a/rapidquery/__init__.py +++ b/rapidquery/__init__.py @@ -1,60 +0,0 @@ -""" -RapidQuery is a powerful SQL query builder library designed for Python, -combining the simplicity of Python with the raw speed of **Rust**. -Build complex SQL queries effortlessly and efficiently, with a library that -prioritizes both performance and ease of use. -""" - -from ._lib import common as common -from ._lib import mysql as mysql -from ._lib import postgres as postgres -from ._lib import query as query -from ._lib import schema as schema -from ._lib import sqlite as sqlite -from ._lib import sqltypes as sqltypes - -# Export .common -Column = common.Column -ColumnRef = common.ColumnRef -Expr = common.Expr -ForeignKey = common.ForeignKey -Func = common.Func -TableName = common.TableName -Value = common.Value -all = common.all -any = common.any -not_ = common.not_ - -# Export .query -CaseStatement = query.CaseStatement -DeleteStatement = query.DeleteStatement -Frame = query.Frame -InsertStatement = query.InsertStatement -OnConflict = query.OnConflict -Ordering = query.Ordering -QueryStatement = query.QueryStatement -Returning = query.Returning -SelectLabel = query.SelectLabel -SelectStatement = query.SelectStatement -UpdateStatement = query.UpdateStatement -WindowStatement = query.WindowStatement -WithClause = query.WithClause -WithQuery = query.WithQuery - -# Export .schema -AlterTable = schema.AlterTable -AlterTableAddColumnOption = schema.AlterTableAddColumnOption -AlterTableAddForeignKeyOption = schema.AlterTableAddForeignKeyOption -AlterTableBaseOption = schema.AlterTableBaseOption -AlterTableDropColumnOption = schema.AlterTableDropColumnOption -AlterTableDropForeignKeyOption = schema.AlterTableDropForeignKeyOption -AlterTableModifyColumnOption = schema.AlterTableModifyColumnOption -AlterTableRenameColumnOption = schema.AlterTableRenameColumnOption -DropIndex = schema.DropIndex -DropTable = schema.DropTable -Index = schema.Index -IndexColumn = schema.IndexColumn -RenameTable = schema.RenameTable -SchemaStatement = schema.SchemaStatement -Table = schema.Table -TruncateTable = schema.TruncateTable diff --git a/rapidquery/_lib/__init__.pyi b/rapidquery/_lib/__init__.pyi deleted file mode 100644 index 978b592..0000000 --- a/rapidquery/_lib/__init__.pyi +++ /dev/null @@ -1,23 +0,0 @@ -""" -RapidQuery core which is written in Rust. -""" - -from __future__ import annotations - -from . import common as common -from . import mysql as mysql -from . import postgres as postgres -from . import query as query -from . import schema as schema -from . import sqlite as sqlite -from . import sqltypes as sqltypes - -__all__ = [ - "sqltypes", - "schema", - "query", - "common", - "sqlite", - "postgres", - "mysql", -] diff --git a/rapidquery/_lib/common.pyi b/rapidquery/_lib/common.pyi deleted file mode 100644 index 03688f1..0000000 --- a/rapidquery/_lib/common.pyi +++ /dev/null @@ -1,873 +0,0 @@ -from __future__ import annotations - -import typing - -from .query import SelectLabel, SelectStatement, WindowStatement -from .sqltypes import SQLTypeAbstract - -T = typing.TypeVar("T") -_ForeignKeyActions: typing.TypeAlias = typing.Literal[ - "CASCADE", "RESTRICT", "NO ACTION", "SET DEFAULT", "SET NULL" -] - -class _ColumnRefProperty(typing.Protocol): - __column_ref__: typing.Any - -class _TableNameProperty(typing.Protocol): - __table_name__: typing.Any - -_ColumnRefNew = typing.Union[ - "ColumnRef", - str, - _ColumnRefProperty, - type[_ColumnRefProperty], -] -_TableNameNew = typing.Union[ - "TableName", - str, - _TableNameProperty, - type[_TableNameProperty], -] -_ExprNew = typing.Any - -class Column(typing.Generic[T]): - """ - Defines a table column with its properties and constraints. - - Represents a complete column definition including: - - Column name and data type - - Constraints (primary key, unique, nullable) - - Auto-increment behavior - - Default values and generated columns - - Comments and extra specifications - - This class is used within Table to specify the structure - of table columns. It encapsulates all the properties that define how - a column behaves and what data it can store. - """ - - def __init__( - self, - name: str, - type: SQLTypeAbstract[T], - *, - primary_key: bool = False, - unique_key: bool = False, - nullable: bool | None = None, - auto_increment: bool = False, - extra: str | None = None, - comment: str | None = None, - default: typing.Any = ..., - generated: typing.Any = ..., - stored_generated: bool = False, - ) -> None: - """ - Construct a table column with column type. - - Args: - name: The name of this column as represented in the database. - type: The column's type. - primary_key: If `True`, marks this column as a primary key column. - unique_key: If `True`, marks this column as a unique key column. - nullable: If `False`, marks this column as "NOT NULL". If `True`, marks it as "NULL". - auto_increment: If `True`, marks this column as auto increment. Auto increment phrase is - depended on backend/dialect. - extra: Some extra options in custom string. - comment: (MySQL only) Column comment. - default: Represents default value for the column. - generated: Sets the column as generated. - stored_generated: Works with `generated` parameter together, which marks column as "STORED GENERATED". - """ - ... - - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - def adapt(self, object: T | None) -> Value[T]: - """Shorthand for `Value(object, self.type)`.""" - ... - - @property - def auto_increment(self) -> bool: - """Whether this is a auto increment column.""" - ... - @auto_increment.setter - def auto_increment(self, value: bool) -> None: ... - @property - def comment(self) -> str | None: - """Comment describing this column.""" - ... - @comment.setter - def comment(self, value: str | None) -> None: ... - @property - def default(self) -> Expr | None: - """Default value for this column.""" - ... - @default.setter - def default(self, value: _ExprNew) -> None: ... - @property - def extra(self) -> str | None: - """Extra SQL specifications for this column.""" - ... - @extra.setter - def extra(self, value: str | None) -> None: ... - @property - def generated(self) -> Expr | None: - """Expression for generated column values.""" - ... - @generated.setter - def generated(self, value: _ExprNew) -> None: ... - @property - def name(self) -> str: - """Column name.""" - ... - @name.setter - def name(self, value: str) -> None: ... - @property - def nullable(self) -> bool | None: - """Whether this is a nullable column.""" - ... - @property - def primary_key(self) -> bool: - """Whether this is a nullable column.""" - ... - @primary_key.setter - def primary_key(self, value: bool) -> None: ... - @property - def stored_generated(self) -> bool: - """Whether this is a stored generated column.""" - ... - @stored_generated.setter - def stored_generated(self, value: bool) -> None: ... - @property - def type(self) -> SQLTypeAbstract[T]: - """Column type.""" - ... - - @property - def unique_key(self) -> bool: - """Whether this is a unique column.""" - ... - @unique_key.setter - def unique_key(self, value: bool) -> None: ... - @property - def __column_ref__(self) -> ColumnRef: ... - def to_expr(self) -> Expr: - """Shorthand for `Expr(self)`""" - ... - - def label(self, alias: str, window: WindowStatement | str | None = None) -> SelectLabel: - """Shorthand for `SelectLabel(self, alias, window)`""" - pass - -@typing.final -class ColumnRef: - """ - Represents a reference to a database column with optional table and schema qualification. - - This class is used to uniquely identify columns in SQL queries, supporting - schema-qualified and table-qualified column references. - - NOTE: this class is immutable and frozen, But you can use `.copy_with` method to make changes on it. - """ - - def __new__( - cls, - name: str, - table: str | None = ..., - schema: str | None = ..., - ) -> typing.Self: ... - def __copy__(self) -> typing.Self: ... - def __eq__(self, value: object, /) -> bool: ... - def __ne__(self, value: object, /) -> bool: ... - def __repr__(self, /) -> str: ... - def copy_with( - self, - *, - name: str = ..., - table: str | None = ..., - schema: str | None = ..., - ) -> typing.Self: - """ - Returns a copy of this `ColumnRef`, but with the changes you want. - """ - ... - - @property - def name(self) -> str: - """Column reference name.""" - ... - - @classmethod - def parse(cls, string: str) -> typing.Self: - """ - Parse a string representation of a column reference. - - Supports formats like: - - "column_name" - - "table.column_name" - - "schema.table.column_name" - """ - ... - - @property - def schema(self) -> str | None: - """Schema name""" - ... - - @property - def table(self) -> str | None: - """Table name""" - ... - - def __hash__(self) -> int: ... - def to_expr(self) -> Expr: - """Shorthand for `Expr(self)`""" - ... - - def label(self, alias: str, window: WindowStatement | str | None = None) -> SelectLabel: - """Shorthand for `SelectLabel(self, alias, window)`""" - pass - -@typing.final -class Expr: - """ - Represents a SQL expression that can be built into SQL code. - - This class provides a fluent interface for constructing complex SQL expressions - in a database-agnostic way. It supports arithmetic operations, comparisons, - logical operations, and database-specific functions. - - The class automatically handles SQL injection protection and proper quoting - when building the final SQL statement. - - NOTE: `Expr` is immutable, so by calling each method you will give a new instance - of it which includes new change(s). - """ - - def __new__(cls, value: _ExprNew, /) -> typing.Self: ... - def __add__(self, other: _ExprNew) -> typing.Self: - """Create an addition expression.""" - ... - - def __and__(self, other: _ExprNew) -> typing.Self: - """Create a logical AND expression.""" - ... - - def __eq__(self, other: _ExprNew) -> typing.Self: # type: ignore[override] - """Create an equality comparison expression.""" - ... - - def __ge__(self, other: _ExprNew) -> typing.Self: - """Create a greater-than-or-equal comparison expression.""" - ... - - def __gt__(self, other: _ExprNew) -> typing.Self: - """Create a greater-than comparison expression.""" - ... - - def __le__(self, other: _ExprNew) -> typing.Self: - """Create a less-than-or-equal comparison expression.""" - ... - - def __lshift__(self, other: _ExprNew) -> typing.Self: - """Create a bitwise left-shift expression.""" - ... - - def __lt__(self, other: _ExprNew) -> typing.Self: - """Create a less-than comparison expression.""" - ... - - def __mod__(self, other: _ExprNew) -> typing.Self: - """Create a modulo expression.""" - ... - - def __mul__(self, other: _ExprNew) -> typing.Self: - """Create a multiplication expression.""" - ... - - def __ne__(self, other: _ExprNew) -> typing.Self: # type: ignore[override] - """Create an inequality comparison expression.""" - ... - - def __neg__(self) -> typing.Self: - """Create a negation expression.""" - ... - - def __or__(self, other: _ExprNew) -> typing.Self: - """Create a logical OR expression.""" - ... - - def __radd__(self, other: _ExprNew) -> typing.Self: - """Create an addition expression.""" - ... - - def __rand__(self, other: _ExprNew) -> typing.Self: - """Create a logical AND expression.""" - ... - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - def __rlshift__(self, other: _ExprNew) -> typing.Self: - """Create a bitwise left-shift expression.""" - ... - - def __rmod__(self, other: _ExprNew) -> typing.Self: - """Create a modulo expression.""" - ... - - def __rmul__(self, other: _ExprNew) -> typing.Self: - """Create a multiplication expression.""" - ... - - def __ror__(self, other: _ExprNew) -> typing.Self: - """Create a logical OR expression.""" - ... - - def __rrshift__(self, other: _ExprNew) -> typing.Self: - """Create a bitwise right-shift expression.""" - ... - - def __rshift__(self, other: _ExprNew) -> typing.Self: - """Create a bitwise right-shift expression.""" - ... - - def __rsub__(self, other: _ExprNew) -> typing.Self: - """Create a subtraction expression.""" - ... - - def __rtruediv__(self, other: _ExprNew) -> typing.Self: - """Create a division expression.""" - ... - - def __sub__(self, other: _ExprNew) -> typing.Self: - """Create a subtraction expression.""" - ... - - def __truediv__(self, other: _ExprNew) -> typing.Self: - """Create a division expression.""" - ... - - @classmethod - def all(cls, statement: SelectStatement) -> typing.Self: - """Express a `ALL` sub-query expression.""" - ... - - @classmethod - def any(cls, statement: SelectStatement) -> typing.Self: - """Express a `ANY` sub-query expression.""" - ... - - @classmethod - def asterisk(cls) -> typing.Self: - """Returns asterisk '*' expression.""" - ... - - def between(self, a: _ExprNew, b: _ExprNew) -> typing.Self: - """Create a BETWEEN range comparison expression.""" - ... - - def bit_and(self, other: _ExprNew) -> typing.Self: - """Create a bitwise AND expression.""" - ... - - def bit_or(self, other: _ExprNew) -> typing.Self: - """Create a bitwise AND expression.""" - ... - - def cast_as(self, value: str) -> typing.Self: - """Create a `CAST` expression to convert to a specific SQL type.""" - ... - - @classmethod - def col(cls, value: _ColumnRefNew) -> typing.Self: - """ - Tries to convert the `value` into `ColumnRef`, and then converts it to `Expr`. - """ - ... - - @classmethod - def current_date(cls) -> typing.Self: - """Create an expression for the CURRENT_DATE SQL function.""" - ... - - @classmethod - def current_time(cls) -> typing.Self: - """Create an expression for the CURRENT_TIME SQL function.""" - ... - - @classmethod - def current_timestamp(cls) -> typing.Self: - """Create an expression for the CURRENT_TIMESTAMP SQL function.""" - ... - - @classmethod - def custom(cls, value: str) -> typing.Self: - """ - Create an expression from a custom SQL string. - - Warning: This method does not escape the input, so it should only - be used with trusted strings to avoid SQL injection vulnerabilities. - """ - ... - - @classmethod - def exists(cls, statement: SelectStatement) -> typing.Self: - """Express a `EXISTS` sub-query expression.""" - ... - - def in_(self, other: typing.Iterable[_ExprNew] | SelectStatement) -> typing.Self: - """Express a `IN` expression.""" - ... - - def is_(self, other: _ExprNew) -> typing.Self: - """Create an IS comparison expression.""" - ... - - def is_not(self, other: _ExprNew) -> typing.Self: - """Create an IS NOT comparison expression.""" - ... - - def is_not_null(self) -> typing.Self: - """Create an IS NOT NULL expression.""" - ... - - def is_null(self) -> typing.Self: - """Create an IS NULL expression.""" - ... - - def like(self, pattern: str, escape: str | None = ...) -> typing.Self: - """Create a `LIKE` pattern matching expression.""" - ... - - def not_between(self, a: _ExprNew, b: _ExprNew) -> typing.Self: - """Create a NOT BETWEEN range comparison expression.""" - ... - - def not_in(self, other: typing.Iterable[_ExprNew] | SelectStatement) -> typing.Self: - """Express a `NOT IN` expression.""" - ... - - def not_like(self, pattern: str, escape: str | None = ...) -> typing.Self: - """Create a NOT LIKE pattern matching expression.""" - ... - - def max(self) -> typing.Self: - """ - Create `MAX(self)` function call. - - Shorthand for `Func.max(self).to_expr()`. - """ - ... - - def min(self) -> typing.Self: - """ - Create `MIN(self)` function call. - - Shorthand for `Func.min(self).to_expr()`. - """ - ... - - def abs(self) -> typing.Self: - """ - Create `ABS(self)` function call. - - Shorthand for `Func.abs(self).to_expr()`. - """ - ... - - @classmethod - def null(cls) -> typing.Self: - """Create an expression representing the NULL value.""" - ... - - @classmethod - def some(cls, statement: SelectStatement) -> typing.Self: - """Express a `SOME` sub-query expression.""" - ... - - @classmethod - def val( - cls, - /, - value: T | None, - sql_type: SQLTypeAbstract[T] | None = ..., - ) -> typing.Self: - """Shorthand for `Expr(Value(value, sql_type))`""" - ... - - def label(self, alias: str, window: WindowStatement | str | None = None) -> SelectLabel: - """Shorthand for `SelectLabel(self, alias, window)`""" - pass - - def _to_sql(self, backend: str) -> str: ... - - __hash__ = None # type: ignore - -class ForeignKey: - """ - Specifies a foreign key relationship between tables. - - Defines referential integrity constraints including: - - Source columns (in the child table) - - Target columns (in the parent table) - - Actions for updates and deletes (CASCADE, RESTRICT, SET NULL, etc.) - - Optional naming for the constraint - - Foreign keys ensure data consistency by requiring that values in the - child table's columns match existing values in the parent table's columns. - """ - - def __init__( - self, - from_columns: typing.Iterable[_ColumnRefNew], - to_columns: typing.Iterable[_ColumnRefNew], - to_table: _TableNameNew | None = None, - name: str | None = None, - *, - on_delete: _ForeignKeyActions | None = None, - on_update: _ForeignKeyActions | None = None, - ) -> None: - """ - Construct a foreign key constraint. You can use it with `Table` or `AlterTable`. - - Args: - from_columns: An iterable of column references, or column names, or `Column` object. - The columns must defined and presented in the parent table. Cannot be empty. - to_columns: An iterable of column references, or column names, or `Column` object. - The columns must defined and presented in the target table. Cannot be empty. - to_table: The target table. If not specified, tries to detect it from `to_columns` argument. - name: The constraint name. - on_delete: The foreign key action for "ON DELETE". - on_update: The foreign key action for "ON UPDATE". - """ - ... - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - @property - def from_columns(self) -> typing.Sequence[str]: - """Key columns.""" - ... - @from_columns.setter - def from_columns(self, value: typing.Iterable[_ColumnRefNew]) -> None: ... - @property - def name(self) -> str | None: - """Foreign key constraint name""" - ... - @name.setter - def name(self, value: str | None) -> None: ... - @property - def on_delete(self) -> _ForeignKeyActions | None: - """ON DELETE action.""" - ... - @on_delete.setter - def on_delete(self, value: _ForeignKeyActions | None) -> None: ... - @property - def on_update(self) -> _ForeignKeyActions | None: - """ON UPDATE action.""" - ... - @on_update.setter - def on_update(self, value: _ForeignKeyActions | None) -> None: ... - @property - def to_columns(self) -> typing.Sequence[str]: - """Referencing columns.""" - ... - @to_columns.setter - def to_columns(self, value: typing.Iterable[_ColumnRefNew]) -> None: ... - @property - def to_table(self) -> TableName: - """Referencing table.""" - ... - @to_table.setter - def to_table(self, value: TableName) -> None: ... - -@typing.final -class Func: - """ - Represents a SQL function call that can be used in expressions. - - This class provides a type-safe way to construct SQL function calls - with proper argument handling and database dialect support. - """ - - def __new__(cls, name: str, *args: _ExprNew) -> typing.Self: - """ - Construct a function expression. You can use ready-to-use classmethods instead of this. - - Args: - name: the function name in string. - args: function arguments. - """ - ... - - def __repr__(self, /) -> str: ... - @classmethod - def abs(cls, /, expr: _ExprNew) -> typing.Self: - """Create a ABS(expr) function call.""" - ... - - @classmethod - def avg(cls, /, expr: _ExprNew) -> typing.Self: - """Create a AVG(expr) function call.""" - ... - - @classmethod - def bit_and(cls, /, expr: _ExprNew) -> typing.Self: - """ - Create a BIT_AND(expr) function call - this is not supported on SQLite. - """ - ... - - @classmethod - def bit_or(cls, /, expr: _ExprNew) -> typing.Self: - """Create a BIT_OR(expr) function call - this is not supported on SQLite.""" - ... - - @classmethod - def char_length(cls, /, expr: _ExprNew) -> typing.Self: - """Create a CHAR_LENGTH(expr) function call.""" - ... - - @classmethod - def coalesce(cls, /, *exprs: _ExprNew) -> typing.Self: - """Create a COALESCE function call.""" - ... - - @classmethod - def count(cls, /, expr: _ExprNew) -> typing.Self: - """Create a COUNT(expr) function call.""" - ... - - @classmethod - def count_distinct(cls, /, expr: _ExprNew) -> typing.Self: - """Create a COUNT(DISTINCT expr) function call.""" - ... - - @classmethod - def dense_rank(cls, /) -> typing.Self: - """Create a DENSE_RANK() function call.""" - ... - - @classmethod - def greatest(cls, /, *exprs: _ExprNew) -> typing.Self: - """Create a GREATEST function call.""" - ... - - @classmethod - def if_null(cls, /, a: _ExprNew, b: _ExprNew) -> typing.Self: - """Create a IF_NULL(a, b) function call.""" - ... - - @classmethod - def least(cls, /, *exprs: _ExprNew) -> typing.Self: - """Create a LEAST function call.""" - ... - - @classmethod - def lower(cls, /, expr: _ExprNew) -> typing.Self: - """Create a LOWER(expr) function call.""" - ... - - @classmethod - def max(cls, /, expr: _ExprNew) -> typing.Self: - """Create a MAX(expr) function call.""" - ... - - @classmethod - def md5(cls, /, expr: _ExprNew) -> typing.Self: - """ - Create a MD5(expr) function call - this is only available in Postgres and MySQL. - """ - ... - - @classmethod - def min(cls, /, expr: _ExprNew) -> typing.Self: - """Create a MIN(expr) function call.""" - ... - - @classmethod - def now(cls) -> typing.Self: - """Create a NOW() function call.""" - ... - - @classmethod - def percent_rank(cls, /) -> typing.Self: - """Create a PERCENT_RANK() function call.""" - ... - - @classmethod - def random(cls, /) -> typing.Self: - """Create a RANDOM() function call.""" - ... - - @classmethod - def rank(cls, /) -> typing.Self: - """Create a RANK() function call.""" - ... - - @classmethod - def round(cls, /, expr: _ExprNew) -> typing.Self: - """Create a ROUND(expr) function call.""" - ... - - @classmethod - def round_with_precision(cls, /, a: _ExprNew, b: _ExprNew) -> typing.Self: - """Create a ROUND(a, b) function call.""" - ... - - @classmethod - def sum(cls, /, expr: _ExprNew) -> typing.Self: - """Create a SUM(expr) function call.""" - ... - - @classmethod - def upper(cls, /, expr: _ExprNew) -> typing.Self: - """Create a UPPER(expr) function call.""" - ... - - @classmethod - def cast_as(cls, /, expr: _ExprNew, alias: str) -> typing.Self: - """Call CAST function with a custom type.""" - ... - - def to_expr(self) -> Expr: - """Shorthand for `Expr(self)`""" - ... - - def label(self, alias: str, window: WindowStatement | str | None = None) -> SelectLabel: - """Shorthand for `SelectLabel(self, alias, window)`""" - pass - -@typing.final -class TableName: - """ - Represents a table name reference with optional schema, database, and alias. - - This class encapsulates a table name that can include: - - The base table name - - Optional schema/namespace qualification - - Optional database qualification (for systems that support it) - - The class provides parsing capabilities for string representations - and supports comparison operations. - - NOTE: this class is immutable and frozen. - """ - - def __new__( - cls, - name: str, - schema: str | None = None, - database: str | None = None, - alias: str | None = None, - ) -> typing.Self: ... - def __copy__(self) -> typing.Self: ... - def __eq__(self, value: object, /) -> bool: - """Return self==value.""" - ... - - def __ne__(self, value: object, /) -> bool: - """Return self!=value.""" - ... - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def alias(self) -> str | None: ... - def copy_with( - self, - *, - name: str = ..., - schema: str | None = ..., - database: str | None = ..., - alias: str | None = ..., - ) -> typing.Self: - """Create a shallow copy of this TableName.""" - ... - - @property - def database(self) -> str | None: ... - @property - def name(self) -> str: ... - @classmethod - def parse(cls, string: str) -> typing.Self: - """ - Parse a string representation of a table name. - - Supports formats like: - - "table_name" - - "schema.table_name" - - "database.schema.table_name" - """ - ... - - @property - def schema(self) -> str | None: ... - def __hash__(self) -> int: ... - -class Value(typing.Generic[T]): - """ - Bridges Python types, Rust types, and SQL types for seamless data conversion. - - This class handles validation, adaptation, and conversion between different - type systems used in the application stack. - - NOTE: this class is immutable and frozen. - """ - - def __init__( - self, - value: T | None, - sql_type: SQLTypeAbstract[T] | None = ..., - ) -> None: - """ - Construct a new `Value`. - - It can automatically detects the type of your value and selects appropriate Rust and SQL types. - For example: - - Python `int` becomes `BIGINT` SQL type (`BigIntegerType`) - - Python `dict` or `list` becomes `JSON` SQL type (`JsonType`) - - Python `float` becomes `DOUBLE` SQL type (`DoubleType`) - - However, for more accurate type selection, use the `sql_type` parameter. - """ - ... - - def __hash__(self, /) -> int: ... - def __repr__(self, /) -> str: ... - @property - def sql_type(self) -> SQLTypeAbstract[T]: - """The defined or detected SQL type.""" - ... - - @property - def value(self) -> T | None: - """Converts the adapted value back to Python.""" - ... - - def to_expr(self) -> Expr: - """Shorthand for `Expr(self)`""" - ... - -def all(arg1: Expr, *args: Expr) -> Expr: - """ - Create a logical AND condition that is true only if all conditions are true. - - This is equivalent to SQL's AND operator applied to multiple expressions. - """ - ... - -def any(arg1: Expr, *args: Expr) -> Expr: - """ - Create a logical OR condition that is true if any condition is true. - - This is equivalent to SQL's OR operator applied to multiple expressions. - """ - ... - -def not_(arg: Expr) -> Expr: - """Create a logical NOT.""" - ... diff --git a/rapidquery/_lib/mysql.pyi b/rapidquery/_lib/mysql.pyi deleted file mode 100644 index 4d21ee8..0000000 --- a/rapidquery/_lib/mysql.pyi +++ /dev/null @@ -1,3 +0,0 @@ -from __future__ import annotations - -__all__ = [] diff --git a/rapidquery/_lib/postgres.pyi b/rapidquery/_lib/postgres.pyi deleted file mode 100644 index 4d21ee8..0000000 --- a/rapidquery/_lib/postgres.pyi +++ /dev/null @@ -1,3 +0,0 @@ -from __future__ import annotations - -__all__ = [] diff --git a/rapidquery/_lib/query.pyi b/rapidquery/_lib/query.pyi deleted file mode 100644 index a9c6402..0000000 --- a/rapidquery/_lib/query.pyi +++ /dev/null @@ -1,1836 +0,0 @@ -from __future__ import annotations - -import typing - -from .common import Expr, Func, Value, _ColumnRefNew, _ExprNew, _TableNameNew - -_BackendName: typing.TypeAlias = typing.Literal[ - "sqlite", "postgresql", "postgres", "mysql" -] - -class CaseStatement: - """ - Represents a `CASE WHEN` SQL statement. - """ - - def __init__(self) -> None: - """ - Construct a `CASE WHEN` statement. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.SelectStatement( - rq.SelectLabel( - rq.CaseStatement() - .when(rq.Expr.col("aspect").in_([2, 4]), True) - .else_(False), - "is_even", - ), - ) - .from_table("glyph") - .to_sql("postgres") - ) - # SELECT (CASE WHEN ("aspect" IN (2, 4)) THEN TRUE ELSE FALSE END) AS "is_even" FROM "glyph" - ``` - """ - ... - - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - def else_(self, result: _ExprNew) -> typing.Self: - """ - Ends the case statement with the final ELSE result. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.SelectStatement( - rq.SelectLabel( - rq.CaseStatement() - .when(rq.Expr.col("aspect") > 0, "positive") - .when(rq.Expr.col("aspect") < 0, "negative") - .else_("zero"), - "polarity", - ), - ) - .from_table("glyph") - .to_sql("postgres") - ) - # SELECT (CASE WHEN ("aspect" > 0) THEN 'positive' WHEN ("aspect" < 0) THEN 'negative' ELSE 'zero' END) AS "polarity" FROM "glyph" - ``` - """ - ... - - def when(self, condition: Expr, result: _ExprNew) -> typing.Self: - """ - Adds new `WHEN` to existing case statement. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.SelectStatement( - rq.CaseStatement() - .when( - rq.any( - rq.Expr.col("font_size") > 48, - rq.Expr.col("size_w") > 500, - ), - "large", - ) - .when( - rq.any( - rq.Expr.col("font_size").between(24, 48), - rq.Expr.col("size_w").between(300, 500), - ), - "medium", - ) - .else_("small") - .label("char_size"), - ) - .from_table("characters") - .to_sql("postgres") - ) - # SELECT - # (CASE WHEN ("font_size" > 48 OR "size_w" > 500) THEN 'large' - # WHEN (("font_size" BETWEEN 24 AND 48) OR ("size_w" BETWEEN 300 AND 500)) THEN 'medium' - # ELSE 'small' END) AS "char_size" - # FROM "characters" - ``` - """ - ... - - def to_expr(self) -> Expr: - """Shorthand for `Expr(self)`""" - ... - - def label( - self, alias: str, window: WindowStatement | str | None = None - ) -> SelectLabel: - """Shorthand for `SelectLabel(self, alias, window)`""" - pass - -class DeleteStatement(QueryStatement): - """ - Builds `DELETE` SQL statements with a fluent interface. - - Provides a chainable API for constructing DELETE queries with support for: - - WHERE conditions for filtering - - LIMIT for restricting deletion count - - ORDER BY for determining deletion order - - RETURNING clauses for getting deleted data - """ - - def __init__(self, table: _TableNameNew) -> None: - """ - Construct a `DELETE` statement. - - Args: - table: The table to delete from. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.DeleteStatement("glyph") - .where(rq.any(rq.Expr.col("id") < 1, rq.Expr.col("id") > 10)) - .to_sql("mysql") - ) - # DELETE FROM `glyph` WHERE `id` < 1 OR `id` > 10 - ``` - """ - ... - - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - def clear_order_by(self) -> typing.Self: - """Remove orders from statement.""" - ... - - def clear_where(self) -> typing.Self: - """Remove where conditions from statement.""" - ... - - def from_table(self, table: _TableNameNew) -> typing.Self: - """ - Override the table to delete from. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.DeleteStatement("name_1") - .where(rq.Expr.col("id") == 1) - .from_table("name_2") - .to_sql("sqlite") - ) - # DELETE FROM "name_2" WHERE "id" = 1 - ``` - """ - ... - - def limit(self, n: int) -> typing.Self: - """Limit the number of rows to delete.""" - ... - - def order_by(self, clause: Ordering) -> typing.Self: - """ - Specify the order in which to delete rows. Typically used with - `.limit` method to delete specific rows. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.DeleteStatement("glyph") - .where(rq.Expr.col("id") < 100) - .limit(20) - .order_by(rq.Ordering("id", "DESC")) - .to_sql("postgres") - ) - # DELETE FROM "glyph" WHERE "id" < 100 ORDER BY "id" DESC LIMIT 20 - ``` - """ - ... - - def returning(self, clause: Returning) -> typing.Self: - """ - Specify columns to return from the inserted rows. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.DeleteStatement("glyph") - .where(rq.Expr.col("id") < 30) - .returning(rq.Returning("id")) - .to_sql("sqlite") - ) - # DELETE FROM "glyph" WHERE "id" < 30 RETURNING "id" - ``` - """ - ... - - def where(self, condition: Expr) -> typing.Self: - """ - Add a `WHERE` condition to filter rows to delete. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.DeleteStatement("glyph") - .where(rq.Expr.col("id") < 100) - .where(rq.Expr.col("name").like("%A")) - .to_sql("postgres") - ) - # DELETE FROM "glyph" WHERE "id" < 100 AND "name" LIKE '%A' - ``` - """ - ... - -@typing.final -class Frame: - """ - Defines the window frame start and end boundaries for window functions. - - A frame allows you to constrain the set of rows used by the window function - to a subset relative to the current row. - - Use the provided classmethods to construct specific frame boundaries. - """ - - @classmethod - def current_row(cls) -> typing.Self: - """The boundary is the current row being processed.""" - ... - - @classmethod - def following(cls, val: int) -> typing.Self: - """ - The boundary is a fixed number of rows/values after the current row. - - Args: - val: The number of rows or the value range following the current row. - """ - ... - - @classmethod - def preceding(cls, val: int) -> typing.Self: - """ - The boundary is a fixed number of rows/values before the current row. - - Args: - val: The number of rows or the value range preceding the current row. - """ - ... - - @classmethod - def unbounded_following(cls) -> typing.Self: - """The boundary is the last row of the partition.""" - ... - - @classmethod - def unbounded_preceding(cls) -> typing.Self: - """The boundary is the first row of the partition.""" - ... - -class InsertStatement(QueryStatement): - """ - Builds `INSERT` SQL statements with a fluent interface. - - Provides a chainable API for constructing INSERT queries with support for: - - Single or multiple row insertion - - Conflict resolution (UPSERT) - - RETURNING clauses - - REPLACE functionality - - Default values - """ - - def __init__(self, table: _TableNameNew) -> None: - """ - Construct a `INSERT` statement. - - Args: - table: The target table for insertion. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.InsertStatement("glyph") - .values(aspect=4.21, image="123") - .to_sql("mysql") - ) - # INSERT INTO `glyph` (`aspect`, `image`) VALUES (4.21, '123') - ``` - """ - ... - - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - def columns(self, *args: _ColumnRefNew) -> typing.Self: - """ - Specify (override) the columns for insertion. - - There's no need to use this method when you're specifying column - names in `.values` method. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.InsertStatement("glyph") - .columns("aspect", "image") - .values(5.15, "12A") - .values(4.21, "123") - .to_sql("mysql") - ) - # INSERT INTO `glyph` (`aspect`, `image`) VALUES (5.15, '12A'), (4.21, '123') - ``` - """ - ... - - def into(self, table: _TableNameNew) -> typing.Self: - """ - Override the target table for insertion. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.InsertStatement("name_1") - .values(aspect=4.21, image="123") - .into("name_2") - .to_sql("sqlite") - ) - # INSERT INTO "name_2" ("aspect", "image") VALUES (4.21, '123') - ``` - """ - ... - - def on_conflict(self, action: OnConflict) -> typing.Self: - """Specify conflict resolution behavior (UPSERT).""" - ... - - def or_default_values(self, rows: int = 1) -> typing.Self: - """ - Use DEFAULT VALUES if no values were specified. The `rows` - Specifies number of rows to insert with default values. - - Examples: - ```python - import rapidquery as rq - - stmt = rq.InsertStatement("glyph").or_default_values().to_sql("postgres") - # INSERT INTO "glyph" VALUES (DEFAULT) - - stmt = ( - rq.InsertStatement("glyph") - .or_default_values() - .values(aspect=6.7) - .to_sql("postgres") - ) - # INSERT INTO "glyph" ("aspect") VALUES (6.7) - ``` - """ - ... - - def replace(self) -> typing.Self: - """ - Convert this INSERT to a REPLACE statement. - - REPLACE will delete existing rows that conflict with the new row - before inserting. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.InsertStatement("glyph") - .replace() - .values(aspect=5.15, image="12A") - .to_sql("sqlite") - ) - # REPLACE INTO "glyph" ("aspect", "image") VALUES (5.15, '12A') - ``` - """ - ... - - def returning(self, clause: Returning) -> typing.Self: - """ - Specify columns to return from the inserted rows. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.InsertStatement("glyph") - .values(image="12A") - .returning(rq.Returning.all()) - .to_sql("sqlite") - ) - # INSERT INTO "glyph" ("image") VALUES ('12A') RETURNING * - - stmt = ( - rq.InsertStatement("glyph") - .values(image="12A") - .returning(rq.Returning("id")) - .to_sql("sqlite") - ) - # INSERT INTO "glyph" ("image") VALUES ('12A') RETURNING "id" - ``` - """ - ... - - def select_from(self, statement: SelectStatement) -> typing.Self: - """ - Specify a select query whose values to be inserted. Raises `ValueError` if - `self`s columns length and `statement`s columns length has mismatch. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.InsertStatement("glyph") - .columns("aspect", "image") - .select_from( - rq.SelectStatement(rq.Expr.col("aspect"), rq.Expr.col("image")) - .from_table("glyph") - .where(rq.Expr.col("image").like("0%")) - ) - .to_sql("mysql") - ) - # INSERT INTO `glyph` (`aspect`, `image`) - # SELECT `aspect` AS `aspect`, `image` AS `image` FROM `glyph` WHERE `image` LIKE '0%' - - stmt = ( - rq.InsertStatement("glyph") - .columns("image") - .select_from( - rq.SelectStatement("hello").where( - rq.not_(rq.Expr.exists(rq.SelectStatement("world"))) - ) - ) - .to_sql("postgres") - ) - # INSERT INTO "glyph" ("image") SELECT 'hello' WHERE NOT EXISTS(SELECT 'world') - ``` - """ - ... - - @typing.overload - def values(self, *args: _ExprNew) -> typing.Self: - """ - Specify values to insert. Also you can specify columns using keyword arguments. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.InsertStatement("glyph") - .columns("aspect", "image") - .values(2, rq.Func.cast_as("2020-02-02 00:00:00", "DATE")) - .to_sql("postgres") - ) - # INSERT INTO "glyph" ("aspect", "image") VALUES (2, CAST('2020-02-02 00:00:00' AS DATE)) - ``` - """ - ... - - @typing.overload - def values(self, **kwds: _ExprNew) -> typing.Self: - """ - Specify values to insert. Also you can specify columns using keyword arguments. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.InsertStatement("glyph") - .values(aspect=2, image=rq.Func.cast_as("2020-02-02 00:00:00", "DATE")) - .to_sql("postgres") - ) - # INSERT INTO "glyph" ("aspect", "image") VALUES (2, CAST('2020-02-02 00:00:00' AS DATE)) - ``` - """ - ... - -class OnConflict: - """ - Specifies conflict resolution behavior for `INSERT` statements. - - Handles situations where an `INSERT` would violate a unique constraint - or primary key. - - This corresponds to `INSERT ... ON CONFLICT` in PostgreSQL and - `INSERT ... ON DUPLICATE KEY UPDATE` in MySQL. - """ - - def __init__(self, *targets: _ColumnRefNew) -> None: - """ - Construct a new `OnConflict` instance. - - Args: - targets: Target columns. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.InsertStatement("glyph") - .values(aspect=3.1415, image="abcd") - .on_conflict(rq.OnConflict("id").do_update(image="ex")) - ) - stmt.to_sql("postgres") - # INSERT INTO "glyph" ("aspect", "image") VALUES (3.1415, 'abcd') - # ON CONFLICT ("id") DO UPDATE SET "image" = 'ex' - ``` - """ - ... - - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - def action_where(self, condition: Expr) -> typing.Self: - """ - Add a `WHERE` clause to the conflict action (conditional update). - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.InsertStatement("glyph") - .values(aspect=3.1415, image="abcd") - .on_conflict( - rq.OnConflict("id") - .do_update(image="ex") - .action_where(rq.Expr.col("aspect").is_null()) - ) - ) - stmt.to_sql("postgres") - # INSERT INTO "glyph" ("aspect", "image") VALUES (3.1415, 'abcd') - # ON CONFLICT ("id") DO UPDATE SET "image" = 'ex' WHERE "aspect" IS NULL - - stmt.to_sql("mysql") - # INSERT INTO `glyph` (`aspect`, `image`) VALUES (3.1415, 'abcd') - # ON DUPLICATE KEY UPDATE `image` = 'ex' - ``` - """ - ... - - def do_nothing(self, *keys: _ColumnRefNew) -> typing.Self: - """ - Specify `DO NOTHING` action for conflicts. - - When a conflict occurs, the conflicting row will be skipped. - - `keys` parameter provides primary keys if you are using MySQL, for MySQL specific polyfill. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.InsertStatement("glyph") - .values(aspect=3.1415, image="abcd") - .on_conflict(rq.OnConflict("id").do_nothing()) - ) - stmt.to_sql("postgres") - # INSERT INTO "glyph" ("aspect", "image") VALUES (3.1415, 'abcd') ON CONFLICT ("id") DO NOTHING - - stmt.to_sql("mysql") - # INSERT INTO `glyph` (`aspect`, `image`) VALUES (3.1415, 'abcd') ON DUPLICATE KEY IGNORE - - stmt = ( - rq.InsertStatement("glyph") - .values(aspect=3.1415, image="abcd") - .on_conflict(rq.OnConflict("id").do_nothing("id")) - ) - stmt.to_sql("postgres") - # INSERT INTO "glyph" ("aspect", "image") VALUES (3.1415, 'abcd') ON CONFLICT ("id") DO NOTHING - - stmt.to_sql("mysql") - # INSERT INTO `glyph` (`aspect`, `image`) VALUES (3.1415, 'abcd') ON DUPLICATE KEY UPDATE `id` = `id` - ``` - """ - ... - - def do_update(self, *args: _ColumnRefNew, **kwds: _ExprNew) -> typing.Self: - """ - Specify `DO UPDATE` action for conflicts using column names, or with explicit values. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.InsertStatement("glyph") - .values(aspect=3.1415, image="abcd") - .on_conflict(rq.OnConflict("id").do_update("aspect", image=rq.Expr(1) + 2)) - ) - stmt.to_sql("postgres") - # INSERT INTO "glyph" ("aspect", "image") VALUES (3.1415, 'abcd') - # ON CONFLICT ("id") DO UPDATE SET "aspect" = "excluded"."aspect", "image" = 1 + 2 - - stmt.to_sql("mysql") - # INSERT INTO `glyph` (`aspect`, `image`) VALUES (3.1415, 'abcd') - # ON DUPLICATE KEY UPDATE `aspect` = VALUES(`aspect`), `image` = 1 + 2 - ``` - """ - ... - - def target_where(self, condition: Expr) -> typing.Self: - """ - Add a `WHERE` clause to the conflict target (partial unique index). - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.InsertStatement("glyph") - .values(aspect=3.1415, image="abcd") - .on_conflict( - rq.OnConflict("id") - .do_update(image="ex") - .target_where(rq.Expr.col("aspect").is_null()) - ) - ) - stmt.to_sql("postgres") - # INSERT INTO "glyph" ("aspect", "image") VALUES (3.1415, 'abcd') - # ON CONFLICT ("id") WHERE "aspect" IS NULL DO UPDATE SET "image" = 'ex' - - stmt.to_sql("mysql") - # INSERT INTO `glyph` (`aspect`, `image`) VALUES (3.1415, 'abcd') - # ON DUPLICATE KEY UPDATE `image` = 'ex' - ``` - """ - ... - -@typing.final -class Ordering: - """ - Specifies ordering behavior statements. - - NOTE: this class is immutable and frozen. - """ - - def __new__( - cls, - target: Expr | _ColumnRefNew, - order: typing.Literal["ASC", "DESC"] = "ASC", - null_ordering: typing.Literal["FIRST", "LAST"] | None = None, - ) -> typing.Self: - """ - Construct a new `Ordering` instance. - - Args: - target: Ordering target. Can be an expression (`Expr`) or a column. - order: Ascendant (`"ASC"`) or descendant (`"DESC"`). - null_ordering: Null ordering option. `"FIRST"` means `NULLS FIRST` SQL, and - `"LAST"` means `NULLS LAST` SQL. - """ - ... - - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - @property - def null_order(self) -> typing.Literal["FIRST", "LAST"] | None: ... - @property - def order(self) -> typing.Literal["ASC", "DESC"]: ... - @property - def target(self) -> Expr: - """Target expression.""" - ... - -class QueryStatement: - """Subclass of query statements.""" - - def build(self, backend: _BackendName, /) -> tuple[str, tuple[Value, ...]]: - """Build the SQL statement with parameter values.""" - ... - - def to_sql(self, backend: _BackendName, /) -> str: - """ - Build a SQL string representation. - - **This method is unsafe and can cause SQL injection.** use `.build()` method instead. - """ - ... - -@typing.final -class Returning: - """ - `RETURNING` clause. - - Works on PostgreSQL and SQLite>=3.35.0. - """ - - def __new__(cls, *args: Expr | _ColumnRefNew) -> typing.Self: - """ - Construct a new `RETURNING` clause. - - Args: - args: Returning expressions. Can be expressions (`Expr`) or column references. - If any `"*"`, or `ColumnRef("*")` found, ignores all others and returns `RETURNING *` clause. - """ - ... - - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - @classmethod - def all(cls) -> typing.Self: - """Same as `self("*")`.""" - ... - -class SelectLabel: - """ - Represents a column expression with an optional alias in a `SELECT` statement. - - Used to specify both the expression to select and an optional alias name - for the result column. - """ - - def __init__( - self, - expr: _ExprNew, - alias: str | None = None, - window: WindowStatement | str | None = None, - ) -> None: - """ - Construct a new `SelectLabel` instance. - - Args: - expr: Target expression to select. - alias: Target label, i.e. ` AS `. - window: Window statement or window name. - """ - ... - - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - @property - def alias(self) -> str | None: ... - @property - def expr(self) -> Expr: ... - @property - def window(self) -> WindowStatement | str | None: ... - -class SelectStatement(QueryStatement): - """ - Builds SELECT SQL statements with a fluent interface. - - Provides a chainable API for constructing SELECT queries with support for: - - Column selection with expressions and aliases - - Table and subquery sources - - Filtering with WHERE and HAVING - - Joins (inner, left, right, full, cross, lateral) - - Grouping and aggregation - - Ordering and pagination - - Set operations (UNION, EXCEPT, INTERSECT) - - Row locking for transactions - - DISTINCT queries - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.SelectStatement() - .columns("character", "fonts.name") - .from_table("characters") - .join("fonts", rq.Expr.col("characters.font_id") == rq.Expr.col("fonts.id"), "LEFT") - .where(rq.Expr.col("size_w").in_((3, 4))) - .where(rq.Expr.col("character").like("A%")) - .to_sql("postgres") - ) - # SELECT "character" AS "character", "fonts"."name" AS "name" FROM "characters" - # LEFT JOIN "fonts" ON "characters"."font_id" = "fonts"."id" - # WHERE "size_w" IN (3, 4) AND "character" LIKE 'A%' - ``` - """ - - def __init__(self, *exprs: SelectLabel | _ExprNew) -> None: - """ - Construct a new `SELECT` statement. - - Args: - exprs: Select expressions. - - Examples: - ```python - import rapidquery as rq - - stmt = rq.SelectStatement(1, "hello", "font").to_sql("postgres") - # SELECT 1, 'hello', 'font' - ``` - """ - ... - - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - def clear_order_by(self) -> typing.Self: - """Remove orders from statement.""" - ... - - def clear_where(self) -> typing.Self: - """Remove where conditions from statement.""" - ... - - def columns(self, *args: _ColumnRefNew) -> typing.Self: - """ - Add select target expressions. - - Works same as `self.exprs(Expr.col(i) for i in args)`, but much easier and faster. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.SelectStatement() - .columns("character", "size_w", "size_h") - .from_table("characters") - .to_sql("mysql") - ) - # SELECT `character` AS `character`, `size_w` AS `size_w`, `size_h` AS `size_h` FROM `characters` - ``` - """ - ... - - def distinct(self, *on: _ColumnRefNew) -> typing.Self: - """ - Changes `SELECT` statement into `SELECT DISTINCT` statement. - - Args: - on: Column references. If specified, uses *Postgres-ONLY* `SELECT DISTINCT ON ...` syntax. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.SelectStatement() - .distinct() - .columns("character", "size_w", "size_h") - .from_table("characters") - .to_sql("mysql") - ) - # SELECT DISTINCT `character` AS `character`, `size_w` AS `size_w`, `size_h` AS `size_h` FROM `characters` - - stmt = ( - rq.SelectStatement() - .distinct("character") - .columns("character", "size_w", "size_h") - .to_sql("postgres") - ) - # SELECT DISTINCT ON ("character") "character" AS "character", "size_w" AS "size_w", "size_h" AS "size_h" - ``` - """ - ... - - def exprs(self, *args: SelectLabel | _ExprNew) -> typing.Self: - """ - Add select target expressions. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.SelectStatement() - .exprs(rq.Func.max(rq.Expr.col("id")), rq.Expr.val(0) + 1 + 2 + 3) - .from_table("characters") - .to_sql("sqlite") - ) - # SELECT MAX("id"), 0 + 1 + 2 + 3 FROM "characters" - ``` - """ - ... - - def from_function(self, function: Expr | Func, alias: str) -> typing.Self: - """ - Select from function call. - - Args: - function: An expression or a function. If it is `Expr`, it should be a function call - and nothing more, or `ValueError` will be raised. - alias: label, i.e. `FROM AS `. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.SelectStatement(rq.Expr.asterisk()) - .from_function(rq.Func.random(), "func") - .to_sql("postgres") - ) - # SELECT * FROM RANDOM() AS "func" - ``` - """ - ... - - def from_subquery(self, subquery: SelectStatement, alias: str) -> typing.Self: - """ - Select from `SELECT` subquery. - - Args: - subquery: A select statement. - alias: label, i.e. `FROM AS `. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.SelectStatement() - .columns("image") - .from_subquery( - rq.SelectStatement().columns("image", "aspect").from_table("glyph"), - "subglyph", - ) - .to_sql("postgres") - ) - # SELECT "image" AS "image" FROM - # (SELECT "image" AS "image", "aspect" AS "aspect" FROM "glyph") AS "subglyph" - ``` - """ - ... - - def from_table(self, table: _TableNameNew) -> typing.Self: - """ - Select from table. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.SelectStatement(rq.Expr.asterisk()) - .from_table("characters") - .from_table("fonts") - .where(rq.Expr.col("fonts.id") == rq.Expr.col("characters.font_id")) - .to_sql("postgres") - ) - # SELECT * FROM "characters", "fonts" WHERE "fonts"."id" = "characters"."font_id" - ``` - """ - ... - - def group_by(self, *groups: Expr | _ColumnRefNew) -> typing.Self: - """ - Add group by expressions or column references. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.SelectStatement() - .columns("character") - .from_table("characters") - .group_by(rq.Expr.col("size_w"), rq.Expr.col("size_h")) - .to_sql("postgres") - ) - # SELECT "character" AS "character" FROM "characters" GROUP BY "size_w", "size_h" - ``` - """ - ... - - def having(self, condition: Expr) -> typing.Self: - """ - Add having condition expression. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.SelectStatement(rq.Func.max(rq.Expr.col("image"))) - .columns("aspect") - .from_table("glyph") - .group_by(rq.Expr.col("aspect")) - .having(rq.Expr.col("aspect") > 2) - .having(rq.Expr.col("aspect") < 8) - .to_sql("postgres") - ) - # SELECT MAX("image"), "aspect" AS "aspect" FROM "glyph" - # GROUP BY "aspect" HAVING "aspect" > 2 AND "aspect" < 8 - ``` - """ - ... - - def join( - self, - table: _TableNameNew, - on: Expr | None = None, - type: typing.Literal["CROSS", "FULL", "INNER", "LEFT", "RIGHT"] | None = None, - ) -> typing.Self: - """ - Join with other table. - - Args: - table: The table to join with. - on: Join condition. - type: Join type. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.SelectStatement() - .columns("character", "fonts.name") - .from_table("characters") - .join( - "fonts", - rq.Expr.col("characters.font_id") == rq.Expr.col("fonts.id"), - "RIGHT", - ) - .to_sql("mysql") - ) - # SELECT `character` AS `character`, `fonts`.`name` AS `name` FROM `characters` - # RIGHT JOIN `fonts` ON `characters`.`font_id` = `fonts`.`id` - ``` - """ - ... - - def join_function( - self, - function: Func | Expr, - alias: str, - on: Expr | None = None, - type: typing.Literal["CROSS", "FULL", "INNER", "LEFT", "RIGHT"] | None = None, - ) -> typing.Self: - """ - Join with a function call. - - Args: - function: An expression or a function. If it is `Expr`, it should be a function call - and nothing more, or `ValueError` will be raised. - alias: label. - on: Join condition. - type: Join type. - """ - ... - - def join_subquery( - self, - subquery: SelectStatement, - alias: str, - on: Expr | None = None, - type: typing.Literal["CROSS", "FULL", "INNER", "LEFT", "RIGHT"] | None = None, - lateral: bool = False, - ) -> typing.Self: - """ - Join with other `SELECT` statement. - - Args: - subquery: Select statement. - alias: label. - on: Join condition. - type: Join type. - literal: If `True`, uses `JOIN LATERAL` syntax, which *is not supported by SQLite*. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.SelectStatement() - .columns("name") - .from_table("fonts") - .join_subquery( - rq.SelectStatement().columns("id").from_table("glyph"), - "sub_glyph", - rq.Expr.col("fonts.id") == rq.Expr.col("sub_glyph.id"), - "LEFT", - ) - .to_sql("mysql") - ) - # SELECT `name` AS `name` FROM `fonts` - # LEFT JOIN (SELECT `id` AS `id` FROM `glyph`) AS `sub_glyph` ON `fonts`.`id` = `sub_glyph`.`id` - ``` - """ - ... - - def limit(self, n: int) -> typing.Self: - """Limit the number of rows to select.""" - ... - - def lock( - self, - type: typing.Literal[ - "UPDATE", "NO KEY UPDATE", "SHARE", "KEY SHARE" - ] = "UPDATE", - behavior: typing.Literal["NOWAIT", "SKIP"] | None = None, - tables: typing.Iterable[_TableNameNew] = (), - ) -> typing.Self: - """ - Row locking (if is supported by backend/dialect). - - Args: - type: Row locking type. - behavior: Row locking behavior. - tables: Row locking tables. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.SelectStatement() - .columns("character") - .from_table("characters") - .where(rq.Expr.col("id") == 5) - .lock() - .to_sql("postgres") - ) - # SELECT "character" AS "character" FROM "characters" WHERE "id" = 5 FOR UPDATE - - stmt = ( - rq.SelectStatement() - .columns("character") - .from_table("characters") - .where(rq.Expr.col("id") == 5) - .lock(tables=["glyph"]) - .to_sql("postgres") - ) - # SELECT "character" AS "character" FROM "characters" WHERE "id" = 5 FOR UPDATE OF "glyph" - - stmt = ( - rq.SelectStatement() - .columns("character") - .from_table("characters") - .where(rq.Expr.col("id") == 5) - .lock(behavior="NOWAIT") - .to_sql("postgres") - ) - # SELECT "character" AS "character" FROM "characters" WHERE "id" = 5 FOR UPDATE NOWAIT - - stmt = ( - rq.SelectStatement() - .columns("character") - .from_table("characters") - .where(rq.Expr.col("id") == 5) - .lock("SHARE", behavior="SKIP", tables=["glyph"]) - .to_sql("postgres") - ) - # SELECT "character" AS "character" FROM "characters" WHERE "id" = 5 FOR SHARE OF "glyph" SKIP LOCKED - ``` - """ - ... - - def offset(self, n: int) -> typing.Self: - """Set offset.""" - ... - - def order_by(self, clause: Ordering) -> typing.Self: - """Specify the order in which to delete rows.""" - ... - - def union( - self, - statement: SelectStatement, - type: typing.Literal["ALL", "INTERSECT", "DISTINCT", "EXCEPT"] = "DISTINCT", - ) -> typing.Self: - """ - Union with multiple `SELECT` statement that **must have the same selected fields**. - - Args: - statement: Select statement. - type: Union type. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.SelectStatement("hello") - .union(rq.SelectStatement("world"), "ALL") - .to_sql("postgres") - ) - # SELECT 'hello' UNION ALL (SELECT 'world') - ``` - """ - ... - - def where(self, condition: Expr) -> typing.Self: - """ - Add select `WHERE` condition. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.SelectStatement() - .columns("character") - .from_table("characters") - .where(rq.Expr.col("id") == 5) - .where(rq.Expr.col("name").like("%A")) - .to_sql("postgres") - ) - # SELECT "character" AS "character" FROM "characters" WHERE "id" = 5 AND "name" LIKE '%A' - ``` - """ - ... - - def window(self, name: str, statement: WindowStatement) -> typing.Self: - """ - Add `WINDOW` to statement. - - Args: - name: Window name. - statement: Window statement. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.SelectStatement( - rq.Expr.col("character").label("w", "C") - ) - .window("C", rq.WindowStatement("font_size")) - .from_table("characters") - .to_sql("postgres") - ) - # SELECT "character" OVER "C" AS "w" FROM "characters" WINDOW "C" AS (PARTITION BY "font_size") - ``` - """ - ... - - def to_expr(self) -> Expr: - """Shorthand for `Expr(self)`""" - ... - - def label( - self, alias: str, window: WindowStatement | str | None = None - ) -> SelectLabel: - """Shorthand for `SelectLabel(self, alias, window)`""" - pass - -class UpdateStatement(QueryStatement): - """ - Builds UPDATE SQL statements with a fluent interface. - - Provides a chainable API for constructing UPDATE queries with support for: - - Setting column values - - WHERE conditions for filtering - - LIMIT for restricting update count - - ORDER BY for determining update order - - RETURNING clauses for getting updated data - """ - - def __init__(self, table: _TableNameNew) -> None: - """ - Construct a new `UPDATE` statement. - - Args: - table: The table to update. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.UpdateStatement("glyph") - .values(aspect=1.23, image=123) - .where(rq.Expr.col("id") == 1) - .to_sql("sqlite") - ) - # UPDATE "glyph" SET "aspect" = 1.23, "image" = 123 WHERE "id" = 1 - ``` - """ - ... - - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - def clear_order_by(self) -> typing.Self: - """Remove orders from statement.""" - ... - - def clear_where(self) -> typing.Self: - """Remove where conditions from statement.""" - ... - - def from_table(self, table: _TableNameNew) -> typing.Self: - """ - Update using data from another table (`UPDATE .. FROM ..`). - - MySQL doesn't support the UPDATE FROM syntax. And the current implementation attempt to - tranform it to the UPDATE JOIN syntax, which only works for one join target. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.UpdateStatement("glyph") - .values(tokens=rq.Expr.col("characters.character")) - .from_table("characters") - .where(rq.Expr.col("glyph.image") == rq.Expr.col("characters.user_data")) - ) - stmt.to_sql("postgres") - # UPDATE "glyph" SET "tokens" = "characters"."character" FROM "characters" WHERE "glyph"."image" = "characters"."user_data" - - stmt.to_sql("mysql") - # UPDATE `glyph` JOIN `characters` ON `glyph`.`image` = `characters`.`user_data` SET `glyph`.`tokens` = `characters`.`character` - ``` - """ - ... - - def limit(self, n: int) -> typing.Self: - """Limit the number of rows to update.""" - ... - - def order_by(self, clause: Ordering) -> typing.Self: - """ - Specify the order in which to delete rows. Typically used with - `.limit` method to delete specific rows. - """ - ... - - def returning(self, clause: Returning) -> typing.Self: - """ - Specify columns to return from the inserted rows. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.UpdateStatement("glyph") - .values(aspect=1.23, image=123) - .returning(rq.Returning.all()) - .to_sql("sqlite") - ) - # UPDATE "glyph" SET "aspect" = 1.23, "image" = 123 RETURNING * - - stmt = ( - rq.UpdateStatement("glyph") - .values(aspect=1.23, image=123) - .returning(rq.Returning("id")) - .to_sql("sqlite") - ) - # UPDATE "glyph" SET "aspect" = 1.23, "image" = 123 RETURNING "id" - ``` - """ - ... - - def table(self, table: _TableNameNew) -> typing.Self: - """ - Override the table to update. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.UpdateStatement("name_1") - .values(aspect=1.23, image=123) - .table("name_2") - .to_sql("sqlite") - ) - # UPDATE "name_2" SET "aspect" = 1.23, "image" = 123 - ``` - """ - ... - - def values(self, **kwds: _ExprNew) -> typing.Self: - """ - Specify columns and their new values. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.UpdateStatement("glyph") - .values(aspect=1.23, image=123) - .values(font_id=20) - .to_sql("sqlite") - ) - # UPDATE "name_2" SET "aspect" = 1.23, "image" = 123, "font_id" = 20 - ``` - """ - ... - - def where(self, condition: Expr) -> typing.Self: - """ - Add a WHERE condition to filter rows to update. - - Examples: - ```python - import rapidquery as rq - - stmt = ( - rq.UpdateStatement("glyph") - .values(aspect=1.23, image=123) - .where(rq.Expr.col("id") > 10) - .where(rq.Expr.col("id") < 20) - .to_sql("sqlite") - ) - # UPDATE "glyph" SET "aspect" = 1.23, "image" = 123 WHERE "id" > 10 AND "id" < 20 - ``` - """ - ... - -class WindowStatement: - """ - Represents an `OVER` clause used with window functions. - - Window functions perform calculations across a set of table rows that are - somehow related to the current row. This class builds the specification - of that set (the window), including partitioning, ordering, and framing. - - # References: - 1. - 2. - 3. - """ - - def __init__(self, *partition_by: Expr | _ColumnRefNew) -> None: - """ - Construct a new `WINDOW` specification. - - Args: - *partition_by: One or more columns or expressions to divide - the result set into partitions. - - Examples: - ```python - import rapidquery as rq - - # Example 1: Defining a simple frame - stmt = ( - rq.SelectStatement( - rq.Expr.col("character").label( - "C", - rq.WindowStatement("font_size").frame( - "ROWS", - rq.Frame.preceding(10), - ) - ) - ) - .from_table("characters") - .to_sql("postgres") - ) - - # Example 2: Defining a frame with start and end - stmt = ( - rq.SelectStatement( - rq.Expr.col("character").label( - "C", - rq.WindowStatement("font_size").frame( - "ROWS", - rq.Frame.unbounded_preceding(), - rq.Frame.unbounded_following(), - ) - ) - ) - .from_table("characters") - .to_sql("postgres") - ) - ``` - """ - ... - - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - def frame( - self, - frame_type: typing.Literal["ROWS", "RANGE"], - frame_start: Frame, - frame_end: Frame | None = None, - ) -> typing.Self: - """ - Set the window frame clause. - - Args: - frame_type: Either "ROWS" (physical rows) or "RANGE" (logical values). - frame_start: The starting boundary of the frame. - frame_end: Optional ending boundary. If not provided, the frame - typically defaults to the current row or the database default. - """ - ... - - def order_by(self, clause: Ordering) -> typing.Self: - """ - Specify the order of rows within each partition. - - Args: - clause: An `Ordering` instance defining the sort key and direction. - """ - ... - - def partition(self, partition_by: Expr | _ColumnRefNew) -> typing.Self: - """ - Add a column or expression to the `PARTITION BY` clause. - - Args: - partition_by: The column or expression to partition by. - """ - ... - -_CommonTableExpressionQuery = ( - SelectStatement | DeleteStatement | UpdateStatement | InsertStatement -) - -class WithClause: - """ - A WITH clause can contain one or multiple common table expressions (CTEs). - - These named queries can act as a "query local table" that are materialized during execution and - then can be used by the query prefixed with the WITH clause. - - A CTE is a name, column names and a query returning data for those columns. - - Some databases (like sqlite) restrict the acceptable kinds of queries inside of the WITH clause - CTEs. These databases only allow `SelectStatement`s to form a CTE. - - Other databases like postgres allow modification queries (UPDATE, DELETE) inside of the WITH - clause but they have to return a table. (They must have a RETURNING clause). - - RapidQuery doesn't check this or restrict the kind of CTE that you can create - in rust. This means that you can put an UPDATE or DELETE queries into WITH clause and RapidQuery - will succeed in generating that kind of sql query but the execution inside the database will - fail because they are invalid. - - It is your responsibility to ensure that the kind of WITH clause that you put together makes - sense and valid for that database that you are using. - - NOTE that for recursive WITH queries (in sql: "WITH RECURSIVE") you can only have a - single CTE inside of the WITH clause. That query must match certain - requirements: - * It is a query of UNION or UNION ALL of two queries. - * The first part of the query (the left side of the UNION) must be executable first in itself. - It must be non-recursive. (Cannot contain self reference) - * The self reference must appear in the right hand side of the UNION. - * The query can only have a single self-reference. - * Recursive data-modifying statements are not supported, but you can use the results of a - recursive SELECT query in a data-modifying statement. (like so: WITH RECURSIVE - cte_name(a,b,c,d) AS (SELECT ... UNION SELECT ... FROM ... JOIN cte_name ON ... WHERE ...) - DELETE FROM table WHERE table.a = cte_name.a) - - Note that this type is not a `QueryStatement`. To generate `WITH` statement, you must convert this type to - `WithQuery` type, using `WithClause.query` method. - """ - - def __init__(self) -> None: - """ - Construct a new `WithClause` instance. - - Note that this type is not a `QueryStatement`. To generate `WITH` statement, you must convert this type to - `WithQuery` type, using `WithClause.query` method. - - Examples: - ```python - import rapidquery as rq - - select = ( - rq.SelectStatement() - .columns("names.id", "names.name") - .from_table("filtered_names AS fil") - .join("names", rq.Expr.col("fil.name_id") == rq.Expr.col("names.id")) - ) - clause = ( - rq.WithClause() - .cte( - "source_filtered_names", - select.__copy__().where(rq.Expr.col("fil.user_id") == 5), - ) - .cte( - "dest_filtered_names", - select.where(rq.Expr.col("fil.user_id") == 8), - ) - .cte( - "missing_names", - rq.SelectStatement() - .columns("dest.name") - .from_table("dest_filtered_names AS dest") - .join( - "source_filtered_names AS source", - rq.Expr.col("dest.id") == rq.Expr.col("source.id"), - ) - .where(rq.Expr.col("source.id").is_null()), - ) - .query(rq.SelectStatement().columns("name").from_table("missing_names")) - ) - clause.to_sql("postgres") - # WITH - # "source_filtered_names" ("id", "name") AS ( - # SELECT "names"."id" AS "id", "names"."name" AS "name" FROM "filtered_names" AS "fil" - # JOIN "names" ON "fil"."name_id" = "names"."id" WHERE "fil"."user_id" = 5 - # ) , - # "dest_filtered_names" ("id", "name") AS ( - # SELECT "names"."id" AS "id", "names"."name" AS "name" FROM "filtered_names" AS "fil" - # JOIN "names" ON "fil"."name_id" = "names"."id" - # WHERE "fil"."user_id" = 8 - # ) , - # "missing_names" ("name") AS ( - # SELECT "dest"."name" AS "name" FROM "dest_filtered_names" AS "dest" - # JOIN "source_filtered_names" AS "source" ON "dest"."id" = "source"."id" - # WHERE "source"."id" IS NULL - # ) - # SELECT "name" AS "name" FROM "missing_names" - ``` - """ - ... - - def recursive(self) -> typing.Self: - """ - Sets whether this clause is a recursive with clause of not. - It will generate a 'WITH RECURSIVE' query. - """ - ... - - def cte( - self, - name: str, - query: _CommonTableExpressionQuery, - columns: typing.Iterable[str] = (), - materialized: bool | None = None, - ) -> typing.Self: - """ - Add a CTE to this with clause. - - Args: - name: The common table expression (CTE) name. - query: The query, which have to return data. - columns: Named columns for the CTE table definition. - If empty, tries to detect them from the `query`. - materialized: Some databases allow you to put ā€œMATERIALIZEDā€ or ā€œNOT MATERIALIZEDā€ in the - CTE definition. This will affect how during the execution of WithQuery the - CTE in the WithClause will be executed. If the database doesn’t support this - syntax this option specified here will be ignored and not appear in the - generated sql. - """ - ... - - def cycle( - self, - expr: _ExprNew | None = None, - set_as: str | None = None, - using: str | None = None, - ) -> typing.Self: - """ - For recursive `WithQuery` `WithClause`s the CYCLE sql clause can be specified to avoid creating - an infinite traversals that loops on graph cycles indefinitely. You specify an expression that - identifies a node in the graph and that will be used to determine during the iteration of - the execution of the query when appending of new values whether the new values are distinct new - nodes or are already visited and therefore they should be added again into the result. - - A query can have both SEARCH and CYCLE clauses. - - This setting is not meaningful if the query is not recursive. - Some databases don’t support this clause. In that case this option will be silently ignored. - - Examples: - ```python - base_query = rq.SelectStatement( - rq.Expr.col("id"), - 1, - rq.Expr.col("next"), - rq.Expr.col("value"), - ).from_table("table") - - cte_referencing = ( - rq.SelectStatement( - rq.Expr.col("id"), - rq.Expr.col("depth") + 1, - rq.Expr.col("next"), - rq.Expr.col("value"), - ) - .from_table("table") - .join( - "cte_traversal", - rq.Expr.col("cte_traversal.next") == rq.Expr.col("table.id"), - "INNER", - ) - ) - - clause = ( - rq.WithClause() - .recursive() - .cte( - "cte_traversal", - base_query.union(cte_referencing, "ALL"), - columns=["id", "depth", "next", "value"], - ) - .cycle(rq.Expr.col("id"), set_as="looped", using="traversal_path") - .query(rq.SelectStatement().columns("*").from_table("cte_traversal")) - ) - clause.to_sql("postgres") - # WITH RECURSIVE "cte_traversal" ("id", "depth", "next", "value") AS ( - # SELECT "id" AS "id", 1, "next" AS "next", "value" AS "value" FROM "table" - # UNION ALL ( - # SELECT "id" AS "id", "depth" + 1, "next" AS "next", "value" AS "value" FROM "table" - # INNER JOIN "cte_traversal" ON "cte_traversal"."next" = "table"."id" - # ) - # ) - # CYCLE "id" SET "looped" USING "traversal_path" SELECT * FROM "cte_traversal" - ``` - """ - ... - - def search( - self, - expr: SelectLabel | None = None, - order: typing.Literal["BREADTH", "DEPTH"] | str | None = None, - ) -> typing.Self: - """ - For recursive `WithQuery` `WithClause`s the traversing order can be specified in some databases - that support this functionality. - - A query can have both SEARCH and CYCLE clauses. - - This setting is not meaningful if the query is not recursive. - Some databases don’t support this clause. In that case this option will be silently ignored. - - The `SelectLabel` used must specify an alias which will be the name that you can use to order - the result of the CTE. - - Examples: - ```python - base_query = ( - rq.SelectStatement() - .columns("id", "parent_id") - .from_table("nodes") - .where(rq.Expr.col("parent_id").is_null()) - ) - reference_query = ( - rq.SelectStatement() - .columns("n.id", "n.parent_id") - .from_table("nodes as n") - .join( - "tree as t", - rq.Expr.col("n.parent_id") == rq.Expr.col("t.id"), - ) - ) - - clause = ( - rq.WithClause() - .recursive() - .cte( - "tree", - base_query.union(reference_query, "ALL"), - columns=["id", "parent_id"], - ) - .search(rq.Expr.col("id").label("traversal_order"), "DEPTH") - .query( - rq.SelectStatement() - .columns("*") - .from_table("tree") - .order_by(rq.Ordering("traversal_order")) - ) - ) - clause.to_sql("postgresql") - # WITH RECURSIVE "tree" ("id", "parent_id") AS ( - # SELECT "id" AS "id", "parent_id" AS "parent_id" FROM "nodes" - # WHERE "parent_id" IS NULL - # UNION ALL ( - # SELECT "n"."id" AS "id", "n"."parent_id" AS "parent_id" - # FROM "nodes" AS "n" - # JOIN "tree" AS "t" ON "n"."parent_id" = "t"."id" - # ) - # ) - # SEARCH DEPTH FIRST BY "id" SET "traversal_order" - # SELECT * FROM "tree" ORDER BY "traversal_order" ASC - ``` - """ - ... - - def query(self, val: _CommonTableExpressionQuery) -> WithQuery: - """ - You can turn this into a `WithQuery` using this function. - The resulting WITH query will execute the argument query with this WITH clause. - """ - ... - -class WithQuery(QueryStatement): - """ - A WITH query. A simple SQL query that has a WITH clause (`WithClause`). - - It's recommended to use `WithClause.query` method instead of directly constructing - this type. - - For full description, see `WithClause`'s documentation. - """ - - def __init__( - self, - clause: WithClause, - query: _CommonTableExpressionQuery, - ) -> None: - """ - Construct a new `WithQuery` instance. - - Args: - clause: The `WITH` clause. - query: The final query. - """ - ... diff --git a/rapidquery/_lib/schema.pyi b/rapidquery/_lib/schema.pyi deleted file mode 100644 index 16d269c..0000000 --- a/rapidquery/_lib/schema.pyi +++ /dev/null @@ -1,857 +0,0 @@ -from __future__ import annotations - -import typing - -from .common import Column, Expr, ForeignKey, TableName, _ColumnRefNew, _TableNameNew - -_IndexColumnValue: typing.TypeAlias = IndexColumn | _ColumnRefNew -_IndexColumnOrder: typing.TypeAlias = typing.Literal["ASC", "DESC"] -_BackendName: typing.TypeAlias = typing.Literal["sqlite", "postgresql", "postgres", "mysql"] - -class AlterTable(SchemaStatement): - """ - Represents an `ALTER TABLE` SQL statement. - - Provides a flexible way to modify existing table structures by applying - one or more alteration operations such as adding/dropping columns, - modifying column definitions, or managing constraints. - - Multiple operations can be batched together in a single ALTER TABLE - statement for efficiency. - """ - - def __init__( - self, - name: _TableNameNew, - options: typing.Iterable[AlterTableBaseOption] = (), - ) -> None: - """ - Construct a new `ALTER TABLE` statement. - - Args: - name: The name of the table to alter. - options: Iterable of alteration operations to apply. - - Examples: - ```python - import rapidquery as rq - - stmt = rq.AlterTable( - "users", - [ - rq.AlterTableDropColumnOption("updated_at"), - rq.AlterTableRenameColumnOption("name", "username"), - ], - ).to_sql("postgres") - # ALTER TABLE "users" DROP COLUMN "updated_at", RENAME COLUMN "name" TO "username" - ``` - """ - ... - - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - def add_option(self, opt: AlterTableBaseOption) -> typing.Self: - """Add an alteration operation.""" - ... - - @property - def name(self) -> TableName: - """The name of the table to alter.""" - ... - @name.setter - def name(self, value: _TableNameNew) -> None: ... - @property - def options(self) -> typing.Sequence[AlterTableBaseOption]: - """The list of alteration operations to apply.""" - ... - @options.setter - def options(self, value: typing.Iterable[AlterTableBaseOption]) -> None: ... - -class AlterTableAddColumnOption(AlterTableBaseOption): - """ - `ALTER TABLE` operation to add a new column. - - Adds a column to an existing table with optional IF NOT EXISTS clause - to prevent errors if the column already exists. - - NOTE: this class is immutable and frozen. - """ - - def __init__(self, column: Column, if_not_exists: bool = False) -> None: - """ - Construct a new `AlterTableAddColumnOption` instance. - - Args: - column: The column definition to add. - if_not_exists: If `True`, uses `IF NOT EXISTS` clause. - - Examples: - ```python - import rapidquery as rq - - stmt = rq.AlterTable( - "users", - [ - rq.AlterTableAddColumnOption( - rq.Column( - "updated_at", - rq.sqltypes.Timestamp(timezone=True), - default=rq.Expr.current_timestamp(), - ) - ) - ], - ).to_sql("postgres") - # ALTER TABLE "users" ADD COLUMN "updated_at" timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP - ``` - """ - ... - - def __repr__(self, /) -> str: ... - @property - def column(self) -> Column: - """The column definition to add.""" - ... - - @property - def if_not_exists(self) -> bool: - """Whether to use IF NOT EXISTS clause.""" - ... - -class AlterTableAddForeignKeyOption(AlterTableBaseOption): - """ - `ALTER TABLE` operation to add a foreign key constraint. - - Adds referential integrity between tables by creating a foreign key - relationship on an existing table. - """ - - def __init__(self, foreign_key: ForeignKey) -> None: - """ - Construct a new `AlterTableAddForeignKeyOption` instance. - - Args: - foreign_key: The foreign key definition to add. - - Examples: - ```python - import rapidquery as rq - - stmt = rq.AlterTable( - "users", - [ - rq.AlterTableAddForeignKeyOption( - rq.ForeignKey(["font_id"], ["fonts.id"], on_delete="CASCADE") - ) - ], - ).to_sql("postgres") - # ALTER TABLE "users" ADD CONSTRAINT "fk_fonts_font_id__id" FOREIGN KEY ("font_id") REFERENCES "fonts" ("id") ON DELETE CASCADE - ``` - """ - ... - - def __repr__(self, /) -> str: ... - @property - def foreign_key(self) -> ForeignKey: - """The foreign key definition to add.""" - ... - -class AlterTableBaseOption: - """ - This abstract base class represents the different types of modifications - that can be made to an existing table structure, such as adding/dropping - columns, modifying column definitions, or managing foreign keys. - - NOTE: You cannot use this class as a parent (subclass), but you can use its children - as a parent (subclass). e.g. `AlterTableDropColumnOption`. - """ - -class AlterTableDropColumnOption(AlterTableBaseOption): - """ - `ALTER TABLE` operation to drop an existing column. - - Removes a column from the table. This operation may fail if the column - is referenced by other database objects. - """ - - def __init__(self, name: _ColumnRefNew) -> None: - """ - Construct a new `AlterTableDropColumnOption` instance. - - Args: - name: The column name/reference to drop. - - Examples: - ```python - import rapidquery as rq - - stmt = rq.AlterTable( - "users", - [rq.AlterTableDropColumnOption("updated_at")], - ).to_sql("postgres") - # ALTER TABLE "users" DROP COLUMN "updated_at" - ``` - """ - ... - - def __repr__(self, /) -> str: ... - @property - def name(self) -> str: - """The column name to drop.""" - ... - -class AlterTableDropForeignKeyOption(AlterTableBaseOption): - """ - `ALTER TABLE` operation to drop a foreign key constraint. - - Removes a foreign key relationship by its constraint name. - """ - - def __init__(self, name: str) -> None: - """ - Construct a new `AlterTableDropForeignKeyOption` instance. - - Args: - name: The foreign key constraint name to drop. - - Examples: - ```python - import rapidquery as rq - - stmt = rq.AlterTable( - "users", - [rq.AlterTableDropForeignKeyOption("fk_users_id_fonts_id")], - ).to_sql("postgres") - # ALTER TABLE "users" DROP CONSTRAINT "fk_users_id_fonts_id" - ``` - """ - ... - - def __repr__(self, /) -> str: ... - @property - def name(self) -> str: - """The foreign key constraint name to drop.""" - ... - -class AlterTableModifyColumnOption(AlterTableBaseOption): - """ - `ALTER TABLE` operation to modify a column definition. - - Changes properties of an existing column such as type, nullability, - default value, or other constraints. - """ - - def __init__(self, column: Column) -> None: - """ - Construct a new `AlterTableModifyColumnOption` instance. - - Args: - column: The column you want to modify with changes. - - Examples: - ```python - import rapidquery as rq - - stmt = rq.AlterTable( - "users", - [ - rq.AlterTableModifyColumnOption( - rq.Column("updated_at", rq.sqltypes.Date(), default=None) - ) - ], - ).to_sql("postgres") - # ALTER TABLE "users" ALTER COLUMN "updated_at" TYPE date, ALTER COLUMN "updated_at" SET DEFAULT NULL - ``` - """ - ... - - def __repr__(self, /) -> str: ... - @property - def column(self) -> Column: ... - -class AlterTableRenameColumnOption(AlterTableBaseOption): - """ - `ALTER TABLE` operation to rename a column. - - Changes the name of an existing column without modifying its type - or constraints. - """ - - def __init__(self, from_name: _ColumnRefNew, to_name: _ColumnRefNew) -> None: - """ - Construct a new `AlterTableRenameColumnOption` instance. - - Args: - from_name: The column name/reference to rename. - to_name: New column name. - - Examples: - ```python - import rapidquery as rq - - stmt = rq.AlterTable( - "users", - [rq.AlterTableRenameColumnOption("hello", "world")], - ).to_sql("postgres") - # ALTER TABLE "users" RENAME COLUMN "hello" TO "world" - ``` - """ - ... - - def __repr__(self, /) -> str: ... - @property - def from_name(self) -> str: - """The column name/reference to rename.""" - ... - - @property - def to_name(self) -> str: - """New column name.""" - ... - -class DropIndex(SchemaStatement): - """ - Represents a `DROP INDEX` SQL statement. - - Builds index deletion statements with support for: - - Conditional deletion (IF EXISTS) - - Table-specific index dropping - """ - - def __init__( - self, - name: str, - table: _TableNameNew, - if_exists: bool = False, - ) -> None: - """ - Construct a new `DROP INDEX` statement. - - Args: - name: The name of the index to drop. - table: The table from which to drop the index. - if_exists: If `True`, uses `IF EXISTS` clause (is not supported by MySQL). - - Examples: - ```python - import rapidquery as rq - - stmt = rq.DropIndex("idx_glyph_aspect", "glyph").to_sql("mysql") - # DROP INDEX `idx_glyph_aspect` ON `glyph` - ``` - """ - ... - - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - @property - def if_exists(self) -> bool: - """Whether to use IF EXISTS clause to avoid errors.""" - ... - - @if_exists.setter - def if_exists(self, value: bool) -> None: ... - @property - def name(self) -> str: - """The name of the index to drop.""" - ... - @name.setter - def name(self, value: str) -> None: ... - @property - def table(self) -> TableName: - """The table from which to drop the index.""" - ... - @table.setter - def table(self, value: _TableNameNew) -> None: ... - -class DropTable(SchemaStatement): - """ - Represents a `DROP TABLE` SQL statement. - - Builds table deletion statements with support for: - - Conditional deletion (IF EXISTS) to avoid errors - - CASCADE to drop dependent objects - - RESTRICT to prevent deletion if dependencies exist - """ - - def __init__( - self, - name: _TableNameNew, - *, - if_exists: bool = False, - cascade: bool = False, - restrict: bool = False, - ) -> None: - """ - Construct a new `DROP TABLE` statement. - - Args: - name: The table name to drop. - if_exists: If `True`, adds the `IF EXISTS` clause to avoid an error if the table does not exist. - cascade: If `True`, adds the `CASCADE` clause to automatically drop objects that depend on - the table (such as foreign keys or views). - restrict: If `True`, adds the `RESTRICT` clause to refuse to drop the table if any - objects depend on it. This is typically the default behavior in many SQL dialects. - - Examples: - ```python - import rapidquery as rq - - stmt = rq.DropTable("glyph", if_exists=True, cascade=True, restrict=True).to_sql( - "mysql" - ) - # DROP TABLE IF EXISTS `glyph` RESTRICT CASCADE - ``` - """ - ... - - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - @property - def cascade(self) -> bool: - """Whether to automatically drop dependent objects.""" - ... - - @cascade.setter - def cascade(self, value: bool) -> None: ... - @property - def if_exists(self) -> bool: - """Whether to use the `IF EXISTS` clause.""" - ... - - @if_exists.setter - def if_exists(self, value: bool) -> None: ... - @property - def name(self) -> TableName: - """The table name to drop.""" - ... - - @name.setter - def name(self, value: _TableNameNew) -> None: ... - @property - def restrict(self) -> bool: - """Whether to prevent deletion if dependencies exist.""" - ... - - @restrict.setter - def restrict(self, value: bool) -> None: ... - -class Index(SchemaStatement): - """ - Represents a database index specification. - - This class defines the structure and properties of a database index, - including column definitions, uniqueness constraints, index type, - and partial indexing conditions. - - You can use it to generate `CREATE INDEX` SQL expressions. - """ - - def __init__( - self, - name: str | None, - columns: typing.Iterable[_IndexColumnValue], - table: _TableNameNew | None = None, - *, - primary: bool = False, - if_not_exists: bool = False, - nulls_not_distinct: bool = False, - unique: bool = False, - index_type: str | None = None, - where: Expr | None = None, - include: typing.Iterable[str] = (), - ) -> None: - """ - Construct a new database index specification. - Also you can use it to generate `CREATE INDEX` SQL expression. - - Args: - name: The index name. You should always set for indexes that aren't primary or unique, or - you want use them to generate `CREATE INDEX` statement. - columns: Column expressions to include in the index. You can use `IndexColumn` class - if you want to specify prefix or order. - table: The table on which to create the index. You should always set for indexes that - you want use them to generate `CREATE INDEX` statement. - primary: If `True`, means this is a primary key constraint. - if_not_exists: If `True`, uses `IF NOT EXISTS` clause. - nulls_not_distinct: If `True`, NULL values will be considered equal for uniqueness. - unique: If `True`, means this is a unique key constraint. - index_type: The type/algorithm for this index. e.g. `"HASH"`, `"BTREE"`, `"FULL TEXT"`. - where: Condition for partial indexing. - include: Additional columns to include in the index for covering queries. - - Examples: - ```python - import rapidquery as rq - - stmt = rq.Index("idx_glyph_aspect", ["aspect"], "glyph", if_not_exists=True).to_sql( - "sqlite" - ) - # CREATE INDEX IF NOT EXISTS "idx_glyph_aspect" ON "glyph" ("aspect") - - stmt = rq.Index( - "idx_glyph_aspect", [rq.IndexColumn("aspect", "ASC", 128)], "glyph" - ).to_sql("mysql") - # CREATE INDEX `idx_glyph_aspect` ON `glyph` (`aspect` (128) ASC) - - stmt = rq.Index( - "idx_font_name_include_language", - ["name"], - "fonts", - include=["language"], - where=rq.Expr.col("aspect").in_([3, 4]), - ).to_sql("postgresql") - # CREATE INDEX "idx_font_name_include_language" ON "fonts" ("name") INCLUDE ("language") - ``` - """ - ... - - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - @property - def columns(self) -> typing.Sequence[IndexColumn]: - """The columns that make up this index.""" - ... - @columns.setter - def columns(self, value: typing.Iterable[_IndexColumnValue]) -> None: ... - @property - def if_not_exists(self) -> bool: - """Whether to use IF NOT EXISTS clause.""" - ... - @if_not_exists.setter - def if_not_exists(self, value: bool) -> None: ... - @property - def include(self) -> typing.Sequence[str]: - """Additional columns to include in the index for covering queries.""" - ... - @include.setter - def include(self, value: typing.Iterable[str]) -> None: ... - @property - def index_type(self) -> str | None: - """The type/algorithm for this index.""" - ... - @index_type.setter - def index_type(self, value: str | None) -> None: ... - @property - def name(self) -> str | None: - """Index name""" - ... - @name.setter - def name(self, value: str | None) -> None: ... - @property - def nulls_not_distinct(self) -> bool: - """Whether NULL values should be considered equal for uniqueness.""" - ... - @nulls_not_distinct.setter - def nulls_not_distinct(self, value: bool) -> None: ... - @property - def primary(self) -> bool: - """Whether this is a primary key constraint.""" - ... - @primary.setter - def primary(self, value: bool) -> None: ... - @property - def table(self) -> TableName | None: - """The table on which to create the index.""" - ... - @table.setter - def table(self, value: _TableNameNew | None) -> None: ... - @property - def unique(self) -> bool: - """Whether this is a unique constraint.""" - ... - @unique.setter - def unique(self, value: bool) -> None: ... - @property - def where(self) -> Expr | None: - """Condition for partial indexing.""" - ... - @where.setter - def where(self, value: object | None) -> None: ... - -@typing.final -class IndexColumn: - """ - Defines a column within an index specification. - - Represents a single column's participation in an index, including: - - The column name - - Optional prefix length (for partial indexing) - - Sort order (ascending or descending) - - Used within `Index` to specify which columns are indexed - and how they should be ordered. - - NOTE: this class is immutable and frozen. - """ - - def __new__( - cls, - name: str, - order: _IndexColumnOrder | None = None, - prefix: int | None = None, - ) -> typing.Self: - """ - Construct a new `IndexColumn` instance. - - Args: - name: The column name to be indexed. - order: The sort order for the column, typically 'ASC' for ascending - or 'DESC' for descending. If `None`, the database default is used. - prefix: The number of characters to index for string/text columns. - Used for prefix indexing to save space or handle large text fields. - """ - ... - - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - @property - def name(self) -> str: - """The name of the column to include in the index.""" - ... - - @property - def order(self) -> _IndexColumnOrder | None: - """Sort order for this column (e.g., ASC or DESC).""" - ... - - @property - def prefix(self) -> int | None: - """Number of characters to index for string columns (prefix indexing).""" - ... - -class RenameTable(SchemaStatement): - """ - Represents a `RENAME TABLE` SQL statement. - - Changes the name of an existing table to a new name. Both names can be - schema-qualified if needed. - """ - - def __init__(self, from_name: _TableNameNew, to_name: _TableNameNew) -> None: - """ - Construct a new `RENAME TABLE` statement. - - Args: - from_name: The current name of the table. - to_name: The new name for the table. - - Examples: - ```python - import rapidquery as rq - - stmt = rq.RenameTable("users", "accounts").to_sql("mysql") - # RENAME TABLE `users` TO `accounts` - ``` - """ - ... - - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - @property - def from_name(self) -> TableName: - """The current name of the table.""" - ... - @from_name.setter - def from_name(self, value: _TableNameNew) -> None: ... - @property - def to_name(self) -> TableName: - """The new name for the table.""" - ... - @to_name.setter - def to_name(self, value: _TableNameNew) -> None: ... - -class SchemaStatement: - """Subclass of schema statements.""" - - def to_sql(self, backend: _BackendName, /) -> str: - """Build a SQL string representation.""" - ... - -class Table(SchemaStatement): - """ - Represents a complete database table definition. - - This class encapsulates all aspects of a table structure including: - - Column definitions with their types and constraints - - Indexes for query optimization - - Foreign key relationships for referential integrity - - Check constraints for data validation - - Table-level options like engine, collation, and character set - - Used to generate `CREATE TABLE` SQL statements with full schema specifications. - """ - - def __init__( - self, - name: TableName | str, - *args: Column | Index | ForeignKey | Expr, - if_not_exists: bool = False, - temporary: bool = False, - comment: str | None = None, - engine: str | None = None, - collate: str | None = None, - character_set: str | None = None, - extra: str | None = None, - ) -> None: - """ - Construct a new database table definition. Also you can use it - to generate `CREATE TABLE` SQL statements with full schema specifications. - - Args: - name: The name of this table as represented in the database. You can also specify database and schema here. - If you're using `TableName`, you can use `database` and `schema` parameters; but you can also do it with `str`. - Format: `"db_name.schema_name.table_name"`. - args: List of columns (`Column`s), indexes (`Index`s), foreign keys (`ForeignKey`s), or - check constraints (`Expr`s). - if_not_exists: If `True`, uses `IF NOT EXISTS` clause. - temporary: If `True`, marks the table as a temporary table. - comment: Comment describing the purpose of this table. - engine: Storage engine for the table (e.g., InnoDB, MyISAM for MySQL). - collate: Collation for string comparisons and sorting in this table. - character_set: Character set encoding for text data in this table. - extra: Additional table-specific options for the statement. - - Examples: - ```python - import rapidquery as rq - - table = rq.Table( - "public.characters", - rq.Column("id", rq.sqltypes.BigInteger(), primary_key=True, auto_increment=True), - rq.Column("character", rq.sqltypes.Char(1)), - rq.Column("font_size", rq.sqltypes.SmallInteger()), - rq.Column("font_id", rq.sqltypes.BigInteger()), - rq.ForeignKey(["font_id"], ["fonts.id"], on_delete="CASCADE"), - rq.Index("ix_characters_fonts_id", ["font_id"]), - rq.Expr(rq.Func.char_length(rq.Expr.col("character"))) == 1, - if_not_exists=True, - ) - - table.to_sql("postgres") - # CREATE TABLE IF NOT EXISTS "public"."characters" ( - # "id" bigserial PRIMARY KEY NOT NULL, - # "character" char(1) NOT NULL, - # "font_size" smallint NOT NULL, - # "font_id" bigint NOT NULL, - # CONSTRAINT "fk_fonts_font_id__id" FOREIGN KEY ("font_id") REFERENCES "fonts" ("id") ON DELETE CASCADE, - # CHECK (CHAR_LENGTH("character") = 1) - # ); - # CREATE INDEX IF NOT EXISTS "ix_characters_fonts_id" ON "public"."characters" ("font_id") - ``` - """ - ... - - @property - def __table_name__(self) -> TableName: ... - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - @property - def character_set(self) -> str | None: - """Character set encoding for text data in this table.""" - ... - @character_set.setter - def character_set(self, value: str | None) -> None: ... - @property - def checks(self) -> typing.Sequence[Expr]: - """Table check constraints.""" - ... - @checks.setter - def checks(self, value: typing.Iterable[Expr]) -> None: ... - @property - def collate(self) -> str | None: - """Collation for string comparisons and sorting in this table.""" - ... - @collate.setter - def collate(self, value: str | None) -> None: ... - @property - def columns(self) -> typing.Sequence[Column]: - """Table columns.""" - ... - @columns.setter - def columns(self, value: typing.Iterable[Column]) -> None: ... - @property - def comment(self) -> str | None: - """Comment describing the purpose of this table.""" - ... - @comment.setter - def comment(self, value: str | None) -> None: ... - @property - def engine(self) -> str | None: - """Storage engine for the table (e.g., InnoDB, MyISAM for MySQL).""" - ... - @engine.setter - def engine(self, value: str | None) -> None: ... - @property - def extra(self) -> str | None: - """Additional table-specific options for the CREATE TABLE statement.""" - ... - @extra.setter - def extra(self, value: str | None) -> None: ... - @property - def foreign_keys(self) -> typing.Sequence[ForeignKey]: - """Table foreign keys.""" - ... - @foreign_keys.setter - def foreign_keys(self, value: typing.Iterable[ForeignKey]) -> None: ... - @property - def if_not_exists(self) -> bool: - """Whether to use IF NOT EXISTS clause to avoid errors if table exists.""" - ... - @if_not_exists.setter - def if_not_exists(self, value: bool) -> None: ... - @property - def indexes(self) -> typing.Sequence[Index]: - """Table indexes.""" - ... - @indexes.setter - def indexes(self, value: typing.Iterable[Index]) -> None: ... - @property - def name(self) -> TableName: - """The name of this table.""" - ... - - @property - def temporary(self) -> bool: - """Whether this is a temporary table that exists only for the session.""" - ... - @temporary.setter - def temporary(self, value: bool) -> None: ... - -class TruncateTable(SchemaStatement): - """ - Represents a `TRUNCATE TABLE` SQL statement. - - Quickly removes all rows from a table, typically faster than DELETE - and with different transaction and trigger behavior depending on the - database system. - - NOTE: SQLite doesn't support TRUNCATE statement. - """ - - def __init__(self, name: _TableNameNew) -> None: - """ - Construct a new `TRUNCATE TABLE` statement. - - Args: - name: The name of the table to truncate. - - Examples: - ```python - import rapidquery as rq - - stmt = rq.TruncateTable("users").to_sql("mysql") - # TRUNCATE TABLE `users` - ``` - - NOTE: SQLite doesn't support TRUNCATE statement. - """ - ... - - def __copy__(self) -> typing.Self: ... - def __repr__(self, /) -> str: ... - @property - def name(self) -> TableName: - """The name of the table to truncate.""" - ... - @name.setter - def name(self, value: _TableNameNew) -> None: ... diff --git a/rapidquery/_lib/sqlite.pyi b/rapidquery/_lib/sqlite.pyi deleted file mode 100644 index 4d21ee8..0000000 --- a/rapidquery/_lib/sqlite.pyi +++ /dev/null @@ -1,3 +0,0 @@ -from __future__ import annotations - -__all__ = [] diff --git a/rapidquery/_lib/sqltypes.pyi b/rapidquery/_lib/sqltypes.pyi deleted file mode 100644 index f6779dd..0000000 --- a/rapidquery/_lib/sqltypes.pyi +++ /dev/null @@ -1,797 +0,0 @@ -from __future__ import annotations - -import datetime -import decimal -import enum -import typing -import uuid - -T = typing.TypeVar("T") - -@typing.final -class Array(SQLTypeAbstract[list[T]]): - """ - Array column type for storing arrays of elements. - - Represents a column that stores arrays of a specified element type. - Useful in databases that support native array types (like PostgreSQL) - for storing lists of values in a single column. - """ - - def __new__(cls, element: SQLTypeAbstract[T]) -> typing.Self: ... - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - - @property - def element(self) -> SQLTypeAbstract[T]: ... - -@typing.final -class BigInteger(SQLTypeAbstract[int]): - """ - Large integer column type (BIGINT). - - Stores 64-bit integers for very large numeric values. Essential for - high-volume systems, timestamps, large counters, or when integer - overflow is a concern. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class BigUnsigned(SQLTypeAbstract[int]): - """ - Unsigned big integer column type. - - Stores very large positive integers only. Provides the maximum positive - integer range for high-volume systems or when very large positive - values are required. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class Binary(SQLTypeAbstract[bytes]): - """ - Fixed-length binary data column type (BINARY). - - Stores binary data of a fixed length. Values shorter than the specified - length are padded. Useful for storing hashes, keys, or other binary - data with consistent length. - """ - - def __new__(cls, length: int = 255) -> typing.Self: ... - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - - @property - def length(self) -> int: ... - -@typing.final -class Bit(SQLTypeAbstract[bytes]): - """ - Fixed-length bit string column type (BIT). - - Stores a fixed number of bits. Useful for storing boolean flags efficiently - or binary data where individual bits have meaning. - """ - - def __new__(cls, length: int) -> typing.Self: ... - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - - @property - def length(self) -> int: ... - -@typing.final -class Blob(SQLTypeAbstract[bytes]): - """ - Binary large object column type (BLOB). - - Stores large binary data such as images, documents, audio files, or - any binary content. Size limits vary by database system. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class Boolean(SQLTypeAbstract[bool]): - """ - Boolean column type (BOOLEAN). - - Stores true/false values. The standard way to store boolean data, - though implementation varies by database (some use TINYINT(1) or - similar representations). - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class Char(SQLTypeAbstract[str]): - """ - Fixed-length character string column type (CHAR). - - Represents a fixed-length character string. Values shorter than the - specified length are padded with spaces. Suitable for storing data - with consistent, known lengths like country codes or status flags. - """ - - def __new__(cls, length: int | None = ...) -> typing.Self: ... - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - - @property - def length(self) -> int | None: ... - -@typing.final -class Date(SQLTypeAbstract[datetime.date]): - """ - Date-only column type (DATE). - - Stores date information without time component. Ideal for birth dates, - deadlines, or any date-based data where time precision is not needed. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class DateTime(SQLTypeAbstract[datetime.datetime]): - """ - Date and time column type (DATETIME). - - Stores both date and time information without timezone awareness. - Suitable for recording timestamps, event times, or scheduling information - when timezone handling is managed at the application level. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class Decimal(SQLTypeAbstract[decimal.Decimal | int | float | str]): - """ - Exact numeric decimal column type (DECIMAL/NUMERIC). - - Stores exact numeric values with fixed precision and scale. Essential for - financial calculations, currency values, or any situation where exact - decimal representation is required without floating-point approximation. - """ - - def __new__(cls, context: tuple[int, int] | None = None) -> typing.Self: ... - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - - @property - def context(self) -> tuple[int, int] | None: ... - -@typing.final -class Double(SQLTypeAbstract[float | int]): - """ - Double-precision floating point column type (DOUBLE). - - Stores approximate numeric values with double precision. Provides higher - precision than FLOAT for scientific calculations or when more accuracy - is required in floating-point operations. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class Enum(SQLTypeAbstract[str | enum.Enum]): - """ - Enumeration column type (ENUM). - - Stores one value from a predefined set of allowed string values. - Provides type safety and storage efficiency for categorical data - with a fixed set of possible values. - """ - - def __new__(cls, name: str, variants: typing.Iterable[str]) -> typing.Self: ... - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - - @property - def name(self) -> str: ... - @property - def variants(self) -> typing.Sequence[str]: ... - -@typing.final -class Float(SQLTypeAbstract[float | int]): - """ - Single-precision floating point column type (FLOAT). - - Stores approximate numeric values with single precision. Suitable for - scientific calculations, measurements, or any numeric data where some - precision loss is acceptable in exchange for storage efficiency. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class INET(SQLTypeAbstract[str]): - """ - Internet address column type (INET). - - Stores IPv4 or IPv6 addresses, with or without subnet specification. - More flexible than CIDR type, allowing both host addresses and network ranges. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class Integer(SQLTypeAbstract[int]): - """ - Standard integer column type (INTEGER/INT). - - The most common integer type, typically storing 32-bit integers in the - range -2,147,483,648 to 2,147,483,647 (signed). Suitable for most - numeric data including IDs, quantities, and counters. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class JSON(SQLTypeAbstract[typing.Any]): - """ - JSON data column type (JSON). - - Stores JSON documents with validation and indexing capabilities. - Allows for flexible schema design and complex nested data structures - while maintaining some query capabilities. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class JSONBinary(SQLTypeAbstract[typing.Any]): - """ - Binary JSON column type (JSONB). - - Stores JSON documents in a binary format for improved performance. - Provides faster query and manipulation operations compared to text-based - JSON storage, with additional indexing capabilities. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class MacAddress(SQLTypeAbstract[str]): - """ - MAC address column type (MACADDR). - - Stores MAC (Media Access Control) addresses for network devices. - Provides validation and formatting for 6-byte MAC addresses. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -class SQLTypeAbstract(typing.Generic[T]): - """ - Base class for all SQL column data types. - - This abstract base class represents SQL data types that can be used in - column definitions. Each subclass implements a specific SQL data type - with its particular characteristics, constraints, and backend-specific - representations. - """ - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class SmallInteger(SQLTypeAbstract[int]): - """ - Small integer column type (SMALLINT). - - Typically stores integers in the range -32,768 to 32,767 (signed) or - 0 to 65,535 (unsigned). Good for moderate-sized counters or numeric codes. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class SmallUnsigned(SQLTypeAbstract[int]): - """ - Unsigned small integer column type. - - Stores moderate positive integers only, typically 0 to 65,535. Good for - larger counters or numeric codes that are always positive. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class String(SQLTypeAbstract[str]): - """ - Variable-length character string column type (VARCHAR). - - Represents a variable-length character string with a maximum length limit. - This is the most common string type for storing text data of varying lengths - like names, descriptions, or user input. - """ - - def __new__(cls, length: int | None = ...) -> typing.Self: ... - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - - @property - def length(self) -> int | None: ... - -@typing.final -class Text(SQLTypeAbstract[str]): - """ - Large text column type (TEXT). - - Represents a large text field capable of storing long strings without - a predefined length limit. Suitable for storing articles, comments, - descriptions, or any text content that may be very long. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class Time(SQLTypeAbstract[datetime.time]): - """ - Time-only column type (TIME). - - Stores time information without date component. Useful for storing - daily schedules, opening hours, or any time-based data that repeats - daily regardless of the specific date. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class Timestamp(SQLTypeAbstract[datetime.datetime]): - """ - Timestamp column type (TIMESTAMP). - - Stores timestamp values, often with automatic update capabilities. - Behavior varies by database system. - """ - - def __new__(cls, timezone: bool = False) -> typing.Self: ... - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - - @property - def timezone(self) -> bool: ... - -@typing.final -class TinyInteger(SQLTypeAbstract[int]): - """ - Very small integer column type (TINYINT). - - Typically stores integers in the range -128 to 127 (signed) or 0 to 255 - (unsigned). Useful for flags, small counters, or enumerated values. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class TinyUnsigned(SQLTypeAbstract[int]): - """ - Unsigned tiny integer column type. - - Stores small positive integers only, typically 0 to 255. Useful for - small counters, percentages, or enumerated values that are always positive. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class UUID(SQLTypeAbstract[uuid.UUID]): - """ - UUID column type (UUID). - - Stores universally unique identifiers. Ideal for distributed systems, - primary keys, or any situation where globally unique identifiers are - needed without central coordination. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class Unsigned(SQLTypeAbstract[int]): - """ - Unsigned integer column type. - - Stores positive integers only, typically 0 to 4,294,967,295. Doubles the - positive range compared to signed integers, useful for IDs and counters - that will never be negative. - """ - - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - -@typing.final -class VarBinary(SQLTypeAbstract[bytes]): - """ - Variable-length binary data column type (VARBINARY). - - Stores binary data of variable length up to a specified maximum. - More storage-efficient than BINARY for binary data of varying lengths. - """ - - def __new__(cls, length: int | None = None) -> typing.Self: ... - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - - @property - def length(self) -> int: ... - -@typing.final -class VarBit(SQLTypeAbstract[bytes]): - """ - Variable-length bit string column type (VARBIT). - - Stores a variable number of bits up to a specified maximum. More flexible - than fixed BIT type for bit strings of varying lengths. - """ - - def __new__(cls, length: int) -> typing.Self: ... - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - - @property - def length(self) -> int: ... - -@typing.final -class Vector(SQLTypeAbstract[list[float]]): - """ - Vector column type for storing mathematical vectors. - - Specialized type for storing vector data, often used in machine learning, - similarity search, or mathematical applications. - """ - - def __new__(cls, length: int | None = None) -> typing.Self: ... - def __repr__(self, /) -> str: - """Return repr(self).""" - ... - - @property - def __type_name__(self) -> str: - """ - Type name. e.g. `'INTEGER'`, `'STRING'` - - It also may be a property. This function must NOT raise any error. - """ - ... - - @property - def length(self) -> int | None: ... diff --git a/src/common/column.rs b/src/common/column.rs deleted file mode 100644 index fd71ef5..0000000 --- a/src/common/column.rs +++ /dev/null @@ -1,427 +0,0 @@ -use sea_query::IntoIden; - -use crate::common::expression::PyExpr; -use crate::internal::parameters::OptionalParam; -use crate::internal::repr::ReprFormatter; -use crate::internal::type_engine::TypeEngine; -use crate::internal::{BoundArgs, BoundKwargs, BoundObject, PyObject, RefBoundObject, ToSeaQuery}; -use crate::sqltypes::SQLTypeTrait; - -pub const OPT_PRIMARY_KEY: u8 = 1 << 0; -pub const OPT_UNIQUE_KEY: u8 = 1 << 1; -pub const OPT_NOT_NULL: u8 = 1 << 2; -pub const OPT_NULL: u8 = 1 << 3; -pub const OPT_AUTO_INCREMENT: u8 = 1 << 4; -pub const OPT_STORED_GENERATED: u8 = 1 << 5; - -crate::implement_pyclass! { - /// Defines a table column with its properties and constraints. - /// - /// Represents a complete column definition including: - /// - Column name and data type - /// - Constraints (primary key, unique, nullable) - /// - Auto-increment behavior - /// - Default values and generated columns - /// - Comments and extra specifications - /// - /// This class is used within Table to specify the structure - /// of table columns. It encapsulates all the properties that define how - /// a column behaves and what data it can store. - #[derive(Debug, Clone)] - mutable [subclass, generic] PyColumn(ColumnState) as "Column" { - pub name: String, - pub r#type: TypeEngine, - pub options: u8, - pub default: Option, - pub generated: Option, - pub extra: Option, - pub comment: Option, - } -} - -impl ToSeaQuery for ColumnState { - #[inline] - fn to_sea_query<'a>(&self, _py: pyo3::Python<'a>) -> sea_query::ColumnRef { - sea_query::ColumnRef::Column(sea_query::Alias::new(&self.name).into_iden()) - } -} - -impl ToSeaQuery for ColumnState { - #[inline] - fn to_sea_query<'a>(&self, _py: pyo3::Python<'a>) -> sea_query::ColumnDef { - let mut column_def = sea_query::ColumnDef::new_with_type( - sea_query::Alias::new(self.name.clone()), - self.r#type.to_sea_query_column_type(), - ); - - if self.options & OPT_PRIMARY_KEY > 0 { - column_def.primary_key(); - } - if self.options & OPT_AUTO_INCREMENT > 0 { - column_def.auto_increment(); - } - if self.options & OPT_NULL > 0 { - column_def.null(); - } - if self.options & OPT_NOT_NULL > 0 { - column_def.not_null(); - } - if self.options & OPT_UNIQUE_KEY > 0 { - column_def.unique_key(); - } - - if let Some(x) = &self.default { - column_def.default(x.0.clone()); - } - if let Some(x) = &self.generated { - column_def.generated(x.0.clone(), self.options & OPT_STORED_GENERATED > 0); - } - - if let Some(x) = &self.extra { - column_def.extra(x); - } - if let Some(x) = &self.comment { - column_def.comment(x); - } - - column_def - } -} - -#[pyo3::pymethods] -impl PyColumn { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> Self { - Self::uninit() - } - - #[ - pyo3( - signature=( - name, - r#type, - *, - primary_key=false, - unique_key=false, - nullable=None, - auto_increment=false, - extra=None, - comment=None, - default=OptionalParam::Undefined, - generated=OptionalParam::Undefined, - stored_generated=false, - ) - ) - ] - fn __init__( - &self, - name: String, - r#type: RefBoundObject<'_>, - primary_key: bool, - unique_key: bool, - nullable: Option, - auto_increment: bool, - extra: Option, - comment: Option, - default: OptionalParam, - generated: OptionalParam, - stored_generated: bool, - ) -> pyo3::PyResult<()> { - let sql_type = TypeEngine::new(r#type)?; - - let default_expr = match default { - OptionalParam::Defined(x) => Some(super::expression::PyExpr::try_from_specific_type( - &x, - Some(sql_type.clone()), - )?), - OptionalParam::Undefined => None, - }; - let generated_expr = match generated { - OptionalParam::Defined(x) => Some(super::expression::PyExpr::try_from(&x)?), - OptionalParam::Undefined => None, - }; - - let mut options = 0u8; - if primary_key { - options |= OPT_PRIMARY_KEY; - } - if unique_key { - options |= OPT_UNIQUE_KEY; - } - - match nullable { - Some(true) => options |= OPT_NULL, - Some(false) => options |= OPT_NOT_NULL, - None => (), - } - - if auto_increment { - options |= OPT_AUTO_INCREMENT; - } - if stored_generated { - options |= OPT_STORED_GENERATED; - } - - let state = ColumnState { - name, - r#type: sql_type, - options, - default: default_expr, - generated: generated_expr, - extra, - comment, - }; - self.0.set(state); - - Ok(()) - } - - /// Column name. - #[getter] - fn name(&self) -> String { - self.0.lock().name.clone() - } - - #[setter] - fn set_name(&self, value: String) { - self.0.lock().name = value; - } - - /// Column type. - #[getter] - fn r#type(&self, py: pyo3::Python) -> PyObject { - let lock = self.0.lock(); - lock.r#type.as_pyobject(py).unbind() - } - - #[getter] - fn primary_key(&self) -> bool { - self.0.lock().options & OPT_PRIMARY_KEY > 0 - } - - #[setter] - fn set_primary_key(&self, value: bool) { - let mut lock = self.0.lock(); - if value { - lock.options |= OPT_PRIMARY_KEY; - } else { - lock.options &= !OPT_PRIMARY_KEY; - } - } - - #[getter] - fn unique_key(&self) -> bool { - self.0.lock().options & OPT_UNIQUE_KEY > 0 - } - - #[setter] - fn set_unique_key(&self, value: bool) { - let mut lock = self.0.lock(); - if value { - lock.options |= OPT_UNIQUE_KEY; - } else { - lock.options &= !OPT_UNIQUE_KEY; - } - } - - #[getter] - fn auto_increment(&self) -> bool { - self.0.lock().options & OPT_AUTO_INCREMENT > 0 - } - - #[setter] - fn set_auto_increment(&self, value: bool) { - let mut lock = self.0.lock(); - if value { - lock.options |= OPT_AUTO_INCREMENT; - } else { - lock.options &= !OPT_AUTO_INCREMENT; - } - } - - #[getter] - fn nullable(&self) -> Option { - let options = self.0.lock().options; - - if options & OPT_NOT_NULL > 0 { - Some(false) - } else if options & OPT_NULL > 0 { - Some(true) - } else { - None - } - } - - #[setter] - fn set_nullable(&self, value: Option) { - let mut lock = self.0.lock(); - - match value { - Some(true) => { - lock.options |= OPT_NULL; - lock.options &= !OPT_NOT_NULL; - } - Some(false) => { - lock.options |= OPT_NOT_NULL; - lock.options &= !OPT_NULL; - } - None => { - lock.options &= !OPT_NOT_NULL; - lock.options &= !OPT_NULL; - } - } - } - - #[getter] - fn stored_generated(&self) -> bool { - self.0.lock().options & OPT_STORED_GENERATED > 0 - } - - #[setter] - fn set_stored_generated(&self, value: bool) { - let mut lock = self.0.lock(); - if value { - lock.options |= OPT_STORED_GENERATED; - } else { - lock.options &= !OPT_STORED_GENERATED; - } - } - - /// Extra SQL specifications for this column. - #[getter] - fn extra(&self) -> Option { - self.0.lock().extra.clone() - } - - #[setter] - fn set_extra(&self, val: Option) { - let mut lock = self.0.lock(); - lock.extra = val; - } - - /// Comment describing this column. - #[getter] - fn comment(&self) -> Option { - self.0.lock().comment.clone() - } - - #[setter] - fn set_comment(&self, val: Option) { - let mut lock = self.0.lock(); - lock.comment = val; - } - - /// Default value for this column. - #[getter] - fn default(&self) -> Option { - let lock = self.0.lock(); - lock.default.clone() - } - - #[setter] - fn set_default(&self, val: RefBoundObject<'_>) -> pyo3::PyResult<()> { - let mut lock = self.0.lock(); - let sql_type = lock.r#type.clone(); - - let default = super::expression::PyExpr::try_from_specific_type(val, Some(sql_type))?; - lock.default = Some(default); - Ok(()) - } - - /// Expression for generated column values. - #[getter] - fn generated(&self) -> Option { - let lock = self.0.lock(); - lock.generated.clone() - } - - #[setter] - fn set_generated(&self, val: RefBoundObject<'_>) -> pyo3::PyResult<()> { - let mut lock = self.0.lock(); - - let generated = super::expression::PyExpr::try_from_specific_type(val, None)?; - lock.generated = Some(generated); - Ok(()) - } - - /// Shorthand for `Value(object, self.type)`. - fn adapt(&self, object: BoundObject<'_>) -> pyo3::PyResult { - let lock = self.0.lock(); - let sql_type = lock.r#type.clone(); - - let result = super::value::ValueState::from_pyobject(sql_type, object)?; - Ok(result.into()) - } - - /// Shorthand for `SelectLabel(self, alias, window)` - #[pyo3(signature=(alias, window=None))] - fn label( - &self, - alias: String, - window: Option>, - ) -> pyo3::PyResult { - let window = match window { - Some(x) => Some(crate::query::select::SelectLabelWindow::try_from(x)?), - None => None, - }; - let expr = self.to_expr(); - - let state = crate::query::select::SelectLabelState { - expr, - alias: Some(alias), - window, - }; - Ok(state.into()) - } - - /// Shorthand for `Expr(self)` - fn to_expr(&self) -> PyExpr { - let col_ref = Self::__column_ref__(self); - PyExpr(sea_query::Expr::column(col_ref)) - } - - #[getter] - fn __column_ref__(&self) -> super::column_ref::PyColumnRef { - let lock = self.0.lock(); - - super::column_ref::PyColumnRef { - name: Some(sea_query::Alias::new(&lock.name).into_iden()), - table: None, - schema: None, - } - } - - fn __copy__(&self) -> Self { - let lock = self.0.lock(); - lock.clone().into() - } - - pub fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let lock = slf.0.lock(); - - ReprFormatter::new_with_pyref(&slf) - .quote("", &lock.name) - .display("type", &lock.r#type) - .optional_boolean("primary_key", lock.options & OPT_PRIMARY_KEY > 0) - .optional_boolean("unique_key", lock.options & OPT_UNIQUE_KEY > 0) - .optional_boolean("auto_increment", lock.options & OPT_AUTO_INCREMENT > 0) - .optional_display( - "nullable", - if lock.options & OPT_NOT_NULL > 0 { - Some(false) - } else if lock.options & OPT_NULL > 0 { - Some(true) - } else { - None - }, - ) - .optional_boolean("stored_generated", lock.options & OPT_STORED_GENERATED > 0) - .optional_quote("extra", lock.extra.as_ref()) - .optional_quote("comment", lock.comment.as_ref()) - .optional_map("default", lock.default.as_ref(), |x| x.__repr__()) - .optional_map("generated", lock.generated.as_ref(), |x| x.__repr__()) - .finish() - } -} diff --git a/src/common/column_ref.rs b/src/common/column_ref.rs deleted file mode 100644 index 59cdd71..0000000 --- a/src/common/column_ref.rs +++ /dev/null @@ -1,340 +0,0 @@ -use std::str::FromStr; - -use pyo3::types::{PyAnyMethods, PyStringMethods}; -use sea_query::IntoIden; - -use crate::internal::repr::ReprFormatter; -use crate::internal::{BoundKwargs, RefBoundObject}; - -crate::implement_pyclass! { - // NOTE: SQLTypes, PyExpr, PyFunc, PyTableName & PyColumnRef could never mark as subclass. - // these should be immutable and final types. - - /// Represents a reference to a database column with optional table and schema qualification. - /// - /// This class is used to uniquely identify columns in SQL queries, supporting - /// schema-qualified and table-qualified column references. - /// - /// NOTE: this class is immutable and frozen. - #[derive(Debug, Clone)] - [] PyColumnRef as "ColumnRef" { - /// Name of the referenced column. [`Option::None`] means '*'. - pub name: Option, - pub table: Option, - pub schema: Option, - } -} - -impl sea_query::IntoColumnRef for PyColumnRef { - fn into_column_ref(self) -> sea_query::ColumnRef { - if let Some(name) = self.name { - match (self.table, self.schema) { - (Some(table), Some(schema)) => { - sea_query::ColumnRef::SchemaTableColumn(schema, table, name) - } - (Some(table), None) => sea_query::ColumnRef::TableColumn(table, name), - _ => sea_query::ColumnRef::Column(name), - } - } else if let Some(table) = self.table { - sea_query::ColumnRef::TableAsterisk(table) - } else { - sea_query::ColumnRef::Asterisk - } - } -} - -impl FromStr for PyColumnRef { - type Err = pyo3::PyErr; - - fn from_str(string: &str) -> Result { - let string = string.trim().to_owned(); - - if string.is_empty() { - return Err(pyo3::exceptions::PyValueError::new_err( - "cannot parse an empty string", - )); - } - - // Possible formats: - // name - // table.name - // schema.table.name - let mut string: Vec = string.split('.').map(String::from).collect(); - - if string.len() > 3 { - return Err(pyo3::exceptions::PyValueError::new_err("invalid format")); - } - - let name = string.pop().unwrap(); - let table = string.pop().map(|x| sea_query::Alias::new(x).into_iden()); - let schema = string.pop().map(|x| sea_query::Alias::new(x).into_iden()); - - Ok(Self { - name: if name == "*" { - None - } else { - Some(sea_query::Alias::new(name).into_iden()) - }, - table, - schema, - }) - } -} - -impl TryFrom> for PyColumnRef { - type Error = pyo3::PyErr; - - fn try_from(value: RefBoundObject<'_>) -> Result { - unsafe { - if pyo3::ffi::Py_TYPE(value.as_ptr()) == crate::typeref::COLUMN_REF_TYPE { - let casted_value = value.cast_unchecked::(); - return Ok(casted_value.get().clone()); - } - - if let Ok(x) = value.cast_exact::() { - return Self::from_str(x.to_str()?); - } - - if let Some(result) = Self::try_from_property(value)? { - return Ok(result); - } - - crate::new_error!( - PyTypeError, - "expected ColumnRef, str, or object.to_column_ref property, got {}", - crate::internal::get_type_name(value.py(), value.as_ptr()) - ) - } - } -} - -impl PyColumnRef { - #[inline] - pub fn try_from_property(value: RefBoundObject) -> pyo3::PyResult> { - const PROPERTY_NAME: &std::ffi::CStr = c"__column_ref__"; - - let property = match value.getattr(PROPERTY_NAME) { - Ok(x) => Ok(Some(x)), - Err(err) if err.is_instance_of::(value.py()) => { - Ok(None) - } - Err(err) => Err(err), - }; - let property = property?; - - if property.is_none() { - return Ok(None); - } - - unsafe { - let property = property.unwrap_unchecked(); - - if pyo3::ffi::Py_TYPE(property.as_ptr()) == crate::typeref::COLUMN_REF_TYPE { - let casted_value = property.cast_unchecked::(); - return Ok(Some(casted_value.get().clone())); - } - - if let Ok(x) = property.extract::() { - return Self::from_str(&x).map(Some); - } - - crate::new_error!( - PyTypeError, - "__column_ref__ property returns something other than ColumnRef or str; returns {}", - crate::internal::get_type_name(property.py(), property.as_ptr()) - ) - } - } -} - -#[pyo3::pymethods] -impl PyColumnRef { - #[new] - #[pyo3(signature=(name, table=None, schema=None))] - fn __new__( - name: String, - table: Option, - schema: Option, - ) -> pyo3::PyResult { - let name = unsafe { - if name == "*" { - None - } else { - Some(name) - } - }; - - Ok(Self { - name: name.map(|x| sea_query::Alias::new(x).into_iden()), - table: table.map(|x| sea_query::Alias::new(x).into_iden()), - schema: schema.map(|x| sea_query::Alias::new(x).into_iden()), - }) - } - - #[getter] - fn name(&self) -> String { - match &self.name { - None => String::from("*"), - Some(x) => x.to_string(), - } - } - - #[getter] - fn table(&self) -> Option { - self.table.as_ref().map(|x| x.to_string()) - } - - #[getter] - fn schema(&self) -> Option { - self.schema.as_ref().map(|x| x.to_string()) - } - - /// Parse a string representation of a column reference. - /// - /// Supports formats like: - /// - "column_name" - /// - "table.column_name" - /// - "schema.table.column_name" - #[classmethod] - fn parse(_cls: &pyo3::Bound<'_, pyo3::types::PyType>, string: String) -> pyo3::PyResult { - Self::from_str(&string) - } - - #[pyo3(signature=(**kwds))] - fn copy_with(&self, kwds: Option>) -> pyo3::PyResult { - use pyo3::types::PyDictMethods; - - let mut cloned = self.clone(); - if kwds.is_none() { - return Ok(cloned); - } - - let kwds = unsafe { kwds.unwrap_unchecked() }; - - for (key, val) in kwds.iter() { - unsafe { - let key = key.extract::().unwrap_unchecked(); - - let val: Option = unsafe { - if pyo3::ffi::Py_IsNone(val.as_ptr()) == 1 { - None - } else if pyo3::ffi::PyUnicode_CheckExact(val.as_ptr()) == 1 { - Some(val.extract::().unwrap_unchecked()) - } else { - return crate::new_error!( - PyTypeError, - "expected str or None, got {}", - crate::internal::get_type_name(val.py(), val.as_ptr()) - ); - } - }; - - if key == "name" { - if val.is_none() { - return crate::new_error!(PyTypeError, "expected str for name, got None"); - } - - let val_str = unsafe { val.unwrap_unchecked() }; - if val_str == "*" { - cloned.name = None; - } else { - cloned.name = Some(sea_query::Alias::new(val_str).into_iden()); - } - } else if key == "table" { - cloned.table = val.map(|x| sea_query::Alias::new(x).into_iden()); - } else if key == "schema" { - cloned.schema = val.map(|x| sea_query::Alias::new(x).into_iden()); - } else { - return crate::new_error!( - PyTypeError, - "got an unexpected keyword argument '{}'", - key - ); - } - } - } - - Ok(cloned) - } - - /// Shorthand for `SelectLabel(self, alias, window)` - #[pyo3(signature=(alias, window=None))] - fn label( - &self, - alias: String, - window: Option>, - ) -> pyo3::PyResult { - let window = match window { - Some(x) => Some(crate::query::select::SelectLabelWindow::try_from(x)?), - None => None, - }; - let expr = self.to_expr(); - - let state = crate::query::select::SelectLabelState { - expr, - alias: Some(alias), - window, - }; - Ok(state.into()) - } - - fn __eq__(slf: pyo3::PyRef<'_, Self>, other: pyo3::PyRef<'_, Self>) -> bool { - if slf.as_ptr() == other.as_ptr() { - return true; - } - - slf.name == other.name && slf.schema == other.schema && slf.table == other.table - } - - fn __ne__(slf: pyo3::PyRef<'_, Self>, other: pyo3::PyRef<'_, Self>) -> bool { - if slf.as_ptr() == other.as_ptr() { - return false; - } - - slf.name != other.name || slf.schema != other.schema || slf.table != other.table - } - - fn __copy__(&self) -> Self { - self.clone() - } - - fn __hash__(&self) -> u64 { - use std::hash::{Hash, Hasher}; - - let mut state = std::hash::DefaultHasher::new(); - - if let Some(x) = &self.name { - x.to_string().hash(&mut state); - } - if let Some(x) = &self.table { - x.to_string().hash(&mut state); - } - if let Some(x) = &self.schema { - x.to_string().hash(&mut state); - } - - state.finish() - } - - /// Shorthand for `Expr(self)` - fn to_expr(&self) -> super::expression::PyExpr { - super::expression::PyExpr(sea_query::Expr::column(self.clone())) - } - - pub fn __repr__(&self) -> String { - let mut fmt = ReprFormatter::new("ColumnRef"); - - match &self.name { - Some(x) => { - fmt.iden("name", x); - } - None => { - fmt.pair("name", "*"); - } - } - fmt.optional_iden("table", self.table.as_ref()); - fmt.optional_iden("schema", self.schema.as_ref()); - - fmt.finish() - } -} diff --git a/src/common/expression/expr.rs b/src/common/expression/expr.rs deleted file mode 100644 index 9ec08ae..0000000 --- a/src/common/expression/expr.rs +++ /dev/null @@ -1,700 +0,0 @@ -use pyo3::types::PyAnyMethods; - -use crate::internal::repr::ReprFormatter; -use crate::internal::type_engine::TypeEngine; -use crate::internal::{BoundObject, RefBoundObject, ToSeaQuery}; - -crate::implement_pyclass! { - // NOTE: SQLTypes, PyExpr, PyFunc, PyTableName & PyColumnRef could never mark as subclass. - // these should be immutable and final types. - - /// Represents a SQL expression that can be built into SQL code. - /// - /// This class provides a fluent interface for constructing complex SQL expressions - /// in a database-agnostic way. It supports arithmetic operations, comparisons, - /// logical operations, and database-specific functions. - /// - /// The class automatically handles SQL injection protection and proper quoting - /// when building the final SQL statement. - /// - /// NOTE: `Expr` is immutable, so by calling each method you will give a new instance - /// of it which includes new change(s). - #[derive(Debug, Clone)] - [] PyExpr as "Expr" (pub sea_query::SimpleExpr); -} - -impl PyExpr { - pub fn try_from_specific_type( - value: RefBoundObject<'_>, - type_engine: Option, - ) -> pyo3::PyResult { - unsafe { - let py = value.py(); - let type_ptr = pyo3::ffi::Py_TYPE(value.as_ptr()); - - if type_ptr == crate::typeref::EXPR_TYPE { - let casted_value = value.cast_unchecked::(); - - return Ok(casted_value.get().clone()); - } - - if pyo3::ffi::PyObject_TypeCheck(value.as_ptr(), crate::typeref::VALUE_TYPE) == 1 { - let casted_value = value.cast_unchecked::(); - let unbound = casted_value.get(); - - return Ok(Self(unbound.0.lock().simple_expr(py)?)); - } - - if type_ptr == crate::typeref::COLUMN_REF_TYPE { - let casted_value = value.cast_unchecked::(); - let cloned = casted_value.get().clone(); - - return Ok(Self(sea_query::Expr::column(cloned))); - } - - if type_ptr == crate::typeref::FUNC_TYPE { - let casted_value = value.cast_unchecked::(); - let cloned = casted_value.get().clone(); - - return Ok(Self(sea_query::SimpleExpr::FunctionCall(cloned.0))); - } - - if pyo3::ffi::PyObject_TypeCheck(value.as_ptr(), crate::typeref::SELECT_STATEMENT_TYPE) - == 1 - { - let casted_value = - value.cast_unchecked::(); - - let inner_value = casted_value.get(); - let result = sea_query::SimpleExpr::SubQuery( - None, - Box::new(sea_query::SubQueryStatement::SelectStatement( - inner_value.0.lock().to_sea_query(py), - )), - ); - - return Ok(Self(result)); - } - - if pyo3::ffi::PyObject_TypeCheck(value.as_ptr(), crate::typeref::CASE_STATEMENT_TYPE) - == 1 - { - let casted_value = value.cast_unchecked::(); - - let inner_value = casted_value.get(); - let result = - sea_query::SimpleExpr::Case(Box::new(inner_value.0.lock().to_sea_query(py))); - - return Ok(Self(result)); - } - - if pyo3::ffi::PyTuple_Check(value.as_ptr()) == 1 { - use pyo3::types::PyTupleMethods; - - let casted_value = value.cast_unchecked::(); - let mut arr = Vec::with_capacity(casted_value.len()); - - for item in casted_value.iter() { - arr.push(Self::try_from(&item)?); - } - - let result = sea_query::Expr::tuple(arr.into_iter().map(|x| x.0)); - return Ok(Self(result.into())); - } - - if let Some(result) = Self::try_from_property(value)? { - return Ok(result); - } - - if let Some(result) = crate::common::column_ref::PyColumnRef::try_from_property(value)? - { - return Ok(Self(sea_query::Expr::column(result))); - } - - let type_engine = match type_engine { - Some(x) => x, - None => TypeEngine::infer_pyobject(value)?, - }; - - let result = - crate::common::value::ValueState::from_pyobject(type_engine, value.clone())? - .simple_expr(py)?; - - Ok(Self(result)) - } - } - - #[inline] - pub fn try_from_property(value: RefBoundObject) -> pyo3::PyResult> { - const PROPERTY_NAME: &std::ffi::CStr = c"__expr__"; - - let property = match value.getattr(PROPERTY_NAME) { - Ok(x) => Ok(Some(x)), - Err(err) if err.is_instance_of::(value.py()) => { - Ok(None) - } - Err(err) => Err(err), - }; - let property = property?; - - if property.is_none() { - return Ok(None); - } - - unsafe { - let property = property.unwrap_unchecked(); - - if pyo3::ffi::Py_TYPE(property.as_ptr()) == crate::typeref::EXPR_TYPE { - let casted_value = property.cast_into_unchecked::(); - return Ok(Some(casted_value.get().clone())); - } - - crate::new_error!( - PyTypeError, - "__expr__ property returns something other than Expr; returns {}", - crate::internal::get_type_name(property.py(), property.as_ptr()) - ) - } - } -} - -impl From for PyExpr { - fn from(value: sea_query::SimpleExpr) -> Self { - Self(value) - } -} - -impl TryFrom> for PyExpr { - type Error = pyo3::PyErr; - - fn try_from(value: RefBoundObject<'_>) -> Result { - Self::try_from_specific_type(value, None) - } -} - -#[pyo3::pymethods] -impl PyExpr { - #[new] - #[pyo3(signature = (value, /))] - fn __new__(value: RefBoundObject<'_>) -> pyo3::PyResult { - Self::try_from(value) - } - - /// Shorthand for `Expr(Value(value, sql_type))` - #[classmethod] - #[pyo3(signature=(value, sql_type=None))] - fn val( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - value: RefBoundObject<'_>, - sql_type: Option>, - ) -> pyo3::PyResult { - unsafe { - if pyo3::ffi::Py_TYPE(value.as_ptr()) == crate::typeref::VALUE_TYPE { - let casted_value = value.cast_unchecked::(); - let unbound = casted_value.get(); - - return Ok(Self(unbound.0.lock().simple_expr(value.py())?)); - } - - let type_engine = { - if let Some(sql_type) = sql_type { - TypeEngine::new(&sql_type)? - } else { - TypeEngine::infer_pyobject(value)? - } - }; - - let result = - crate::common::value::ValueState::from_pyobject(type_engine, value.clone())? - .simple_expr(value.py())?; - - Ok(Self(result)) - } - } - - /// Tries to convert the `value` into `ColumnRef`, and then converts it to `Expr`. - #[classmethod] - fn col( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - value: RefBoundObject<'_>, - ) -> pyo3::PyResult { - let column_ref = crate::common::column_ref::PyColumnRef::try_from(value)?; - Ok(Self(sea_query::Expr::column(column_ref))) - } - - /// Returns asterisk '*' expression. - #[classmethod] - fn asterisk(_cls: &pyo3::Bound<'_, pyo3::types::PyType>) -> Self { - sea_query::Expr::column(sea_query::Asterisk).into() - } - - /// Create an expression from a custom SQL string. - /// - /// Warning: This method does not escape the input, so it should only - /// be used with trusted strings to avoid SQL injection vulnerabilities. - #[classmethod] - fn custom(_cls: &pyo3::Bound<'_, pyo3::types::PyType>, value: String) -> Self { - sea_query::SimpleExpr::Custom(value).into() - } - - /// Create an expression for the CURRENT_DATE SQL function. - #[classmethod] - fn current_date(_cls: &pyo3::Bound<'_, pyo3::types::PyType>) -> Self { - sea_query::SimpleExpr::Keyword(sea_query::Keyword::CurrentDate).into() - } - - /// Create an expression for the CURRENT_TIME SQL function. - #[classmethod] - fn current_time(_cls: &pyo3::Bound<'_, pyo3::types::PyType>) -> Self { - sea_query::SimpleExpr::Keyword(sea_query::Keyword::CurrentTime).into() - } - - /// Create an expression for the CURRENT_TIMESTAMP SQL function. - #[classmethod] - fn current_timestamp(_cls: &pyo3::Bound<'_, pyo3::types::PyType>) -> Self { - sea_query::SimpleExpr::Keyword(sea_query::Keyword::CurrentTimestamp).into() - } - - /// Create an expression representing the NULL value. - #[classmethod] - fn null(_cls: &pyo3::Bound<'_, pyo3::types::PyType>) -> Self { - sea_query::SimpleExpr::Keyword(sea_query::Keyword::Null).into() - } - - /// Express a `EXISTS` sub-query expression. - #[classmethod] - fn exists( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - statement: RefBoundObject<'_>, - ) -> pyo3::PyResult { - unsafe { - if pyo3::ffi::PyObject_TypeCheck( - statement.as_ptr(), - crate::typeref::SELECT_STATEMENT_TYPE, - ) != 1 - { - return crate::new_error!( - PyTypeError, - "expected SelectStatement, got {}", - crate::internal::get_type_name(statement.py(), statement.as_ptr()) - ); - } - - let casted_statement = - statement.cast_unchecked::(); - - let inner_statement = casted_statement.get(); - - let result = inner_statement.0.lock().to_sea_query(_cls.py()); - - Ok(Self(sea_query::Expr::exists(result))) - } - } - - /// Express a `ANY` sub-query expression. - #[classmethod] - fn any( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - statement: RefBoundObject<'_>, - ) -> pyo3::PyResult { - unsafe { - if pyo3::ffi::PyObject_TypeCheck( - statement.as_ptr(), - crate::typeref::SELECT_STATEMENT_TYPE, - ) != 1 - { - return crate::new_error!( - PyTypeError, - "expected SelectStatement, got {}", - crate::internal::get_type_name(statement.py(), statement.as_ptr()) - ); - } - - let casted_statement = - statement.cast_unchecked::(); - - let inner_statement = casted_statement.get(); - - let result = inner_statement.0.lock().to_sea_query(_cls.py()); - - Ok(Self(sea_query::Expr::any(result))) - } - } - - /// Express a `SOME` sub-query expression. - #[classmethod] - fn some( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - statement: RefBoundObject<'_>, - ) -> pyo3::PyResult { - unsafe { - if pyo3::ffi::PyObject_TypeCheck( - statement.as_ptr(), - crate::typeref::SELECT_STATEMENT_TYPE, - ) != 1 - { - return crate::new_error!( - PyTypeError, - "expected SelectStatement, got {}", - crate::internal::get_type_name(statement.py(), statement.as_ptr()) - ); - } - - let casted_statement = - statement.cast_unchecked::(); - - let inner_statement = casted_statement.get(); - - let result = inner_statement.0.lock().to_sea_query(_cls.py()); - - Ok(Self(sea_query::Expr::some(result))) - } - } - - /// Express a `ALL` sub-query expression. - #[classmethod] - fn all( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - statement: RefBoundObject<'_>, - ) -> pyo3::PyResult { - unsafe { - if pyo3::ffi::PyObject_TypeCheck( - statement.as_ptr(), - crate::typeref::SELECT_STATEMENT_TYPE, - ) != 1 - { - return crate::new_error!( - PyTypeError, - "expected SelectStatement, got {}", - crate::internal::get_type_name(statement.py(), statement.as_ptr()) - ); - } - - let casted_statement = - statement.cast_unchecked::(); - - let inner_statement = casted_statement.get(); - let result = inner_statement.0.lock().to_sea_query(_cls.py()); - - Ok(Self(sea_query::Expr::all(result))) - } - } - - /// Express a `IN` expression. - fn in_(slf: pyo3::PyRef<'_, Self>, other: RefBoundObject<'_>) -> pyo3::PyResult { - unsafe { - if pyo3::ffi::PyObject_TypeCheck(other.as_ptr(), crate::typeref::SELECT_STATEMENT_TYPE) - == 1 - { - let casted_statement = - other.cast_unchecked::(); - - let inner_statement = casted_statement.get(); - let result = inner_statement.0.lock().to_sea_query(slf.py()); - let result = sea_query::ExprTrait::in_subquery(slf.0.clone(), result); - - return Ok(result.into()); - } - } - - let mut exprs: Vec = Vec::new(); - - for item in other.try_iter()? { - let item = item?; - let item = Self::try_from(&item)?; - exprs.push(item.0); - } - - Ok(sea_query::ExprTrait::is_in(slf.0.clone(), exprs).into()) - } - - /// Express a `NOT IN` expression. - fn not_in(slf: pyo3::PyRef<'_, Self>, other: RefBoundObject<'_>) -> pyo3::PyResult { - unsafe { - if pyo3::ffi::PyObject_TypeCheck(other.as_ptr(), crate::typeref::SELECT_STATEMENT_TYPE) - == 1 - { - let casted_statement = - other.cast_unchecked::(); - - let inner_statement = casted_statement.get(); - let result = inner_statement.0.lock().to_sea_query(slf.py()); - let result = sea_query::ExprTrait::not_in_subquery(slf.0.clone(), result); - - return Ok(result.into()); - } - } - - let mut exprs: Vec = Vec::new(); - - for item in other.try_iter()? { - let item = item?; - let item = Self::try_from(&item)?; - exprs.push(item.0); - } - - Ok(sea_query::ExprTrait::is_not_in(slf.0.clone(), exprs).into()) - } - - /// Create a `CAST` expression to convert to a specific SQL type. - fn cast_as(slf: pyo3::PyRef<'_, Self>, value: String) -> Self { - slf.0.clone().cast_as(sea_query::Alias::new(value)).into() - } - - /// Create a `LIKE` pattern matching expression. - #[pyo3(signature=(pattern, escape=None))] - fn like(slf: pyo3::PyRef<'_, Self>, pattern: String, escape: Option) -> Self { - let e = sea_query::LikeExpr::new(pattern); - - if let Some(x) = escape { - sea_query::ExprTrait::like(slf.0.clone(), e.escape(x)).into() - } else { - sea_query::ExprTrait::like(slf.0.clone(), e).into() - } - } - - /// Create a NOT LIKE pattern matching expression. - #[pyo3(signature=(pattern, escape=None))] - fn not_like(slf: pyo3::PyRef<'_, Self>, pattern: String, escape: Option) -> Self { - let e = sea_query::LikeExpr::new(pattern); - - if let Some(x) = escape { - sea_query::ExprTrait::not_like(slf.0.clone(), e.escape(x)).into() - } else { - sea_query::ExprTrait::not_like(slf.0.clone(), e).into() - } - } - - /// Create an equality comparison expression. - fn __eq__<'a>(slf: pyo3::PyRef<'a, Self>, other: RefBoundObject<'a>) -> pyo3::PyResult { - let other = Self::try_from(other)?; - Ok(sea_query::ExprTrait::eq(slf.0.clone(), other.0).into()) - } - - /// Create an inequality comparison expression. - fn __ne__<'a>(slf: pyo3::PyRef<'a, Self>, other: RefBoundObject<'a>) -> pyo3::PyResult { - let other = Self::try_from(other)?; - Ok(sea_query::ExprTrait::ne(slf.0.clone(), other.0).into()) - } - - /// Create a greater-than comparison expression. - fn __gt__<'a>(slf: pyo3::PyRef<'a, Self>, other: RefBoundObject<'a>) -> pyo3::PyResult { - let other = Self::try_from(other)?; - Ok(sea_query::ExprTrait::gt(slf.0.clone(), other.0).into()) - } - - /// Create a greater-than-or-equal comparison expression. - fn __ge__<'a>(slf: pyo3::PyRef<'a, Self>, other: RefBoundObject<'a>) -> pyo3::PyResult { - let other = Self::try_from(other)?; - Ok(sea_query::ExprTrait::gte(slf.0.clone(), other.0).into()) - } - - /// Create a less-than comparison expression. - fn __lt__<'a>(slf: pyo3::PyRef<'a, Self>, other: RefBoundObject<'a>) -> pyo3::PyResult { - let other = Self::try_from(other)?; - Ok(sea_query::ExprTrait::lt(slf.0.clone(), other.0).into()) - } - - /// Create a less-than-or-equal comparison expression. - fn __le__<'a>(slf: pyo3::PyRef<'a, Self>, other: RefBoundObject<'a>) -> pyo3::PyResult { - let other = Self::try_from(other)?; - Ok(sea_query::ExprTrait::lte(slf.0.clone(), other.0).into()) - } - - /// Create an addition expression. - fn __add__<'a>(slf: pyo3::PyRef<'a, Self>, other: RefBoundObject<'a>) -> pyo3::PyResult { - let other = Self::try_from(other)?; - Ok(sea_query::ExprTrait::add(slf.0.clone(), other.0).into()) - } - - /// Create an subtraction expression. - fn __sub__<'a>(slf: pyo3::PyRef<'a, Self>, other: RefBoundObject<'a>) -> pyo3::PyResult { - let other = Self::try_from(other)?; - Ok(sea_query::ExprTrait::sub(slf.0.clone(), other.0).into()) - } - - /// Create a logical AND expression. - fn __and__<'a>(slf: pyo3::PyRef<'a, Self>, other: RefBoundObject<'a>) -> pyo3::PyResult { - let other = Self::try_from(other)?; - Ok(sea_query::ExprTrait::and(slf.0.clone(), other.0).into()) - } - - fn __neg__<'a>(slf: pyo3::PyRef<'a, Self>) -> pyo3::PyResult { - Ok(sea_query::ExprTrait::mul(slf.0.clone(), -1).into()) - } - - /// Create a logical OR expression. - fn __or__<'a>(slf: pyo3::PyRef<'a, Self>, other: RefBoundObject<'a>) -> pyo3::PyResult { - let other = Self::try_from(other)?; - Ok(sea_query::ExprTrait::or(slf.0.clone(), other.0).into()) - } - - fn bit_and<'a>(slf: pyo3::PyRef<'a, Self>, other: RefBoundObject<'a>) -> pyo3::PyResult { - let other = Self::try_from(other)?; - Ok(sea_query::ExprTrait::bit_and(slf.0.clone(), other.0).into()) - } - - fn bit_or<'a>(slf: pyo3::PyRef<'a, Self>, other: RefBoundObject<'a>) -> pyo3::PyResult { - let other = Self::try_from(other)?; - Ok(sea_query::ExprTrait::bit_or(slf.0.clone(), other.0).into()) - } - - /// Create a division expression. - fn __truediv__<'a>( - slf: pyo3::PyRef<'a, Self>, - other: RefBoundObject<'a>, - ) -> pyo3::PyResult { - let other = Self::try_from(other)?; - Ok(sea_query::ExprTrait::div(slf.0.clone(), other.0).into()) - } - - /// Create an IS comparison expression. - fn is_<'a>(slf: pyo3::PyRef<'a, Self>, other: RefBoundObject<'a>) -> pyo3::PyResult { - let other = Self::try_from(other)?; - Ok(sea_query::ExprTrait::is(slf.0.clone(), other.0).into()) - } - - /// Create an IS NOT comparison expression. - fn is_not<'a>(slf: pyo3::PyRef<'a, Self>, other: RefBoundObject<'a>) -> pyo3::PyResult { - let other = Self::try_from(other)?; - Ok(sea_query::ExprTrait::is_not(slf.0.clone(), other.0).into()) - } - - /// Create an IS NULL expression. - fn is_null(slf: pyo3::PyRef<'_, Self>) -> Self { - sea_query::ExprTrait::is_null(slf.0.clone()).into() - } - - /// Create an IS NOT NULL expression. - fn is_not_null(slf: pyo3::PyRef<'_, Self>) -> Self { - sea_query::ExprTrait::is_not_null(slf.0.clone()).into() - } - - /// Create a bitwise left shift expression. - fn __lshift__<'a>( - slf: pyo3::PyRef<'a, Self>, - other: RefBoundObject<'a>, - ) -> pyo3::PyResult { - let other = Self::try_from(other)?; - Ok(sea_query::ExprTrait::left_shift(slf.0.clone(), other.0).into()) - } - - /// Create a bitwise right shift expression. - fn __rshift__<'a>( - slf: pyo3::PyRef<'a, Self>, - other: RefBoundObject<'a>, - ) -> pyo3::PyResult { - let other = Self::try_from(other)?; - Ok(sea_query::ExprTrait::right_shift(slf.0.clone(), other.0).into()) - } - - /// Create a modulo expression. - fn __mod__<'a>(slf: pyo3::PyRef<'a, Self>, other: RefBoundObject<'a>) -> pyo3::PyResult { - let other = Self::try_from(other)?; - Ok(sea_query::ExprTrait::modulo(slf.0.clone(), other.0).into()) - } - - /// Create a multiplication expression. - fn __mul__<'a>(slf: pyo3::PyRef<'a, Self>, other: RefBoundObject<'a>) -> pyo3::PyResult { - let other = Self::try_from(other)?; - Ok(sea_query::ExprTrait::mul(slf.0.clone(), other.0).into()) - } - - /// Create a BETWEEN range comparison expression. - fn between<'a>( - slf: pyo3::PyRef<'a, Self>, - a: RefBoundObject<'a>, - b: RefBoundObject<'a>, - ) -> pyo3::PyResult { - let a = Self::try_from(a)?; - let b = Self::try_from(b)?; - - Ok(sea_query::ExprTrait::between(slf.0.clone(), a.0, b.0).into()) - } - - /// Create a NOT BETWEEN range comparison expression. - fn not_between<'a>( - slf: pyo3::PyRef<'a, Self>, - a: RefBoundObject<'a>, - b: RefBoundObject<'a>, - ) -> pyo3::PyResult { - let a = Self::try_from(a)?; - let b = Self::try_from(b)?; - - Ok(sea_query::ExprTrait::not_between(slf.0.clone(), a.0, b.0).into()) - } - - /// Create `MAX(self)` function call. - /// - /// Shorthand for `Func.max(self).to_expr()` - fn max(&self) -> Self { - let expr: sea_query::SimpleExpr = sea_query::Func::max(self.0.clone()).into(); - Self(expr) - } - - /// Create `MIN(self)` function call. - /// - /// Shorthand for `Func.min(self).to_expr()` - fn min(&self) -> Self { - let expr: sea_query::SimpleExpr = sea_query::Func::min(self.0.clone()).into(); - Self(expr) - } - - /// Create `ABS(self)` function call. - /// - /// Shorthand for `Func.abs(self).to_expr()` - fn abs(&self) -> Self { - let expr: sea_query::SimpleExpr = sea_query::Func::abs(self.0.clone()).into(); - Self(expr) - } - - /// Shorthand for `SelectLabel(self, alias, window)` - #[pyo3(signature=(alias, window=None))] - fn label( - &self, - alias: String, - window: Option>, - ) -> pyo3::PyResult { - let window = match window { - Some(x) => Some(crate::query::select::SelectLabelWindow::try_from(x)?), - None => None, - }; - - let state = crate::query::select::SelectLabelState { - expr: self.clone(), - alias: Some(alias), - window, - }; - Ok(state.into()) - } - - #[pyo3(signature = (backend, /))] - #[allow(clippy::wrong_self_convention)] - fn _to_sql(&self, _py: pyo3::Python<'_>, backend: String) -> pyo3::PyResult { - let builder = crate::internal::get_schema_builder(backend)?; - let mut sql = String::new(); - - let assert_unwind = std::panic::AssertUnwindSafe(|| { - sea_query::QueryBuilder::prepare_simple_expr(&*builder, &self.0, &mut sql) - }); - - std::panic::catch_unwind(assert_unwind) - .map_err(|_| pyo3::PyErr::new::("build failed"))?; - - Ok(sql) - } - - // TODO: sqlite_*, pg_*, mysql_* - - pub fn __repr__(&self) -> String { - #[cfg(not(debug_assertions))] - { - ReprFormatter::new("Expr").pair("", "...").finish() - } - - #[cfg(debug_assertions)] - { - ReprFormatter::new("Expr").debug("", &self.0).finish() - } - } -} diff --git a/src/common/expression/func.rs b/src/common/expression/func.rs deleted file mode 100644 index 5cd209e..0000000 --- a/src/common/expression/func.rs +++ /dev/null @@ -1,331 +0,0 @@ -use pyo3::types::PyTupleMethods; -use sea_query::IntoIden; - -use crate::internal::repr::ReprFormatter; -use crate::internal::{BoundArgs, RefBoundObject}; - -crate::implement_pyclass! { - // NOTE: SQLTypes, PyExpr, PyFunc, PyTableName & PyColumnRef could never mark as subclass. - // these should be immutable and final types. - - /// Represents a SQL function call that can be used in expressions. - /// - /// This class provides a type-safe way to construct SQL function calls - /// with proper argument handling and database dialect support. - #[derive(Debug, Clone)] - [] PyFunc as "Func" (pub sea_query::FunctionCall); -} - -#[pyo3::pymethods] -impl PyFunc { - #[new] - #[pyo3(signature=(name, *args))] - pub fn __new__(name: String, args: BoundArgs<'_>) -> pyo3::PyResult { - let mut function_call = sea_query::Func::cust(sea_query::Alias::new(name)); - - for item in args.iter() { - let expr = super::expr::PyExpr::try_from(&item)?; - function_call = function_call.arg(expr.0); - } - - Ok(Self(function_call)) - } - - /// Create a NOW() function call. - #[classmethod] - fn now(_cls: &pyo3::Bound<'_, pyo3::types::PyType>) -> Self { - Self(sea_query::Func::cust(sea_query::Alias::new("NOW"))) - } - - /// Create a SUM(expr) function call. - #[classmethod] - fn sum( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - expr: RefBoundObject<'_>, - ) -> pyo3::PyResult { - let expr = super::expr::PyExpr::try_from(expr)?; - Ok(Self(sea_query::Func::sum(expr.0))) - } - - /// Create a MIN(expr) function call. - #[classmethod] - fn min( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - expr: RefBoundObject<'_>, - ) -> pyo3::PyResult { - let expr = super::expr::PyExpr::try_from(expr)?; - Ok(Self(sea_query::Func::min(expr.0))) - } - - /// Create a MAX(expr) function call. - #[classmethod] - fn max( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - expr: RefBoundObject<'_>, - ) -> pyo3::PyResult { - let expr = super::expr::PyExpr::try_from(expr)?; - Ok(Self(sea_query::Func::max(expr.0))) - } - - /// Create a ABS(expr) function call. - #[classmethod] - fn abs( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - expr: RefBoundObject<'_>, - ) -> pyo3::PyResult { - let expr = super::expr::PyExpr::try_from(expr)?; - Ok(Self(sea_query::Func::abs(expr.0))) - } - - /// Create a AVG(expr) function call. - #[classmethod] - fn avg( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - expr: RefBoundObject<'_>, - ) -> pyo3::PyResult { - let expr = super::expr::PyExpr::try_from(expr)?; - Ok(Self(sea_query::Func::avg(expr.0))) - } - - /// Create a COUNT(expr) function call. - #[classmethod] - fn count( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - expr: RefBoundObject<'_>, - ) -> pyo3::PyResult { - let expr = super::expr::PyExpr::try_from(expr)?; - Ok(Self(sea_query::Func::count(expr.0))) - } - - /// Create a COUNT(DISTINCT expr) function call. - #[classmethod] - fn count_distinct( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - expr: RefBoundObject<'_>, - ) -> pyo3::PyResult { - let expr = super::expr::PyExpr::try_from(expr)?; - Ok(Self(sea_query::Func::count_distinct(expr.0))) - } - - /// Create a IF_NULL(a, b) function call. - #[classmethod] - fn if_null( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - a: RefBoundObject<'_>, - b: RefBoundObject<'_>, - ) -> pyo3::PyResult { - let a = super::expr::PyExpr::try_from(a)?; - let b = super::expr::PyExpr::try_from(b)?; - - Ok(Self(sea_query::Func::if_null(a.0, b.0))) - } - - /// Create a GREATEST function call. - #[classmethod] - #[pyo3(signature=(*exprs))] - fn greatest( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - exprs: BoundArgs<'_>, - ) -> pyo3::PyResult { - let mut simple_exprs = Vec::with_capacity(exprs.len()); - - for expr in exprs.iter() { - let expr = super::expr::PyExpr::try_from(&expr)?; - simple_exprs.push(expr.0); - } - - Ok(Self(sea_query::Func::greatest(simple_exprs))) - } - - /// Create a LEAST function call. - #[classmethod] - #[pyo3(signature=(*exprs))] - fn least( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - exprs: BoundArgs<'_>, - ) -> pyo3::PyResult { - let mut simple_exprs = Vec::with_capacity(exprs.len()); - - for expr in exprs.iter() { - let expr = super::expr::PyExpr::try_from(&expr)?; - simple_exprs.push(expr.0); - } - - Ok(Self(sea_query::Func::least(simple_exprs))) - } - - /// Create a CHAR_LENGTH(expr) function call. - #[classmethod] - fn char_length( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - expr: RefBoundObject<'_>, - ) -> pyo3::PyResult { - let expr = super::expr::PyExpr::try_from(expr)?; - Ok(Self(sea_query::Func::char_length(expr.0))) - } - - /// Create a COALESCE function call. - #[classmethod] - #[pyo3(signature=(*exprs))] - fn coalesce( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - exprs: BoundArgs<'_>, - ) -> pyo3::PyResult { - let mut simple_exprs = Vec::with_capacity(exprs.len()); - - for expr in exprs.iter() { - let expr = super::expr::PyExpr::try_from(&expr)?; - simple_exprs.push(expr.0); - } - - Ok(Self(sea_query::Func::coalesce(simple_exprs))) - } - - /// Create a LOWER(expr) function call. - #[classmethod] - fn lower( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - expr: RefBoundObject<'_>, - ) -> pyo3::PyResult { - let expr = super::expr::PyExpr::try_from(expr)?; - Ok(Self(sea_query::Func::lower(expr.0))) - } - - /// Create a UPPER(expr) function call. - #[classmethod] - fn upper( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - expr: RefBoundObject<'_>, - ) -> pyo3::PyResult { - let expr = super::expr::PyExpr::try_from(expr)?; - Ok(Self(sea_query::Func::upper(expr.0))) - } - - /// Create a BIT_AND(expr) function call - this is not supported on SQLite. - #[classmethod] - fn bit_and( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - expr: RefBoundObject<'_>, - ) -> pyo3::PyResult { - let expr = super::expr::PyExpr::try_from(expr)?; - Ok(Self(sea_query::Func::bit_and(expr.0))) - } - - /// Create a BIT_OR(expr) function call - this is not supported on SQLite. - #[classmethod] - fn bit_or( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - expr: RefBoundObject<'_>, - ) -> pyo3::PyResult { - let expr = super::expr::PyExpr::try_from(expr)?; - Ok(Self(sea_query::Func::bit_or(expr.0))) - } - - /// Create a RANDOM() function call. - #[classmethod] - fn random(_cls: &pyo3::Bound<'_, pyo3::types::PyType>) -> Self { - Self(sea_query::Func::random()) - } - - /// Create a RANK() function call. - #[classmethod] - fn rank(_cls: &pyo3::Bound<'_, pyo3::types::PyType>) -> Self { - Self(sea_query::Func::cust("RANK")) - } - - /// Create a DENSE_RANK() function call. - #[classmethod] - fn dense_rank(_cls: &pyo3::Bound<'_, pyo3::types::PyType>) -> Self { - Self(sea_query::Func::cust("DENSE_RANK")) - } - - /// Create a PERCENT_RANK() function call. - #[classmethod] - fn percent_rank(_cls: &pyo3::Bound<'_, pyo3::types::PyType>) -> Self { - Self(sea_query::Func::cust("PERCENT_RANK")) - } - - /// Create a ROUND(expr) function call. - #[classmethod] - fn round( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - expr: RefBoundObject<'_>, - ) -> pyo3::PyResult { - let expr = super::expr::PyExpr::try_from(expr)?; - Ok(Self(sea_query::Func::round(expr.0))) - } - - /// Create a ROUND(a, b) function call. - #[classmethod] - fn round_with_precision( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - a: RefBoundObject<'_>, - b: RefBoundObject<'_>, - ) -> pyo3::PyResult { - let a = super::expr::PyExpr::try_from(a)?; - let b = super::expr::PyExpr::try_from(b)?; - - Ok(Self(sea_query::Func::round_with_precision(a.0, b.0))) - } - - /// Create a MD5(expr) function call - this is only available in Postgres and MySQL. - #[classmethod] - fn md5( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - expr: RefBoundObject<'_>, - ) -> pyo3::PyResult { - let expr = super::expr::PyExpr::try_from(expr)?; - Ok(Self(sea_query::Func::md5(expr.0))) - } - - #[classmethod] - fn cast_as( - _cls: &pyo3::Bound<'_, pyo3::types::PyType>, - expr: RefBoundObject<'_>, - alias: String, - ) -> pyo3::PyResult { - let expr = super::expr::PyExpr::try_from(expr)?; - Ok(Self(sea_query::Func::cast_as( - expr.0, - sea_query::Alias::new(alias).into_iden(), - ))) - } - - /// Shorthand for `SelectLabel(self, alias, window)` - #[pyo3(signature=(alias, window=None))] - fn label( - &self, - alias: String, - window: Option>, - ) -> pyo3::PyResult { - let window = match window { - Some(x) => Some(crate::query::select::SelectLabelWindow::try_from(x)?), - None => None, - }; - let expr = self.to_expr(); - - let state = crate::query::select::SelectLabelState { - expr, - alias: Some(alias), - window, - }; - Ok(state.into()) - } - - /// Shorthand for `Expr(self)` - fn to_expr(&self) -> super::expr::PyExpr { - super::expr::PyExpr(sea_query::SimpleExpr::FunctionCall(self.0.clone())) - } - - pub fn __repr__(&self) -> String { - #[cfg(not(debug_assertions))] - { - ReprFormatter::new("Func").pair("", "...").finish() - } - - #[cfg(debug_assertions)] - { - ReprFormatter::new("Func").debug("", &self.0).finish() - } - } -} diff --git a/src/common/expression/mod.rs b/src/common/expression/mod.rs deleted file mode 100644 index 82c61f1..0000000 --- a/src/common/expression/mod.rs +++ /dev/null @@ -1,57 +0,0 @@ -mod expr; -mod func; - -pub use expr::*; -pub use func::*; - -use crate::internal::{BoundArgs, PyObject}; - -/// Create a logical AND condition that is true only if all conditions are true. -/// -/// This is equivalent to SQL's AND operator applied to multiple expressions. -/// -/// @signature (arg1: Expr, *args: Expr) -> Expr -#[pyo3::pyfunction] -#[pyo3(signature=(arg1, *args))] -pub fn all(arg1: pyo3::Bound<'_, PyExpr>, args: BoundArgs<'_>) -> pyo3::PyResult { - let py = arg1.py(); - let mut expr = arg1.unbind(); - - for m in args { - let m = m.cast_into_exact::()?; - - let result = sea_query::ExprTrait::and(expr.get().0.clone(), m.get().0.clone()); - expr = pyo3::Py::new(py, PyExpr(result))?; - } - - Ok(expr.into_any()) -} - -/// Create a logical OR condition that is true if any condition is true. -/// -/// This is equivalent to SQL's OR operator applied to multiple expressions. -/// -/// @signature (arg1: Expr, *args: Expr) -> Expr -#[pyo3::pyfunction] -#[pyo3(signature=(arg1, *args))] -pub fn any(arg1: pyo3::Bound<'_, PyExpr>, args: BoundArgs<'_>) -> pyo3::PyResult { - let py = arg1.py(); - let mut expr = arg1.unbind(); - - for m in args { - let m = m.cast_into_exact::()?; - - let result = sea_query::ExprTrait::or(expr.get().0.clone(), m.get().0.clone()); - expr = pyo3::Py::new(py, PyExpr(result))?; - } - - Ok(expr.into_any()) -} - -/// Create a logical NOT. -/// -/// @signature (arg: Expr) -> Expr -#[pyo3::pyfunction] -pub fn not_(arg: &pyo3::Bound<'_, PyExpr>) -> PyExpr { - sea_query::ExprTrait::not(arg.get().0.clone()).into() -} diff --git a/src/common/foreign_key.rs b/src/common/foreign_key.rs deleted file mode 100644 index ba72806..0000000 --- a/src/common/foreign_key.rs +++ /dev/null @@ -1,434 +0,0 @@ -use super::table_ref::PyTableName; -use crate::internal::repr::ReprFormatter; -use crate::internal::{BoundArgs, BoundKwargs, BoundObject, RefBoundObject, ToSeaQuery}; - -#[inline] -fn map_str_to_foreign_key_action(value: String) -> pyo3::PyResult { - match value.to_lowercase().as_str() { - "cascade" => Ok(sea_query::ForeignKeyAction::Cascade), - "no action" => Ok(sea_query::ForeignKeyAction::NoAction), - "restrict" => Ok(sea_query::ForeignKeyAction::Restrict), - "set default" => Ok(sea_query::ForeignKeyAction::SetDefault), - "set null" => Ok(sea_query::ForeignKeyAction::SetNull), - _ => Err(pyo3::exceptions::PyValueError::new_err(format!( - "unknown foreign key action: {value}", - ))), - } -} - -#[inline] -fn map_foreign_key_action_to_str(value: sea_query::ForeignKeyAction) -> String { - match value { - sea_query::ForeignKeyAction::Cascade => String::from("CASCADE"), - sea_query::ForeignKeyAction::NoAction => String::from("NO ACTION"), - sea_query::ForeignKeyAction::Restrict => String::from("RESTRICT"), - sea_query::ForeignKeyAction::SetDefault => String::from("SET DEFAULT"), - sea_query::ForeignKeyAction::SetNull => String::from("SET NULL"), - } -} - -crate::implement_pyclass! { - /// Specifies a foreign key relationship between tables. - /// - /// Defines referential integrity constraints including: - /// - Source columns (in the child table) - /// - Target columns (in the parent table) - /// - Actions for updates and deletes (CASCADE, RESTRICT, SET NULL, etc.) - /// - Optional naming for the constraint - /// - /// Foreign keys ensure data consistency by requiring that values in the - /// child table's columns match existing values in the parent table's columns. - #[derive(Debug, Clone)] - mutable [subclass] PyForeignKey(ForeignKeyState) as "ForeignKey" { - /// Foreign key constraint name - pub name: Option, - - /// To table - pub to_table: PyTableName, - - /// To columns - pub to_columns: Vec, - - // /// From table - // pub from_table: Option, - - /// From columns - pub from_columns: Vec, - - /// On delete action - pub on_delete: Option, - - /// On update action - pub on_update: Option, - } -} - -impl ToSeaQuery for ForeignKeyState { - #[cfg_attr(feature = "optimize", optimize(speed))] - fn to_sea_query<'a>(&self, _py: pyo3::Python<'a>) -> sea_query::ForeignKeyCreateStatement { - let mut stmt = sea_query::ForeignKeyCreateStatement::new(); - stmt.to_tbl(self.to_table.clone()); - - if let Some(x) = &self.name { - stmt.name(x); - } - - for c in &self.from_columns { - stmt.from_col(sea_query::Alias::new(c)); - } - for c in &self.to_columns { - stmt.to_col(sea_query::Alias::new(c)); - } - - if let Some(x) = self.on_delete { - stmt.on_delete(x); - } - if let Some(x) = self.on_update { - stmt.on_update(x); - } - - stmt - } -} - -impl ToSeaQuery for ForeignKeyState { - #[cfg_attr(feature = "optimize", optimize(speed))] - fn to_sea_query<'a>(&self, _py: pyo3::Python<'a>) -> sea_query::TableForeignKey { - let mut stmt = sea_query::TableForeignKey::new(); - stmt.to_tbl(self.to_table.clone()); - - if let Some(x) = &self.name { - stmt.name(x); - } - - for c in &self.from_columns { - stmt.from_col(sea_query::Alias::new(c)); - } - for c in &self.to_columns { - stmt.to_col(sea_query::Alias::new(c)); - } - - if let Some(x) = self.on_delete { - stmt.on_delete(x); - } - if let Some(x) = self.on_update { - stmt.on_update(x); - } - - stmt - } -} - -#[pyo3::pymethods] -impl PyForeignKey { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> Self { - Self::uninit() - } - - #[ - pyo3( - signature=( - from_columns, - to_columns, - to_table=None, - name=None, - *, - on_delete=None, - on_update=None - ) - ) - ] - fn __init__( - &self, - from_columns: Vec>, - to_columns: Vec>, - to_table: Option>, - name: Option, - on_delete: Option, - on_update: Option, - ) -> pyo3::PyResult<()> { - // Validate & convert actions - let on_delete = match on_delete { - None => None, - Some(x) => Some(map_str_to_foreign_key_action(x)?), - }; - let on_update = match on_update { - None => None, - Some(x) => Some(map_str_to_foreign_key_action(x)?), - }; - - let mut to_table = match to_table { - Some(x) => Some(PyTableName::try_from(x)?), - None => None, - }; - - // Validate from/to_columns - if from_columns.is_empty() { - return crate::new_error!(PyValueError, "from_columns cannot be empty."); - } - if to_columns.is_empty() { - return crate::new_error!(PyValueError, "to_columns cannot be empty."); - } - if from_columns.len() != to_columns.len() { - return crate::new_error!( - PyValueError, - "from_columns and to_columns must have same length ({} != {})", - from_columns.len(), - to_columns.len() - ); - } - - // Convert from_columns - let mut from_columns_str = Vec::with_capacity(from_columns.len()); - - for col in from_columns.into_iter() { - let col_ref = super::column_ref::PyColumnRef::try_from(&col)?; - - match col_ref.name { - Some(x) => from_columns_str.push(x.to_string()), - None => { - return crate::new_error!( - PyValueError, - "ForeignKey cannot accept asterisk '*' as column" - ); - } - } - } - - // Convert to_columns - let mut to_columns_str = Vec::with_capacity(to_columns.len()); - - for col in to_columns.into_iter() { - let col_ref = super::column_ref::PyColumnRef::try_from(&col)?; - - match &col_ref.name { - Some(x) => to_columns_str.push(x.to_string()), - None => { - return crate::new_error!( - PyValueError, - "ForeignKey cannot accept asterisk '*' as column" - ); - } - } - - if let Some(detected_table_name) = col_ref.table { - let detected_table_name = PyTableName { - name: detected_table_name, - schema: col_ref.schema, - database: None, - alias: None, - }; - - if let Some(last) = &to_table { - if last.ne(&detected_table_name) { - return crate::new_error!(PyValueError, "multiple target table found"); - } - } - - to_table = Some(detected_table_name); - } - } - - if to_table.is_none() { - return crate::new_error!( - PyValueError, - "No target table found. You should specify the target table with `to_table`, or \ - `to_columns` parameter." - ); - } - let to_table = unsafe { to_table.unwrap_unchecked() }; - - // let name = match name { - // Some(x) => x, - // None => { - // let mut s = format!("fk_{}", to_table.name.to_string()); - - // for col in from_columns_str.iter() { - // s.push('_'); - // s += col; - // } - - // s.push('_'); - - // for col in to_columns_str.iter() { - // s.push('_'); - // s += col; - // } - - // s - // } - // }; - - let result = ForeignKeyState { - name, - to_table, - to_columns: to_columns_str, - from_columns: from_columns_str, - on_delete, - on_update, - }; - self.0.set(result); - Ok(()) - } - - /// Foreign key constraint name - #[getter] - fn name(&self) -> Option { - self.0.lock().name.clone() - } - - #[setter] - fn set_name(&self, val: Option) { - let mut lock = self.0.lock(); - lock.name = val; - } - - /// Referencing table. - #[getter] - fn to_table(&self) -> PyTableName { - self.0.lock().to_table.clone() - } - - #[setter] - fn set_to_table(&self, value: RefBoundObject<'_>) -> pyo3::PyResult<()> { - let mut lock = self.0.lock(); - lock.to_table = super::table_ref::PyTableName::try_from(value)?; - Ok(()) - } - - /// Key columns. - #[getter] - #[allow(clippy::wrong_self_convention)] - fn from_columns(&self) -> Vec { - self.0.lock().from_columns.clone() - } - - #[setter] - fn set_from_columns(&self, val: Vec>) -> pyo3::PyResult<()> { - let mut lock = self.0.lock(); - - if val.is_empty() { - return Err(pyo3::exceptions::PyValueError::new_err( - "from_columns is empty", - )); - } - if val.len() != lock.to_columns.len() { - return Err(pyo3::exceptions::PyValueError::new_err(format!( - "from_columns and to_columns must have same length ({} != {})", - val.len(), - lock.to_columns.len() - ))); - } - - let mut from_columns_str = Vec::with_capacity(val.len()); - for col in val.into_iter() { - let col_ref = super::column_ref::PyColumnRef::try_from(&col)?; - - match col_ref.name { - Some(x) => from_columns_str.push(x.to_string()), - None => { - return Err(pyo3::exceptions::PyValueError::new_err( - "ForeignKey cannot accept asterisk '*' as column", - )) - } - } - } - - lock.from_columns = from_columns_str; - Ok(()) - } - - /// Referencing columns. - #[getter] - fn to_columns(&self) -> Vec { - self.0.lock().to_columns.clone() - } - - #[setter] - fn set_to_columns(&self, val: Vec>) -> pyo3::PyResult<()> { - let mut lock = self.0.lock(); - - if val.is_empty() { - return Err(pyo3::exceptions::PyValueError::new_err( - "to_columns is empty", - )); - } - if val.len() != lock.from_columns.len() { - return Err(pyo3::exceptions::PyValueError::new_err(format!( - "to_columns and to_columns must have same length ({} != {})", - val.len(), - lock.to_columns.len() - ))); - } - - let mut to_columns_str = Vec::with_capacity(val.len()); - for col in val.into_iter() { - let col_ref = super::column_ref::PyColumnRef::try_from(&col)?; - - match col_ref.name { - Some(x) => to_columns_str.push(x.to_string()), - None => { - return Err(pyo3::exceptions::PyValueError::new_err( - "ForeignKey cannot accept asterisk '*' as column", - )) - } - } - } - - lock.to_columns = to_columns_str; - Ok(()) - } - - /// ON DELETE action. - #[getter] - fn on_delete(&self) -> Option { - self.0.lock().on_delete.map(map_foreign_key_action_to_str) - } - - #[setter] - fn set_on_delete(&self, value: Option) -> pyo3::PyResult<()> { - let mut lock = self.0.lock(); - lock.on_delete = match value { - None => None, - Some(x) => Some(map_str_to_foreign_key_action(x)?), - }; - Ok(()) - } - - /// ON UPDATE action. - #[getter] - fn on_update(&self) -> Option { - self.0.lock().on_update.map(map_foreign_key_action_to_str) - } - - #[setter] - fn set_on_update(&self, value: Option) -> pyo3::PyResult<()> { - let mut lock = self.0.lock(); - lock.on_update = match value { - None => None, - Some(x) => Some(map_str_to_foreign_key_action(x)?), - }; - Ok(()) - } - - fn __copy__(&self) -> Self { - let lock = self.0.lock(); - lock.clone().into() - } - - pub fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let lock = slf.0.lock(); - - ReprFormatter::new_with_pyref(&slf) - .optional_quote("name", lock.name.as_ref()) - .map("to_table", &lock.to_table, |x| x.__repr__()) - .debug("to_columns", &lock.to_columns) - .debug("from_columns", &lock.from_columns) - .optional_map("on_delete", lock.on_delete, map_foreign_key_action_to_str) - .optional_map("on_update", lock.on_update, map_foreign_key_action_to_str) - .finish() - } -} diff --git a/src/common/mod.rs b/src/common/mod.rs deleted file mode 100644 index 0789a7b..0000000 --- a/src/common/mod.rs +++ /dev/null @@ -1,35 +0,0 @@ -pub mod column; -pub mod column_ref; -pub mod expression; -pub mod foreign_key; -pub mod table_ref; -pub mod value; - -#[pyo3::pymodule(name = "common")] -pub mod common_module { - #[pymodule_export] - use super::value::PyValue; - - #[pymodule_export] - use super::column_ref::PyColumnRef; - - #[pymodule_export] - use super::table_ref::PyTableName; - - #[pymodule_export] - use super::expression::all; - #[pymodule_export] - use super::expression::any; - #[pymodule_export] - use super::expression::not_; - #[pymodule_export] - use super::expression::PyExpr; - #[pymodule_export] - use super::expression::PyFunc; - - #[pymodule_export] - use super::column::PyColumn; - - #[pymodule_export] - use super::foreign_key::PyForeignKey; -} diff --git a/src/common/table_ref.rs b/src/common/table_ref.rs deleted file mode 100644 index ec0ee9f..0000000 --- a/src/common/table_ref.rs +++ /dev/null @@ -1,319 +0,0 @@ -use pyo3::types::PyAnyMethods; -use sea_query::IntoIden; -use std::str::FromStr; - -use crate::internal::repr::ReprFormatter; -use crate::internal::{BoundKwargs, RefBoundObject}; - -crate::implement_pyclass! { - // NOTE: SQLTypes, PyExpr, PyFunc, PyTableName & PyColumnRef could never mark as subclass. - // these should be immutable and final types. - - /// Represents a table name reference with optional schema, database, and alias. - /// - /// This class encapsulates a table name that can include: - /// - The base table name - /// - Optional schema/namespace qualification - /// - Optional database qualification (for systems that support it) - /// - /// The class provides parsing capabilities for string representations - /// and supports comparison operations. - /// - /// NOTE: this class is immutable and frozen. - #[derive(Debug, Clone, PartialEq)] - [] PyTableName as "TableName" { - pub name: sea_query::DynIden, - pub schema: Option, - pub database: Option, - pub alias: Option, - } -} - -impl sea_query::IntoTableRef for PyTableName { - fn into_table_ref(self) -> sea_query::TableRef { - match (self.schema, self.database, self.alias) { - (Some(schema), Some(database), Some(alias)) => { - sea_query::TableRef::DatabaseSchemaTableAlias(database, schema, self.name, alias) - } - (Some(schema), None, Some(alias)) => { - sea_query::TableRef::SchemaTableAlias(schema, self.name, alias) - } - (Some(schema), Some(database), None) => { - sea_query::TableRef::DatabaseSchemaTable(database, schema, self.name) - } - (Some(schema), None, None) => sea_query::TableRef::SchemaTable(schema, self.name), - (None, None, Some(alias)) => sea_query::TableRef::TableAlias(self.name, alias), - _ => sea_query::TableRef::Table(self.name), - } - } -} - -impl TryFrom> for PyTableName { - type Error = pyo3::PyErr; - - fn try_from(value: RefBoundObject<'_>) -> Result { - unsafe { - if pyo3::ffi::Py_TYPE(value.as_ptr()) == crate::typeref::TABLE_NAME_TYPE { - let casted_value = value.cast_unchecked::(); - return Ok(casted_value.get().clone()); - } - - if let Ok(x) = value.extract::() { - return Self::from_str(&x); - } - - if let Some(result) = Self::try_from_property(value)? { - return Ok(result); - } - - crate::new_error!( - PyTypeError, - "expected TableName or str or object.__table_name__ property, got {}", - crate::internal::get_type_name(value.py(), value.as_ptr()) - ) - } - } -} - -impl FromStr for PyTableName { - type Err = pyo3::PyErr; - - fn from_str(s: &str) -> Result { - let s = s.trim(); - if s.is_empty() { - return Err(pyo3::PyErr::new::( - "cannot parse an empty string", - )); - } - - // Possible formats: - // name - // schema.name - // database.schema.name - // database.schema.name AS alias - let mut s: Vec = s.split('.').map(String::from).collect(); - - if s.len() > 3 { - return Err(pyo3::PyErr::new::( - "invalid format", - )); - } - - let name = s.pop().unwrap(); - let schema = s.pop().map(|x| sea_query::Alias::new(x).into_iden()); - let database = s.pop().map(|x| sea_query::Alias::new(x).into_iden()); - - let (name, alias) = match name.to_ascii_lowercase().find("as") { - None => (name, None), - Some(position) => ( - name[..position].trim().to_string(), - Some(name[position + 2..].trim().to_string()), - ), - }; - - Ok(Self { - name: sea_query::Alias::new(name).into_iden(), - schema, - database, - alias: alias.map(|x| sea_query::Alias::new(x).into_iden()), - }) - } -} - -impl PyTableName { - #[inline] - pub fn try_from_property(value: RefBoundObject) -> pyo3::PyResult> { - const PROPERTY_NAME: &std::ffi::CStr = c"__table_name__"; - - let property = match value.getattr(PROPERTY_NAME) { - Ok(x) => Ok(Some(x)), - Err(err) if err.is_instance_of::(value.py()) => { - Ok(None) - } - Err(err) => Err(err), - }; - let property = property?; - - if property.is_none() { - return Ok(None); - } - - unsafe { - let property = property.unwrap_unchecked(); - - if pyo3::ffi::Py_TYPE(property.as_ptr()) == crate::typeref::TABLE_NAME_TYPE { - let casted_property = property.cast_unchecked::(); - return Ok(Some(casted_property.get().clone())); - } - - if let Ok(x) = property.extract::() { - return Self::from_str(&x).map(Some); - } - - crate::new_error!( - PyTypeError, - "__table_name__ property returns something other than TableName or str; returns {}", - crate::internal::get_type_name(property.py(), property.as_ptr()) - ) - } - } -} - -#[pyo3::pymethods] -impl PyTableName { - #[new] - #[pyo3(signature=(name, schema=None, database=None, alias=None))] - fn new( - name: String, - schema: Option, - database: Option, - alias: Option, - ) -> Self { - Self { - name: sea_query::Alias::new(name).into_iden(), - schema: schema.map(|x| sea_query::Alias::new(x).into_iden()), - database: database.map(|x| sea_query::Alias::new(x).into_iden()), - alias: alias.map(|x| sea_query::Alias::new(x).into_iden()), - } - } - - /// Parse a string representation of a table name. - /// - /// Supports formats like: - /// - "table_name" - /// - "schema.table_name" - /// - "database.schema.table_name" - /// - "database.schema.table_name as alias" - #[classmethod] - fn parse(_cls: &pyo3::Bound<'_, pyo3::types::PyType>, string: String) -> pyo3::PyResult { - Self::from_str(&string) - } - - #[getter] - fn name(&self) -> String { - self.name.to_string() - } - - #[getter] - fn schema(&self) -> Option { - self.schema.as_ref().map(|x| x.to_string()) - } - - #[getter] - fn database(&self) -> Option { - self.database.as_ref().map(|x| x.to_string()) - } - - #[getter] - fn alias(&self) -> Option { - self.alias.as_ref().map(|x| x.to_string()) - } - - /// Create a shallow copy of this TableName. - #[pyo3(signature=(**kwds))] - fn copy_with(&self, kwds: Option>) -> pyo3::PyResult { - use pyo3::types::PyDictMethods; - - let mut cloned = self.clone(); - if kwds.is_none() { - return Ok(cloned); - } - - let kwds = unsafe { kwds.unwrap_unchecked() }; - - for (key, val) in kwds.iter() { - #[cfg(debug_assertions)] - let key = key.extract::().unwrap(); - - #[cfg(not(debug_assertions))] - let key = unsafe { key.extract::().unwrap_unchecked() }; - - // All of values are Option - let val = unsafe { - if pyo3::ffi::Py_IsNone(val.as_ptr()) == 1 { - None - } else if pyo3::ffi::PyUnicode_CheckExact(val.as_ptr()) == 1 { - Some(val.extract::().unwrap_unchecked()) - } else { - return crate::new_error!( - PyTypeError, - "expected str or None, got {}", - crate::internal::get_type_name(val.py(), val.as_ptr()) - ); - } - }; - - if key == "name" { - if let Some(x) = val { - // Ignore name=None - cloned.name = sea_query::Alias::new(x).into_iden(); - } - } else if key == "database" { - cloned.database = val.map(|x| sea_query::Alias::new(x).into_iden()); - } else if key == "schema" { - cloned.schema = val.map(|x| sea_query::Alias::new(x).into_iden()); - } else if key == "alias" { - cloned.alias = val.map(|x| sea_query::Alias::new(x).into_iden()); - } else { - return crate::new_error!( - PyTypeError, - "got an unexpected keyword argument '{}'", - key - ); - } - } - - Ok(cloned) - } - - fn __eq__(slf: pyo3::PyRef<'_, Self>, other: &pyo3::Bound<'_, Self>) -> pyo3::PyResult { - if slf.as_ptr() == other.as_ptr() { - return Ok(true); - } - - let other = other.get(); - Ok(slf.eq(other)) - } - - fn __ne__(slf: pyo3::PyRef<'_, Self>, other: &pyo3::Bound<'_, Self>) -> pyo3::PyResult { - if slf.as_ptr() == other.as_ptr() { - return Ok(false); - } - - let other = other.get(); - Ok(slf.ne(other)) - } - - fn __copy__(&self) -> Self { - self.clone() - } - - fn __hash__(&self) -> u64 { - use std::hash::{Hash, Hasher}; - - let mut state = std::hash::DefaultHasher::new(); - - self.name.to_string().hash(&mut state); - - if let Some(x) = &self.database { - x.to_string().hash(&mut state); - } - if let Some(x) = &self.schema { - x.to_string().hash(&mut state); - } - if let Some(x) = &self.alias { - x.to_string().hash(&mut state); - } - - state.finish() - } - - pub fn __repr__(&self) -> String { - ReprFormatter::new("TableName") - .iden("name", &self.name) - .optional_iden("schema", self.schema.as_ref()) - .optional_iden("database", self.database.as_ref()) - .optional_iden("alias", self.alias.as_ref()) - .finish() - } -} diff --git a/src/common/value.rs b/src/common/value.rs deleted file mode 100644 index 450f38c..0000000 --- a/src/common/value.rs +++ /dev/null @@ -1,172 +0,0 @@ -use crate::internal::repr::ReprFormatter; -use crate::internal::type_engine::TypeEngine; -use crate::internal::{BoundArgs, BoundKwargs, BoundObject, PyObject}; -use crate::sqltypes::SQLTypeTrait; - -crate::implement_pyclass! { - /// Bridges Python types, Rust types, and SQL types for seamless data conversion. - /// - /// This class handles validation, adaptation, and conversion between different - /// type systems used in the application stack. - /// - /// It can automatically detects the type of your value and selects appropriate Rust and SQL types. - /// For example: - /// - Python `int` becomes `BIGINT` SQL type (`BigIntegerType`) - /// - Python `dict` or `list` becomes `JSON` SQL type (`JsonType`) - /// - Python `float` becomes `DOUBLE` SQL type (`DoubleType`) - /// - /// However, for more accurate type selection, it's recommended to use the `sql_type` parameter. - /// - /// NOTE: this class is immutable and frozen. - mutable [subclass] PyValue(ValueState) as "Value" { - sql_type: TypeEngine, - serialized: Option, - deserialized: Option, - } -} - -impl ValueState { - #[inline(always)] - pub unsafe fn new_unchecked( - sql_type: TypeEngine, - serialized: Option, - deserialized: Option, - ) -> Self { - Self { - sql_type, - serialized, - deserialized, - } - } - - pub fn from_pyobject(sql_type: TypeEngine, object: BoundObject<'_>) -> pyo3::PyResult { - unsafe { - if pyo3::ffi::Py_IsNone(object.as_ptr()) == 0 { - sql_type.validate(object.py(), object.as_ptr())?; - } else { - // There's no need to do validation. - // The `ValueState` will handle `NoneType`s itself - } - - Ok(Self::new_unchecked(sql_type, None, Some(object.unbind()))) - } - } - - pub fn from_sea_query_value(py: pyo3::Python, value: sea_query::Value) -> Self { - let sql_type = TypeEngine::infer_value(py, &value); - - unsafe { Self::new_unchecked(sql_type, Some(value), None) } - } - - pub fn simple_expr(&mut self, py: pyo3::Python) -> pyo3::PyResult { - if let Some(x) = &self.serialized { - return Ok(sea_query::SimpleExpr::Value(x.clone())); - } - - let result = unsafe { - let deserialized = self.deserialized.as_ref().unwrap_unchecked(); - - if pyo3::ffi::Py_IsNone(deserialized.as_ptr()) == 1 { - sea_query::Value::Bool(None) - } else { - self.sql_type.serialize(py, deserialized.as_ptr())? - } - }; - - self.serialized = Some(result.clone()); - Ok(sea_query::SimpleExpr::Value(result)) - } - - pub fn clone_ref(&self, py: pyo3::Python) -> Self { - Self { - sql_type: self.sql_type.clone(), - serialized: self.serialized.clone(), - deserialized: self.deserialized.as_ref().map(|x| x.clone_ref(py)), - } - } -} - -#[pyo3::pymethods] -impl PyValue { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> Self { - Self::uninit() - } - - #[pyo3(signature=(value, sql_type=None))] - fn __init__( - &self, - value: BoundObject<'_>, - sql_type: Option>, - ) -> pyo3::PyResult<()> { - unsafe { - if pyo3::ffi::PyObject_TypeCheck(value.as_ptr(), crate::typeref::VALUE_TYPE) == 1 { - let py = value.py(); - - let casted_value = value.cast_into_unchecked::(); - let state = casted_value.get().0.lock().clone_ref(py); - - self.0.set(state); - return Ok(()); - } - } - - let type_engine = { - if let Some(sql_type) = sql_type { - TypeEngine::new(&sql_type)? - } else { - TypeEngine::infer_pyobject(&value)? - } - }; - - let result = ValueState::from_pyobject(type_engine, value)?; - self.0.set(result); - Ok(()) - } - - #[getter] - fn sql_type<'a>(&self, py: pyo3::Python<'a>) -> BoundObject<'a> { - let lock = self.0.lock(); - lock.sql_type.as_pyobject(py) - } - - /// Converts the adapted value back to a Python type. - #[getter] - fn value<'py>(&self, py: pyo3::Python<'py>) -> pyo3::PyResult> { - let mut lock = self.0.lock(); - - if let Some(x) = &lock.deserialized { - return Ok(x.bind(py).clone()); - } - - let deserialized = unsafe { - lock.sql_type - .deserialize(py, lock.serialized.as_ref().unwrap()) - .map(|x| pyo3::Bound::from_owned_ptr(py, x))? - }; - lock.deserialized = Some(deserialized.clone().unbind()); - - Ok(deserialized) - } - - fn __hash__(&self, py: pyo3::Python) -> pyo3::PyResult { - self.value(py) - .map(|x| unsafe { pyo3::ffi::PyObject_Hash(x.as_ptr()) }) - } - - /// Shorthand for `Expr(self)` - fn to_expr(&self, py: pyo3::Python) -> pyo3::PyResult { - let mut lock = self.0.lock(); - lock.simple_expr(py).map(super::expression::PyExpr) - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { - let value = slf.value(slf.py())?; - - Ok(ReprFormatter::new_with_pyref(&slf) - .debug("", &value) - .finish()) - } -} diff --git a/src/internal/macro_rules.rs b/src/internal/macro_rules.rs deleted file mode 100644 index b9457bc..0000000 --- a/src/internal/macro_rules.rs +++ /dev/null @@ -1,203 +0,0 @@ -/// Implement python classes using `#[pyo3::pyclass]` macro. -/// -/// Usage: -/// ```rust,no-run -/// implement_pyclass! { -/// [generic] PyMyClass as "MyClass" { field: String } -/// } -/// ``` -#[macro_export] -macro_rules! implement_pyclass { - ( - $(#[$outer:meta])* - [$($pyclass_args:tt)*] $struct_name:ident as $python_name:literal $($rest:tt)* - ) => { - #[ - pyo3::pyclass( - module = "rapidquery._lib", - name = $python_name, - frozen, - immutable_type, - skip_from_py_object, - $($pyclass_args)* - ) - ] - $(#[$outer])* - pub struct $struct_name $($rest)* - }; - - ( - $(#[$outer:meta])* - immutable [$($pyclass_args:tt)*] $struct_name:ident($state_name:ident) as $python_name:literal $($rest:tt)* - ) => { - $(#[$outer])* - pub struct $state_name $($rest)* - - $crate::implement_pyclass! { - $(#[$outer])* - [$($pyclass_args)*] $struct_name as $python_name (pub $crate::internal::uninitialized::ImmutableUninit<$state_name>); - } - - impl $struct_name { - #[inline] - #[must_use] - pub fn uninit() -> Self { - Self( - $crate::internal::uninitialized::ImmutableUninit::uninit() - ) - } - } - - impl From<$state_name> for $struct_name { - fn from(value: $state_name) -> Self { - Self( - $crate::internal::uninitialized::ImmutableUninit::new(value) - ) - } - } - - impl AsRef<$state_name> for $struct_name { - fn as_ref(&self) -> &$state_name { - self.0.as_ref() - } - } - }; - - ( - $(#[$outer:meta])* - mutable [$($pyclass_args:tt)*] $struct_name:ident($state_name:ident) as $python_name:literal $($rest:tt)* - ) => { - $(#[$outer])* - pub struct $state_name $($rest)* - - $crate::implement_pyclass! { - $(#[$outer])* - [$($pyclass_args)*] $struct_name as $python_name (pub $crate::internal::uninitialized::MutableUninit<$state_name>); - } - - impl $struct_name { - #[inline] - #[must_use] - pub fn uninit() -> Self { - Self( - $crate::internal::uninitialized::MutableUninit::uninit() - ) - } - } - - impl From<$state_name> for $struct_name { - fn from(value: $state_name) -> Self { - Self( - $crate::internal::uninitialized::MutableUninit::new(value) - ) - } - } - }; -} - -/// Creates new [`PyErr`] -/// -/// Usage: -/// ```rust,no-run -/// new_raw_error!(PyValueError, "Message") -/// new_raw_error!(PyValueError, "Message {}", arg1) -/// ``` -#[macro_export] -macro_rules! new_py_error { - ($name:ident, $message:expr) => { - ::pyo3::exceptions::$name::new_err($message) - }; - ($name:ident, $message:expr, $($args:tt)*) => { - ::pyo3::exceptions::$name::new_err( - format!($message, $($args)*) - ) - }; -} - -/// Creates new [`Err(PyErr)`] -/// -/// Usage: -/// ```rust,no-run -/// new_error!(PyValueError, "Message") -/// new_error!(PyValueError, "Message {}", arg1) -/// ``` -#[macro_export] -macro_rules! new_error { - ($name:ident, $message:expr) => { - Err($crate::new_py_error!($name, $message)) - }; - ($name:ident, $message:expr, $($args:tt)*) => { - Err($crate::new_py_error!($name, $message, $($args)*)) - }; -} - -#[macro_export] -macro_rules! build_schema_statement { - ($backend:expr, $stmt:expr) => {{ - let builder = $crate::internal::get_schema_builder($backend)?; - let assert_unwind = std::panic::AssertUnwindSafe(|| $stmt.build_any(&*builder)); - - std::panic::catch_unwind(assert_unwind) - .map_err(|_| pyo3::PyErr::new::("build failed")) - }}; -} - -#[macro_export] -macro_rules! build_query_statement { - ($backend:expr, $stmt:expr) => {{ - let builder = $crate::internal::get_query_builder($backend)?; - let mut sql = String::with_capacity(255); - - let assert_unwind = - std::panic::AssertUnwindSafe(|| $stmt.build_collect_any_into(&*builder, &mut sql)); - - std::panic::catch_unwind(assert_unwind) - .map_err(|_| pyo3::PyErr::new::("build failed"))?; - - Ok(sql) - }}; -} - -#[macro_export] -macro_rules! build_query_parts { - ($py:expr, $backend:expr, $stmt:expr) => {{ - let builder = $crate::internal::get_query_builder($backend)?; - - let (placeholder, numbered) = builder.placeholder(); - let mut sql = sea_query::SqlWriterValues::new(placeholder, numbered); - - let assert_unwind = - std::panic::AssertUnwindSafe(|| $stmt.build_collect_any_into(&*builder, &mut sql)); - - std::panic::catch_unwind(assert_unwind) - .map_err(|_| pyo3::PyErr::new::("build failed"))?; - - let (sql, values) = sql.into_parts(); - - let values = { - values - .into_iter() - .map(|x| $crate::common::value::ValueState::from_sea_query_value($py, x)) - .map($crate::common::value::PyValue::from) - }; - - unsafe { - let tuple_ptr = pyo3::ffi::PyTuple_New(values.len() as isize); - if tuple_ptr.is_null() { - return Err(pyo3::PyErr::fetch($py)); - } - - for (index, key) in values.enumerate() { - let key = pyo3::Py::new($py, key).unwrap().into_ptr(); - - if pyo3::ffi::PyTuple_SetItem(tuple_ptr, index as isize, key) == -1 { - pyo3::ffi::Py_XDECREF(tuple_ptr); - pyo3::ffi::Py_XDECREF(key); - return Err(pyo3::PyErr::fetch($py)); - } - } - - Ok((sql, pyo3::Bound::from_owned_ptr($py, tuple_ptr))) - } - }}; -} diff --git a/src/internal/mod.rs b/src/internal/mod.rs deleted file mode 100644 index 08a17f4..0000000 --- a/src/internal/mod.rs +++ /dev/null @@ -1,76 +0,0 @@ -pub mod macro_rules; -pub mod parameters; -pub mod repr; -pub mod type_engine; -pub mod uninitialized; - -/// Returns the type name of a [`pyo3::ffi::PyObject`]. -/// -/// Returns `""` on failure. -#[inline] -pub fn get_type_name<'a>(py: pyo3::Python<'a>, obj: *mut pyo3::ffi::PyObject) -> String { - use pyo3::types::{PyStringMethods, PyTypeMethods}; - - unsafe { - let type_ = pyo3::ffi::Py_TYPE(obj); - - if type_.is_null() { - String::from("") - } else { - let obj = pyo3::types::PyType::from_borrowed_type_ptr(py, type_); - - obj.fully_qualified_name() - .map(|x| x.to_string_lossy().into_owned()) - .unwrap_or_else(|_| String::from("")) - } - } -} - -pub type PyObject = pyo3::Py; -pub type BoundArgs<'a> = &'a pyo3::Bound<'a, pyo3::types::PyTuple>; -pub type BoundKwargs<'a> = &'a pyo3::Bound<'a, pyo3::types::PyDict>; -pub type BoundObject<'a> = pyo3::Bound<'a, pyo3::PyAny>; -pub type RefBoundObject<'a> = &'a pyo3::Bound<'a, pyo3::PyAny>; - -pub trait ToSeaQuery { - /// Convert to sea_query structures. - fn to_sea_query<'a>(&self, py: pyo3::Python<'a>) -> Output; -} - -#[inline(always)] -pub fn get_schema_builder( - name: impl AsRef, -) -> pyo3::PyResult> { - let name = name.as_ref(); - - if name == "sqlite" { - Ok(Box::new(sea_query::SqliteQueryBuilder)) - } else if name == "mysql" { - Ok(Box::new(sea_query::MysqlQueryBuilder)) - } else if name == "postgresql" || name == "postgres" { - Ok(Box::new(sea_query::PostgresQueryBuilder)) - } else { - Err(pyo3::exceptions::PyValueError::new_err(format!( - "invalid backend value, got {name}" - ))) - } -} - -#[inline(always)] -pub fn get_query_builder( - name: impl AsRef, -) -> pyo3::PyResult> { - let name = name.as_ref(); - - if name == "sqlite" { - Ok(Box::new(sea_query::SqliteQueryBuilder)) - } else if name == "mysql" { - Ok(Box::new(sea_query::MysqlQueryBuilder)) - } else if name == "postgresql" || name == "postgres" { - Ok(Box::new(sea_query::PostgresQueryBuilder)) - } else { - Err(pyo3::exceptions::PyValueError::new_err(format!( - "invalid backend value, got {name}" - ))) - } -} diff --git a/src/internal/parameters.rs b/src/internal/parameters.rs deleted file mode 100644 index 4d9bfc4..0000000 --- a/src/internal/parameters.rs +++ /dev/null @@ -1,12 +0,0 @@ -pub enum OptionalParam<'a> { - Undefined, - Defined(pyo3::Bound<'a, pyo3::PyAny>), -} - -impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for OptionalParam<'py> { - type Error = pyo3::PyErr; - - fn extract(obj: pyo3::Borrowed<'a, 'py, pyo3::PyAny>) -> Result { - Ok(Self::Defined(obj.to_owned())) - } -} diff --git a/src/internal/repr.rs b/src/internal/repr.rs deleted file mode 100644 index 892d029..0000000 --- a/src/internal/repr.rs +++ /dev/null @@ -1,309 +0,0 @@ -/// A helper to generate __repr__ methods. -#[derive(Default, Clone, Debug)] -pub struct ReprFormatter { - buf: String, - started: bool, -} - -#[derive(Debug)] -pub struct ReprVec { - key: &'static str, - optional: bool, - items: String, -} - -#[derive(Debug)] -pub struct ReprNestedVec { - index: usize, - items: String, -} - -impl ReprFormatter { - #[inline] - pub fn new(name: impl AsRef) -> Self { - Self { - buf: String::from("<") + name.as_ref(), - started: false, - } - } - - #[inline] - pub fn new_with_pyref(pyref: &pyo3::PyRef<'_, T>) -> Self { - Self::new(crate::internal::get_type_name(pyref.py(), pyref.as_ptr())) - } - - #[inline] - pub fn vec(&self, key: &'static str, optional: bool) -> ReprVec { - ReprVec { - key, - optional, - items: String::new(), - } - } - - pub fn take(&mut self) -> Self { - std::mem::take(self) - } - - pub fn finish(&mut self) -> String { - self.take().buf + ">" - } - - #[inline] - pub fn write(&mut self, part: impl AsRef) -> &mut Self { - if self.started { - self.buf.push(' '); - self.buf.push_str(part.as_ref()); - } else { - self.buf.push(' '); - self.buf.push_str(part.as_ref()); - self.started = true; - } - - self - } - - #[inline] - pub fn pair(&mut self, key: &'static str, value: impl AsRef) -> &mut Self { - if key.is_empty() { - self.write(value) - } else { - self.write(format!("{}={}", key, value.as_ref())) - } - } - - #[inline] - pub fn quote(&mut self, key: &'static str, value: impl AsRef) -> &mut Self { - if key.is_empty() { - self.write(format!("'{}'", value.as_ref())) - } else { - self.write(format!("{}='{}'", key, value.as_ref())) - } - } - - #[inline] - pub fn iden(&mut self, key: &'static str, value: &sea_query::DynIden) -> &mut Self { - self.quote(key, value.to_string()) - } - - #[inline] - pub fn debug(&mut self, key: &'static str, value: impl std::fmt::Debug) -> &mut Self { - self.pair(key, format!("{value:?}")) - } - - #[inline] - pub fn display(&mut self, key: &'static str, value: impl std::fmt::Display) -> &mut Self { - self.pair(key, value.to_string()) - } - - #[inline] - pub fn map(&mut self, key: &'static str, value: T, func: F) -> &mut Self - where - R: AsRef, - F: FnOnce(T) -> R, - { - self.pair(key, func(value)) - } - - #[inline] - pub fn optional_pair( - &mut self, - key: &'static str, - value: Option>, - ) -> &mut Self { - match value { - Some(x) => self.pair(key, x), - None => self, - } - } - - #[inline] - pub fn optional_quote( - &mut self, - key: &'static str, - value: Option>, - ) -> &mut Self { - match value { - Some(x) => self.quote(key, x), - None => self, - } - } - - #[inline] - pub fn optional_iden( - &mut self, - key: &'static str, - value: Option<&sea_query::DynIden>, - ) -> &mut Self { - match value { - Some(x) => self.iden(key, x), - None => self, - } - } - - #[inline] - pub fn optional_debug( - &mut self, - key: &'static str, - value: Option, - ) -> &mut Self { - match value { - Some(x) => self.debug(key, x), - None => self, - } - } - - #[inline] - pub fn optional_display( - &mut self, - key: &'static str, - value: Option, - ) -> &mut Self { - match value { - Some(x) => self.display(key, x), - None => self, - } - } - - #[inline] - pub fn optional_boolean(&mut self, key: &'static str, value: bool) -> &mut Self { - if value { - self.pair(key, "true") - } else { - self - } - } - - #[inline] - pub fn optional_map( - &mut self, - key: &'static str, - value: Option, - func: F, - ) -> &mut Self - where - R: AsRef, - F: FnOnce(T) -> R, - { - match value { - Some(x) => self.map(key, x, func), - None => self, - } - } -} - -impl ReprVec { - #[inline] - pub fn vec(&self, index: usize) -> ReprNestedVec { - ReprNestedVec { - index, - items: String::new(), - } - } - - #[inline] - pub fn push(&mut self, index: usize, item: impl AsRef) -> &mut Self { - if index > 0 { - self.items.push_str(", "); - } - self.items.push_str(item.as_ref()); - self - } - - #[inline] - pub fn quote(&mut self, index: usize, item: impl AsRef) -> &mut Self { - if index > 0 { - self.items.push_str(", "); - } - self.items.push('\''); - self.items.push_str(item.as_ref()); - self.items.push('\''); - self - } - - #[inline] - pub fn quote_iter(&mut self, iterator: I) -> &mut Self - where - D: AsRef, - I: Iterator, - { - for (index, item) in iterator.enumerate() { - self.quote(index, item); - } - self - } - - #[inline] - pub fn display_iter(&mut self, iterator: I) -> &mut Self - where - D: std::fmt::Display, - I: Iterator, - { - for (index, item) in iterator.enumerate() { - self.push(index, item.to_string()); - } - self - } - - pub fn finish(&mut self, fmt: &mut ReprFormatter) { - if self.optional && self.items.is_empty() { - return; - } - - self.items.insert(0, '['); - self.items.push(']'); - fmt.pair(self.key, std::mem::take(&mut self.items)); - } -} - -impl ReprNestedVec { - #[inline] - pub fn push(&mut self, index: usize, item: impl AsRef) -> &mut Self { - if index > 0 { - self.items.push_str(", "); - } - self.items.push_str(item.as_ref()); - self - } - - #[inline] - pub fn quote(&mut self, index: usize, item: impl AsRef) -> &mut Self { - if index > 0 { - self.items.push_str(", "); - } - self.items.push('\''); - self.items.push_str(item.as_ref()); - self.items.push('\''); - self - } - - #[inline] - pub fn quote_iter(&mut self, iterator: I) -> &mut Self - where - D: AsRef, - I: Iterator, - { - for (index, item) in iterator.enumerate() { - self.quote(index, item); - } - self - } - - #[inline] - pub fn display_iter(&mut self, iterator: I) -> &mut Self - where - D: std::fmt::Display, - I: Iterator, - { - for (index, item) in iterator.enumerate() { - self.push(index, item.to_string()); - } - self - } - - pub fn finish(&mut self, fmtvec: &mut ReprVec) { - self.items.insert(0, '['); - self.items.push(']'); - fmtvec.push(self.index, std::mem::take(&mut self.items)); - } -} diff --git a/src/internal/type_engine.rs b/src/internal/type_engine.rs deleted file mode 100644 index 039eab2..0000000 --- a/src/internal/type_engine.rs +++ /dev/null @@ -1,436 +0,0 @@ -use crate::internal::RefBoundObject; -use crate::sqltypes::SQLTypeTrait; - -/// Type engine is an enum which can control validations, serializations, -/// and deserializations of column types. -/// -/// [`SQLTypeTrait`] is implemented for this type. -pub struct TypeEngine( - pub std::sync::Arc, - pub std::ptr::NonNull, -); - -// SAFETY: PyObjects are safe between threads. -unsafe impl Send for TypeEngine {} -unsafe impl Sync for TypeEngine {} - -impl Clone for TypeEngine { - fn clone(&self) -> Self { - unsafe { - pyo3::ffi::Py_INCREF(self.1.as_ptr()); - } - - Self(self.0.clone(), self.1) - } -} - -impl Drop for TypeEngine { - fn drop(&mut self) { - unsafe { - pyo3::ffi::Py_DECREF(self.1.as_ptr()); - } - } -} - -macro_rules! wrap_typeengine { - ($result:expr, $ptr:expr) => { - unsafe { - pyo3::ffi::Py_INCREF($ptr); - Self( - std::sync::Arc::new($result), - std::ptr::NonNull::new_unchecked($ptr), - ) - } - }; - - ($py:expr, $result:expr, $initializer:expr) => { - unsafe { - let object_ptr = pyo3::Py::new($py, ($initializer, crate::sqltypes::PySQLTypeAbstract)) - .unwrap() - .into_ptr(); - - Self( - std::sync::Arc::new($result), - std::ptr::NonNull::new_unchecked(object_ptr), - ) - } - }; -} - -macro_rules! check_native_column_types { - ( - $object:expr, - $type_ptr:expr, - $($type:ty => $constant:ident,)* - ) => { - $( - if $type_ptr == crate::typeref::$constant { - let val = $object.cast_unchecked::<$type>(); - return Ok(wrap_typeengine!(val.get().clone(), $object.as_ptr())); - } - )* - }; -} - -macro_rules! infer_rules { - ( - $py:expr, - $object_ptr:expr, - $type_ptr:expr, - $(($cond:expr) => $result:expr,)* - ) => { - $( - if $cond { - return Ok(wrap_typeengine!($py, $result, $result)); - } - )* - }; -} - -impl TypeEngine { - /// Creates a new [`TypeEngine`]. - pub fn new(object: RefBoundObject<'_>) -> pyo3::PyResult { - unsafe { - let typ = pyo3::ffi::Py_TYPE(object.as_ptr()); - - check_native_column_types!( - object, - typ, - crate::sqltypes::PyBlobType => BLOB_COLUMN_TYPE, - crate::sqltypes::PyBinaryType => BINARY_COLUMN_TYPE, - crate::sqltypes::PyVarBinaryType => VAR_BINARY_COLUMN_TYPE, - crate::sqltypes::PyBitType => BIT_COLUMN_TYPE, - crate::sqltypes::PyVarBitType => VAR_BIT_COLUMN_TYPE, - crate::sqltypes::PyDateTimeType => DATETIME_COLUMN_TYPE, - crate::sqltypes::PyTimestampType => TIMESTAMP_COLUMN_TYPE, - crate::sqltypes::PyTimeType => TIME_COLUMN_TYPE, - crate::sqltypes::PyDateType => DATE_COLUMN_TYPE, - crate::sqltypes::PyJSONType => JSON_COLUMN_TYPE, - crate::sqltypes::PyJSONBinaryType => JSON_BINARY_COLUMN_TYPE, - crate::sqltypes::PyDecimalType => DECIMAL_COLUMN_TYPE, - crate::sqltypes::PyUUIDType => UUID_COLUMN_TYPE, - crate::sqltypes::PyINETType => INET_COLUMN_TYPE, - crate::sqltypes::PyMacAddressType => MAC_ADDRESS_COLUMN_TYPE, - crate::sqltypes::PyBooleanType => BOOLEAN_COLUMN_TYPE, - crate::sqltypes::PyTinyIntegerType => TINY_INTEGER_COLUMN_TYPE, - crate::sqltypes::PySmallIntegerType => SMALL_INTEGER_COLUMN_TYPE, - crate::sqltypes::PyIntegerType => INTEGER_COLUMN_TYPE, - crate::sqltypes::PyBigIntegerType => BIG_INTEGER_COLUMN_TYPE, - crate::sqltypes::PyTinyUnsignedType => TINY_UNSIGNED_COLUMN_TYPE, - crate::sqltypes::PySmallUnsignedType => SMALL_UNSIGNED_COLUMN_TYPE, - crate::sqltypes::PyUnsignedType => UNSIGNED_COLUMN_TYPE, - crate::sqltypes::PyBigUnsignedType => BIG_UNSIGNED_COLUMN_TYPE, - crate::sqltypes::PyFloatType => FLOAT_COLUMN_TYPE, - crate::sqltypes::PyDoubleType => DOUBLE_COLUMN_TYPE, - crate::sqltypes::PyTextType => TEXT_COLUMN_TYPE, - crate::sqltypes::PyCharType => CHAR_COLUMN_TYPE, - crate::sqltypes::PyStringType => STRING_COLUMN_TYPE, - crate::sqltypes::PyVectorType => VECTOR_COLUMN_TYPE, - crate::sqltypes::PyArrayType => ARRAY_COLUMN_TYPE, - crate::sqltypes::PyEnumType => ENUM_COLUMN_TYPE, - ); - - crate::new_error!( - PyTypeError, - "expected SQLTypeAbstract, got {:?}", - crate::internal::get_type_name(object.py(), object.as_ptr()) - ) - } - } - - /// Tries to guess a native column type depends on type of the `object`. - #[cfg_attr(feature = "optimize", optimize(speed))] - pub fn infer_pyobject(object: RefBoundObject<'_>) -> pyo3::PyResult { - unsafe { - let object_ptr = object.as_ptr(); - let object_type_ptr = pyo3::ffi::Py_TYPE(object_ptr); - - if pyo3::ffi::PyLong_CheckExact(object_ptr) == 1 { - return Ok(wrap_typeengine!( - object.py(), - crate::sqltypes::PyBigIntegerType, - crate::sqltypes::PyBigIntegerType - )); - } - - infer_rules!( - object.py(), - object_ptr, - object_type_ptr, - - (pyo3::ffi::Py_IsNone(object_ptr) == 1) => crate::sqltypes::PyBooleanType, - (pyo3::ffi::PyBool_Check(object_ptr) == 1) => crate::sqltypes::PyBooleanType, - (pyo3::ffi::PyFloat_CheckExact(object_ptr) == 1) => crate::sqltypes::PyDoubleType, - (pyo3::ffi::PyUnicode_CheckExact(object_ptr) == 1) => crate::sqltypes::PyTextType, - (pyo3::ffi::PyBytes_CheckExact(object_ptr) == 1) => crate::sqltypes::PyBlobType, - - (object_type_ptr == crate::typeref::STD_DATETIME_TYPE) => crate::sqltypes::PyDateTimeType, - (object_type_ptr == crate::typeref::STD_DATE_TYPE) => crate::sqltypes::PyDateType, - (object_type_ptr == crate::typeref::STD_TIME_TYPE) => crate::sqltypes::PyTimeType, - (object_type_ptr == crate::typeref::STD_DECIMAL_TYPE) => crate::sqltypes::PyDecimalType(None), - (object_type_ptr == crate::typeref::STD_UUID_TYPE) => crate::sqltypes::PyUUIDType, - ); - - if (pyo3::ffi::PyDict_CheckExact(object_ptr) == 1 - || pyo3::ffi::PyList_CheckExact(object_ptr) == 1) - && crate::sqltypes::_validate_json_object(object.py(), object_ptr).is_ok() - { - return Ok(wrap_typeengine!( - object.py(), - crate::sqltypes::PyJSONType, - crate::sqltypes::PyJSONType - )); - } - - crate::new_error!( - PyTypeError, - "Could not infer column/sql type for {:?}", - crate::internal::get_type_name(object.py(), object.as_ptr()) - ) - } - } - - /// Tries to guess a native column type depends on type of the `object`. - #[cfg_attr(feature = "optimize", optimize(speed))] - pub fn infer_value(py: pyo3::Python<'_>, object: &sea_query::Value) -> Self { - match object { - sea_query::Value::Bool(_) => wrap_typeengine!( - py, - crate::sqltypes::PyBooleanType, - crate::sqltypes::PyBooleanType - ), - sea_query::Value::TinyInt(_) => { - wrap_typeengine!( - py, - crate::sqltypes::PyTinyIntegerType, - crate::sqltypes::PyTinyIntegerType - ) - } - sea_query::Value::SmallInt(_) => { - wrap_typeengine!( - py, - crate::sqltypes::PySmallIntegerType, - crate::sqltypes::PySmallIntegerType - ) - } - sea_query::Value::Int(_) => wrap_typeengine!( - py, - crate::sqltypes::PyIntegerType, - crate::sqltypes::PyIntegerType - ), - sea_query::Value::BigInt(_) => wrap_typeengine!( - py, - crate::sqltypes::PyBigIntegerType, - crate::sqltypes::PyBigIntegerType - ), - sea_query::Value::TinyUnsigned(_) => { - wrap_typeengine!( - py, - crate::sqltypes::PyTinyUnsignedType, - crate::sqltypes::PyTinyUnsignedType - ) - } - sea_query::Value::SmallUnsigned(_) => { - wrap_typeengine!( - py, - crate::sqltypes::PySmallUnsignedType, - crate::sqltypes::PySmallUnsignedType - ) - } - sea_query::Value::Unsigned(_) => wrap_typeengine!( - py, - crate::sqltypes::PyUnsignedType, - crate::sqltypes::PyUnsignedType - ), - sea_query::Value::BigUnsigned(_) => { - wrap_typeengine!( - py, - crate::sqltypes::PyBigUnsignedType, - crate::sqltypes::PyBigUnsignedType - ) - } - sea_query::Value::Float(_) => wrap_typeengine!( - py, - crate::sqltypes::PyFloatType, - crate::sqltypes::PyFloatType - ), - sea_query::Value::Double(_) => wrap_typeengine!( - py, - crate::sqltypes::PyDoubleType, - crate::sqltypes::PyDoubleType - ), - sea_query::Value::String(_) => { - wrap_typeengine!( - py, - crate::sqltypes::PyStringType(None), - crate::sqltypes::PyStringType(None) - ) - } - sea_query::Value::Char(_) => wrap_typeengine!( - py, - crate::sqltypes::PyCharType(None), - crate::sqltypes::PyCharType(None) - ), - sea_query::Value::Bytes(_) => { - wrap_typeengine!(py, crate::sqltypes::PyBlobType, crate::sqltypes::PyBlobType) - } - sea_query::Value::Json(_) => { - wrap_typeengine!(py, crate::sqltypes::PyJSONType, crate::sqltypes::PyJSONType) - } - sea_query::Value::ChronoDate(_) => { - wrap_typeengine!(py, crate::sqltypes::PyDateType, crate::sqltypes::PyDateType) - } - sea_query::Value::ChronoTime(_) => { - wrap_typeengine!(py, crate::sqltypes::PyTimeType, crate::sqltypes::PyTimeType) - } - sea_query::Value::ChronoDateTime(_) => { - wrap_typeengine!( - py, - crate::sqltypes::PyDateTimeType, - crate::sqltypes::PyDateTimeType - ) - } - sea_query::Value::ChronoDateTimeUtc(_) => { - wrap_typeengine!( - py, - crate::sqltypes::PyDateTimeType, - crate::sqltypes::PyDateTimeType - ) - } - sea_query::Value::ChronoDateTimeLocal(_) => { - wrap_typeengine!( - py, - crate::sqltypes::PyDateTimeType, - crate::sqltypes::PyDateTimeType - ) - } - sea_query::Value::ChronoDateTimeWithTimeZone(_) => { - wrap_typeengine!( - py, - crate::sqltypes::PyDateTimeType, - crate::sqltypes::PyDateTimeType - ) - } - sea_query::Value::Uuid(_) => { - wrap_typeengine!(py, crate::sqltypes::PyUUIDType, crate::sqltypes::PyUUIDType) - } - sea_query::Value::Decimal(_) => { - wrap_typeengine!( - py, - crate::sqltypes::PyDecimalType(None), - crate::sqltypes::PyDecimalType(None) - ) - } - sea_query::Value::Array(_, Some(nested)) => { - let nested_type_engine = { - if nested.is_empty() { - // Vec is empty so the nested type is not important - wrap_typeengine!( - py, - crate::sqltypes::PyBooleanType, - crate::sqltypes::PyBooleanType - ) - } else { - Self::infer_value(py, &nested[0]) - } - }; - - wrap_typeengine!( - py, - crate::sqltypes::PyArrayType(nested_type_engine), - crate::sqltypes::PyArrayType(nested_type_engine.clone()) - ) - } - sea_query::Value::Array(_, None) => { - // Vec is None so the nested type is not important - let nested_type_engine = wrap_typeengine!( - py, - crate::sqltypes::PyBooleanType, - crate::sqltypes::PyBooleanType - ); - wrap_typeengine!( - py, - crate::sqltypes::PyArrayType(nested_type_engine), - crate::sqltypes::PyArrayType(nested_type_engine.clone()) - ) - } - sea_query::Value::Vector(_) => { - wrap_typeengine!( - py, - crate::sqltypes::PyVectorType(None), - crate::sqltypes::PyVectorType(None) - ) - } - sea_query::Value::IpNetwork(_) => { - wrap_typeengine!(py, crate::sqltypes::PyINETType, crate::sqltypes::PyINETType) - } - sea_query::Value::MacAddress(_) => { - wrap_typeengine!( - py, - crate::sqltypes::PyMacAddressType, - crate::sqltypes::PyMacAddressType - ) - } - } - } - - pub fn as_pyobject<'py>(&self, py: pyo3::Python<'py>) -> pyo3::Bound<'py, pyo3::PyAny> { - unsafe { pyo3::Bound::from_borrowed_ptr(py, self.1.as_ptr()) } - } -} - -impl SQLTypeTrait for TypeEngine { - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - self.0.to_sea_query_column_type() - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - self.0.validate(py, ptr) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - self.0.deserialize(py, value) - } - - fn to_sql_type_name(&self) -> String { - self.0.to_sql_type_name() - } - - unsafe fn serialize( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - self.0.serialize(py, ptr) - } -} - -impl std::fmt::Display for TypeEngine { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - unsafe { - pyo3::Python::attach_unchecked(|py| { - let object = self.as_pyobject(py); - std::fmt::Display::fmt(&object, f) - }) - } - } -} - -impl std::fmt::Debug for TypeEngine { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - unsafe { - pyo3::Python::attach_unchecked(|py| { - let object = self.as_pyobject(py); - std::fmt::Debug::fmt(&object, f) - }) - } - } -} diff --git a/src/internal/uninitialized.rs b/src/internal/uninitialized.rs deleted file mode 100644 index aa796c1..0000000 --- a/src/internal/uninitialized.rs +++ /dev/null @@ -1,189 +0,0 @@ -//! Helpers for creating __init__ methods for types. - -use ::std::cell::UnsafeCell; -use ::std::sync::Arc; - -/// An uninitialized value which can nominally be written to only once. -#[repr(transparent)] -pub struct ImmutableUninit(UnsafeCell>); - -/// An uninitialized Arc-Mutex. -#[repr(transparent)] -pub struct MutableUninit(Arc<::parking_lot::Mutex>>); - -impl ImmutableUninit { - /// Creates a new uninitialized instance. - #[inline] - #[must_use] - pub const fn uninit() -> Self { - Self(UnsafeCell::new(None)) - } - - /// Creates a new initialized instance. - #[inline] - #[must_use] - pub const fn new(val: T) -> Self { - Self(UnsafeCell::new(Some(val))) - } - - pub fn is_initialized(&self) -> bool { - unsafe { - let inner = &*self.0.get(); - inner.is_some() - } - } - - /// Initializes the self by `val`. - /// - /// # Safety - /// - It is Undefined Behavior to call this while any other reference(s) to the wrapped value are alive. - /// - Mutating the wrapped value through other means while the returned reference is alive is Undefined Behavior. - #[inline] - pub unsafe fn set(&self, val: T) { - let _ = (*self.0.get()).replace(val); - } - - /// Gets a reference to the wrapped value. - #[inline] - pub fn get_checked(&self) -> Option<&T> { - unsafe { - let inner = &*self.0.get(); - inner.as_ref() - } - } - - /// Unwraps the value, consuming the [`ImmutableUninit`]. - pub fn into_inner(self) -> Option { - self.0.into_inner() - } -} - -impl AsRef for ImmutableUninit { - /// Gets a reference to the wrapped value. - /// - /// # Panics - /// Panics if you forgot to call [`ImmutableUninit::set()`] - #[inline] - fn as_ref(&self) -> &T { - unsafe { - let inner = &*self.0.get(); - - inner.as_ref().expect( - "Object did not initialized yet. This happens when you forget to call __init__ \ - method. This is a critical issue.", - ) - } - } -} -impl Default for ImmutableUninit { - /// Creates a new uninitialized instance, like [`ImmutableUninit::uninit`] - #[inline] - fn default() -> Self { - Self::uninit() - } -} -impl Clone for ImmutableUninit { - fn clone(&self) -> Self { - let res = Self::uninit(); - - if let Some(x) = self.get_checked() { - unsafe { - res.set(x.clone()); - } - } - - res - } -} -impl ::std::fmt::Debug for ImmutableUninit { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut tuple = f.debug_tuple("ImmutableUninit"); - - match self.get_checked() { - Some(x) => tuple.field(&x).finish(), - None => tuple.field(&"").finish(), - } - } -} -impl From for ImmutableUninit { - fn from(value: T) -> Self { - ImmutableUninit::new(value) - } -} -unsafe impl Send for ImmutableUninit {} -unsafe impl Sync for ImmutableUninit {} - -impl MutableUninit { - /// Creates a new uninitialized instance. - #[inline] - #[must_use] - pub fn uninit() -> Self { - Self(Arc::new(::parking_lot::Mutex::new(None))) - } - - /// Creates a new initialized instance. - #[inline] - #[must_use] - pub fn new(val: T) -> Self { - Self(Arc::new(::parking_lot::Mutex::new(Some(val)))) - } - - pub fn is_initialized(&self) -> bool { - self.0.lock().is_some() - } - - /// Initializes the self by `val`. - #[inline] - pub fn set(&self, val: T) { - let _ = (*self.0.lock()).replace(val); - } - - /// Gets a reference to the wrapped value. - /// - /// # Panics - /// Panics if you forgot to call [`ImmutableUninit::set()`] - pub fn lock(&self) -> parking_lot::MappedMutexGuard<'_, T> { - let inner = self.0.lock(); - assert!( - inner.as_ref().is_some(), - "Object did not initialized yet. This happens when you forget to call __init__ \ - method. This is a critical issue.", - ); - - unsafe { parking_lot::MutexGuard::map(inner, |x| x.as_mut().unwrap_unchecked()) } - } - - /// Unwraps the value, consuming the [`MutableInit`]. - pub fn into_inner(self) -> Arc<::parking_lot::Mutex>> { - self.0 - } -} - -impl Default for MutableUninit { - /// Creates a new uninitialized instance, like [`MutableUninit::uninit`] - #[inline] - fn default() -> Self { - Self::uninit() - } -} -impl Clone for MutableUninit { - fn clone(&self) -> Self { - Self(self.0.clone()) - } -} -impl ::std::fmt::Debug for MutableUninit { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut tuple = f.debug_tuple("MutableUninit"); - - match &*self.0.lock() { - Some(x) => tuple.field(&x).finish(), - None => tuple.field(&"").finish(), - } - } -} -impl From for MutableUninit { - #[inline] - fn from(value: T) -> Self { - Self::new(value) - } -} diff --git a/src/lib.rs b/src/lib.rs index 84f0465..b5551d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,46 +1,2 @@ -#![allow(unused_unsafe)] -#![allow(clippy::macro_metavars_in_unsafe)] -#![allow(clippy::too_many_arguments)] -#![allow(clippy::not_unsafe_ptr_arg_deref)] -#![warn(clippy::print_stdout)] -#![warn(clippy::print_stderr)] -#![warn(clippy::dbg_macro)] -#![feature(optimize_attribute)] -#![feature(once_cell_try)] - -pub mod internal; -mod typeref; - -mod common; -mod mysql; -mod postgres; -mod query; -mod schema; -mod sqlite; -mod sqltypes; - -/// RapidQuery core which is written in Rust. #[pyo3::pymodule(gil_used = false)] -mod _lib { - #[pymodule_export] - use super::common::common_module; - #[pymodule_export] - use super::mysql::mysql_module; - #[pymodule_export] - use super::postgres::postgres_module; - #[pymodule_export] - use super::query::query_module; - #[pymodule_export] - use super::schema::schema_module; - #[pymodule_export] - use super::sqlite::sqlite_module; - #[pymodule_export] - use super::sqltypes::sqltypes_module; - - #[pymodule_init] - #[cold] - fn init(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> { - crate::typeref::initialize_typeref(m.py()); - Ok(()) - } -} +mod _native {} diff --git a/src/mysql.rs b/src/mysql.rs deleted file mode 100644 index 2a2e0fc..0000000 --- a/src/mysql.rs +++ /dev/null @@ -1,5 +0,0 @@ -/// MySQL-Only functions -/// -/// **Comming Soon ...** -#[pyo3::pymodule(name = "mysql")] -pub mod mysql_module {} diff --git a/src/postgres.rs b/src/postgres.rs deleted file mode 100644 index 16e8dea..0000000 --- a/src/postgres.rs +++ /dev/null @@ -1,5 +0,0 @@ -/// Postgres-Only functions -/// -/// **Comming Soon ...** -#[pyo3::pymodule(name = "postgres")] -pub mod postgres_module {} diff --git a/src/query/base.rs b/src/query/base.rs deleted file mode 100644 index 4c8d2fa..0000000 --- a/src/query/base.rs +++ /dev/null @@ -1,42 +0,0 @@ -use crate::internal::{BoundArgs, BoundKwargs, BoundObject}; - -crate::implement_pyclass! { - /// Subclass of query statements. - #[derive(Debug, Clone, Copy)] - [subclass] PyQueryStatement as "QueryStatement"; -} - -#[pyo3::pymethods] -impl PyQueryStatement { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> Self { - Self - } - - fn __init__(&self) {} - - /// Build a SQL string representation. - /// - /// **This method is unsafe and can cause SQL injection.** use `.build()` method instead. - #[pyo3(signature = (backend, /))] - #[allow(unused_variables)] - #[allow(clippy::wrong_self_convention)] - fn to_sql(&self, py: pyo3::Python<'_>, backend: String) -> pyo3::PyResult { - Err(pyo3::exceptions::PyNotImplementedError::new_err(())) - } - - /// Build the SQL statement with parameter values. - /// - /// @signature (self, backend: _BackendName, /) -> tuple[str, tuple[Value, ...]] - #[pyo3(signature = (backend, /))] - #[allow(unused_variables)] - fn build<'a>( - &self, - py: pyo3::Python<'a>, - backend: String, - ) -> pyo3::PyResult<(String, BoundObject<'a>)> { - Err(pyo3::exceptions::PyNotImplementedError::new_err(())) - } -} diff --git a/src/query/case.rs b/src/query/case.rs deleted file mode 100644 index 106b38d..0000000 --- a/src/query/case.rs +++ /dev/null @@ -1,136 +0,0 @@ -use crate::common::expression::PyExpr; -use crate::internal::repr::ReprFormatter; -use crate::internal::{BoundArgs, BoundKwargs, RefBoundObject, ToSeaQuery}; - -crate::implement_pyclass! { - mutable [subclass] PyCaseStatement(CaseStatementState) as "CaseStatement" { - pub when: Vec<(PyExpr, PyExpr)>, - pub r#else: Option, - } -} - -impl Clone for CaseStatementState { - fn clone(&self) -> Self { - Self { - when: self.when.clone(), - r#else: self.r#else.clone(), - } - } -} - -impl ToSeaQuery for CaseStatementState { - #[cfg_attr(feature = "optimize", optimize(speed))] - fn to_sea_query<'a>(&self, _py: pyo3::Python<'a>) -> sea_query::CaseStatement { - let mut stmt = sea_query::CaseStatement::new(); - - for (x, y) in self.when.iter() { - stmt = stmt.case(x.0.clone(), y.0.clone()); - } - - if let Some(x) = &self.r#else { - stmt = stmt.finally(x.0.clone()); - } - - stmt - } -} - -#[pyo3::pymethods] -impl PyCaseStatement { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> Self { - Self::uninit() - } - - pub fn __init__(&self) -> pyo3::PyResult<()> { - self.0.set(CaseStatementState { - when: vec![], - r#else: None, - }); - Ok(()) - } - - fn when<'a>( - slf: pyo3::PyRef<'a, Self>, - condition: RefBoundObject<'a>, - result: RefBoundObject<'a>, - ) -> pyo3::PyResult> { - unsafe { - if pyo3::ffi::Py_TYPE(condition.as_ptr()) != crate::typeref::EXPR_TYPE { - return crate::new_error!( - PyTypeError, - "expected Expr as condition, got {}", - crate::internal::get_type_name(condition.py(), condition.as_ptr()) - ); - } - - let condition = condition.cast_unchecked::().get().clone(); - let result = PyExpr::try_from(result)?; - - slf.0.lock().when.push((condition, result)); - } - - Ok(slf) - } - - fn else_<'a>( - slf: pyo3::PyRef<'a, Self>, - result: RefBoundObject<'a>, - ) -> pyo3::PyResult> { - let result = PyExpr::try_from(result)?; - slf.0.lock().r#else = Some(result); - - Ok(slf) - } - - /// Shorthand for `Expr(self)` - fn to_expr(&self, py: pyo3::Python) -> PyExpr { - let stmt = self.0.lock().to_sea_query(py); - PyExpr(sea_query::SimpleExpr::Case(Box::new(stmt))) - } - - /// Shorthand for `SelectLabel(self, alias, window)` - #[pyo3(signature=(alias, window=None))] - fn label( - &self, - py: pyo3::Python, - alias: String, - window: Option>, - ) -> pyo3::PyResult { - let window = match window { - Some(x) => Some(crate::query::select::SelectLabelWindow::try_from(x)?), - None => None, - }; - let expr = self.to_expr(py); - - let state = crate::query::select::SelectLabelState { - expr, - alias: Some(alias), - window, - }; - Ok(state.into()) - } - - fn __copy__(&self) -> Self { - let lock = self.0.lock(); - lock.clone().into() - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let lock = slf.0.lock(); - let mut fmt = ReprFormatter::new_with_pyref(&slf); - - fmt.vec("when", false) - .display_iter( - lock.when - .iter() - .map(|x| format!("{} => {}", x.0.__repr__(), x.1.__repr__())), - ) - .finish(&mut fmt); - - fmt.optional_map("else", lock.r#else.as_ref(), |x| x.__repr__()) - .finish() - } -} diff --git a/src/query/delete.rs b/src/query/delete.rs deleted file mode 100644 index 5663c7a..0000000 --- a/src/query/delete.rs +++ /dev/null @@ -1,220 +0,0 @@ -use super::base::PyQueryStatement; -use super::ordering::PyOrdering; -use super::returning::PyReturning; -use crate::common::expression::PyExpr; -use crate::common::table_ref::PyTableName; -use crate::internal::repr::ReprFormatter; -use crate::internal::{BoundArgs, BoundKwargs, BoundObject, RefBoundObject, ToSeaQuery}; - -crate::implement_pyclass! { - /// Builds DELETE SQL statements with a fluent interface. - /// - /// Provides a chainable API for constructing DELETE queries with support for: - /// - WHERE conditions for filtering - /// - LIMIT for restricting deletion count - /// - ORDER BY for determining deletion order - /// - RETURNING clauses for getting deleted data - #[derive(Clone)] - mutable [subclass, extends=PyQueryStatement] PyDeleteStatement(DeleteStatementState) as "DeleteStatement" { - pub table: PyTableName, - pub r#where: Option, - pub limit: Option, - pub returning_clause: Option, - pub orders: Vec, - } -} - -impl ToSeaQuery for DeleteStatementState { - #[cfg_attr(feature = "optimize", optimize(speed))] - fn to_sea_query<'a>(&self, py: pyo3::Python<'a>) -> sea_query::DeleteStatement { - let mut stmt = sea_query::DeleteStatement::new(); - stmt.from_table(self.table.clone()); - - if let Some(x) = &self.r#where { - stmt.and_where(x.0.clone()); - } - if let Some(x) = self.limit { - stmt.limit(x); - } - - if let Some(x) = &self.returning_clause { - stmt.returning(x.0.to_sea_query(py)); - } - - for order in self.orders.iter() { - if let Some(x) = order.null_order { - stmt.order_by_expr_with_nulls(order.target.0.clone(), order.order.clone(), x); - } else { - stmt.order_by_expr(order.target.0.clone(), order.order.clone()); - } - } - - stmt - } -} - -#[pyo3::pymethods] -impl PyDeleteStatement { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> (Self, PyQueryStatement) { - (Self::uninit(), PyQueryStatement) - } - - pub fn __init__(&self, table: RefBoundObject<'_>) -> pyo3::PyResult<()> { - let table = PyTableName::try_from(table)?; - - let state = DeleteStatementState { - table, - r#where: None, - limit: None, - returning_clause: None, - orders: vec![], - }; - self.0.set(state); - Ok(()) - } - - /// Specify the table to delete from. - #[allow(clippy::wrong_self_convention)] - fn from_table<'a>( - slf: pyo3::PyRef<'a, Self>, - table: RefBoundObject<'a>, - ) -> pyo3::PyResult> { - let table = PyTableName::try_from(table)?; - - { - let mut lock = slf.0.lock(); - lock.table = table; - } - Ok(slf) - } - - /// Limit the number of rows to delete. - fn limit(slf: pyo3::PyRef<'_, Self>, n: u64) -> pyo3::PyRef<'_, Self> { - { - let mut lock = slf.0.lock(); - lock.limit = Some(n); - } - - slf - } - - /// Specify columns to return from the inserted rows. - #[pyo3(signature=(clause))] - fn returning<'a>( - slf: pyo3::PyRef<'a, Self>, - clause: pyo3::Bound<'_, PyReturning>, - ) -> pyo3::PyResult> { - { - let mut lock = slf.0.lock(); - lock.returning_clause = Some(clause.get().clone()); - } - Ok(slf) - } - - /// Add a WHERE condition to filter rows to delete. - fn r#where<'a>( - slf: pyo3::PyRef<'a, Self>, - condition: RefBoundObject<'a>, - ) -> pyo3::PyResult> { - unsafe { - if pyo3::ffi::Py_TYPE(condition.as_ptr()) != crate::typeref::EXPR_TYPE { - return crate::new_error!( - PyTypeError, - "expected Expr, got {}", - crate::internal::get_type_name(condition.py(), condition.as_ptr()) - ); - } - - let condition = condition.cast_unchecked::().get().clone(); - let mut lock = slf.0.lock(); - - match std::mem::take(&mut lock.r#where) { - None => { - lock.r#where = Some(condition); - } - Some(x) => { - lock.r#where = Some(PyExpr(x.0.and(condition.0))); - } - } - } - - Ok(slf) - } - - /// Remove where conditions from statement. - fn clear_where(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyRef<'_, Self> { - slf.0.lock().r#where = None; - slf - } - - /// Specify the order in which to delete rows. Typically used with - /// `.limit` method to delete specific rows. - #[pyo3(signature=(clause))] - fn order_by<'a>( - slf: pyo3::PyRef<'a, Self>, - clause: pyo3::Bound<'_, PyOrdering>, - ) -> pyo3::PyResult> { - { - let mut lock = slf.0.lock(); - lock.orders.push(clause.get().clone()); - } - - Ok(slf) - } - - /// Remove orders from statement. - fn clear_order_by(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyRef<'_, Self> { - slf.0.lock().orders.clear(); - slf - } - - #[pyo3(signature = (backend, /))] - #[allow(clippy::wrong_self_convention)] - fn to_sql(&self, py: pyo3::Python<'_>, backend: String) -> pyo3::PyResult { - let lock = self.0.lock(); - let stmt = lock.to_sea_query(py); - drop(lock); - - crate::build_query_statement!(backend, stmt) - } - - #[pyo3(signature = (backend, /))] - fn build<'a>( - &self, - py: pyo3::Python<'a>, - backend: String, - ) -> pyo3::PyResult<(String, BoundObject<'a>)> { - let lock = self.0.lock(); - let stmt = lock.to_sea_query(py); - drop(lock); - - crate::build_query_parts!(py, backend, stmt) - } - - fn __copy__<'a>(&self, py: pyo3::Python<'a>) -> pyo3::PyResult> { - let lock = self.0.lock(); - pyo3::Bound::new(py, (lock.clone().into(), PyQueryStatement)) - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let lock = slf.0.lock(); - - let mut fmt = ReprFormatter::new_with_pyref(&slf) - .map("table", &lock.table, |x| x.__repr__()) - .optional_map("where", lock.r#where.as_ref(), |x| x.__repr__()) - .optional_display("limit", lock.limit.as_ref()) - .take(); - - fmt.vec("orders", true) - .display_iter(lock.orders.iter().map(|x| x.__repr__())) - .finish(&mut fmt); - - fmt.optional_map("returning", lock.returning_clause.as_ref(), |x| { - x.__repr__() - }) - .finish() - } -} diff --git a/src/query/insert.rs b/src/query/insert.rs deleted file mode 100644 index 8f7bb82..0000000 --- a/src/query/insert.rs +++ /dev/null @@ -1,463 +0,0 @@ -use super::base::PyQueryStatement; -use super::on_conflict::PyOnConflict; -use super::returning::PyReturning; -use crate::common::column_ref::PyColumnRef; -use crate::common::expression::PyExpr; -use crate::common::table_ref::PyTableName; -use crate::internal::repr::ReprFormatter; -use crate::internal::{BoundArgs, BoundKwargs, BoundObject, PyObject, RefBoundObject, ToSeaQuery}; - -#[derive(Debug, Default)] -pub enum InsertValueSource { - #[default] - None, - Single(Vec), - Many(Vec>), - Select( - /// Always is `PySelectStatement` - PyObject, - ), -} - -crate::implement_pyclass! { - /// Builds INSERT SQL statements with a fluent interface. - /// - /// Provides a chainable API for constructing INSERT queries with support for: - /// - Single or multiple row insertion - /// - Conflict resolution (UPSERT) - /// - RETURNING clauses - /// - REPLACE functionality - /// - Default values - mutable [subclass, extends=PyQueryStatement] PyInsertStatement(InsertStatementState) as "InsertStatement" { - pub replace: bool, - pub table: PyTableName, - pub columns: Vec, - pub source: InsertValueSource, - - /// Always is `Option`. - pub on_conflict: Option, - pub returning_clause: Option, - pub default_values: Option, - } -} - -impl InsertValueSource { - fn clone_ref(&self, py: pyo3::Python) -> Self { - match self { - Self::Single(x) => Self::Single(x.clone()), - Self::Many(x) => Self::Many(x.clone()), - Self::Select(x) => Self::Select(x.clone_ref(py)), - Self::None => Self::None, - } - } -} - -impl InsertStatementState { - fn clone_ref(&self, py: pyo3::Python) -> Self { - Self { - replace: self.replace, - table: self.table.clone(), - columns: self.columns.clone(), - source: self.source.clone_ref(py), - on_conflict: self.on_conflict.as_ref().map(|x| x.clone_ref(py)), - returning_clause: self.returning_clause.clone(), - default_values: self.default_values, - } - } -} - -impl ToSeaQuery for InsertStatementState { - #[cfg_attr(feature = "optimize", optimize(speed))] - fn to_sea_query<'a>(&self, py: pyo3::Python<'a>) -> sea_query::InsertStatement { - let mut stmt = sea_query::InsertStatement::new(); - stmt.into_table(self.table.clone()); - - if self.replace { - stmt.replace(); - } - stmt.columns(self.columns.iter().map(sea_query::Alias::new)); - - match &self.source { - InsertValueSource::None => (), - InsertValueSource::Single(x) => { - stmt.values(x.iter().map(|x| x.0.clone())).unwrap(); - } - InsertValueSource::Many(x) => { - for y in x.iter() { - stmt.values(y.iter().map(|x| x.0.clone())).unwrap(); - } - } - InsertValueSource::Select(subquery) => unsafe { - let subquery = - subquery.cast_bound_unchecked::(py); - - stmt.select_from(subquery.get().0.lock().to_sea_query(py)) - .unwrap(); - }, - } - - if let Some(x) = &self.on_conflict { - let py_oc = unsafe { x.cast_bound_unchecked::(py) }; - stmt.on_conflict(py_oc.get().0.lock().to_sea_query(py)); - } - if let Some(rows) = self.default_values { - stmt.or_default_values_many(rows); - } - if let Some(x) = &self.returning_clause { - stmt.returning(x.0.to_sea_query(py)); - } - - stmt - } -} - -impl InsertStatementState { - #[inline] - fn values_from_dictionary( - &mut self, - kwds: pyo3::Bound<'_, pyo3::types::PyDict>, - ) -> pyo3::PyResult<()> { - use pyo3::types::{PyAnyMethods, PyDictMethods}; - - if !self.columns.is_empty() && self.columns.len() != kwds.len() { - return Err(pyo3::PyErr::new::( - "values length isn't equal to columns length - this occurres when you're calling \ - `.values()` method multiple times with different columns.", - )); - } - - let mut cols = Vec::with_capacity(kwds.len()); - let mut vals = Vec::with_capacity(kwds.len()); - - unsafe { - for (key, value) in kwds.into_iter() { - let key = key.extract::().unwrap_unchecked(); - - cols.push(key); - vals.push(PyExpr::try_from(&value)?); - } - } - - match std::mem::take(&mut self.source) { - InsertValueSource::None | InsertValueSource::Select(_) => { - self.source = InsertValueSource::Single(vals); - } - InsertValueSource::Single(oldvals) => { - self.source = InsertValueSource::Many(vec![oldvals, vals]); - } - InsertValueSource::Many(mut arr_of_vals) => { - arr_of_vals.push(vals); - self.source = InsertValueSource::Many(arr_of_vals); - } - } - self.columns = cols; - - Ok(()) - } - - #[inline] - fn values_from_tuple(&mut self, args: BoundArgs<'_>) -> pyo3::PyResult<()> { - use pyo3::types::PyTupleMethods; - - if !self.columns.is_empty() && self.columns.len() != args.len() { - return Err(pyo3::PyErr::new::( - "values length isn't equal to columns length", - )); - } - - let mut vals = Vec::with_capacity(args.len()); - for value in args.iter() { - vals.push(PyExpr::try_from(&value)?); - } - - match std::mem::take(&mut self.source) { - InsertValueSource::None | InsertValueSource::Select(_) => { - self.source = InsertValueSource::Single(vals); - } - InsertValueSource::Single(oldvals) => { - self.source = InsertValueSource::Many(vec![oldvals, vals]); - } - InsertValueSource::Many(mut arr_of_vals) => { - arr_of_vals.push(vals); - self.source = InsertValueSource::Many(arr_of_vals); - } - } - - Ok(()) - } -} - -#[pyo3::pymethods] -impl PyInsertStatement { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> (Self, PyQueryStatement) { - (Self::uninit(), PyQueryStatement) - } - - pub fn __init__(&self, table: RefBoundObject<'_>) -> pyo3::PyResult<()> { - let table = PyTableName::try_from(table)?; - - let state = InsertStatementState { - replace: false, - table, - columns: vec![], - source: InsertValueSource::None, - on_conflict: None, - returning_clause: None, - default_values: None, - }; - self.0.set(state); - Ok(()) - } - - /// Convert this INSERT to a REPLACE statement. - /// - /// REPLACE will delete existing rows that conflict with the new row - /// before inserting. - fn replace(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyRef<'_, Self> { - { - let mut lock = slf.0.lock(); - lock.replace = true; - } - - slf - } - - /// Specify the target table for insertion. - fn into<'a>( - slf: pyo3::PyRef<'a, Self>, - table: RefBoundObject<'a>, - ) -> pyo3::PyResult> { - let table = PyTableName::try_from(table)?; - - { - let mut lock = slf.0.lock(); - lock.table = table; - } - Ok(slf) - } - - /// Specify the columns for insertion. - /// - /// There's no need to use this method when you're specifying column - /// names in `.values` method. - #[pyo3(signature=(*args))] - fn columns<'a>( - slf: pyo3::PyRef<'a, Self>, - args: BoundArgs<'a>, - ) -> pyo3::PyResult> { - use pyo3::types::PyTupleMethods; - - let mut columns = Vec::with_capacity(args.len()); - - for col in args.iter() { - let column_ref = PyColumnRef::try_from(&col)?; - - match column_ref.name { - Some(x) => columns.push(x.to_string()), - None => { - return Err(pyo3::exceptions::PyValueError::new_err( - "Insert.columns cannot accept asterisk '*'", - )) - } - } - } - - { - let mut lock = slf.0.lock(); - lock.columns = columns; - } - Ok(slf) - } - - /// Specify values to insert. Also you can specify columns using keyword arguments. - #[pyo3(signature=(*args, **kwds))] - fn values<'a>( - slf: pyo3::PyRef<'a, Self>, - args: BoundArgs<'a>, - kwds: Option>, - ) -> pyo3::PyResult> { - use pyo3::types::PyTupleMethods; - - if !PyTupleMethods::is_empty(args) && kwds.is_some() { - return crate::new_error!( - PyTypeError, - "cannot use both args and kwargs at the same time" - ); - } - - if let Some(kwds) = kwds { - let mut lock = slf.0.lock(); - lock.values_from_dictionary(kwds.clone())?; - } else { - let mut lock = slf.0.lock(); - lock.values_from_tuple(args)?; - } - - Ok(slf) - } - - /// Specify a select query whose values to be inserted. - fn select_from<'a>( - slf: pyo3::PyRef<'a, Self>, - statement: BoundObject<'a>, - ) -> pyo3::PyResult> { - unsafe { - if pyo3::ffi::PyObject_TypeCheck( - statement.as_ptr(), - crate::typeref::SELECT_STATEMENT_TYPE, - ) == 0 - { - return crate::new_error!( - PyTypeError, - "expected SelectStatement, got {}", - crate::internal::get_type_name(statement.py(), statement.as_ptr()) - ); - } - - // We have to return error if columns mismatch, like what SeaQuery does - let cast = statement.cast_unchecked::(); - - let val_len = cast.get().0.lock().exprs.len(); - let col_len = slf.0.lock().columns.len(); - - if col_len != val_len { - return crate::new_error!( - PyValueError, - "columns and values length mismatch: {} != {}", - col_len, - val_len, - ); - } - } - - { - let mut lock = slf.0.lock(); - lock.source = InsertValueSource::Select(statement.unbind()); - } - Ok(slf) - } - - /// Use DEFAULT VALUES if no values were specified. The `rows` - /// Specifies number of rows to insert with default values. - #[pyo3(signature=(rows=1))] - fn or_default_values(slf: pyo3::PyRef<'_, Self>, rows: u32) -> pyo3::PyRef<'_, Self> { - { - let mut lock = slf.0.lock(); - lock.default_values = Some(rows); - } - - slf - } - - /// Specify conflict resolution behavior (UPSERT). - fn on_conflict<'a>( - slf: pyo3::PyRef<'a, Self>, - action: RefBoundObject<'a>, - ) -> pyo3::PyResult> { - unsafe { - if pyo3::ffi::PyObject_TypeCheck(action.as_ptr(), crate::typeref::ON_CONFLICT_TYPE) == 0 - { - return crate::new_error!( - PyTypeError, - "expected OnConflict, got {}", - crate::internal::get_type_name(action.py(), action.as_ptr()) - ); - } - - let mut lock = slf.0.lock(); - lock.on_conflict = Some(action.clone().unbind()); - } - - Ok(slf) - } - - /// Specify columns to return from the inserted rows. - #[pyo3(signature=(clause))] - fn returning<'a>( - slf: pyo3::PyRef<'a, Self>, - clause: pyo3::Bound<'_, PyReturning>, - ) -> pyo3::PyResult> { - { - let mut lock = slf.0.lock(); - lock.returning_clause = Some(clause.get().clone()); - } - Ok(slf) - } - - #[pyo3(signature = (backend, /))] - #[allow(clippy::wrong_self_convention)] - fn to_sql(&self, py: pyo3::Python<'_>, backend: String) -> pyo3::PyResult { - let lock = self.0.lock(); - let stmt = lock.to_sea_query(py); - drop(lock); - - crate::build_query_statement!(backend, stmt) - } - - #[pyo3(signature = (backend, /))] - fn build<'a>( - &self, - py: pyo3::Python<'a>, - backend: String, - ) -> pyo3::PyResult<(String, BoundObject<'a>)> { - let lock = self.0.lock(); - let stmt = lock.to_sea_query(py); - drop(lock); - - crate::build_query_parts!(py, backend, stmt) - } - - fn __copy__<'a>(&self, py: pyo3::Python<'a>) -> pyo3::PyResult> { - let lock = self.0.lock(); - pyo3::Bound::new(py, (lock.clone_ref(py).into(), PyQueryStatement)) - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let lock = slf.0.lock(); - - let mut fmt = ReprFormatter::new_with_pyref(&slf) - .optional_boolean("replace", lock.replace) - .map("into", &lock.table, |x| x.__repr__()) - .take(); - - fmt.vec("columns", true) - .quote_iter(lock.columns.iter()) - .finish(&mut fmt); - - fmt.optional_display("on_conflict", lock.on_conflict.as_ref()); - - match &lock.source { - InsertValueSource::None => { - fmt.optional_display("default_rows", lock.default_values); - } - InsertValueSource::Select(x) => { - fmt.display("select_from", x); - } - InsertValueSource::Single(x) => { - fmt.vec("values", true) - .display_iter(x.iter().map(|x| x.__repr__())) - .finish(&mut fmt); - } - InsertValueSource::Many(x) => { - let mut fmtvec = fmt.vec("values", true); - - for (index, nested) in x.iter().enumerate() { - fmtvec - .vec(index) - .display_iter(nested.iter().map(|x| x.__repr__())) - .finish(&mut fmtvec); - } - - fmtvec.finish(&mut fmt); - } - } - - fmt.optional_map("returning", lock.returning_clause.as_ref(), |x| { - x.__repr__() - }) - .finish() - } -} diff --git a/src/query/mod.rs b/src/query/mod.rs deleted file mode 100644 index deccbbf..0000000 --- a/src/query/mod.rs +++ /dev/null @@ -1,56 +0,0 @@ -pub mod base; -pub mod case; -pub mod delete; -pub mod insert; -pub mod on_conflict; -pub mod ordering; -pub mod returning; -pub mod select; -pub mod update; -pub mod window; -pub mod with; - -#[pyo3::pymodule(name = "query")] -pub mod query_module { - #[pymodule_export] - use super::base::PyQueryStatement; - - #[pymodule_export] - use super::on_conflict::PyOnConflict; - - #[pymodule_export] - use super::returning::PyReturning; - - #[pymodule_export] - use super::insert::PyInsertStatement; - - #[pymodule_export] - use super::ordering::PyOrdering; - - #[pymodule_export] - use super::delete::PyDeleteStatement; - - #[pymodule_export] - use super::update::PyUpdateStatement; - - #[pymodule_export] - use super::window::PyFrame; - - #[pymodule_export] - use super::window::PyWindowStatement; - - #[pymodule_export] - use super::select::PySelectLabel; - - #[pymodule_export] - use super::select::PySelectStatement; - - #[pymodule_export] - use super::case::PyCaseStatement; - - #[pymodule_export] - use super::with::PyWithClause; - - #[pymodule_export] - use super::with::PyWithQuery; -} diff --git a/src/query/on_conflict.rs b/src/query/on_conflict.rs deleted file mode 100644 index 4354599..0000000 --- a/src/query/on_conflict.rs +++ /dev/null @@ -1,326 +0,0 @@ -use pyo3::types::{PyAnyMethods, PyDictMethods, PyTupleMethods}; -use sea_query::IntoIden; - -use crate::common::column_ref::PyColumnRef; -use crate::common::expression::PyExpr; -use crate::internal::repr::ReprFormatter; -use crate::internal::{BoundArgs, BoundKwargs, RefBoundObject, ToSeaQuery}; - -#[derive(Debug, Clone)] -pub enum OnConflictUpdate { - Column(sea_query::DynIden), - Expr(sea_query::DynIden, PyExpr), -} - -#[derive(Debug, Clone, Default)] -pub enum OnConflictAction { - #[default] - None, - DoNothing(Vec), - DoUpdate(Vec), -} - -crate::implement_pyclass! { - /// Specifies conflict resolution behavior for INSERT statements. - /// - /// Handles situations where an INSERT would violate a unique constraint - /// or primary key. - /// - /// This corresponds to INSERT ... ON CONFLICT in PostgreSQL and - /// INSERT ... ON DUPLICATE KEY UPDATE in MySQL. - #[derive(Debug, Clone)] - mutable [subclass] PyOnConflict(OnConflictState) as "OnConflict" { - targets: Vec, - action: OnConflictAction, - target_where: Option, - action_where: Option, - } -} - -impl ToSeaQuery for OnConflictState { - #[cfg_attr(feature = "optimize", optimize(speed))] - fn to_sea_query<'a>(&self, _py: pyo3::Python<'a>) -> sea_query::OnConflict { - let mut stmt = sea_query::OnConflict::columns(self.targets.clone()); - - match &self.action { - OnConflictAction::None => (), - OnConflictAction::DoNothing(x) => { - if x.is_empty() { - stmt.do_nothing(); - } else { - stmt.do_nothing_on(x.iter().cloned()); - } - } - OnConflictAction::DoUpdate(x) => { - let mut columns = Vec::new(); - let mut exprs = Vec::new(); - - for val in x.iter() { - match val { - OnConflictUpdate::Column(name) => columns.push(name.clone()), - OnConflictUpdate::Expr(name, expr) => { - exprs.push((name.clone(), expr.0.clone())); - } - } - } - - stmt.update_columns(columns); - stmt.values(exprs); - } - } - - if let Some(x) = &self.action_where { - stmt.action_and_where(x.0.clone()); - } - if let Some(x) = &self.target_where { - stmt.target_and_where(x.0.clone()); - } - - stmt - } -} - -impl OnConflictState { - #[inline] - fn update_from_dictionary( - &mut self, - kwds: pyo3::Bound<'_, pyo3::types::PyDict>, - ) -> pyo3::PyResult<()> { - let mut actions = Vec::with_capacity(kwds.len()); - - for (key, val) in kwds.iter() { - unsafe { - let val = PyExpr::try_from(&val)?; - - let action = OnConflictUpdate::Expr( - sea_query::Alias::new(key.extract::().unwrap_unchecked()).into_iden(), - val, - ); - actions.push(action); - } - } - - match &mut self.action { - OnConflictAction::DoNothing(_) | OnConflictAction::None => { - self.action = OnConflictAction::DoUpdate(actions); - } - OnConflictAction::DoUpdate(x) => { - x.append(&mut actions); - } - } - - Ok(()) - } - - #[inline] - fn update_from_tuple(&mut self, args: BoundArgs<'_>) -> pyo3::PyResult<()> { - let mut actions = Vec::with_capacity(args.len()); - - for key in args.iter() { - let column_ref = PyColumnRef::try_from(&key)?; - match column_ref.name { - Some(x) => actions.push(OnConflictUpdate::Column(x)), - None => { - return Err(pyo3::exceptions::PyValueError::new_err( - "OnConflict cannot accept asterisk '*' as column", - )) - } - } - } - - match &mut self.action { - OnConflictAction::DoNothing(_) | OnConflictAction::None => { - self.action = OnConflictAction::DoUpdate(actions); - } - OnConflictAction::DoUpdate(x) => { - x.append(&mut actions); - } - } - - Ok(()) - } -} - -#[pyo3::pymethods] -impl PyOnConflict { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> Self { - Self::uninit() - } - - #[pyo3(signature=(*targets))] - fn __init__(&self, targets: BoundArgs<'_>) -> pyo3::PyResult<()> { - if targets.is_empty() { - let state = OnConflictState { - targets: vec![], - action: OnConflictAction::None, - target_where: None, - action_where: None, - }; - - self.0.set(state); - return Ok(()); - } - - let mut normalized_targets = Vec::with_capacity(targets.len()); - for item in targets.iter() { - let column_ref = PyColumnRef::try_from(&item)?; - match column_ref.name { - Some(x) => normalized_targets.push(x), - None => { - return Err(pyo3::exceptions::PyValueError::new_err( - "OnConflict cannot accept asterisk '*' as target", - )) - } - } - } - - let state = OnConflictState { - targets: normalized_targets, - action: OnConflictAction::None, - target_where: None, - action_where: None, - }; - self.0.set(state); - Ok(()) - } - - /// Specify DO NOTHING action for conflicts. - /// - /// When a conflict occurs, the conflicting row will be skipped. - /// - /// `keys` parameter provides primary keys if you are using MySQL, for MySQL specific polyfill. - #[pyo3(signature=(*keys))] - fn do_nothing<'a>( - slf: pyo3::PyRef<'a, Self>, - keys: &pyo3::Bound<'a, pyo3::types::PyTuple>, - ) -> pyo3::PyResult> { - if keys.is_empty() { - let mut lock = slf.0.lock(); - lock.action = OnConflictAction::DoNothing(vec![]); - drop(lock); - - return Ok(slf); - } - - let mut normalized_keys: Vec = Vec::with_capacity(keys.len()); - for item in keys.iter() { - let column_ref = PyColumnRef::try_from(&item)?; - match column_ref.name { - Some(x) => normalized_keys.push(x), - None => { - return Err(pyo3::exceptions::PyValueError::new_err( - "OnConflict cannot accept asterisk '*' as column", - )) - } - } - } - - { - let mut lock = slf.0.lock(); - - match &mut lock.action { - OnConflictAction::DoUpdate(_) | OnConflictAction::None => { - lock.action = OnConflictAction::DoNothing(normalized_keys); - } - OnConflictAction::DoNothing(x) => { - x.append(&mut normalized_keys); - } - } - } - - Ok(slf) - } - - /// Specify DO UPDATE action for conflicts using column names, or with explicit values. - #[pyo3(signature=(*args, **kwds))] - fn do_update<'a>( - slf: pyo3::PyRef<'a, Self>, - args: BoundArgs<'a>, - kwds: Option>, - ) -> pyo3::PyResult> { - if !PyTupleMethods::is_empty(args) { - let mut lock = slf.0.lock(); - lock.update_from_tuple(args)?; - } - if let Some(kwds) = kwds { - let mut lock = slf.0.lock(); - lock.update_from_dictionary(kwds.clone())?; - } - - Ok(slf) - } - - /// Add a WHERE clause to the conflict target (partial unique index). - fn target_where<'a>( - slf: pyo3::PyRef<'a, Self>, - condition: RefBoundObject<'a>, - ) -> pyo3::PyResult> { - unsafe { - if pyo3::ffi::Py_TYPE(condition.as_ptr()) != crate::typeref::EXPR_TYPE { - return crate::new_error!( - PyTypeError, - "expected Expr, got {}", - crate::internal::get_type_name(slf.py(), condition.as_ptr()) - ); - } - - let mut lock = slf.0.lock(); - lock.target_where = Some(condition.cast_unchecked::().get().clone()); - } - - Ok(slf) - } - - /// Add a WHERE clause to the conflict action (conditional update). - fn action_where<'a>( - slf: pyo3::PyRef<'a, Self>, - condition: RefBoundObject<'a>, - ) -> pyo3::PyResult> { - unsafe { - if pyo3::ffi::Py_TYPE(condition.as_ptr()) != crate::typeref::EXPR_TYPE { - return crate::new_error!( - PyTypeError, - "expected Expr, got {}", - crate::internal::get_type_name(slf.py(), condition.as_ptr()) - ); - } - - let mut lock = slf.0.lock(); - lock.action_where = Some(condition.cast_unchecked::().get().clone()); - } - - Ok(slf) - } - - fn __copy__(&self) -> Self { - let lock = self.0.lock(); - lock.clone().into() - } - - pub fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let lock = slf.0.lock(); - - let mut fmt = ReprFormatter::new_with_pyref(&slf); - - fmt.vec("targets", true) - .quote_iter(lock.targets.iter().map(|x| x.to_string())) - .finish(&mut fmt); - - match &lock.action { - OnConflictAction::DoNothing(_) => { - fmt.quote("action", "DO NOTHING"); - } - OnConflictAction::DoUpdate(_) => { - fmt.quote("action", "DO UPDATE"); - } - OnConflictAction::None => (), - } - - fmt.optional_map("target_where", lock.target_where.as_ref(), |x| x.__repr__()) - .optional_map("action_where", lock.action_where.as_ref(), |x| x.__repr__()) - .finish() - } -} diff --git a/src/query/ordering.rs b/src/query/ordering.rs deleted file mode 100644 index e15d891..0000000 --- a/src/query/ordering.rs +++ /dev/null @@ -1,114 +0,0 @@ -use sea_query::IntoColumnRef; - -use crate::common::column_ref::PyColumnRef; -use crate::common::expression::PyExpr; -use crate::internal::repr::ReprFormatter; -use crate::internal::BoundObject; - -#[inline] -fn map_order_to_str(order: &sea_query::Order) -> String { - match order { - sea_query::Order::Asc => String::from("ASC"), - sea_query::Order::Desc => String::from("DESC"), - _ => unsafe { std::hint::unreachable_unchecked() }, - } -} - -#[inline] -fn map_null_ordering_to_str(order: Option) -> Option { - match order { - Some(sea_query::NullOrdering::First) => Some(String::from("FIRST")), - Some(sea_query::NullOrdering::Last) => Some(String::from("LAST")), - None => None, - } -} - -crate::implement_pyclass! { - // NOTE: It's a very simple clause, so I think it's OK to be a final type. - - /// Specifies ordering behavior for UPDATE, DELETE, and SELECT statements. - #[derive(Debug, Clone)] - [] PyOrdering as "Ordering" { - pub target: PyExpr, - pub order: sea_query::Order, - pub null_order: Option, - } -} - -#[pyo3::pymethods] -impl PyOrdering { - #[new] - #[pyo3(signature=(target, order = String::from("ASC"), null_order=None))] - fn __new__( - target: BoundObject<'_>, - order: String, - null_order: Option, - ) -> pyo3::PyResult { - let target = unsafe { - if pyo3::ffi::Py_TYPE(target.as_ptr()) == crate::typeref::EXPR_TYPE { - target.cast_unchecked::().get().clone() - } else { - let column_ref = PyColumnRef::try_from(&target)?; - - PyExpr(sea_query::SimpleExpr::Column(column_ref.into_column_ref())) - } - }; - - let order = match order.to_ascii_lowercase().as_str() { - "asc" => sea_query::Order::Asc, - "desc" => sea_query::Order::Desc, - _ => { - return Err(pyo3::exceptions::PyValueError::new_err(format!( - "unknown order: {order}" - ))) - } - }; - - let null_order = match null_order { - None => None, - Some(x) => match x.to_ascii_lowercase().as_str() { - "first" => Some(sea_query::NullOrdering::First), - "last" => Some(sea_query::NullOrdering::Last), - _ => { - return Err(pyo3::exceptions::PyValueError::new_err(format!( - "unknown null_order: {x}" - ))) - } - }, - }; - - Ok(Self { - target, - order, - null_order, - }) - } - - /// Target expression. - #[getter] - fn target(&self) -> PyExpr { - self.target.clone() - } - - #[getter] - fn order(&self) -> String { - map_order_to_str(&self.order) - } - - #[getter] - fn null_order(&self) -> Option { - map_null_ordering_to_str(self.null_order) - } - - fn __copy__(&self) -> Self { - self.clone() - } - - pub fn __repr__(&self) -> String { - ReprFormatter::new("Ordering") - .map("target", &self.target, |x| x.__repr__()) - .map("order", &self.order, map_order_to_str) - .optional_quote("null_ordering", map_null_ordering_to_str(self.null_order)) - .finish() - } -} diff --git a/src/query/returning.rs b/src/query/returning.rs deleted file mode 100644 index 5871af5..0000000 --- a/src/query/returning.rs +++ /dev/null @@ -1,92 +0,0 @@ -use pyo3::types::PyTupleMethods; -use sea_query::IntoColumnRef; - -use crate::common::column_ref::PyColumnRef; -use crate::common::expression::PyExpr; -use crate::internal::repr::ReprFormatter; -use crate::internal::{BoundArgs, ToSeaQuery}; - -#[derive(Debug, Clone)] -pub enum ReturningState { - All, - Exprs(Vec), -} - -crate::implement_pyclass! { - // NOTE: It's a very simple clause, so I think it's OK to be a final type. - - /// RETURNING clause. - /// - /// Works on PostgreSQL and SQLite>=3.35.0. - #[derive(Debug, Clone)] - [] PyReturning as "Returning" (pub ReturningState); -} - -impl ToSeaQuery for ReturningState { - #[cfg_attr(feature = "optimize", optimize(speed))] - fn to_sea_query<'a>(&self, _py: pyo3::Python<'a>) -> sea_query::ReturningClause { - match self { - Self::All => sea_query::ReturningClause::All, - Self::Exprs(x) => { - sea_query::ReturningClause::Exprs(x.iter().map(|x| x.0.clone()).collect()) - } - } - } -} - -#[pyo3::pymethods] -impl PyReturning { - /// Specify columns you need to return - #[new] - #[pyo3(signature=(*args))] - pub fn __new__(args: BoundArgs<'_>) -> pyo3::PyResult { - let mut columns = Vec::with_capacity(args.len()); - - for col in args.iter() { - unsafe { - if pyo3::ffi::Py_TYPE(col.as_ptr()) == crate::typeref::EXPR_TYPE { - let value = col.cast_into_unchecked::(); - - columns.push(value.get().clone()); - continue; - } - } - - let column_ref = PyColumnRef::try_from(&col)?; - if column_ref.name.is_none() { - return Ok(Self(ReturningState::All)); - } - - columns.push(PyExpr(column_ref.into_column_ref().into())); - } - - Ok(Self(ReturningState::Exprs(columns))) - } - - /// Return all columns. Same as `self.columns("*")`. - #[classmethod] - fn all(_cls: &pyo3::Bound<'_, pyo3::types::PyType>) -> Self { - Self(ReturningState::All) - } - - fn __copy__(&self) -> Self { - self.clone() - } - - pub fn __repr__(&self) -> String { - let mut fmt = ReprFormatter::new("Returning"); - - match &self.0 { - ReturningState::All => { - fmt.pair("", "*"); - } - ReturningState::Exprs(x) => { - fmt.vec("", false) - .display_iter(x.iter().map(|x| x.__repr__())) - .finish(&mut fmt); - } - } - - fmt.finish() - } -} diff --git a/src/query/select.rs b/src/query/select.rs deleted file mode 100644 index f2dc887..0000000 --- a/src/query/select.rs +++ /dev/null @@ -1,1241 +0,0 @@ -use super::base::PyQueryStatement; -use super::ordering::PyOrdering; -use crate::common::column_ref::PyColumnRef; -use crate::common::expression::{PyExpr, PyFunc}; -use crate::common::table_ref::PyTableName; -use crate::internal::repr::ReprFormatter; -use crate::internal::{BoundArgs, BoundKwargs, BoundObject, PyObject, RefBoundObject, ToSeaQuery}; -use crate::query::window::PyWindowStatement; - -use pyo3::types::{PyAnyMethods, PyTupleMethods}; -use sea_query::{IntoColumnRef, IntoIden}; - -/// Window type in [`PySelectLabel`] -pub enum SelectLabelWindow { - Name(sea_query::DynIden), - Query( - /// Always is `PyWindowStatement` - PyObject, - ), -} - -/// Select references in [`PySelectStatement`] -/// -/// It exactly does what [`sea_query::TableRef`] does -pub enum SelectReference { - SubQuery( - /// Always is `PySelectStatement` - PyObject, - sea_query::DynIden, - ), - Func(PyFunc, sea_query::DynIden), - TableName(PyTableName), -} - -/// Distinct mode in [`PySelectStatement`] -#[derive(Debug, Clone, Default)] -pub enum DistinctMode { - #[default] - None, - Distinct, - DistinctOn(Vec), -} - -/// Lock mode and options in [`PySelectStatement`] -#[derive(Debug, Clone)] -pub struct LockMode { - pub r#type: sea_query::LockType, - pub behavior: Option, - pub tables: Vec, -} - -/// Join mode and options in [`PySelectStatement`] -pub struct JoinMode { - pub r#type: sea_query::JoinType, - pub reference: SelectReference, - pub on: Option, - pub lateral: bool, -} - -crate::implement_pyclass! { - /// Represents a column expression with an optional alias in a SELECT statement. - /// - /// Used to specify both the expression to select and an optional alias name - /// for the result column. - immutable [subclass] PySelectLabel(SelectLabelState) as "SelectLabel" { - pub expr: PyExpr, - pub alias: Option, - pub window: Option, - } -} -crate::implement_pyclass! { - /// Builds SELECT SQL statements with a fluent interface. - /// - /// Provides a chainable API for constructing SELECT queries with support for: - /// - Column selection with expressions and aliases - /// - Table and subquery sources - /// - Filtering with WHERE and HAVING - /// - Joins (inner, left, right, full, cross, lateral) - /// - Grouping and aggregation - /// - Ordering and pagination - /// - Set operations (UNION, EXCEPT, INTERSECT) - /// - Row locking for transactions - /// - DISTINCT queries - mutable [subclass, extends=PyQueryStatement] PySelectStatement(SelectStatementState) as "SelectStatement" { - pub references: Vec, - - /// Always is `Vec` - pub exprs: Vec, - - pub r#where: Option, - pub groups: Vec, - - pub having: Option, - pub orders: Vec, - pub distinct: DistinctMode, - pub joins: Vec, - pub lock: Option, - pub limit: Option, - pub offset: Option, - - /// Always is `Option<(_, PyWindowStatement)>` - pub window: Option<(sea_query::DynIden, PyObject)>, - - /// Always is `Option<(_, PySelectStatement)>` - pub unions: Vec<(sea_query::UnionType, PyObject)>, - - // TODO - // pub table_sample: Option, - // pub index_hint: Option, - } -} - -#[allow(clippy::derivable_impls)] -impl Default for SelectStatementState { - fn default() -> Self { - Self { - references: Default::default(), - exprs: Default::default(), - r#where: Default::default(), - groups: Default::default(), - unions: Default::default(), - having: Default::default(), - orders: Default::default(), - distinct: Default::default(), - joins: Default::default(), - lock: Default::default(), - limit: Default::default(), - offset: Default::default(), - window: Default::default(), - } - } -} - -impl SelectLabelState { - fn clone_ref(&self, py: pyo3::Python) -> Self { - Self { - expr: self.expr.clone(), - alias: self.alias.clone(), - window: self.window.as_ref().map(|x| match x { - SelectLabelWindow::Name(x) => SelectLabelWindow::Name(x.clone()), - SelectLabelWindow::Query(x) => SelectLabelWindow::Query(x.clone_ref(py)), - }), - } - } -} - -impl SelectReference { - fn clone_ref(&self, py: pyo3::Python) -> Self { - match self { - Self::Func(x, y) => Self::Func(x.clone(), y.clone()), - Self::TableName(x) => Self::TableName(x.clone()), - Self::SubQuery(x, y) => Self::SubQuery(x.clone_ref(py), y.clone()), - } - } -} - -impl JoinMode { - fn clone_ref(&self, py: pyo3::Python) -> Self { - Self { - r#type: self.r#type, - reference: self.reference.clone_ref(py), - on: self.on.clone(), - lateral: self.lateral, - } - } -} - -impl SelectStatementState { - fn clone_ref(&self, py: pyo3::Python) -> Self { - Self { - references: self.references.iter().map(|x| x.clone_ref(py)).collect(), - exprs: self.exprs.iter().map(|x| x.clone_ref(py)).collect(), - r#where: self.r#where.clone(), - groups: self.groups.clone(), - having: self.having.clone(), - orders: self.orders.clone(), - distinct: self.distinct.clone(), - joins: self.joins.iter().map(|x| x.clone_ref(py)).collect(), - lock: self.lock.clone(), - limit: self.limit, - offset: self.offset, - window: self - .window - .as_ref() - .map(|(x, y)| (x.clone(), y.clone_ref(py))), - unions: self - .unions - .iter() - .map(|(x, y)| (*x, y.clone_ref(py))) - .collect(), - } - } -} - -impl TryFrom> for SelectLabelWindow { - type Error = pyo3::PyErr; - - fn try_from(value: RefBoundObject<'_>) -> Result { - unsafe { - // Window statement - if pyo3::ffi::PyObject_TypeCheck(value.as_ptr(), crate::typeref::WINDOW_STATEMENT_TYPE) - == 1 - { - Ok(Self::Query(value.clone().unbind())) - } - // String - else if pyo3::ffi::PyUnicode_CheckExact(value.as_ptr()) == 1 { - let window_name = value.extract::().unwrap_unchecked(); - Ok(Self::Name(sea_query::Alias::new(window_name).into_iden())) - } - // Other types - else { - crate::new_error!( - PyTypeError, - "expected WindowStatement or str for SelectLabel window, got {}", - crate::internal::get_type_name(value.py(), value.as_ptr()) - ) - } - } - } -} - -#[inline] -pub fn cast_into_select_label<'a>(value: BoundObject<'a>) -> pyo3::PyResult> { - unsafe { - // SelectLabel itself - if pyo3::ffi::PyObject_TypeCheck(value.as_ptr(), crate::typeref::SELECT_LABEL_TYPE) == 1 { - return Ok(value); - } - - let state = SelectLabelState { - expr: PyExpr::try_from(&value)?, - alias: None, - window: None, - }; - let result: PySelectLabel = state.into(); - pyo3::Bound::new(value.py(), result).map(|x| x.into_any()) - } -} - -#[inline] -fn map_str_to_join_type(value: Option) -> pyo3::PyResult { - match value.map(|x| x.to_ascii_uppercase()) { - Some(x) => match x.as_str() { - "CROSS" => Ok(sea_query::JoinType::CrossJoin), - "FULL" => Ok(sea_query::JoinType::FullOuterJoin), - "INNER" => Ok(sea_query::JoinType::InnerJoin), - "LEFT" => Ok(sea_query::JoinType::LeftJoin), - "RIGHT" => Ok(sea_query::JoinType::RightJoin), - _ => { - crate::new_error!( - PyValueError, - "acceptable join types are 'CROSS', 'FULL', 'INNER', 'RIGHT', and 'LEFT'. got \ - {}", - x - ) - } - }, - None => Ok(sea_query::JoinType::Join), - } -} - -impl SelectReference { - fn from_table(value: RefBoundObject<'_>) -> pyo3::PyResult { - let table = PyTableName::try_from(value)?; - Ok(Self::TableName(table)) - } - - fn from_subquery(value: RefBoundObject<'_>, alias: String) -> pyo3::PyResult { - unsafe { - if pyo3::ffi::PyObject_TypeCheck(value.as_ptr(), crate::typeref::SELECT_STATEMENT_TYPE) - == 0 - { - return crate::new_error!( - PyValueError, - "expected SelectStatement, got {}", - crate::internal::get_type_name(value.py(), value.as_ptr()) - ); - } - } - let alias = sea_query::Alias::new(alias).into_iden(); - - Ok(Self::SubQuery(value.clone().unbind(), alias)) - } - - fn from_function(value: RefBoundObject<'_>, alias: String) -> pyo3::PyResult { - let function = unsafe { - let type_ptr = pyo3::ffi::Py_TYPE(value.as_ptr()); - - // Func type - if type_ptr == crate::typeref::FUNC_TYPE { - value.cast_unchecked::().get().clone() - } - // Expr type - else if type_ptr == crate::typeref::EXPR_TYPE { - let casted_expr = value.cast_unchecked::(); - let get_casted_expr = casted_expr.get(); - - if let sea_query::SimpleExpr::FunctionCall(x) = &get_casted_expr.0 { - PyFunc(x.clone()) - } else { - return crate::new_error!(PyValueError, "given Expr is not a function call"); - } - } - // Other types - else { - return crate::new_error!( - PyValueError, - "expected Func or Expr, got {}", - crate::internal::get_type_name(value.py(), value.as_ptr()) - ); - } - }; - let alias = sea_query::Alias::new(alias).into_iden(); - - Ok(Self::Func(function, alias)) - } -} - -impl std::fmt::Display for SelectReference { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::TableName(x) => write!(f, "{}", x.__repr__()), - Self::Func(x, alias) => write!(f, "({}, {})", x.__repr__(), alias.to_string()), - Self::SubQuery(x, alias) => write!(f, "({}, {})", x, alias.to_string()), - } - } -} - -impl std::fmt::Display for JoinMode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("(")?; - - match self.r#type { - sea_query::JoinType::Join => (), - sea_query::JoinType::CrossJoin => write!(f, "CROSS, ")?, - sea_query::JoinType::FullOuterJoin => write!(f, "FULL, ")?, - sea_query::JoinType::InnerJoin => write!(f, "INNER, ")?, - sea_query::JoinType::LeftJoin => write!(f, "LEFT, ")?, - sea_query::JoinType::RightJoin => write!(f, "RIGHT, ")?, - } - - if let Some(on) = &self.on { - write!(f, "{}, {}", self.reference, on.__repr__())?; - } else { - write!(f, "{}", self.reference)?; - } - - if self.lateral { - write!(f, ", LATERAL")?; - } - - f.write_str(")") - } -} - -impl std::fmt::Display for LockMode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "(")?; - match self.r#type { - sea_query::LockType::KeyShare => write!(f, "KEY SHARE")?, - sea_query::LockType::NoKeyUpdate => write!(f, "NO KEY UPDATE")?, - sea_query::LockType::Share => write!(f, "SHARE")?, - sea_query::LockType::Update => write!(f, "UPDATE")?, - } - - if let Some(x) = self.behavior { - match x { - sea_query::LockBehavior::Nowait => write!(f, ", NOWAIT")?, - sea_query::LockBehavior::SkipLocked => write!(f, ", SKIP")?, - } - } - - if self.tables.is_empty() { - return write!(f, ")"); - } - - write!(f, ", [")?; - for (index, item) in self.tables.iter().enumerate() { - if index == 0 { - write!(f, "{}", item.__repr__())?; - } else { - write!(f, ", {}", item.__repr__())?; - } - } - write!(f, "])") - } -} - -impl ToSeaQuery for SelectLabelWindow { - fn to_sea_query<'a>(&self, py: pyo3::Python<'a>) -> sea_query::WindowSelectType { - match self { - Self::Name(x) => sea_query::WindowSelectType::Name(x.clone()), - SelectLabelWindow::Query(x) => { - let statement = unsafe { x.cast_bound_unchecked::(py) }; - let lock = statement.get().0.lock(); - - sea_query::WindowSelectType::Query(lock.to_sea_query(py)) - } - } - } -} - -impl ToSeaQuery for SelectLabelState { - fn to_sea_query<'a>(&self, py: pyo3::Python<'a>) -> sea_query::SelectExpr { - if self.alias.is_none() { - // If expr is column, setting alias can optimize the SQL query. - let default_alias = { - match &self.expr.0 { - sea_query::SimpleExpr::Column(sea_query::ColumnRef::Column(name)) => { - Some(name.clone()) - } - sea_query::SimpleExpr::Column(sea_query::ColumnRef::TableColumn(_, name)) => { - Some(name.clone()) - } - sea_query::SimpleExpr::Column(sea_query::ColumnRef::SchemaTableColumn( - _, - _, - name, - )) => Some(name.clone()), - _ => None, - } - }; - - sea_query::SelectExpr { - expr: self.expr.0.clone(), - alias: default_alias, - window: self.window.as_ref().map(|x| x.to_sea_query(py)), - } - } else { - sea_query::SelectExpr { - expr: self.expr.0.clone(), - alias: self - .alias - .as_ref() - .map(|x| sea_query::Alias::new(x).into_iden()), - - window: self.window.as_ref().map(|x| x.to_sea_query(py)), - } - } - } -} - -impl ToSeaQuery for SelectStatementState { - #[cfg_attr(feature = "optimize", optimize(speed))] - fn to_sea_query<'a>(&self, py: pyo3::Python<'a>) -> sea_query::SelectStatement { - let mut stmt = sea_query::SelectStatement::new(); - - // Distinct mode - match &self.distinct { - DistinctMode::None => (), - DistinctMode::Distinct => { - stmt.distinct(); - } - DistinctMode::DistinctOn(cols) => { - stmt.distinct_on(cols.clone()); - } - } - - // References - for table in self.references.iter() { - match table { - SelectReference::TableName(x) => unsafe { - stmt.from(x.clone()); - }, - SelectReference::Func(x, alias) => unsafe { - stmt.from_function(x.0.clone(), alias.clone()); - }, - SelectReference::SubQuery(x, alias) => unsafe { - let x = unsafe { x.cast_bound_unchecked::(py) }; - let lock = x.get().0.lock(); - - stmt.from_subquery(lock.to_sea_query(py), alias.clone()); - }, - } - } - - // Columns - stmt.exprs(self.exprs.iter().map(|x| unsafe { - let expr = x.cast_bound_unchecked::(py); - expr.get().0.as_ref().to_sea_query(py) - })); - - // Groups - stmt.add_group_by(self.groups.iter().map(|x| x.0.clone())); - - // Condition - stmt.and_where_option(self.r#where.as_ref().map(|x| x.0.clone())); - - // Having - if let Some(x) = &self.having { - stmt.and_having(x.0.clone()); - } - - // Limit & Offset - if let Some(n) = self.limit { - stmt.limit(n); - } - if let Some(n) = self.offset { - stmt.offset(n); - } - - // Orders - for order in self.orders.iter() { - if let Some(x) = order.null_order { - stmt.order_by_expr_with_nulls(order.target.0.clone(), order.order.clone(), x); - } else { - stmt.order_by_expr(order.target.0.clone(), order.order.clone()); - } - } - - // Lock mode - if let Some(lock) = &self.lock { - match (lock.behavior, lock.tables.is_empty()) { - (Some(behavior), false) => { - stmt.lock_with_tables_behavior( - lock.r#type, - lock.tables.iter().cloned(), - behavior, - ); - } - (Some(behavior), true) => { - stmt.lock_with_behavior(lock.r#type, behavior); - } - (None, false) => { - stmt.lock_with_tables(lock.r#type, lock.tables.iter().cloned()); - } - (None, true) => { - stmt.lock(lock.r#type); - } - } - } - - // Unions - stmt.unions(self.unions.iter().map(|(union_type, union_stmt)| { - let union_stmt = unsafe { union_stmt.cast_bound_unchecked::(py) }; - let union_lock = union_stmt.get().0.lock(); - - (*union_type, union_lock.to_sea_query(py)) - })); - - // Joins - for join in self.joins.iter() { - let condition = - sea_query::Condition::all().add_option(join.on.as_ref().map(|x| x.0.clone())); - - match (&join.reference, join.lateral) { - (SelectReference::TableName(x), _) => { - stmt.join(join.r#type, x.clone(), condition); - } - (SelectReference::Func(func, alias), _) => { - stmt.join( - join.r#type, - sea_query::TableRef::FunctionCall(func.0.clone(), alias.clone()), - condition, - ); - } - (SelectReference::SubQuery(subquery, alias), true) => { - let subquery_stmt = - unsafe { subquery.cast_bound_unchecked::(py) }; - let subquery_lock = subquery_stmt.get().0.lock(); - - stmt.join_lateral( - join.r#type, - subquery_lock.to_sea_query(py), - alias.clone(), - condition, - ); - } - (SelectReference::SubQuery(subquery, alias), false) => { - let subquery_stmt = - unsafe { subquery.cast_bound_unchecked::(py) }; - let subquery_lock = subquery_stmt.get().0.lock(); - - stmt.join_subquery( - join.r#type, - subquery_lock.to_sea_query(py), - alias.clone(), - condition, - ); - } - } - } - - // Window - if let Some((window_name, window)) = &self.window { - let window = unsafe { window.cast_bound_unchecked::(py) }; - let lock = window.get().0.lock(); - - stmt.window(window_name.clone(), lock.to_sea_query(py)); - } - - stmt - } -} - -#[pyo3::pymethods] -impl PySelectLabel { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> Self { - Self::uninit() - } - - #[pyo3(signature=(expr, alias=None, window=None))] - fn __init__( - &self, - expr: RefBoundObject<'_>, - alias: Option, - window: Option>, - ) -> pyo3::PyResult<()> { - let window = match window { - Some(x) => Some(SelectLabelWindow::try_from(x)?), - None => None, - }; - let expr = PyExpr::try_from(expr)?; - - let state = SelectLabelState { - expr, - alias, - window, - }; - unsafe { - self.0.set(state); - } - Ok(()) - } - - #[getter] - fn expr(&self) -> PyExpr { - self.0.as_ref().expr.clone() - } - - #[getter] - fn alias(&self) -> Option { - self.0.as_ref().alias.clone() - } - - #[getter] - fn window<'a>(&self, py: pyo3::Python<'a>) -> Option> { - use pyo3::IntoPyObjectExt; - - let inner = self.0.as_ref(); - - match &inner.window { - Some(ref select_window) => match select_window { - SelectLabelWindow::Name(name) => { - Some(name.to_string().into_bound_py_any(py).unwrap()) - } - SelectLabelWindow::Query(w) => Some(w.bind(py).clone()), - }, - None => None, - } - } - - fn __copy__(&self, py: pyo3::Python<'_>) -> Self { - let lock = self.0.as_ref(); - lock.clone_ref(py).into() - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let inner = slf.0.as_ref(); - - let mut fmt = ReprFormatter::new_with_pyref(&slf) - .map("expr", &inner.expr, |x| x.__repr__()) - .optional_quote("alias", inner.alias.as_ref()) - .take(); - - match &inner.window { - Some(SelectLabelWindow::Name(x)) => { - fmt.iden("window", x); - } - Some(SelectLabelWindow::Query(x)) => { - fmt.display("window", x); - } - None => {} - } - - fmt.finish() - } -} - -#[pyo3::pymethods] -impl PySelectStatement { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> (Self, PyQueryStatement) { - (Self::uninit(), PyQueryStatement) - } - - #[pyo3(signature=(*exprs))] - pub fn __init__(&self, exprs: BoundArgs<'_>) -> pyo3::PyResult<()> { - let mut casted = Vec::new(); - for item in exprs.iter() { - casted.push(cast_into_select_label(item)?.unbind()); - } - - let state = SelectStatementState { - exprs: casted, - ..Default::default() - }; - self.0.set(state); - Ok(()) - } - - #[pyo3(signature=(*on))] - fn distinct<'a>( - slf: pyo3::PyRef<'a, Self>, - on: BoundArgs<'a>, - ) -> pyo3::PyResult> { - if on.is_empty() { - // Distinct mode without specific column - slf.0.lock().distinct = DistinctMode::Distinct; - } else { - let mut cols = Vec::new(); - - for item in on.iter() { - cols.push(PyColumnRef::try_from(&item)?); - } - - slf.0.lock().distinct = DistinctMode::DistinctOn(cols); - } - - Ok(slf) - } - - #[pyo3(signature=(*args))] - fn columns<'a>( - slf: pyo3::PyRef<'a, Self>, - args: BoundArgs<'a>, - ) -> pyo3::PyResult> { - let mut casted = Vec::new(); - for item in args.iter() { - let column_ref = PyColumnRef::try_from(&item)?; - - let state = SelectLabelState { - expr: PyExpr(sea_query::SimpleExpr::Column(column_ref.into_column_ref())), - alias: None, - window: None, - }; - let result: PySelectLabel = state.into(); - - casted.push(pyo3::Py::new(slf.py(), result)?.into_any()); - } - - slf.0.lock().exprs.append(&mut casted); - Ok(slf) - } - - #[pyo3(signature=(*args))] - fn exprs<'a>( - slf: pyo3::PyRef<'a, Self>, - args: BoundArgs<'a>, - ) -> pyo3::PyResult> { - let mut casted = Vec::new(); - for item in args.iter() { - casted.push(cast_into_select_label(item)?.unbind()); - } - - slf.0.lock().exprs.append(&mut casted); - Ok(slf) - } - - #[allow(clippy::wrong_self_convention)] - fn from_table<'a>( - slf: pyo3::PyRef<'a, Self>, - table: RefBoundObject<'a>, - ) -> pyo3::PyResult> { - let reference = SelectReference::from_table(table)?; - - slf.0.lock().references.push(reference); - Ok(slf) - } - - #[allow(clippy::wrong_self_convention)] - fn from_subquery<'a>( - slf: pyo3::PyRef<'a, Self>, - subquery: RefBoundObject<'a>, - alias: String, - ) -> pyo3::PyResult> { - if subquery.as_ptr() == slf.as_ptr() { - return crate::new_error!(PyValueError, "select statement cannot select from itself!"); - } - - let reference = SelectReference::from_subquery(subquery, alias)?; - - slf.0.lock().references.push(reference); - Ok(slf) - } - - #[allow(clippy::wrong_self_convention)] - fn from_function<'a>( - slf: pyo3::PyRef<'a, Self>, - function: RefBoundObject<'a>, - alias: String, - ) -> pyo3::PyResult> { - let reference = SelectReference::from_function(function, alias)?; - - slf.0.lock().references.push(reference); - Ok(slf) - } - - fn limit(slf: pyo3::PyRef<'_, Self>, n: u64) -> pyo3::PyRef<'_, Self> { - slf.0.lock().limit = Some(n); - slf - } - - fn offset(slf: pyo3::PyRef<'_, Self>, n: u64) -> pyo3::PyRef<'_, Self> { - slf.0.lock().offset = Some(n); - slf - } - - fn r#where<'a>( - slf: pyo3::PyRef<'a, Self>, - condition: RefBoundObject<'a>, - ) -> pyo3::PyResult> { - unsafe { - if pyo3::ffi::Py_TYPE(condition.as_ptr()) != crate::typeref::EXPR_TYPE { - return crate::new_error!( - PyTypeError, - "expected Expr, got {}", - crate::internal::get_type_name(condition.py(), condition.as_ptr()) - ); - } - - let condition = condition.cast_unchecked::().get().clone(); - let mut lock = slf.0.lock(); - - match std::mem::take(&mut lock.r#where) { - None => { - lock.r#where = Some(condition); - } - Some(x) => { - lock.r#where = Some(PyExpr(x.0.and(condition.0))); - } - } - } - - Ok(slf) - } - - /// Remove where conditions from statement. - fn clear_where(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyRef<'_, Self> { - slf.0.lock().r#where = None; - slf - } - - /// Specify the order in which to delete rows. - #[pyo3(signature=(clause))] - fn order_by<'a>( - slf: pyo3::PyRef<'a, Self>, - clause: pyo3::Bound<'_, PyOrdering>, - ) -> pyo3::PyResult> { - { - let mut lock = slf.0.lock(); - lock.orders.push(clause.get().clone()); - } - - Ok(slf) - } - - /// Remove orders from statement. - fn clear_order_by(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyRef<'_, Self> { - slf.0.lock().orders.clear(); - slf - } - - fn having<'a>( - slf: pyo3::PyRef<'a, Self>, - condition: RefBoundObject<'a>, - ) -> pyo3::PyResult> { - unsafe { - if pyo3::ffi::Py_TYPE(condition.as_ptr()) != crate::typeref::EXPR_TYPE { - return crate::new_error!( - PyTypeError, - "expected Expr, got {}", - crate::internal::get_type_name(condition.py(), condition.as_ptr()) - ); - } - - let condition = condition.cast_unchecked::().get().clone(); - let mut lock = slf.0.lock(); - - match std::mem::take(&mut lock.having) { - None => { - lock.having = Some(condition); - } - Some(x) => { - lock.having = Some(PyExpr(x.0.and(condition.0))); - } - } - } - - Ok(slf) - } - - #[pyo3(signature=(r#type=String::from("UPDATE"), behavior=None, tables=Vec::new()))] - fn lock( - slf: pyo3::PyRef<'_, Self>, - r#type: String, - behavior: Option, - tables: Vec, - ) -> pyo3::PyResult> { - let lock_type = match r#type.to_ascii_uppercase().as_str() { - "UPDATE" => sea_query::LockType::Update, - "NO KEY UPDATE" => sea_query::LockType::NoKeyUpdate, - "SHARE" => sea_query::LockType::Share, - "KEY SHARE" => sea_query::LockType::KeyShare, - _ => { - return crate::new_error!( - PyValueError, - "acceptable lock types are 'UPDATE', 'NO KEY UPDATE', 'SHARE', and 'KEY \ - SHARE'. got {}", - r#type - ) - } - }; - let lock_behavior = match behavior.map(|x| x.to_ascii_uppercase()) { - Some(x) => match x.as_str() { - "NOWAIT" => Some(sea_query::LockBehavior::Nowait), - "SKIP" => Some(sea_query::LockBehavior::SkipLocked), - _ => { - return crate::new_error!( - PyValueError, - "acceptable lock behaviors are 'NOWAIT', and 'SKIP'. got {}", - x - ) - } - }, - None => None, - }; - - let mut tbs = Vec::with_capacity(tables.len()); - - for table in tables.into_iter() { - tbs.push(PyTableName::try_from(table.bind(slf.py()))?); - } - - slf.0.lock().lock = Some(LockMode { - r#type: lock_type, - behavior: lock_behavior, - tables: tbs, - }); - Ok(slf) - } - - #[pyo3(signature=(*groups))] - fn group_by<'a>( - slf: pyo3::PyRef<'a, Self>, - groups: BoundArgs<'a>, - ) -> pyo3::PyResult> { - let mut exprs = Vec::with_capacity(groups.len()); - - for expr in groups.iter() { - let target = unsafe { - if pyo3::ffi::Py_TYPE(expr.as_ptr()) == crate::typeref::EXPR_TYPE { - expr.cast_unchecked::().get().clone() - } else { - let column_ref = PyColumnRef::try_from(&expr)?; - - PyExpr(sea_query::SimpleExpr::Column(column_ref.into_column_ref())) - } - }; - - exprs.push(target); - } - - slf.0.lock().groups.append(&mut exprs); - Ok(slf) - } - - #[pyo3(signature=(statement, r#type=String::from("DISTINCT")))] - fn union<'a>( - slf: pyo3::PyRef<'a, Self>, - statement: BoundObject<'a>, - r#type: String, - ) -> pyo3::PyResult> { - unsafe { - if statement.as_ptr() == slf.as_ptr() { - return crate::new_error!( - PyValueError, - "select statement cannot select from itself!" - ); - } - - if pyo3::ffi::PyObject_TypeCheck( - statement.as_ptr(), - crate::typeref::SELECT_STATEMENT_TYPE, - ) == 0 - { - return crate::new_error!( - PyValueError, - "expected SelectStatement, got {}", - crate::internal::get_type_name(statement.py(), statement.as_ptr()) - ); - } - } - - let union_type = match r#type.to_ascii_uppercase().as_str() { - "ALL" => sea_query::UnionType::All, - "INTERSECT" => sea_query::UnionType::Intersect, - "DISTINCT" => sea_query::UnionType::Distinct, - "EXCEPT" => sea_query::UnionType::Except, - _ => { - return crate::new_error!( - PyValueError, - "acceptable union types are 'ALL', 'INTERSECT', 'DISTINCT', and 'EXCEPT'. got \ - {}", - r#type - ) - } - }; - - slf.0.lock().unions.push((union_type, statement.unbind())); - Ok(slf) - } - - #[pyo3(signature=(table, on=None, r#type=None))] - fn join<'a>( - slf: pyo3::PyRef<'a, Self>, - table: RefBoundObject<'a>, - on: Option>, - r#type: Option, - ) -> pyo3::PyResult> { - let reference = SelectReference::from_table(table)?; - let join_mode = JoinMode { - r#type: map_str_to_join_type(r#type)?, - reference, - on: match on { - Some(x) => Some(PyExpr::try_from(x)?), - None => None, - }, - lateral: false, - }; - - slf.0.lock().joins.push(join_mode); - Ok(slf) - } - - #[pyo3(signature=(function, alias, on=None, r#type=None))] - fn join_function<'a>( - slf: pyo3::PyRef<'a, Self>, - function: RefBoundObject<'a>, - alias: String, - on: Option>, - r#type: Option, - ) -> pyo3::PyResult> { - let reference = SelectReference::from_function(function, alias)?; - - let join_mode = JoinMode { - r#type: map_str_to_join_type(r#type)?, - reference, - on: match on { - Some(x) => Some(PyExpr::try_from(x)?), - None => None, - }, - lateral: false, - }; - - slf.0.lock().joins.push(join_mode); - Ok(slf) - } - - #[pyo3(signature=(subquery, alias, on=None, r#type=None, lateral=false))] - fn join_subquery<'a>( - slf: pyo3::PyRef<'a, Self>, - subquery: RefBoundObject<'a>, - alias: String, - on: Option>, - r#type: Option, - lateral: bool, - ) -> pyo3::PyResult> { - if subquery.as_ptr() == slf.as_ptr() { - return crate::new_error!(PyValueError, "select statement cannot select from itself!"); - } - - let reference = SelectReference::from_subquery(subquery, alias)?; - - let join_mode = JoinMode { - r#type: map_str_to_join_type(r#type)?, - reference, - on: match on { - Some(x) => Some(PyExpr::try_from(x)?), - None => None, - }, - lateral, - }; - slf.0.lock().joins.push(join_mode); - Ok(slf) - } - - #[pyo3(signature=(name, statement))] - fn window<'a>( - slf: pyo3::PyRef<'a, Self>, - name: String, - statement: RefBoundObject<'a>, - ) -> pyo3::PyResult> { - unsafe { - if pyo3::ffi::PyObject_TypeCheck( - statement.as_ptr(), - crate::typeref::WINDOW_STATEMENT_TYPE, - ) == 0 - { - return crate::new_error!( - PyTypeError, - "expected WindowStatement, got {}", - crate::internal::get_type_name(statement.py(), statement.as_ptr()) - ); - } - } - - slf.0.lock().window = Some(( - sea_query::Alias::new(name).into_iden(), - statement.clone().unbind(), - )); - Ok(slf) - } - - /// Shorthand for `Expr(self)` - fn to_expr(&self, py: pyo3::Python) -> PyExpr { - let stmt = self.0.lock().to_sea_query(py); - let subquery = sea_query::SubQueryStatement::SelectStatement(stmt); - PyExpr(sea_query::SimpleExpr::SubQuery(None, Box::new(subquery))) - } - - /// Shorthand for `SelectLabel(self, alias, window)` - #[pyo3(signature=(alias, window=None))] - fn label( - &self, - py: pyo3::Python, - alias: String, - window: Option>, - ) -> pyo3::PyResult { - let window = match window { - Some(x) => Some(crate::query::select::SelectLabelWindow::try_from(x)?), - None => None, - }; - let expr = self.to_expr(py); - - let state = crate::query::select::SelectLabelState { - expr, - alias: Some(alias), - window, - }; - Ok(state.into()) - } - - #[pyo3(signature = (backend, /))] - #[allow(clippy::wrong_self_convention)] - fn to_sql(&self, py: pyo3::Python<'_>, backend: String) -> pyo3::PyResult { - let lock = self.0.lock(); - let stmt = lock.to_sea_query(py); - drop(lock); - - crate::build_query_statement!(backend, stmt) - } - - #[pyo3(signature = (backend, /))] - fn build<'a>( - &self, - py: pyo3::Python<'a>, - backend: String, - ) -> pyo3::PyResult<(String, BoundObject<'a>)> { - let lock = self.0.lock(); - let stmt = lock.to_sea_query(py); - drop(lock); - - crate::build_query_parts!(py, backend, stmt) - } - - fn __copy__<'a>(&self, py: pyo3::Python<'a>) -> pyo3::PyResult> { - let lock = self.0.lock(); - pyo3::Bound::new(py, (lock.clone_ref(py).into(), PyQueryStatement)) - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let lock = slf.0.lock(); - - let mut fmt = ReprFormatter::new_with_pyref(&slf); - - match &lock.distinct { - DistinctMode::None => (), - DistinctMode::Distinct => { - fmt.pair("distinct", "true"); - } - DistinctMode::DistinctOn(x) => { - fmt.vec("distinct", false) - .display_iter(x.iter().map(|x| x.__repr__())) - .finish(&mut fmt); - } - } - - fmt.vec("references", false) - .display_iter(lock.references.iter()) - .finish(&mut fmt); - - fmt.vec("exprs", false) - .display_iter(lock.exprs.iter()) - .finish(&mut fmt); - - fmt.optional_map("where", lock.r#where.as_ref(), |x| x.__repr__()); - - fmt.vec("groups", true) - .display_iter(lock.groups.iter().map(|x| x.__repr__())) - .finish(&mut fmt); - - fmt.optional_map("having", lock.having.as_ref(), |x| x.__repr__()); - - fmt.vec("orders", true) - .display_iter(lock.orders.iter().map(|x| x.__repr__())) - .finish(&mut fmt); - - fmt.vec("joins", true) - .display_iter(lock.joins.iter()) - .finish(&mut fmt); - - fmt.optional_display("lock", lock.lock.as_ref()) - .optional_display("offset", lock.offset) - .optional_display("limit", lock.limit) - .optional_map("window", lock.window.as_ref(), |(name, stmt)| { - format!("('{}', {})", name.to_string(), stmt) - }); - - fmt.vec("unions", true) - .display_iter( - lock.unions - .iter() - .map(|(union_type, stmt)| match union_type { - sea_query::UnionType::All => format!("(ALL, {})", stmt), - sea_query::UnionType::Distinct => format!("(DISTINCT, {})", stmt), - sea_query::UnionType::Except => format!("(EXCEPT, {})", stmt), - sea_query::UnionType::Intersect => format!("(INTERSECT, {})", stmt), - }), - ) - .finish(&mut fmt); - - fmt.finish() - } -} diff --git a/src/query/update.rs b/src/query/update.rs deleted file mode 100644 index cf46b9f..0000000 --- a/src/query/update.rs +++ /dev/null @@ -1,289 +0,0 @@ -use sea_query::IntoIden; - -use super::base::PyQueryStatement; -use super::ordering::PyOrdering; -use super::returning::PyReturning; -use crate::common::expression::PyExpr; -use crate::common::table_ref::PyTableName; -use crate::internal::repr::ReprFormatter; -use crate::internal::{BoundArgs, BoundKwargs, BoundObject, RefBoundObject, ToSeaQuery}; - -crate::implement_pyclass! { - /// Builds UPDATE SQL statements with a fluent interface. - /// - /// Provides a chainable API for constructing UPDATE queries with support for: - /// - Setting column values - /// - WHERE conditions for filtering - /// - LIMIT for restricting update count - /// - ORDER BY for determining update order - /// - RETURNING clauses for getting updated data - #[derive(Clone)] - mutable [subclass, extends=PyQueryStatement] PyUpdateStatement(UpdateStatementState) as "UpdateStatement" { - pub table: PyTableName, - pub from_table: Option, - pub values: Vec<(sea_query::DynIden, PyExpr)>, - pub r#where: Option, - pub limit: Option, - pub returning_clause: Option, - pub orders: Vec, - } -} - -impl ToSeaQuery for UpdateStatementState { - #[cfg_attr(feature = "optimize", optimize(speed))] - fn to_sea_query<'a>(&self, py: pyo3::Python<'a>) -> sea_query::UpdateStatement { - let mut stmt = sea_query::UpdateStatement::new(); - stmt.table(self.table.clone()); - - if let Some(x) = &self.from_table { - stmt.from(x.clone()); - } - stmt.values(self.values.iter().map(|(x, y)| (x.clone(), y.0.clone()))); - - if let Some(x) = &self.r#where { - stmt.and_where(x.0.clone()); - } - if let Some(x) = self.limit { - stmt.limit(x); - } - if let Some(x) = &self.returning_clause { - stmt.returning(x.0.to_sea_query(py)); - } - - for order in self.orders.iter() { - if let Some(x) = order.null_order { - stmt.order_by_expr_with_nulls(order.target.0.clone(), order.order.clone(), x); - } else { - stmt.order_by_expr(order.target.0.clone(), order.order.clone()); - } - } - - stmt - } -} - -#[pyo3::pymethods] -impl PyUpdateStatement { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> (Self, PyQueryStatement) { - (Self::uninit(), PyQueryStatement) - } - - pub fn __init__(&self, table: RefBoundObject<'_>) -> pyo3::PyResult<()> { - let table = PyTableName::try_from(table)?; - - let state = UpdateStatementState { - table, - from_table: None, - values: vec![], - r#where: None, - limit: None, - returning_clause: None, - orders: vec![], - }; - self.0.set(state); - Ok(()) - } - - /// Specify the table to update. - fn table<'a>( - slf: pyo3::PyRef<'a, Self>, - table: RefBoundObject<'a>, - ) -> pyo3::PyResult> { - let table = PyTableName::try_from(table)?; - - { - let mut lock = slf.0.lock(); - lock.table = table; - } - Ok(slf) - } - - /// Update using data from another table (`UPDATE .. FROM ..`). - /// - /// MySQL doesn't support the UPDATE FROM syntax. And the current implementation attempt to - /// tranform it to the UPDATE JOIN syntax, which only works for one join target. - #[allow(clippy::wrong_self_convention)] - fn from_table<'a>( - slf: pyo3::PyRef<'a, Self>, - table: RefBoundObject<'a>, - ) -> pyo3::PyResult> { - let table = PyTableName::try_from(table)?; - - { - let mut lock = slf.0.lock(); - lock.from_table = Some(table); - } - Ok(slf) - } - - /// Limit the number of rows to update. - fn limit(slf: pyo3::PyRef<'_, Self>, n: u64) -> pyo3::PyRef<'_, Self> { - { - let mut lock = slf.0.lock(); - lock.limit = Some(n); - } - - slf - } - - /// Specify columns to return from the inserted rows. - #[pyo3(signature=(clause))] - fn returning<'a>( - slf: pyo3::PyRef<'a, Self>, - clause: pyo3::Bound<'_, PyReturning>, - ) -> pyo3::PyResult> { - { - let mut lock = slf.0.lock(); - lock.returning_clause = Some(clause.get().clone()); - } - Ok(slf) - } - - /// Add a WHERE condition to filter rows to update. - fn r#where<'a>( - slf: pyo3::PyRef<'a, Self>, - condition: RefBoundObject<'a>, - ) -> pyo3::PyResult> { - unsafe { - if pyo3::ffi::Py_TYPE(condition.as_ptr()) != crate::typeref::EXPR_TYPE { - return crate::new_error!( - PyTypeError, - "expected Expr, got {}", - crate::internal::get_type_name(condition.py(), condition.as_ptr()) - ); - } - - let condition = condition.cast_unchecked::().get().clone(); - let mut lock = slf.0.lock(); - - match std::mem::take(&mut lock.r#where) { - None => { - lock.r#where = Some(condition); - } - Some(x) => { - lock.r#where = Some(PyExpr(x.0.and(condition.0))); - } - } - } - - Ok(slf) - } - - /// Remove where conditions from statement. - fn clear_where(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyRef<'_, Self> { - slf.0.lock().r#where = None; - slf - } - - /// Specify the order in which to delete rows. Typically used with - /// `.limit` method to delete specific rows. - #[pyo3(signature=(clause))] - fn order_by<'a>( - slf: pyo3::PyRef<'a, Self>, - clause: pyo3::Bound<'_, PyOrdering>, - ) -> pyo3::PyResult> { - { - let mut lock = slf.0.lock(); - lock.orders.push(clause.get().clone()); - } - - Ok(slf) - } - - /// Remove orders from statement. - fn clear_order_by(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyRef<'_, Self> { - slf.0.lock().orders.clear(); - slf - } - - /// Specify columns and their new values. - #[pyo3(signature=(**kwds))] - fn values<'a>( - slf: pyo3::PyRef<'a, Self>, - kwds: Option>, - ) -> pyo3::PyResult> { - use pyo3::types::{PyAnyMethods, PyDictMethods}; - - if kwds.is_none() { - return Ok(slf); - } - - let kwds = unsafe { kwds.unwrap_unchecked() }; - let mut values = Vec::with_capacity(kwds.len()); - - unsafe { - for (key, value) in kwds.iter() { - let key = key.extract::().unwrap_unchecked(); - values.push(( - (sea_query::Alias::new(key).into_iden()), - PyExpr::try_from(&value)?, - )); - } - } - - { - let mut lock = slf.0.lock(); - lock.values.append(&mut values); - } - Ok(slf) - } - - #[pyo3(signature = (backend, /))] - #[allow(clippy::wrong_self_convention)] - fn to_sql(&self, py: pyo3::Python<'_>, backend: String) -> pyo3::PyResult { - let lock = self.0.lock(); - let stmt = lock.to_sea_query(py); - drop(lock); - - crate::build_query_statement!(backend, stmt) - } - - #[pyo3(signature = (backend, /))] - fn build<'a>( - &self, - py: pyo3::Python<'a>, - backend: String, - ) -> pyo3::PyResult<(String, BoundObject<'a>)> { - let lock = self.0.lock(); - let stmt = lock.to_sea_query(py); - drop(lock); - - crate::build_query_parts!(py, backend, stmt) - } - - fn __copy__<'a>(&self, py: pyo3::Python<'a>) -> pyo3::PyResult> { - let lock = self.0.lock(); - pyo3::Bound::new(py, (lock.clone().into(), PyQueryStatement)) - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let lock = slf.0.lock(); - - let mut fmt = ReprFormatter::new_with_pyref(&slf) - .map("table", &lock.table, |x| x.__repr__()) - .optional_map("from_table", lock.from_table.as_ref(), |x| x.__repr__()) - .take(); - - fmt.vec("values", false) - .display_iter( - lock.values - .iter() - .map(|x| format!("('{}', {})", x.0.to_string(), x.1.__repr__())), - ) - .finish(&mut fmt); - - fmt.vec("orders", true) - .display_iter(lock.orders.iter().map(|x| x.__repr__())) - .finish(&mut fmt); - - fmt.optional_display("limit", lock.limit) - .optional_map("where", lock.r#where.as_ref(), |x| x.__repr__()) - .optional_map("returning", lock.returning_clause.as_ref(), |x| { - x.__repr__() - }) - .finish() - } -} diff --git a/src/query/window.rs b/src/query/window.rs deleted file mode 100644 index 8dd94b6..0000000 --- a/src/query/window.rs +++ /dev/null @@ -1,218 +0,0 @@ -use pyo3::types::PyTupleMethods; -use sea_query::{IntoColumnRef, OverStatement}; - -use super::ordering::PyOrdering; -use crate::common::column_ref::PyColumnRef; -use crate::common::expression::PyExpr; -use crate::internal::repr::ReprFormatter; -use crate::internal::{BoundArgs, BoundKwargs, RefBoundObject, ToSeaQuery}; - -/// Frame clause -#[derive(Debug, Clone, PartialEq)] -pub struct FrameClause { - pub r#type: sea_query::FrameType, - pub start: sea_query::Frame, - pub end: Option, -} - -crate::implement_pyclass! { - /// Window frame start and frame end clause. Use its classmethods. - #[derive(Debug, Clone)] - [] PyFrame as "Frame" (pub sea_query::Frame); -} -crate::implement_pyclass! { - /// Window expression. - /// - /// # References: - /// - /// 1. - /// 2. - /// 3. - #[derive(Clone)] - mutable [subclass] PyWindowStatement(WindowStatementState) as "WindowStatement" { - pub partition_by: Vec, - pub orders: Vec, - pub frame: Option - } -} - -#[pyo3::pymethods] -impl PyFrame { - #[classmethod] - fn unbounded_preceding(_cls: &pyo3::Bound<'_, pyo3::types::PyType>) -> Self { - Self(sea_query::Frame::UnboundedPreceding) - } - - #[classmethod] - fn current_row(_cls: &pyo3::Bound<'_, pyo3::types::PyType>) -> Self { - Self(sea_query::Frame::CurrentRow) - } - - #[classmethod] - fn unbounded_following(_cls: &pyo3::Bound<'_, pyo3::types::PyType>) -> Self { - Self(sea_query::Frame::UnboundedFollowing) - } - - #[classmethod] - fn following(_cls: &pyo3::Bound<'_, pyo3::types::PyType>, val: u32) -> Self { - Self(sea_query::Frame::Following(val)) - } - - #[classmethod] - fn preceding(_cls: &pyo3::Bound<'_, pyo3::types::PyType>, val: u32) -> Self { - Self(sea_query::Frame::Preceding(val)) - } -} - -impl ToSeaQuery for WindowStatementState { - fn to_sea_query<'a>(&self, _py: pyo3::Python<'a>) -> sea_query::WindowStatement { - let mut stmt = sea_query::WindowStatement::new(); - - for partition in &self.partition_by { - stmt.add_partition_by(partition.0.clone()); - } - - if let Some(x) = &self.frame { - stmt.frame(x.r#type.clone(), x.start.clone(), x.end.clone()); - } - - for order in self.orders.iter() { - if let Some(x) = order.null_order { - stmt.order_by_expr_with_nulls(order.target.0.clone(), order.order.clone(), x); - } else { - stmt.order_by_expr(order.target.0.clone(), order.order.clone()); - } - } - - stmt - } -} - -#[pyo3::pymethods] -impl PyWindowStatement { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> Self { - Self::uninit() - } - - #[pyo3(signature=(*partition_by))] - pub fn __init__(&self, partition_by: BoundArgs<'_>) -> pyo3::PyResult<()> { - let mut partitions = Vec::with_capacity(partition_by.len()); - - for partition in partition_by.iter() { - let partition = unsafe { - if pyo3::ffi::Py_TYPE(partition.as_ptr()) == crate::typeref::EXPR_TYPE { - partition.cast_unchecked::().get().clone() - } else { - let column_ref = PyColumnRef::try_from(&partition)?; - - PyExpr(sea_query::SimpleExpr::Column(column_ref.into_column_ref())) - } - }; - - partitions.push(partition); - } - - let state = WindowStatementState { - partition_by: partitions, - orders: Vec::new(), - frame: None, - }; - self.0.set(state); - Ok(()) - } - - /// Partition by column or custom expression. - fn partition<'a>( - slf: pyo3::PyRef<'a, Self>, - partition_by: RefBoundObject<'a>, - ) -> pyo3::PyResult> { - let partition_by = unsafe { - if pyo3::ffi::Py_TYPE(partition_by.as_ptr()) == crate::typeref::EXPR_TYPE { - partition_by.cast_unchecked::().get().clone() - } else { - let column_ref = PyColumnRef::try_from(partition_by)?; - - PyExpr(sea_query::SimpleExpr::Column(column_ref.into_column_ref())) - } - }; - - { - let mut lock = slf.0.lock(); - lock.partition_by.push(partition_by); - } - Ok(slf) - } - - #[pyo3(signature=(clause))] - fn order_by<'a>( - slf: pyo3::PyRef<'a, Self>, - clause: pyo3::Bound<'_, PyOrdering>, - ) -> pyo3::PyResult> { - { - let mut lock = slf.0.lock(); - lock.orders.push(clause.get().clone()); - } - - Ok(slf) - } - - #[pyo3(signature=(frame_type, frame_start, frame_end=None))] - fn frame<'a>( - slf: pyo3::PyRef<'a, Self>, - frame_type: String, - frame_start: pyo3::Bound<'_, PyFrame>, - frame_end: Option>, - ) -> pyo3::PyResult> { - let frame_type = match frame_type.to_ascii_lowercase().as_str() { - "rows" => sea_query::FrameType::Rows, - "range" => sea_query::FrameType::Range, - _ => { - return Err(pyo3::exceptions::PyValueError::new_err(format!( - "invalid frame type, expected 'rows' or 'range'; got {:?}", - frame_type - ))); - } - }; - - { - let mut lock = slf.0.lock(); - lock.frame = Some(FrameClause { - r#type: frame_type, - start: frame_start.get().0.clone(), - end: frame_end.map(|x| x.get().0.clone()), - }) - } - Ok(slf) - } - - fn __copy__(&self) -> Self { - let lock = self.0.lock(); - lock.clone().into() - } - - pub fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let lock = slf.0.lock(); - - let mut fmt = ReprFormatter::new_with_pyref(&slf); - - fmt.vec("partition_by", true) - .display_iter(lock.partition_by.iter().map(|x| x.__repr__())) - .finish(&mut fmt); - - fmt.vec("orders", true) - .display_iter(lock.orders.iter().map(|x| x.__repr__())) - .finish(&mut fmt); - - fmt.optional_map("frame_type", lock.frame.as_ref(), |x| { - format!("{:?}", x.r#type) - }) - .optional_map("frame_start", lock.frame.as_ref(), |x| { - format!("{:?}", x.start) - }) - .optional_map("frame_end", lock.frame.as_ref(), |x| format!("{:?}", x.end)) - .finish() - } -} diff --git a/src/query/with.rs b/src/query/with.rs deleted file mode 100644 index 4a8c5b1..0000000 --- a/src/query/with.rs +++ /dev/null @@ -1,522 +0,0 @@ -use sea_query::IntoIden; - -use crate::common::expression::PyExpr; -use crate::internal::repr::ReprFormatter; -use crate::internal::{BoundArgs, BoundKwargs, BoundObject, PyObject, RefBoundObject, ToSeaQuery}; -use crate::new_error; -use crate::query::base::PyQueryStatement; - -pub enum CommonTableExpressionQuery { - Select( - /// Always is `SelectStatement` - PyObject, - ), - Delete( - /// Always is `DeleteStatement` - PyObject, - ), - Update( - /// Always is `UpdateStatement` - PyObject, - ), - Insert( - /// Always is `InsertStatement` - PyObject, - ), -} - -pub struct CommonTableExpression { - pub name: String, - pub query: CommonTableExpressionQuery, - pub columns: Vec, - pub materialized: Option, -} - -crate::implement_pyclass! { - /// A WITH clause can contain one or multiple common table expressions (CTEs). - /// - /// These named queries can act as a "query local table" that are materialized during execution and - /// then can be used by the query prefixed with the WITH clause. - /// - /// A CTE is a name, column names and a query returning data for those columns. - /// - /// Some databases (like sqlite) restrict the acceptable kinds of queries inside of the WITH clause - /// CTEs. These databases only allow `SelectStatement`s to form a CTE. - /// - /// Other databases like postgres allow modification queries (UPDATE, DELETE) inside of the WITH - /// clause but they have to return a table. (They must have a RETURNING clause). - /// - /// RapidQuery doesn't check this or restrict the kind of CTE that you can create - /// in rust. This means that you can put an UPDATE or DELETE queries into WITH clause and RapidQuery - /// will succeed in generating that kind of sql query but the execution inside the database will - /// fail because they are invalid. - /// - /// It is your responsibility to ensure that the kind of WITH clause that you put together makes - /// sense and valid for that database that you are using. - /// - /// NOTE that for recursive WITH queries (in sql: "WITH RECURSIVE") you can only have a - /// single CTE inside of the WITH clause. That query must match certain - /// requirements: - /// * It is a query of UNION or UNION ALL of two queries. - /// * The first part of the query (the left side of the UNION) must be executable first in itself. - /// It must be non-recursive. (Cannot contain self reference) - /// * The self reference must appear in the right hand side of the UNION. - /// * The query can only have a single self-reference. - /// * Recursive data-modifying statements are not supported, but you can use the results of a - /// recursive SELECT query in a data-modifying statement. (like so: WITH RECURSIVE - /// cte_name(a,b,c,d) AS (SELECT ... UNION SELECT ... FROM ... JOIN cte_name ON ... WHERE ...) - /// DELETE FROM table WHERE table.a = cte_name.a) - /// - /// Recursive with query generation will raise `RuntimeError` if you specify more than one CTE. - mutable [subclass] PyWithClause(WithClauseState) as "WithClause" { - pub recursive: bool, - pub cte_expressions: Vec, - pub cycle: Option, - pub search: Option, - } -} -crate::implement_pyclass! { - /// A WITH query. A simple SQL query that has a WITH clause (`WithClause`). - /// - /// For full description, see `WithClause`'s documentation. - mutable [subclass, extends=PyQueryStatement] PyWithQuery(WithQueryState) as "WithQuery" { - /// Always is `PyWithClause` - pub with_clause: PyObject, - pub query: CommonTableExpressionQuery, - } -} - -impl TryFrom> for CommonTableExpressionQuery { - type Error = pyo3::PyErr; - - fn try_from(value: RefBoundObject) -> Result { - unsafe { - let value_ptr = value.as_ptr(); - - // SelectStatement - if pyo3::ffi::PyObject_TypeCheck(value_ptr, crate::typeref::SELECT_STATEMENT_TYPE) == 1 - { - Ok(Self::Select(value.clone().unbind())) - } - // DeleteStatement - else if pyo3::ffi::PyObject_TypeCheck( - value_ptr, - crate::typeref::DELETE_STATEMENT_TYPE, - ) == 1 - { - Ok(Self::Delete(value.clone().unbind())) - } - // UpdateStatement - else if pyo3::ffi::PyObject_TypeCheck( - value_ptr, - crate::typeref::UPDATE_STATEMENT_TYPE, - ) == 1 - { - Ok(Self::Update(value.clone().unbind())) - } - // InsertStatement - else if pyo3::ffi::PyObject_TypeCheck( - value_ptr, - crate::typeref::INSERT_STATEMENT_TYPE, - ) == 1 - { - Ok(Self::Insert(value.clone().unbind())) - } - // Other types - else { - crate::new_error!( - PyTypeError, - "expected SelectStatement, DeleteStatement, UpdateStatement, or \ - InsertStatement, got {}", - crate::internal::get_type_name(value.py(), value_ptr) - ) - } - } - } -} - -#[inline(always)] -fn cast_into_with_clause<'a, 'b>(py: pyo3::Python<'a>, value: &'b PyObject) -> &'b PyWithClause -where - 'a: 'b, -{ - let bound = value.bind(py); - - unsafe { - let casted = bound.cast_unchecked::(); - casted.get() - } -} - -impl std::fmt::Display for CommonTableExpressionQuery { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Select(x) => write!(f, "{x}"), - Self::Update(x) => write!(f, "{x}"), - Self::Delete(x) => write!(f, "{x}"), - Self::Insert(x) => write!(f, "{x}"), - } - } -} - -impl std::fmt::Display for CommonTableExpression { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "('{}', {}", self.name, self.query)?; - - if !self.columns.is_empty() { - write!(f, ", {:?}", self.columns)?; - } - match self.materialized { - Some(true) => write!(f, ", materialized")?, - Some(false) => write!(f, ", not materialized")?, - None => {} - } - - write!(f, ")") - } -} - -impl ToSeaQuery for CommonTableExpression { - #[cfg_attr(feature = "optimize", optimize(speed))] - fn to_sea_query<'a>(&self, py: pyo3::Python<'a>) -> sea_query::CommonTableExpression { - let (mut stmt, select_used) = { - match &self.query { - CommonTableExpressionQuery::Select(x) if self.columns.is_empty() => { - let select = - unsafe { x.cast_bound_unchecked::(py) }; - - let result = select.get().0.lock().to_sea_query(py); - - (sea_query::CommonTableExpression::from_select(result), true) - } - _ => (sea_query::CommonTableExpression::new(), false), - } - }; - stmt.table_name(sea_query::Alias::new(&self.name).into_iden()); - - match &self.query { - CommonTableExpressionQuery::Select(x) => { - if !select_used { - let delete = - unsafe { x.cast_bound_unchecked::(py) }; - - let result = delete.get().0.lock().to_sea_query(py); - stmt.query(result); - } - } - CommonTableExpressionQuery::Delete(x) => { - let delete = - unsafe { x.cast_bound_unchecked::(py) }; - - let result = delete.get().0.lock().to_sea_query(py); - stmt.query(result); - } - CommonTableExpressionQuery::Update(x) => { - let update = - unsafe { x.cast_bound_unchecked::(py) }; - - let result = update.get().0.lock().to_sea_query(py); - stmt.query(result); - } - CommonTableExpressionQuery::Insert(x) => { - let insert = - unsafe { x.cast_bound_unchecked::(py) }; - - let result = insert.get().0.lock().to_sea_query(py); - stmt.query(result); - } - } - - if !self.columns.is_empty() { - stmt.columns(self.columns.iter().map(sea_query::Alias::new)); - } - if let Some(x) = self.materialized { - stmt.materialized(x); - } - - stmt - } -} - -impl ToSeaQuery for WithClauseState { - #[cfg_attr(feature = "optimize", optimize(speed))] - fn to_sea_query<'a>(&self, py: pyo3::Python<'a>) -> sea_query::WithClause { - let mut stmt = sea_query::WithClause::new(); - - if self.recursive { - stmt.recursive(true); - } - for cte in self.cte_expressions.iter() { - stmt.cte(cte.to_sea_query(py)); - } - if let Some(x) = &self.cycle { - stmt.cycle(x.clone()); - } - if let Some(x) = &self.search { - stmt.search(x.clone()); - } - - stmt - } -} - -impl ToSeaQuery for WithQueryState { - #[cfg_attr(feature = "optimize", optimize(speed))] - fn to_sea_query<'a>(&self, py: pyo3::Python<'a>) -> sea_query::WithQuery { - let clause = unsafe { - let x = cast_into_with_clause(py, &self.with_clause); - x.0.lock().to_sea_query(py) - }; - - match &self.query { - CommonTableExpressionQuery::Select(x) => { - let select = - unsafe { x.cast_bound_unchecked::(py) }; - - let result = select.get().0.lock().to_sea_query(py); - clause.query(result) - } - CommonTableExpressionQuery::Delete(x) => { - let delete = - unsafe { x.cast_bound_unchecked::(py) }; - - let result = delete.get().0.lock().to_sea_query(py); - clause.query(result) - } - CommonTableExpressionQuery::Update(x) => { - let update = - unsafe { x.cast_bound_unchecked::(py) }; - - let result = update.get().0.lock().to_sea_query(py); - clause.query(result) - } - CommonTableExpressionQuery::Insert(x) => { - let insert = - unsafe { x.cast_bound_unchecked::(py) }; - - let result = insert.get().0.lock().to_sea_query(py); - clause.query(result) - } - } - } -} - -#[pyo3::pymethods] -impl PyWithClause { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> Self { - Self::uninit() - } - - fn __init__(&self) -> pyo3::PyResult<()> { - let state = WithClauseState { - recursive: false, - cte_expressions: vec![], - cycle: None, - search: None, - }; - self.0.set(state); - Ok(()) - } - - /// Sets whether this clause is a recursive with clause of not. - /// If set to true it will generate a 'WITH RECURSIVE' query. - /// - /// You can only specify a single CTE containing a union query - /// if this is set to true. - fn recursive<'a>(slf: pyo3::PyRef<'a, Self>) -> pyo3::PyRef<'a, Self> { - slf.0.lock().recursive = true; - slf - } - - /// Add a CTE to this with clause. - #[pyo3(signature=(name, query, columns=Vec::new(), materialized=None))] - fn cte<'a>( - slf: pyo3::PyRef<'a, Self>, - name: String, - query: RefBoundObject<'a>, - columns: Vec, - materialized: Option, - ) -> pyo3::PyResult> { - let query = CommonTableExpressionQuery::try_from(query)?; - - slf.0.lock().cte_expressions.push(CommonTableExpression { - name, - query, - columns, - materialized, - }); - Ok(slf) - } - - /// For recursive `WithQuery` `WithClause`s the CYCLE sql clause can be specified to avoid creating - /// an infinite traversals that loops on graph cycles indefinitely. You specify an expression that - /// identifies a node in the graph and that will be used to determine during the iteration of - /// the execution of the query when appending of new values whether the new values are distinct new - /// nodes or are already visited and therefore they should be added again into the result. - /// - /// A query can have both SEARCH and CYCLE clauses. - /// - /// This setting is not meaningful if the query is not recursive. - /// Some databases don’t support this clause. In that case this option will be silently ignored. - fn cycle<'a>( - slf: pyo3::PyRef<'a, Self>, - expr: Option>, - set_as: Option, - using: Option, - ) -> pyo3::PyResult> { - let mut cycle = sea_query::Cycle::new(); - - if let Some(x) = expr { - let val = PyExpr::try_from(x)?; - cycle.expr(val.0); - } - if let Some(x) = set_as { - cycle.set(sea_query::Alias::new(x)); - } - if let Some(x) = using { - cycle.using(sea_query::Alias::new(x)); - } - - slf.0.lock().cycle = Some(cycle); - Ok(slf) - } - - /// For recursive `WithQuery` `WithClause`s the traversing order can be specified in some databases - /// that support this functionality. - /// - /// A query can have both SEARCH and CYCLE clauses. - /// - /// This setting is not meaningful if the query is not recursive. - /// Some databases don’t support this clause. In that case this option will be silently ignored. - /// - /// The `expr` used must specify an alias which will be the name that you can use to order - /// the result of the CTE. - fn search<'a>( - slf: pyo3::PyRef<'a, Self>, - expr: Option>, - order: Option, - ) -> pyo3::PyResult> { - let mut search = sea_query::Search::new(); - - if let Some(x) = expr { - let val = x.cast_into::()?; - - search.expr(val.get().as_ref().to_sea_query(slf.py())); - } - - if let Some(x) = order { - match x.to_ascii_lowercase().as_str() { - "breadth" => { - search.order(sea_query::SearchOrder::BREADTH); - } - "depth" => { - search.order(sea_query::SearchOrder::DEPTH); - } - _ => return new_error!(PyValueError, "unknown order: {}", x), - } - } - slf.0.lock().search = Some(search); - Ok(slf) - } - - /// You can turn this into a `WithQuery` using this function. - /// The resulting WITH query will execute the argument query with this WITH clause. - fn query<'a>( - slf: pyo3::PyRef<'a, Self>, - val: RefBoundObject<'a>, - ) -> pyo3::PyResult> { - let query = CommonTableExpressionQuery::try_from(val)?; - - let state = WithQueryState { - with_clause: unsafe { pyo3::Bound::from_borrowed_ptr(slf.py(), slf.as_ptr()).unbind() }, - query, - }; - let result: PyWithQuery = state.into(); - - pyo3::Bound::new(slf.py(), (result, PyQueryStatement)) - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let lock = slf.0.lock(); - - let mut fmt = ReprFormatter::new_with_pyref(&slf) - .optional_boolean("recursive", lock.recursive) - .take(); - - fmt.vec("cte", false) - .display_iter(lock.cte_expressions.iter()) - .finish(&mut fmt); - - fmt.finish() - } -} - -#[pyo3::pymethods] -impl PyWithQuery { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> (Self, PyQueryStatement) { - (Self::uninit(), PyQueryStatement) - } - - fn __init__(&self, clause: BoundObject, query: RefBoundObject) -> pyo3::PyResult<()> { - unsafe { - if pyo3::ffi::PyObject_TypeCheck(clause.as_ptr(), crate::typeref::WITH_CLAUSE_TYPE) == 0 - { - return crate::new_error!( - PyTypeError, - "expected WithClause, got {}", - crate::internal::get_type_name(clause.py(), clause.as_ptr()) - ); - } - } - let query = CommonTableExpressionQuery::try_from(query)?; - - let state = WithQueryState { - with_clause: clause.unbind(), - query, - }; - self.0.set(state); - Ok(()) - } - - #[pyo3(signature = (backend, /))] - #[allow(clippy::wrong_self_convention)] - fn to_sql(&self, py: pyo3::Python<'_>, backend: String) -> pyo3::PyResult { - use sea_query::QueryStatementBuilder; - - let lock = self.0.lock(); - let stmt = lock.to_sea_query(py); - drop(lock); - - crate::build_query_statement!(backend, stmt) - } - - #[pyo3(signature = (backend, /))] - fn build<'a>( - &self, - py: pyo3::Python<'a>, - backend: String, - ) -> pyo3::PyResult<(String, BoundObject<'a>)> { - use sea_query::QueryStatementBuilder; - - let lock = self.0.lock(); - let stmt = lock.to_sea_query(py); - drop(lock); - - crate::build_query_parts!(py, backend, stmt) - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let lock = slf.0.lock(); - - ReprFormatter::new_with_pyref(&slf) - .display("clause", &lock.with_clause) - .display("query", &lock.query) - .finish() - } -} diff --git a/src/schema/alter_table.rs b/src/schema/alter_table.rs deleted file mode 100644 index 20121c1..0000000 --- a/src/schema/alter_table.rs +++ /dev/null @@ -1,620 +0,0 @@ -use super::base::PySchemaStatement; -use crate::common::column::PyColumn; -use crate::common::column_ref::PyColumnRef; -use crate::common::foreign_key::PyForeignKey; -use crate::common::table_ref::PyTableName; -use crate::internal::repr::ReprFormatter; -use crate::internal::{BoundArgs, BoundKwargs, BoundObject, PyObject, RefBoundObject, ToSeaQuery}; - -use pyo3::PyTypeInfo; -use sea_query::IntoIden; - -crate::implement_pyclass! { - // Base class for all ALTER TABLE operation types. - /// - /// This abstract base class represents the different types of modifications - /// that can be made to an existing table structure, such as adding/dropping - /// columns, modifying column definitions, or managing foreign keys. - #[derive(Debug, Clone, Copy)] - [subclass] PyAlterTableBaseOption as "AlterTableBaseOption"; -} -crate::implement_pyclass! { - /// ALTER TABLE operation to add a new column. - /// - /// Adds a column to an existing table with optional IF NOT EXISTS clause - /// to prevent errors if the column already exists. - #[derive(Debug)] - immutable [subclass, extends=PyAlterTableBaseOption] PyAlterTableAddColumnOption(AlterTableAddColumnOptionState) - as "AlterTableAddColumnOption" { - /// Always is `PyColumn` - column: PyObject, - if_not_exists: bool, - } -} -crate::implement_pyclass! { - /// ALTER TABLE operation to add a foreign key constraint. - /// - /// Adds referential integrity between tables by creating a foreign key - /// relationship on an existing table. - #[derive(Debug)] - immutable [subclass, extends=PyAlterTableBaseOption] PyAlterTableAddForeignKeyOption(AlterTableAddForeignKeyOptionState) - as "AlterTableAddForeignKeyOption" { - /// Always is `PyForeignKey` - foreign_key: PyObject, - } -} -crate::implement_pyclass! { - /// ALTER TABLE operation to drop an existing column. - /// - /// Removes a column from the table. This operation may fail if the column - /// is referenced by other database objects. - #[derive(Debug, Clone)] - immutable [subclass, extends=PyAlterTableBaseOption] PyAlterTableDropColumnOption(AlterTableDropColumnOptionState) - as "AlterTableDropColumnOption" { name: sea_query::DynIden } -} -crate::implement_pyclass! { - /// ALTER TABLE operation to drop a foreign key constraint. - /// - /// Removes a foreign key relationship by its constraint name. - #[derive(Debug, Clone)] - immutable [subclass, extends=PyAlterTableBaseOption] PyAlterTableDropForeignKeyOption(AlterTableDropForeignKeyOptionState) - as "AlterTableDropForeignKeyOption" { name: sea_query::DynIden } -} -crate::implement_pyclass! { - /// ALTER TABLE operation to modify a column definition. - /// - /// Changes properties of an existing column such as type, nullability, - /// default value, or other constraints. - #[derive(Debug)] - immutable [subclass, extends=PyAlterTableBaseOption] PyAlterTableModifyColumnOption(AlterTableModifyColumnOptionState) - as "AlterTableModifyColumnOption" { - /// Always is `PyColumn` - column: PyObject, - } -} -crate::implement_pyclass! { - /// ALTER TABLE operation to rename a column. - /// - /// Changes the name of an existing column without modifying its type - /// or constraints. - #[derive(Debug, Clone)] - immutable [subclass, extends=PyAlterTableBaseOption] PyAlterTableRenameColumnOption(AlterTableRenameColumnOptionState) - as "AlterTableRenameColumnOption" { - from_name: sea_query::DynIden, - to_name: sea_query::DynIden, - } -} -crate::implement_pyclass! { - /// Represents an ALTER TABLE SQL statement. - /// - /// Provides a flexible way to modify existing table structures by applying - /// one or more alteration operations such as adding/dropping columns, - /// modifying column definitions, or managing constraints. - /// - /// Multiple operations can be batched together in a single ALTER TABLE - /// statement for efficiency. - #[derive(Debug)] - mutable [subclass, extends=PySchemaStatement] PyAlterTable(AlterTableState) as "AlterTable" { - name: PyTableName, - options: Vec, - } -} - -#[pyo3::pymethods] -impl PyAlterTableAddColumnOption { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__( - args: BoundArgs<'_>, - kwds: Option>, - ) -> (Self, PyAlterTableBaseOption) { - (Self::uninit(), PyAlterTableBaseOption) - } - - #[pyo3(signature = (column, if_not_exists=false))] - fn __init__(&self, column: RefBoundObject<'_>, if_not_exists: bool) -> pyo3::PyResult<()> { - unsafe { - if pyo3::ffi::PyObject_TypeCheck(column.as_ptr(), crate::typeref::COLUMN_TYPE) == 0 { - return crate::new_error!( - PyTypeError, - "expected Column, got {}", - crate::internal::get_type_name(column.py(), column.as_ptr()) - ); - } - } - - let result = AlterTableAddColumnOptionState { - column: column.clone().unbind(), - if_not_exists, - }; - unsafe { - self.0.set(result); - } - Ok(()) - } - - #[getter] - fn column(&self, py: pyo3::Python) -> PyObject { - self.0.as_ref().column.clone_ref(py) - } - - #[getter] - fn if_not_exists(&self) -> bool { - self.0.as_ref().if_not_exists - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let inner = slf.0.as_ref(); - - ReprFormatter::new_with_pyref(&slf) - .display("column", &inner.column) - .optional_boolean("if_not_exists", inner.if_not_exists) - .finish() - } -} - -#[pyo3::pymethods] -impl PyAlterTableAddForeignKeyOption { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__( - args: BoundArgs<'_>, - kwds: Option>, - ) -> (Self, PyAlterTableBaseOption) { - (Self::uninit(), PyAlterTableBaseOption) - } - - #[pyo3(signature = (foreign_key))] - fn __init__(&self, foreign_key: RefBoundObject<'_>) -> pyo3::PyResult<()> { - unsafe { - if pyo3::ffi::PyObject_TypeCheck(foreign_key.as_ptr(), crate::typeref::FOREIGN_KEY_TYPE) - == 0 - { - return crate::new_error!( - PyTypeError, - "expected ForeignKey, got {}", - crate::internal::get_type_name(foreign_key.py(), foreign_key.as_ptr()) - ); - } - } - - let result = AlterTableAddForeignKeyOptionState { - foreign_key: foreign_key.clone().unbind(), - }; - unsafe { - self.0.set(result); - } - Ok(()) - } - - #[getter] - fn foreign_key(&self, py: pyo3::Python) -> PyObject { - self.0.as_ref().foreign_key.clone_ref(py) - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let inner = slf.0.as_ref(); - - ReprFormatter::new_with_pyref(&slf) - .display("foreign_key", &inner.foreign_key) - .finish() - } -} - -#[pyo3::pymethods] -impl PyAlterTableDropColumnOption { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__( - args: BoundArgs<'_>, - kwds: Option>, - ) -> (Self, PyAlterTableBaseOption) { - (Self::uninit(), PyAlterTableBaseOption) - } - - #[pyo3(signature=(name))] - fn __init__(&self, name: RefBoundObject<'_>) -> pyo3::PyResult<()> { - let column_ref = PyColumnRef::try_from(name)?; - - match column_ref.name { - Some(x) => { - let result = AlterTableDropColumnOptionState { name: x }; - unsafe { - self.0.set(result); - } - Ok(()) - } - None => crate::new_error!( - PyValueError, - "AlterTableDropColumnOption cannot accept asterisk '*' as name" - ), - } - } - - #[getter] - fn name(&self) -> String { - self.0.as_ref().name.to_string() - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let inner = slf.0.as_ref(); - - ReprFormatter::new_with_pyref(&slf) - .iden("name", &inner.name) - .finish() - } -} - -#[pyo3::pymethods] -impl PyAlterTableDropForeignKeyOption { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__( - args: BoundArgs<'_>, - kwds: Option>, - ) -> (Self, PyAlterTableBaseOption) { - (Self::uninit(), PyAlterTableBaseOption) - } - - #[pyo3(signature=(name))] - fn __init__(&self, name: String) -> pyo3::PyResult<()> { - let result = AlterTableDropForeignKeyOptionState { - name: sea_query::Alias::new(name).into_iden(), - }; - unsafe { - self.0.set(result); - } - Ok(()) - } - - #[getter] - fn name(&self) -> String { - self.0.as_ref().name.to_string() - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let inner = slf.0.as_ref(); - - ReprFormatter::new_with_pyref(&slf) - .iden("name", &inner.name) - .finish() - } -} - -#[pyo3::pymethods] -impl PyAlterTableModifyColumnOption { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__( - args: BoundArgs<'_>, - kwds: Option>, - ) -> (Self, PyAlterTableBaseOption) { - (Self::uninit(), PyAlterTableBaseOption) - } - - #[pyo3(signature = (column))] - fn __init__(&self, column: RefBoundObject<'_>) -> pyo3::PyResult<()> { - unsafe { - if pyo3::ffi::PyObject_TypeCheck(column.as_ptr(), crate::typeref::COLUMN_TYPE) == 0 { - return crate::new_error!( - PyTypeError, - "expected Column, got {}", - crate::internal::get_type_name(column.py(), column.as_ptr()) - ); - } - } - - let result = AlterTableModifyColumnOptionState { - column: column.clone().unbind(), - }; - unsafe { - self.0.set(result); - } - Ok(()) - } - - #[getter] - fn column(&self, py: pyo3::Python) -> PyObject { - self.0.as_ref().column.clone_ref(py) - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let inner = slf.0.as_ref(); - - ReprFormatter::new_with_pyref(&slf) - .display("column", &inner.column) - .finish() - } -} - -#[pyo3::pymethods] -impl PyAlterTableRenameColumnOption { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__( - args: BoundArgs<'_>, - kwds: Option>, - ) -> (Self, PyAlterTableBaseOption) { - (Self::uninit(), PyAlterTableBaseOption) - } - - #[pyo3(signature = (from_name, to_name))] - fn __init__( - &self, - from_name: RefBoundObject<'_>, - to_name: RefBoundObject<'_>, - ) -> pyo3::PyResult<()> { - let from_column_ref = PyColumnRef::try_from(from_name)?; - let to_column_ref = PyColumnRef::try_from(to_name)?; - - if from_column_ref.name.is_none() { - return Err(pyo3::exceptions::PyValueError::new_err( - "AlterTableRenameColumnOption cannot accept asterisk '*' as from_name", - )); - } - if to_column_ref.name.is_none() { - return Err(pyo3::exceptions::PyValueError::new_err( - "AlterTableRenameColumnOption cannot accept asterisk '*' as to_name", - )); - } - - unsafe { - let result = AlterTableRenameColumnOptionState { - from_name: from_column_ref.name.unwrap_unchecked(), - to_name: to_column_ref.name.unwrap_unchecked(), - }; - self.0.set(result); - Ok(()) - } - } - - #[getter] - #[allow(clippy::wrong_self_convention)] - fn from_name(&self) -> String { - self.0.as_ref().from_name.clone().to_string() - } - - #[getter] - fn to_name(&self) -> String { - self.0.as_ref().to_name.clone().to_string() - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let inner = slf.0.as_ref(); - - ReprFormatter::new_with_pyref(&slf) - .iden("from_name", &inner.from_name) - .iden("to_name", &inner.to_name) - .finish() - } -} - -impl ToSeaQuery for AlterTableState { - #[cfg_attr(feature = "optimize", optimize(speed))] - fn to_sea_query<'a>(&self, py: pyo3::Python<'a>) -> sea_query::TableAlterStatement { - let mut stmt = sea_query::TableAlterStatement::new(); - stmt.table(self.name.clone()); - - for op in self.options.iter() { - unsafe { - let op_type = pyo3::ffi::Py_TYPE(op.as_ptr()); - - if op_type == PyAlterTableAddColumnOption::type_object_raw(py) { - let add_column_opt = op.cast_bound_unchecked::(py); - let get_add_column_opt = add_column_opt.get().as_ref(); - let py_column = get_add_column_opt - .column - .cast_bound_unchecked::(py) - .get() - .clone(); - - let column_def: sea_query::ColumnDef = py_column.0.lock().to_sea_query(py); - - if get_add_column_opt.if_not_exists { - stmt.add_column_if_not_exists(column_def); - } else { - stmt.add_column(column_def); - } - } else if op_type == PyAlterTableAddForeignKeyOption::type_object_raw(py) { - let add_fk = op.cast_bound_unchecked::(py); - let get_add_fk = add_fk.get().as_ref(); - - let py_fk = get_add_fk - .foreign_key - .cast_bound_unchecked::(py) - .get() - .clone(); - - let table_fk: sea_query::TableForeignKey = py_fk.0.lock().to_sea_query(py); - stmt.add_foreign_key(&table_fk); - } else if op_type == PyAlterTableDropColumnOption::type_object_raw(py) { - let bound = op.cast_bound_unchecked::(py); - let x = bound.get(); - - stmt.drop_column(x.as_ref().name.clone()); - } else if op_type == PyAlterTableDropForeignKeyOption::type_object_raw(py) { - let bound = op.cast_bound_unchecked::(py); - let x = bound.get(); - - stmt.drop_foreign_key(x.0.as_ref().name.clone()); - } else if op_type == PyAlterTableModifyColumnOption::type_object_raw(py) { - let bound = op.cast_bound_unchecked::(py); - let x = bound.get().as_ref(); - - let py_column = x.column.cast_bound_unchecked::(py).get().clone(); - - let column_def: sea_query::ColumnDef = py_column.0.lock().to_sea_query(py); - stmt.modify_column(column_def); - } else if op_type == PyAlterTableRenameColumnOption::type_object_raw(py) { - let bound = op.cast_bound_unchecked::(py); - let x = bound.get().as_ref(); - - stmt.rename_column(x.from_name.clone(), x.to_name.clone()); - } - } - } - - stmt - } -} - -#[pyo3::pymethods] -impl PyAlterTable { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> (Self, PySchemaStatement) { - (Self::uninit(), PySchemaStatement) - } - - #[pyo3(signature = (name, options=Vec::new()))] - fn __init__( - &self, - name: RefBoundObject<'_>, - options: Vec>, - ) -> pyo3::PyResult<()> { - let name = PyTableName::try_from(name)?; - - unsafe { - for opt in options.iter() { - if pyo3::ffi::PyObject_TypeCheck( - opt.as_ptr(), - crate::typeref::ALTER_TABLE_BASE_OPTION_TYPE, - ) == 0 - { - return crate::new_error!( - PyTypeError, - "expected AlterTableBaseOption, got {}", - crate::internal::get_type_name(opt.py(), opt.as_ptr()) - ); - } - } - } - - let state = AlterTableState { - name, - options: options.into_iter().map(|x| x.unbind()).collect(), - }; - self.0.set(state); - Ok(()) - } - - /// The name of the table to alter. - #[getter] - fn name(&self) -> PyTableName { - self.0.lock().name.clone() - } - - #[setter] - fn set_name(&self, val: RefBoundObject<'_>) -> pyo3::PyResult<()> { - let val = PyTableName::try_from(val)?; - - let mut lock = self.0.lock(); - lock.name = val; - Ok(()) - } - - /// The list of alteration operations to apply. - #[getter] - fn options(&self, py: pyo3::Python) -> Vec { - self.0 - .lock() - .options - .iter() - .map(|x| x.clone_ref(py)) - .collect() - } - - #[setter] - fn set_options(&self, val: Vec>) -> pyo3::PyResult<()> { - unsafe { - for opt in val.iter() { - if pyo3::ffi::PyObject_TypeCheck( - opt.as_ptr(), - crate::typeref::ALTER_TABLE_BASE_OPTION_TYPE, - ) == 0 - { - return crate::new_error!( - PyTypeError, - "expected AlterTableBaseOption, got {}", - crate::internal::get_type_name(opt.py(), opt.as_ptr()) - ); - } - } - } - - let mut lock = self.0.lock(); - lock.options = val.into_iter().map(|x| x.unbind()).collect(); - Ok(()) - } - - /// Add an alteration operation to this ALTER TABLE statement. - fn add_option<'a>( - slf: pyo3::PyRef<'a, Self>, - opt: BoundObject<'a>, - ) -> pyo3::PyResult> { - unsafe { - if pyo3::ffi::PyObject_TypeCheck( - opt.as_ptr(), - crate::typeref::ALTER_TABLE_BASE_OPTION_TYPE, - ) == 0 - { - return crate::new_error!( - PyTypeError, - "expected AlterTableBaseOption, got {}", - crate::internal::get_type_name(opt.py(), opt.as_ptr()) - ); - } - } - - { - let mut lock = slf.0.lock(); - lock.options.push(opt.unbind()); - } - Ok(slf) - } - - #[pyo3(signature = (backend, /))] - #[allow(clippy::wrong_self_convention)] - fn to_sql(&self, py: pyo3::Python<'_>, backend: String) -> pyo3::PyResult { - let lock = self.0.lock(); - - // Prevent "No alter option found" panic - if lock.options.is_empty() { - return crate::new_error!( - PyRuntimeError, - "No alter option found. You have to specify at least one alter option." - ); - } - - let stmt = lock.to_sea_query(py); - drop(lock); - - crate::build_schema_statement!(backend, stmt) - } - - fn __copy__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult> { - let lock = self.0.lock(); - - let new_state = AlterTableState { - name: lock.name.clone(), - options: lock.options.iter().map(|x| x.clone_ref(py)).collect(), - }; - - pyo3::Py::new(py, (new_state.into(), PySchemaStatement)) - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let inner = slf.0.lock(); - - ReprFormatter::new_with_pyref(&slf) - .map("name", &inner.name, |x| x.__repr__()) - .finish() - } -} diff --git a/src/schema/base.rs b/src/schema/base.rs deleted file mode 100644 index a50af53..0000000 --- a/src/schema/base.rs +++ /dev/null @@ -1,27 +0,0 @@ -use crate::internal::{BoundArgs, BoundKwargs}; - -crate::implement_pyclass! { - /// Subclass of schema statements. - #[derive(Debug, Clone, Copy)] - [subclass] PySchemaStatement as "SchemaStatement"; -} - -#[pyo3::pymethods] -impl PySchemaStatement { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> Self { - Self - } - - fn __init__(&self) {} - - /// Build a SQL string representation. - #[pyo3(signature = (backend, /))] - #[allow(unused_variables)] - #[allow(clippy::wrong_self_convention)] - fn to_sql(&self, backend: String) -> pyo3::PyResult { - Err(pyo3::exceptions::PyNotImplementedError::new_err(())) - } -} diff --git a/src/schema/index.rs b/src/schema/index.rs deleted file mode 100644 index 2ff6169..0000000 --- a/src/schema/index.rs +++ /dev/null @@ -1,684 +0,0 @@ -use sea_query::IntoIden; - -use super::base::PySchemaStatement; -use crate::common::expression::PyExpr; -use crate::common::table_ref::PyTableName; -use crate::internal::repr::ReprFormatter; -use crate::internal::{BoundArgs, BoundKwargs, BoundObject, RefBoundObject, ToSeaQuery}; - -#[inline] -fn map_str_to_index_order(value: String) -> pyo3::PyResult { - match value.to_ascii_uppercase().as_str() { - "ASC" => Ok(sea_query::IndexOrder::Asc), - "DESC" => Ok(sea_query::IndexOrder::Desc), - _ => Err(pyo3::exceptions::PyValueError::new_err(format!( - "unknown order: {}", - value - ))), - } -} - -#[inline] -fn map_index_order_to_str(value: &sea_query::IndexOrder) -> String { - match value { - sea_query::IndexOrder::Asc => String::from("ASC"), - sea_query::IndexOrder::Desc => String::from("DESC"), - } -} - -pub const OPT_PRIMARY: u8 = 1 << 0; -pub const OPT_UNIQUE: u8 = 1 << 1; -pub const OPT_IF_NOT_EXISTS: u8 = 1 << 2; -pub const OPT_NULLS_NOT_DISTINCT: u8 = 1 << 3; - -crate::implement_pyclass! { - /// Defines a column within an index specification. - /// - /// Represents a single column's participation in an index, including: - /// - The column name - /// - Optional prefix length (for partial indexing) - /// - Sort order (ascending or descending) - /// - /// Used within `Index` to specify which columns are indexed - /// and how they should be ordered. - /// - /// NOTE: this class is immutable and frozen. - #[derive(Debug, Clone)] - [] PyIndexColumn as "IndexColumn" { - pub name: sea_query::DynIden, - pub order: Option, - pub prefix: Option, - } -} -crate::implement_pyclass! { - /// Represents a database index specification. - /// - /// This class defines the structure and properties of a database index, - /// including column definitions, uniqueness constraints, index type, - /// and partial indexing conditions. - /// - /// You can use it to generate `CREATE INDEX` SQL expressions. - #[derive(Debug)] - mutable [subclass, extends=PySchemaStatement] PyIndex(IndexState) as "Index" { - pub name: Option, - pub columns: Vec, - pub table: Option, - pub options: u8, - pub index_type: Option, - pub r#where: Option, - pub include: Vec, - } -} -crate::implement_pyclass! { - /// Represents a DROP INDEX SQL statement. - /// - /// Builds index deletion statements with support for: - /// - Conditional deletion (IF EXISTS) - /// - Table-specific index dropping - #[derive(Debug)] - mutable [subclass, extends=PySchemaStatement] PyDropIndex(DropIndexState) as "DropIndex" { - pub name: String, - pub table: PyTableName, - pub if_exists: bool, - } -} - -impl sea_query::IntoIndexColumn for PyIndexColumn { - fn into_index_column(self) -> sea_query::IndexColumn { - match (self.prefix, self.order) { - (Some(p), Some(o)) => (self.name, p, o).into_index_column(), - (Some(p), None) => (self.name, p).into_index_column(), - (None, Some(o)) => (self.name, o).into_index_column(), - (None, None) => self.name.into_index_column(), - } - } -} - -impl TryFrom> for PyIndexColumn { - type Error = pyo3::PyErr; - - fn try_from(value: RefBoundObject<'_>) -> Result { - unsafe { - if pyo3::ffi::PyObject_TypeCheck(value.as_ptr(), crate::typeref::INDEX_COLUMN_TYPE) == 1 - { - let casted_value = value.cast_unchecked::(); - return Ok(casted_value.get().clone()); - } - - let column_ref = - crate::common::column_ref::PyColumnRef::try_from(value).map_err(|_| { - crate::new_py_error!( - PyTypeError, - "expected IndexColumn, ColumnRef, Column, str, or object.to_column_ref \ - property, got {}", - crate::internal::get_type_name(value.py(), value.as_ptr()) - ) - })?; - - match column_ref.name { - Some(x) => { - let inner = Self { - name: x, - order: None, - prefix: None, - }; - Ok(inner) - } - None => Err(pyo3::exceptions::PyValueError::new_err( - "IndexColumn cannot accept asterisk '*' as column", - )), - } - } - } -} - -#[pyo3::pymethods] -impl PyIndexColumn { - #[new] - #[pyo3(signature=(name, order=None, prefix=None))] - fn __new__(name: String, order: Option, prefix: Option) -> pyo3::PyResult { - let state = Self { - name: sea_query::Alias::new(name).into_iden(), - order: match order { - None => None, - Some(x) => Some(map_str_to_index_order(x)?), - }, - prefix, - }; - Ok(state) - } - - /// The name of the column to include in the index. - #[getter] - fn name(&self) -> String { - self.name.to_string() - } - - /// Number of characters to index for string columns (prefix indexing). - #[getter] - fn prefix(&self) -> Option { - self.prefix - } - - /// Sort order for this column. - #[getter] - fn order(&self) -> Option { - self.order.as_ref().map(map_index_order_to_str) - } - - fn __copy__(&self) -> Self { - self.clone() - } - - fn __repr__(&self) -> String { - ReprFormatter::new("IndexColumn") - .iden("name", &self.name) - .optional_display("prefix", self.prefix) - .optional_map("order", self.order.as_ref(), map_index_order_to_str) - .finish() - } -} - -impl Clone for IndexState { - fn clone(&self) -> Self { - Self { - name: self.name.clone(), - columns: self.columns.clone(), - table: self.table.clone(), - options: self.options, - index_type: self.index_type.clone(), - r#where: self.r#where.clone(), - include: self.include.clone(), - } - } -} - -impl ToSeaQuery for IndexState { - #[cfg_attr(feature = "optimize", optimize(speed))] - fn to_sea_query<'a>(&self, _py: pyo3::Python<'a>) -> sea_query::IndexCreateStatement { - let mut stmt = sea_query::IndexCreateStatement::new(); - - if let Some(x) = &self.name { - stmt.name(x); - } - if let Some(x) = &self.table { - stmt.table(x.clone()); - } - - for col in self.columns.iter() { - stmt.col(col.clone()); - } - - if let Some(x) = &self.index_type { - stmt.index_type(sea_query::IndexType::Custom( - sea_query::Alias::new(x.clone()).into_iden(), - )); - } - - for include in self.include.iter() { - stmt.include(sea_query::Alias::new(include)); - } - - if self.options & OPT_PRIMARY > 0 { - stmt.primary(); - } - if self.options & OPT_IF_NOT_EXISTS > 0 { - stmt.if_not_exists(); - } - if self.options & OPT_NULLS_NOT_DISTINCT > 0 { - stmt.nulls_not_distinct(); - } - if self.options & OPT_UNIQUE > 0 { - stmt.unique(); - } - - stmt - } -} - -#[pyo3::pymethods] -impl PyIndex { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> (Self, PySchemaStatement) { - (Self::uninit(), PySchemaStatement) - } - - #[ - pyo3( - signature = ( - name, - columns, - table=None, - *, - primary=false, - if_not_exists=false, - nulls_not_distinct=false, - unique=false, - index_type=None, - r#where=None, - include=Vec::new() - ) - ) - ] - fn __init__( - &self, - name: Option, - columns: Vec>, - table: Option>, - primary: bool, - if_not_exists: bool, - nulls_not_distinct: bool, - unique: bool, - index_type: Option, - r#where: Option>, - include: Vec, - ) -> pyo3::PyResult<()> { - let table = match table { - Some(x) => Some(PyTableName::try_from(x)?), - None => None, - }; - - let r#where = match r#where { - None => None, - Some(x) => unsafe { - if pyo3::ffi::Py_TYPE(x.as_ptr()) != crate::typeref::EXPR_TYPE { - return crate::new_error!( - PyTypeError, - "expected Expr or None, got {}", - crate::internal::get_type_name(x.py(), x.as_ptr()) - ); - } - - Some(x.cast_unchecked::().get().clone()) - }, - }; - - if columns.is_empty() { - return Err(pyo3::exceptions::PyValueError::new_err( - "columns cannot be empty", - )); - } - - let mut cols = Vec::with_capacity(columns.len()); - for c in columns.into_iter() { - cols.push(PyIndexColumn::try_from(&c)?); - } - - let mut options = 0u8; - if primary { - options |= OPT_PRIMARY; - } - if unique { - options |= OPT_UNIQUE; - } - if nulls_not_distinct { - options |= OPT_NULLS_NOT_DISTINCT; - } - if if_not_exists { - options |= OPT_IF_NOT_EXISTS; - } - - let state = IndexState { - name, - columns: cols, - table, - options, - index_type, - r#where, - include, - }; - self.0.set(state); - - Ok(()) - } - - /// Index name. - #[getter] - fn name(&self) -> Option { - let lock = self.0.lock(); - lock.name.clone() - } - - #[setter] - fn set_name(&self, val: Option) { - let mut lock = self.0.lock(); - lock.name = val; - } - - /// The table on which to create the index. - #[getter] - fn table(&self) -> Option { - let lock = self.0.lock(); - lock.table.clone() - } - - #[setter] - fn set_table(&self, val: Option>) -> pyo3::PyResult<()> { - let val = match val { - Some(x) => Some(PyTableName::try_from(x)?), - None => None, - }; - - let mut lock = self.0.lock(); - lock.table = val; - Ok(()) - } - - /// Whether this is a primary key constraint. - #[getter] - fn primary(&self) -> bool { - self.0.lock().options & OPT_PRIMARY > 0 - } - - #[setter] - fn set_primary(&self, value: bool) { - let mut lock = self.0.lock(); - if value { - lock.options |= OPT_PRIMARY; - } else { - lock.options &= !OPT_PRIMARY; - } - } - - /// Whether this is a unique constraint. - #[getter] - fn unique(&self) -> bool { - self.0.lock().options & OPT_UNIQUE > 0 - } - - #[setter] - fn set_unique(&self, value: bool) { - let mut lock = self.0.lock(); - if value { - lock.options |= OPT_UNIQUE; - } else { - lock.options &= !OPT_UNIQUE; - } - } - - /// Whether NULL values should be considered equal for uniqueness. - #[getter] - fn nulls_not_distinct(&self) -> bool { - self.0.lock().options & OPT_NULLS_NOT_DISTINCT > 0 - } - - #[setter] - fn set_nulls_not_distinct(&self, value: bool) { - let mut lock = self.0.lock(); - if value { - lock.options |= OPT_NULLS_NOT_DISTINCT; - } else { - lock.options &= !OPT_NULLS_NOT_DISTINCT; - } - } - - /// Whether to use IF NOT EXISTS clause. - #[getter] - fn if_not_exists(&self) -> bool { - self.0.lock().options & OPT_IF_NOT_EXISTS > 0 - } - - #[setter] - fn set_if_not_exists(&self, value: bool) { - let mut lock = self.0.lock(); - if value { - lock.options |= OPT_IF_NOT_EXISTS; - } else { - lock.options &= !OPT_IF_NOT_EXISTS; - } - } - - /// The columns that make up this index. - #[getter] - fn columns(&self) -> Vec { - let lock = self.0.lock(); - lock.columns.clone() - } - - #[setter] - fn set_columns(&self, val: Vec>) -> pyo3::PyResult<()> { - if val.is_empty() { - return Err(pyo3::exceptions::PyValueError::new_err( - "columns cannot be empty", - )); - } - - let mut cols = Vec::with_capacity(val.len()); - for c in val.into_iter() { - cols.push(PyIndexColumn::try_from(&c)?); - } - - let mut lock = self.0.lock(); - lock.columns = cols; - Ok(()) - } - - /// The type/algorithm for this index. - #[getter] - fn index_type(&self) -> Option { - let lock = self.0.lock(); - lock.index_type.clone() - } - - #[setter] - fn set_index_type(&self, val: Option) -> pyo3::PyResult<()> { - let mut lock = self.0.lock(); - lock.index_type = val; - Ok(()) - } - - /// Condition for partial indexing. - #[getter] - fn r#where(&self) -> Option { - let lock = self.0.lock(); - lock.r#where.clone() - } - - #[setter] - fn set_where(&self, val: Option>) -> pyo3::PyResult<()> { - let val = match val { - None => None, - Some(x) => Some(PyExpr::try_from(x)?), - }; - - let mut lock = self.0.lock(); - lock.r#where = val; - Ok(()) - } - - /// Additional columns to include in the index for covering queries - #[getter] - fn include(&self) -> Vec { - let lock = self.0.lock(); - lock.include.clone() - } - - #[setter] - fn set_include(&self, val: Vec) { - let mut lock = self.0.lock(); - lock.include = val; - } - - fn __copy__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult> { - let lock = self.0.lock(); - - pyo3::Py::new(py, (lock.clone().into(), PySchemaStatement)) - } - - #[pyo3(signature = (backend, /))] - #[allow(clippy::wrong_self_convention)] - fn to_sql(&self, py: pyo3::Python<'_>, backend: String) -> pyo3::PyResult { - let lock = self.0.lock(); - - if lock.table.is_none() { - return crate::new_error!( - PyRuntimeError, - "To generate a CREATE INDEX statement, both `table` and `name` parameters are \ - necessary." - ); - } - - if lock.name.is_none() { - return crate::new_error!( - PyRuntimeError, - "To generate a CREATE INDEX statement, both `table` and `name` parameters are \ - necessary." - ); - } - - let stmt = lock.to_sea_query(py); - drop(lock); - - crate::build_schema_statement!(backend, stmt) - } - - pub fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let lock = slf.0.lock(); - - let mut fmt = ReprFormatter::new_with_pyref(&slf) - .optional_quote("name", lock.name.as_ref()) - .optional_map("table", lock.table.as_ref(), |x| x.__repr__()) - .take(); - - fmt.vec("columns", true) - .display_iter(lock.columns.iter().map(|x| x.__repr__())) - .finish(&mut fmt); - - fmt.optional_boolean("if_not_exists", lock.options & OPT_IF_NOT_EXISTS > 0) - .optional_boolean("primary", lock.options & OPT_PRIMARY > 0) - .optional_boolean("unique", lock.options & OPT_UNIQUE > 0) - .optional_boolean( - "nulls_not_distinct", - lock.options & OPT_NULLS_NOT_DISTINCT > 0, - ) - .optional_quote("index_type", lock.index_type.as_ref()) - .optional_map("where", lock.r#where.as_ref(), |x| x.__repr__()); - - fmt.vec("include", true) - .display_iter(lock.include.iter()) - .finish(&mut fmt); - - fmt.finish() - } -} - -impl Clone for DropIndexState { - fn clone(&self) -> Self { - Self { - name: self.name.clone(), - table: self.table.clone(), - if_exists: self.if_exists, - } - } -} - -impl ToSeaQuery for DropIndexState { - fn to_sea_query<'a>(&self, _py: pyo3::Python<'a>) -> sea_query::IndexDropStatement { - let mut stmt = sea_query::IndexDropStatement::new(); - - stmt.name(&self.name); - stmt.table(self.table.clone()); - - if self.if_exists { - stmt.if_exists(); - } - stmt - } -} - -#[pyo3::pymethods] -impl PyDropIndex { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> (Self, PySchemaStatement) { - (Self::uninit(), PySchemaStatement) - } - - #[pyo3(signature = (name, table, if_exists=false))] - fn __init__( - &self, - name: String, - table: RefBoundObject<'_>, - if_exists: bool, - ) -> pyo3::PyResult<()> { - let table = PyTableName::try_from(table)?; - - let state = DropIndexState { - name, - table, - if_exists, - }; - self.0.set(state); - Ok(()) - } - - /// The name of the index to drop. - #[getter] - fn name(&self) -> String { - let lock = self.0.lock(); - lock.name.clone() - } - - #[setter] - fn set_name(&self, val: String) { - let mut lock = self.0.lock(); - lock.name = val; - } - - /// The table from which to drop the index. - #[getter] - fn table(&self) -> PyTableName { - let lock = self.0.lock(); - lock.table.clone() - } - - #[setter] - fn set_table(&self, val: RefBoundObject<'_>) -> pyo3::PyResult<()> { - let val = PyTableName::try_from(val)?; - - let mut lock = self.0.lock(); - lock.table = val; - Ok(()) - } - - /// Whether to use IF EXISTS clause to avoid errors. - #[getter] - fn if_exists(slf: pyo3::PyRef<'_, Self>) -> bool { - slf.0.lock().if_exists - } - - #[setter] - fn set_if_exists(slf: pyo3::PyRef<'_, Self>, val: bool) { - let mut lock = slf.0.lock(); - lock.if_exists = val; - } - - fn __copy__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult> { - let lock = self.0.lock(); - - pyo3::Py::new(py, (lock.clone().into(), PySchemaStatement)) - } - - #[pyo3(signature = (backend, /))] - #[allow(clippy::wrong_self_convention)] - fn to_sql(&self, py: pyo3::Python<'_>, backend: String) -> pyo3::PyResult { - let lock = self.0.lock(); - let stmt = lock.to_sea_query(py); - drop(lock); - - crate::build_schema_statement!(backend, stmt) - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let lock = slf.0.lock(); - - ReprFormatter::new_with_pyref(&slf) - .quote("name", &lock.name) - .map("table", &lock.table, |x| x.__repr__()) - .optional_boolean("if_exists", lock.if_exists) - .finish() - } -} diff --git a/src/schema/mod.rs b/src/schema/mod.rs deleted file mode 100644 index 08fae68..0000000 --- a/src/schema/mod.rs +++ /dev/null @@ -1,46 +0,0 @@ -pub mod alter_table; -pub mod base; -pub mod index; -pub mod table; -pub mod table_operations; - -#[pyo3::pymodule(name = "schema")] -pub mod schema_module { - - #[pymodule_export] - use super::base::PySchemaStatement; - - #[pymodule_export] - use super::alter_table::PyAlterTable; - #[pymodule_export] - use super::alter_table::PyAlterTableAddColumnOption; - #[pymodule_export] - use super::alter_table::PyAlterTableAddForeignKeyOption; - #[pymodule_export] - use super::alter_table::PyAlterTableBaseOption; - #[pymodule_export] - use super::alter_table::PyAlterTableDropColumnOption; - #[pymodule_export] - use super::alter_table::PyAlterTableDropForeignKeyOption; - #[pymodule_export] - use super::alter_table::PyAlterTableModifyColumnOption; - #[pymodule_export] - use super::alter_table::PyAlterTableRenameColumnOption; - - #[pymodule_export] - use super::index::PyDropIndex; - #[pymodule_export] - use super::index::PyIndex; - #[pymodule_export] - use super::index::PyIndexColumn; - - #[pymodule_export] - use super::table::PyTable; - - #[pymodule_export] - use super::table_operations::PyDropTable; - #[pymodule_export] - use super::table_operations::PyRenameTable; - #[pymodule_export] - use super::table_operations::PyTruncateTable; -} diff --git a/src/schema/table.rs b/src/schema/table.rs deleted file mode 100644 index 021b22b..0000000 --- a/src/schema/table.rs +++ /dev/null @@ -1,510 +0,0 @@ -use super::base::PySchemaStatement; -use crate::common::table_ref::PyTableName; -use crate::internal::repr::ReprFormatter; -use crate::internal::{BoundArgs, BoundKwargs, BoundObject, PyObject, RefBoundObject, ToSeaQuery}; - -use pyo3::types::PyTupleMethods; - -pub const OPT_IF_NOT_EXISTS: u8 = 1 << 0; -pub const OPT_TEMPORARY: u8 = 1 << 1; - -crate::implement_pyclass! { - /// Represents a complete database table definition. - /// - /// This class encapsulates all aspects of a table structure including: - /// - Column definitions with their types and constraints - /// - Indexes for query optimization - /// - Foreign key relationships for referential integrity - /// - Check constraints for data validation - /// - Table-level options like engine, collation, and character set - /// - /// Used to generate CREATE TABLE SQL statements with full schema specifications. - mutable [subclass, extends=PySchemaStatement] PyTable(TableState) as "Table" { - pub name: PyTableName, - - /// Always is `Vec` - pub columns: Vec, - - /// Always is `Vec` - pub indexes: Vec, - - /// Always is `Vec` - pub foreign_keys: Vec, - - /// Always is `Vec` - pub checks: Vec, - - pub options: u8, - pub comment: Option, - pub engine: Option, - pub collate: Option, - pub character_set: Option, - pub extra: Option, - } -} - -impl TableState { - fn clone_ref(&self, py: pyo3::Python) -> Self { - Self { - name: self.name.clone(), - columns: self.columns.iter().map(|x| x.clone_ref(py)).collect(), - indexes: self.indexes.iter().map(|x| x.clone_ref(py)).collect(), - foreign_keys: self.foreign_keys.iter().map(|x| x.clone_ref(py)).collect(), - checks: self.checks.iter().map(|x| x.clone_ref(py)).collect(), - options: self.options, - comment: self.comment.clone(), - engine: self.engine.clone(), - collate: self.collate.clone(), - character_set: self.character_set.clone(), - extra: self.extra.clone(), - } - } -} - -impl ToSeaQuery for TableState { - #[cfg_attr(feature = "optimize", optimize(speed))] - fn to_sea_query<'a>(&self, py: pyo3::Python<'a>) -> sea_query::TableCreateStatement { - let mut stmt = sea_query::TableCreateStatement::new(); - stmt.table(self.name.clone()); - - // Add columns - for column in self.columns.iter() { - let col = unsafe { column.cast_bound_unchecked::(py) }; - - let column_def: sea_query::ColumnDef = col.get().0.lock().to_sea_query(py); - stmt.col(column_def); - } - - // Add primary and unique indexes - for index in self.indexes.iter() { - let ix = unsafe { index.cast_bound_unchecked::(py) }; - - let lock = ix.get().0.lock(); - if lock.options & super::index::OPT_PRIMARY > 0 - || lock.options & super::index::OPT_UNIQUE > 0 - { - let mut index_stmt = lock.to_sea_query(py); - stmt.index(&mut index_stmt); - } - } - - // Add foreign keys - for foreign_key in self.foreign_keys.iter() { - let fk = unsafe { - foreign_key.cast_bound_unchecked::(py) - }; - - let mut fk_stmt = fk.get().0.lock().to_sea_query(py); - stmt.foreign_key(&mut fk_stmt); - } - - // Add check constraints - for check in self.checks.iter() { - let simple_expr = - unsafe { check.cast_bound_unchecked::(py) }; - - stmt.check(simple_expr.get().0.clone()); - } - - if self.options & OPT_IF_NOT_EXISTS > 0 { - stmt.if_not_exists(); - } - if self.options & OPT_TEMPORARY > 0 { - stmt.temporary(); - } - - if let Some(x) = &self.comment { - stmt.comment(x); - } - if let Some(x) = &self.engine { - stmt.engine(x); - } - if let Some(x) = &self.collate { - stmt.collate(x); - } - if let Some(x) = &self.character_set { - stmt.character_set(x); - } - if let Some(x) = &self.extra { - stmt.extra(x); - } - - stmt - } -} - -#[pyo3::pymethods] -impl PyTable { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> (Self, PySchemaStatement) { - (Self::uninit(), PySchemaStatement) - } - - #[ - pyo3( - signature=( - name, - *args, - if_not_exists=false, - temporary=false, - comment=None, - engine=None, - collate=None, - character_set=None, - extra=None, - ) - ) - ] - fn __init__( - &self, - name: RefBoundObject<'_>, - args: BoundArgs<'_>, - if_not_exists: bool, - temporary: bool, - comment: Option, - engine: Option, - collate: Option, - character_set: Option, - extra: Option, - ) -> pyo3::PyResult<()> { - let name = PyTableName::try_from(name)?; - - let mut columns = Vec::new(); - let mut indexes = Vec::new(); - let mut foreign_keys = Vec::new(); - let mut checks = Vec::new(); - - for object in args.iter() { - unsafe { - // columns - if pyo3::ffi::PyObject_TypeCheck(object.as_ptr(), crate::typeref::COLUMN_TYPE) == 1 - { - columns.push(object.unbind()); - } - // indexes - else if pyo3::ffi::PyObject_TypeCheck(object.as_ptr(), crate::typeref::INDEX_TYPE) - == 1 - { - indexes.push(object.unbind()); - } - // foreign keys - else if pyo3::ffi::PyObject_TypeCheck( - object.as_ptr(), - crate::typeref::FOREIGN_KEY_TYPE, - ) == 1 - { - foreign_keys.push(object.unbind()); - } - // expressions (check constraints) - else if pyo3::ffi::Py_TYPE(object.as_ptr()) == crate::typeref::EXPR_TYPE { - checks.push(object.unbind()); - } else { - return crate::new_error!( - PyTypeError, - "expected Column, Index, ForeignKey, or Expr, got {}", - crate::internal::get_type_name(object.py(), object.as_ptr()) - ); - } - } - } - - let mut options = 0u8; - if if_not_exists { - options |= OPT_IF_NOT_EXISTS; - } - if temporary { - options |= OPT_TEMPORARY; - } - - let state = TableState { - name, - columns, - indexes, - foreign_keys, - checks, - options, - comment, - engine, - collate, - character_set, - extra, - }; - self.0.set(state); - Ok(()) - } - - /// The name of this table. - #[getter] - fn name(&self) -> PyTableName { - let lock = self.0.lock(); - lock.name.clone() - } - - /// Table columns. - #[getter] - fn columns(&self, py: pyo3::Python) -> Vec { - let lock = self.0.lock(); - lock.columns.iter().map(|x| x.clone_ref(py)).collect() - } - - #[setter] - fn set_columns(&self, val: Vec>) -> pyo3::PyResult<()> { - let mut columns = Vec::new(); - - for object in val.into_iter() { - unsafe { - if pyo3::ffi::PyObject_TypeCheck(object.as_ptr(), crate::typeref::COLUMN_TYPE) == 0 - { - return crate::new_error!( - PyTypeError, - "expected iterable of Column, found {}", - crate::internal::get_type_name(object.py(), object.as_ptr()) - ); - } - } - - columns.push(object.unbind()); - } - - self.0.lock().columns = columns; - Ok(()) - } - - /// Table indexes. - #[getter] - fn indexes(&self, py: pyo3::Python) -> Vec { - let lock = self.0.lock(); - lock.indexes.iter().map(|x| x.clone_ref(py)).collect() - } - - #[setter] - fn set_indexes(&self, val: Vec>) -> pyo3::PyResult<()> { - let mut indexes = Vec::new(); - - for object in val.into_iter() { - unsafe { - if pyo3::ffi::PyObject_TypeCheck(object.as_ptr(), crate::typeref::INDEX_TYPE) == 0 { - return crate::new_error!( - PyTypeError, - "expected iterable of Index, found {}", - crate::internal::get_type_name(object.py(), object.as_ptr()) - ); - } - } - - indexes.push(object.unbind()); - } - - self.0.lock().indexes = indexes; - Ok(()) - } - - /// Table foreign keys. - #[getter] - fn foreign_keys(&self, py: pyo3::Python) -> Vec { - let lock = self.0.lock(); - lock.foreign_keys.iter().map(|x| x.clone_ref(py)).collect() - } - - #[setter] - fn set_foreign_keys(&self, val: Vec>) -> pyo3::PyResult<()> { - let mut foreign_keys = Vec::new(); - - for object in val.into_iter() { - unsafe { - if pyo3::ffi::PyObject_TypeCheck(object.as_ptr(), crate::typeref::FOREIGN_KEY_TYPE) - == 0 - { - return crate::new_error!( - PyTypeError, - "expected iterable of ForeignKey, found {}", - crate::internal::get_type_name(object.py(), object.as_ptr()) - ); - } - } - - foreign_keys.push(object.unbind()); - } - - self.0.lock().foreign_keys = foreign_keys; - Ok(()) - } - - /// Table check constraints. - #[getter] - fn checks(&self, py: pyo3::Python) -> Vec { - let lock = self.0.lock(); - lock.checks.iter().map(|x| x.clone_ref(py)).collect() - } - - #[setter] - fn set_checks(&self, val: Vec>) -> pyo3::PyResult<()> { - let mut checks = Vec::new(); - - for object in val.into_iter() { - unsafe { - if pyo3::ffi::Py_TYPE(object.as_ptr()) != crate::typeref::EXPR_TYPE { - return crate::new_error!( - PyTypeError, - "expected iterable of Expr, found {}", - crate::internal::get_type_name(object.py(), object.as_ptr()) - ); - } - } - - checks.push(object.unbind()); - } - - self.0.lock().checks = checks; - Ok(()) - } - - /// Whether to use IF NOT EXISTS clause to avoid errors if table exists. - #[getter] - fn if_not_exists(&self) -> bool { - self.0.lock().options & OPT_IF_NOT_EXISTS > 0 - } - - /// Whether this is a temporary table that exists only for the session. - #[getter] - fn temporary(&self) -> bool { - self.0.lock().options & OPT_TEMPORARY > 0 - } - - /// Comment describing the purpose of this table. - #[getter] - fn comment(&self) -> Option { - self.0.lock().comment.clone() - } - - #[setter] - fn set_comment(&self, val: Option) { - self.0.lock().comment = val; - } - - /// Storage engine for the table (e.g., InnoDB, MyISAM for MySQL). - #[getter] - fn engine(&self) -> Option { - self.0.lock().engine.clone() - } - - #[setter] - fn set_engine(&self, val: Option) { - self.0.lock().engine = val; - } - - /// Collation for string comparisons and sorting in this table. - #[getter] - fn collate(&self) -> Option { - self.0.lock().collate.clone() - } - - #[setter] - fn set_collate(&self, val: Option) { - self.0.lock().collate = val; - } - - /// Character set encoding for text data in this table. - #[getter] - fn character_set(&self) -> Option { - self.0.lock().character_set.clone() - } - - #[setter] - fn set_character_set(&self, val: Option) { - self.0.lock().character_set = val; - } - - /// Additional table-specific options for the CREATE TABLE statement. - #[getter] - fn extra(&self) -> Option { - self.0.lock().extra.clone() - } - - #[setter] - fn set_extra(&self, val: Option) { - self.0.lock().extra = val; - } - - #[pyo3(signature = (backend, /))] - #[allow(clippy::wrong_self_convention)] - fn to_sql(&self, py: pyo3::Python<'_>, backend: String) -> pyo3::PyResult { - let lock = self.0.lock(); - let stmt = lock.to_sea_query(py); - - let mut sqls = vec![crate::build_schema_statement!(&backend, stmt)?]; - - for index in lock.indexes.iter() { - let ix = unsafe { index.cast_bound_unchecked::(py) }; - let ix_lock = ix.get().0.lock(); - - if ix_lock.options & super::index::OPT_PRIMARY > 0 - || ix_lock.options & super::index::OPT_UNIQUE > 0 - { - continue; - } - - // Index name and table is necessary here - if ix_lock.name.is_none() { - return Err(pyo3::exceptions::PyValueError::new_err( - "You should always set name for indexes that aren't primary or unique", - )); - } - - let mut index_stmt = ix_lock.to_sea_query(py); - index_stmt.table(lock.name.clone()); - - if lock.options & OPT_IF_NOT_EXISTS > 0 { - index_stmt.if_not_exists(); - } - - sqls.push(crate::build_schema_statement!(&backend, index_stmt)?); - } - - Ok(sqls.join(";\n")) - } - - #[getter] - fn __table_name__(&self) -> PyTableName { - self.0.lock().name.clone() - } - - fn __copy__<'a>(&self, py: pyo3::Python<'a>) -> pyo3::PyResult> { - let lock = self.0.lock(); - pyo3::Bound::new(py, (lock.clone_ref(py).into(), PySchemaStatement)) - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let lock = slf.0.lock(); - - let mut fmt = ReprFormatter::new_with_pyref(&slf) - .map("name", &lock.name, |x| x.__repr__()) - .take(); - - fmt.vec("columns", true) - .display_iter(lock.columns.iter()) - .finish(&mut fmt); - - fmt.vec("indexes", true) - .display_iter(lock.indexes.iter()) - .finish(&mut fmt); - - fmt.vec("foreign_keys", true) - .display_iter(lock.foreign_keys.iter()) - .finish(&mut fmt); - - fmt.vec("checks", true) - .display_iter(lock.checks.iter()) - .finish(&mut fmt); - - fmt.optional_boolean("if_not_exists", lock.options & OPT_IF_NOT_EXISTS > 0) - .optional_boolean("temporary", lock.options & OPT_TEMPORARY > 0) - .optional_quote("comment", lock.comment.as_ref()) - .optional_quote("engine", lock.engine.as_ref()) - .optional_quote("collate", lock.collate.as_ref()) - .optional_quote("character_set", lock.character_set.as_ref()) - .finish() - } -} diff --git a/src/schema/table_operations.rs b/src/schema/table_operations.rs deleted file mode 100644 index 19ac707..0000000 --- a/src/schema/table_operations.rs +++ /dev/null @@ -1,364 +0,0 @@ -use super::base::PySchemaStatement; -use crate::common::table_ref::PyTableName; -use crate::internal::repr::ReprFormatter; -use crate::internal::{BoundArgs, BoundKwargs, RefBoundObject, ToSeaQuery}; - -pub const DROP_OPT_IF_EXISTS: u8 = 1 << 0; -pub const DROP_OPT_CASCADE: u8 = 1 << 1; -pub const DROP_OPT_RESTRICT: u8 = 1 << 2; - -crate::implement_pyclass! { - /// Represents a DROP TABLE SQL statement. - /// - /// Builds table deletion statements with support for: - /// - Conditional deletion (IF EXISTS) to avoid errors - /// - CASCADE to drop dependent objects - /// - RESTRICT to prevent deletion if dependencies exist - mutable [subclass, extends=PySchemaStatement] PyDropTable(DropTableState) as "DropTable" { - name: PyTableName, - options: u8, - } -} -crate::implement_pyclass! { - /// Represents a RENAME TABLE SQL statement. - /// - /// Changes the name of an existing table to a new name. Both names can be - /// schema-qualified if needed. - mutable [subclass, extends=PySchemaStatement] PyRenameTable(RenameTableState) as "RenameTable" { - from_name: PyTableName, - to_name: PyTableName, - } -} -crate::implement_pyclass! { - /// Represents a TRUNCATE TABLE SQL statement. - /// - /// Quickly removes all rows from a table, typically faster than DELETE - /// and with different transaction and trigger behavior depending on the - /// database system. - mutable [subclass, extends=PySchemaStatement] PyTruncateTable(TruncateTableState) as "TruncateTable" { - name: PyTableName, - } -} - -impl Clone for DropTableState { - fn clone(&self) -> Self { - Self { - name: self.name.clone(), - options: self.options, - } - } -} - -impl ToSeaQuery for DropTableState { - fn to_sea_query<'a>(&self, _py: pyo3::Python<'a>) -> sea_query::TableDropStatement { - let mut stmt = sea_query::TableDropStatement::new(); - stmt.table(self.name.clone()); - - if self.options & DROP_OPT_IF_EXISTS > 0 { - stmt.if_exists(); - } - if self.options & DROP_OPT_RESTRICT > 0 { - stmt.restrict(); - } - if self.options & DROP_OPT_CASCADE > 0 { - stmt.cascade(); - } - - stmt - } -} - -#[pyo3::pymethods] -impl PyDropTable { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> (Self, PySchemaStatement) { - (Self::uninit(), PySchemaStatement) - } - - #[pyo3(signature = (name, *, if_exists=false, cascade=false, restrict=false))] - fn __init__( - &self, - name: RefBoundObject<'_>, - if_exists: bool, - cascade: bool, - restrict: bool, - ) -> pyo3::PyResult<()> { - let name = PyTableName::try_from(name)?; - - let mut options = 0u8; - if if_exists { - options |= DROP_OPT_IF_EXISTS; - } - if cascade { - options |= DROP_OPT_CASCADE; - } - if restrict { - options |= DROP_OPT_RESTRICT; - } - - let state = DropTableState { name, options }; - self.0.set(state); - Ok(()) - } - - /// The table name to drop. - #[getter] - fn name(&self) -> PyTableName { - let lock = self.0.lock(); - lock.name.clone() - } - - #[setter] - fn set_name(&self, val: RefBoundObject<'_>) -> pyo3::PyResult<()> { - let val = PyTableName::try_from(val)?; - - let mut lock = self.0.lock(); - lock.name = val; - Ok(()) - } - - #[getter] - fn if_exists(&self) -> bool { - self.0.lock().options & DROP_OPT_IF_EXISTS > 0 - } - - #[setter] - fn set_if_exists(&self, value: bool) { - let mut lock = self.0.lock(); - if value { - lock.options |= DROP_OPT_IF_EXISTS; - } else { - lock.options &= !DROP_OPT_IF_EXISTS; - } - } - - #[getter] - fn cascade(&self) -> bool { - self.0.lock().options & DROP_OPT_CASCADE > 0 - } - - #[setter] - fn set_cascade(&self, value: bool) { - let mut lock = self.0.lock(); - if value { - lock.options |= DROP_OPT_CASCADE; - } else { - lock.options &= !DROP_OPT_CASCADE; - } - } - - #[getter] - fn restrict(&self) -> bool { - self.0.lock().options & DROP_OPT_RESTRICT > 0 - } - - #[setter] - fn set_restrict(&self, value: bool) { - let mut lock = self.0.lock(); - if value { - lock.options |= DROP_OPT_RESTRICT; - } else { - lock.options &= !DROP_OPT_RESTRICT; - } - } - - fn __copy__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult> { - let lock = self.0.lock(); - pyo3::Py::new(py, (lock.clone().into(), PySchemaStatement)) - } - - #[pyo3(signature = (backend, /))] - #[allow(clippy::wrong_self_convention)] - fn to_sql(&self, py: pyo3::Python<'_>, backend: String) -> pyo3::PyResult { - let lock = self.0.lock(); - let stmt = lock.to_sea_query(py); - drop(lock); - - crate::build_schema_statement!(backend, stmt) - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let lock = slf.0.lock(); - - ReprFormatter::new_with_pyref(&slf) - .map("name", &lock.name, |x| x.__repr__()) - .optional_boolean("if_exists", lock.options & DROP_OPT_IF_EXISTS > 0) - .optional_boolean("cascade", lock.options & DROP_OPT_CASCADE > 0) - .optional_boolean("restrict", lock.options & DROP_OPT_RESTRICT > 0) - .finish() - } -} - -impl Clone for RenameTableState { - fn clone(&self) -> Self { - Self { - from_name: self.from_name.clone(), - to_name: self.to_name.clone(), - } - } -} - -impl ToSeaQuery for RenameTableState { - fn to_sea_query<'a>(&self, _py: pyo3::Python<'a>) -> sea_query::TableRenameStatement { - let mut stmt = sea_query::TableRenameStatement::new(); - stmt.table(self.from_name.clone(), self.to_name.clone()); - stmt - } -} - -#[pyo3::pymethods] -impl PyRenameTable { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> (Self, PySchemaStatement) { - (Self::uninit(), PySchemaStatement) - } - - #[pyo3(signature = (from_name, to_name))] - fn __init__( - &self, - from_name: RefBoundObject<'_>, - to_name: RefBoundObject<'_>, - ) -> pyo3::PyResult<()> { - let from_name = PyTableName::try_from(from_name)?; - let to_name = PyTableName::try_from(to_name)?; - - let state = RenameTableState { from_name, to_name }; - self.0.set(state); - Ok(()) - } - - /// The current name of the table. - #[getter] - #[allow(clippy::wrong_self_convention)] - fn from_name(&self) -> PyTableName { - let lock = self.0.lock(); - lock.from_name.clone() - } - - #[setter] - fn set_from_name(&self, val: RefBoundObject<'_>) -> pyo3::PyResult<()> { - let val = PyTableName::try_from(val)?; - - let mut lock = self.0.lock(); - lock.from_name = val; - Ok(()) - } - - /// The new name for the table. - #[getter] - fn to_name(&self) -> PyTableName { - let lock = self.0.lock(); - lock.to_name.clone() - } - - #[setter] - fn set_to_name(&self, val: RefBoundObject<'_>) -> pyo3::PyResult<()> { - let val = PyTableName::try_from(val)?; - - let mut lock = self.0.lock(); - lock.to_name = val; - Ok(()) - } - - fn __copy__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult> { - let lock = self.0.lock(); - pyo3::Py::new(py, (lock.clone().into(), PySchemaStatement)) - } - - #[pyo3(signature = (backend, /))] - #[allow(clippy::wrong_self_convention)] - fn to_sql(&self, py: pyo3::Python<'_>, backend: String) -> pyo3::PyResult { - let lock = self.0.lock(); - let stmt = lock.to_sea_query(py); - drop(lock); - - crate::build_schema_statement!(backend, stmt) - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let lock = slf.0.lock(); - - ReprFormatter::new_with_pyref(&slf) - .map("from_name", &lock.from_name, |x| x.__repr__()) - .map("to_name", &lock.to_name, |x| x.__repr__()) - .finish() - } -} - -impl Clone for TruncateTableState { - fn clone(&self) -> Self { - Self { - name: self.name.clone(), - } - } -} - -impl ToSeaQuery for TruncateTableState { - fn to_sea_query<'a>(&self, _py: pyo3::Python<'a>) -> sea_query::TableTruncateStatement { - let mut stmt = sea_query::TableTruncateStatement::new(); - stmt.table(self.name.clone()); - stmt - } -} - -#[pyo3::pymethods] -impl PyTruncateTable { - #[new] - #[allow(unused_variables)] - #[pyo3(signature=(*args, **kwds))] - fn __new__(args: BoundArgs<'_>, kwds: Option>) -> (Self, PySchemaStatement) { - (Self::uninit(), PySchemaStatement) - } - - #[pyo3(signature = (name))] - fn __init__(&self, name: RefBoundObject<'_>) -> pyo3::PyResult<()> { - let name = PyTableName::try_from(name)?; - - let state = TruncateTableState { name }; - self.0.set(state); - Ok(()) - } - - /// The name of the table to truncate. - #[getter] - fn name(&self) -> PyTableName { - let lock = self.0.lock(); - lock.name.clone() - } - - #[setter] - fn set_name(&self, val: RefBoundObject<'_>) -> pyo3::PyResult<()> { - let val = PyTableName::try_from(val)?; - - let mut lock = self.0.lock(); - lock.name = val; - Ok(()) - } - - fn __copy__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult> { - let lock = self.0.lock(); - pyo3::Py::new(py, (lock.clone().into(), PySchemaStatement)) - } - - #[pyo3(signature = (backend, /))] - #[allow(clippy::wrong_self_convention)] - fn to_sql(&self, py: pyo3::Python<'_>, backend: String) -> pyo3::PyResult { - let lock = self.0.lock(); - let stmt = lock.to_sea_query(py); - drop(lock); - - crate::build_schema_statement!(backend, stmt) - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let lock = slf.0.lock(); - - ReprFormatter::new_with_pyref(&slf) - .map("name", &lock.name, |x| x.__repr__()) - .finish() - } -} diff --git a/src/sqlite.rs b/src/sqlite.rs deleted file mode 100644 index b5783a7..0000000 --- a/src/sqlite.rs +++ /dev/null @@ -1,5 +0,0 @@ -/// SQLite-Only functions -/// -/// **Comming Soon ...** -#[pyo3::pymodule(name = "sqlite")] -pub mod sqlite_module {} diff --git a/src/sqltypes/abstracts.rs b/src/sqltypes/abstracts.rs deleted file mode 100644 index 1b9ba0a..0000000 --- a/src/sqltypes/abstracts.rs +++ /dev/null @@ -1,138 +0,0 @@ -/// All of native SQL types should implement this trait. -/// -/// This is useful for generic typing. -pub trait SQLTypeTrait { - /// Returns the related sea_query column type - fn to_sea_query_column_type(&self) -> ::sea_query::ColumnType; - - /// Returns SQL type name in string (uses PostgreSQL builder) - fn to_sql_type_name(&self) -> String { - let mut name = String::with_capacity(10); - let builder = sea_query::PostgresQueryBuilder; - sea_query::TableBuilder::prepare_column_type( - &builder, - &self.to_sea_query_column_type(), - &mut name, - ); - name - } - - /// Validates the `ptr` and makes sure that we can process it by using - /// `serialize` method. - /// - /// ### Safety - /// - The `ptr` should be borrowed. - unsafe fn validate( - &self, - py: ::pyo3::Python, - ptr: *mut ::pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()>; - - /// Converts PyObject into sea_query::Value - /// - /// ### Safety - /// - The `ptr` should be borrowed. - /// - The `ptr` should be validated by `validate` method. - unsafe fn serialize( - &self, - py: ::pyo3::Python, - ptr: *mut ::pyo3::ffi::PyObject, - ) -> pyo3::PyResult<::sea_query::Value>; - - /// Converts sea_query::Value into PyObject - /// - /// ### Safety - /// - The returned object is owned and you should call Py_DECREF if you do - /// not need it. - unsafe fn deserialize( - &self, - py: ::pyo3::Python, - value: &::sea_query::Value, - ) -> pyo3::PyResult<*mut ::pyo3::ffi::PyObject>; -} - -crate::implement_pyclass! { - /// Base class for all SQL column data types. - /// - /// This abstract base class represents SQL data types that can be used in - /// column definitions. Each subclass implements a specific SQL data type - /// with its particular characteristics, constraints, and backend-specific - /// representations. - #[derive(Debug, Clone, Copy)] - [subclass, generic] PySQLTypeAbstract as "SQLTypeAbstract"; -} - -#[pyo3::pymethods] -impl PySQLTypeAbstract { - /// Type name. e.g. `'INTEGER'`, `'STRING'` - /// - /// It also may be a property. This function must NOT raise any error. - #[getter] - fn __type_name__(&self) -> pyo3::PyResult<()> { - Err(pyo3::exceptions::PyNotImplementedError::new_err(())) - } -} - -#[macro_export] -macro_rules! implement_sqltype_pymethods { - ($name:ident) => { - #[pyo3::pymethods] - impl $name { - #[new] - fn __new__() -> (Self, PySQLTypeAbstract) { - (Self, PySQLTypeAbstract) - } - - /// Type name. e.g. `'INTEGER'`, `'STRING'` - /// - /// It also may be a property. This function must NOT raise any error. - #[getter] - fn __type_name__(&self) -> String { - self.to_sql_type_name() - } - - fn __repr__(&self) -> String { - let result = String::from(stringify!($name)); - result[2..].to_string() + "()" - } - } - }; - - ( - $name:ident, - init(|$param:ident: $param_type:ty| $init:expr), - $return_type:literal - $(, signature($($signature:tt)*))? - ) => { - #[pyo3::pymethods] - impl $name { - #[new] - $(#[pyo3(signature=($($signature)*))])? - fn __new__($param: $param_type) -> (Self, PySQLTypeAbstract) { - ($init, PySQLTypeAbstract) - } - - /// Type name. e.g. `'INTEGER'`, `'STRING'` - /// - /// It also may be a property. This function must NOT raise any error. - #[getter] - fn __type_name__(&self) -> String { - self.to_sql_type_name() - } - - #[getter] - fn $param(&self) -> $param_type { - self.0 - } - - fn __repr__(&self) -> String { - let result = format!( - concat!(stringify!($name), "(", stringify!($param), "={:?})"), - self.0 - ); - result[2..].to_string() - } - } - }; -} -pub(super) use implement_sqltype_pymethods; diff --git a/src/sqltypes/binary.rs b/src/sqltypes/binary.rs deleted file mode 100644 index 6c6e077..0000000 --- a/src/sqltypes/binary.rs +++ /dev/null @@ -1,352 +0,0 @@ -use crate::sqltypes::abstracts::{PySQLTypeAbstract, SQLTypeTrait}; - -crate::implement_pyclass! { - /// Binary large object column type (BLOB). - /// - /// Stores large binary data such as images, documents, audio files, or - /// any binary content. Size limits vary by database system. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyBlobType as "Blob"; -} -crate::implement_pyclass! { - /// Fixed-length binary data column type (BINARY). - /// - /// Stores binary data of a fixed length. Values shorter than the specified - /// length are padded. Useful for storing hashes, keys, or other binary - /// data with consistent length. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyBinaryType as "Binary" (pub u32); -} -crate::implement_pyclass! { - /// Variable-length binary data column type (VARBINARY). - /// - /// Stores binary data of variable length up to a specified maximum. - /// More storage-efficient than BINARY for binary data of varying lengths. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyVarBinaryType as "VarBinary" (pub Option); -} -crate::implement_pyclass! { - /// Fixed-length bit string column type (BIT). - /// - /// Stores a fixed number of bits. Useful for storing boolean flags efficiently - /// or binary data where individual bits have meaning. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyBitType as "Bit" (pub Option); -} -crate::implement_pyclass! { - /// Variable-length bit string column type (VARBIT). - /// - /// Stores a variable number of bits up to a specified maximum. More flexible - /// than fixed BIT type for bit strings of varying lengths. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyVarBitType as "VarBit" (pub u32); - -} - -#[inline] -unsafe fn _serialize_function( - object: *mut pyo3::ffi::PyObject, -) -> pyo3::PyResult { - let buffer = pyo3::ffi::PyBytes_AsString(object) as *const u8; - let size = pyo3::ffi::PyBytes_Size(object) as usize; - - debug_assert!(!buffer.is_null()); - - let val = std::slice::from_raw_parts(buffer, size); - - Ok(sea_query::Value::Bytes(Some(Box::new(val.to_vec())))) -} - -#[inline] -unsafe fn _deserialize_function( - py: pyo3::Python, - value: &[u8], -) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - let pyptr = pyo3::ffi::PyBytes_FromStringAndSize(std::ptr::null(), value.len() as isize); - - if pyptr.is_null() { - return Err(pyo3::PyErr::fetch(py)); - } - - let buffer = pyo3::ffi::PyBytes_AsString(pyptr) as *mut u8; - debug_assert!(!buffer.is_null()); - - let mutable = std::slice::from_raw_parts_mut(buffer, value.len()); - mutable.copy_from_slice(value); - - Ok(pyptr) -} - -impl SQLTypeTrait for PyBlobType { - #[inline(always)] - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::Blob - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - if pyo3::ffi::PyBytes_CheckExact(ptr) != 1 { - crate::new_error!( - PyTypeError, - "expected bytes for {} serialization, got {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ) - } else { - Ok(()) - } - } - - unsafe fn serialize( - &self, - _py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - _serialize_function(ptr) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::Bytes(Some(x)) => _deserialize_function(py, x), - sea_query::Value::Bytes(None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected bytes for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -impl SQLTypeTrait for PyBinaryType { - #[inline(always)] - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::Binary(self.0) - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - if pyo3::ffi::PyBytes_CheckExact(ptr) != 1 { - crate::new_error!( - PyTypeError, - "expected bytes for {} serialization, got {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ) - } else { - Ok(()) - } - } - - unsafe fn serialize( - &self, - _py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - _serialize_function(ptr) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::Bytes(Some(x)) => _deserialize_function(py, x), - sea_query::Value::Bytes(None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected bytes for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -impl SQLTypeTrait for PyVarBinaryType { - #[inline(always)] - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::VarBinary( - self.0 - .map_or(sea_query::StringLen::None, sea_query::StringLen::N), - ) - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - if pyo3::ffi::PyBytes_CheckExact(ptr) != 1 { - crate::new_error!( - PyTypeError, - "expected bytes for {} serialization, got {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ) - } else { - Ok(()) - } - } - - unsafe fn serialize( - &self, - _py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - _serialize_function(ptr) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::Bytes(Some(x)) => _deserialize_function(py, x), - sea_query::Value::Bytes(None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected bytes for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -impl SQLTypeTrait for PyBitType { - #[inline(always)] - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::Bit(self.0) - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - if pyo3::ffi::PyBytes_CheckExact(ptr) != 1 { - crate::new_error!( - PyTypeError, - "expected bytes for {} serialization, got {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ) - } else { - Ok(()) - } - } - - unsafe fn serialize( - &self, - _py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - _serialize_function(ptr) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::Bytes(Some(x)) => _deserialize_function(py, x), - sea_query::Value::Bytes(None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected bytes for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -impl SQLTypeTrait for PyVarBitType { - #[inline(always)] - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::VarBit(self.0) - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - if pyo3::ffi::PyBytes_CheckExact(ptr) != 1 { - crate::new_error!( - PyTypeError, - "expected bytes for {} serialization, got {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ) - } else { - Ok(()) - } - } - - unsafe fn serialize( - &self, - _py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - _serialize_function(ptr) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::Bytes(Some(x)) => _deserialize_function(py, x), - sea_query::Value::Bytes(None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected bytes for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -super::abstracts::implement_sqltype_pymethods!(PyBlobType); -super::abstracts::implement_sqltype_pymethods!( - PyBinaryType, - init(|length: u32| Self(length)), - "int", - signature(length = 255) -); -super::abstracts::implement_sqltype_pymethods!( - PyVarBinaryType, - init(|length: Option| Self(length)), - "int", - signature(length = None) -); -super::abstracts::implement_sqltype_pymethods!( - PyBitType, - init(|length: Option| Self(length)), - "int", - signature(length = None) -); -super::abstracts::implement_sqltype_pymethods!( - PyVarBitType, - init(|length: u32| Self(length)), - "int", - signature(length = 255) -); diff --git a/src/sqltypes/datetimes.rs b/src/sqltypes/datetimes.rs deleted file mode 100644 index 596c4f9..0000000 --- a/src/sqltypes/datetimes.rs +++ /dev/null @@ -1,353 +0,0 @@ -use crate::sqltypes::abstracts::{PySQLTypeAbstract, SQLTypeTrait}; - -use chrono::TimeZone; -use pyo3::types::{PyAnyMethods, PyTzInfoAccess}; -use pyo3::IntoPyObject; - -crate::implement_pyclass! { - /// Date and time column type (DATETIME). - /// - /// Stores both date and time information without timezone awareness. - /// Suitable for recording timestamps, event times, or scheduling information - /// when timezone handling is managed at the application level. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyDateTimeType as "DateTime"; -} -crate::implement_pyclass! { - /// Timestamp column type (TIMESTAMP). - /// - /// Stores timestamp values, often with automatic update capabilities. - /// Behavior varies by database system. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyTimestampType as "Timestamp" (pub bool); -} -crate::implement_pyclass! { - /// Time-only column type (TIME). - /// - /// Stores time information without date component. Useful for storing - /// daily schedules, opening hours, or any time-based data that repeats - /// daily regardless of the specific date. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyTimeType as "Time"; -} -crate::implement_pyclass! { - /// Date-only column type (DATE). - /// - /// Stores date information without time component. Ideal for birth dates, - /// deadlines, or any date-based data where time precision is not needed. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyDateType as "Date"; - -} - -#[inline(always)] -unsafe fn _serialize_datetime( - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, -) -> pyo3::PyResult { - let val: pyo3::Bound<'_, pyo3::types::PyDateTime> = - pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; - - let tzinfo = val.get_tzinfo(); - - if tzinfo.is_none() { - let result = Box::new(val.extract()?); - Ok(sea_query::Value::ChronoDateTime(Some(result))) - } else { - let result = Box::new(val.extract()?); - Ok(sea_query::Value::ChronoDateTimeWithTimeZone(Some(result))) - } -} - -impl SQLTypeTrait for PyDateTimeType { - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::DateTime - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - if pyo3::ffi::Py_TYPE(ptr) != crate::typeref::STD_DATETIME_TYPE { - crate::new_error!( - PyTypeError, - "expected datetime.datetime for {} serialization, got {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ) - } else { - Ok(()) - } - } - - unsafe fn serialize( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - _serialize_datetime(py, ptr) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::ChronoDateTime(Some(x)) => { - let pyobject = x.clone().into_pyobject(py)?; - Ok(pyobject.into_ptr()) - } - sea_query::Value::ChronoDateTimeWithTimeZone(Some(x)) => { - let pyobject = x.clone().into_pyobject(py)?; - Ok(pyobject.into_ptr()) - } - sea_query::Value::ChronoDateTimeUtc(Some(x)) => { - let pyobject = x.clone().into_pyobject(py)?; - Ok(pyobject.into_ptr()) - } - sea_query::Value::ChronoDateTimeLocal(Some(x)) => { - let pyobject = x.to_utc().into_pyobject(py)?; - Ok(pyobject.into_ptr()) - } - sea_query::Value::ChronoDateTime(None) - | sea_query::Value::ChronoDateTimeWithTimeZone(None) - | sea_query::Value::ChronoDateTimeUtc(None) - | sea_query::Value::ChronoDateTimeLocal(None) => Ok(pyo3::ffi::Py_None()), - - _ => crate::new_error!( - PyTypeError, - "expected datetime for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -impl SQLTypeTrait for PyTimestampType { - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - if self.0 { - sea_query::ColumnType::TimestampWithTimeZone - } else { - sea_query::ColumnType::Timestamp - } - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - if pyo3::ffi::Py_TYPE(ptr) != crate::typeref::STD_DATETIME_TYPE - && pyo3::ffi::PyLong_CheckExact(ptr) != 1 - && pyo3::ffi::PyFloat_CheckExact(ptr) != 1 - { - crate::new_error!( - PyTypeError, - "expected datetime.datetime/int/float for {} serialization, got {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ) - } else { - Ok(()) - } - } - - unsafe fn serialize( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - if pyo3::ffi::PyLong_CheckExact(ptr) == 1 { - let mut has_overflow: i32 = 0; - let num = pyo3::ffi::PyLong_AsLongLongAndOverflow(ptr, &mut has_overflow); - - if has_overflow == 1 { - return Err(pyo3::exceptions::PyOverflowError::new_err("out of range")); - } - - match chrono::Utc.timestamp_opt(num, 0) { - chrono::offset::LocalResult::Single(result) => { - return Ok(sea_query::Value::ChronoDateTimeUtc(Some(Box::new(result)))); - } - _ => { - return Err(pyo3::exceptions::PyOverflowError::new_err( - "timestamp is invalid", - )); - } - } - } - - if pyo3::ffi::PyFloat_CheckExact(ptr) == 1 { - let num = pyo3::ffi::PyFloat_AsDouble(ptr); - - match chrono::Utc - .timestamp_opt(num.trunc() as i64, (num.fract() * 1_000_000_000.0) as u32) - { - chrono::offset::LocalResult::Single(result) => { - return Ok(sea_query::Value::ChronoDateTimeUtc(Some(Box::new(result)))); - } - _ => { - return Err(pyo3::exceptions::PyOverflowError::new_err( - "timestamp is invalid", - )); - } - } - } - - _serialize_datetime(py, ptr) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::ChronoDateTime(Some(x)) => { - let pyobject = x.clone().into_pyobject(py)?; - Ok(pyobject.into_ptr()) - } - sea_query::Value::ChronoDateTimeWithTimeZone(Some(x)) => { - let pyobject = x.clone().into_pyobject(py)?; - Ok(pyobject.into_ptr()) - } - sea_query::Value::ChronoDateTimeUtc(Some(x)) => { - let pyobject = x.clone().into_pyobject(py)?; - Ok(pyobject.into_ptr()) - } - sea_query::Value::ChronoDateTime(None) - | sea_query::Value::ChronoDateTimeWithTimeZone(None) - | sea_query::Value::ChronoDateTimeUtc(None) => Ok(pyo3::ffi::Py_None()), - - _ => crate::new_error!( - PyTypeError, - "expected datetime for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -impl SQLTypeTrait for PyDateType { - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::Date - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - if pyo3::ffi::Py_TYPE(ptr) != crate::typeref::STD_DATE_TYPE { - crate::new_error!( - PyTypeError, - "expected datetime.date for {} serialization, got {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ) - } else { - Ok(()) - } - } - - unsafe fn serialize( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - let val: pyo3::Bound<'_, pyo3::types::PyDate> = - pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; - - let result = Box::new(val.extract()?); - Ok(sea_query::Value::ChronoDate(Some(result))) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::ChronoDate(Some(x)) => { - let pyobject = x.clone().into_pyobject(py)?; - Ok(pyobject.into_ptr()) - } - sea_query::Value::ChronoDate(None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected date for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -impl SQLTypeTrait for PyTimeType { - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::Time - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - if pyo3::ffi::Py_TYPE(ptr) != crate::typeref::STD_TIME_TYPE { - crate::new_error!( - PyTypeError, - "expected datetime.time for {} serialization, got {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ) - } else { - Ok(()) - } - } - - unsafe fn serialize( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - let val: pyo3::Bound<'_, pyo3::types::PyTime> = - pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; - - let result = Box::new(val.extract()?); - Ok(sea_query::Value::ChronoTime(Some(result))) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::ChronoTime(Some(x)) => { - let pyobject = x.clone().into_pyobject(py)?; - Ok(pyobject.into_ptr()) - } - sea_query::Value::ChronoTime(None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected time for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -super::abstracts::implement_sqltype_pymethods!(PyDateTimeType); -super::abstracts::implement_sqltype_pymethods!(PyDateType); -super::abstracts::implement_sqltype_pymethods!(PyTimeType); -super::abstracts::implement_sqltype_pymethods!( - PyTimestampType, - init(|timezone: bool| Self(timezone)), - "bool", - signature(timezone = false) -); diff --git a/src/sqltypes/json.rs b/src/sqltypes/json.rs deleted file mode 100644 index 981503b..0000000 --- a/src/sqltypes/json.rs +++ /dev/null @@ -1,213 +0,0 @@ -use crate::sqltypes::abstracts::{PySQLTypeAbstract, SQLTypeTrait}; - -use pyo3::types::PyAnyMethods; - -crate::implement_pyclass! { - /// JSON data column type (JSON). - /// - /// Stores JSON documents with validation and indexing capabilities. - /// Allows for flexible schema design and complex nested data structures - /// while maintaining some query capabilities. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyJSONType as "JSON"; -} -crate::implement_pyclass! { - /// Binary JSON column type (JSONB). - /// - /// Stores JSON documents in a binary format for improved performance. - /// Provides faster query and manipulation operations compared to text-based - /// JSON storage, with additional indexing capabilities. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyJSONBinaryType as "JSONBinary"; -} - -/// Import json module only once -#[inline(always)] -pub fn import_json_module( - py: pyo3::Python<'_>, -) -> pyo3::PyResult<&pyo3::Bound<'_, pyo3::types::PyModule>> { - static JSON_CLS: std::sync::OnceLock> = - std::sync::OnceLock::new(); - - let json = JSON_CLS.get_or_try_init(|| py.import("json").map(|x| x.unbind())); - json.map(|x| x.bind(py)) -} - -/// Serialize pyobject with Python `json` module -/// -/// Note: `ptr` should be borrowed -#[inline(always)] -pub fn _serialize_object_with_pyjson( - py: pyo3::Python<'_>, - ptr: *mut pyo3::ffi::PyObject, -) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - let json = import_json_module(py)?; - let dumps_func = json.getattr("dumps")?; - - unsafe { - let arg1 = pyo3::Bound::from_borrowed_ptr(py, ptr); - dumps_func.call1((arg1,)).map(|x| x.into_ptr()) - } -} - -/// Deserialize pyobject with Python `json` module -/// -/// Note: `ptr` should be borrowed -#[inline(always)] -pub fn _deserialize_object_with_pyjson( - py: pyo3::Python<'_>, - ptr: *mut pyo3::ffi::PyObject, -) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - let json = import_json_module(py)?; - let loads_func = json.getattr("loads")?; - - unsafe { - let arg1 = pyo3::Bound::from_borrowed_ptr(py, ptr); - loads_func.call1((arg1,)).map(|x| x.into_ptr()) - } -} - -/// Try to serialize pyobject to validate pyobject is JSON-serializable -#[inline] -#[cfg_attr(feature = "optimize", optimize(speed))] -pub fn _validate_json_object( - py: pyo3::Python<'_>, - ptr: *mut pyo3::ffi::PyObject, -) -> pyo3::PyResult<()> { - unsafe { - // Fast path - if (pyo3::ffi::PyLong_CheckExact(ptr) == 1) - || (pyo3::ffi::PyUnicode_CheckExact(ptr) == 1) - || (pyo3::ffi::PyFloat_CheckExact(ptr) == 1) - || (pyo3::ffi::Py_IsNone(ptr) == 1) - { - return Ok(()); - } - } - - _serialize_object_with_pyjson(py, ptr)?; - Ok(()) -} - -#[inline] -#[cfg_attr(feature = "optimize", optimize(speed))] -unsafe fn _serialize_function( - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, -) -> pyo3::PyResult { - let serialized = _serialize_object_with_pyjson(py, ptr)?; - - let mut size: pyo3::ffi::Py_ssize_t = 0; - let c_str = pyo3::ffi::PyUnicode_AsUTF8AndSize(serialized, &mut size); - - if c_str.is_null() || size < 0 { - pyo3::ffi::Py_DECREF(serialized); - Err(pyo3::PyErr::fetch(py)) - } else { - let val = std::ffi::CStr::from_ptr(c_str); - let val = serde_json::from_slice::(val.to_bytes()); - - pyo3::ffi::Py_DECREF(serialized); - - let val = - val.map_err(|x| pyo3::PyErr::new::(x.to_string()))?; - - Ok(sea_query::Value::Json(Some(Box::new(val)))) - } -} - -#[inline] -#[cfg_attr(feature = "optimize", optimize(speed))] -unsafe fn _deserialize_function( - py: pyo3::Python, - value: &serde_json::Value, -) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - let encoded = serde_json::to_vec(value) - .map_err(|x| pyo3::PyErr::new::(x.to_string()))?; - - let val = pyo3::types::PyString::intern(py, std::str::from_utf8_unchecked(&encoded)); - - let val = _deserialize_object_with_pyjson(py, val.as_ptr())?; - Ok(val) -} - -impl SQLTypeTrait for PyJSONType { - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::Json - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - _validate_json_object(py, ptr) - } - - unsafe fn serialize( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - _serialize_function(py, ptr) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::Json(Some(x)) => _deserialize_function(py, x), - sea_query::Value::Json(None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected json for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -impl SQLTypeTrait for PyJSONBinaryType { - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::JsonBinary - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - _validate_json_object(py, ptr) - } - - unsafe fn serialize( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - _serialize_function(py, ptr) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::Json(Some(x)) => _deserialize_function(py, x), - sea_query::Value::Json(None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected json for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -super::abstracts::implement_sqltype_pymethods!(PyJSONType); -super::abstracts::implement_sqltype_pymethods!(PyJSONBinaryType); diff --git a/src/sqltypes/mod.rs b/src/sqltypes/mod.rs deleted file mode 100644 index 5cbd5dc..0000000 --- a/src/sqltypes/mod.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! All SQL types with their serialization and deserialization methods. -//! -//! With support for custom types for Python. - -mod abstracts; -mod binary; -mod datetimes; -mod json; -mod others; -mod primitives; -mod vector; - -pub use abstracts::*; -pub use binary::*; -pub use datetimes::*; -pub use json::*; -pub use others::*; -pub use primitives::*; -pub use vector::*; - -#[pyo3::pymodule(name = "sqltypes")] -pub mod sqltypes_module { - // NOTE: SQLTypes, PyExpr, PyFunc, PyTableName & PyColumnRef could never mark as subclass. - // these should be immutable and final types. - - // sqltypes::abstracts - #[pymodule_export] - use super::PySQLTypeAbstract; - - // sqltypes::binary - #[pymodule_export] - use super::PyBinaryType; - #[pymodule_export] - use super::PyBitType; - #[pymodule_export] - use super::PyBlobType; - #[pymodule_export] - use super::PyVarBinaryType; - #[pymodule_export] - use super::PyVarBitType; - - // sqltypes::datetimes - #[pymodule_export] - use super::PyDateTimeType; - #[pymodule_export] - use super::PyDateType; - #[pymodule_export] - use super::PyTimeType; - #[pymodule_export] - use super::PyTimestampType; - - // sqltypes::json - #[pymodule_export] - use super::PyJSONBinaryType; - #[pymodule_export] - use super::PyJSONType; - - // sqltypes::vector - #[pymodule_export] - use super::PyVectorType; - - // sqltypes::others - #[pymodule_export] - use super::PyArrayType; - #[pymodule_export] - use super::PyDecimalType; - #[pymodule_export] - use super::PyEnumType; - #[pymodule_export] - use super::PyINETType; - #[pymodule_export] - use super::PyMacAddressType; - #[pymodule_export] - use super::PyUUIDType; - - // sqltypes::primitives - #[pymodule_export] - use super::PyBigIntegerType; - #[pymodule_export] - use super::PyBigUnsignedType; - #[pymodule_export] - use super::PyBooleanType; - #[pymodule_export] - use super::PyCharType; - #[pymodule_export] - use super::PyDoubleType; - #[pymodule_export] - use super::PyFloatType; - #[pymodule_export] - use super::PyIntegerType; - #[pymodule_export] - use super::PySmallIntegerType; - #[pymodule_export] - use super::PySmallUnsignedType; - #[pymodule_export] - use super::PyStringType; - #[pymodule_export] - use super::PyTextType; - #[pymodule_export] - use super::PyTinyIntegerType; - #[pymodule_export] - use super::PyTinyUnsignedType; - #[pymodule_export] - use super::PyUnsignedType; -} diff --git a/src/sqltypes/others.rs b/src/sqltypes/others.rs deleted file mode 100644 index 684433f..0000000 --- a/src/sqltypes/others.rs +++ /dev/null @@ -1,491 +0,0 @@ -use pyo3::types::PyAnyMethods; -use pyo3::IntoPyObject; -use sea_query::IntoIden; -use std::str::FromStr; - -use crate::sqltypes::abstracts::{PySQLTypeAbstract, SQLTypeTrait}; - -crate::implement_pyclass! { - /// Exact numeric decimal column type (DECIMAL/NUMERIC). - /// - /// Stores exact numeric values with fixed precision and scale. Essential for - /// financial calculations, currency values, or any situation where exact - /// decimal representation is required without floating-point approximation. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyDecimalType as "Decimal" (pub Option<(u32, u32)>); -} -crate::implement_pyclass! { - /// UUID column type (UUID). - /// - /// Stores universally unique identifiers. Ideal for distributed systems, - /// primary keys, or any situation where globally unique identifiers are - /// needed without central coordination. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyUUIDType as "UUID"; -} -crate::implement_pyclass! { - /// Internet address column type (INET). - /// - /// Stores IPv4 or IPv6 addresses, with or without subnet specification. - /// More flexible than CIDR type, allowing both host addresses and network ranges. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyINETType as "INET"; -} -crate::implement_pyclass! { - /// MAC address column type (MACADDR). - /// - /// Stores MAC (Media Access Control) addresses for network devices. - /// Provides validation and formatting for 6-byte MAC addresses. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyMacAddressType as "MacAddress"; -} -crate::implement_pyclass! { - /// Enumeration column type (ENUM). - /// - /// Stores one value from a predefined set of allowed string values. - /// Provides type safety and storage efficiency for categorical data - /// with a fixed set of possible values. - #[derive(Debug, Clone)] - [extends=PySQLTypeAbstract] PyEnumType as "Enum" { - pub name: sea_query::DynIden, - pub variants: Vec, - } -} - -impl SQLTypeTrait for PyDecimalType { - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::Decimal(self.0) - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - if pyo3::ffi::PyLong_CheckExact(ptr) == 1 { - let mut has_overflow: i32 = 0; - let _ = pyo3::ffi::PyLong_AsLongLongAndOverflow(ptr, &mut has_overflow); - - if has_overflow == 1 { - return Err(pyo3::exceptions::PyOverflowError::new_err("out of range")); - } - - return Ok(()); - } - - if pyo3::ffi::Py_TYPE(ptr) == crate::typeref::STD_DECIMAL_TYPE - || pyo3::ffi::PyFloat_CheckExact(ptr) == 1 - { - return Ok(()); - } - - if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { - let val = super::primitives::_serialize_string(py, ptr).map_err(|x| { - pyo3::PyErr::new::(x.to_string()) - })?; - - rust_decimal::Decimal::from_str_exact(&val).or_else(|_| { - rust_decimal::Decimal::from_scientific(&val).map_err(|x| { - pyo3::PyErr::new::(x.to_string()) - }) - })?; - return Ok(()); - } - - crate::new_error!( - PyTypeError, - "expected decimal.Decimal/int/float/str for {} serialization, got {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ) - } - - unsafe fn serialize( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - if pyo3::ffi::PyLong_CheckExact(ptr) == 1 { - let mut has_overflow: i32 = 0; - let num = pyo3::ffi::PyLong_AsLongLongAndOverflow(ptr, &mut has_overflow); - - if has_overflow == 1 { - return Err(pyo3::exceptions::PyOverflowError::new_err("out of range")); - } - - return Ok(sea_query::Value::Decimal(Some(Box::new( - rust_decimal::Decimal::new(num, 0), - )))); - } - - if pyo3::ffi::PyFloat_CheckExact(ptr) == 1 { - use rust_decimal::prelude::FromPrimitive; - let num = pyo3::ffi::PyFloat_AsDouble(ptr); - - return rust_decimal::Decimal::from_f64(num) - .ok_or_else(|| { - pyo3::PyErr::new::( - "This value cannot be represented as a decimal", - ) - }) - .map(|x| sea_query::Value::Decimal(Some(Box::new(x)))); - } - - if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { - let val = super::primitives::_serialize_string(py, ptr).map_err(|x| { - pyo3::PyErr::new::(x.to_string()) - })?; - - return rust_decimal::Decimal::from_str_exact(&val) - .or_else(|_| { - rust_decimal::Decimal::from_scientific(&val).map_err(|x| { - pyo3::PyErr::new::(x.to_string()) - }) - }) - .map(|x| sea_query::Value::Decimal(Some(Box::new(x)))); - } - - if pyo3::ffi::Py_TYPE(ptr) == crate::typeref::STD_DECIMAL_TYPE { - let borrowed = pyo3::Borrowed::from_ptr(py, ptr); - let val = borrowed.to_string(); - - return rust_decimal::Decimal::from_str_radix(&val, 10) - .or_else(|_| { - rust_decimal::Decimal::from_scientific(&val).map_err(|x| { - pyo3::PyErr::new::(x.to_string()) - }) - }) - .map(|x| sea_query::Value::Decimal(Some(Box::new(x)))); - } - - crate::new_error!( - PyTypeError, - "expected decimal.Decimal/int/float/str for {} serialization, got {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - use pyo3::IntoPyObject; - - match value { - sea_query::Value::Decimal(Some(x)) => x.into_pyobject(py).map(|x| x.into_ptr()), - sea_query::Value::Decimal(None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected decimal for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -impl SQLTypeTrait for PyUUIDType { - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::Uuid - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - if pyo3::ffi::Py_TYPE(ptr) != crate::typeref::STD_UUID_TYPE { - crate::new_error!( - PyTypeError, - "expected uuid.UUID for {} serialization, got {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ) - } else { - Ok(()) - } - } - - unsafe fn serialize( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - let result = pyo3::Bound::from_borrowed_ptr(py, ptr) - .clone() - .extract::<::uuid::Uuid>()?; - - Ok(sea_query::Value::Uuid(Some(Box::new(result)))) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::Uuid(Some(x)) => { - let result = x.clone().into_pyobject(py)?; - Ok(result.into_ptr()) - } - sea_query::Value::Uuid(None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected uuid for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -impl SQLTypeTrait for PyINETType { - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::Inet - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - let string = super::primitives::_serialize_string(py, ptr)?; - - ipnetwork::Ipv4Network::from_str(&string) - .map_err(|x| pyo3::exceptions::PyValueError::new_err(x.to_string()))?; - - Ok(()) - } - - unsafe fn serialize( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - let string = super::primitives::_serialize_string(py, ptr)?; - - ipnetwork::Ipv4Network::from_str(&string) - .map(|x| sea_query::Value::IpNetwork(Some(Box::new(ipnetwork::IpNetwork::V4(x))))) - .map_err(|x| pyo3::exceptions::PyValueError::new_err(x.to_string())) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::IpNetwork(Some(x)) => { - super::primitives::_deserialize_string(py, &x.to_string()) - } - sea_query::Value::IpNetwork(None) => Ok(pyo3::ffi::Py_None()), - sea_query::Value::String(Some(x)) => super::primitives::_deserialize_string(py, x), - sea_query::Value::String(None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected ipnetwork/str for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -impl SQLTypeTrait for PyMacAddressType { - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::MacAddr - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - let string = super::primitives::_serialize_string(py, ptr)?; - - mac_address::MacAddress::from_str(&string) - .map_err(|x| pyo3::exceptions::PyValueError::new_err(x.to_string()))?; - - Ok(()) - } - - unsafe fn serialize( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - let string = super::primitives::_serialize_string(py, ptr)?; - - mac_address::MacAddress::from_str(&string) - .map(|x| sea_query::Value::MacAddress(Some(Box::new(x)))) - .map_err(|x| pyo3::exceptions::PyValueError::new_err(x.to_string())) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::MacAddress(Some(x)) => { - super::primitives::_deserialize_string(py, &x.to_string()) - } - sea_query::Value::MacAddress(None) => Ok(pyo3::ffi::Py_None()), - sea_query::Value::String(Some(x)) => super::primitives::_deserialize_string(py, x), - sea_query::Value::String(None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected mac_address/str for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -const ENUM_TYPE_VALUE: &std::ffi::CStr = c"value"; - -impl SQLTypeTrait for PyEnumType { - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::Enum { - name: self.name.clone(), - variants: self.variants.clone(), - } - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - // https://github.com/ijl/orjson/blob/master/src/util.rs#L55 - if (*pyo3::ffi::Py_TYPE(ptr)).ob_base.ob_base.ob_type == crate::typeref::STD_ENUM_TYPE { - let attribute = pyo3::ffi::PyObject_GetAttrString(ptr, ENUM_TYPE_VALUE.as_ptr()); - - if attribute.is_null() { - return Err(pyo3::PyErr::fetch(py)); - } - - if pyo3::ffi::PyUnicode_CheckExact(attribute) == 0 { - let type_error = crate::new_error!( - PyTypeError, - "Enum value wasn't str for {} serialization, was {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, attribute) - ); - - pyo3::ffi::Py_DECREF(attribute); - return type_error; - } - } else if pyo3::ffi::PyUnicode_CheckExact(ptr) == 0 { - return crate::new_error!( - PyTypeError, - "expected Enum/str for {} serialization, got {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ); - } - - Ok(()) - } - - unsafe fn serialize( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - let string = { - if (*pyo3::ffi::Py_TYPE(ptr)).ob_base.ob_base.ob_type == crate::typeref::STD_ENUM_TYPE { - let attribute = pyo3::ffi::PyObject_GetAttrString(ptr, ENUM_TYPE_VALUE.as_ptr()); - - if attribute.is_null() { - return Err(pyo3::PyErr::fetch(py)); - } - - // SAFETY: Type of the attribute was checked in `validate` method - attribute - } else { - pyo3::ffi::Py_INCREF(ptr); - - // SAFETY: Type of the attribute was checked in `validate` method - ptr - } - }; - - let serialized = super::primitives::_serialize_string(py, string); - pyo3::ffi::Py_DECREF(string); - - serialized.map(|x| sea_query::Value::String(Some(Box::new(x)))) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::String(Some(x)) => super::primitives::_deserialize_string(py, x), - sea_query::Value::String(None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected str for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -super::abstracts::implement_sqltype_pymethods!(PyUUIDType); -super::abstracts::implement_sqltype_pymethods!(PyINETType); -super::abstracts::implement_sqltype_pymethods!(PyMacAddressType); -super::abstracts::implement_sqltype_pymethods!( - PyDecimalType, - init(|context: Option<(u32, u32)>| Self(context)), - "tuple[int, int] | None", - signature(context = None) -); - -#[pyo3::pymethods] -impl PyEnumType { - #[new] - fn __new__(name: String, variants: Vec) -> (Self, PySQLTypeAbstract) { - let slf = Self { - name: sea_query::Alias::new(name).into_iden(), - variants: variants - .into_iter() - .map(|x| sea_query::Alias::new(x).into_iden()) - .collect(), - }; - - (slf, PySQLTypeAbstract) - } - - /// Type name. e.g. `'INTEGER'`, `'STRING'` - /// - /// It also may be a property. This function must NOT raise any error. - #[getter] - fn __type_name__(&self) -> String { - self.to_sql_type_name() - } - - #[getter] - fn name(&self) -> String { - self.name.to_string() - } - - #[getter] - fn variants(&self) -> Vec { - self.variants.iter().map(|x| x.to_string()).collect() - } - - fn __repr__(&self) -> String { - format!( - "EnumType(name={:?}, variants={:?})", - self.name.to_string(), - self.variants.iter().map(|x| x.to_string()) - ) - } -} diff --git a/src/sqltypes/primitives.rs b/src/sqltypes/primitives.rs deleted file mode 100644 index a4cb8ff..0000000 --- a/src/sqltypes/primitives.rs +++ /dev/null @@ -1,556 +0,0 @@ -use crate::sqltypes::abstracts::{PySQLTypeAbstract, SQLTypeTrait}; - -crate::implement_pyclass! { - /// Boolean column type (BOOLEAN). - /// - /// Stores true/false values. The standard way to store boolean data, - /// though implementation varies by database (some use TINYINT(1) or - /// similar representations). - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyBooleanType as "Boolean"; -} -crate::implement_pyclass! { - /// Very small integer column type (TINYINT). - /// - /// Typically stores integers in the range -128 to 127 (signed) or 0 to 255 - /// (unsigned). Useful for flags, small counters, or enumerated values. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyTinyIntegerType as "TinyInteger"; -} -crate::implement_pyclass! { - /// Small integer column type (SMALLINT). - /// - /// Typically stores integers in the range -32,768 to 32,767 (signed) or - /// 0 to 65,535 (unsigned). Good for moderate-sized counters or numeric codes. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PySmallIntegerType as "SmallInteger"; -} -crate::implement_pyclass! { - /// Standard integer column type (INTEGER/INT). - /// - /// The most common integer type, typically storing 32-bit integers in the - /// range -2,147,483,648 to 2,147,483,647 (signed). Suitable for most - /// numeric data including IDs, quantities, and counters. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyIntegerType as "Integer"; -} -crate::implement_pyclass! { - /// Large integer column type (BIGINT). - /// - /// Stores 64-bit integers for very large numeric values. Essential for - /// high-volume systems, timestamps, large counters, or when integer - /// overflow is a concern. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyBigIntegerType as "BigInteger"; -} -crate::implement_pyclass! { - /// Unsigned tiny integer column type. - /// - /// Stores small positive integers only, typically 0 to 255. Useful for - /// small counters, percentages, or enumerated values that are always positive. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyTinyUnsignedType as "TinyUnsigned"; -} -crate::implement_pyclass! { - /// Unsigned small integer column type. - /// - /// Stores moderate positive integers only, typically 0 to 65,535. Good for - /// larger counters or numeric codes that are always positive. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PySmallUnsignedType as "SmallUnsigned"; -} -crate::implement_pyclass! { - /// Unsigned integer column type. - /// - /// Stores positive integers only, typically 0 to 4,294,967,295. Doubles the - /// positive range compared to signed integers, useful for IDs and counters - /// that will never be negative. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyUnsignedType as "Unsigned"; -} -crate::implement_pyclass! { - /// Unsigned big integer column type. - /// - /// Stores very large positive integers only. Provides the maximum positive - /// integer range for high-volume systems or when very large positive - /// values are required. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyBigUnsignedType as "BigUnsigned"; -} -crate::implement_pyclass! { - /// Single-precision floating point column type (FLOAT). - /// - /// Stores approximate numeric values with single precision. Suitable for - /// scientific calculations, measurements, or any numeric data where some - /// precision loss is acceptable in exchange for storage efficiency. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyFloatType as "Float"; -} -crate::implement_pyclass! { - /// Double-precision floating point column type (DOUBLE). - /// - /// Stores approximate numeric values with double precision. Provides higher - /// precision than FLOAT for scientific calculations or when more accuracy - /// is required in floating-point operations. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyDoubleType as "Double"; -} -crate::implement_pyclass! { - /// Large text column type (TEXT). - /// - /// Represents a large text field capable of storing long strings without - /// a predefined length limit. Suitable for storing articles, comments, - /// descriptions, or any text content that may be very long. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyTextType as "Text"; -} -crate::implement_pyclass! { - /// Fixed-length character string column type (CHAR). - /// - /// Represents a fixed-length character string. Values shorter than the - /// specified length are padded with spaces. Suitable for storing data - /// with consistent, known lengths like country codes or status flags. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyCharType as "Char" (pub Option); -} -crate::implement_pyclass! { - /// Variable-length character string column type (VARCHAR). - /// - /// Represents a variable-length character string with a maximum length limit. - /// This is the most common string type for storing text data of varying lengths - /// like names, descriptions, or user input. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyStringType as "String" (pub Option); -} - -impl SQLTypeTrait for PyBooleanType { - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::Boolean - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - if pyo3::ffi::PyBool_Check(ptr) != 1 { - crate::new_error!( - PyTypeError, - "expected bool for {} serialization, got {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ) - } else { - Ok(()) - } - } - - unsafe fn serialize( - &self, - _py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - if pyo3::ffi::Py_True() == ptr { - Ok(sea_query::Value::Bool(Some(true))) - } else { - Ok(sea_query::Value::Bool(Some(false))) - } - } - - unsafe fn deserialize( - &self, - _py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::Bool(Some(x)) if *x => Ok(pyo3::ffi::Py_True()), - sea_query::Value::Bool(Some(x)) if !*x => Ok(pyo3::ffi::Py_False()), - sea_query::Value::Bool(None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected bool for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -macro_rules! implement_numeric_SQLTypeTrait { - ( - $name:ident, - $type:ty, - $python_type_name:literal, - $column_type:ident, - $value_type:ident, - $convertfunction:ident, - $($checkfunction:ident,)+ - ) => { - impl SQLTypeTrait for $name { - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::$column_type - } - - unsafe fn validate(&self, py: pyo3::Python, ptr: *mut pyo3::ffi::PyObject) -> pyo3::PyResult<()> { - self.serialize(py, ptr)?; - Ok(()) - } - - unsafe fn serialize( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - $( - if pyo3::ffi::$checkfunction(ptr) == 1 { - let val = pyo3::ffi::$convertfunction(ptr); - - if !pyo3::ffi::PyErr_Occurred().is_null() { - return Err(pyo3::PyErr::fetch(py)); - } - - return TryInto::<$type>::try_into(val) - .map_err(|_| pyo3::exceptions::PyOverflowError::new_err("out of range")) - .map(|x| sea_query::Value::$value_type(Some(x))); - } - )+ - - crate::new_error!( - PyTypeError, - concat!("expected ", $python_type_name, " for {} serialization, got {}"), - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - use pyo3::IntoPyObject; - - match value { - sea_query::Value::BigInt(Some(x)) => Ok((*x).into_pyobject(py).unwrap().into_ptr()), - sea_query::Value::Int(Some(x)) => Ok((*x).into_pyobject(py).unwrap().into_ptr()), - sea_query::Value::SmallInt(Some(x)) => Ok((*x).into_pyobject(py).unwrap().into_ptr()), - sea_query::Value::TinyInt(Some(x)) => Ok((*x).into_pyobject(py).unwrap().into_ptr()), - sea_query::Value::BigUnsigned(Some(x)) => Ok((*x).into_pyobject(py).unwrap().into_ptr()), - sea_query::Value::Unsigned(Some(x)) => Ok((*x).into_pyobject(py).unwrap().into_ptr()), - sea_query::Value::SmallUnsigned(Some(x)) => Ok((*x).into_pyobject(py).unwrap().into_ptr()), - sea_query::Value::TinyUnsigned(Some(x)) => Ok((*x).into_pyobject(py).unwrap().into_ptr()), - sea_query::Value::Double(Some(x)) => Ok((*x).into_pyobject(py).unwrap().into_ptr()), - sea_query::Value::Float(Some(x)) => Ok((*x as f64).into_pyobject(py).unwrap().into_ptr()), - sea_query::Value::BigInt(None) - | sea_query::Value::Int(None) - | sea_query::Value::SmallInt(None) - | sea_query::Value::TinyInt(None) - | sea_query::Value::BigUnsigned(None) - | sea_query::Value::Unsigned(None) - | sea_query::Value::SmallUnsigned(None) - | sea_query::Value::TinyUnsigned(None) => Ok(py.None().into_ptr()), - _ => crate::new_error!( - PyTypeError, - "expected int for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } - } - }; -} - -implement_numeric_SQLTypeTrait!( - PyBigIntegerType, - i64, - "int", - BigInteger, - BigInt, - PyLong_AsLongLong, - PyLong_CheckExact, -); -implement_numeric_SQLTypeTrait!( - PyIntegerType, - i32, - "int", - Integer, - Int, - PyLong_AsLongLong, - PyLong_CheckExact, -); -implement_numeric_SQLTypeTrait!( - PySmallIntegerType, - i16, - "int", - SmallInteger, - SmallInt, - PyLong_AsLongLong, - PyLong_CheckExact, -); -implement_numeric_SQLTypeTrait!( - PyTinyIntegerType, - i8, - "int", - TinyInteger, - TinyInt, - PyLong_AsLongLong, - PyLong_CheckExact, -); -implement_numeric_SQLTypeTrait!( - PyBigUnsignedType, - u64, - "int", - BigUnsigned, - BigUnsigned, - PyLong_AsUnsignedLongLong, - PyLong_CheckExact, -); -implement_numeric_SQLTypeTrait!( - PyUnsignedType, - u32, - "int", - Unsigned, - Unsigned, - PyLong_AsUnsignedLong, - PyLong_CheckExact, -); -implement_numeric_SQLTypeTrait!( - PySmallUnsignedType, - u16, - "int", - SmallUnsigned, - SmallUnsigned, - PyLong_AsUnsignedLong, - PyLong_CheckExact, -); -implement_numeric_SQLTypeTrait!( - PyTinyUnsignedType, - u8, - "int", - TinyUnsigned, - TinyUnsigned, - PyLong_AsUnsignedLong, - PyLong_CheckExact, -); -implement_numeric_SQLTypeTrait!( - PyFloatType, - // We're using f64 instead of f32 'cause we don't have f32::try_from(f64) - f64, - "float", - Float, - Double, - PyFloat_AsDouble, - PyFloat_CheckExact, - PyLong_CheckExact, -); -implement_numeric_SQLTypeTrait!( - PyDoubleType, - f64, - "float", - Float, - Double, - PyFloat_AsDouble, - PyFloat_CheckExact, - PyLong_CheckExact, -); - -#[inline] -pub(super) unsafe fn _serialize_string( - py: pyo3::Python, - object: *mut pyo3::ffi::PyObject, -) -> pyo3::PyResult { - let mut size: pyo3::ffi::Py_ssize_t = 0; - let c_str = pyo3::ffi::PyUnicode_AsUTF8AndSize(object, &mut size); - - if c_str.is_null() || size < 0 { - Err(pyo3::PyErr::fetch(py)) - } else { - let val = std::ffi::CStr::from_ptr(c_str); - Ok(std::str::from_utf8_unchecked(val.to_bytes()).into()) - } -} - -#[inline] -pub(super) unsafe fn _deserialize_string( - py: pyo3::Python, - value: &str, -) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - let val = pyo3::types::PyString::intern(py, std::str::from_utf8_unchecked(value.as_bytes())); - Ok(val.into_ptr()) -} - -impl SQLTypeTrait for PyTextType { - #[inline(always)] - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::Text - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - if pyo3::ffi::PyUnicode_CheckExact(ptr) == 0 { - crate::new_error!( - PyTypeError, - "expected str for {} serialization, got {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ) - } else { - Ok(()) - } - } - - unsafe fn serialize( - &self, - py: pyo3::Python, - object: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - _serialize_string(py, object).map(|x| sea_query::Value::String(Some(Box::new(x)))) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::String(Some(x)) => _deserialize_string(py, x), - sea_query::Value::String(None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected str for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -impl SQLTypeTrait for PyCharType { - #[inline(always)] - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::Char(self.0) - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - if pyo3::ffi::PyUnicode_CheckExact(ptr) == 0 { - crate::new_error!( - PyTypeError, - "expected str for {} serialization, got {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ) - } else { - Ok(()) - } - } - - unsafe fn serialize( - &self, - py: pyo3::Python, - object: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - _serialize_string(py, object).map(|x| sea_query::Value::String(Some(Box::new(x)))) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::String(Some(x)) => _deserialize_string(py, x), - sea_query::Value::String(None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected str for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -impl SQLTypeTrait for PyStringType { - #[inline(always)] - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::String( - self.0 - .map_or(sea_query::StringLen::None, sea_query::StringLen::N), - ) - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - if pyo3::ffi::PyUnicode_CheckExact(ptr) == 0 { - crate::new_error!( - PyTypeError, - "expected str for {} serialization, got {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ) - } else { - Ok(()) - } - } - - unsafe fn serialize( - &self, - py: pyo3::Python, - object: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - _serialize_string(py, object).map(|x| sea_query::Value::String(Some(Box::new(x)))) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::String(Some(x)) => _deserialize_string(py, x), - sea_query::Value::String(None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected str for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -super::abstracts::implement_sqltype_pymethods!(PyBooleanType); -super::abstracts::implement_sqltype_pymethods!(PyBigIntegerType); -super::abstracts::implement_sqltype_pymethods!(PyIntegerType); -super::abstracts::implement_sqltype_pymethods!(PySmallIntegerType); -super::abstracts::implement_sqltype_pymethods!(PyTinyIntegerType); -super::abstracts::implement_sqltype_pymethods!(PyBigUnsignedType); -super::abstracts::implement_sqltype_pymethods!(PyUnsignedType); -super::abstracts::implement_sqltype_pymethods!(PySmallUnsignedType); -super::abstracts::implement_sqltype_pymethods!(PyTinyUnsignedType); -super::abstracts::implement_sqltype_pymethods!(PyFloatType); -super::abstracts::implement_sqltype_pymethods!(PyDoubleType); -super::abstracts::implement_sqltype_pymethods!(PyTextType); -super::abstracts::implement_sqltype_pymethods!( - PyCharType, - init(|length: Option| Self(length)), - "int | None", - signature(length = None) -); -super::abstracts::implement_sqltype_pymethods!( - PyStringType, - init(|length: Option| Self(length)), - "int | None", - signature(length = None) -); diff --git a/src/sqltypes/vector.rs b/src/sqltypes/vector.rs deleted file mode 100644 index 7a1081c..0000000 --- a/src/sqltypes/vector.rs +++ /dev/null @@ -1,337 +0,0 @@ -use pyo3::types::{PyListMethods, PyTupleMethods}; -use pyo3::IntoPyObject; - -use crate::internal::type_engine::TypeEngine; -use crate::internal::RefBoundObject; -use crate::sqltypes::abstracts::{PySQLTypeAbstract, SQLTypeTrait}; - -crate::implement_pyclass! { - /// Vector column type for storing mathematical vectors. - /// - /// Specialized type for storing vector data, often used in machine learning, - /// similarity search, or mathematical applications. - #[derive(Debug, Clone, Copy)] - [extends=PySQLTypeAbstract] PyVectorType as "Vector" (pub Option); -} -crate::implement_pyclass! { - /// Array column type for storing arrays of elements. - /// - /// Represents a column that stores arrays of a specified element type. - /// Useful in databases that support native array types (like PostgreSQL) - /// for storing lists of values in a single column. - #[derive(Clone)] - [extends=PySQLTypeAbstract] PyArrayType as "Array" (pub TypeEngine); -} - -#[inline] -#[cfg_attr(feature = "optimize", optimize(speed))] -unsafe fn _validate_iterable_ptr( - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - sql_type_name: &str, - mut condition: F, -) -> pyo3::PyResult<()> -where - F: FnMut(*mut pyo3::ffi::PyObject) -> bool, -{ - // We won't check all of elements, we only check some of them to improve performance `O(log(n))`. - - if pyo3::ffi::PyList_CheckExact(ptr) == 1 { - let mut size = pyo3::ffi::PyList_Size(ptr); - if size == 0 { - return Ok(()); - } - - while size > 0 { - // item is a owned pointer - let item = pyo3::ffi::PyList_GetItem(ptr, size - 1); - if item.is_null() { - return Err(pyo3::PyErr::fetch(py)); - } - - if !condition(item) { - return Err(pyo3::exceptions::PyTypeError::new_err( - "invalid type found in the list", - )); - } - - size /= 2; - } - - return Ok(()); - } - - if pyo3::ffi::PyTuple_CheckExact(ptr) == 1 { - let mut size = pyo3::ffi::PyTuple_Size(ptr); - if size == 0 { - return Ok(()); - } - - while size > 0 { - // item is a borrowed pointer - let item = pyo3::ffi::PyTuple_GetItem(ptr, size - 1); - if item.is_null() { - return Err(pyo3::PyErr::fetch(py)); - } - - if !condition(item) { - return Err(pyo3::exceptions::PyTypeError::new_err( - "invalid type found in the tuple", - )); - } - - size /= 2; - } - - return Ok(()); - } - - crate::new_error!( - PyTypeError, - "expected list/tuple for {} serialization, got {}", - sql_type_name, - crate::internal::get_type_name(py, ptr) - ) -} - -impl SQLTypeTrait for PyVectorType { - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::Vector(self.0) - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - _validate_iterable_ptr(py, ptr, &self.to_sql_type_name(), |item| { - pyo3::ffi::PyFloat_CheckExact(item) == 1 || pyo3::ffi::PyLong_CheckExact(item) == 1 - }) - } - - #[cfg_attr(feature = "optimize", optimize(speed))] - unsafe fn serialize( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - if pyo3::ffi::PyList_CheckExact(ptr) == 1 { - let bound = pyo3::Bound::from_borrowed_ptr(py, ptr); - let list = bound.cast_unchecked::(); - - let mut values: Vec = Vec::with_capacity(list.len()); - - for item in list.iter() { - if pyo3::ffi::PyFloat_CheckExact(item.as_ptr()) == 0 - && pyo3::ffi::PyLong_CheckExact(item.as_ptr()) == 0 - { - return crate::new_error!( - PyTypeError, - "expected list of floats for {} serialization, found {} in the list", - self.to_sql_type_name(), - crate::internal::get_type_name(py, item.as_ptr()) - ); - } - - values.push(pyo3::ffi::PyFloat_AsDouble(item.as_ptr()) as f32); - } - - return Ok(sea_query::Value::Vector(Some(Box::new(values.into())))); - } - - if pyo3::ffi::PyTuple_CheckExact(ptr) == 1 { - let bound = pyo3::Bound::from_borrowed_ptr(py, ptr); - let list = bound.cast_unchecked::(); - - let mut values: Vec = Vec::with_capacity(list.len()); - - for item in list.iter() { - if pyo3::ffi::PyFloat_CheckExact(item.as_ptr()) == 0 - && pyo3::ffi::PyLong_CheckExact(item.as_ptr()) == 0 - { - return crate::new_error!( - PyTypeError, - "expected tuple of floats for {} serialization, found {} in the tuple", - self.to_sql_type_name(), - crate::internal::get_type_name(py, item.as_ptr()) - ); - } - - values.push(pyo3::ffi::PyFloat_AsDouble(item.as_ptr()) as f32); - } - - return Ok(sea_query::Value::Vector(Some(Box::new(values.into())))); - } - - crate::new_error!( - PyTypeError, - "expected list[float]/tuple[float] for {} serialization, got {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ) - } - - #[cfg_attr(feature = "optimize", optimize(speed))] - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::Vector(Some(x)) => { - let list = x.as_slice().into_pyobject(py)?; - Ok(list.into_ptr()) - } - sea_query::Value::Vector(None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected vector for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -impl SQLTypeTrait for PyArrayType { - fn to_sea_query_column_type(&self) -> sea_query::ColumnType { - sea_query::ColumnType::Array(std::sync::Arc::new(self.0.to_sea_query_column_type())) - } - - unsafe fn validate( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult<()> { - _validate_iterable_ptr(py, ptr, &self.to_sql_type_name(), |item| { - self.0.validate(py, item).is_ok() - }) - } - - unsafe fn serialize( - &self, - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - ) -> pyo3::PyResult { - if pyo3::ffi::PyList_CheckExact(ptr) == 1 { - let bound = pyo3::Bound::from_borrowed_ptr(py, ptr); - let list = bound.cast_unchecked::(); - - let mut values: Vec = Vec::with_capacity(list.len()); - - for item in list.iter() { - let val = self.0.serialize(py, item.as_ptr())?; - values.push(val); - } - - return Ok(sea_query::Value::Array( - // array type is not important - sea_query::ArrayType::BigInt, - Some(Box::new(values)), - )); - } - - if pyo3::ffi::PyTuple_CheckExact(ptr) == 1 { - let bound = pyo3::Bound::from_borrowed_ptr(py, ptr); - let list = bound.cast_unchecked::(); - - let mut values: Vec = Vec::with_capacity(list.len()); - - for item in list.iter() { - let val = self.0.serialize(py, item.as_ptr())?; - values.push(val); - } - - return Ok(sea_query::Value::Array( - // array type is not important - sea_query::ArrayType::BigInt, - Some(Box::new(values)), - )); - } - - crate::new_error!( - PyTypeError, - "expected list/tuple for {} serialization, got {}", - self.to_sql_type_name(), - crate::internal::get_type_name(py, ptr) - ) - } - - unsafe fn deserialize( - &self, - py: pyo3::Python, - value: &sea_query::Value, - ) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - match value { - sea_query::Value::Array(_, Some(array)) => { - let pylist = pyo3::ffi::PyList_New(array.len() as isize); - - if pylist.is_null() { - return Err(pyo3::PyErr::fetch(py)); - } - - for (index, item) in array.iter().enumerate() { - let result = self.0.deserialize(py, item); - - match result { - Ok(x) => { - if pyo3::ffi::PyList_SetItem(pylist, index as isize, x) == 0 { - pyo3::ffi::Py_DECREF(x); - pyo3::ffi::Py_DECREF(pylist); - return Err(pyo3::PyErr::fetch(py)); - } - } - Err(e) => { - pyo3::ffi::Py_DECREF(pylist); - return Err(e); - } - } - } - - Ok(pylist) - } - sea_query::Value::Array(_, None) => Ok(pyo3::ffi::Py_None()), - _ => crate::new_error!( - PyTypeError, - "expected array for {} deserialization, got {:?}", - self.to_sql_type_name(), - value - ), - } - } -} - -super::abstracts::implement_sqltype_pymethods!( - PyVectorType, - init(|length: Option| Self(length)), - "int | None", - signature(length = None) -); - -#[pyo3::pymethods] -impl PyArrayType { - #[new] - fn __new__(element: RefBoundObject<'_>) -> pyo3::PyResult<(Self, PySQLTypeAbstract)> { - let type_engine = TypeEngine::new(element)?; - - Ok((Self(type_engine), PySQLTypeAbstract)) - } - - /// Type name. e.g. `'INTEGER'`, `'STRING'` - /// - /// It also may be a property. This function must NOT raise any error. - #[getter] - fn __type_name__(&self) -> String { - self.to_sql_type_name() - } - - #[getter] - fn element<'py>(&self, py: pyo3::Python<'py>) -> pyo3::Bound<'py, pyo3::PyAny> { - let ptr = self.0 .1.as_ptr(); - unsafe { pyo3::Bound::from_borrowed_ptr(py, ptr) } - } - - fn __repr__(&self) -> String { - format!("ArrayType(element={})", self.0) - } -} diff --git a/src/typeref.rs b/src/typeref.rs deleted file mode 100644 index 218b5ab..0000000 --- a/src/typeref.rs +++ /dev/null @@ -1,172 +0,0 @@ -// Column types -pub(crate) static mut BLOB_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut BINARY_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut VAR_BINARY_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut BIT_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut VAR_BIT_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut DATETIME_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut TIMESTAMP_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut TIME_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut DATE_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut JSON_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut JSON_BINARY_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut DECIMAL_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut UUID_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut INET_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut MAC_ADDRESS_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut BOOLEAN_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut TINY_INTEGER_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut SMALL_INTEGER_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = - std::ptr::null_mut(); -pub(crate) static mut INTEGER_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut BIG_INTEGER_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut TINY_UNSIGNED_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = - std::ptr::null_mut(); -pub(crate) static mut SMALL_UNSIGNED_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = - std::ptr::null_mut(); -pub(crate) static mut UNSIGNED_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut BIG_UNSIGNED_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut FLOAT_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut DOUBLE_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut TEXT_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut CHAR_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut STRING_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut VECTOR_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut ARRAY_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut ENUM_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); - -pub(crate) static mut VALUE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut COLUMN_REF_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut TABLE_NAME_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut EXPR_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut FUNC_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut INDEX_COLUMN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut INDEX_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut FOREIGN_KEY_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut TABLE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut ON_CONFLICT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut WINDOW_STATEMENT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut SELECT_LABEL_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut SELECT_STATEMENT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut DELETE_STATEMENT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut UPDATE_STATEMENT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut INSERT_STATEMENT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut CASE_STATEMENT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut WITH_CLAUSE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut ALTER_TABLE_BASE_OPTION_TYPE: *mut pyo3::ffi::PyTypeObject = - std::ptr::null_mut(); - -// Python standard libraries types -pub(crate) static mut STD_DECIMAL_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut STD_UUID_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut STD_DATETIME_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut STD_DATE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut STD_TIME_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut STD_ENUM_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); - -unsafe fn get_type_object_for( - py: pyo3::Python, -) -> *mut pyo3::ffi::PyTypeObject { - T::type_object_raw(py) -} - -unsafe fn look_up_type_object( - module_name: &std::ffi::CStr, - member_name: &std::ffi::CStr, -) -> *mut pyo3::ffi::PyTypeObject { - let module = pyo3::ffi::PyImport_ImportModule(module_name.as_ptr()); - let module_dict = pyo3::ffi::PyObject_GenericGetDict(module, std::ptr::null_mut()); - let ptr = pyo3::ffi::PyMapping_GetItemString(module_dict, member_name.as_ptr()) - .cast::(); - - pyo3::ffi::Py_DECREF(module_dict); - pyo3::ffi::Py_DECREF(module); - ptr -} - -macro_rules! multiple_get_type_object_for { - ($py:expr, $($type:ty => $name:ident,)*) => { - $($name = get_type_object_for::<$type>($py);)* - }; -} - -#[cold] -#[cfg_attr(feature = "optimize", optimize(size))] -fn _initialize_typeref(py: pyo3::Python) { - unsafe { - multiple_get_type_object_for!( - py, - crate::sqltypes::PyBlobType => BLOB_COLUMN_TYPE, - crate::sqltypes::PyBinaryType => BINARY_COLUMN_TYPE, - crate::sqltypes::PyVarBinaryType => VAR_BINARY_COLUMN_TYPE, - crate::sqltypes::PyBitType => BIT_COLUMN_TYPE, - crate::sqltypes::PyVarBitType => VAR_BIT_COLUMN_TYPE, - crate::sqltypes::PyDateTimeType => DATETIME_COLUMN_TYPE, - crate::sqltypes::PyTimestampType => TIMESTAMP_COLUMN_TYPE, - crate::sqltypes::PyTimeType => TIME_COLUMN_TYPE, - crate::sqltypes::PyDateType => DATE_COLUMN_TYPE, - crate::sqltypes::PyJSONType => JSON_COLUMN_TYPE, - crate::sqltypes::PyJSONBinaryType => JSON_BINARY_COLUMN_TYPE, - crate::sqltypes::PyDecimalType => DECIMAL_COLUMN_TYPE, - crate::sqltypes::PyUUIDType => UUID_COLUMN_TYPE, - crate::sqltypes::PyINETType => INET_COLUMN_TYPE, - crate::sqltypes::PyMacAddressType => MAC_ADDRESS_COLUMN_TYPE, - crate::sqltypes::PyBooleanType => BOOLEAN_COLUMN_TYPE, - crate::sqltypes::PyTinyIntegerType => TINY_INTEGER_COLUMN_TYPE, - crate::sqltypes::PySmallIntegerType => SMALL_INTEGER_COLUMN_TYPE, - crate::sqltypes::PyIntegerType => INTEGER_COLUMN_TYPE, - crate::sqltypes::PyBigIntegerType => BIG_INTEGER_COLUMN_TYPE, - crate::sqltypes::PyTinyUnsignedType => TINY_UNSIGNED_COLUMN_TYPE, - crate::sqltypes::PySmallUnsignedType => SMALL_UNSIGNED_COLUMN_TYPE, - crate::sqltypes::PyUnsignedType => UNSIGNED_COLUMN_TYPE, - crate::sqltypes::PyBigUnsignedType => BIG_UNSIGNED_COLUMN_TYPE, - crate::sqltypes::PyFloatType => FLOAT_COLUMN_TYPE, - crate::sqltypes::PyDoubleType => DOUBLE_COLUMN_TYPE, - crate::sqltypes::PyTextType => TEXT_COLUMN_TYPE, - crate::sqltypes::PyCharType => CHAR_COLUMN_TYPE, - crate::sqltypes::PyStringType => STRING_COLUMN_TYPE, - crate::sqltypes::PyVectorType => VECTOR_COLUMN_TYPE, - crate::sqltypes::PyArrayType => ARRAY_COLUMN_TYPE, - crate::sqltypes::PyEnumType => ENUM_COLUMN_TYPE, - crate::common::value::PyValue => VALUE_TYPE, - crate::common::table_ref::PyTableName => TABLE_NAME_TYPE, - crate::common::column_ref::PyColumnRef => COLUMN_REF_TYPE, - crate::common::expression::PyExpr => EXPR_TYPE, - crate::common::expression::PyFunc => FUNC_TYPE, - crate::common::column::PyColumn => COLUMN_TYPE, - crate::common::foreign_key::PyForeignKey => FOREIGN_KEY_TYPE, - crate::schema::index::PyIndexColumn => INDEX_COLUMN_TYPE, - crate::schema::index::PyIndex => INDEX_TYPE, - crate::schema::alter_table::PyAlterTableBaseOption => ALTER_TABLE_BASE_OPTION_TYPE, - crate::schema::table::PyTable => TABLE_TYPE, - crate::query::on_conflict::PyOnConflict => ON_CONFLICT_TYPE, - crate::query::window::PyWindowStatement => WINDOW_STATEMENT_TYPE, - crate::query::select::PySelectLabel => SELECT_LABEL_TYPE, - crate::query::select::PySelectStatement => SELECT_STATEMENT_TYPE, - crate::query::delete::PyDeleteStatement => DELETE_STATEMENT_TYPE, - crate::query::update::PyUpdateStatement => UPDATE_STATEMENT_TYPE, - crate::query::insert::PyInsertStatement => INSERT_STATEMENT_TYPE, - crate::query::case::PyCaseStatement => CASE_STATEMENT_TYPE, - crate::query::with::PyWithClause => WITH_CLAUSE_TYPE, - ); - - STD_DECIMAL_TYPE = look_up_type_object(c"decimal", c"Decimal"); - STD_UUID_TYPE = look_up_type_object(c"uuid", c"UUID"); - STD_ENUM_TYPE = look_up_type_object(c"enum", c"EnumMeta"); - - pyo3::ffi::PyDateTime_IMPORT(); - let datetime_capsule = pyo3::ffi::PyCapsule_Import(c"datetime.datetime_CAPI".as_ptr(), 1) - .cast::(); - - STD_DATETIME_TYPE = (*datetime_capsule).DateTimeType; - STD_DATE_TYPE = (*datetime_capsule).DateType; - STD_TIME_TYPE = (*datetime_capsule).TimeType; - } -} - -pub fn initialize_typeref(py: pyo3::Python) { - static INIT: std::sync::Once = std::sync::Once::new(); - - INIT.call_once(|| _initialize_typeref(py)); -} diff --git a/tests/mixin.py b/tests/mixin.py deleted file mode 100644 index 6351bdc..0000000 --- a/tests/mixin.py +++ /dev/null @@ -1,70 +0,0 @@ -import typing - -import rapidquery as rq - - -class TableNameInitMixin: - table_name_instance: typing.Callable - - def test_table_name_init(self): - class Prop: - @property - def __table_name__(self): - return "world.public.users" - - class Var: - __table_name__ = "world.public.users" - - self.table_name_instance(rq.TableName("users", "public", "world")) - self.table_name_instance("world.public.users") - self.table_name_instance(Prop()) - self.table_name_instance(Var) - - -class ReturningMixin: - def get_statement(self): - raise NotImplementedError - - def test_returning(self): - stmt = self.get_statement().returning(rq.Returning("id")) - assert 'RETURNING "id"' in stmt.to_sql("postgres") - - stmt = self.get_statement().returning(rq.Returning.all()) - assert "RETURNING *" in stmt.to_sql("postgres") - - -class OrderByMixin: - def get_statement(self): - raise NotImplementedError - - def test_order_by(self): - stmt = self.get_statement().order_by(rq.Ordering("id", "DESC")) - assert 'ORDER BY "id" DESC' in stmt.to_sql("postgres") - - def test_clear_order_by(self): - stmt = self.get_statement().order_by(rq.Ordering("id")) - assert "ORDER BY" in stmt.to_sql("mysql") - - stmt = stmt.clear_order_by() - assert "ORDER BY" not in stmt.to_sql("mysql") - - -class WhereMixin: - def get_statement(self): - raise NotImplementedError - - def test_clear_where(self): - stmt = self.get_statement().where(rq.Expr.val(True)) - assert "WHERE" in stmt.to_sql("mysql") - - stmt = stmt.clear_where() - assert "WHERE" not in stmt.to_sql("mysql") - - def test_where(self): - stmt = ( - self.get_statement() - .where(rq.Expr.col("id") > 10) - .where(rq.Expr.col("name") < 20) - .to_sql("sqlite") - ) - assert 'WHERE "id" > 10 AND "name" < 20' in stmt diff --git a/tests/test_common.py b/tests/test_common.py deleted file mode 100644 index c1dce08..0000000 --- a/tests/test_common.py +++ /dev/null @@ -1,343 +0,0 @@ -import pytest - -import rapidquery as rq - - -class TestColumn: - def test_init(self): - rq.Column("id", rq.sqltypes.Integer()) - - with pytest.raises(TypeError): - rq.Column("id", rq.sqltypes.Integer) # type: ignore - - with pytest.raises(TypeError): - rq.Column("id", "INTEGER") # type: ignore - - rq.Column("id", rq.sqltypes.String(), default="") - rq.Column("id", rq.sqltypes.String(), default=None) - rq.Column("id", rq.sqltypes.String(), default=rq.Expr.val(232)) - rq.Column("id", rq.sqltypes.String(), default=None, generated=232) - - with pytest.raises(TypeError): - rq.Column("id", rq.sqltypes.String(), default=3243) - - col = rq.Column( - "id", - rq.sqltypes.Float(), - primary_key=True, - auto_increment=True, - nullable=False, - extra="HELLO", - comment="WOW", - default=4.3, - generated=98, - stored_generated=True, - ) - assert col.name == "id" - assert type(col.type) is rq.sqltypes.Float - assert col.primary_key - assert col.auto_increment - assert col.nullable is False - assert col.extra == "HELLO" - assert col.comment == "WOW" - assert type(col.default) is rq.Expr - assert type(col.generated) is rq.Expr - assert col.stored_generated - - def test_adapt(self): - col = rq.Column("name", rq.sqltypes.String()) - col.adapt("Ali") - col.adapt(None) - - with pytest.raises(TypeError): - col.adapt(4) # type: ignore - - def test_column_ref_property(self): - col = rq.Column("name", rq.sqltypes.String()) - rq.AlterTableDropColumnOption(col) - - def test_label(self): - col = rq.Column("name", rq.sqltypes.String()) - label = col.label("a") - assert type(label) is rq.SelectLabel - assert label.alias == "a" - assert label.window is None - - def test_to_expr(self): - col = rq.Column("name", rq.sqltypes.String()) - assert type(col.to_expr()) is rq.Expr - - -class TestColumnRef: - def test_new(self): - ref_1 = rq.ColumnRef("id") - assert ref_1.name == "id" - assert ref_1.table is None - assert ref_1.schema is None - - ref_2 = rq.ColumnRef("id", "characters", "public") - assert ref_2.name == "id" - assert ref_2.table == "characters" - assert ref_2.schema == "public" - - ref_3 = rq.ColumnRef.parse("public.characters.id") - assert ref_3.name == "id" - assert ref_3.table == "characters" - assert ref_3.schema == "public" - - assert ref_2 == ref_3 - assert ref_1 != ref_2 - - asterisk_1 = rq.ColumnRef("*") - assert asterisk_1.name == "*" - assert asterisk_1.table is None - assert asterisk_1.schema is None - - asterisk_2 = rq.ColumnRef("*", "characters") - assert asterisk_2.name == "*" - assert asterisk_2.table == "characters" - assert asterisk_2.schema is None - - asterisk_3 = rq.ColumnRef.parse("characters.*") - assert asterisk_3.name == "*" - assert asterisk_3.table == "characters" - assert asterisk_3.schema is None - - assert asterisk_2 == asterisk_3 - assert asterisk_1 != asterisk_2 - - def test_copy_with(self): - ref_1 = rq.ColumnRef("id") - assert ref_1.name == "id" - assert ref_1.table is None - assert ref_1.schema is None - - ref_2 = ref_1.copy_with(table="characters", schema="public") - assert ref_2.name == "id" - assert ref_2.table == "characters" - assert ref_2.schema == "public" - - ref_3 = ref_2.copy_with(name="*") - assert ref_3.name == "*" - assert ref_3.table == "characters" - assert ref_3.schema == "public" - - def test_try_from(self): - class Prop: - @property - def __column_ref__(self): - return "characters.id" - - class Var: - __column_ref__ = "characters.id" - - rq.Expr.col("characters.id") - rq.Expr.col(rq.ColumnRef("id", "characters")) - rq.Expr.col(Prop()) - rq.Expr.col(Var) - - def test_to_expr(self): - ref = rq.ColumnRef("id") - assert type(ref.to_expr()) is rq.Expr - - def test_label(self): - ref = rq.ColumnRef("id") - label = ref.label("a") - assert type(label) is rq.SelectLabel - assert label.alias == "a" - assert label.window is None - - -class SelectStatementChild(rq.SelectStatement): - pass - - -class TestExpr: - def test_new(self): - rq.Expr(rq.Expr.custom("WOW")) - rq.Expr(rq.Value(None)) - rq.Expr(rq.ColumnRef("id")) - rq.Expr(rq.Func("NOW")) - rq.Expr(rq.SelectStatement().columns("id")) - rq.Expr(SelectStatementChild().columns("id")) - rq.Expr(rq.CaseStatement().when(rq.Expr.col("aspect").in_([2, 4]), True).else_(False)) - - rq.Expr((rq.Expr.custom("TUPLE"), rq.Expr.custom("TUPLE"))) - - class Prop: - @property - def __expr__(self): - return rq.Expr.custom("PROPERTY") - - rq.Expr(Prop()) - - class ColumnRefProp: - @property - def __column_ref__(self): - return "characters.id" - - rq.Expr(ColumnRefProp()) - - rq.Expr(1) - rq.Expr("a") - rq.Expr(3.4) - rq.Expr(None) - rq.Expr(["a", "b"]) - - def test_val(self): - rq.Expr.val(1) - rq.Expr.val(1.4) - rq.Expr.val("a") - rq.Expr.val(None) - - with pytest.raises(TypeError): - rq.Expr.val("a", rq.sqltypes.Integer()) # type: ignore - - def test_exists(self): - with pytest.raises(TypeError): - rq.Expr.exists(1) # type: ignore - - stmt = rq.SelectStatement().columns("id") - assert "EXISTS" in rq.Expr.exists(stmt)._to_sql("postgres") - - stmt = SelectStatementChild().columns("id") - assert "EXISTS" in rq.Expr.exists(stmt)._to_sql("postgres") - - def test_all(self): - with pytest.raises(TypeError): - rq.Expr.all(1) # type: ignore - - stmt = rq.SelectStatement().columns("id") - assert "ALL" in rq.Expr.all(stmt)._to_sql("postgres") - - stmt = SelectStatementChild().columns("id") - assert "ALL" in rq.Expr.all(stmt)._to_sql("postgres") - - def test_any(self): - with pytest.raises(TypeError): - rq.Expr.any(1) # type: ignore - - stmt = rq.SelectStatement().columns("id") - assert "ANY" in rq.Expr.any(stmt)._to_sql("postgres") - - stmt = SelectStatementChild().columns("id") - assert "ANY" in rq.Expr.any(stmt)._to_sql("postgres") - - def test_some(self): - with pytest.raises(TypeError): - rq.Expr.some(1) # type: ignore - - stmt = rq.SelectStatement().columns("id") - assert "SOME" in rq.Expr.some(stmt)._to_sql("postgres") - - stmt = SelectStatementChild().columns("id") - assert "SOME" in rq.Expr.some(stmt)._to_sql("postgres") - - -class TestForeignKey: - def test_init(self): - rq.ForeignKey( - ["font_id"], - ["id"], - "fonts", - "fk_name", - on_delete="CASCADE", - on_update="NO ACTION", - ) - fk = rq.ForeignKey(["font_id"], ["fonts.id"]) - assert fk.to_table.name == "fonts" - - rq.ForeignKey(["font_id", "font_name"], ["fonts.id", "fonts.name"]) - - with pytest.raises(ValueError): - rq.ForeignKey(["id_1", "id_2"], ["t1.id", "t2.id"]) - - with pytest.raises(ValueError): - rq.ForeignKey(["1", "2"], ["1"]) - - with pytest.raises(ValueError): - rq.ForeignKey(["font_id"], ["fonts.id"], on_delete="AAA") # type: ignore - - with pytest.raises(ValueError): - rq.ForeignKey(["font_id"], ["fonts.id"], on_update="AAA") # type: ignore - - -class TestFunc: - def test_new(self): - rq.Func("CUSTOM", 1, 2) - - -class TestTableName: - def test_new(self): - t_1 = rq.TableName("users") - assert t_1.name == "users" - assert t_1.schema is None - assert t_1.database is None - assert t_1.alias is None - - t_2 = rq.TableName("users", "public", "world") - assert t_2.name == "users" - assert t_2.schema == "public" - assert t_2.database == "world" - assert t_2.alias is None - - t_3 = rq.TableName.parse("world.public.users") - assert t_3.name == "users" - assert t_3.schema == "public" - assert t_3.database == "world" - assert t_3.alias is None - - assert t_3 == t_2 - assert t_3 != t_1 - - def test_copy_with(self): - t_1 = rq.TableName("users") - assert t_1.name == "users" - assert t_1.schema is None - assert t_1.database is None - assert t_1.alias is None - - t_2 = t_1.copy_with(schema="public", database="world") - assert t_2.name == "users" - assert t_2.schema == "public" - assert t_2.database == "world" - assert t_2.alias is None - - t_3 = t_2.copy_with(database=None, alias="t_3") - assert t_3.name == "users" - assert t_3.schema == "public" - assert t_3.database is None - assert t_3.alias == "t_3" - - def test_try_from(self): - class Prop: - @property - def __table_name__(self): - return "world.public.users" - - class Var: - __table_name__ = "world.public.users" - - stmt = rq.AlterTable(rq.TableName("users", "public", "world")) - assert stmt.name == rq.TableName("users", "public", "world") - - stmt = rq.AlterTable("world.public.users") - assert stmt.name == rq.TableName("users", "public", "world") - - stmt = rq.AlterTable(Prop()) - assert stmt.name == rq.TableName("users", "public", "world") - - stmt = rq.AlterTable(Var) - assert stmt.name == rq.TableName("users", "public", "world") - - stmt = rq.AlterTable("world.public.users as t1") - assert stmt.name == rq.TableName("users", "public", "world", "t1") - - stmt = rq.AlterTable("world.public.users AS t1") - assert stmt.name == rq.TableName("users", "public", "world", "t1") - - stmt = rq.AlterTable("world.public.users As t1") - assert stmt.name == rq.TableName("users", "public", "world", "t1") - - stmt = rq.AlterTable("world.public.users As s t1") - assert stmt.name == rq.TableName("users", "public", "world", "s t1") diff --git a/tests/test_query.py b/tests/test_query.py deleted file mode 100644 index 1b1564b..0000000 --- a/tests/test_query.py +++ /dev/null @@ -1,427 +0,0 @@ -import pytest - -import rapidquery as rq - -from .mixin import OrderByMixin, ReturningMixin, TableNameInitMixin, WhereMixin - - -class TestCaseStatement: - def test_init(self): - rq.CaseStatement() - - def test_when_else(self): - stmt = ( - rq.CaseStatement().when(rq.Expr.col("id") == 1, 1).when(rq.Expr.col("name") == "ali", 1) - ) - sql = rq.SelectStatement(rq.SelectLabel(stmt, "test")).to_sql("postgres") - assert '"id"' in sql - assert '"name"' in sql - assert '"test"' in sql - - stmt = ( - rq.CaseStatement().when(rq.Expr.col("id") == 1, 1).when(rq.Expr.col("name") == "ali", 1) - ) - sql = rq.SelectStatement(stmt.label("test")).to_sql("postgres") - assert '"id"' in sql - assert '"name"' in sql - assert '"test"' in sql - - with pytest.raises(TypeError): - rq.CaseStatement().when("Ali", 1) # type: ignore - - rq.CaseStatement().when(rq.Expr("Ali"), 1).else_(1) - - -class TestDeleteStatement(TableNameInitMixin, ReturningMixin, WhereMixin, OrderByMixin): - table_name_instance = rq.DeleteStatement - - def get_statement(self): - return rq.DeleteStatement("users") - - def test_from_table(self): - stmt = rq.DeleteStatement("users") - assert '"users"' in stmt.to_sql("postgres") - - stmt = stmt.from_table("fonts") - assert '"users"' not in stmt.to_sql("postgres") - assert '"fonts"' in stmt.to_sql("postgres") - - def test_limit(self): - stmt = rq.DeleteStatement("users").limit(20) - assert "LIMIT" in stmt.to_sql("postgres") - - -class TestInsertStatement(TableNameInitMixin, ReturningMixin): - table_name_instance = rq.InsertStatement - - def get_statement(self): - return rq.InsertStatement("users") - - def test_replace(self): - stmt = rq.InsertStatement("public.users").replace().to_sql("postgres") - assert "INSERT" not in stmt - assert "REPLACE" in stmt - - def test_columns(self): - stmt = rq.InsertStatement("users").columns("id", "name") - assert '"id"' in stmt.to_sql("postgres") - assert '"name"' in stmt.to_sql("postgres") - - stmt.columns("created_at") - assert '"created_at"' in stmt.to_sql("postgres") - assert '"id"' not in stmt.to_sql("postgres") - assert '"name"' not in stmt.to_sql("postgres") - - def test_values(self): - with pytest.raises(TypeError): - rq.InsertStatement("users").values(1, a=1) # type: ignore - - with pytest.raises(ValueError): - rq.InsertStatement("users").values(name="ali", id=1).values(name="ali") # type: ignore - - with pytest.raises(ValueError): - rq.InsertStatement("users").columns("id").values(1, 2) - - with pytest.raises(ValueError): - rq.InsertStatement("users").columns("id").values() - - stmt = rq.InsertStatement("users").columns("id").values("ali") - assert stmt.to_sql("postgres").count("ali") == 1 - - stmt = rq.InsertStatement("users").columns("id").values("ali").values("ali") - assert stmt.to_sql("postgres").count("ali") == 2 - - stmt = rq.InsertStatement("users").values(name="ali", id=1) - assert "name" in stmt.to_sql("postgres") - assert "id" in stmt.to_sql("postgres") - assert "ali" in stmt.to_sql("postgres") - assert "1" in stmt.to_sql("postgres") - - stmt = rq.InsertStatement("users").values(name="ali", id=1).values(name="ali", id=1) - assert stmt.to_sql("postgres").count("ali") == 2 - assert stmt.to_sql("postgres").count("1") == 2 - - def test_into(self): - stmt = ( - rq.InsertStatement("name_1") - .values(aspect=4.21, image="123") - .into("name_2") - .to_sql("sqlite") - ) - assert "name_2" in stmt - assert "name_1" not in stmt - - def test_or_default_values(self): - stmt = rq.InsertStatement("users").or_default_values() - assert stmt.to_sql("postgres").count("DEFAULT") == 1 - - stmt = rq.InsertStatement("users").or_default_values(4) - assert stmt.to_sql("postgres").count("DEFAULT") == 4 - - stmt = rq.InsertStatement("users").or_default_values(4).values(aspect=3.14) - assert stmt.to_sql("postgres").count("DEFAULT") == 0 - - def test_select_from(self): - valid_cols = rq.SelectStatement().columns("id", "name").from_table("fonts") - invalid_cols = rq.SelectStatement().columns("id").from_table("fonts") - - stmt = rq.InsertStatement("users").columns("id", "name") - - with pytest.raises(ValueError): - stmt.select_from(invalid_cols) - - stmt.select_from(valid_cols) - assert "SELECT" in stmt.to_sql("postgres") - - -class TestOnConflict: - def test_init(self): - stmt = ( - rq.InsertStatement("glyph") - .values(aspect=3.1415, image="abcd") - .on_conflict(rq.OnConflict("id").do_update(image="ex")) - ) - assert 'ON CONFLICT ("id")' in stmt.to_sql("postgres") - assert "DO UPDATE" in stmt.to_sql("postgres") - - def test_action_where(self): - stmt = ( - rq.InsertStatement("glyph") - .values(aspect=3.1415, image="abcd") - .on_conflict( - rq.OnConflict("id") - .do_update(image="ex") - .action_where(rq.Expr.col("aspect").is_null()) - ) - ) - assert "WHERE" in stmt.to_sql("postgres") - - def test_target_where(self): - stmt = ( - rq.InsertStatement("glyph") - .values(aspect=3.1415, image="abcd") - .on_conflict( - rq.OnConflict("id") - .do_update(image="ex") - .target_where(rq.Expr.col("aspect").is_null()) - ) - ) - assert "WHERE" in stmt.to_sql("postgres") - - def test_do_update(self): - stmt = ( - rq.InsertStatement("glyph") - .values(aspect=3.1415, image="abcd") - .on_conflict(rq.OnConflict("id").do_update("aspect", image=rq.Expr(1) + 2)) - ) - assert "DO UPDATE" in stmt.to_sql("postgres") - - def test_do_nothing(self): - stmt = ( - rq.InsertStatement("glyph") - .values(aspect=3.1415, image="abcd") - .on_conflict(rq.OnConflict("id").do_nothing()) - ) - assert "DO NOTHING" in stmt.to_sql("postgres") - - stmt = ( - rq.InsertStatement("glyph") - .values(aspect=3.1415, image="abcd") - .on_conflict(rq.OnConflict("id").do_nothing("id")) - ) - assert "DO NOTHING" in stmt.to_sql("postgres") - - -class TestSelectStatement(WhereMixin, OrderByMixin): - table_name_instance = rq.SelectStatement - - def get_statement(self): - return rq.SelectStatement().columns("character").from_table("characters") - - def test_init(self): - stmt = ( - rq.SelectStatement() - .columns("character", "fonts.name") - .from_table("characters") - .join( - "fonts", - rq.Expr.col("characters.font_id") == rq.Expr.col("fonts.id"), - "LEFT", - ) - .where(rq.Expr.col("size_w").in_((3, 4))) - .where(rq.Expr.col("character").like("A%")) - .to_sql("postgres") - ) - assert 'LEFT JOIN "fonts" ON "characters"."font_id" = "fonts"."id"' in stmt - - stmt = rq.SelectStatement( - rq.SelectLabel(1), - rq.SelectLabel("hello"), - rq.Expr.col("font"), - ).to_sql("postgres") - assert "SELECT 1, 'hello', \"font\"" in stmt - - def test_columns(self): - stmt = ( - rq.SelectStatement() - .columns("character", "size_w", "size_h") - .from_table("characters") - .to_sql("mysql") - ) - assert "`character` AS `character`, `size_w` AS `size_w`, `size_h` AS `size_h`" in stmt - - def test_distinct(self): - stmt = ( - rq.SelectStatement() - .distinct() - .columns("character", "size_w", "size_h") - .from_table("characters") - .to_sql("mysql") - ) - assert "DISTINCT" in stmt - - def test_from_function(self): - stmt = ( - rq.SelectStatement(rq.Expr.asterisk()) - .from_function(rq.Func.random(), "func") - .to_sql("postgres") - ) - assert "FROM RANDOM()" in stmt - - def test_from_subquery(self): - rq.SelectStatement().from_subquery( - rq.SelectStatement().columns("image", "aspect").from_table("glyph"), - "subglyph", - ) - - stmt = rq.SelectStatement() - with pytest.raises(ValueError): - stmt.from_subquery(stmt, "a") - - -class TestUpdateStatement(WhereMixin, OrderByMixin): - table_name_instance = rq.UpdateStatement - - def get_statement(self): - return rq.UpdateStatement("users") - - def test_from_table(self): - stmt = rq.UpdateStatement("archive.users").from_table("public.users").to_sql("postgres") - assert "FROM" in stmt - assert '"public"."users"' in stmt - assert '"archive"."users"' in stmt - - def test_table(self): - stmt = ( - rq.UpdateStatement("name_1") - .values(aspect=1.23, image=123) - .table("name_2") - .to_sql("sqlite") - ) - assert "name_1" not in stmt - assert "name_2" in stmt - - def test_values(self): - stmt = ( - rq.UpdateStatement("glyph") - .values(aspect=1.23, image=123) - .values(font_id=20) - .to_sql("sqlite") - ) - assert "aspect" in stmt - assert "image" in stmt - assert "font_id" in stmt - - -class TestWindowStatement: - def test_basic_partition(self): - """Test a basic OVER clause with PARTITION BY.""" - window = rq.WindowStatement("department").order_by(rq.Ordering("salary", order="DESC")) - - # Using it in a SelectStatement context - stmt = rq.SelectStatement( - rq.Expr.custom("AVG(salary)").label("svg_sal", window) - ).from_table("employees") - - sql = stmt.to_sql("postgres") - assert 'PARTITION BY "department"' in sql - assert 'ORDER BY "salary" DESC' in sql - assert "OVER" in sql - - def test_frame_rows(self): - """Test WINDOW with ROWS framing.""" - window = ( - rq.WindowStatement() - .order_by(rq.Ordering("id")) - .frame("ROWS", rq.Frame.preceding(5), rq.Frame.current_row()) - ) - - stmt = rq.SelectStatement( - rq.Expr.custom("SUM(val)").label("running_sum", window) - ).from_table("data") - - sql = stmt.to_sql("postgres") - assert "ROWS BETWEEN 5PRECEDING AND CURRENT ROW" in sql - - def test_frame_unbounded(self): - """Test WINDOW with UNBOUNDED boundaries.""" - window = rq.WindowStatement().frame( - "RANGE", rq.Frame.unbounded_preceding(), rq.Frame.unbounded_following() - ) - - stmt = rq.SelectStatement( - rq.SelectLabel(rq.Expr.custom("COUNT(*)"), "total", window) - ).from_table("users") - - sql = stmt.to_sql("mysql") - assert "RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING" in sql - - -class TestWith: - def test_simple_cte(self): - """Test a basic CTE using WithClause.""" - cte_query = ( - rq.SelectStatement() - .columns("id", "name") - .from_table("users") - .where(rq.Expr.col("active").is_(True)) - ) - - with_query = ( - rq.WithClause() - .cte("active_users", cte_query) - .query(rq.SelectStatement().columns("*").from_table("active_users")) - ) - - sql = with_query.to_sql("postgres") - assert 'WITH "active_users"' in sql - assert 'SELECT "id" AS "id", "name" AS "name" FROM "users"' in sql - assert 'SELECT * FROM "active_users"' in sql - - def test_multiple_ctes(self): - """Test multiple CTEs in a single WITH clause.""" - q1 = rq.SelectStatement().columns("id").from_table("table_a") - q2 = rq.SelectStatement().columns("id").from_table("table_b") - - with_query = ( - rq.WithClause() - .cte("cte_a", q1) - .cte("cte_b", q2) - .query( - rq.SelectStatement() - .columns("cte_a.id") - .from_table("cte_a") - .join("cte_b", rq.Expr.col("cte_a.id") == rq.Expr.col("cte_b.id")) - ) - ) - - sql = with_query.to_sql("postgres") - assert 'WITH "cte_a"' in sql - assert '"cte_b"' in sql - assert 'JOIN "cte_b"' in sql - - def test_recursive(self): - """Test RECURSIVE WITH clause generation.""" - # A simplified recursive CTE for sequence generation - base_part = rq.SelectStatement(rq.Expr.val(1).label("n")) - recursive_part = ( - rq.SelectStatement((rq.Expr.col("n") + 1).label("n")) - .from_table("nums") - .where(rq.Expr.col("n") < 5) - ) - - # SeaQuery/RapidQuery typically uses Union to combine parts for recursion - union_query = base_part.union(recursive_part) - - with_query = ( - rq.WithClause() - .recursive() - .cte("nums", union_query, columns=["n"]) - .query(rq.SelectStatement().columns("*").from_table("nums")) - ) - - sql = with_query.to_sql("postgres") - assert "WITH RECURSIVE" in sql - assert '"nums" ("n") AS' in sql - - def test_materialized(self): - """Test the MATERIALIZED hint in CTEs (Postgres specific).""" - q = rq.SelectStatement().columns("id").from_table("very_large_table") - - # Materialized True - sql_mat = ( - rq.WithClause() - .cte("cached_data", q, materialized=True) - .query(rq.SelectStatement().from_table("cached_data")) - .to_sql("postgres") - ) - assert "MATERIALIZED" in sql_mat - - # Materialized False (NOT MATERIALIZED) - sql_not_mat = ( - rq.WithClause() - .cte("raw_data", q, materialized=False) - .query(rq.SelectStatement().from_table("raw_data")) - .to_sql("postgres") - ) - assert "NOT MATERIALIZED" in sql_not_mat diff --git a/tests/test_schema.py b/tests/test_schema.py deleted file mode 100644 index 814c6a2..0000000 --- a/tests/test_schema.py +++ /dev/null @@ -1,272 +0,0 @@ -import pytest - -import rapidquery as rq - -from .mixin import TableNameInitMixin - - -class TestAlterTable(TableNameInitMixin): - table_name_instance = rq.AlterTable - - options = [ - rq.AlterTableAddColumnOption(rq.Column("premium", rq.sqltypes.Boolean())), - rq.AlterTableAddForeignKeyOption(rq.ForeignKey(["id"], ["fonts.id"])), - rq.AlterTableDropColumnOption("id"), - rq.AlterTableDropForeignKeyOption("fk_12"), - rq.AlterTableModifyColumnOption(rq.Column("name", rq.sqltypes.Char(64), nullable=True)), - rq.AlterTableRenameColumnOption("id", "user_id"), - ] - - def test_init(self): - rq.AlterTable("users", self.options) - rq.AlterTable("users", []) - - with pytest.raises(TypeError): - rq.AlterTable("users", [1]) # type: ignore - - with pytest.raises(TypeError): - rq.AlterTable("users", ["a"]) # type: ignore - - def test_add_option(self): - stmt = rq.AlterTable("users", []) - - for op in self.options: - stmt.add_option(op) - - def test_properties(self): - stmt = rq.AlterTable("users", self.options) - - assert stmt.name == rq.TableName("users") - assert stmt.options == self.options - - stmt.name = "public.fonts" - assert stmt.name == rq.TableName("fonts", "public") - - stmt.options = [] - assert stmt.options == [] - - -class TestDropIndex(TableNameInitMixin): - def table_name_instance(self, tb): - return rq.DropIndex("idx_1", tb) - - def test_init(self): - stmt = rq.DropIndex("idx_name", "users", if_exists=True) - assert "IF EXISTS" in stmt.to_sql("postgres") - - def test_properties(self): - stmt = rq.DropIndex("idx_1", "fonts") - - assert stmt.name == "idx_1" - assert stmt.table == rq.TableName("fonts") - assert not stmt.if_exists - - stmt.name = "idx_2" - assert stmt.name == "idx_2" - - stmt.table = "users" - assert stmt.table == rq.TableName("users") - - stmt.if_exists = True - assert stmt.if_exists - - -class TestDropTable(TableNameInitMixin): - table_name_instance = rq.DropTable - - def test_init(self): - rq.DropTable("users", if_exists=True, cascade=True, restrict=True) - - def test_properties(self): - stmt = rq.DropTable("fonts") - - assert stmt.name == rq.TableName("fonts") - assert not stmt.if_exists - assert not stmt.cascade - assert not stmt.restrict - - stmt.name = "public.users" - assert stmt.name == rq.TableName("users", "public") - - stmt.if_exists = True - stmt.cascade = True - stmt.restrict = True - assert stmt.if_exists - assert stmt.cascade - assert stmt.restrict - - -class TestIndex(TableNameInitMixin): - def table_name_instance(self, tb): - return rq.Index(None, ["id"], tb) - - def test_init(self): - stmt = rq.Index("idx_glyph_aspect", ["aspect"], "glyph", if_not_exists=True).to_sql( - "sqlite" - ) - assert stmt == 'CREATE INDEX IF NOT EXISTS "idx_glyph_aspect" ON "glyph" ("aspect")' - - stmt = rq.Index("idx_glyph_aspect", [rq.IndexColumn("aspect", "ASC", 128)], "glyph").to_sql( - "mysql" - ) - assert stmt == "CREATE INDEX `idx_glyph_aspect` ON `glyph` (`aspect` (128) ASC)" - - stmt = rq.Index( - "idx_font_name_include_language", - ["name"], - "fonts", - include=["language"], - where=rq.Expr.col("aspect").in_([3, 4]), - ).to_sql("postgresql") - assert ( - stmt - == 'CREATE INDEX "idx_font_name_include_language" ON "fonts" ("name") INCLUDE ("language")' - ) - - stmt = rq.Index( - "idx_name", - ["font_id"], - "fonts", - primary=False, - nulls_not_distinct=True, - unique=True, - index_type="BTREE", - where=None, - include=["language"], - ).to_sql("postgres") - assert ( - stmt - == 'CREATE UNIQUE INDEX "idx_name" ON "fonts" USING BTREE ("font_id") INCLUDE ("language") NULLS NOT DISTINCT' - ) - - -class TestIndexColumn: - def test_new(self): - rq.IndexColumn("name") - rq.IndexColumn("name", "ASC") - rq.IndexColumn("name", prefix=12) - rq.IndexColumn("name", "DESC", prefix=1) - rq.IndexColumn("name", None, None) - - # lowercase orders - rq.IndexColumn("name", "asc") # type: ignore - rq.IndexColumn("name", "desc") # type: ignore - - with pytest.raises(Exception): - rq.IndexColumn("name", "invalid") # type: ignore - - val = rq.IndexColumn("name", "desc", prefix=1) # type: ignore - assert val.name == "name" - assert val.order == "DESC" - assert val.prefix == 1 - - -class TestRenameTable(TableNameInitMixin): - def table_name_instance(self, tb): - return rq.RenameTable(tb, tb) - - def test_properties(self): - stmt = rq.RenameTable("t1", "t2") - - assert stmt.from_name == rq.TableName("t1") - assert stmt.to_name == rq.TableName("t2") - - stmt.from_name = "pub.users" - stmt.to_name = "arc.users" - assert stmt.from_name == rq.TableName("users", "pub") - assert stmt.to_name == rq.TableName("users", "arc") - - -class TestTable: - def test_init(self): - rq.Table("users") - rq.Table("public.users") - rq.Table("db.public.users") - rq.Table(rq.TableName("users")) - rq.Table(rq.TableName("users", "public")) - rq.Table(rq.TableName("users", "public", "db")) - - table = rq.Table( - "pub.users", - rq.Column("id", rq.sqltypes.Integer(), primary_key=True, auto_increment=True), - rq.Column("username", rq.sqltypes.Integer(), unique_key=True, nullable=False), - rq.Column("font_id", rq.sqltypes.Integer(), unique_key=True, nullable=False), - rq.ForeignKey(["font_id"], ["fonts.id"], on_delete="CASCADE"), - rq.Index("idx_name", ["id", "font_id"]), - rq.Expr.col("username").like("A%"), - if_not_exists=True, - extra="WOW", - ) - assert table.name == rq.TableName("users", "pub") - assert len(table.columns) == 3 - assert len(table.foreign_keys) == 1 - assert len(table.indexes) == 1 - assert len(table.checks) == 1 - assert all(isinstance(x, rq.Column) for x in table.columns) - assert all(isinstance(x, rq.ForeignKey) for x in table.foreign_keys) - assert all(isinstance(x, rq.Index) for x in table.indexes) - assert all(isinstance(x, rq.Expr) for x in table.checks) - assert table.if_not_exists - assert not table.temporary - assert table.extra == "WOW" - - stmt = table.to_sql("postgres") - assert stmt.count(";") == 1 - assert "IF NOT EXISTS" in stmt - assert '"id"' in stmt - assert '"username"' in stmt - assert '"font_id"' in stmt - assert "WOW" in stmt - - def test_args(self): - with pytest.raises(TypeError): - rq.Table( - "pub.users", - rq.Column("id", rq.sqltypes.Integer(), primary_key=True, auto_increment=True), - complex(), # type: ignore - ) - - class Col(rq.Column): - pass - - class Fk(rq.ForeignKey): - pass - - class Idx(rq.Index): - pass - - table = rq.Table( - "pub.users", - Col("id", rq.sqltypes.Integer(), primary_key=True, auto_increment=True), - Fk(["font_id"], ["fonts.id"], on_delete="CASCADE"), - Idx("idx_name", ["id", "font_id"]), - ) - - assert table.name == rq.TableName("users", "pub") - assert len(table.columns) == 1 - assert len(table.foreign_keys) == 1 - assert len(table.indexes) == 1 - assert all(isinstance(x, Col) for x in table.columns) - assert all(isinstance(x, Fk) for x in table.foreign_keys) - assert all(isinstance(x, Idx) for x in table.indexes) - - def test_multiple_primary(self): - stmt = rq.Table( - "pub.users", - rq.Column("id", rq.sqltypes.Integer(), primary_key=True), - rq.Column("name", rq.sqltypes.Integer(), primary_key=True), - ) - assert stmt.to_sql("postgres").count("PRIMARY KEY") == 2 - - -class TestTruncateTable: - def table_name_instance(self, tb): - return rq.TruncateTable(tb) - - def test_properties(self): - stmt = rq.TruncateTable("t1") - - assert stmt.name == rq.TableName("t1") - - stmt.name = "pub.users" - assert stmt.name == rq.TableName("users", "pub") diff --git a/tests/test_sqltypes.py b/tests/test_sqltypes.py deleted file mode 100644 index 5538402..0000000 --- a/tests/test_sqltypes.py +++ /dev/null @@ -1,262 +0,0 @@ -import datetime -import decimal -import enum -import uuid -from collections import namedtuple - -import pytest - -import rapidquery as rq - -Case = namedtuple( - "Case", - ("data_type", "value", "exc"), -) - - -class Enum(enum.Enum): - FIELD = "field" - - -class StrEnum(str, enum.Enum): - FIELD = "field" - - -class IntEnum(int, enum.Enum): - FIELD = 1 - - -TEST_CASES = [ - # --- BlobType --- - Case(rq.sqltypes.Blob(), b"A", None), - Case(rq.sqltypes.Blob(), 4.5, TypeError), - Case(rq.sqltypes.Blob(), "Ali", TypeError), - # --- BinaryType --- - Case(rq.sqltypes.Binary(), b"A", None), - Case(rq.sqltypes.Binary(), 4.5, TypeError), - Case(rq.sqltypes.Binary(), "Ali", TypeError), - # --- VarBinaryType --- - Case(rq.sqltypes.VarBinary(), b"A", None), - Case(rq.sqltypes.VarBinary(), 4.5, TypeError), - Case(rq.sqltypes.VarBinary(), "Ali", TypeError), - # --- BitType --- - Case(rq.sqltypes.Bit(8), b"A", None), - Case(rq.sqltypes.Bit(8), 4.5, TypeError), - Case(rq.sqltypes.Bit(8), "Ali", TypeError), - # --- VarBitType --- - Case(rq.sqltypes.VarBit(8), b"A", None), - Case(rq.sqltypes.VarBit(8), 4.5, TypeError), - Case(rq.sqltypes.VarBit(8), "Ali", TypeError), - # --- DateTimeType --- - Case(rq.sqltypes.DateTime(), datetime.datetime.now(), None), - Case(rq.sqltypes.DateTime(), datetime.datetime.now(tz=datetime.timezone.utc), None), - Case(rq.sqltypes.DateTime(), datetime.datetime.now(tz=datetime.timezone.min), None), - Case(rq.sqltypes.DateTime(), datetime.datetime.now().date(), TypeError), - Case(rq.sqltypes.DateTime(), datetime.datetime.now().time(), TypeError), - Case(rq.sqltypes.DateTime(), 4, TypeError), - Case(rq.sqltypes.DateTime(), 4.5, TypeError), - Case(rq.sqltypes.DateTime(), "Ali", TypeError), - # --- TimestampType --- - Case(rq.sqltypes.Timestamp(), datetime.datetime.now(), None), - Case( - rq.sqltypes.Timestamp(True), - datetime.datetime.now(tz=datetime.timezone.utc), - None, - ), - Case(rq.sqltypes.Timestamp(), datetime.datetime.now(tz=datetime.timezone.min), None), - Case(rq.sqltypes.Timestamp(), datetime.datetime.now().date(), TypeError), - Case(rq.sqltypes.Timestamp(), datetime.datetime.now().time(), TypeError), - Case(rq.sqltypes.Timestamp(), datetime.datetime.now().timestamp(), None), - Case(rq.sqltypes.Timestamp(), int(datetime.datetime.now().timestamp()), None), - Case(rq.sqltypes.Timestamp(), "Ali", TypeError), - # --- TimeType --- - Case(rq.sqltypes.Time(), datetime.datetime.now(), TypeError), - Case(rq.sqltypes.Time(), datetime.datetime.now().date(), TypeError), - Case(rq.sqltypes.Time(), datetime.datetime.now().time(), None), - Case(rq.sqltypes.Time(), datetime.datetime.now().timestamp(), TypeError), - Case(rq.sqltypes.Time(), int(datetime.datetime.now().timestamp()), TypeError), - Case(rq.sqltypes.Time(), "Ali", TypeError), - # --- DateType --- - Case(rq.sqltypes.Date(), datetime.datetime.now(), TypeError), - Case(rq.sqltypes.Date(), datetime.datetime.now().date(), None), - Case(rq.sqltypes.Date(), datetime.datetime.now().time(), TypeError), - Case(rq.sqltypes.Date(), datetime.datetime.now().timestamp(), TypeError), - Case(rq.sqltypes.Date(), int(datetime.datetime.now().timestamp()), TypeError), - Case(rq.sqltypes.Date(), "Ali", TypeError), - # --- JSONType --- - Case(rq.sqltypes.JSON(), 4.5, None), - Case(rq.sqltypes.JSON(), 4456746532, None), - Case(rq.sqltypes.JSON(), "Ali", None), - Case(rq.sqltypes.JSON(), {"key": "value"}, None), - Case(rq.sqltypes.JSON(), [2, 3], None), - Case(rq.sqltypes.JSON(), b"bytes", TypeError), - Case(rq.sqltypes.JSON(), (1, 2), TypeError), - Case(rq.sqltypes.JSON(), datetime.datetime.now(), TypeError), - # --- JSONBinaryType --- - Case(rq.sqltypes.JSONBinary(), 4.5, None), - Case(rq.sqltypes.JSONBinary(), 4456746532, None), - Case(rq.sqltypes.JSONBinary(), "Ali", None), - Case(rq.sqltypes.JSONBinary(), {"key": "value"}, None), - Case(rq.sqltypes.JSONBinary(), [2, 3], None), - Case(rq.sqltypes.JSONBinary(), b"bytes", TypeError), - Case(rq.sqltypes.JSONBinary(), (1, 2), TypeError), - Case(rq.sqltypes.JSONBinary(), datetime.datetime.now(), TypeError), - # --- DecimalType --- - Case(rq.sqltypes.Decimal(None), 4.5, None), - Case(rq.sqltypes.Decimal((10, 4)), "5.6", None), - Case(rq.sqltypes.Decimal(), 4, None), - Case(rq.sqltypes.Decimal(), "9e-10", None), - Case(rq.sqltypes.Decimal(), decimal.Decimal("1.0"), None), - Case(rq.sqltypes.Decimal(), "Ali", ValueError), - # --- UUIDType --- - Case(rq.sqltypes.UUID(), uuid.uuid4(), None), - Case(rq.sqltypes.UUID(), uuid.uuid4().hex, TypeError), - Case(rq.sqltypes.UUID(), uuid.uuid4().int, TypeError), - # --- INETType --- - Case(rq.sqltypes.INET(), "1.2.3.4", None), - Case(rq.sqltypes.INET(), "1.2.3.4/23", None), - Case(rq.sqltypes.INET(), "127.0.0.1", None), - Case(rq.sqltypes.INET(), "127.0.0.1:8080", ValueError), - Case(rq.sqltypes.INET(), "EA:31:7D:14:D8:40", ValueError), - Case(rq.sqltypes.INET(), "invalid", ValueError), - Case(rq.sqltypes.INET(), uuid.uuid4().hex, ValueError), - Case(rq.sqltypes.INET(), 45, TypeError), - # --- MacAddressType --- - Case(rq.sqltypes.MacAddress(), "EA:31:7D:14:D8:40", None), - Case(rq.sqltypes.MacAddress(), "1.2.3.4", ValueError), - Case(rq.sqltypes.MacAddress(), "invalid", ValueError), - Case(rq.sqltypes.MacAddress(), uuid.uuid4().hex, ValueError), - Case(rq.sqltypes.MacAddress(), 45, TypeError), - # --- EnumType --- - Case(rq.sqltypes.Enum("USER", ("a", "b")), "string", None), - Case(rq.sqltypes.Enum("USER", ("a", "b")), Enum.FIELD, None), - Case(rq.sqltypes.Enum("USER", ("a", "b")), StrEnum.FIELD, None), - Case(rq.sqltypes.Enum("USER", ("a", "b")), IntEnum.FIELD, TypeError), - Case(rq.sqltypes.Enum("USER", ("a", "b")), Enum, TypeError), - Case(rq.sqltypes.Enum("USER", ("a", "b")), 4.5, TypeError), - Case(rq.sqltypes.Enum("USER", ("a", "b")), 45, TypeError), - # --- BigIntegerType --- - Case(rq.sqltypes.BigInteger(), 1638479230, None), - Case(rq.sqltypes.BigInteger(), -1638479230, None), - Case(rq.sqltypes.BigInteger(), 4.5, TypeError), - Case(rq.sqltypes.BigInteger(), "Ali", TypeError), - Case(rq.sqltypes.BigInteger(), 9223372036854775807, None), - Case(rq.sqltypes.BigInteger(), 9223372036854775807 + 1, OverflowError), - # --- IntegerType --- - Case(rq.sqltypes.Integer(), 37, None), - Case(rq.sqltypes.Integer(), -37, None), - Case(rq.sqltypes.Integer(), 4.5, TypeError), - Case(rq.sqltypes.Integer(), "Ali", TypeError), - Case(rq.sqltypes.Integer(), 2147483647, None), - Case(rq.sqltypes.Integer(), 2147483647 + 1, OverflowError), - # --- SmallIntegerType --- - Case(rq.sqltypes.SmallInteger(), 647, None), - Case(rq.sqltypes.SmallInteger(), -647, None), - Case(rq.sqltypes.SmallInteger(), 4.5, TypeError), - Case(rq.sqltypes.SmallInteger(), "Ali", TypeError), - Case(rq.sqltypes.SmallInteger(), 32767, None), - Case(rq.sqltypes.SmallInteger(), 32767 + 1, OverflowError), - # --- TinyIntegerType --- - Case(rq.sqltypes.TinyInteger(), 10, None), - Case(rq.sqltypes.TinyInteger(), -10, None), - Case(rq.sqltypes.TinyInteger(), 4.5, TypeError), - Case(rq.sqltypes.TinyInteger(), "Ali", TypeError), - Case(rq.sqltypes.TinyInteger(), 127, None), - Case(rq.sqltypes.TinyInteger(), 127 + 1, OverflowError), - # --- BigUnsignedType --- - Case(rq.sqltypes.BigUnsigned(), 89438302, None), - Case(rq.sqltypes.BigUnsigned(), -1, OverflowError), - Case(rq.sqltypes.BigUnsigned(), 4.5, TypeError), - Case(rq.sqltypes.BigUnsigned(), "Ali", TypeError), - Case(rq.sqltypes.BigUnsigned(), 18446744073709551615, None), - Case(rq.sqltypes.BigUnsigned(), 18446744073709551615 + 1, OverflowError), - # --- UnsignedType --- - Case(rq.sqltypes.Unsigned(), 89438302, None), - Case(rq.sqltypes.Unsigned(), -1, OverflowError), - Case(rq.sqltypes.Unsigned(), 4.5, TypeError), - Case(rq.sqltypes.Unsigned(), "Ali", TypeError), - Case(rq.sqltypes.Unsigned(), 4294967295, None), - Case(rq.sqltypes.Unsigned(), 4294967295 + 1, OverflowError), - # --- SmallUnsignedType --- - Case(rq.sqltypes.SmallUnsigned(), 978, None), - Case(rq.sqltypes.SmallUnsigned(), -1, OverflowError), - Case(rq.sqltypes.SmallUnsigned(), 4.5, TypeError), - Case(rq.sqltypes.SmallUnsigned(), "Ali", TypeError), - Case(rq.sqltypes.SmallUnsigned(), 65535, None), - Case(rq.sqltypes.SmallUnsigned(), 65535 + 1, OverflowError), - # --- TinyUnsignedType --- - Case(rq.sqltypes.TinyUnsigned(), 20, None), - Case(rq.sqltypes.TinyUnsigned(), -1, OverflowError), - Case(rq.sqltypes.TinyUnsigned(), 4.5, TypeError), - Case(rq.sqltypes.TinyUnsigned(), "Ali", TypeError), - Case(rq.sqltypes.TinyUnsigned(), 255, None), - Case(rq.sqltypes.TinyUnsigned(), 255 + 1, OverflowError), - # --- FloatType --- - Case(rq.sqltypes.Float(), 20.0, None), - Case(rq.sqltypes.Float(), 20, None), - Case(rq.sqltypes.Float(), 4.5, None), - Case(rq.sqltypes.Float(), "Ali", TypeError), - # --- DoubleType --- - Case(rq.sqltypes.Double(), 20.0, None), - Case(rq.sqltypes.Double(), 20, None), - Case(rq.sqltypes.Double(), 4.5, None), - Case(rq.sqltypes.Double(), "Ali", TypeError), - # --- TextType --- - Case(rq.sqltypes.Text(), "A", None), - Case(rq.sqltypes.Text(), "A" * 10000, None), - Case(rq.sqltypes.Text(), b"B", TypeError), - Case(rq.sqltypes.Text(), 1, TypeError), - Case(rq.sqltypes.Text(), 1.4, TypeError), - # --- StringType --- - Case(rq.sqltypes.String(), "A", None), - Case(rq.sqltypes.String(), "A" * 10000, None), - Case(rq.sqltypes.String(), b"B", TypeError), - Case(rq.sqltypes.String(), 1, TypeError), - Case(rq.sqltypes.String(), 1.4, TypeError), - # --- CharType --- - Case(rq.sqltypes.Char(), "A", None), - Case(rq.sqltypes.Char(), "A" * 10000, None), - Case(rq.sqltypes.Char(), b"B", TypeError), - Case(rq.sqltypes.Char(), 1, TypeError), - Case(rq.sqltypes.Char(), 1.4, TypeError), - # --- ArrayType --- - Case(rq.sqltypes.Array(rq.sqltypes.Integer()), [1, 2], None), - Case(rq.sqltypes.Array(rq.sqltypes.Integer()), [1, 2], None), - Case(rq.sqltypes.Array(rq.sqltypes.Integer()), (1, 2, 5), None), - Case(rq.sqltypes.Array(rq.sqltypes.Float()), [1.3, 2.7], None), - Case(rq.sqltypes.Array(rq.sqltypes.Integer()), [1, "A"], TypeError), - Case( - rq.sqltypes.Array(rq.sqltypes.Integer()), - [1, 2, 3, 4, 5, 6, 7, 8, "A", 10], - TypeError, - ), - Case(rq.sqltypes.Array(rq.sqltypes.Text()), ["B", "A"], TypeError), - # --- VectorType --- - Case(rq.sqltypes.Vector(6), [1.4, 2.1], None), - Case(rq.sqltypes.Vector(5), [1, 2], None), - Case(rq.sqltypes.Vector(4), (1, 2, 5.3), None), - Case(rq.sqltypes.Vector(3), [1.3, 2.7], None), - Case(rq.sqltypes.Vector(2), [1, "A"], TypeError), - Case(rq.sqltypes.Vector(1), [1, 2, 3, 4, 5, 6, 7, 8, "A", 10], TypeError), - Case(rq.sqltypes.Vector(), ["B", "A"], TypeError), -] - - -class TestSQLType: - @pytest.mark.parametrize("case", TEST_CASES) - def test_validate_serialize(self, case: Case): - try: - val = rq.Value(case.value, case.data_type) - rq.Expr(val) # Force Value to adapt - except Exception as e: - if case.exc is not None and type(e) is case.exc: - return - - raise e - - assert val.sql_type is case.data_type - assert val.value == case.value - - @pytest.mark.parametrize("case", TEST_CASES) - def test_type_name(self, case: Case): - assert case.data_type.__type_name__ diff --git a/uv.lock b/uv.lock index de45cac..4038847 100644 --- a/uv.lock +++ b/uv.lock @@ -1,598 +1,29 @@ version = 1 revision = 3 -requires-python = ">=3.8" -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", - "python_full_version < '3.9'", -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -sdist = { url = "https://mirror-pypi.runflare.com/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -dependencies = [ - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, -] -sdist = { url = "https://mirror-pypi.runflare.com/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598" }, -] - -[[package]] -name = "greenlet" -version = "3.1.1" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://mirror-pypi.runflare.com/packages/2f/ff/df5fede753cc10f6a5be0931204ea30c35fa2f2ea7a35b25bdaf4fe40e46/greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/25/90/5234a78dc0ef6496a6eb97b67a42a8e96742a56f7dc808cb954a85390448/greenlet-3.1.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563" }, - { url = "https://mirror-pypi.runflare.com/packages/7c/16/cd631fa0ab7d06ef06387135b7549fdcc77d8d859ed770a0d28e47b20972/greenlet-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83" }, - { url = "https://mirror-pypi.runflare.com/packages/2f/b1/aed39043a6fec33c284a2c9abd63ce191f4f1a07319340ffc04d2ed3256f/greenlet-3.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36b89d13c49216cadb828db8dfa6ce86bbbc476a82d3a6c397f0efae0525bdd0" }, - { url = "https://mirror-pypi.runflare.com/packages/fb/2f/3850b867a9af519794784a7eeed1dd5bc68ffbcc5b28cef703711025fd0a/greenlet-3.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93147c513fac16385d1036b7e5b102c7fbbdb163d556b791f0f11eada7ba65dc" }, - { url = "https://mirror-pypi.runflare.com/packages/cf/69/79e4d63b9387b48939096e25115b8af7cd8a90397a304f92436bcb21f5b2/greenlet-3.1.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da7a9bff22ce038e19bf62c4dd1ec8391062878710ded0a845bcf47cc0200617" }, - { url = "https://mirror-pypi.runflare.com/packages/46/1d/44dbcb0e6c323bd6f71b8c2f4233766a5faf4b8948873225d34a0b7efa71/greenlet-3.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b2795058c23988728eec1f36a4e5e4ebad22f8320c85f3587b539b9ac84128d7" }, - { url = "https://mirror-pypi.runflare.com/packages/e0/1d/a305dce121838d0278cee39d5bb268c657f10a5363ae4b726848f833f1bb/greenlet-3.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ed10eac5830befbdd0c32f83e8aa6288361597550ba669b04c48f0f9a2c843c6" }, - { url = "https://mirror-pypi.runflare.com/packages/96/28/d62835fb33fb5652f2e98d34c44ad1a0feacc8b1d3f1aecab035f51f267d/greenlet-3.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:77c386de38a60d1dfb8e55b8c1101d68c79dfdd25c7095d51fec2dd800892b80" }, - { url = "https://mirror-pypi.runflare.com/packages/28/62/1c2665558618553c42922ed47a4e6d6527e2fa3516a8256c2f431c5d0441/greenlet-3.1.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70" }, - { url = "https://mirror-pypi.runflare.com/packages/76/9d/421e2d5f07285b6e4e3a676b016ca781f63cfe4a0cd8eaecf3fd6f7a71ae/greenlet-3.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159" }, - { url = "https://mirror-pypi.runflare.com/packages/e5/de/6e05f5c59262a584e502dd3d261bbdd2c97ab5416cc9c0b91ea38932a901/greenlet-3.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e" }, - { url = "https://mirror-pypi.runflare.com/packages/15/85/72f77fc02d00470c86a5c982b8daafdf65d38aefbbe441cebff3bf7037fc/greenlet-3.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383" }, - { url = "https://mirror-pypi.runflare.com/packages/f7/4b/1c9695aa24f808e156c8f4813f685d975ca73c000c2a5056c514c64980f6/greenlet-3.1.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a" }, - { url = "https://mirror-pypi.runflare.com/packages/76/70/ad6e5b31ef330f03b12559d19fda2606a522d3849cde46b24f223d6d1619/greenlet-3.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511" }, - { url = "https://mirror-pypi.runflare.com/packages/f4/fb/201e1b932e584066e0f0658b538e73c459b34d44b4bd4034f682423bc801/greenlet-3.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395" }, - { url = "https://mirror-pypi.runflare.com/packages/12/da/b9ed5e310bb8b89661b80cbcd4db5a067903bbcd7fc854923f5ebb4144f0/greenlet-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39" }, - { url = "https://mirror-pypi.runflare.com/packages/7d/ec/bad1ac26764d26aa1353216fcbfa4670050f66d445448aafa227f8b16e80/greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d" }, - { url = "https://mirror-pypi.runflare.com/packages/66/d4/c8c04958870f482459ab5956c2942c4ec35cac7fe245527f1039837c17a9/greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79" }, - { url = "https://mirror-pypi.runflare.com/packages/51/41/467b12a8c7c1303d20abcca145db2be4e6cd50a951fa30af48b6ec607581/greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa" }, - { url = "https://mirror-pypi.runflare.com/packages/57/5c/7c6f50cb12be092e1dccb2599be5a942c3416dbcfb76efcf54b3f8be4d8d/greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36" }, - { url = "https://mirror-pypi.runflare.com/packages/f1/66/033e58a50fd9ec9df00a8671c74f1f3a320564c6415a4ed82a1c651654ba/greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9" }, - { url = "https://mirror-pypi.runflare.com/packages/19/c5/36384a06f748044d06bdd8776e231fadf92fc896bd12cb1c9f5a1bda9578/greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0" }, - { url = "https://mirror-pypi.runflare.com/packages/38/f9/c0a0eb61bdf808d23266ecf1d63309f0e1471f284300ce6dac0ae1231881/greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942" }, - { url = "https://mirror-pypi.runflare.com/packages/43/21/a5d9df1d21514883333fc86584c07c2b49ba7c602e670b174bd73cfc9c7f/greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01" }, - { url = "https://mirror-pypi.runflare.com/packages/f3/57/0db4940cd7bb461365ca8d6fd53e68254c9dbbcc2b452e69d0d41f10a85e/greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1" }, - { url = "https://mirror-pypi.runflare.com/packages/1c/ec/423d113c9f74e5e402e175b157203e9102feeb7088cee844d735b28ef963/greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff" }, - { url = "https://mirror-pypi.runflare.com/packages/a9/46/ddbd2db9ff209186b7b7c621d1432e2f21714adc988703dbdd0e65155c77/greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a" }, - { url = "https://mirror-pypi.runflare.com/packages/d9/42/b87bc2a81e3a62c3de2b0d550bf91a86939442b7ff85abb94eec3fc0e6aa/greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4" }, - { url = "https://mirror-pypi.runflare.com/packages/37/fa/71599c3fd06336cdc3eac52e6871cfebab4d9d70674a9a9e7a482c318e99/greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e" }, - { url = "https://mirror-pypi.runflare.com/packages/4e/96/e9ef85de031703ee7a4483489b40cf307f93c1824a02e903106f2ea315fe/greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1" }, - { url = "https://mirror-pypi.runflare.com/packages/87/76/b2b6362accd69f2d1889db61a18c94bc743e961e3cab344c2effaa4b4a25/greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c" }, - { url = "https://mirror-pypi.runflare.com/packages/1f/1b/54336d876186920e185066d8c3024ad55f21d7cc3683c856127ddb7b13ce/greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761" }, - { url = "https://mirror-pypi.runflare.com/packages/5f/17/bea55bf36990e1638a2af5ba10c1640273ef20f627962cf97107f1e5d637/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011" }, - { url = "https://mirror-pypi.runflare.com/packages/78/d2/aa3d2157f9ab742a08e0fd8f77d4699f37c22adfbfeb0c610a186b5f75e0/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13" }, - { url = "https://mirror-pypi.runflare.com/packages/05/79/e15408220bbb989469c8871062c97c6c9136770657ba779711b90870d867/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b" }, - { url = "https://mirror-pypi.runflare.com/packages/18/87/470e01a940307796f1d25f8167b551a968540fbe0551c0ebb853cb527dd6/greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822" }, - { url = "https://mirror-pypi.runflare.com/packages/e2/72/576815ba674eddc3c25028238f74d7b8068902b3968cbe456771b166455e/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01" }, - { url = "https://mirror-pypi.runflare.com/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6" }, - { url = "https://mirror-pypi.runflare.com/packages/97/83/bdf5f69fcf304065ec7cf8fc7c08248479cfed9bcca02bf0001c07e000aa/greenlet-3.1.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:346bed03fe47414091be4ad44786d1bd8bef0c3fcad6ed3dee074a032ab408a9" }, - { url = "https://mirror-pypi.runflare.com/packages/31/4a/2d4443adcb38e1e90e50c653a26b2be39998ea78ca1a4cf414dfdeb2e98b/greenlet-3.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfc59d69fc48664bc693842bd57acfdd490acafda1ab52c7836e3fc75c90a111" }, - { url = "https://mirror-pypi.runflare.com/packages/5a/c9/b5d9ac1b932aa772dd1eb90a8a2b30dbd7ad5569dcb7fdac543810d206b4/greenlet-3.1.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d21e10da6ec19b457b82636209cbe2331ff4306b54d06fa04b7c138ba18c8a81" }, - { url = "https://mirror-pypi.runflare.com/packages/a7/25/de419a2b22fa6e18ce3b2a5adb01d33ec7b2784530f76fa36ba43d8f0fac/greenlet-3.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef9ea3f137e5711f0dbe5f9263e8c009b7069d8a1acea822bd5e9dae0ae49c8" }, - { url = "https://mirror-pypi.runflare.com/packages/d8/88/0ce16c0afb2d71d85562a7bcd9b092fec80a7767ab5b5f7e1bbbca8200f8/greenlet-3.1.1-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85f3ff71e2e60bd4b4932a043fbbe0f499e263c628390b285cb599154a3b03b1" }, - { url = "https://mirror-pypi.runflare.com/packages/5a/10/39a417ad0afb0b7e5b150f1582cdeb9416f41f2e1df76018434dfac4a6cc/greenlet-3.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:95ffcf719966dd7c453f908e208e14cde192e09fde6c7186c8f1896ef778d8cd" }, - { url = "https://mirror-pypi.runflare.com/packages/9f/f5/e9b151ddd2ed0508b7a47bef7857e46218dbc3fd10e564617a3865abfaac/greenlet-3.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:03a088b9de532cbfe2ba2034b2b85e82df37874681e8c470d6fb2f8c04d7e4b7" }, - { url = "https://mirror-pypi.runflare.com/packages/86/97/2c86989ca4e0f089fbcdc9229c972a01ef53abdafd5ae89e0f3dcdcd4adb/greenlet-3.1.1-cp38-cp38-win32.whl", hash = "sha256:8b8b36671f10ba80e159378df9c4f15c14098c4fd73a36b9ad715f057272fbef" }, - { url = "https://mirror-pypi.runflare.com/packages/d3/50/7b7a3e10ed82c760c1fd8d3167a7c95508e9fdfc0b0604f05ed1a9a9efdc/greenlet-3.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:7017b2be767b9d43cc31416aba48aab0d2309ee31b4dbf10a1d38fb7972bdf9d" }, - { url = "https://mirror-pypi.runflare.com/packages/8c/82/8051e82af6d6b5150aacb6789a657a8afd48f0a44d8e91cb72aaaf28553a/greenlet-3.1.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:396979749bd95f018296af156201d6211240e7a23090f50a8d5d18c370084dc3" }, - { url = "https://mirror-pypi.runflare.com/packages/f9/74/f66de2785880293780eebd18a2958aeea7cbe7814af1ccef634f4701f846/greenlet-3.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca9d0ff5ad43e785350894d97e13633a66e2b50000e8a183a50a88d834752d42" }, - { url = "https://mirror-pypi.runflare.com/packages/68/23/acd9ca6bc412b02b8aa755e47b16aafbe642dde0ad2f929f836e57a7949c/greenlet-3.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f6ff3b14f2df4c41660a7dec01045a045653998784bf8cfcb5a525bdffffbc8f" }, - { url = "https://mirror-pypi.runflare.com/packages/03/d3/1006543621f16689f6dc75f6bcf06e3c23e044c26fe391c16c253623313e/greenlet-3.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aaad12ac0ff500f62cebed98d8789198ea0e6f233421059fa68a5aa7220145" }, - { url = "https://mirror-pypi.runflare.com/packages/2f/c1/ad71ce1b5f61f900593377b3f77b39408bce5dc96754790311b49869e146/greenlet-3.1.1-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63e4844797b975b9af3a3fb8f7866ff08775f5426925e1e0bbcfe7932059a12c" }, - { url = "https://mirror-pypi.runflare.com/packages/f7/ff/183226685b478544d61d74804445589e069d00deb8ddef042699733950c7/greenlet-3.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7939aa3ca7d2a1593596e7ac6d59391ff30281ef280d8632fa03d81f7c5f955e" }, - { url = "https://mirror-pypi.runflare.com/packages/c0/8b/9b3b85a89c22f55f315908b94cd75ab5fed5973f7393bbef000ca8b2c5c1/greenlet-3.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d0028e725ee18175c6e422797c407874da24381ce0690d6b9396c204c7f7276e" }, - { url = "https://mirror-pypi.runflare.com/packages/b8/1c/248fadcecd1790b0ba793ff81fa2375c9ad6442f4c748bf2cc2e6563346a/greenlet-3.1.1-cp39-cp39-win32.whl", hash = "sha256:5e06afd14cbaf9e00899fae69b24a32f2196c19de08fcb9f4779dd4f004e5e7c" }, - { url = "https://mirror-pypi.runflare.com/packages/ae/02/e7d0aef2354a38709b764df50b2b83608f0621493e47f47694eb80922822/greenlet-3.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:3319aa75e0e0639bc15ff54ca327e8dc7a6fe404003496e3c6925cd3142e0e22" }, -] - -[[package]] -name = "greenlet" -version = "3.2.5" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", -] -sdist = { url = "https://mirror-pypi.runflare.com/packages/b0/f5/3e9eafb4030588337b2a2ae4df46212956854e9069c07b53aa3caabafd47/greenlet-3.2.5.tar.gz", hash = "sha256:c816554eb33e7ecf9ba4defcb1fd8c994e59be6b4110da15480b3e7447ea4286" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/ff/d6/b3db928fc329b1b19ba32ffe143d2305f3aaafc583f5e1074c74ec445189/greenlet-3.2.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:34cc7cf8ab6f4b85298b01e13e881265ee7b3c1daf6bc10a2944abc15d4f87c3" }, - { url = "https://mirror-pypi.runflare.com/packages/b3/ff/ab0ad4ff3d9e1faa266de4f6c79763b33fccd9265995f2940192494cc0ec/greenlet-3.2.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c11fe0cfb0ce33132f0b5d27eeadd1954976a82e5e9b60909ec2c4b884a55382" }, - { url = "https://mirror-pypi.runflare.com/packages/da/dd/7b3ac77099a1671af8077ecedb12c9a1be1310e4c35bb69fd34c18ab6093/greenlet-3.2.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a145f4b1c4ed7a2c94561b7f18b4beec3d3fb6f0580db22f7ed1d544e0620b34" }, - { url = "https://mirror-pypi.runflare.com/packages/0f/36/84630e9ff1dfc8b7690957c0f77834a84eabdbd9c4977c3a2d0cbd5325c2/greenlet-3.2.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc1d01bdd67db3e5711e6246e451d7a0f75fae7bbf40adde129296a7f9aa7cc9" }, - { url = "https://mirror-pypi.runflare.com/packages/12/c4/6a2ee6c676dea7a05a3c3c1291fbc8ea44f26456b0accc891471293825af/greenlet-3.2.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd593db7ee1fa8a513a48a404f8cc4126998a48025e3f5cbbc68d51be0a6bf66" }, - { url = "https://mirror-pypi.runflare.com/packages/01/c0/75e75c2c993aa850292561ec80f5c263e3924e5843aa95a38716df69304c/greenlet-3.2.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ac8db07bced2c39b987bba13a3195f8157b0cfbce54488f86919321444a1cc3c" }, - { url = "https://mirror-pypi.runflare.com/packages/ee/03/e38ebf9024a0873fe8f60f5b7bc36bfb3be5e13efe4d798240f2d1f0fb73/greenlet-3.2.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4544ab2cfd5912e42458b13516429e029f87d8bbcdc8d5506db772941ae12493" }, - { url = "https://mirror-pypi.runflare.com/packages/d8/7b/c6e1192c795c0c12871e199237909a6bd35757d92c8472c7c019959b8637/greenlet-3.2.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:acabf468466d18017e2ae5fbf1a5a88b86b48983e550e1ae1437b69a83d9f4ac" }, - { url = "https://mirror-pypi.runflare.com/packages/3e/b6/9887b559f3e1952d23052ec352e9977e808a2246c7cb8282a38337221e88/greenlet-3.2.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:472841de62d60f2cafd60edd4fd4dd7253eb70e6eaf14b8990dcaf177f4af957" }, - { url = "https://mirror-pypi.runflare.com/packages/8a/be/e3e48b63bbc27d660fa1d98aecb64906b90a12e686a436169c1330ef34b2/greenlet-3.2.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d951e7d628a6e8b68af469f0fe4f100ef64c4054abeb9cdafbfaa30a920c950" }, - { url = "https://mirror-pypi.runflare.com/packages/4c/ac/e731ed62576e91e533b36d0d97325adc2786674ab9e48ed8a6a24f4ef4e9/greenlet-3.2.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8317d732e2ae0935d9ed2af2ea876fa714cf6f3b887a31ca150b54329b0a6e9" }, - { url = "https://mirror-pypi.runflare.com/packages/70/64/99e5cdceb494bd4c1341c45b93f322601d2c8a5e1e4d1c7a2d24c5ed0570/greenlet-3.2.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce8aed6fdd5e07d3cbb988cbdc188266a4eb9e1a52db9ef5c6526e59962d3933" }, - { url = "https://mirror-pypi.runflare.com/packages/ee/e9/968e11f388c2b8792d3b8b40a57984c894a3b4745dae3662dce722653bc5/greenlet-3.2.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:60c06b502d56d5451f60ca665691da29f79ed95e247bcf8ce5024d7bbe64acb9" }, - { url = "https://mirror-pypi.runflare.com/packages/cb/2c/b5f2c4c68d753dce08218dc5a6b21d82238fdfdc44309032f6fe24d285e6/greenlet-3.2.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d2a78e6f1bf3f1672df91e212a2f8314e1e7c922f065d14cbad4bc815059467" }, - { url = "https://mirror-pypi.runflare.com/packages/ad/32/022b21523eee713e7550162d5ca6aed23f913cc2c6232b154b9fd9badc07/greenlet-3.2.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2acb30e77042f747ca81f0a10cc153296567e92e666c5e1b117f4595afd43352" }, - { url = "https://mirror-pypi.runflare.com/packages/90/c5/8a3b0ed3cc34d8b988a44349437dfa0941f9c23ac108175f7b4ccea97111/greenlet-3.2.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:393c03c26c865f17f31d8db2f09603fadbe0581ad85a5d5908b131549fc38217" }, - { url = "https://mirror-pypi.runflare.com/packages/b1/2c/2627bea183554695016af6cae93d7474fa90f61e5a6601a84ae7841cb720/greenlet-3.2.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:04e6a202cde56043fd355fefd1552c4caa5c087528121871d950eb4f1b51fa99" }, - { url = "https://mirror-pypi.runflare.com/packages/2f/1b/75a5aeff487a26ba427a3837da6372f1fe6f2a9c6b2898e28ac99d491c11/greenlet-3.2.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:45fcea7b697b91290b36eafc12fff479aca6ba6500d98ef6f34d5634c7119cbe" }, - { url = "https://mirror-pypi.runflare.com/packages/53/91/9b5dfb4f3c88f8247c7a8f4c3759f0740bfa6bb0c59a9f6bf938e913df56/greenlet-3.2.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96e2bb8a56b7e1aed1dbfbbe0050cb2ecca99c7c91892fd1771e3afab63b3e3" }, - { url = "https://mirror-pypi.runflare.com/packages/b4/8d/d0b086410512d9859c84e9242a9b341de9f5566011ddf3a3f6886b842b61/greenlet-3.2.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d7456e67b0be653dfe643bb37d9566cd30939c80f858e2ce6d2d54951f75b14a" }, - { url = "https://mirror-pypi.runflare.com/packages/ef/37/59fe12fe456e84ced6ba71781e28cde52a3124d1dd2077bc1727021f49fd/greenlet-3.2.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5ceb29d1f74c7280befbbfa27b9bf91ba4a07a1a00b2179a5d953fc219b16c42" }, - { url = "https://mirror-pypi.runflare.com/packages/dd/95/d5d332fb73affaf7a1fbe80e49c2c7eae4f17c645af24a3b3fa25736d6f0/greenlet-3.2.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f2cc88b50b9006b324c1b9f5f3552f9d4564c78af57cdfb4c7baf4f0aa089146" }, - { url = "https://mirror-pypi.runflare.com/packages/6c/77/89458e20db5a4f1c64f9a0191561227e76d809941ca2d7529006d17d3450/greenlet-3.2.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e66872daffa360b2537170b73ad530f14fa31785b1bc78080125d92edf0a6def" }, - { url = "https://mirror-pypi.runflare.com/packages/90/f8/9962175d2f2eaa629a7fd7545abacc8c4deda3baa4e52c1526d2eb5f5546/greenlet-3.2.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c5445ddb7b586d870dad32ca9fc47c287d6022a528d194efdb8912093c5303ad" }, - { url = "https://mirror-pypi.runflare.com/packages/f5/d7/826d0e080f0a7ad5ec47c8d143bbd3ca0887657bb806595fe2434d12938a/greenlet-3.2.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:752c896a8c976548faafe8a306d446c6a4c68d4fd24699b84d4393bd9ac69a8e" }, - { url = "https://mirror-pypi.runflare.com/packages/41/cc/33bd4c2f816be8c8e16f71740c4130adf3a66a3dd2ba29de72b9d8dd1096/greenlet-3.2.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499b809e7738c8af0ff9ac9d5dd821cb93f4293065a9237543217f0b252f950a" }, - { url = "https://mirror-pypi.runflare.com/packages/48/79/f3891dcfc59097474a53cc3c624f2f2465e431ab493bda043b8c873fb20a/greenlet-3.2.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2c7429f6e9cea7cbf2637d86d3db12806ba970f7f972fcab39d6b54b4457cbaf" }, - { url = "https://mirror-pypi.runflare.com/packages/ca/47/212b47e6d2d7a04c4083db1af2fdd291bc8fe99b7e3571bfa560b65fc361/greenlet-3.2.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a5e4b25e855800fba17713020c5c33e0a4b7a1829027719344f0c7c8870092a2" }, - { url = "https://mirror-pypi.runflare.com/packages/f6/9d/4e9b941be05f8da7ba804c6413761d2c11cca05994cbf0a015bd729419f0/greenlet-3.2.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7123b29e6bad2f3f89681be4ef316480fca798ebe8d22fbaced9cc3775007a4f" }, - { url = "https://mirror-pypi.runflare.com/packages/23/cb/a73625c9a35138330014ecf3740c0d62e0c2b5e7279bb7f2586b1b199fac/greenlet-3.2.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6e8fe0c72603201a86b2e038daf9b6c8570715f8779566419cff543b6ace88de" }, - { url = "https://mirror-pypi.runflare.com/packages/83/49/6d1531109507bce7dfb23acf57a87013627ed3ac058851176e443a6a9134/greenlet-3.2.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:050703a60603db0e817364d69e048c70af299040c13a7e67792b9e62d4571196" }, - { url = "https://mirror-pypi.runflare.com/packages/f7/38/f958ee90fab93529b30cc1e4a59b27c1112b640570043a84af84da3b3b98/greenlet-3.2.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6712bfd520530eb67331813f7112d3ee18e206f48b3d026d8a96cd2d2ad20251" }, - { url = "https://mirror-pypi.runflare.com/packages/51/c1/a603906e79716d61f08afedaf8aed62017661457aef233d62d6e57ecd511/greenlet-3.2.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc06a78fa3ffbe2a75f1ebc7e040eacf6fa1050a9432953ab111fbbbf0d03c1" }, - { url = "https://mirror-pypi.runflare.com/packages/3f/8f/f880ff4587d236b4d06893fb34da6b299aa0d00f6c8259673f80e1b6d63c/greenlet-3.2.5-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:dbe0e81e24982bb45907ca20152b31c2e3300ca352fdc4acbd4956e4a2cbc195" }, - { url = "https://mirror-pypi.runflare.com/packages/3c/50/f6c78b8420187fdfe97fcf2e6d1dd243a7742d272c32fd4d4b1095474b37/greenlet-3.2.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:15871afc0d78ec87d15d8412b337f287fc69f8f669346e391585824970931c48" }, - { url = "https://mirror-pypi.runflare.com/packages/26/d6/3277f92e1961e6e9f41d9f173ea74b5c1f7065072637669f761626f26cc0/greenlet-3.2.5-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5bf0d7d62e356ef2e87e55e46a4e930ac165f9372760fb983b5631bb479e9d3a" }, - { url = "https://mirror-pypi.runflare.com/packages/2a/6a/4f79d2e7b5ef3723fc5ffea0d6cb22627e5f95e0f19c973fa12bf1cf7891/greenlet-3.2.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6dff6433742073e5b6ad40953a78a0e8cddcb3f6869e5ea635d29a810ca5e7d0" }, - { url = "https://mirror-pypi.runflare.com/packages/4d/59/7aadf33f23c65dbf4db27e7f5b60c414797a61e954352ae4a86c5c8b0553/greenlet-3.2.5-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdd67619cefe1cc9fcab57c8853d2bb36eca9f166c0058cc0d428d471f7c785c" }, - { url = "https://mirror-pypi.runflare.com/packages/1d/46/b3422959f830de28a4eea447414e6bd7b980d755892f66ab52ad805da1c4/greenlet-3.2.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3828b309dfb1f117fe54867512a8265d8d4f00f8de6908eef9b885f4d8789062" }, - { url = "https://mirror-pypi.runflare.com/packages/54/4a/3d1c9728f093415637cf3696909fa10852632e33e68238fb8ca60eb90de1/greenlet-3.2.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:67725ae9fea62c95cf1aa230f1b8d4dc38f7cd14f6103d1df8a5a95657eb8e54" }, -] - -[[package]] -name = "greenlet" -version = "3.3.2" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] -sdist = { url = "https://mirror-pypi.runflare.com/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d" }, - { url = "https://mirror-pypi.runflare.com/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13" }, - { url = "https://mirror-pypi.runflare.com/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e" }, - { url = "https://mirror-pypi.runflare.com/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f" }, - { url = "https://mirror-pypi.runflare.com/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef" }, - { url = "https://mirror-pypi.runflare.com/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca" }, - { url = "https://mirror-pypi.runflare.com/packages/ac/78/f93e840cbaef8becaf6adafbaf1319682a6c2d8c1c20224267a5c6c8c891/greenlet-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:5d0e35379f93a6d0222de929a25ab47b5eb35b5ef4721c2b9cbcc4036129ff1f" }, - { url = "https://mirror-pypi.runflare.com/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86" }, - { url = "https://mirror-pypi.runflare.com/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f" }, - { url = "https://mirror-pypi.runflare.com/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55" }, - { url = "https://mirror-pypi.runflare.com/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358" }, - { url = "https://mirror-pypi.runflare.com/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99" }, - { url = "https://mirror-pypi.runflare.com/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be" }, - { url = "https://mirror-pypi.runflare.com/packages/f1/3a/efb2cf697fbccdf75b24e2c18025e7dfa54c4f31fab75c51d0fe79942cef/greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5" }, - { url = "https://mirror-pypi.runflare.com/packages/e1/a1/65bbc059a43a7e2143ec4fc1f9e3f673e04f9c7b371a494a101422ac4fd5/greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd" }, - { url = "https://mirror-pypi.runflare.com/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd" }, - { url = "https://mirror-pypi.runflare.com/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd" }, - { url = "https://mirror-pypi.runflare.com/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac" }, - { url = "https://mirror-pypi.runflare.com/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070" }, - { url = "https://mirror-pypi.runflare.com/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79" }, - { url = "https://mirror-pypi.runflare.com/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395" }, - { url = "https://mirror-pypi.runflare.com/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f" }, - { url = "https://mirror-pypi.runflare.com/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643" }, - { url = "https://mirror-pypi.runflare.com/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4" }, - { url = "https://mirror-pypi.runflare.com/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986" }, - { url = "https://mirror-pypi.runflare.com/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92" }, - { url = "https://mirror-pypi.runflare.com/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab" }, - { url = "https://mirror-pypi.runflare.com/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a" }, - { url = "https://mirror-pypi.runflare.com/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b" }, - { url = "https://mirror-pypi.runflare.com/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124" }, - { url = "https://mirror-pypi.runflare.com/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327" }, - { url = "https://mirror-pypi.runflare.com/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab" }, - { url = "https://mirror-pypi.runflare.com/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082" }, - { url = "https://mirror-pypi.runflare.com/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9" }, - { url = "https://mirror-pypi.runflare.com/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506" }, - { url = "https://mirror-pypi.runflare.com/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce" }, - { url = "https://mirror-pypi.runflare.com/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5" }, - { url = "https://mirror-pypi.runflare.com/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492" }, - { url = "https://mirror-pypi.runflare.com/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71" }, - { url = "https://mirror-pypi.runflare.com/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54" }, - { url = "https://mirror-pypi.runflare.com/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4" }, - { url = "https://mirror-pypi.runflare.com/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff" }, - { url = "https://mirror-pypi.runflare.com/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4" }, - { url = "https://mirror-pypi.runflare.com/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727" }, - { url = "https://mirror-pypi.runflare.com/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e" }, - { url = "https://mirror-pypi.runflare.com/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a" }, -] - -[[package]] -name = "iniconfig" -version = "2.1.0" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", - "python_full_version < '3.9'", -] -sdist = { url = "https://mirror-pypi.runflare.com/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] -sdist = { url = "https://mirror-pypi.runflare.com/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, -] - -[[package]] -name = "librt" -version = "0.8.1" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -sdist = { url = "https://mirror-pypi.runflare.com/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc" }, - { url = "https://mirror-pypi.runflare.com/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7" }, - { url = "https://mirror-pypi.runflare.com/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6" }, - { url = "https://mirror-pypi.runflare.com/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0" }, - { url = "https://mirror-pypi.runflare.com/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b" }, - { url = "https://mirror-pypi.runflare.com/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6" }, - { url = "https://mirror-pypi.runflare.com/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71" }, - { url = "https://mirror-pypi.runflare.com/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7" }, - { url = "https://mirror-pypi.runflare.com/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05" }, - { url = "https://mirror-pypi.runflare.com/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891" }, - { url = "https://mirror-pypi.runflare.com/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7" }, - { url = "https://mirror-pypi.runflare.com/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2" }, - { url = "https://mirror-pypi.runflare.com/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd" }, - { url = "https://mirror-pypi.runflare.com/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965" }, - { url = "https://mirror-pypi.runflare.com/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da" }, - { url = "https://mirror-pypi.runflare.com/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0" }, - { url = "https://mirror-pypi.runflare.com/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e" }, - { url = "https://mirror-pypi.runflare.com/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3" }, - { url = "https://mirror-pypi.runflare.com/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac" }, - { url = "https://mirror-pypi.runflare.com/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596" }, - { url = "https://mirror-pypi.runflare.com/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99" }, - { url = "https://mirror-pypi.runflare.com/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe" }, - { url = "https://mirror-pypi.runflare.com/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb" }, - { url = "https://mirror-pypi.runflare.com/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b" }, - { url = "https://mirror-pypi.runflare.com/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9" }, - { url = "https://mirror-pypi.runflare.com/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a" }, - { url = "https://mirror-pypi.runflare.com/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9" }, - { url = "https://mirror-pypi.runflare.com/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb" }, - { url = "https://mirror-pypi.runflare.com/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d" }, - { url = "https://mirror-pypi.runflare.com/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7" }, - { url = "https://mirror-pypi.runflare.com/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440" }, - { url = "https://mirror-pypi.runflare.com/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9" }, - { url = "https://mirror-pypi.runflare.com/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972" }, - { url = "https://mirror-pypi.runflare.com/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921" }, - { url = "https://mirror-pypi.runflare.com/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0" }, - { url = "https://mirror-pypi.runflare.com/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a" }, - { url = "https://mirror-pypi.runflare.com/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444" }, - { url = "https://mirror-pypi.runflare.com/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d" }, - { url = "https://mirror-pypi.runflare.com/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35" }, - { url = "https://mirror-pypi.runflare.com/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583" }, - { url = "https://mirror-pypi.runflare.com/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c" }, - { url = "https://mirror-pypi.runflare.com/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04" }, - { url = "https://mirror-pypi.runflare.com/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363" }, - { url = "https://mirror-pypi.runflare.com/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0" }, - { url = "https://mirror-pypi.runflare.com/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012" }, - { url = "https://mirror-pypi.runflare.com/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb" }, - { url = "https://mirror-pypi.runflare.com/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b" }, - { url = "https://mirror-pypi.runflare.com/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d" }, - { url = "https://mirror-pypi.runflare.com/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a" }, - { url = "https://mirror-pypi.runflare.com/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79" }, - { url = "https://mirror-pypi.runflare.com/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0" }, - { url = "https://mirror-pypi.runflare.com/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f" }, - { url = "https://mirror-pypi.runflare.com/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c" }, - { url = "https://mirror-pypi.runflare.com/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc" }, - { url = "https://mirror-pypi.runflare.com/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c" }, - { url = "https://mirror-pypi.runflare.com/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3" }, - { url = "https://mirror-pypi.runflare.com/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14" }, - { url = "https://mirror-pypi.runflare.com/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7" }, - { url = "https://mirror-pypi.runflare.com/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6" }, - { url = "https://mirror-pypi.runflare.com/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071" }, - { url = "https://mirror-pypi.runflare.com/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78" }, - { url = "https://mirror-pypi.runflare.com/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023" }, - { url = "https://mirror-pypi.runflare.com/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730" }, - { url = "https://mirror-pypi.runflare.com/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3" }, - { url = "https://mirror-pypi.runflare.com/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1" }, - { url = "https://mirror-pypi.runflare.com/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee" }, - { url = "https://mirror-pypi.runflare.com/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7" }, - { url = "https://mirror-pypi.runflare.com/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040" }, - { url = "https://mirror-pypi.runflare.com/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e" }, - { url = "https://mirror-pypi.runflare.com/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732" }, - { url = "https://mirror-pypi.runflare.com/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624" }, - { url = "https://mirror-pypi.runflare.com/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4" }, - { url = "https://mirror-pypi.runflare.com/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382" }, - { url = "https://mirror-pypi.runflare.com/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994" }, - { url = "https://mirror-pypi.runflare.com/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a" }, - { url = "https://mirror-pypi.runflare.com/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4" }, - { url = "https://mirror-pypi.runflare.com/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61" }, - { url = "https://mirror-pypi.runflare.com/packages/01/1f/c7d8b66a3ca3ca3ed8ded4b32c96ee58a45920ebbbaa934355c74adcc33e/librt-0.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3dff3d3ca8db20e783b1bc7de49c0a2ab0b8387f31236d6a026597d07fcd68ac" }, - { url = "https://mirror-pypi.runflare.com/packages/56/be/ee9ba1730052313d08457f19beaa1b878619978863fba09b40aed5b5c123/librt-0.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08eec3a1fc435f0d09c87b6bf1ec798986a3544f446b864e4099633a56fcd9ed" }, - { url = "https://mirror-pypi.runflare.com/packages/81/27/b7309298b96f7690cec3ceee38004c1a7f60fcd96d952d3ac344a1e3e8b3/librt-0.8.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e3f0a41487fd5fad7e760b9e8a90e251e27c2816fbc2cff36a22a0e6bcbbd9dd" }, - { url = "https://mirror-pypi.runflare.com/packages/10/48/160a5aacdcb21824b10a52378c39e88c46a29bb31efdaf3910dd1f9b670e/librt-0.8.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bacdb58d9939d95cc557b4dbaa86527c9db2ac1ed76a18bc8d26f6dc8647d851" }, - { url = "https://mirror-pypi.runflare.com/packages/ee/65/33dd1d8caabb7c6805d87d095b143417dc96b0277c06ffa0508361422c82/librt-0.8.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d7ab1f01aa753188605b09a51faa44a3327400b00b8cce424c71910fc0a128" }, - { url = "https://mirror-pypi.runflare.com/packages/09/d4/353805aa6181c7950a2462bd6e855366eeca21a501f375228d72a51547df/librt-0.8.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4998009e7cb9e896569f4be7004f09d0ed70d386fa99d42b6d363f6d200501ac" }, - { url = "https://mirror-pypi.runflare.com/packages/06/08/725b3f304d61eba56c713c251fb833a06d84bf93381caad5152366f5d2bb/librt-0.8.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2cc68eeeef5e906839c7bb0815748b5b0a974ec27125beefc0f942715785b551" }, - { url = "https://mirror-pypi.runflare.com/packages/0e/55/e8cdf04145872b3b97cb9b68287b22d1c08348227063f305aec11a3e6ce7/librt-0.8.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0bf69d79a23f4f40b8673a947a234baeeb133b5078b483b7297c5916539cf5d5" }, - { url = "https://mirror-pypi.runflare.com/packages/8f/d8/23b1c6592d2422dd6829c672f45b1f1c257f219926b0d216fedb572d0184/librt-0.8.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:22b46eabd76c1986ee7d231b0765ad387d7673bbd996aa0d0d054b38ac65d8f6" }, - { url = "https://mirror-pypi.runflare.com/packages/c9/92/2b44fd3cc3313f44e43bdbb41343735b568fa675fa351642b408ee48d418/librt-0.8.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:237796479f4d0637d6b9cbcb926ff424a97735e68ade6facf402df4ec93375ed" }, - { url = "https://mirror-pypi.runflare.com/packages/00/23/92313ecdab80e142d8ea10e8dfa6297694359dbaacc9e81679bdc8cbceb6/librt-0.8.1-cp39-cp39-win32.whl", hash = "sha256:4beb04b8c66c6ae62f8c1e0b2f097c1ebad9295c929a8d5286c05eae7c2fc7dc" }, - { url = "https://mirror-pypi.runflare.com/packages/68/36/18f6e768afad6b55a690d38427c53251b69b7ba8795512730fd2508b31a9/librt-0.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:64548cde61b692dc0dc379f4b5f59a2f582c2ebe7890d09c1ae3b9e66fa015b7" }, -] +requires-python = ">=3.10" [[package]] name = "maturin" -version = "1.12.2" -source = { registry = "https://mirror-pypi.runflare.com/simple" } +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://mirror-pypi.runflare.com/packages/ae/13/aeff8a21835ed0e40c329c286750fcdcdcbf231f1a5cb327378666c5def6/maturin-1.12.2.tar.gz", hash = "sha256:d6253079f53dbb692395a13abddc0f2d3d96af32f8c0b32e2912849713c55794" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/8a/9d/4811e1fcaa346a0b9fad6aee0ac0eec9eb376a24fe27c66d5d4fe975586e/maturin-1.12.2-py3-none-linux_armv6l.whl", hash = "sha256:0ed31b6a392928ad23645a470edc4f3814b952a416e41f8e5daac42d7bfbabc6" }, - { url = "https://mirror-pypi.runflare.com/packages/69/db/74d582af74c32bbda12e4d7e153b389884409a1c5cd31edc9d3194d515f7/maturin-1.12.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:f1c2e4ee43bf286b052091a3b2356a157978985837c7aed42354deb2947a4006" }, - { url = "https://mirror-pypi.runflare.com/packages/9d/6f/71be226c6780387f032c0b4ab791c390c7162ed62f93a11e600f9266dafd/maturin-1.12.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:04c9c4f9c9f904f007cbfcd4640c406e53f19d04c220f5940d1537edb914d325" }, - { url = "https://mirror-pypi.runflare.com/packages/6a/cc/989dce6140227277b4184aab248d07fe67fa11f95411ccf90e272542287d/maturin-1.12.2-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:4bdc486b9ab80d8b50143ecc9a1924b890866fe95be150dd9a59fa22a6b37238" }, - { url = "https://mirror-pypi.runflare.com/packages/a1/e8/02bb64f7150013d8af3ca622944e22f550beb312b6d5cf8760dc2896cce8/maturin-1.12.2-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:134e895578258a693ba1d55b166c2ba96e9f51067e106b8a74d422432653d45b" }, - { url = "https://mirror-pypi.runflare.com/packages/84/81/b603a74bef68fabd402d1e54f43560213ea69c3c01467610d0256eea013b/maturin-1.12.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:39665d622dcc950ab17b9569e8cab84a4d64eea6a18b540a8b49e00c0f7dda02" }, - { url = "https://mirror-pypi.runflare.com/packages/70/a5/387c7bced34f7fd8d08d399c6b1ac3d94d7ca50c9f87db9e1bc0dd8c8d08/maturin-1.12.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:ca3b20bcc3aff115c9eaf97340e78bff58829ea1efa16764940dd0d858dcf6af" }, - { url = "https://mirror-pypi.runflare.com/packages/6d/30/d5ae812c54a70d5d3a5b67b073e92d1d14d36675242e2d00e6a175fa6117/maturin-1.12.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:d1617989b4a5dc543fea6d23c28b2f07fadb2c726ff00fe959538ee71a301384" }, - { url = "https://mirror-pypi.runflare.com/packages/84/f4/7baac2fa5324ccdc3f888ff5f6a793f3eb5a7805d89bc17a8bacbe9fc566/maturin-1.12.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6af778e7ee048612e55a1255488db7678741bea2ba881e66a19712f59f2534cb" }, - { url = "https://mirror-pypi.runflare.com/packages/6f/ed/5680efbb1becb4f47da3ada8ea4eb6844d2fd91ae558287e1dd0871cb603/maturin-1.12.2-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:72aad9efe09a6392de9930f2bea80bfcc36fd98e18caa621f512571179c02d41" }, - { url = "https://mirror-pypi.runflare.com/packages/86/20/7e27e07dd2270b707dd0124256cd46bef7c8832476b0aefa2ecd74835365/maturin-1.12.2-py3-none-win32.whl", hash = "sha256:9763d277e143409cf0ce309eb1a493fc4e1e75777364d67ccac39a161b51b5b0" }, - { url = "https://mirror-pypi.runflare.com/packages/3b/6e/9cc0e19c9a336fbc1b9664c1a7955caa6d8fd510c0047ace9be66a33704a/maturin-1.12.2-py3-none-win_amd64.whl", hash = "sha256:c06d218931985035d7ab4d0211ba96027e1bc7e4b01a87c8c4e30a57790403ec" }, - { url = "https://mirror-pypi.runflare.com/packages/2e/67/07ea2c991ca1a55c6b08cd821710736276af7a3e160e1f869ea5c41c78c3/maturin-1.12.2-py3-none-win_arm64.whl", hash = "sha256:a882cc80c241b1e2c27bd1acd713b09e9ac9266a3159cc1e34e8c7b77f049bba" }, -] - -[[package]] -name = "mypy" -version = "1.14.1" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "mypy-extensions", marker = "python_full_version < '3.9'" }, - { name = "tomli", marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://mirror-pypi.runflare.com/packages/b9/eb/2c92d8ea1e684440f54fa49ac5d9a5f19967b7b472a281f419e69a8d228e/mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/9b/7a/87ae2adb31d68402da6da1e5f30c07ea6063e9f09b5e7cfc9dfa44075e74/mypy-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb" }, - { url = "https://mirror-pypi.runflare.com/packages/e1/23/eada4c38608b444618a132be0d199b280049ded278b24cbb9d3fc59658e4/mypy-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0" }, - { url = "https://mirror-pypi.runflare.com/packages/43/c9/d6785c6f66241c62fd2992b05057f404237deaad1566545e9f144ced07f5/mypy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d" }, - { url = "https://mirror-pypi.runflare.com/packages/c3/62/daa7e787770c83c52ce2aaf1a111eae5893de9e004743f51bfcad9e487ec/mypy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b" }, - { url = "https://mirror-pypi.runflare.com/packages/1b/a2/5fb18318a3637f29f16f4e41340b795da14f4751ef4f51c99ff39ab62e52/mypy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427" }, - { url = "https://mirror-pypi.runflare.com/packages/28/99/e153ce39105d164b5f02c06c35c7ba958aaff50a2babba7d080988b03fe7/mypy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f" }, - { url = "https://mirror-pypi.runflare.com/packages/da/11/a9422850fd506edbcdc7f6090682ecceaf1f87b9dd847f9df79942da8506/mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c" }, - { url = "https://mirror-pypi.runflare.com/packages/b6/9e/47e450fd39078d9c02d620545b2cb37993a8a8bdf7db3652ace2f80521ca/mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1" }, - { url = "https://mirror-pypi.runflare.com/packages/01/b5/6c8d33bd0f851a7692a8bfe4ee75eb82b6983a3cf39e5e32a5d2a723f0c1/mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8" }, - { url = "https://mirror-pypi.runflare.com/packages/f0/4c/e10e2c46ea37cab5c471d0ddaaa9a434dc1d28650078ac1b56c2d7b9b2e4/mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f" }, - { url = "https://mirror-pypi.runflare.com/packages/88/55/beacb0c69beab2153a0f57671ec07861d27d735a0faff135a494cd4f5020/mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1" }, - { url = "https://mirror-pypi.runflare.com/packages/a2/75/8c93ff7f315c4d086a2dfcde02f713004357d70a163eddb6c56a6a5eff40/mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae" }, - { url = "https://mirror-pypi.runflare.com/packages/43/1b/b38c079609bb4627905b74fc6a49849835acf68547ac33d8ceb707de5f52/mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14" }, - { url = "https://mirror-pypi.runflare.com/packages/6b/75/2ed0d2964c1ffc9971c729f7a544e9cd34b2cdabbe2d11afd148d7838aa2/mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9" }, - { url = "https://mirror-pypi.runflare.com/packages/a1/5f/7b8051552d4da3c51bbe8fcafffd76a6823779101a2b198d80886cd8f08e/mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11" }, - { url = "https://mirror-pypi.runflare.com/packages/04/90/f53971d3ac39d8b68bbaab9a4c6c58c8caa4d5fd3d587d16f5927eeeabe1/mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e" }, - { url = "https://mirror-pypi.runflare.com/packages/03/d2/8bc0aeaaf2e88c977db41583559319f1821c069e943ada2701e86d0430b7/mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89" }, - { url = "https://mirror-pypi.runflare.com/packages/6f/17/07815114b903b49b0f2cf7499f1c130e5aa459411596668267535fe9243c/mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b" }, - { url = "https://mirror-pypi.runflare.com/packages/9e/15/bb6a686901f59222275ab228453de741185f9d54fecbaacec041679496c6/mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255" }, - { url = "https://mirror-pypi.runflare.com/packages/f8/b3/8b0f74dfd072c802b7fa368829defdf3ee1566ba74c32a2cb2403f68024c/mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34" }, - { url = "https://mirror-pypi.runflare.com/packages/c5/9b/4fd95ab20c52bb5b8c03cc49169be5905d931de17edfe4d9d2986800b52e/mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a" }, - { url = "https://mirror-pypi.runflare.com/packages/56/9d/4a236b9c57f5d8f08ed346914b3f091a62dd7e19336b2b2a0d85485f82ff/mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9" }, - { url = "https://mirror-pypi.runflare.com/packages/40/88/a61a5497e2f68d9027de2bb139c7bb9abaeb1be1584649fa9d807f80a338/mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd" }, - { url = "https://mirror-pypi.runflare.com/packages/54/da/3d6fc5d92d324701b0c23fb413c853892bfe0e1dbe06c9138037d459756b/mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107" }, - { url = "https://mirror-pypi.runflare.com/packages/39/02/1817328c1372be57c16148ce7d2bfcfa4a796bedaed897381b1aad9b267c/mypy-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7084fb8f1128c76cd9cf68fe5971b37072598e7c31b2f9f95586b65c741a9d31" }, - { url = "https://mirror-pypi.runflare.com/packages/b9/07/99db9a95ece5e58eee1dd87ca456a7e7b5ced6798fd78182c59c35a7587b/mypy-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f845a00b4f420f693f870eaee5f3e2692fa84cc8514496114649cfa8fd5e2c6" }, - { url = "https://mirror-pypi.runflare.com/packages/9a/eb/85ea6086227b84bce79b3baf7f465b4732e0785830726ce4a51528173b71/mypy-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44bf464499f0e3a2d14d58b54674dee25c031703b2ffc35064bd0df2e0fac319" }, - { url = "https://mirror-pypi.runflare.com/packages/4b/bb/f01bebf76811475d66359c259eabe40766d2f8ac8b8250d4e224bb6df379/mypy-1.14.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c99f27732c0b7dc847adb21c9d47ce57eb48fa33a17bc6d7d5c5e9f9e7ae5bac" }, - { url = "https://mirror-pypi.runflare.com/packages/2f/c9/84837ff891edcb6dcc3c27d85ea52aab0c4a34740ff5f0ccc0eb87c56139/mypy-1.14.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:bce23c7377b43602baa0bd22ea3265c49b9ff0b76eb315d6c34721af4cdf1d9b" }, - { url = "https://mirror-pypi.runflare.com/packages/84/5f/901e18464e6a13f8949b4909535be3fa7f823291b8ab4e4b36cfe57d6769/mypy-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:8edc07eeade7ebc771ff9cf6b211b9a7d93687ff892150cb5692e4f4272b0837" }, - { url = "https://mirror-pypi.runflare.com/packages/ca/1f/186d133ae2514633f8558e78cd658070ba686c0e9275c5a5c24a1e1f0d67/mypy-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35" }, - { url = "https://mirror-pypi.runflare.com/packages/af/fc/4842485d034e38a4646cccd1369f6b1ccd7bc86989c52770d75d719a9941/mypy-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc" }, - { url = "https://mirror-pypi.runflare.com/packages/b4/e6/457b83f2d701e23869cfec013a48a12638f75b9d37612a9ddf99072c1051/mypy-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9" }, - { url = "https://mirror-pypi.runflare.com/packages/f1/bf/76a569158db678fee59f4fd30b8e7a0d75bcbaeef49edd882a0d63af6d66/mypy-1.14.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb" }, - { url = "https://mirror-pypi.runflare.com/packages/43/bc/0bc6b694b3103de9fed61867f1c8bd33336b913d16831431e7cb48ef1c92/mypy-1.14.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60" }, - { url = "https://mirror-pypi.runflare.com/packages/b0/79/5f5ec47849b6df1e6943d5fd8e6632fbfc04b4fd4acfa5a5a9535d11b4e2/mypy-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c" }, - { url = "https://mirror-pypi.runflare.com/packages/a0/b5/32dd67b69a16d088e533962e5044e51004176a9952419de0370cdaead0f8/mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1" }, -] - -[[package]] -name = "mypy" -version = "1.19.1" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -dependencies = [ - { name = "librt", marker = "python_full_version >= '3.9' and platform_python_implementation != 'PyPy'" }, - { name = "mypy-extensions", marker = "python_full_version >= '3.9'" }, - { name = "pathspec", marker = "python_full_version >= '3.9'" }, - { name = "tomli", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "python_full_version >= '3.9'" }, -] -sdist = { url = "https://mirror-pypi.runflare.com/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec" }, - { url = "https://mirror-pypi.runflare.com/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b" }, - { url = "https://mirror-pypi.runflare.com/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6" }, - { url = "https://mirror-pypi.runflare.com/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74" }, - { url = "https://mirror-pypi.runflare.com/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1" }, - { url = "https://mirror-pypi.runflare.com/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac" }, - { url = "https://mirror-pypi.runflare.com/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288" }, - { url = "https://mirror-pypi.runflare.com/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab" }, - { url = "https://mirror-pypi.runflare.com/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6" }, - { url = "https://mirror-pypi.runflare.com/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331" }, - { url = "https://mirror-pypi.runflare.com/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925" }, - { url = "https://mirror-pypi.runflare.com/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042" }, - { url = "https://mirror-pypi.runflare.com/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1" }, - { url = "https://mirror-pypi.runflare.com/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e" }, - { url = "https://mirror-pypi.runflare.com/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2" }, - { url = "https://mirror-pypi.runflare.com/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8" }, - { url = "https://mirror-pypi.runflare.com/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a" }, - { url = "https://mirror-pypi.runflare.com/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13" }, - { url = "https://mirror-pypi.runflare.com/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250" }, - { url = "https://mirror-pypi.runflare.com/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b" }, - { url = "https://mirror-pypi.runflare.com/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e" }, - { url = "https://mirror-pypi.runflare.com/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef" }, - { url = "https://mirror-pypi.runflare.com/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75" }, - { url = "https://mirror-pypi.runflare.com/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd" }, - { url = "https://mirror-pypi.runflare.com/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1" }, - { url = "https://mirror-pypi.runflare.com/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718" }, - { url = "https://mirror-pypi.runflare.com/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b" }, - { url = "https://mirror-pypi.runflare.com/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045" }, - { url = "https://mirror-pypi.runflare.com/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957" }, - { url = "https://mirror-pypi.runflare.com/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f" }, - { url = "https://mirror-pypi.runflare.com/packages/b5/f7/88436084550ca9af5e610fa45286be04c3b63374df3e021c762fe8c4369f/mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3" }, - { url = "https://mirror-pypi.runflare.com/packages/ca/a5/43dfad311a734b48a752790571fd9e12d61893849a01bff346a54011957f/mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a" }, - { url = "https://mirror-pypi.runflare.com/packages/88/f0/efbfa391395cce2f2771f937e0620cfd185ec88f2b9cd88711028a768e96/mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67" }, - { url = "https://mirror-pypi.runflare.com/packages/25/05/58b3ba28f5aed10479e899a12d2120d582ba9fa6288851b20bf1c32cbb4f/mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e" }, - { url = "https://mirror-pypi.runflare.com/packages/c5/a0/c006ccaff50b31e542ae69b92fe7e2f55d99fba3a55e01067dd564325f85/mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376" }, - { url = "https://mirror-pypi.runflare.com/packages/b2/ff/8bdb051cd710f01b880472241bd36b3f817a8e1c5d5540d0b761675b6de2/mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24" }, - { url = "https://mirror-pypi.runflare.com/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -sdist = { url = "https://mirror-pypi.runflare.com/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505" }, -] - -[[package]] -name = "packaging" -version = "26.0" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -sdist = { url = "https://mirror-pypi.runflare.com/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529" }, -] - -[[package]] -name = "pathspec" -version = "1.0.4" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -sdist = { url = "https://mirror-pypi.runflare.com/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723" }, -] - -[[package]] -name = "pluggy" -version = "1.5.0" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://mirror-pypi.runflare.com/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -sdist = { url = "https://mirror-pypi.runflare.com/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -sdist = { url = "https://mirror-pypi.runflare.com/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" }, -] - -[[package]] -name = "pypika" -version = "0.51.1" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -dependencies = [ - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, -] -sdist = { url = "https://mirror-pypi.runflare.com/packages/f8/78/cbaebba88e05e2dcda13ca203131b38d3640219f20ebb49676d26714861b/pypika-0.51.1.tar.gz", hash = "sha256:c30c7c1048fbf056fd3920c5a2b88b0c29dd190a9b2bee971fd17e4abe4d0ebe" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/57/83/c77dfeed04022e8930b08eedca2b6e5efed256ab3321396fde90066efb65/pypika-0.51.1-py2.py3-none-any.whl", hash = "sha256:77985b4d7ce71b9905255bf12468cf598349e98837c037541cfc240e528aec46" }, -] - -[[package]] -name = "pytest" -version = "8.3.5" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version < '3.9' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.9'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "python_full_version < '3.9'" }, - { name = "packaging", marker = "python_full_version < '3.9'" }, - { name = "pluggy", version = "1.5.0", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "python_full_version < '3.9'" }, - { name = "tomli", marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://mirror-pypi.runflare.com/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/c8/22e5e21b2679c9bce6415ca578034ca2cc9316be0642ae21e051a2d5198c/maturin-1.15.0.tar.gz", hash = "sha256:94b26cc8e8aba61a5f2099715fe640e18c5f678e9a500408b38761263954228a", size = 385504, upload-time = "2026-08-24T12:11:22.665Z" } wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820" }, -] - -[[package]] -name = "pytest" -version = "8.4.2" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version == '3.9.*' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.9.*'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "packaging", marker = "python_full_version == '3.9.*'" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pygments", marker = "python_full_version == '3.9.*'" }, - { name = "tomli", marker = "python_full_version == '3.9.*'" }, -] -sdist = { url = "https://mirror-pypi.runflare.com/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79" }, -] - -[[package]] -name = "pytest" -version = "9.0.2" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pygments", marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, -] -sdist = { url = "https://mirror-pypi.runflare.com/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b" }, + { url = "https://files.pythonhosted.org/packages/14/69/5c01b461044eb1f45ddcce006706eb88110c793cdb11c7ae0b5e08492e94/maturin-1.15.0-py3-none-linux_armv6l.whl", hash = "sha256:6bf6dc62e22d4dcfd5a51244ff0d58975fa4979c48209fe84159617648956d82", size = 10206220, upload-time = "2026-08-24T12:10:53.327Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1f/2b431554e11687cdb1077e0cdadcc118c53f611086b3af00c8545a67c6a5/maturin-1.15.0-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:cd35772633f489841132bc8e71d6fc7f842df30b9c05cd5cdf1ee1ddcb744cc7", size = 19416513, upload-time = "2026-08-24T12:10:56.126Z" }, + { url = "https://files.pythonhosted.org/packages/51/36/e23a21cb34a648b711036b9b2fe1d4f3f4ee24f8db54215d73f1a9a3a3ec/maturin-1.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c40b4eae7bf5ef1f4b1af8d623fe4105016f93578fb15b764e741d08ec3b92dd", size = 10014962, upload-time = "2026-08-24T12:10:58.486Z" }, + { url = "https://files.pythonhosted.org/packages/8c/80/33b15cb2d8f30f12c807955e8f2fd775027692904e30ec0784744ce8cd83/maturin-1.15.0-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:7eb066372f541f8eb4909c79c5d9bd0b9e8125980bdf1ec9e8aba23c6c8d6c55", size = 10196223, upload-time = "2026-08-24T12:11:00.696Z" }, + { url = "https://files.pythonhosted.org/packages/fe/91/b495e19e2f5c503b540452b2039115e7b2363867e8c5ad4179eb752fa92c/maturin-1.15.0-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:653020a63525bb224e5ab0adf02e17a2e08bc86dbea7fc1399c9a56d7529b99e", size = 10541186, upload-time = "2026-08-24T12:11:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2b/2abff58037188d852b124871b1f0d720e1c2bfb3d4f1b03d87c52cd66488/maturin-1.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:0ebf9767892725083138e671c34482c660317a2f3d6a29fc0e0f34e9d8c99136", size = 10083468, upload-time = "2026-08-24T12:11:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/b7e9f8be99a6627849e81ac7b6694876bce8f50a92995fe17e3cf2610f0a/maturin-1.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:7ab7eebffd7b8debca2265985de4eaeb332141276d24b9560b5ad484d4b3add1", size = 10047786, upload-time = "2026-08-24T12:11:07.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ab/167e3cb7accee11b507dbe53e0e87aeccb376d44ae66284c96ee4df3a9fd/maturin-1.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:126e12e618b4db42f68c779a56d41f82a390145ba36ac3f621d057eb34f5ad9d", size = 13315332, upload-time = "2026-08-24T12:11:09.433Z" }, + { url = "https://files.pythonhosted.org/packages/14/4d/801379f646cbc6b00998e5289b0630a886be3a4ee4c75b6bc9b87478a7f1/maturin-1.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4f9d33e6c3f9615c8caceecbbbd440f8eb25a3ddeb687077682cd5eca2e9ae15", size = 10807183, upload-time = "2026-08-24T12:11:11.73Z" }, + { url = "https://files.pythonhosted.org/packages/89/27/2e612e1cbd1580e9e94d4722c227b5180dca27b32b955a34b79918aa1292/maturin-1.15.0-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:bf29beddd0c6708f112db51d5275fc28b28b9e9c9c5faae387eaef662918b176", size = 10413274, upload-time = "2026-08-24T12:11:14.04Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/202a7b4d75a51f20f84ec9ce3b7345b12b822207072164e2b1c6ef665125/maturin-1.15.0-py3-none-win32.whl", hash = "sha256:da649988be98e87e009e51b1bf0d301b6a301bc0cecbdd60d40d8ba60748d1ca", size = 8928744, upload-time = "2026-08-24T12:11:16.269Z" }, + { url = "https://files.pythonhosted.org/packages/40/dc/4e90da594986ba78dd3bc8a5921ecdcdb11085b22b02a412caab3b225601/maturin-1.15.0-py3-none-win_amd64.whl", hash = "sha256:552c2be4afd43fe8d5c9f3ec8d4c4756d973b8dcbe94c14084390301f50243e1", size = 10335085, upload-time = "2026-08-24T12:11:18.326Z" }, + { url = "https://files.pythonhosted.org/packages/8b/10/15d4314edf130955edf2dc237aa393a8a7c10f2b9b57b89fa2f61f915659/maturin-1.15.0-py3-none-win_arm64.whl", hash = "sha256:c7dc0c66c78d3debdd9c5aa807e861fbcbf07f3505d34b125df74c03986b0f48", size = 9713795, upload-time = "2026-08-24T12:11:20.83Z" }, ] [[package]] @@ -602,178 +33,63 @@ source = { editable = "." } [package.dev-dependencies] dev = [ { name = "maturin" }, - { name = "mypy", version = "1.14.1", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "python_full_version < '3.9'" }, - { name = "mypy", version = "1.19.1", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "pypika" }, - { name = "pytest", version = "8.3.5", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pytest", version = "9.0.2", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "sqlalchemy" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "maturin", specifier = ">=1.9.4" }, - { name = "mypy", specifier = ">=1.14.1" }, - { name = "pypika", specifier = ">=0.51.1" }, - { name = "pytest", specifier = ">=8.3.5" }, - { name = "sqlalchemy", specifier = ">=2.0.47" }, -] - -[[package]] -name = "sqlalchemy" -version = "2.0.47" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -dependencies = [ - { name = "greenlet", version = "3.1.1", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "(python_full_version < '3.9' and platform_machine == 'AMD64') or (python_full_version < '3.9' and platform_machine == 'WIN32') or (python_full_version < '3.9' and platform_machine == 'aarch64') or (python_full_version < '3.9' and platform_machine == 'amd64') or (python_full_version < '3.9' and platform_machine == 'ppc64le') or (python_full_version < '3.9' and platform_machine == 'win32') or (python_full_version < '3.9' and platform_machine == 'x86_64')" }, - { name = "greenlet", version = "3.2.5", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "(python_full_version == '3.9.*' and platform_machine == 'AMD64') or (python_full_version == '3.9.*' and platform_machine == 'WIN32') or (python_full_version == '3.9.*' and platform_machine == 'aarch64') or (python_full_version == '3.9.*' and platform_machine == 'amd64') or (python_full_version == '3.9.*' and platform_machine == 'ppc64le') or (python_full_version == '3.9.*' and platform_machine == 'win32') or (python_full_version == '3.9.*' and platform_machine == 'x86_64')" }, - { name = "greenlet", version = "3.3.2", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "(python_full_version >= '3.10' and platform_machine == 'AMD64') or (python_full_version >= '3.10' and platform_machine == 'WIN32') or (python_full_version >= '3.10' and platform_machine == 'aarch64') or (python_full_version >= '3.10' and platform_machine == 'amd64') or (python_full_version >= '3.10' and platform_machine == 'ppc64le') or (python_full_version >= '3.10' and platform_machine == 'win32') or (python_full_version >= '3.10' and platform_machine == 'x86_64')" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://mirror-pypi.runflare.com/simple" }, marker = "python_full_version >= '3.9'" }, -] -sdist = { url = "https://mirror-pypi.runflare.com/packages/cd/4b/1e00561093fe2cd8eef09d406da003c8a118ff02d6548498c1ae677d68d9/sqlalchemy-2.0.47.tar.gz", hash = "sha256:e3e7feb57b267fe897e492b9721ae46d5c7de6f9e8dee58aacf105dc4e154f3d" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/ec/75/17db77c57129c223c7d98518ad1e1faa24ee350c22a44b55390d8463c28c/sqlalchemy-2.0.47-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:33a917ede39406ddb93c3e642b5bc480be7c5fd0f3d0d6ae1036d466fb963f1a" }, - { url = "https://mirror-pypi.runflare.com/packages/b0/d6/3658f7e5c376de774c009f2bb9c0ddf88a35b89c5bfb15ee7174a17b1a5f/sqlalchemy-2.0.47-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:561d027c829b01e040bdade6b6f5b429249d056ef95d7bdcb9211539ecc82803" }, - { url = "https://mirror-pypi.runflare.com/packages/4e/38/f4b94f85d1c26cb9ee0e57449754de816c326f9586b9a8c5247eb49146de/sqlalchemy-2.0.47-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fa5072a37e68c565363c009b7afa5b199b488c87940ec02719860093a08f34ca" }, - { url = "https://mirror-pypi.runflare.com/packages/94/f2/36714f1de01e135a2bf142b662e416e5338ab63c47878e31051338c66e2d/sqlalchemy-2.0.47-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1e7ed17dd4312a298b6024bfd1baf51654bc49e3f03c798005babf0c7922d6a7" }, - { url = "https://mirror-pypi.runflare.com/packages/ab/94/fcd978e7625cd1c97d9f1d7363e18e37d24314e572acd7c091e3a4210106/sqlalchemy-2.0.47-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6992e353fcb0593eb42d95ad84b3e58fe40b5e37fd332b9ccba28f4b2f36d1fc" }, - { url = "https://mirror-pypi.runflare.com/packages/23/29/c633202b9900ab65f0162f59df737b57f30010f44d892b186810c9ed58b7/sqlalchemy-2.0.47-cp310-cp310-win32.whl", hash = "sha256:05a6d58ed99ebd01303c92d29a0c9cbf70f637b3ddd155f5172c5a7239940998" }, - { url = "https://mirror-pypi.runflare.com/packages/00/39/54acf13913932b8508058d47a169e6fcde9adaa4cbfa16cbf30da1f6a482/sqlalchemy-2.0.47-cp310-cp310-win_amd64.whl", hash = "sha256:4a7aa4a584cc97e268c11e700dea0b763874eaebb435e75e7d0ffee5d90f5030" }, - { url = "https://mirror-pypi.runflare.com/packages/94/13/886338d3e8ab5ddcfe84d54302c749b1793e16c4bba63d7004e3f7baa8ec/sqlalchemy-2.0.47-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a1dbf0913879c443617d6b64403cf2801c941651db8c60e96d204ed9388d6b0" }, - { url = "https://mirror-pypi.runflare.com/packages/b6/bb/a897f6a66c9986aa9f27f5cf8550637d8a5ea368fd7fb42f6dac3105b4dc/sqlalchemy-2.0.47-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:775effbb97ea3b00c4dd3aeaf3ba8acba6e3e2b4b41d17d67a27e696843dbc95" }, - { url = "https://mirror-pypi.runflare.com/packages/59/fb/69bfae022b681507565ab0d34f0c80aa1e9f954a5a7cbfb0ed054966ac8d/sqlalchemy-2.0.47-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56cc834a3ffac34270cc2a41875e0f40e97aa651f4f3ca1cfbbf421c044cb62b" }, - { url = "https://mirror-pypi.runflare.com/packages/04/f3/0eba329f7c182d53205a228c4fd24651b95489b431ea2bd830887b4c13c4/sqlalchemy-2.0.47-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49b5e0c7244262f39e767c018e4fdb5e5dbc23cd54c5ddac8eea8f0ba32ef890" }, - { url = "https://mirror-pypi.runflare.com/packages/5c/06/654edc084b3b46ac79e04200d7c46467ae80c759c4ee41c897f9272b036f/sqlalchemy-2.0.47-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cd822a3f1f6f77b5b841a30c1a07a07f7dee3385f17e638e1722de9ab683be" }, - { url = "https://mirror-pypi.runflare.com/packages/78/33/c18c8f63b61981219d3aa12321bb7ccee605034d195e868ed94f9727b27c/sqlalchemy-2.0.47-cp311-cp311-win32.whl", hash = "sha256:9847a19548cd283a65e1ce0afd54016598d55ff72682d6fd3e493af6fc044064" }, - { url = "https://mirror-pypi.runflare.com/packages/f5/c6/a59e3f9796fff844e16afbd821db9abfd6e12698db9441a231a96193a100/sqlalchemy-2.0.47-cp311-cp311-win_amd64.whl", hash = "sha256:722abf1c82aeca46a1a0803711244a48a298279eeaec9e02f7bfee9e064182e5" }, - { url = "https://mirror-pypi.runflare.com/packages/80/88/74eb470223ff88ea6572a132c0b8de8c1d8ed7b843d3b44a8a3c77f31d39/sqlalchemy-2.0.47-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4fa91b19d6b9821c04cc8f7aa2476429cc8887b9687c762815aa629f5c0edec1" }, - { url = "https://mirror-pypi.runflare.com/packages/ef/ba/1447d3d558971b036cb93b557595cb5dcdfe728f1c7ac4dec16505ef5756/sqlalchemy-2.0.47-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c5bbbd14eff577c8c79cbfe39a0771eecd20f430f3678533476f0087138f356" }, - { url = "https://mirror-pypi.runflare.com/packages/8a/07/b47472d2ffd0776826f17ccf0b4d01b224c99fbd1904aeb103dffbb4b1cc/sqlalchemy-2.0.47-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5a6c555da8d4280a3c4c78c5b7a3f990cee2b2884e5f934f87a226191682ff7" }, - { url = "https://mirror-pypi.runflare.com/packages/bb/c6/95fa32b79b57769da3e16f054cf658d90940317b5ca0ec20eac84aa19c4f/sqlalchemy-2.0.47-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ed48a1701d24dff3bb49a5bce94d6bc84cbe33d98af2aa2d3cdcce3dea1709ec" }, - { url = "https://mirror-pypi.runflare.com/packages/bb/c8/3d07e7c73928dc59a0bed40961ca4e313e797bce650b088e8d5fdd3ad939/sqlalchemy-2.0.47-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4f3178c920ad98158f0b6309382194df04b14808fa6052ae07099fdde29d5602" }, - { url = "https://mirror-pypi.runflare.com/packages/6b/d2/ed32b1611c1e19fdb028eee1adc5a9aa138c2952d09ae11f1670170f80ae/sqlalchemy-2.0.47-cp312-cp312-win32.whl", hash = "sha256:b9c11ac9934dd59ece9619fe42780a08abe2faab7b0543bb00d5eabea4f421b9" }, - { url = "https://mirror-pypi.runflare.com/packages/fd/52/9de590356a4dd8e9ef5a881dbba64b2bbc4cbc71bf02bc68e775fb9b1899/sqlalchemy-2.0.47-cp312-cp312-win_amd64.whl", hash = "sha256:db43b72cf8274a99e089755c9c1e0b947159b71adbc2c83c3de2e38d5d607acb" }, - { url = "https://mirror-pypi.runflare.com/packages/4a/e5/0af64ce7d8f60ec5328c10084e2f449e7912a9b8bdbefdcfb44454a25f49/sqlalchemy-2.0.47-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:456a135b790da5d3c6b53d0ef71ac7b7d280b7f41eb0c438986352bf03ca7143" }, - { url = "https://mirror-pypi.runflare.com/packages/63/79/746b8d15f6940e2ac469ce22d7aa5b1124b1ab820bad9b046eb3000c88a6/sqlalchemy-2.0.47-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09a2f7698e44b3135433387da5d8846cf7cc7c10e5425af7c05fee609df978b6" }, - { url = "https://mirror-pypi.runflare.com/packages/91/b1/bd793ddb34345d1ed43b13ab2d88c95d7d4eb2e28f5b5a99128b9cc2bca2/sqlalchemy-2.0.47-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bbc72e6a177c78d724f9106aaddc0d26a2ada89c6332b5935414eccf04cbd5" }, - { url = "https://mirror-pypi.runflare.com/packages/97/84/7213def33f94e5ca6f5718d259bc9f29de0363134648425aa218d4356b23/sqlalchemy-2.0.47-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:75460456b043b78b6006e41bdf5b86747ee42eafaf7fffa3b24a6e9a456a2092" }, - { url = "https://mirror-pypi.runflare.com/packages/ef/06/456810204f4dc29b5f025b1b0a03b4bd6b600ebf3c1040aebd90a257fa33/sqlalchemy-2.0.47-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d9adaa616c3bc7d80f9ded57cd84b51d6617cad6a5456621d858c9f23aaee01" }, - { url = "https://mirror-pypi.runflare.com/packages/fb/20/df3920a4b2217dbd7390a5bd277c1902e0393f42baaf49f49b3c935e7328/sqlalchemy-2.0.47-cp313-cp313-win32.whl", hash = "sha256:76e09f974382a496a5ed985db9343628b1cb1ac911f27342e4cc46a8bac10476" }, - { url = "https://mirror-pypi.runflare.com/packages/46/06/7873ddf69918efbfabd7211829f4bd8019739d0a719253112d305d3ba51d/sqlalchemy-2.0.47-cp313-cp313-win_amd64.whl", hash = "sha256:0664089b0bf6724a0bfb49a0cf4d4da24868a0a5c8e937cd7db356d5dcdf2c66" }, - { url = "https://mirror-pypi.runflare.com/packages/54/fa/61ad9731370c90ac7ea5bf8f5eaa12c48bb4beec41c0fa0360becf4ac10d/sqlalchemy-2.0.47-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed0c967c701ae13da98eb220f9ddab3044ab63504c1ba24ad6a59b26826ad003" }, - { url = "https://mirror-pypi.runflare.com/packages/33/d5/221fac96f0529391fe374875633804c866f2b21a9c6d3a6ca57d9c12cfd7/sqlalchemy-2.0.47-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3537943a61fd25b241e976426a0c6814434b93cf9b09d39e8e78f3c9eb9a487" }, - { url = "https://mirror-pypi.runflare.com/packages/ec/55/8247d53998c3673e4a8d1958eba75c6f5cc3b39082029d400bb1f2a911ae/sqlalchemy-2.0.47-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:57f7e336a64a0dba686c66392d46b9bc7af2c57d55ce6dc1697b4ef32b043ceb" }, - { url = "https://mirror-pypi.runflare.com/packages/6b/b5/c1f0eea1bac6790845f71420a7fe2f2a0566203aa57543117d4af3b77d1c/sqlalchemy-2.0.47-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dff735a621858680217cb5142b779bad40ef7322ddbb7c12062190db6879772e" }, - { url = "https://mirror-pypi.runflare.com/packages/c5/ed/2f43f92474ea0c43c204657dc47d9d002cd738b96ca2af8e6d29a9b5e42d/sqlalchemy-2.0.47-cp313-cp313t-win32.whl", hash = "sha256:3893dc096bb3cca9608ea3487372ffcea3ae9b162f40e4d3c51dd49db1d1b2dc" }, - { url = "https://mirror-pypi.runflare.com/packages/cc/a9/8b73f9f1695b6e92f7aaf1711135a1e3bbeb78bca9eded35cb79180d3c6d/sqlalchemy-2.0.47-cp313-cp313t-win_amd64.whl", hash = "sha256:b5103427466f4b3e61f04833ae01f9a914b1280a2a8bcde3a9d7ab11f3755b42" }, - { url = "https://mirror-pypi.runflare.com/packages/c1/30/98243209aae58ed80e090ea988d5182244ca7ab3ff59e6d850c3dfc7651e/sqlalchemy-2.0.47-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b03010a5a5dfe71676bc83f2473ebe082478e32d77e6f082c8fe15a31c3b42a6" }, - { url = "https://mirror-pypi.runflare.com/packages/ab/62/12ca6ea92055fe486d6558a2a4efe93e194ff597463849c01f88e5adb99d/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8e3371aa9024520883a415a09cc20c33cfd3eeccf9e0f4f4c367f940b9cbd44" }, - { url = "https://mirror-pypi.runflare.com/packages/97/88/7dfbdeaa8d42b1584e65d6cc713e9d33b6fa563e0d546d5cb87e545bb0e5/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9449f747e50d518c6e1b40cc379e48bfc796453c47b15e627ea901c201e48a6" }, - { url = "https://mirror-pypi.runflare.com/packages/d0/b7/75e1c1970616a9dd64a8a6fd788248da2ddaf81c95f4875f2a1e8aee4128/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:21410f60d5cac1d6bfe360e05bd91b179be4fa0aa6eea6be46054971d277608f" }, - { url = "https://mirror-pypi.runflare.com/packages/31/ac/eec1a13b891df9a8bc203334caf6e6aac60b02f61b018ef3b4124b8c4120/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:819841dd5bb4324c284c09e2874cf96fe6338bfb57a64548d9b81a4e39c9871f" }, - { url = "https://mirror-pypi.runflare.com/packages/c9/b0/661b0245b06421058610da39f8ceb34abcc90b49f90f256380968d761dbe/sqlalchemy-2.0.47-cp314-cp314-win32.whl", hash = "sha256:e255ee44821a7ef45649c43064cf94e74f81f61b4df70547304b97a351e9b7db" }, - { url = "https://mirror-pypi.runflare.com/packages/aa/ef/1035a90d899e61810791c052004958be622a2cf3eb3df71c3fe20778c5d0/sqlalchemy-2.0.47-cp314-cp314-win_amd64.whl", hash = "sha256:209467ff73ea1518fe1a5aaed9ba75bb9e33b2666e2553af9ccd13387bf192cb" }, - { url = "https://mirror-pypi.runflare.com/packages/76/bb/17a1dd09cbba91258218ceb582225f14b5364d2683f9f5a274f72f2d764f/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e78fd9186946afaa287f8a1fe147ead06e5d566b08c0afcb601226e9c7322a64" }, - { url = "https://mirror-pypi.runflare.com/packages/66/8f/1a03d24c40cc321ef2f2231f05420d140bb06a84f7047eaa7eaa21d230ba/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5740e2f31b5987ed9619d6912ae5b750c03637f2078850da3002934c9532f172" }, - { url = "https://mirror-pypi.runflare.com/packages/fd/53/d56a213055d6b038a5384f0db5ece7343334aca230ff3f0fa1561106f22c/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb9ac00d03de93acb210e8ec7243fefe3e012515bf5fd2f0898c8dff38bc77a4" }, - { url = "https://mirror-pypi.runflare.com/packages/ff/19/c235d81b9cfdd6130bf63143b7bade0dc4afa46c4b634d5d6b2a96bea233/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c72a0b9eb2672d70d112cb149fbaf172d466bc691014c496aaac594f1988e706" }, - { url = "https://mirror-pypi.runflare.com/packages/0e/db/cafdeca5ecdaa3bb0811ba5449501da677ce0d83be8d05c5822da72d2e86/sqlalchemy-2.0.47-cp314-cp314t-win32.whl", hash = "sha256:c200db1128d72a71dc3c31c24b42eb9fd85b2b3e5a3c9ba1e751c11ac31250ff" }, - { url = "https://mirror-pypi.runflare.com/packages/fc/5e/ff41a010e9e0f76418b02ad352060a4341bb15f0af66cedc924ab376c7c6/sqlalchemy-2.0.47-cp314-cp314t-win_amd64.whl", hash = "sha256:669837759b84e575407355dcff912835892058aea9b80bd1cb76d6a151cf37f7" }, - { url = "https://mirror-pypi.runflare.com/packages/ed/44/52c490a81d747e10c6c3427d2169b551ae9a8c5059c0d792418fead6921a/sqlalchemy-2.0.47-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fe3f8519a52ca5032015780de3fc4e6ab42c6e0bcf9d807143a3d17b3350d546" }, - { url = "https://mirror-pypi.runflare.com/packages/05/1a/431489c8991f5a06d9b55f0999df221488a2725c2cccb661d21ac7dc315b/sqlalchemy-2.0.47-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6acfc1f95ed0369e0c4100d98870c9c4bfd56818ddc825357a0a979d5973195" }, - { url = "https://mirror-pypi.runflare.com/packages/cb/b6/e13f0136b21e98d3385554712c9a0a85fb0ebab2502f784b587474bd3fd4/sqlalchemy-2.0.47-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f530b939eca775f6f77fa359a3e7039209a96958c1aa28c1b796f600e0fee7cd" }, - { url = "https://mirror-pypi.runflare.com/packages/e6/f1/77dcffeeb622c4cd9083a4ddc314d5cd9b7caca4ef88ee487e3b79f7bcd0/sqlalchemy-2.0.47-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:0a6fdf665268dfe0ba52fb2d8d62deee96b297d460e2797bdd52d2d1941dd8cd" }, - { url = "https://mirror-pypi.runflare.com/packages/6f/f3/0c7222af667c8c8307d4ed365e5eb3ead2f1375afe03c8992c500e56dd21/sqlalchemy-2.0.47-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:25b3c189dab94dedb6db9d4e06476ce955182e7f45412b096ae9033519e33ce8" }, - { url = "https://mirror-pypi.runflare.com/packages/ae/d4/b813da53629aadb97101d091bcae54b29735d82e74f85d1a8988fd788ba3/sqlalchemy-2.0.47-cp38-cp38-win32.whl", hash = "sha256:a8f991cac31b673aff1648cafb8b10022719e5a632bbadaa9c5d41511bd507a5" }, - { url = "https://mirror-pypi.runflare.com/packages/ba/1d/e992dbafa8a2189021683416d84cc325e81a2b29abc5c11586002f3c58eb/sqlalchemy-2.0.47-cp38-cp38-win_amd64.whl", hash = "sha256:52be08b31f70bed2ed05c5c4b8237cf361a8581f32a5e89f9dfc295f795db10f" }, - { url = "https://mirror-pypi.runflare.com/packages/e3/3f/2a9d99e811ada3d82bb13beaed2a0b1294de931ebb5590de18b8bce59a5b/sqlalchemy-2.0.47-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d7477190e90852c00bf654b70ae21e5b85b5ac4d09094cf82e84eb3abdb6c5a7" }, - { url = "https://mirror-pypi.runflare.com/packages/39/8f/dd79902a3e0e0a8b8d75727cdad2f2ac0d299ced73cd815f3b716d853079/sqlalchemy-2.0.47-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb0b3e4946bf05d68673a1857db1a16bd58207c83ebc4ed5732a6e60029bac2d" }, - { url = "https://mirror-pypi.runflare.com/packages/52/e1/f3524df09fecfa4b122b3d1318f1d242e61ed9b04715498dabe76be58980/sqlalchemy-2.0.47-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bc111a5b98881b7e1ab108921f2fcc09fa06abbd98f4f0ed6cb2c23e70cdd23" }, - { url = "https://mirror-pypi.runflare.com/packages/54/cf/e3a13f04a775e87a967386d2b00caafd36fe7b6ac8f477cfd29c2270ad0f/sqlalchemy-2.0.47-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:97bf49caf4e405c18f3b9f695751c5bf14a9d8899c6e54eaeb49dda0d4fa009d" }, - { url = "https://mirror-pypi.runflare.com/packages/b4/4d/83d84cc4b4ad459d88e0d6b20614d84a26704ca42e41cba07d97fd8ffca8/sqlalchemy-2.0.47-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d2fa029051d3db77ad79a55c3ddf005bd7c038a111af0db9b56158857702aef6" }, - { url = "https://mirror-pypi.runflare.com/packages/b3/b7/19a24d27718fa06225828871be5e4308904fd9965fe38015288ce1016f0a/sqlalchemy-2.0.47-cp39-cp39-win32.whl", hash = "sha256:6e547682d508d141de942484b9976cbee91b7a50739d4ee25b3d0a62dd71a954" }, - { url = "https://mirror-pypi.runflare.com/packages/7d/6e/b6317aa4af77f5506a70dda83e39a2ac4043e4cf735737778024f3c56805/sqlalchemy-2.0.47-cp39-cp39-win_amd64.whl", hash = "sha256:bb833131169444c87160aa95fcdd22ae86d0fa4ef174d36b3dfb9be363b4e574" }, - { url = "https://mirror-pypi.runflare.com/packages/15/9f/7c378406b592fcf1fc157248607b495a40e3202ba4a6f1372a2ba6447717/sqlalchemy-2.0.47-py3-none-any.whl", hash = "sha256:e2647043599297a1ef10e720cf310846b7f31b6c841fee093d2b09d81215eb93" }, -] +dev = [{ name = "maturin", specifier = "==1.15.0" }] [[package]] name = "tomli" version = "2.4.0" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -sdist = { url = "https://mirror-pypi.runflare.com/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867" }, - { url = "https://mirror-pypi.runflare.com/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9" }, - { url = "https://mirror-pypi.runflare.com/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95" }, - { url = "https://mirror-pypi.runflare.com/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76" }, - { url = "https://mirror-pypi.runflare.com/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d" }, - { url = "https://mirror-pypi.runflare.com/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576" }, - { url = "https://mirror-pypi.runflare.com/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a" }, - { url = "https://mirror-pypi.runflare.com/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa" }, - { url = "https://mirror-pypi.runflare.com/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614" }, - { url = "https://mirror-pypi.runflare.com/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1" }, - { url = "https://mirror-pypi.runflare.com/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8" }, - { url = "https://mirror-pypi.runflare.com/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a" }, - { url = "https://mirror-pypi.runflare.com/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1" }, - { url = "https://mirror-pypi.runflare.com/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b" }, - { url = "https://mirror-pypi.runflare.com/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51" }, - { url = "https://mirror-pypi.runflare.com/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729" }, - { url = "https://mirror-pypi.runflare.com/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da" }, - { url = "https://mirror-pypi.runflare.com/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3" }, - { url = "https://mirror-pypi.runflare.com/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0" }, - { url = "https://mirror-pypi.runflare.com/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e" }, - { url = "https://mirror-pypi.runflare.com/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4" }, - { url = "https://mirror-pypi.runflare.com/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e" }, - { url = "https://mirror-pypi.runflare.com/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c" }, - { url = "https://mirror-pypi.runflare.com/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f" }, - { url = "https://mirror-pypi.runflare.com/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86" }, - { url = "https://mirror-pypi.runflare.com/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87" }, - { url = "https://mirror-pypi.runflare.com/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132" }, - { url = "https://mirror-pypi.runflare.com/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6" }, - { url = "https://mirror-pypi.runflare.com/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc" }, - { url = "https://mirror-pypi.runflare.com/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66" }, - { url = "https://mirror-pypi.runflare.com/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d" }, - { url = "https://mirror-pypi.runflare.com/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702" }, - { url = "https://mirror-pypi.runflare.com/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8" }, - { url = "https://mirror-pypi.runflare.com/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776" }, - { url = "https://mirror-pypi.runflare.com/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475" }, - { url = "https://mirror-pypi.runflare.com/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2" }, - { url = "https://mirror-pypi.runflare.com/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9" }, - { url = "https://mirror-pypi.runflare.com/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0" }, - { url = "https://mirror-pypi.runflare.com/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df" }, - { url = "https://mirror-pypi.runflare.com/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d" }, - { url = "https://mirror-pypi.runflare.com/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f" }, - { url = "https://mirror-pypi.runflare.com/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b" }, - { url = "https://mirror-pypi.runflare.com/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087" }, - { url = "https://mirror-pypi.runflare.com/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd" }, - { url = "https://mirror-pypi.runflare.com/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4" }, - { url = "https://mirror-pypi.runflare.com/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a" }, -] - -[[package]] -name = "typing-extensions" -version = "4.13.2" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://mirror-pypi.runflare.com/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://mirror-pypi.runflare.com/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -sdist = { url = "https://mirror-pypi.runflare.com/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" } -wheels = [ - { url = "https://mirror-pypi.runflare.com/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] From d4a326d37570bdb62f3b9d4209390ada9a87799a Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 7 Sep 2026 10:54:11 +0330 Subject: [PATCH 02/87] Add new pyerr proc-macro to rapidquery-macros --- rapidquery-macros/lib.rs | 130 ++++++++++++++++++++++++++++++++++----- src/lib.rs | 2 + 2 files changed, 116 insertions(+), 16 deletions(-) diff --git a/rapidquery-macros/lib.rs b/rapidquery-macros/lib.rs index 2d48e7e..181627a 100644 --- a/rapidquery-macros/lib.rs +++ b/rapidquery-macros/lib.rs @@ -1,39 +1,42 @@ use proc_macro::TokenStream; use quote::quote; use syn::parse::{Parse, ParseStream}; -use syn::{ItemStruct, LitStr, Meta, Token, parse_macro_input}; -struct PyclassArgs { - name: LitStr, - extra: Vec, +struct PyClassInput { + name: syn::LitStr, + meta_args: Vec, } -impl Parse for PyclassArgs { +impl Parse for PyClassInput { + /// Accepts `#name, args...` format fn parse(input: ParseStream) -> syn::Result { - let name: LitStr = input.parse()?; + let name: syn::LitStr = input.parse()?; + + let mut meta_args = Vec::new(); + while input.peek(syn::token::Comma) { + input.parse::()?; - let mut extra = Vec::new(); - while input.peek(Token![,]) { - input.parse::()?; if input.is_empty() { break; } - extra.push(input.parse::()?); + + meta_args.push(input.parse::()?); } - Ok(PyclassArgs { name, extra }) + Ok(PyClassInput { name, meta_args }) } } +/// Shortcut for `pyo3::pyclass(module="rapidquery._native", name=..., immutable_type, frozen, ...)` #[proc_macro_attribute] -pub fn pyclass_shortcut(attr: TokenStream, item: TokenStream) -> TokenStream { - let PyclassArgs { name, extra } = parse_macro_input!(attr as PyclassArgs); - let item = parse_macro_input!(item as ItemStruct); +pub fn pyclass(attr: TokenStream, item: TokenStream) -> TokenStream { + let PyClassInput { name, meta_args } = syn::parse_macro_input!(attr as PyClassInput); + let item = syn::parse_macro_input!(item as syn::ItemStruct); - let extra_tokens = if extra.is_empty() { + let extra_tokens = if meta_args.is_empty() { quote! {} } else { - quote! { , #(#extra),* } + quote! { , #(#meta_args),* } }; quote! { @@ -42,3 +45,98 @@ pub fn pyclass_shortcut(attr: TokenStream, item: TokenStream) -> TokenStream { } .into() } + +struct PyErrInput { + return_err_enum: bool, + name: syn::Ident, + msg_or_fmt: Option, + format_args: Vec, +} + +impl Parse for PyErrInput { + /// Acceptable formats: + /// - `#name` + /// - `Err(#name)` + /// - `Err(#name), #msg` + /// - `Err(#name), #fmt, #args...` + /// - `#name, #msg` + /// - `#name, #fmt, #args...` + fn parse(input: ParseStream<'_>) -> syn::Result { + let ident: syn::Ident = input.parse()?; + let (wrap_in_err, name) = if ident == "Err" && input.peek(syn::token::Paren) { + let inner; + syn::parenthesized!(inner in input); + let name: syn::Ident = inner.parse()?; + inner.parse::()?; + (true, name) + } else { + (false, ident) + }; + + let mut slf = Self { + return_err_enum: wrap_in_err, + name, + msg_or_fmt: None, + format_args: vec![], + }; + + if input.is_empty() { + return Ok(slf); + } + + input.parse::()?; + + slf.msg_or_fmt = Some(input.parse()?); + + while input.peek(syn::token::Comma) { + input.parse::()?; + + if input.is_empty() { + break; + } + + slf.format_args.push(input.parse()?); + } + + Ok(slf) + } +} + +/// Creates a new [`PyErr`] of the given exception type. +/// +/// Examples: +/// ```rust,no-test +/// pyerr!(PyTypeError) +/// // => PyTypeError::new_err(()) +/// +/// pyerr!(PyTypeError, "hello") +/// // => PyTypeError::new_err("hello") +/// +/// pyerr!(PyTypeError, "hello {}", "world") +/// // => PyTypeError::new_err(format!("hello {}", "world")) +/// ``` +#[proc_macro] +pub fn pyerr(attr: TokenStream) -> TokenStream { + let PyErrInput { + return_err_enum, + name, + msg_or_fmt, + format_args, + } = syn::parse_macro_input!(attr as PyErrInput); + + let constructed = match (msg_or_fmt, format_args.is_empty()) { + (Some(a), false) => { + quote! { ::pyo3::exceptions::#name::new_err(format!(#a, #(#format_args),*)) } + } + (Some(a), true) => { + quote! { ::pyo3::exceptions::#name::new_err(#a) } + } + (None, _) => quote! { ::pyo3::exceptions::#name::new_err(()) }, + }; + + if return_err_enum { + quote! { Err(#constructed) }.into() + } else { + constructed.into() + } +} diff --git a/src/lib.rs b/src/lib.rs index b5551d7..1e566ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,4 @@ +#![allow(unsafe_op_in_unsafe_fn)] + #[pyo3::pymodule(gil_used = false)] mod _native {} From ff07807fd865e719a527b4df8fbed99063f7549c Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 7 Sep 2026 11:24:48 +0330 Subject: [PATCH 03/87] Add internal helper functions and aiases --- rapidquery-macros/lib.rs | 4 +- src/internal/alias.rs | 15 ++ src/internal/mod.rs | 2 + src/internal/utils/mod.rs | 25 +++ src/internal/utils/strings.rs | 313 ++++++++++++++++++++++++++++ src/internal/utils/submodule_ext.rs | 30 +++ src/lib.rs | 2 + 7 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 src/internal/alias.rs create mode 100644 src/internal/mod.rs create mode 100644 src/internal/utils/mod.rs create mode 100644 src/internal/utils/strings.rs create mode 100644 src/internal/utils/submodule_ext.rs diff --git a/rapidquery-macros/lib.rs b/rapidquery-macros/lib.rs index 181627a..200aae4 100644 --- a/rapidquery-macros/lib.rs +++ b/rapidquery-macros/lib.rs @@ -116,13 +116,13 @@ impl Parse for PyErrInput { /// // => PyTypeError::new_err(format!("hello {}", "world")) /// ``` #[proc_macro] -pub fn pyerr(attr: TokenStream) -> TokenStream { +pub fn pyerr(item: TokenStream) -> TokenStream { let PyErrInput { return_err_enum, name, msg_or_fmt, format_args, - } = syn::parse_macro_input!(attr as PyErrInput); + } = syn::parse_macro_input!(item as PyErrInput); let constructed = match (msg_or_fmt, format_args.is_empty()) { (Some(a), false) => { diff --git a/src/internal/alias.rs b/src/internal/alias.rs new file mode 100644 index 0000000..3694619 --- /dev/null +++ b/src/internal/alias.rs @@ -0,0 +1,15 @@ +//! There are type aliases that are used across the library + +/// Type alias for `pyo3::Py` +pub type PyObject = pyo3::Py; + +/// Type alias for `pyo3::Bound<'a, pyo3::PyAny>` +pub type BoundObject<'a> = pyo3::Bound<'a, pyo3::PyAny>; + +/// Type alias for `&'a pyo3::Bound<'a, pyo3::types::PyTuple>`. +/// Use it directly as `*args` argument type. +pub type ArgsType<'a> = &'a pyo3::Bound<'a, pyo3::types::PyTuple>; + +/// Type alias for `&'a pyo3::Bound<'a, pyo3::types::PyDict>`. +/// Use it directly as `**kwds` argument type. +pub type KwdsType<'a> = &'a pyo3::Bound<'a, pyo3::types::PyDict>; diff --git a/src/internal/mod.rs b/src/internal/mod.rs new file mode 100644 index 0000000..f552fbc --- /dev/null +++ b/src/internal/mod.rs @@ -0,0 +1,2 @@ +pub mod alias; +pub mod utils; diff --git a/src/internal/utils/mod.rs b/src/internal/utils/mod.rs new file mode 100644 index 0000000..b4c778b --- /dev/null +++ b/src/internal/utils/mod.rs @@ -0,0 +1,25 @@ +mod strings; +mod submodule_ext; + +pub use strings::*; +pub use submodule_ext::*; + +/// Returns the type name of a [`pyo3::ffi::PyObject`]. +/// +/// Returns `""` on failure. +#[inline] +pub unsafe fn get_type_name<'a>(py: pyo3::Python<'a>, obj: *mut pyo3::ffi::PyObject) -> String { + use pyo3::types::{PyStringMethods, PyTypeMethods}; + + let type_ = pyo3::ffi::Py_TYPE(obj); + + if type_.is_null() { + String::from("") + } else { + let obj = pyo3::types::PyType::from_borrowed_type_ptr(py, type_); + + obj.fully_qualified_name() + .map(|x| x.to_string_lossy().into_owned()) + .unwrap_or_else(|_| String::from("")) + } +} diff --git a/src/internal/utils/strings.rs b/src/internal/utils/strings.rs new file mode 100644 index 0000000..8aa7a23 --- /dev/null +++ b/src/internal/utils/strings.rs @@ -0,0 +1,313 @@ +//! Allocation-conscious string replacement that writes directly to a [`fmt::Write`]. +// Assisted-by: Grok + +use std::fmt::{self, Write}; + +/// A search needle that can be located inside a UTF-8 haystack. +pub trait ReplacePattern: Copy { + /// First match of this pattern in `haystack`. + /// + /// Returns `(byte_offset, match_len_in_bytes)`. + fn find(self, haystack: &str) -> Option<(usize, usize)>; + + /// If this pattern is exactly one ASCII byte, return that byte. + /// + /// Used to prove that match boundaries are UTF-8 char boundaries without + /// inspecting the haystack. + fn as_ascii_byte(self) -> Option { + None + } +} + +impl ReplacePattern for char { + #[inline] + fn find(self, haystack: &str) -> Option<(usize, usize)> { + haystack.find(self).map(|i| (i, self.len_utf8())) + } + + #[inline] + fn as_ascii_byte(self) -> Option { + self.is_ascii().then_some(self as u8) + } +} + +impl ReplacePattern for &str { + #[inline] + fn find(self, haystack: &str) -> Option<(usize, usize)> { + // An empty needle matches at every byte offset and would loop forever + // (or insert between every character). Treat it as "no match". + if self.is_empty() { + return None; + } + haystack.find(self).map(|i| (i, self.len())) + } + + #[inline] + fn as_ascii_byte(self) -> Option { + let bytes = self.as_bytes(); + match bytes { + [b] if b.is_ascii() => Some(*b), + _ => None, + } + } +} + +/// Replace every occurrence of `from` with `to`, writing the result to `out`. +/// +/// Matches are non-overlapping and searched left to right, same as [`str::replace`]. +/// Empty `&str` needles are ignored (no infinite loop, input is copied as-is). +#[inline] +pub fn replace_to(haystack: &str, from: P, to: &str, out: &mut W) -> fmt::Result +where + P: ReplacePattern, + W: Write, +{ + // Single ASCII byte → single ASCII byte: still go through the generic loop. + // `str::find(char)` already uses memchr, which beats a hand-rolled byte scan + // when matches are sparse, and the generic loop is correct for every other case. + let _ = from.as_ascii_byte(); + + let mut last_end = 0; + let mut search_at = 0; + + while let Some((rel, len)) = from.find(&haystack[search_at..]) { + let start = search_at + rel; + out.write_str(&haystack[last_end..start])?; + out.write_str(to)?; + last_end = start + len; + search_at = last_end; + + // Defensive: a zero-width match would otherwise spin. + if len == 0 { + break; + } + } + + out.write_str(&haystack[last_end..]) +} + +/// Replace multiple single ASCII bytes in one pass over `haystack`. +/// +/// Equivalent to applying each pair as a simultaneous substitution against the +/// *original* input (not a chain of sequential [`str::replace`] calls). If the +/// same byte appears more than once, the last pair wins. +/// +/// # Constraints +/// - Each key must be an ASCII byte (`0..=127`). Non-ASCII keys are skipped so +/// that splits cannot land inside a multi-byte UTF-8 scalar. +/// - Replacement values may be any string, including empty (deletion). +/// +/// # Complexity +/// O(n + k) where n is the input length and k is the number of pairs. +pub fn replace_ascii_to( + haystack: &str, + replacements: &[(u8, &str)], + out: &mut W, +) -> fmt::Result { + if replacements.is_empty() { + return out.write_str(haystack); + } + + // table[b] == 0 → no replacement + // table[b] == i + 1 → replacements[i] + // 512 bytes on the stack, no heap, last write wins. + let mut table = [0u16; 256]; + for (i, &(byte, _)) in replacements.iter().enumerate() { + if !byte.is_ascii() { + continue; + } + table[byte as usize] = i as u16 + 1; + } + + let bytes = haystack.as_bytes(); + let mut last_end = 0; + + for (i, &b) in bytes.iter().enumerate() { + let slot = table[b as usize]; + if slot == 0 { + continue; + } + + write_utf8_prefix(out, bytes, last_end, i)?; + out.write_str(replacements[slot as usize - 1].1)?; + last_end = i + 1; + } + + write_utf8_prefix(out, bytes, last_end, bytes.len()) +} + +/// Write `bytes[from..to]` as a string. +/// +/// # Safety invariant +/// `bytes` is the raw view of a valid `&str`, and `from..to` only starts/ends on +/// ASCII bytes or the slice ends — hence a char boundary. +#[inline] +fn write_utf8_prefix(out: &mut W, bytes: &[u8], from: usize, to: usize) -> fmt::Result { + if from == to { + return Ok(()); + } + // SAFETY: see invariant above. Callers only advance `from` past ASCII matches + // or to `bytes.len()`. + let chunk = unsafe { bytes.get_unchecked(from..to) }; + out.write_str(unsafe { std::str::from_utf8_unchecked(chunk) }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn replace(haystack: &str, from: impl ReplacePattern, to: &str) -> String { + let mut out = String::with_capacity(haystack.len()); + replace_to(haystack, from, to, &mut out).unwrap(); + out + } + + fn replace_ascii(s: &str, replacements: &[(u8, &str)]) -> String { + let mut out = String::with_capacity(s.len()); + replace_ascii_to(s, replacements, &mut out).unwrap(); + out + } + + #[test] + fn str_pattern() { + assert_eq!(replace("hello world", "o", "0"), "hell0 w0rld"); + } + + #[test] + fn char_pattern() { + assert_eq!(replace("banana", 'a', "AA"), "bAAnAAnAA"); + } + + #[test] + fn ascii_char_to_ascii_str() { + assert_eq!(replace("banana", 'a', "_"), "b_n_n_"); + } + + #[test] + fn no_match() { + assert_eq!(replace("hello", "z", "x"), "hello"); + } + + #[test] + fn unicode_pattern() { + assert_eq!(replace("cafĆ© cafĆ©", "Ć©", "e"), "cafe cafe"); + } + + #[test] + fn multi_char_string_pattern() { + assert_eq!(replace("aaaa", "aa", "b"), "bb"); + } + + #[test] + fn overlapping_is_non_overlapping_left_to_right() { + assert_eq!(replace("aaa", "aa", "b"), "ba"); + } + + #[test] + fn empty_haystack() { + assert_eq!(replace("", "a", "b"), ""); + } + + #[test] + fn empty_needle_is_ignored() { + assert_eq!(replace("keep", "", "x"), "keep"); + } + + #[test] + fn delete_via_empty_replacement() { + assert_eq!(replace("a_b_c", "_", ""), "abc"); + } + + #[test] + fn replacement_longer_than_match() { + assert_eq!(replace("ab", "b", "BBB"), "aBBB"); + } + + #[test] + fn quote_and_nul() { + let input = "O'Brien\0's \0dog's\0 name"; + let expected = input.replace('\'', "''").replace('\0', ""); + let got = replace_ascii(input, &[(b'\'', "''"), (b'\0', "")]); + assert_eq!(got, expected); + } + + #[test] + fn single_replacement_matches_naive() { + let input = "hello world"; + assert_eq!( + replace_ascii(input, &[(b'o', "0")]), + input.replace('o', "0") + ); + } + + #[test] + fn no_matches_ascii() { + assert_eq!( + replace_ascii("hello world", &[(b'z', "Z"), (b'x', "X")]), + "hello world" + ); + } + + #[test] + fn empty_input_ascii() { + assert_eq!(replace_ascii("", &[(b'a', "b")]), ""); + } + + #[test] + fn empty_replacements_list() { + assert_eq!(replace_ascii("unchanged string", &[]), "unchanged string"); + } + + #[test] + fn many_pairs_at_once() { + let input = "a-b_c.d,e;f:g"; + let expected = input + .replace('-', "_DASH_") + .replace('.', "_DOT_") + .replace(',', "_COMMA_") + .replace(';', "_SEMI_") + .replace(':', "_COLON_"); + + let got = replace_ascii( + input, + &[ + (b'-', "_DASH_"), + (b'.', "_DOT_"), + (b',', "_COMMA_"), + (b';', "_SEMI_"), + (b':', "_COLON_"), + ], + ); + assert_eq!(got, expected); + } + + #[test] + fn unicode_content_preserved() { + let input = "cafĆ©\0naĆÆve'test"; + let expected = input.replace('\'', "''").replace('\0', ""); + assert_eq!( + replace_ascii(input, &[(b'\'', "''"), (b'\0', "")]), + expected + ); + } + + #[test] + fn last_duplicate_wins() { + assert_eq!(replace_ascii("abc", &[(b'a', "X"), (b'a', "Y")]), "Ybc"); + } + + #[test] + fn non_ascii_keys_are_skipped() { + // 0xC3 is a UTF-8 lead byte in "Ć©". Treating it as a split point would be UB. + assert_eq!(replace_ascii("cafĆ©", &[(0xC3, "X")]), "cafĆ©"); + } + + #[test] + fn matches_std_replace_for_char() { + let samples = ["", "a", "aaa", "cafĆ©", "hello\0world", "šŸ‡ŗšŸ‡ø"]; + for s in samples { + assert_eq!(replace(s, 'a', "AA"), s.replace('a', "AA")); + assert_eq!(replace(s, "l", "_"), s.replace("l", "_")); + } + } +} diff --git a/src/internal/utils/submodule_ext.rs b/src/internal/utils/submodule_ext.rs new file mode 100644 index 0000000..1d64686 --- /dev/null +++ b/src/internal/utils/submodule_ext.rs @@ -0,0 +1,30 @@ +// https://github.com/PyO3/pyo3/issues/759#issuecomment-4041245073 + +use pyo3::types::{PyAnyMethods, PyModule, PyModuleMethods}; + +fn _inner_init(module: &pyo3::Bound<'_, PyModule>, path: &str) -> pyo3::PyResult<()> { + let py = module.py(); + let sys_modules = py.import("sys")?.getattr("modules")?; + for submod_name in module.index()? { + let submod_name = submod_name.extract::()?; + let submod = module.getattr(&submod_name)?; + let modpath = format!("{path}.{submod_name}"); + if let Ok(submod) = submod.cast::() { + _inner_init(submod, &modpath)?; + sys_modules.set_item(&modpath, submod)?; + } + } + Ok(()) +} + +pub trait PyModuleSubmoduleExt { + fn init_submodules(&self) -> pyo3::PyResult<()>; +} + +impl PyModuleSubmoduleExt for pyo3::Bound<'_, pyo3::types::PyModule> { + fn init_submodules(&self) -> pyo3::PyResult<()> { + let mod_path = self.name()?.extract::()?; + _inner_init(self, &mod_path)?; + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 1e566ed..c08a6a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,6 @@ #![allow(unsafe_op_in_unsafe_fn)] +pub mod internal; + #[pyo3::pymodule(gil_used = false)] mod _native {} From 9ea9cd0a2c5e4c3de56aa09962bf7fa6b19c7801 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 7 Sep 2026 11:25:23 +0330 Subject: [PATCH 04/87] Bump rapidquery-macros version to 1.0.0-alpha1 --- rapidquery-macros/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rapidquery-macros/Cargo.toml b/rapidquery-macros/Cargo.toml index 23cef75..d3bdb29 100644 --- a/rapidquery-macros/Cargo.toml +++ b/rapidquery-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rapidquery-macros" -version = "0.1.0" +version = "1.0.0-alpha1" edition = "2024" description = "Procedural macros for RapidQuery." license = "GNU GPLv3" From e15278666c419e00bd5874dff317ffbf9aec45de Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 7 Sep 2026 11:26:28 +0330 Subject: [PATCH 05/87] Change license to MIT --- LICENSE | 695 ++------------------------------------------------------ 1 file changed, 21 insertions(+), 674 deletions(-) diff --git a/LICENSE b/LICENSE index f288702..0e064c3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,674 +1,21 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. +MIT License + +Copyright (c) 2026 Ali Pooralijan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 22cd74b3719337ace5b104b3400ea8e1a63e567f Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 7 Sep 2026 11:29:14 +0330 Subject: [PATCH 06/87] Rename rapidquery._native to rapidquery._core --- Cargo.lock | 2 +- pyproject.toml | 2 +- rapidquery-macros/lib.rs | 2 +- src/lib.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4edef35..36a2f50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ dependencies = [ [[package]] name = "rapidquery-macros" -version = "0.1.0" +version = "1.0.0-alpha1" dependencies = [ "proc-macro2", "quote", diff --git a/pyproject.toml b/pyproject.toml index 4df63c8..6d812ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [tool.maturin] features = ["pyo3/extension-module"] -module-name = "rapidquery._native" +module-name = "rapidquery._core" [project] name = "rapidquery" diff --git a/rapidquery-macros/lib.rs b/rapidquery-macros/lib.rs index 200aae4..742a480 100644 --- a/rapidquery-macros/lib.rs +++ b/rapidquery-macros/lib.rs @@ -40,7 +40,7 @@ pub fn pyclass(attr: TokenStream, item: TokenStream) -> TokenStream { }; quote! { - #[pyo3::pyclass(module="rapidquery._native", name=#name, immutable_type, frozen #extra_tokens)] + #[pyo3::pyclass(module="rapidquery._core", name=#name, immutable_type, frozen #extra_tokens)] #item } .into() diff --git a/src/lib.rs b/src/lib.rs index c08a6a5..d5912af 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,4 +3,4 @@ pub mod internal; #[pyo3::pymodule(gil_used = false)] -mod _native {} +mod _core {} From 63aa8761a42caba36932d1f73585f1105c7c921b Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 7 Sep 2026 11:40:40 +0330 Subject: [PATCH 07/87] Write dialects and statement prepare traits --- Cargo.lock | 23 + Cargo.toml | 1 + README.md | 151 +++-- src/lib.rs | 1 + src/native/dialect/bigquery.rs | 70 ++ src/native/dialect/databricks.rs | 74 ++ src/native/dialect/duckdb.rs | 55 ++ src/native/dialect/hive.rs | 71 ++ src/native/dialect/mod.rs | 1089 ++++++++++++++++++++++++++++++ src/native/dialect/mssql.rs | 62 ++ src/native/dialect/mysql.rs | 66 ++ src/native/dialect/oracle.rs | 56 ++ src/native/dialect/postgresql.rs | 73 ++ src/native/dialect/redshift.rs | 63 ++ src/native/dialect/snowflake.rs | 71 ++ src/native/dialect/spark.rs | 73 ++ src/native/dialect/sqlite.rs | 57 ++ src/native/dialect/teradata.rs | 59 ++ src/native/mod.rs | 2 + src/native/prepare_statement.rs | 212 ++++++ 20 files changed, 2279 insertions(+), 50 deletions(-) create mode 100644 src/native/dialect/bigquery.rs create mode 100644 src/native/dialect/databricks.rs create mode 100644 src/native/dialect/duckdb.rs create mode 100644 src/native/dialect/hive.rs create mode 100644 src/native/dialect/mod.rs create mode 100644 src/native/dialect/mssql.rs create mode 100644 src/native/dialect/mysql.rs create mode 100644 src/native/dialect/oracle.rs create mode 100644 src/native/dialect/postgresql.rs create mode 100644 src/native/dialect/redshift.rs create mode 100644 src/native/dialect/snowflake.rs create mode 100644 src/native/dialect/spark.rs create mode 100644 src/native/dialect/sqlite.rs create mode 100644 src/native/dialect/teradata.rs create mode 100644 src/native/mod.rs create mode 100644 src/native/prepare_statement.rs diff --git a/Cargo.lock b/Cargo.lock index 36a2f50..6afb9dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "autocfg" version = "1.5.0" @@ -29,6 +35,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "foldhash", +] + [[package]] name = "heck" version = "0.5.0" @@ -175,6 +197,7 @@ dependencies = [ name = "rapidquery" version = "1.0.0-alpha1" dependencies = [ + "hashbrown", "parking_lot", "pyo3", "rapidquery-macros", diff --git a/Cargo.toml b/Cargo.toml index d00114c..154b6ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ strip = false rapidquery-macros = { path = "rapidquery-macros" } pyo3 = { version = "0.29", default-features = false, features = [ "macros", "chrono", "py-clone" ] } parking_lot = { version = "0.12", default-features = false } +hashbrown = { version = "0.17.1", default-features = false, features = ["allocator-api2", "default-hasher", "inline-more"] } [features] default = ["extension-module"] diff --git a/README.md b/README.md index 8a4b726..4f251f6 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,13 @@ # RapidQuery -__*RapidQuery: High-Performance SQL Query Builder for Python*__ + +**_RapidQuery: High-Performance SQL Query Builder for Python_** RapidQuery is a powerful SQL query builder library designed for Python, combining the simplicity of Python with the raw speed of **Rust**. Build complex SQL queries effortlessly and efficiently, with a library that prioritizes both performance and ease of use. +> [!WARNING]\ +> This is the old documentation for v0.1.0. +> This branch is for v1.0.0 and is still in development. + - [**Installation**](#install) - [**Why RapidQuery?**](#why-rapidquery) - [**Supported Backends**](#backends) @@ -15,17 +20,21 @@ RapidQuery is a powerful SQL query builder library designed for Python, combinin ## Install You can use **PIP**: + ```bash pip3 install rapidquery ``` Or you can use **UV** (recommended): + ```bash uv add rapidquery ``` ## Why RapidQuery? + **Features**: + - **🧶 Thread safe**: It's completely thread-safe and uses locks in internal to prevent concurrency problems. - **⚔ Blazing High Performance**: Leveraging the power of Rust under the hood, RapidQuery ensures your query building process is as fast as possible. - šŸ›”ļø **SQL Injection Protection**: Built-in security measures to prevent SQL injection attacks by default. @@ -36,37 +45,40 @@ uv add rapidquery > In a landscape filled with SQL libraries, we noticed a critical gap: **performance was often an afterthought**. That's why we built RapidQuery with speed as our primary and enduring focus. ## Backends + RapidQuery supports `PostgreSQL`, `MySQL`, and `SQLite`. These are referred to as `backend`s. When building SQL statements, you must specify your target backend. ## Usage 1. Core Concepts - 1. [**Value**](#value) - 2. [**Expr**](#expr) - 3. [**Statement Builders**](#statement-builders) + 1. [**Value**](#value) + 2. [**Expr**](#expr) + 3. [**Statement Builders**](#statement-builders) 2. Query Statements - 1. [**Query Select**](#query-select) - 2. [**Query Insert**](#query-insert) - 3. [**Query Update**](#query-update) - 4. [**Query Delete**](#query-delete) - 5. [**Query With**](#query-with) + 1. [**Query Select**](#query-select) + 2. [**Query Insert**](#query-insert) + 3. [**Query Update**](#query-update) + 4. [**Query Delete**](#query-delete) + 5. [**Query With**](#query-with) 3. More About Queries - 1. [**Custom Function**](#custom-functions) + 1. [**Custom Function**](#custom-functions) 4. Schema Statements - 1. [**Table Create**](#table-create) - 2. [**Table Alter**](#table-alter) - 3. [**Table Drop**](#table-drop) - 4. [**Table Rename**](#table-rename) - 5. [**Table Truncate**](#table-truncate) - 8. [**Index Create**](#index-create) - 9. [**Index Drop**](#index-drop) + 1. [**Table Create**](#table-create) + 2. [**Table Alter**](#table-alter) + 3. [**Table Drop**](#table-drop) + 4. [**Table Rename**](#table-rename) + 5. [**Table Truncate**](#table-truncate) + 6. [**Index Create**](#index-create) + 7. [**Index Drop**](#index-drop) 5. Advanced Usage - 1. [**More About Column References**](#more-about-column-references) - 2. [**More About TableName**](#more-about-tablename) - 3. [**More About Expr**](#more-about-expr) + 1. [**More About Column References**](#more-about-column-references) + 2. [**More About TableName**](#more-about-tablename) + 3. [**More About Expr**](#more-about-expr) ### Core Concepts + #### Value + Bridges Python types, Rust types, and SQL types for seamless data conversion. This class handles validation, adaptation, and conversion between different @@ -79,6 +91,7 @@ type systems used in the application stack. > **Important**: `Value` is lazy. This means it keeps your value and never converts it to Rust and then SQL until needed. #### Expr + Represents a SQL expression that can be built into SQL code. This class provides a fluent interface for constructing complex SQL expressions @@ -90,9 +103,10 @@ when building the final SQL statement. > [!NOTE]\ > `Expr` is immutable, so by calling each method you will give a new instance -of it which includes new change(s). +> of it which includes new change(s). **Basic** + ```python import rapidquery as rp @@ -106,6 +120,7 @@ rp.Expr(rq.ColumnRef("name", table="users")) # -> "users"."name" (column refere ``` **Comparisons** + ```python rq.Expr.col("status") == "active" # -> "status" == 'active' rq.Expr.col("age") > 16 # -> "age" > 16 @@ -126,7 +141,9 @@ rq.any( ``` **Best Practices** + - Always use `Expr.col()` for column references: This ensures proper quoting for your target database + ```python # Column reference (properly quoted identifier) rq.Expr.col("user_name") # → "user_name" @@ -136,15 +153,17 @@ rq.Expr("user_name") # → 'user_name' ``` - Use `rapidquery.all()` and `rapidquery.any()` for logical combinations: More readable than chaining `&` and `|` operators + ```python # Good all(condition1, condition2, condition3) - + # Less readable condition1 & condition2 & condition3 ``` - Be careful with `Expr.custom()`: It bypasses all safety checks + ```python # Dangerous - vulnerable to SQL injection user_input = "'; DROP TABLE users; --" @@ -155,12 +174,14 @@ Expr.col("name") == user_input ``` #### Statement Builders + Statements are divided into 2 categories: `QueryStatement`, and `SchemaStatement`. Some statements like `Select`, `Update`, `Delete`, `Insert`, ... are `QueryStatement`. Other statements like `Table`, `AlterTable`, `Index`, ... are `SchemaStatement`. `QueryStatement` class interface is: + ```python class QueryStatement: """Subclass of query statements.""" @@ -179,6 +200,7 @@ class QueryStatement: ``` `SchemaStatement` class interface is: + ```python class SchemaStatement: """Subclass of schema statements.""" @@ -189,6 +211,7 @@ class SchemaStatement: ``` ### Query Select + Use `rapidquery.SelectStatement` type to generate `SELECT` statements. ```python @@ -214,11 +237,12 @@ print(stmt.to_sql("mysql")) print(stmt.to_sql("sqlite")) # SELECT "character" AS "character", "fonts"."name" AS "name" FROM "characters" -# LEFT JOIN "fonts" ON "characters"."font_id" = "fonts"."id" +# LEFT JOIN "fonts" ON "characters"."font_id" = "fonts"."id" # WHERE "size_w" IN (3, 4) AND "characters" LIKE 'A%' ``` ### Query Insert + Use `rapidquery.InsertStatement` type to generate `INSERT` statements. ```python @@ -233,7 +257,7 @@ stmt = ( print(stmt.to_sql("postgres")) # INSERT INTO "glyph" ("aspect", "image") VALUES (3.14, 'A4') # ON CONFLICT ("id") DO UPDATE SET "image" = "excluded"."image" -# +# print(stmt.to_sql("mysql")) # INSERT INTO `glyph` (`aspect`, `image`) VALUES (3.14, 'A4') # ON DUPLICATE KEY UPDATE `image` = VALUES(`image`) @@ -244,6 +268,7 @@ print(stmt.to_sql("sqlite")) ``` ### Query Update + Use `rapidquery.UpdateStatement` type to generate `UPDATE` statements. ```python @@ -266,6 +291,7 @@ print(stmt.to_sql("sqlite")) ``` ### Query Delete + Use `rapidquery.DeleteStatement` type to generate `DELETE` statements. ```python @@ -289,6 +315,7 @@ print(stmt.to_sql("sqlite")) ``` ### Query With + We have two types here: `rapidquery.WithClause` and `rapidquery.WithQuery`. ```txt @@ -301,7 +328,6 @@ WITH [... CTEs ...] QUERY WithClause ``` - As you can see, `rapidquery.WithClause` includes common table expressions (CTEs), and `rapidquery.WithQuery` includes `rapidquery.WithClause` and the final query. @@ -340,7 +366,7 @@ query: WithQuery = clause.query( ) ) query.to_sql("postgres") -# WITH +# WITH # "users_count" AS ( # UPDATE "users" SET "amount" = "amount" + 10 WHERE "id" > 50 RETURNING 1 # ) , @@ -353,6 +379,7 @@ query.to_sql("postgres") ``` ### Custom Functions + For working with functions in RapidQuery, you have to use `Func` class. A lot of functions such as `SUM`, `AVG`, `MD5`, ... is ready to use. For example: @@ -372,6 +399,7 @@ stmt.to_sql("postgres") ``` ### Table Create + `rapidquery.Table` represents a complete database table definition. Use it to generate `CREATE TABLE` statements. ```python @@ -404,6 +432,7 @@ print(characters.to_sql("postgresql")) ``` ### Table Alter + Use `rapidquery.AlterTable` type to generate `ALTER TABLE` statements. ```python @@ -429,6 +458,7 @@ print(stmt.to_sql("mysql")) ``` ### Table Drop + Use `rapidquery.DropTable` type to generate `DROP TABLE` statements. ```python @@ -440,6 +470,7 @@ print(stmt.to_sql("mysql")) ``` ### Table Rename + Use `rapidquery.RenameTable` type to generate `RENAME TABLE` statements. ```python @@ -455,6 +486,7 @@ print(stmt.to_sql("mysql")) ``` ### Table Truncate + Use `rapidquery.TruncateTable` type to generate `TRUNCATE TABLE` statements. ```python @@ -478,6 +510,7 @@ print(idx.to_sql("postgres")) ``` ### Index Drop + Use `rapidquery.DropIndex` type to generate `DROP INDEX` statements. ```python @@ -490,6 +523,7 @@ print(idx.to_sql("postgres")) ``` ### More About Column Reference + Let's learn some tricks about column references. In RapidQuery, we have something called `ColumnRef`, which represents a reference to a database column with optional table and schema qualification. @@ -502,6 +536,7 @@ In generating statements, we have a lot of situations that you need to work with For the parameters which accept column references, you have 4 ways: 1. Use `ColumnRef`: + ```python col_ref = rq.ColumnRef("id", "characters") # OR @@ -512,12 +547,14 @@ stmt = rq.SelectStatement().columns(col_ref) ``` 2. Use `str`: The easiest way + ```python stmt = rq.SelectStatement().columns("characters.id") # SELECT "characters"."id" AS "id" ``` 3. Use `__column_ref__` property: developer-friendly and expandable way. + ```python class IdColumnProperty: @property @@ -536,6 +573,7 @@ stmt = rq.SelectStatement().columns(IdColumnClassVar) ``` 4. Use `Column`: It's possible because `Column` has `__column_ref__` property. + ```python id = Column("id", rq.Integer()) @@ -543,8 +581,8 @@ stmt = rq.SelectStatement().columns(id) # SELECT "id" AS "id" ``` - ### More About TableName + Let's learn some tricks about table name. In RapidQuery, we have something called `TableName`, which represents a table name reference with optional schema, database, and alias. @@ -557,6 +595,7 @@ In generating statements, we have situations that you need to specify table name For the parameters which accept table name, you have 4 ways: 1. Use `TableName`: + ```python tbl = rq.TableName("users", schema="archive") # OR @@ -567,12 +606,14 @@ stmt = rq.DeleteStatement(tbl) ``` 2. Use `str`: The easiest way + ```python stmt = rq.DeleteStatement("archive.users") # DELETE FROM "archive"."users" ``` 3. Use `__table_name__` property: developer-friendly and expandable way. + ```python class UsersProperty: @property @@ -593,6 +634,7 @@ stmt = rq.DeleteStatement(UsersClassVar) ``` 4. Use `Table`: It's possible because `Table` has `__table_name__` property. + ```python users = Table( "archive.users", @@ -604,6 +646,7 @@ stmt = rq.DeleteStatement(users) ``` ### More About Expr + You learned [here](#expr) about `Expr` type. **ā— The Trick** \ @@ -628,8 +671,10 @@ stmt = rq.SelectStatement(TextClause("WOW!")) 2. Second, same as `ColumnRef`, `Expr` also supports `__column_ref__` property. ## Performance + ### Benchmarks -Benchmarks run on *Linux 6.18.12-1-MANJARO x86_64* with CPython 3.14. Your results may vary. + +Benchmarks run on _Linux 6.18.12-1-MANJARO x86_64_ with CPython 3.14. Your results may vary. Iterations per test: 100,000 \ Python version: 3.14.3 @@ -673,8 +718,10 @@ SQLAlchemy 7924.52 17.95x slower ``` ## Known Issues + ### Unmanaged Rust panic output in building SQL -The library may encounter errors during SQL query construction, which are correctly raised as *RuntimeError* exceptions. For instance, this occurs when using a function that isn't supported by your target database. **While this error-raising behavior is intentional and logical, the issue is that unmanaged Rust panic information is also printed to stderr**. Currently, there is no way to suppress or manage this panic output. We are working to resolve this problem as much as possible in future updates. + +The library may encounter errors during SQL query construction, which are correctly raised as _RuntimeError_ exceptions. For instance, this occurs when using a function that isn't supported by your target database. **While this error-raising behavior is intentional and logical, the issue is that unmanaged Rust panic information is also printed to stderr**. Currently, there is no way to suppress or manage this panic output. We are working to resolve this problem as much as possible in future updates. ```python >>> import rapidquery as rq @@ -691,9 +738,11 @@ RuntimeError: build failed ``` ### Missing `__init__` Calls + If a RapidQuery object is instantiated without calling its `__init__` method (e.g., via certain serialization tricks or `__new__` alone), the internal Rust pointer will be null. Accessing methods on such objects will cause an unmanaged Rust panic. Always use the provided constructors. ### Join conditions are not optional + Currently, `JOIN` operations require an explicit ON or USING condition. ## Supported Platforms @@ -702,41 +751,42 @@ Pre-built wheels are available for the following platforms and Python interprete ### Linux (manylinux) -| Architecture | Python Versions | -|---|---| -| x86_64 | 3.10 – 3.14, 3.14t, PyPy 3.11, GraalPy 3.11/3.12 | -| i686 | 3.10 – 3.14, 3.14t, PyPy 3.11 | -| aarch64 | 3.10 – 3.14, 3.14t, PyPy 3.11, GraalPy 3.11/3.12 | -| armv7 | 3.10 – 3.14, 3.14t | -| s390x | 3.10 – 3.14, 3.14t | -| ppc64le | 3.10 – 3.14, 3.14t | -| riscv64 | 3.10 – 3.14, 3.14t | +| Architecture | Python Versions | +| ------------ | ------------------------------------------------ | +| x86_64 | 3.10 – 3.14, 3.14t, PyPy 3.11, GraalPy 3.11/3.12 | +| i686 | 3.10 – 3.14, 3.14t, PyPy 3.11 | +| aarch64 | 3.10 – 3.14, 3.14t, PyPy 3.11, GraalPy 3.11/3.12 | +| armv7 | 3.10 – 3.14, 3.14t | +| s390x | 3.10 – 3.14, 3.14t | +| ppc64le | 3.10 – 3.14, 3.14t | +| riscv64 | 3.10 – 3.14, 3.14t | ### Linux (musllinux 1.1) -| Architecture | Python Versions | -|---|---| -| x86_64 | 3.10 – 3.14, 3.14t, PyPy 3.11 | -| aarch64 | 3.10 – 3.14, 3.14t, PyPy 3.11 | -| armv7 | 3.10 – 3.14, 3.14t, PyPy 3.11 | +| Architecture | Python Versions | +| ------------ | ----------------------------- | +| x86_64 | 3.10 – 3.14, 3.14t, PyPy 3.11 | +| aarch64 | 3.10 – 3.14, 3.14t, PyPy 3.11 | +| armv7 | 3.10 – 3.14, 3.14t, PyPy 3.11 | ### Windows -| Architecture | Python Versions | -|---|---| -| x64 | 3.10 – 3.14, 3.14t | -| x86 | 3.10 – 3.14, 3.14t | +| Architecture | Python Versions | +| ------------ | ------------------ | +| x64 | 3.10 – 3.14, 3.14t | +| x86 | 3.10 – 3.14, 3.14t | ### macOS -| Architecture | Python Versions | -|---|---| -| x86_64 | 3.10 – 3.14, 3.13t, 3.14t, PyPy 3.11 | +| Architecture | Python Versions | +| ----------------------- | ------------------------------------ | +| x86_64 | 3.10 – 3.14, 3.13t, 3.14t, PyPy 3.11 | | aarch64 (Apple Silicon) | 3.10 – 3.14, 3.13t, 3.14t, PyPy 3.11 | A source distribution (sdist) is also published for platforms not listed above, which requires a Rust nightly toolchain to build from source. ## TODO + - [x] Write tests - [x] Update & automate workflows - [x] Write CTE @@ -745,4 +795,5 @@ A source distribution (sdist) is also published for platforms not listed above, - [ ] Complete backend-only functions ## License + This repository is licensed under the [GNU GPLv3 License](LICENSE) diff --git a/src/lib.rs b/src/lib.rs index d5912af..90ec6c1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ #![allow(unsafe_op_in_unsafe_fn)] pub mod internal; +pub mod native; #[pyo3::pymodule(gil_used = false)] mod _core {} diff --git a/src/native/dialect/bigquery.rs b/src/native/dialect/bigquery.rs new file mode 100644 index 0000000..0b82934 --- /dev/null +++ b/src/native/dialect/bigquery.rs @@ -0,0 +1,70 @@ +//! A dialect for [Google BigQuery](https://cloud.google.com/bigquery/). +//! +//! • Strings: single-quoted; escape with backslash sequences. +//! • Identifiers: back-tick quoted. +//! • Bytes: B'\xNN…' (byte literal with \x escapes). +//! • Bits: No native bit-string literal; cast from INT64. +//! We emit CAST(0b AS INT64) for best portability. +//! • Bool: TRUE / FALSE keywords. +//! +//! Ref: + +use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; + +pub(super) const NAME: &str = "BigQuery"; + +pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; + +pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::Backtick); + +pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::AtSign; + +pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { + for ch in string.chars() { + match ch { + '\\' => writer.write_str("\\\\")?, + '\'' => writer.write_str("\\'")?, + '\n' => writer.write_str("\\n")?, + '\r' => writer.write_str("\\r")?, + '\t' => writer.write_str("\\t")?, + '\0' => writer.write_str("\\x00")?, + // ASCII control characters + c if (c as u32) < 32 || c as u32 == 127 => { + writer.write_str(&format!("\\x{:02X}", c as u32))?; + } + c => writer.write_char(c)?, + } + } + + Ok(()) +} + +pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { + super::wrap_single_quote(writer, string) +} + +pub(super) fn escape_identifier( + writer: &mut T, + ident: &str, +) -> std::fmt::Result { + escape_string(writer, ident) +} + +pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { + // BigQuery byte literal: B'\xNN\xNN…' + writer.write_str("B'")?; + for b in bytes { + writer.write_str(&format!("\\x{:02X}", b))?; + } + writer.write_char('\'') +} + +pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { + debug_assert!( + bits.chars().all(|c| c == '0' || c == '1'), + "encode_bits: invalid input: {bits}" + ); + + // BigQuery has no native bit literal; represent as INT64 binary literal. + write!(writer, "CAST(0b{} AS INT64)", bits) +} diff --git a/src/native/dialect/databricks.rs b/src/native/dialect/databricks.rs new file mode 100644 index 0000000..836f66f --- /dev/null +++ b/src/native/dialect/databricks.rs @@ -0,0 +1,74 @@ +//! A dialect for [Databricks SQL](https://www.databricks.com/). +//! +//! See . +//! +//! • Strings: single-quoted; backslash-escape sequences. +//! • Identifiers: back-tick quoted. +//! • Bytes: X'DEADBEEF' hex literal (BINARY type). +//! • Bits: No native bit literal; we use CAST(… AS BINARY) from INT. +//! • Bool: TRUE / FALSE keywords. +//! +//! Ref: + +use crate::internal::utils::replace_to; + +use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; + +pub(super) const NAME: &str = "Databricks"; + +pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; + +pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::Backtick); + +pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; + +pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { + for ch in string.chars() { + match ch { + '\\' => writer.write_str("\\\\")?, + '\'' => writer.write_str("\\'")?, + '"' => writer.write_str("\\\"")?, + '\n' => writer.write_str("\\n")?, + '\r' => writer.write_str("\\r")?, + '\t' => writer.write_str("\\t")?, + '\0' => writer.write_str("\\0")?, + c if (c as u32) < 32 || c as u32 == 127 => { + writer.write_str(&format!("\\u{:04X}", c as u32))?; + } + c => writer.write_char(c)?, + } + } + + Ok(()) +} + +pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { + super::wrap_single_quote(writer, string) +} + +pub(super) fn escape_identifier( + writer: &mut T, + ident: &str, +) -> std::fmt::Result { + replace_to(ident, '`', "``", writer) +} + +pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { + writer.write_str("X'")?; + super::bytes_to_hex(writer, bytes)?; + writer.write_char('\'') +} + +pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { + debug_assert!( + bits.chars().all(|c| c == '0' || c == '1'), + "encode_bits: invalid input: {bits}" + ); + + // Databricks supports CAST with integer literals + write!( + writer, + "CAST({}L AS BINARY)", + i64::from_str_radix(bits, 2).unwrap_or(0) + ) +} diff --git a/src/native/dialect/duckdb.rs b/src/native/dialect/duckdb.rs new file mode 100644 index 0000000..e97107f --- /dev/null +++ b/src/native/dialect/duckdb.rs @@ -0,0 +1,55 @@ +//! A dialect for [DuckDB](https://duckdb.org/). +//! +//! • Strings: single-quoted; `''` for embedded single-quote. +//! • Identifiers: double-quote quoted. +//! • Bytes: '\xNN…'::BLOB or X'DEADBEEF' hex literal. +//! • Bits: 0b::INTEGER or BIT literal via BITSTRING. +//! • Bool: TRUE / FALSE keywords. +//! +//! Ref: + +use crate::internal::utils::replace_to; + +use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; + +pub(super) const NAME: &str = "DuckDb"; + +pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; + +pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::DoubleQuote); + +pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::DollarNamed; + +pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { + // DuckDB uses '' for quote escaping (ANSI-compatible). + // Backslash is NOT a special escape character in default mode. + super::escape_single_quote(writer, string) +} + +pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { + super::wrap_single_quote(writer, string) +} + +pub(super) fn escape_identifier( + writer: &mut T, + ident: &str, +) -> std::fmt::Result { + replace_to(ident, '"', "\"\"", writer) +} + +pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { + // write!(writer, "X'{}'", super::bytes_to_hex(writer, bytes)) + writer.write_str("X'")?; + super::bytes_to_hex(writer, bytes)?; + writer.write_char('\'') +} + +pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { + debug_assert!( + bits.chars().all(|c| c == '0' || c == '1'), + "encode_bits: invalid input: {bits}" + ); + + // DuckDB supports bitstring literals with B prefix + write!(writer, "B'{}'", bits) +} diff --git a/src/native/dialect/hive.rs b/src/native/dialect/hive.rs new file mode 100644 index 0000000..37936e9 --- /dev/null +++ b/src/native/dialect/hive.rs @@ -0,0 +1,71 @@ +//! A dialect for [Hive](https://hive.apache.org/). +//! +//! • Strings: single-quoted; backslash-escape sequences (Java conventions). +//! • Identifiers: back-tick quoted. +//! • Bytes: No native byte literal; use CAST with hex string. +//! • Bits: No native bit literal; use CAST with integer. +//! • Bool: TRUE / FALSE keywords. +//! +//! Ref: + +use crate::internal::utils::replace_to; + +use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; + +pub(super) const NAME: &str = "Hive"; + +pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; + +pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::Backtick); + +/// This is not natively supported by Hive, and should be rendered on client-side. +pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; + +pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { + for ch in string.chars() { + match ch { + '\\' => writer.write_str("\\\\")?, + '\'' => writer.write_str("\\'")?, + '\n' => writer.write_str("\\n")?, + '\r' => writer.write_str("\\r")?, + '\t' => writer.write_str("\\t")?, + '\0' => writer.write_str("\\0")?, + c if (c as u32) < 32 || c as u32 == 127 => { + writer.write_str(&format!("\\u{:04X}", c as u32))?; + } + c => writer.write_char(c)?, + } + } + Ok(()) +} + +pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { + super::wrap_single_quote(writer, string) +} + +pub(super) fn escape_identifier( + writer: &mut T, + ident: &str, +) -> std::fmt::Result { + replace_to(ident, '`', "``", writer) +} + +pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { + // Hive: BINARY from hex string via unhex() + writer.write_str("UNHEX('")?; + super::bytes_to_hex(writer, bytes)?; + writer.write_str("')") +} + +pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { + debug_assert!( + bits.chars().all(|c| c == '0' || c == '1'), + "encode_bits: invalid input: {bits}" + ); + + write!( + writer, + "CAST({} AS BIGINT)", + i64::from_str_radix(bits, 2).unwrap_or(0) + ) +} diff --git a/src/native/dialect/mod.rs b/src/native/dialect/mod.rs new file mode 100644 index 0000000..6237f17 --- /dev/null +++ b/src/native/dialect/mod.rs @@ -0,0 +1,1089 @@ +//! SQL dialect definitions. +//! +//! Each dialect encodes how SQL literals (strings, bytes, bit-strings) and +//! identifiers should be quoted/escaped/rendered for a particular database +//! engine. Dialects are selected at runtime via the [`SqlDialect`] enum. +//! +//! Assisted-by: Claude + +use crate::internal::utils::replace_to; + +mod bigquery; +mod databricks; +mod duckdb; +mod hive; +mod mssql; +mod mysql; +mod oracle; +mod postgresql; +mod redshift; +mod snowflake; +mod spark; +mod sqlite; +mod teradata; + +/// How a boolean should be serialized. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BoolFormat { + /// `TRUE` / `FALSE` + Keyword, + /// `1` / `0` + Int, +} + +/// Represents the various identifier quote styles. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum IdentifierQuote { + SingleQuote, + DoubleQuote, + Backtick, + SquareBracket, +} + +/// Represents the various placeholder syntaxes used for parameterized queries +/// and prepared statements across different SQL dialects. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlaceholderSyntax { + /// At-Sign Named Parameter syntax (`@param_name`). + AtSign, + + /// Colon Named Parameter syntax (`:param_name`). + Colon, + + /// Dollar-Sign Named Parameter syntax (`$param_name`). + DollarNamed, +} + +/// Converts a byte slice to an uppercase hex string. +#[inline] +pub(super) fn bytes_to_hex(out: &mut T, bytes: &[u8]) -> std::fmt::Result { + for b in bytes { + write!(out, "{:02X}", b)?; + } + Ok(()) +} + +/// Standard single-quote escape: `'` → `''`. +/// Used by most SQL dialects. +#[inline] +pub(super) fn escape_single_quote( + writer: &mut T, + string: &str, +) -> std::fmt::Result { + replace_to(string, '\'', "''", writer) +} + +/// Wraps a (pre-escaped) string in single quotes. +#[inline] +pub(super) fn wrap_single_quote( + writer: &mut T, + string: &str, +) -> std::fmt::Result { + write!(writer, "'{string}'") +} + +/// Dispatches to the per-dialect free function of the same name, forwarding +/// any extra arguments. Keeps [`SqlDialect`]'s methods free of repetitive +/// match boilerplate as new dialects/methods are added. +/// +/// Use `dispatch!(self, fn METHOD, arg1, arg2, ...)` for functions and +/// `dispatch!(self, const NAME)` for associated consts (no call parens). +macro_rules! dispatch { + ($self:expr, fn $method:ident $(, $arg:expr)*) => { + match $self { + SqlDialect::BigQuery => bigquery::$method($($arg),*), + SqlDialect::Databricks => databricks::$method($($arg),*), + SqlDialect::DuckDb => duckdb::$method($($arg),*), + SqlDialect::Hive => hive::$method($($arg),*), + SqlDialect::MsSql => mssql::$method($($arg),*), + SqlDialect::MySql => mysql::$method($($arg),*), + SqlDialect::Oracle => oracle::$method($($arg),*), + SqlDialect::PostgreSql => postgresql::$method($($arg),*), + SqlDialect::Redshift => redshift::$method($($arg),*), + SqlDialect::Snowflake => snowflake::$method($($arg),*), + SqlDialect::Spark => spark::$method($($arg),*), + SqlDialect::Sqlite => sqlite::$method($($arg),*), + SqlDialect::Teradata => teradata::$method($($arg),*), + } + }; + ($self:expr, const $item:ident) => { + match $self { + SqlDialect::BigQuery => bigquery::$item, + SqlDialect::Databricks => databricks::$item, + SqlDialect::DuckDb => duckdb::$item, + SqlDialect::Hive => hive::$item, + SqlDialect::MsSql => mssql::$item, + SqlDialect::MySql => mysql::$item, + SqlDialect::Oracle => oracle::$item, + SqlDialect::PostgreSql => postgresql::$item, + SqlDialect::Redshift => redshift::$item, + SqlDialect::Snowflake => snowflake::$item, + SqlDialect::Spark => spark::$item, + SqlDialect::Sqlite => sqlite::$item, + SqlDialect::Teradata => teradata::$item, + } + }; +} + +/// The SQL dialects supported by this crate, selectable at runtime. +/// +/// Each variant carries no data (it's a plain marker); all +/// dialect-specific behavior lives in the corresponding private module and +/// is reached through the methods below. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub enum SqlDialect { + /// [Google BigQuery](https://cloud.google.com/bigquery/) + BigQuery, + /// [Databricks SQL](https://www.databricks.com/) + Databricks, + /// [DuckDB](https://duckdb.org/) + DuckDb, + /// [Apache Hive](https://hive.apache.org/) + Hive, + /// [Microsoft SQL Server](https://www.microsoft.com/en-us/sql-server/) + MsSql, + /// [MySQL](https://www.mysql.com/) + MySql, + /// [Oracle Database](https://docs.oracle.com/en/database/oracle/oracle-database/) + Oracle, + /// [PostgreSQL](https://www.postgresql.org/) + PostgreSql, + /// [Amazon Redshift](https://aws.amazon.com/redshift/) + Redshift, + /// [Snowflake](https://www.snowflake.com/) + Snowflake, + /// [Apache Spark SQL](https://spark.apache.org/docs/latest/sql-ref.html) + Spark, + /// [SQLite](https://www.sqlite.org/) + Sqlite, + /// [Teradata](https://docs.teradata.com/) + Teradata, +} +impl SqlDialect { + /// The dialect's display name (e.g. `"PostgreSQL"`, `"BigQuery"`). + #[inline] + pub fn name(&self) -> &'static str { + dispatch!(self, const NAME) + } + + /// How this dialect renders boolean literals. + #[inline] + pub fn format_boolean( + &self, + writer: &mut T, + value: bool, + ) -> std::fmt::Result { + let format = dispatch!(self, const BOOL_FORMAT); + + match (format, value) { + (BoolFormat::Keyword, true) => write!(writer, "TRUE"), + (BoolFormat::Keyword, false) => write!(writer, "FALSE"), + (BoolFormat::Int, true) => write!(writer, "1"), + (BoolFormat::Int, false) => write!(writer, "0"), + } + } + + /// How this dialect renders boolean literals, returning an owned `String`. + #[inline] + pub fn format_boolean_as_string(&self, value: bool) -> String { + let mut buf = String::new(); + // Writing to a `String` is infallible. + let _ = self.format_boolean(&mut buf, value); + buf + } + + /// Escapes special characters within a identifier. + /// + /// Call this before [`SqlDialect::quote_identifier`]. + #[inline] + pub fn escape_identifier( + &self, + writer: &mut T, + ident: &str, + quote: bool, + ) -> std::fmt::Result { + let format = dispatch!(self, const IDENTIFIER_QUOTE); + + let (start_token, end_token) = if quote { + match format { + Some(IdentifierQuote::SingleQuote) => (Some('\''), Some('\'')), + Some(IdentifierQuote::DoubleQuote) => (Some('"'), Some('"')), + Some(IdentifierQuote::Backtick) => (Some('`'), Some('`')), + Some(IdentifierQuote::SquareBracket) => (Some('['), Some(']')), + None => (None, None), + } + } else { + (None, None) + }; + + if let Some(token) = start_token { + writer.write_char(token)?; + } + dispatch!(self, fn escape_identifier, writer, ident)?; + if let Some(token) = end_token { + writer.write_char(token)?; + } + Ok(()) + } + + /// Escapes special characters within an identifier, returning an owned `String`. + /// + /// Call this before [`SqlDialect::quote_identifier`] / [`SqlDialect::quote_identifier_as_string`]. + #[inline] + pub fn escape_identifier_as_string(&self, ident: &str, quote: bool) -> String { + let mut buf = String::new(); + let _ = self.escape_identifier(&mut buf, ident, quote); + buf + } + + /// The placeholder syntax used for parameterized queries in this dialect. + #[inline] + pub fn placeholder_syntax(&self) -> PlaceholderSyntax { + dispatch!(self, const PLACEHOLDER_SYNTAX) + } + + /// Escapes special characters within a string literal's contents. + /// + /// Call this before [`SqlDialect::quote_string`]. + #[inline] + pub fn escape_string( + &self, + writer: &mut T, + string: &str, + ) -> std::fmt::Result { + dispatch!(self, fn escape_string, writer, string) + } + + /// Escapes special characters within a string literal's contents, + /// returning an owned `String`. + /// + /// Call this before [`SqlDialect::quote_string`] / [`SqlDialect::quote_string_as_string`]. + #[inline] + pub fn escape_string_as_string(&self, string: &str) -> String { + let mut buf = String::new(); + let _ = self.escape_string(&mut buf, string); + buf + } + + /// Wraps an already-escaped string in this dialect's literal quoting. + #[inline] + pub fn quote_string( + &self, + writer: &mut T, + string: &str, + ) -> std::fmt::Result { + dispatch!(self, fn quote_string, writer, string) + } + + /// Wraps an already-escaped string in this dialect's literal quoting, + /// returning an owned `String`. + #[inline] + pub fn quote_string_as_string(&self, string: &str) -> String { + let mut buf = String::new(); + let _ = self.quote_string(&mut buf, string); + buf + } + + /// Renders a byte slice as a literal in this dialect's native form. + #[inline] + pub fn encode_bytes( + &self, + writer: &mut T, + bytes: &[u8], + ) -> std::fmt::Result { + dispatch!(self, fn encode_bytes, writer, bytes) + } + + /// Renders a byte slice as a literal in this dialect's native form, + /// returning an owned `String`. + #[inline] + pub fn encode_bytes_as_string(&self, bytes: &[u8]) -> String { + let mut buf = String::new(); + let _ = self.encode_bytes(&mut buf, bytes); + buf + } + + /// Renders a bit-string (a string of `'0'`/`'1'` characters) as a + /// literal in this dialect's native form. + /// + /// `bits` is validated via `debug_assert!` in each dialect's + /// implementation; callers should ensure it only contains `'0'`/`'1'`. + #[inline] + pub fn encode_bits(&self, writer: &mut T, bits: &str) -> std::fmt::Result { + dispatch!(self, fn encode_bits, writer, bits) + } + + /// Renders a bit-string (a string of `'0'`/`'1'` characters) as a + /// literal in this dialect's native form, returning an owned `String`. + /// + /// `bits` is validated via `debug_assert!` in each dialect's + /// implementation; callers should ensure it only contains `'0'`/`'1'`. + #[inline] + pub fn encode_bits_as_string(&self, bits: &str) -> String { + let mut buf = String::new(); + let _ = self.encode_bits(&mut buf, bits); + buf + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every dialect this crate supports, used to drive table-style tests. + const ALL_DIALECTS: &[SqlDialect] = &[ + SqlDialect::BigQuery, + SqlDialect::Databricks, + SqlDialect::DuckDb, + SqlDialect::Hive, + SqlDialect::MsSql, + SqlDialect::MySql, + SqlDialect::Oracle, + SqlDialect::PostgreSql, + SqlDialect::Redshift, + SqlDialect::Snowflake, + SqlDialect::Spark, + SqlDialect::Sqlite, + SqlDialect::Teradata, + ]; + + /// Dialects whose `escape_string` interprets backslash sequences + /// (`\\`, `\n`, `\t`, ...). These MUST double a literal backslash. + const BACKSLASH_ESCAPING_DIALECTS: &[SqlDialect] = &[ + SqlDialect::BigQuery, + SqlDialect::Databricks, + SqlDialect::Hive, + SqlDialect::MySql, + SqlDialect::PostgreSql, + SqlDialect::Snowflake, + SqlDialect::Spark, + ]; + + /// Dialects that only double single quotes and treat backslash as a + /// completely literal character (per their own doc comments). These MUST + /// pass a literal backslash straight through, unchanged. + const QUOTE_ONLY_DIALECTS: &[SqlDialect] = &[ + SqlDialect::DuckDb, + SqlDialect::MsSql, + SqlDialect::Oracle, + SqlDialect::Redshift, + SqlDialect::Sqlite, + SqlDialect::Teradata, + ]; + + /// Dialects documented as stripping NUL bytes outright (rather than + /// escaping them) because their target string types can't hold NUL. + const NUL_STRIPPING_DIALECTS: &[SqlDialect] = &[ + SqlDialect::MsSql, + SqlDialect::Oracle, + SqlDialect::Redshift, + SqlDialect::Sqlite, + SqlDialect::Teradata, + ]; + + fn sanity_check_coverage() { + // Every dialect must land in exactly one of the two escaping-style + // buckets above, and that bucket assignment must partition ALL_DIALECTS. + assert_eq!( + BACKSLASH_ESCAPING_DIALECTS.len() + QUOTE_ONLY_DIALECTS.len(), + ALL_DIALECTS.len(), + "every dialect must be classified as either backslash-escaping or quote-only" + ); + } + + // --------------------------------------------------------------------- + // 1. Single-quote handling: the one thing EVERY dialect must get right — + // though the *shape* of the escape differs by family: + // - QUOTE_ONLY dialects (ANSI-style) double it: ' -> '' + // - BACKSLASH_ESCAPING dialects (C/Java-style) prefix it: ' -> \' + // --------------------------------------------------------------------- + + #[test] + fn quote_only_dialects_double_a_lone_single_quote() { + sanity_check_coverage(); + for dialect in QUOTE_ONLY_DIALECTS { + let escaped = dialect.escape_string_as_string("'"); + assert_eq!( + escaped, + "''", + "{} did not double a lone single quote (got {escaped:?})", + dialect.name() + ); + } + } + + #[test] + fn backslash_escaping_dialects_prefix_a_lone_single_quote() { + for dialect in BACKSLASH_ESCAPING_DIALECTS { + let escaped = dialect.escape_string_as_string("'"); + assert_eq!( + escaped, + "\\'", + "{} did not backslash-escape a lone single quote (got {escaped:?})", + dialect.name() + ); + } + } + + #[test] + fn quote_only_dialects_double_multiple_single_quotes() { + for dialect in QUOTE_ONLY_DIALECTS.iter() { + let escaped = dialect.escape_string_as_string("''''"); + assert_eq!( + escaped, + "''''''''", + "{} mis-handled 4 consecutive quotes (got {escaped:?})", + dialect.name() + ); + } + } + + #[test] + fn backslash_escaping_dialects_prefix_multiple_single_quotes() { + for dialect in BACKSLASH_ESCAPING_DIALECTS { + let escaped = dialect.escape_string_as_string("''''"); + assert_eq!( + escaped, + "\\'\\'\\'\\'", + "{} mis-handled 4 consecutive quotes (got {escaped:?})", + dialect.name() + ); + } + } + + #[test] + fn every_dialect_neutralizes_the_apostrophe_in_a_classic_name() { + for dialect in ALL_DIALECTS { + let escaped = dialect.escape_string_as_string("O'Brien"); + let uses_backslash = BACKSLASH_ESCAPING_DIALECTS.contains(&dialect); + let expected = if uses_backslash { + "O\\'Brien" + } else { + "O''Brien" + }; + assert_eq!( + escaped, + expected, + "{} did not neutralize the apostrophe in \"O'Brien\" as expected (got {escaped:?})", + dialect.name() + ); + } + } + + // --------------------------------------------------------------------- + // 2. Backslash handling must match each dialect's documented behavior. + // --------------------------------------------------------------------- + + #[test] + fn backslash_escaping_dialects_double_a_lone_backslash() { + for dialect in BACKSLASH_ESCAPING_DIALECTS { + let escaped = dialect.escape_string_as_string("\\"); + assert_eq!( + escaped, + "\\\\", + "{} should double a lone backslash (got {escaped:?})", + dialect.name() + ); + } + } + + #[test] + fn quote_only_dialects_pass_backslash_through_unchanged() { + for dialect in QUOTE_ONLY_DIALECTS.iter() { + let escaped = dialect.escape_string_as_string("\\"); + assert_eq!( + escaped, + "\\", + "{} must treat backslash as a literal, non-special character (got {escaped:?})", + dialect.name() + ); + } + } + + #[test] + fn quote_only_dialects_dont_touch_backslash_next_to_quote() { + // This is the classic footgun: a naive implementation might see `\'` + // and think it needs special handling. These dialects must NOT + // interpret backslash-quote as an escape sequence — the backslash + // stays literal and the quote still gets doubled independently. + for dialect in QUOTE_ONLY_DIALECTS.iter() { + let escaped = dialect.escape_string_as_string("\\'"); + assert_eq!( + escaped, + "\\''", + "{} mis-handled backslash immediately followed by a quote (got {escaped:?})", + dialect.name() + ); + } + } + + #[test] + fn backslash_escaping_dialects_handle_backslash_next_to_quote() { + for dialect in BACKSLASH_ESCAPING_DIALECTS { + let escaped = dialect.escape_string_as_string("\\'"); + assert_eq!( + escaped, + "\\\\\\'", + "{} mis-handled backslash immediately followed by a quote (got {escaped:?})", + dialect.name() + ); + } + } + + // --------------------------------------------------------------------- + // 3. NUL byte handling: strip vs. escape, per dialect contract. + // --------------------------------------------------------------------- + + /// Dialects documented as escaping NUL (rather than stripping or passing + /// it through raw) — i.e. the backslash-escaping family, all of which + /// have an explicit `'\0' => ...` arm. + const NUL_ESCAPING_DIALECTS: &[SqlDialect] = &[ + SqlDialect::BigQuery, + SqlDialect::Databricks, + SqlDialect::Hive, + SqlDialect::MySql, + SqlDialect::PostgreSql, + SqlDialect::Snowflake, + SqlDialect::Spark, + ]; + + #[test] + fn nul_stripping_dialects_remove_nul_entirely() { + for dialect in NUL_STRIPPING_DIALECTS { + let escaped = dialect.escape_string_as_string("a\0b"); + assert_eq!( + escaped, + "ab", + "{} should strip NUL bytes outright (got {escaped:?})", + dialect.name() + ); + assert!( + !escaped.contains('\0'), + "{} left a raw NUL byte in its output", + dialect.name() + ); + } + } + + #[test] + fn nul_escaping_dialects_replace_nul_with_an_escape_sequence() { + for dialect in NUL_ESCAPING_DIALECTS { + let escaped = dialect.escape_string_as_string("a\0b"); + assert!( + !escaped.contains('\0'), + "{} left a raw NUL byte in escaped output (got {escaped:?})", + dialect.name() + ); + assert_ne!( + escaped, + "a\0b", + "{} should have replaced NUL with an escape sequence, not left it untouched", + dialect.name() + ); + } + } + + #[test] + fn duckdb_documents_nul_as_a_regular_passthrough_character() { + // DuckDB VARCHAR is UTF-8 and its docs don't call out NUL as + // unsupported, unlike MsSql/Oracle/Redshift/Sqlite/Teradata, which + // explicitly strip it. This test exists to make that asymmetry + // explicit and catch it if DuckDB's contract ever changes. + let escaped = SqlDialect::DuckDb.escape_string_as_string("a\0b"); + assert_eq!(escaped, "a\0b"); + } + + // --------------------------------------------------------------------- + // 4. Control characters (newline, tab, CR, and other ASCII control codes). + // --------------------------------------------------------------------- + + #[test] + fn backslash_escaping_dialects_never_emit_a_raw_newline_or_cr() { + // \n and \r get an explicit backslash-escape arm in every + // backslash-escaping dialect's implementation. + for dialect in BACKSLASH_ESCAPING_DIALECTS { + let escaped = dialect.escape_string_as_string("a\nb\rc"); + assert!( + !escaped.contains('\n') && !escaped.contains('\r'), + "{} left a raw \\n or \\r in escaped output (got {escaped:?})", + dialect.name() + ); + } + } + + #[test] + fn most_backslash_escaping_dialects_also_escape_tab() { + // MySQL's implementation intentionally has no explicit tab arm (a + // literal tab is not a syntax hazard inside a MySQL string literal, + // unlike a quote or backslash) so it's excluded here. Every other + // backslash-escaping dialect does translate \t -> \\t. + let tab_escaping: Vec = BACKSLASH_ESCAPING_DIALECTS + .iter() + .cloned() + .filter(|d| *d != SqlDialect::MySql) + .collect(); + + for dialect in tab_escaping { + let escaped = dialect.escape_string_as_string("a\tb"); + assert!( + !escaped.contains('\t'), + "{} left a raw tab in escaped output (got {escaped:?})", + dialect.name() + ); + } + } + + #[test] + fn mysql_passes_a_raw_tab_through_unchanged() { + // Documents MySQL's actual (intentional) behavior explicitly, so a + // future change is a deliberate decision rather than a silent drift. + let escaped = SqlDialect::MySql.escape_string_as_string("a\tb"); + assert_eq!(escaped, "a\tb"); + } + + #[test] + fn quote_only_dialects_pass_newline_cr_tab_through_unchanged() { + // These dialects only double single quotes; per their own doc + // comments they don't treat backslash (or \n/\r/\t) as special. + // A raw newline etc. inside '...' is syntactically valid in these + // engines and is NOT an injection risk on its own — only the quote + // character matters for breaking out of the literal. + for dialect in QUOTE_ONLY_DIALECTS.iter() { + let escaped = dialect.escape_string_as_string("a\nb\rc\td"); + assert_eq!( + escaped, + "a\nb\rc\td", + "{} was expected to pass control chars through unchanged (got {escaped:?})", + dialect.name() + ); + } + } + + #[test] + fn every_dialect_neutralizes_low_ascii_control_bytes() { + // 0x01..=0x1F (excluding \n=0x0A, \r=0x0D, \t=0x09 already covered) + // and 0x7F (DEL) are the classic "invisible injection" characters. + // At minimum, no dialect should pass them through raw next to + // unescaped quotes in a way that changes the quote count. + for dialect in ALL_DIALECTS { + for byte in [0x01u8, 0x07, 0x1b, 0x1f, 0x7f] { + let ch = byte as char; + let input = format!("x{ch}y"); + let escaped = dialect.escape_string_as_string(&input); + // The escape function must not panic and must not introduce + // an unbalanced quote by accident. + assert_eq!( + escaped.matches('\'').count() % 2, + 0, + "{} produced an odd number of quotes for control byte 0x{byte:02X} (got \ + {escaped:?})", + dialect.name() + ); + } + } + } + + // --------------------------------------------------------------------- + // 5. End-to-end injection safety: escape_string + quote_string together + // must never let attacker input terminate the string literal early. + // --------------------------------------------------------------------- + + /// A grab-bag of classic SQL-injection-style payloads. + const HOSTILE_INPUTS: &[&str] = &[ + "", + "'", + "''", + "'; DROP TABLE users; --", + "' OR '1'='1", + "\\", + "\\'", + "\\\\", + "\\'; DROP TABLE users; --", + "a\0b", + "\n\r\t", + "'\0'", + "\\\0'", + "šŸ˜€'; DROP TABLE users;--šŸ˜€", + "back\\slash'quote\"doublequote", + ]; + + /// For a single-quoted-literal dialect, walks the fully escaped+quoted + /// output character by character (respecting the dialect's own escape + /// convention) and confirms the literal's closing quote is genuinely the + /// last unescaped quote — i.e. nothing in the payload could prematurely + /// close the string. + /// + /// Operates entirely in `char` space (never byte-slicing a `&str`) so + /// multi-byte UTF-8 content (e.g. emoji) can't desync index arithmetic. + fn assert_no_premature_close(dialect: &SqlDialect, quoted: &str, uses_backslash_escapes: bool) { + let chars: Vec = quoted.chars().collect(); + + let first_quote_idx = chars.iter().position(|&c| c == '\'').unwrap_or_else(|| { + panic!( + "{}: quoted output has no opening quote: {quoted:?}", + dialect.name() + ) + }); + + let body = &chars[first_quote_idx + 1..]; + let mut i = 0; + let mut closed_at = None; + while i < body.len() { + let c = body[i]; + if uses_backslash_escapes && c == '\\' { + // Skip the escaped character (backslash always consumes the + // next char in these dialects' escape schemes). + i += 2; + continue; + } + if c == '\'' { + // A doubled quote (''), representing one literal quote, + // does NOT close the string — only a single trailing quote + // with no partner does. + if body.get(i + 1) == Some(&'\'') { + i += 2; + continue; + } + closed_at = Some(i); + break; + } + i += 1; + } + + let closed_at = closed_at.unwrap_or_else(|| { + panic!( + "{}: could not find a closing quote at all in {quoted:?}", + dialect.name() + ) + }); + + // The closing quote we found must be the LAST character of the + // string (i.e. nothing meaningful follows it) — otherwise the + // payload managed to close the literal early and inject trailing SQL. + let remainder: String = body[closed_at + 1..].iter().collect(); + assert!( + remainder.is_empty(), + "{}: literal closed early, leaving trailing unescaped content {remainder:?} in \ + {quoted:?}", + dialect.name() + ); + } + + #[test] + fn no_hostile_input_can_break_out_of_the_string_literal() { + for dialect in ALL_DIALECTS { + let uses_backslash = BACKSLASH_ESCAPING_DIALECTS.contains(&dialect); + for payload in HOSTILE_INPUTS { + let escaped = dialect.escape_string_as_string(payload); + let quoted = dialect.quote_string_as_string(&escaped); + assert_no_premature_close(dialect, "ed, uses_backslash); + } + } + } + + #[test] + fn no_hostile_input_ever_panics_any_dialect_function() { + // A panic (e.g. from an unwrap on malformed UTF-8 boundary slicing) + // is itself a correctness bug worth catching explicitly. + for dialect in ALL_DIALECTS { + for payload in HOSTILE_INPUTS { + let escaped = dialect.escape_string_as_string(payload); + let _ = dialect.quote_string_as_string(&escaped); + } + } + } + + // --------------------------------------------------------------------- + // 6. Unicode correctness: multi-byte scalars must survive untouched. + // --------------------------------------------------------------------- + + #[test] + fn unicode_content_passes_through_unmangled() { + let samples = ["hĆ©llo", "ę—„ęœ¬čŖž", "emojišŸ˜€šŸŽ‰", "ƑoƱo", "Straße"]; + for dialect in ALL_DIALECTS { + for sample in samples { + let escaped = dialect.escape_string_as_string(sample); + assert_eq!( + escaped, + sample, + "{} corrupted unicode input {sample:?} (got {escaped:?})", + dialect.name() + ); + // Must still be valid UTF-8 after quoting (String guarantees + // this, but we assert to document the expectation). + let quoted = dialect.quote_string_as_string(&escaped); + assert!(quoted.contains(sample)); + } + } + } + + #[test] + fn unicode_mixed_with_quotes_still_escapes_correctly() { + for dialect in ALL_DIALECTS { + let escaped = dialect.escape_string_as_string("ę—„ęœ¬čŖž'quote'šŸ˜€"); + let uses_backslash = BACKSLASH_ESCAPING_DIALECTS.contains(&dialect); + let quote_marker_count = if uses_backslash { + escaped.matches("\\'").count() + } else { + escaped.matches("''").count() + }; + assert_eq!( + quote_marker_count, + 2, + "{}: expected both quotes in the unicode string to be escaped (got {escaped:?})", + dialect.name() + ); + assert!(escaped.contains("ę—„ęœ¬čŖž")); + assert!(escaped.contains('šŸ˜€')); + } + } + + // --------------------------------------------------------------------- + // 7. Empty-input edge cases. + // --------------------------------------------------------------------- + + #[test] + fn empty_string_escapes_to_empty_string() { + for dialect in ALL_DIALECTS { + assert_eq!(dialect.escape_string_as_string(""), ""); + } + } + + #[test] + fn empty_string_quoted_is_just_the_delimiters() { + for dialect in ALL_DIALECTS { + let quoted = dialect.quote_string_as_string(""); + // Must contain exactly 2 single quotes and nothing else meaningful + // (allowing for a dialect-specific prefix like `E` or `N`). + assert_eq!( + quoted.matches('\'').count(), + 2, + "{}: empty quoted string should have exactly one open/close pair (got {quoted:?})", + dialect.name() + ); + } + } + + // --------------------------------------------------------------------- + // 8. encode_bytes: hex correctness, case, empty/boundary input. + // --------------------------------------------------------------------- + + #[test] + fn encode_bytes_empty_slice_does_not_panic_and_contains_no_hex_pairs() { + for dialect in ALL_DIALECTS { + let out = dialect.encode_bytes_as_string(&[]); + // No stray "00" or similar should appear from an off-by-one bug. + assert!( + !out.is_empty(), + "{}: encode_bytes(&[]) produced an empty string, expected at least wrapper syntax", + dialect.name() + ); + } + } + + #[test] + fn encode_bytes_uses_uppercase_hex_and_round_trips_all_byte_values() { + let bytes: Vec = (0u8..=255).collect(); + + // BigQuery renders each byte as its own \xNN escape (not a + // contiguous hex run via the shared bytes_to_hex() helper), so it's + // checked separately below. + let plain_hex_run_dialects: Vec = ALL_DIALECTS + .iter() + .cloned() + .filter(|d| *d != SqlDialect::BigQuery) + .collect(); + + // The uppercase hex encoding of 0x00..=0xFF, in order — this is + // exactly what bytes_to_hex() must produce, byte for byte. Checking + // for this literal substring (rather than scanning all hex-looking + // characters in the output) avoids false positives from incidental + // lowercase letters in surrounding syntax like "bytea" or "'hex'". + let expected_hex_run: String = bytes.iter().map(|b| format!("{:02X}", b)).collect(); + + for dialect in plain_hex_run_dialects { + let out = dialect.encode_bytes_as_string(&bytes); + assert!( + out.contains(&expected_hex_run), + "{}: encode_bytes did not contain the expected uppercase hex run for all 256 byte \ + values (got {out:?})", + dialect.name() + ); + } + + // BigQuery: each byte must appear as its own \xNN, in order, with + // uppercase hex digits. + let expected_bigquery: String = bytes.iter().map(|b| format!("\\x{:02X}", b)).collect(); + let bigquery_out = SqlDialect::BigQuery.encode_bytes_as_string(&bytes); + assert!( + bigquery_out.contains(&expected_bigquery), + "BigQuery: encode_bytes did not contain the expected \\xNN-per-byte run (got \ + {bigquery_out:?})" + ); + } + + #[test] + fn encode_bytes_single_byte_zero_and_single_byte_max() { + for dialect in ALL_DIALECTS { + let zero = dialect.encode_bytes_as_string(&[0x00]); + let max = dialect.encode_bytes_as_string(&[0xFF]); + assert!(zero.contains("00"), "{}: {zero:?}", dialect.name()); + assert!(max.contains("FF"), "{}: {max:?}", dialect.name()); + assert_ne!(zero, max); + } + } + + // --------------------------------------------------------------------- + // 9. encode_bits: boundary lengths, all-zero, all-one, longer-than-i64. + // --------------------------------------------------------------------- + + #[test] + fn encode_bits_all_zero_and_all_one_single_bit() { + for dialect in ALL_DIALECTS { + let zero = dialect.encode_bits_as_string("0"); + let one = dialect.encode_bits_as_string("1"); + assert_ne!( + zero, + one, + "{}: encode_bits(\"0\") and encode_bits(\"1\") must differ", + dialect.name() + ); + } + } + + #[test] + fn encode_bits_does_not_panic_on_64_bit_boundary() { + // Exactly 64 ones is the classic off-by-one boundary for + // i64::from_str_radix-based implementations. + let sixty_four_ones = "1".repeat(64); + for dialect in ALL_DIALECTS { + let _ = dialect.encode_bits_as_string(&sixty_four_ones); + } + } + + #[test] + fn encode_bits_does_not_panic_on_overflow_longer_than_i64() { + // More than 64 bits overflows i64::from_str_radix; dialects that + // parse via i64 fall back to 0 on error rather than panicking. This + // test locks in "no panic" as the contract, regardless of the + // fallback value chosen. + let too_many_ones = "1".repeat(100); + for dialect in ALL_DIALECTS { + let _ = dialect.encode_bits_as_string(&too_many_ones); + } + } + + #[test] + fn encode_bits_empty_string_does_not_panic() { + for dialect in ALL_DIALECTS { + let _ = dialect.encode_bits_as_string(""); + } + } + + #[test] + fn encode_bits_known_value_is_consistent_across_dialects_that_parse_as_integer() { + // "10110101" == 181 decimal. Every dialect that renders this via an + // integer cast should have "181" appear somewhere in its output. + let bits = "10110101"; + let integer_based = [ + SqlDialect::BigQuery, // CAST(0b... AS INT64) -- uses 0b, not decimal + SqlDialect::Databricks, // CAST(...L AS BINARY) -- decimal + SqlDialect::Hive, // CAST(... AS BIGINT) -- decimal + SqlDialect::MsSql, // CAST(... AS BIGINT) -- decimal + SqlDialect::Oracle, // bare decimal + SqlDialect::Redshift, // CAST(... AS BIGINT) -- decimal + SqlDialect::Snowflake, // TO_HEX(...) -- decimal appears inside TO_HEX( + SqlDialect::Spark, // CAST(... AS BIGINT) -- decimal + SqlDialect::Sqlite, // bare decimal + ]; + for dialect in &integer_based { + let out = dialect.encode_bits_as_string(bits); + if *dialect == SqlDialect::BigQuery { + assert!( + out.contains("0b10110101"), + "{}: expected binary literal form (got {out:?})", + dialect.name() + ); + } else { + assert!( + out.contains("181"), + "{}: expected decimal value 181 to appear in output (got {out:?})", + dialect.name() + ); + } + } + } + + #[test] + fn encode_bits_literal_style_dialects_preserve_the_bit_string_verbatim() { + // Dialects with a native bit-string literal syntax should embed the + // exact input bits, not a numeric conversion. + let bits = "10110101"; + assert_eq!( + SqlDialect::PostgreSql.encode_bits_as_string(bits), + "B'10110101'" + ); + assert_eq!( + SqlDialect::DuckDb.encode_bits_as_string(bits), + "B'10110101'" + ); + assert_eq!(SqlDialect::MySql.encode_bits_as_string(bits), "b'10110101'"); + assert_eq!( + SqlDialect::Teradata.encode_bits_as_string(bits), + "'10110101'B" + ); + } + + // --------------------------------------------------------------------- + // 10. quote_string wrapping sanity: dialect-specific prefixes/suffixes. + // --------------------------------------------------------------------- + + #[test] + fn quote_string_wraps_with_expected_prefix_per_dialect() { + let escaped = "abc"; + assert!( + SqlDialect::PostgreSql + .quote_string_as_string(escaped) + .starts_with("E'") + ); + assert!( + SqlDialect::MsSql + .quote_string_as_string(escaped) + .starts_with("N'") + ); + // Plain single-quote wrapping dialects: no special prefix letter. + for dialect in &[ + SqlDialect::BigQuery, + SqlDialect::MySql, + SqlDialect::Sqlite, + SqlDialect::Snowflake, + SqlDialect::Oracle, + SqlDialect::Redshift, + SqlDialect::Teradata, + SqlDialect::Spark, + SqlDialect::Hive, + SqlDialect::Databricks, + SqlDialect::DuckDb, + ] { + let quoted = dialect.quote_string_as_string(escaped); + assert!( + quoted.starts_with('\''), + "{}: expected plain single-quote wrapping (got {quoted:?})", + dialect.name() + ); + } + } + + #[test] + fn quote_string_always_ends_with_a_single_quote() { + for dialect in ALL_DIALECTS { + let quoted = dialect.quote_string_as_string("abc"); + assert!( + quoted.ends_with('\''), + "{}: quoted string should end with a closing quote (got {quoted:?})", + dialect.name() + ); + } + } +} diff --git a/src/native/dialect/mssql.rs b/src/native/dialect/mssql.rs new file mode 100644 index 0000000..f383f39 --- /dev/null +++ b/src/native/dialect/mssql.rs @@ -0,0 +1,62 @@ +//! A dialect for [Microsoft SQL Server](https://www.microsoft.com/en-us/sql-server/). +//! +//! • Strings: single-quoted; `''` for embedded single-quote. +//! Unicode strings prefixed with N'…'. +//! • Identifiers: square-bracket quoted: [name]. +//! • Bytes: 0xDEADBEEF (no quotes). +//! • Bits: Cast integer to BIT; MSSQL BIT is boolean-like (0/1). +//! • Bool: 1 / 0 (BIT type; no TRUE/FALSE keyword). +//! +//! Ref: + +use crate::internal::utils::{replace_ascii_to, replace_to}; + +use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; + +pub(super) const NAME: &str = "MsSql"; + +pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Int; + +pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::SquareBracket); + +pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::AtSign; + +pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { + // T-SQL: only single-quote needs doubling. + // NUL character (U+0000) is not allowed in T-SQL string literals. + // We remove it to stay safe. + const REPLACEMENTS: [(u8, &str); 2] = [(b'\'', "''"), (b'\0', "")]; + replace_ascii_to(string, &REPLACEMENTS, writer) +} + +pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { + // Prefix with N for Unicode safety (NVARCHAR) + write!(writer, "N'{string}'") +} + +pub(super) fn escape_identifier( + writer: &mut T, + ident: &str, +) -> std::fmt::Result { + replace_to(ident, ']', "]]", writer) +} + +pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { + // T-SQL binary literal: 0xDEADBEEF + writer.write_str("0x")?; + super::bytes_to_hex(writer, bytes) +} + +pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { + debug_assert!( + bits.chars().all(|c| c == '0' || c == '1'), + "encode_bits: invalid input: {bits}" + ); + + // MSSQL BIT is a 1-bit integer; for multi-bit we cast to integer + write!( + writer, + "CAST({} AS BIGINT)", + i64::from_str_radix(bits, 2).unwrap_or(0) + ) +} diff --git a/src/native/dialect/mysql.rs b/src/native/dialect/mysql.rs new file mode 100644 index 0000000..cda59fb --- /dev/null +++ b/src/native/dialect/mysql.rs @@ -0,0 +1,66 @@ +//! A dialect for [MySQL](https://www.mysql.com/). +//! +//! • Strings: single-quoted; backslash-escape sequences. +//! • Identifiers: back-tick quoted. +//! • Bytes: X'DEADBEEF' or 0xDEADBEEF. +//! • Bits: b'10110101' bit literal. +//! • Bool: 1 / 0 (TINYINT; no native BOOLEAN literal). +//! +//! Ref: +//! + +use crate::internal::utils::replace_to; + +use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; + +pub(super) const NAME: &str = "MySql"; + +pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Int; + +pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::Backtick); + +/// This is not natively supported, and should be rendered on client-side, and many clients do. +pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; + +pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { + for ch in string.chars() { + match ch { + '\\' => writer.write_str("\\\\")?, + '\'' => writer.write_str("\\'")?, + '"' => writer.write_str("\\\"")?, + '\0' => writer.write_str("\\0")?, + '\n' => writer.write_str("\\n")?, + '\r' => writer.write_str("\\r")?, + '\x1a' => writer.write_str("\\Z")?, // Ctrl+Z / SUB (Windows EOF) + c => writer.write_char(c)?, + } + } + Ok(()) +} + +pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { + super::wrap_single_quote(writer, string) +} + +pub(super) fn escape_identifier( + writer: &mut T, + ident: &str, +) -> std::fmt::Result { + replace_to(ident, '`', "``", writer) +} + +pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { + // write!(writer, "X'{}'", super::bytes_to_hex(writer, bytes)) + writer.write_str("X'")?; + super::bytes_to_hex(writer, bytes)?; + writer.write_char('\'') +} + +pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { + debug_assert!( + bits.chars().all(|c| c == '0' || c == '1'), + "encode_bits: invalid input: {bits}" + ); + + write!(writer, "b'{}'", bits) +} diff --git a/src/native/dialect/oracle.rs b/src/native/dialect/oracle.rs new file mode 100644 index 0000000..4af802f --- /dev/null +++ b/src/native/dialect/oracle.rs @@ -0,0 +1,56 @@ +//! A dialect for [Oracle Databases](https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/index.html). +//! +//! Ref: +//! +//! • Strings: single-quoted; `''` for embedded single-quote. +//! Alternatively q'[…]' alternative quoting. +//! • Identifiers: double-quote quoted. +//! • Bytes: HEXTORAW('DEADBEEF'). +//! • Bits: No native bit literal; use integer value. +//! • Bool: Oracle < 23c has no BOOLEAN in SQL; 23c onwards has TRUE/FALSE. +//! We default to 1/0 for broadest compatibility. + +use crate::internal::utils::{replace_ascii_to, replace_to}; + +use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; + +pub(super) const NAME: &str = "Oracle"; + +pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Int; + +pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::DoubleQuote); + +pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; + +pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { + // Oracle: only single-quote doubling; no backslash escaping. + // NUL bytes are not supported in VARCHAR2 literals. + const REPLACEMENTS: [(u8, &str); 2] = [(b'\'', "''"), (b'\0', "")]; + replace_ascii_to(string, &REPLACEMENTS, writer) +} + +pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { + super::wrap_single_quote(writer, string) +} + +pub(super) fn escape_identifier( + writer: &mut T, + ident: &str, +) -> std::fmt::Result { + replace_to(ident, '"', "\"\"", writer) +} + +pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { + writer.write_str("HEXTORAW('")?; + super::bytes_to_hex(writer, bytes)?; + writer.write_str("')") +} + +pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { + debug_assert!( + bits.chars().all(|c| c == '0' || c == '1'), + "encode_bits: invalid input: {bits}" + ); + + write!(writer, "{}", i64::from_str_radix(bits, 2).unwrap_or(0)) +} diff --git a/src/native/dialect/postgresql.rs b/src/native/dialect/postgresql.rs new file mode 100644 index 0000000..5a732bc --- /dev/null +++ b/src/native/dialect/postgresql.rs @@ -0,0 +1,73 @@ +//! A dialect for [PostgreSQL](https://www.postgresql.org/). +//! +//! • Strings: single-quoted with `''` doubling. +//! For control chars we use E'…' escape strings with \xNN. +//! • Identifiers: double-quote quoted. +//! • Bytes: '\xDEADBEEF'::bytea (hex format, PostgreSQL 9.0+). +//! • Bits: B'10110101' bit string literal. +//! • Bool: TRUE / FALSE keywords. +//! +//! Ref: +//! +//! + +use crate::internal::utils::replace_to; + +use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; + +pub(super) const NAME: &str = "PostgreSql"; + +pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; + +pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::DoubleQuote); + +/// This is not natively supported, and should be rendered on client-side, and many clients do. +pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; + +pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { + // Use PostgreSQL escape-string syntax (E'…') when control chars present. + // We always emit E'…' for safety; non-special chars pass through. + for ch in string.chars() { + match ch { + '\\' => writer.write_str("\\\\")?, + '\'' => writer.write_str("\\'")?, + '\n' => writer.write_str("\\n")?, + '\r' => writer.write_str("\\r")?, + '\t' => writer.write_str("\\t")?, + '\0' => writer.write_str("\\x00")?, + c if (c as u32) < 32 || c as u32 == 127 => { + writer.write_str(&format!("\\x{:02X}", c as u32))?; + } + c => writer.write_char(c)?, + } + } + Ok(()) +} + +pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { + // Use E'…' escape string syntax so our \-sequences are interpreted. + write!(writer, "E'{string}'") +} + +pub(super) fn escape_identifier( + writer: &mut T, + ident: &str, +) -> std::fmt::Result { + replace_to(ident, '"', "\"\"", writer) +} + +pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { + // PostgreSQL hex-format bytea literal + writer.write_str("'\\x")?; + super::bytes_to_hex(writer, bytes)?; + writer.write_str("'::bytea") +} + +pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { + debug_assert!( + bits.chars().all(|c| c == '0' || c == '1'), + "encode_bits: invalid input: {bits}" + ); + + write!(writer, "B'{}'", bits) +} diff --git a/src/native/dialect/redshift.rs b/src/native/dialect/redshift.rs new file mode 100644 index 0000000..351fe45 --- /dev/null +++ b/src/native/dialect/redshift.rs @@ -0,0 +1,63 @@ +//! A dialect for [RedShift](https://aws.amazon.com/redshift/). +//! +//! • Strings: single-quoted; `''` for embedded single-quote. +//! Redshift is PostgreSQL-derived but does NOT support E'…' by default. +//! Backslash in default mode is literal (not escape); so we double quotes only. +//! • Identifiers: double-quote quoted. +//! • Bytes: VARBYTE from hex via FROM_HEX or TO_VARBYTE. +//! • Bits: No native bit literal; cast integer. +//! • Bool: TRUE / FALSE keywords (BOOLEAN type). +//! +//! Ref: +//! + +use crate::internal::utils::{replace_ascii_to, replace_to}; + +use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; + +pub(super) const NAME: &str = "Redshift"; + +pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; + +pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::DoubleQuote); + +/// This is not natively supported, and should be rendered on client-side. +pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; + +pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { + // Redshift: backslash is NOT an escape character in standard mode. + // Only `'` needs doubling. + // NUL (U+0000) is not supported in VARCHAR literals. + const REPLACEMENTS: [(u8, &str); 2] = [(b'\'', "''"), (b'\0', "")]; + replace_ascii_to(string, &REPLACEMENTS, writer) +} + +pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { + super::wrap_single_quote(writer, string) +} + +pub(super) fn escape_identifier( + writer: &mut T, + ident: &str, +) -> std::fmt::Result { + replace_to(ident, '"', "\"\"", writer) +} + +pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { + writer.write_str("TO_VARBYTE('")?; + super::bytes_to_hex(writer, bytes)?; + writer.write_str("', 'hex')") +} + +pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { + debug_assert!( + bits.chars().all(|c| c == '0' || c == '1'), + "encode_bits: invalid input: {bits}" + ); + + write!( + writer, + "CAST({} AS BIGINT)", + i64::from_str_radix(bits, 2).unwrap_or(0) + ) +} diff --git a/src/native/dialect/snowflake.rs b/src/native/dialect/snowflake.rs new file mode 100644 index 0000000..c291b63 --- /dev/null +++ b/src/native/dialect/snowflake.rs @@ -0,0 +1,71 @@ +//! A dialect for [Snowflake](https://www.snowflake.com/). +//! +//! • Strings: single-quoted; backslash-escape sequences. +//! • Identifiers: double-quote quoted. +//! • Bytes: X'DEADBEEF' hex literal (BINARY type). +//! • Bits: No native bit literal; use TO_BINARY with integer. +//! • Bool: TRUE / FALSE keywords. +//! +//! Ref: +//! + +use crate::internal::utils::replace_to; + +use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; + +pub(super) const NAME: &str = "Snowflake"; + +pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; + +pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::DoubleQuote); + +pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; + +pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { + for ch in string.chars() { + match ch { + '\\' => writer.write_str("\\\\")?, + '\'' => writer.write_str("\\'")?, + '\n' => writer.write_str("\\n")?, + '\r' => writer.write_str("\\r")?, + '\t' => writer.write_str("\\t")?, + '\0' => writer.write_str("\\0")?, + c if (c as u32) < 32 || c as u32 == 127 => { + writer.write_str(&format!("\\x{:02X}", c as u32))?; + } + c => writer.write_char(c)?, + } + } + Ok(()) +} + +pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { + super::wrap_single_quote(writer, string) +} + +pub(super) fn escape_identifier( + writer: &mut T, + ident: &str, +) -> std::fmt::Result { + replace_to(ident, '"', "\"\"", writer) +} + +pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { + // write!(writer, "X'{}'", super::bytes_to_hex(writer, bytes)) + writer.write_str("X'")?; + super::bytes_to_hex(writer, bytes)?; + writer.write_char('\'') +} + +pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { + debug_assert!( + bits.chars().all(|c| c == '0' || c == '1'), + "encode_bits: invalid input: {bits}" + ); + + write!( + writer, + "TO_BINARY(LPAD(TO_HEX({}), 16, '0'), 'HEX')", + i64::from_str_radix(bits, 2).unwrap_or(0) + ) +} diff --git a/src/native/dialect/spark.rs b/src/native/dialect/spark.rs new file mode 100644 index 0000000..ed20f79 --- /dev/null +++ b/src/native/dialect/spark.rs @@ -0,0 +1,73 @@ +//! A dialect for [Apache Spark SQL](https://spark.apache.org/docs/latest/sql-ref.html). +//! +//! See . +//! +//! • Strings: single-quoted; backslash-escape sequences (Java/Scala conventions). +//! • Identifiers: back-tick quoted. +//! • Bytes: X'DEADBEEF' hex literal. +//! • Bits: No native bit literal; cast integer. +//! • Bool: TRUE / FALSE keywords. +//! +//! Ref: + +use crate::internal::utils::replace_to; + +use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; + +pub(super) const NAME: &str = "Spark"; + +pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; + +pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::Backtick); + +pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; + +pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { + for ch in string.chars() { + match ch { + '\\' => writer.write_str("\\\\")?, + '\'' => writer.write_str("\\'")?, + '"' => writer.write_str("\\\"")?, + '\n' => writer.write_str("\\n")?, + '\r' => writer.write_str("\\r")?, + '\t' => writer.write_str("\\t")?, + '\0' => writer.write_str("\\0")?, + c if (c as u32) < 32 || c as u32 == 127 => { + writer.write_str(&format!("\\u{:04X}", c as u32))?; + } + c => writer.write_char(c)?, + } + } + Ok(()) +} + +pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { + super::wrap_single_quote(writer, string) +} + +pub(super) fn escape_identifier( + writer: &mut T, + ident: &str, +) -> std::fmt::Result { + replace_to(ident, '`', "``", writer) +} + +pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { + // write!(writer, "X'{}'", super::bytes_to_hex(writer, bytes)) + writer.write_str("X'")?; + super::bytes_to_hex(writer, bytes)?; + writer.write_char('\'') +} + +pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { + debug_assert!( + bits.chars().all(|c| c == '0' || c == '1'), + "encode_bits: invalid input: {bits}" + ); + + write!( + writer, + "CAST({} AS BIGINT)", + i64::from_str_radix(bits, 2).unwrap_or(0) + ) +} diff --git a/src/native/dialect/sqlite.rs b/src/native/dialect/sqlite.rs new file mode 100644 index 0000000..3834126 --- /dev/null +++ b/src/native/dialect/sqlite.rs @@ -0,0 +1,57 @@ +//! A dialect for [SQLite](https://www.sqlite.org). +//! +//! • Strings: single-quoted; `''` for embedded single-quote. +//! SQLite does NOT interpret backslash escapes in string literals. +//! • Identifiers: double-quote quoted (also supports back-tick and […]). +//! • Bytes: X'DEADBEEF' BLOB hex literal. +//! • Bits: No native bit literal; use integer value. +//! • Bool: 1 / 0 (no native BOOLEAN type). +//! +//! Ref: + +use crate::internal::utils::{replace_ascii_to, replace_to}; + +use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; + +pub(super) const NAME: &str = "Sqlite"; + +pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Int; + +pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::DoubleQuote); + +pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; + +pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { + // SQLite does not recognise backslash escape sequences. + // NUL character (U+0000) terminates the string in the C library; + // we remove it to be safe. + const REPLACEMENTS: [(u8, &str); 2] = [(b'\'', "''"), (b'\0', "")]; + replace_ascii_to(string, &REPLACEMENTS, writer) +} + +pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { + super::wrap_single_quote(writer, string) +} + +pub(super) fn escape_identifier( + writer: &mut T, + ident: &str, +) -> std::fmt::Result { + replace_to(ident, '"', "\"\"", writer) +} + +pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { + // write!(writer, "X'{}'", super::bytes_to_hex(writer, bytes)) + writer.write_str("X'")?; + super::bytes_to_hex(writer, bytes)?; + writer.write_char('\'') +} + +pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { + debug_assert!( + bits.chars().all(|c| c == '0' || c == '1'), + "encode_bits: invalid input: {bits}" + ); + + write!(writer, "{}", i64::from_str_radix(bits, 2).unwrap_or(0)) +} diff --git a/src/native/dialect/teradata.rs b/src/native/dialect/teradata.rs new file mode 100644 index 0000000..ad141e7 --- /dev/null +++ b/src/native/dialect/teradata.rs @@ -0,0 +1,59 @@ +//! A Dialect for [Teradata](https://docs.teradata.com/). +//! +//! • Strings: single-quoted; `''` for embedded single-quote. +//! Teradata supports escape sequences only in named character sets; +//! safest approach is quote-doubling only. +//! • Identifiers: double-quote quoted. +//! • Bytes: '…'XB byte literal (hex digits in single quotes with XB suffix). +//! OR '…'XBV for VARBYTE. +//! • Bits: '10110101'B bit literal. +//! • Bool: TRUE / FALSE (Teradata 13.x+ with BYTEINT or BOOLEAN). +//! +//! Ref: + +use crate::internal::utils::{replace_ascii_to, replace_to}; + +use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; + +pub(super) const NAME: &str = "Teradata"; + +pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; + +pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::SingleQuote); + +/// This is not natively supported, and should be rendered on client-side. +pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; + +pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { + // Teradata: only single-quote doubling; no backslash interpretation. + // NUL bytes are not supported. + const REPLACEMENTS: [(u8, &str); 2] = [(b'\'', "''"), (b'\0', "")]; + replace_ascii_to(string, &REPLACEMENTS, writer) +} + +pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { + super::wrap_single_quote(writer, string) +} + +pub(super) fn escape_identifier( + writer: &mut T, + ident: &str, +) -> std::fmt::Result { + replace_to(ident, '\'', "''", writer) +} + +pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { + // Teradata byte literal: ''XBV + writer.write_str("'")?; + super::bytes_to_hex(writer, bytes)?; + writer.write_str("'XBV") +} + +pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { + debug_assert!( + bits.chars().all(|c| c == '0' || c == '1'), + "encode_bits: invalid input: {bits}" + ); + + write!(writer, "'{}'B", bits) +} diff --git a/src/native/mod.rs b/src/native/mod.rs new file mode 100644 index 0000000..85bed51 --- /dev/null +++ b/src/native/mod.rs @@ -0,0 +1,2 @@ +pub mod dialect; +pub mod prepare_statement; diff --git a/src/native/prepare_statement.rs b/src/native/prepare_statement.rs new file mode 100644 index 0000000..7209375 --- /dev/null +++ b/src/native/prepare_statement.rs @@ -0,0 +1,212 @@ +use std::fmt; + +use hashbrown::HashMap; + +use crate::{ + internal::alias, + native::dialect::{PlaceholderSyntax, SqlDialect}, +}; + +/// Error produced while building a prepared statement. +#[derive(Debug)] +pub enum PrepareError { + /// A [`fmt::Write`] implementation returned [`fmt::Error`]. + Write, + /// An error that originated in Python (e.g. from `.extract()`). + Python(pyo3::PyErr), +} + +impl fmt::Display for PrepareError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Write => f.write_str("failed to write SQL"), + Self::Python(err) => write!(f, "{err}"), + } + } +} + +impl std::error::Error for PrepareError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Write => None, + Self::Python(err) => Some(err), + } + } +} + +impl From for PrepareError { + fn from(_: fmt::Error) -> Self { + Self::Write + } +} + +impl From for PrepareError { + fn from(err: pyo3::PyErr) -> Self { + Self::Python(err) + } +} + +pub type PrepareResult = Result; +pub type PrepareParams = HashMap; + +/// Sigil written before a named placeholder (`@param_0`, `:param_0`, `$param_0`). +#[inline] +fn placeholder_sigil(syntax: PlaceholderSyntax) -> char { + match syntax { + PlaceholderSyntax::AtSign => '@', + PlaceholderSyntax::Colon => ':', + PlaceholderSyntax::DollarNamed => '$', + } +} + +/// Accumulates rendered SQL and the bound-parameter map. +/// +/// Implements [`fmt::Write`], so `write!` / `writeln!` work directly: +/// `write!(ctx, "{name} = ")?;` +pub struct PrepareContext<'py> { + py: pyo3::Python<'py>, + sql: String, + params: PrepareParams, + next_id: usize, +} + +impl<'py> PrepareContext<'py> { + #[inline] + pub fn new(py: pyo3::Python<'py>) -> Self { + Self { + py, + sql: String::with_capacity(256), + params: HashMap::new(), + next_id: 0, + } + } + + #[inline] + pub fn py(&self) -> pyo3::Python<'py> { + self.py + } + + #[inline] + pub fn sql(&self) -> &str { + &self.sql + } + + #[inline] + pub fn params(&self) -> &PrepareParams { + &self.params + } + + #[inline] + pub fn into_parts(self) -> (String, PrepareParams) { + (self.sql, self.params) + } + + #[inline] + fn alloc_param_name(&mut self) -> String { + let name = format!("param_{}", self.next_id); + self.next_id += 1; + name + } +} + +impl PrepareContext<'_> { + /// Register `param` and return its placeholder (`@param_N` / `:param_N` / `$param_N`). + /// + /// Prefer [`push_param`](Self::push_param) when the placeholder is only going + /// to be appended to the SQL — that path does not allocate the placeholder + /// `String`. + #[inline] + #[must_use = "write the placeholder into the SQL, or the parameter is never referenced"] + pub fn add_param(&mut self, dialect: &SqlDialect, param: pyo3::Py) -> String { + let name = self.alloc_param_name(); + let mut placeholder = String::with_capacity(1 + name.len()); + placeholder.push(placeholder_sigil(dialect.placeholder_syntax())); + placeholder.push_str(&name); + self.params.insert(name, param); + placeholder + } + + /// Register `param` and append its placeholder to the SQL in one step. + /// + /// Returns `&mut Self` so calls can be chained: + /// + /// ```ignore + /// context + /// .push_str("SELECT * FROM t WHERE id = ") + /// .push_param(dialect, id) + /// .push_str(" AND name = ") + /// .push_param(dialect, name); + /// ``` + #[inline] + pub fn push_param(&mut self, dialect: &SqlDialect, param: pyo3::Py) -> &mut Self { + let name = self.alloc_param_name(); + self.sql + .push(placeholder_sigil(dialect.placeholder_syntax())); + self.sql.push_str(&name); + self.params.insert(name, param); + self + } + + #[inline] + pub fn push_char(&mut self, c: char) -> &mut Self { + self.sql.push(c); + self + } + + #[inline] + pub fn push_str(&mut self, s: &str) -> &mut Self { + self.sql.push_str(s); + self + } +} + +impl fmt::Write for PrepareContext<'_> { + #[inline] + fn write_str(&mut self, s: &str) -> fmt::Result { + self.sql.push_str(s); + Ok(()) + } + + #[inline] + fn write_char(&mut self, c: char) -> fmt::Result { + self.sql.push(c); + Ok(()) + } +} + +pub trait PrepareStatement { + fn prepare(&self, dialect: &SqlDialect, context: &mut PrepareContext<'_>) -> PrepareResult<()>; +} + +/// Rust-only entry point. Safe to call from non-pyo3 code as long as a +/// `Python<'_>` token and dialect are available. +pub fn prepare( + py: pyo3::Python<'_>, + stmt: &T, + dialect: &SqlDialect, +) -> PrepareResult<(String, PrepareParams)> { + let mut context = PrepareContext::new(py); + stmt.prepare(dialect, &mut context)?; + Ok(context.into_parts()) +} + +/// Prepare each item in order, writing `separator` between them +/// (not before the first or after the last). +/// +/// Use `","` / `", "` for lists and `" AND "` / `" OR "` for predicates. +pub fn prepare_separated<'a, T: PrepareStatement + 'a>( + dialect: &SqlDialect, + context: &mut PrepareContext<'_>, + items: impl IntoIterator, + separator: &str, +) -> PrepareResult<()> { + let mut first = true; + for item in items { + if !first { + context.push_str(separator); + } + first = false; + item.prepare(dialect, context)?; + } + Ok(()) +} From 8071a7732397bf018ba42b4ca273662a02f75010 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 7 Sep 2026 12:13:49 +0330 Subject: [PATCH 08/87] feat(native): Refactor and optimize dialects --- AGENTS.md | 79 +++ src/lib.rs | 16 +- src/native/dialect/bigquery.rs | 46 +- src/native/dialect/databricks.rs | 48 +- src/native/dialect/duckdb.rs | 44 +- src/native/dialect/hive.rs | 43 +- src/native/dialect/mod.rs | 1125 ++++++------------------------ src/native/dialect/mssql.rs | 56 +- src/native/dialect/mysql.rs | 44 +- src/native/dialect/oracle.rs | 47 +- src/native/dialect/postgresql.rs | 50 +- src/native/dialect/redshift.rs | 52 +- src/native/dialect/snowflake.rs | 42 +- src/native/dialect/spark.rs | 48 +- src/native/dialect/sqlite.rs | 51 +- src/native/dialect/teradata.rs | 51 +- src/native/dialect/tests.rs | 833 ++++++++++++++++++++++ src/native/prepare_statement.rs | 20 +- 18 files changed, 1407 insertions(+), 1288 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/native/dialect/tests.rs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..244b2c3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,79 @@ +# RapidQuery — agent notes + +High-performance Python SQL query builder. Public API is Python; the engine is Rust (PyO3 + maturin). Package: `rapidquery`. Native module: `rapidquery._core`. + +This branch (`refactor/v1`) restarts the closed draft PR **[#2](https://github.com/awolverp/rapidquery/pull/2)** (`origin/migrate` @ `77c6267`). That PR is the **architectural target**. It is not a SeaQuery port and it is not a vendored `sqlparser-rs` — it is a custom engine *inspired by* sqlparser-rs. + +Goals from the PR (keep these): +- No SeaQuery panics; production-safe SQL building. +- 13 dialects (BigQuery, Databricks, DuckDB, Hive, MSSQL, MySQL, Oracle, PostgreSQL, Redshift, Snowflake, Spark, SQLite, Teradata). +- Full SQL data-type coverage via interned `DataTypeName` + `DataTypeSerializer`. +- Native Rust vs Python bindings split (`src/native/` vs `src/pybindings/`). +- Parameterized `PrepareStatement` rendering (writer-based, no extra String allocs on the hot path). +- Ident / Asterisk / IdentList with dialect quoting. +- Leave the door open for a SQL parser and an ORM later. +- Prefer backward compatibility with v0, but it is **not** guaranteed. + +Do **not** restore v0 (`_lib`, SeaQuery, three backends) unless the user asks. + +Keep these `refactor/v1` divergences from the PR (do not revert them): +- Module name `rapidquery._core` (PR used `_native`). +- Proc-macros `pyclass` / `pyerr` (PR used `pyclass_shortcut` + `new_pyerr!`). +- MIT license, Python `>=3.10`, crate version `1.0.0-alpha1`. +- `PrepareContext` / `PrepareError` naming in `prepare_statement.rs`. + +## Stack + +- Python `>=3.10` (local: 3.13). Rust edition **2024**. PyO3 **0.29**. Maturin **1.15.0**. uv for the venv. +- Workspace crate `rapidquery-macros` (proc-macros: `pyclass`, `pyerr`). +- Target stack from PR #2 also used: `arcstr`, `once_cell`, `derive_more`, `chrono`, `ipnetwork`, `mac_address`. Re-add them when DataType/serializers/Ident land — not before. +- Dialects (13) and `PrepareStatement` exist on this branch. Still missing vs the PR: `DataType` + serializers, `Ident`/`Asterisk`/`IdentList`, `src/pybindings/`, `src/typeref.rs`, Python stubs, pytest suite. Query/schema AST was never in the PR either. +- `#[pyo3::pymodule(gil_used = false)]` — keep free-threaded Python in mind. +- Frozen/immutable PyO3 types: `rapidquery_macros::pyclass` already sets `module="rapidquery._core"`, `immutable_type`, `frozen`. + +## Layout + +``` +src/native/ Rust engine (dialects, prepare, later data_type + ident) +src/pybindings/ Python wrappers (missing on this branch; required by the PR) +src/internal/ aliases, string utils, submodule helper +src/typeref.rs cached PyTypeObject pointers (missing; required) +rapidquery/ Python package (stub here; PR re-exports Ident, Asterisk, data_type, dialect) +rapidquery-macros/ Proc-macros +tests/ Python tests (empty here; PR has dialect/data_type/serializer tests) +backup/, backup-py/ gitignored v0 snapshots — read-only +target/, .venv/ Generated — do not commit or edit +``` + +Reference the PR without checking it out as the working tree: `git show origin/migrate:`. Do not copy files wholesale if this branch already renamed or cleaned them. + +## Commands + +```bash +uv sync --dev +maturin develop # or: uv run maturin develop +cargo test --workspace --lib --bins --tests +cargo clippy --workspace --all-targets -- -D warnings # project lints are warn-level +cargo fmt # rustfmt.toml uses nightly-only options; CI uses nightly +uv run pytest -vv # CI command; pytest is not in pyproject yet +``` + +There is no Python linter/typechecker configured. Do not add ruff/mypy/a new test runner unless asked. + +After meaningful Rust or Python changes: run `cargo test --workspace --lib --bins --tests`. Run pytest only after Python tests exist. + +`cargo test --workspace` (including doctests) currently fails: `pyerr` docs in `rapidquery-macros/lib.rs` are not valid rustdoc tests. + +## How to edit + +- Small, reviewable diffs. Match existing patterns on **this** branch first; for missing layers, match `origin/migrate` (native types stay PyO3-free; `pybindings` wraps them; serializers subclass a base `Serializer`; dialects subclass `SqlDialect` and are `@final`). +- Do not introduce SeaQuery, a sqlparser-rs crate dependency, SQLAlchemy, another Python package layout, or a new build backend. +- Do not touch `Cargo.lock` / `uv.lock` unless the change requires a dependency update. Do not touch generated artifacts (`target/`, `*.so`, wheels), secrets, or `.env`. +- `backup/` and `backup-py/` are gitignored snapshots of v0. Do not edit them; do not copy them wholesale into `src/`. +- SQL injection safety is a product requirement. Prefer parameterized `PrepareContext::push_param` over interpolating values. Dialect `escape_*` / `quote_*` must not let payloads close literals. +- Unsafe is allowed where the crate already uses it (`from_utf8_unchecked` after ASCII-boundary splits, PyO3 FFI). Keep the safety invariant next to the block. +- Clippy: no `dbg!`, `println!`, or `eprintln!` in library code (warn). Release profile uses `panic = "abort"`. + +## Docs drift + +`README.md` still describes v0.1.0 (SeaQuery, three backends, `_lib`). Treat `Cargo.toml` + `src/` as source of truth for what is implemented; treat PR #2 / `origin/migrate` as source of truth for what comes next. diff --git a/src/lib.rs b/src/lib.rs index 90ec6c1..f001745 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,20 @@ pub mod internal; pub mod native; +pub mod pybindings; +pub mod typeref; #[pyo3::pymodule(gil_used = false)] -mod _core {} +mod _core { + #[pymodule_export] + use super::pybindings::dialect_mod; + + #[pymodule_init] + #[cold] + fn init(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> { + use crate::internal::utils::PyModuleSubmoduleExt; + + crate::typeref::initialize_typeref(m.py()); + m.init_submodules() + } +} diff --git a/src/native/dialect/bigquery.rs b/src/native/dialect/bigquery.rs index 0b82934..0795fba 100644 --- a/src/native/dialect/bigquery.rs +++ b/src/native/dialect/bigquery.rs @@ -1,11 +1,12 @@ -//! A dialect for [Google BigQuery](https://cloud.google.com/bigquery/). +//! [Google BigQuery](https://cloud.google.com/bigquery/) Standard SQL. //! -//! • Strings: single-quoted; escape with backslash sequences. -//! • Identifiers: back-tick quoted. -//! • Bytes: B'\xNN…' (byte literal with \x escapes). -//! • Bits: No native bit-string literal; cast from INT64. -//! We emit CAST(0b AS INT64) for best portability. -//! • Bool: TRUE / FALSE keywords. +//! • Strings: single-quoted; backslash-escape sequences (`\n`, `\xNN`, …). +//! • Identifiers: backtick-quoted. The body uses the same escaping as a +//! string (BigQuery does not double backticks). +//! • Bytes: `B'\xNN…'` byte literal. +//! • Bits: no native bit-string literal; emit `CAST(0b AS INT64)`. +//! • Bool: `TRUE` / `FALSE`. +//! • Placeholders: `@name` (native). //! //! Ref: @@ -15,11 +16,11 @@ pub(super) const NAME: &str = "BigQuery"; pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; -pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::Backtick); +pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::Backtick; pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::AtSign; -pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { +pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { for ch in string.chars() { match ch { '\\' => writer.write_str("\\\\")?, @@ -28,9 +29,8 @@ pub(super) fn escape_string(writer: &mut T, string: &str) -> '\r' => writer.write_str("\\r")?, '\t' => writer.write_str("\\t")?, '\0' => writer.write_str("\\x00")?, - // ASCII control characters c if (c as u32) < 32 || c as u32 == 127 => { - writer.write_str(&format!("\\x{:02X}", c as u32))?; + super::write_byte_hex_escape(writer, c as u8)?; } c => writer.write_char(c)?, } @@ -39,32 +39,26 @@ pub(super) fn escape_string(writer: &mut T, string: &str) -> Ok(()) } -pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { +pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { super::wrap_single_quote(writer, string) } -pub(super) fn escape_identifier( - writer: &mut T, +pub(super) fn escape_identifier( + writer: &mut W, ident: &str, ) -> std::fmt::Result { escape_string(writer, ident) } -pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { - // BigQuery byte literal: B'\xNN\xNN…' +pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { writer.write_str("B'")?; - for b in bytes { - writer.write_str(&format!("\\x{:02X}", b))?; + for &b in bytes { + super::write_byte_hex_escape(writer, b)?; } writer.write_char('\'') } -pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { - debug_assert!( - bits.chars().all(|c| c == '0' || c == '1'), - "encode_bits: invalid input: {bits}" - ); - - // BigQuery has no native bit literal; represent as INT64 binary literal. - write!(writer, "CAST(0b{} AS INT64)", bits) +pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { + super::debug_assert_bits(bits); + write!(writer, "CAST(0b{bits} AS INT64)") } diff --git a/src/native/dialect/databricks.rs b/src/native/dialect/databricks.rs index 836f66f..95fb4f2 100644 --- a/src/native/dialect/databricks.rs +++ b/src/native/dialect/databricks.rs @@ -1,12 +1,12 @@ -//! A dialect for [Databricks SQL](https://www.databricks.com/). +//! [Databricks SQL](https://www.databricks.com/). //! -//! See . -//! -//! • Strings: single-quoted; backslash-escape sequences. -//! • Identifiers: back-tick quoted. -//! • Bytes: X'DEADBEEF' hex literal (BINARY type). -//! • Bits: No native bit literal; we use CAST(… AS BINARY) from INT. -//! • Bool: TRUE / FALSE keywords. +//! • Strings: single-quoted; backslash-escape sequences (Java-style `\uNNNN` +//! for other control chars). +//! • Identifiers: backtick-quoted; backtick doubled. +//! • Bytes: `X'DEADBEEF'` hex literal (BINARY). +//! • Bits: no native bit literal; `CAST(L AS BINARY)`. +//! • Bool: `TRUE` / `FALSE`. +//! • Placeholders: `:name` (client-side; not a native Databricks feature). //! //! Ref: @@ -18,11 +18,11 @@ pub(super) const NAME: &str = "Databricks"; pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; -pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::Backtick); +pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::Backtick; pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; -pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { +pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { for ch in string.chars() { match ch { '\\' => writer.write_str("\\\\")?, @@ -33,7 +33,7 @@ pub(super) fn escape_string(writer: &mut T, string: &str) -> '\t' => writer.write_str("\\t")?, '\0' => writer.write_str("\\0")?, c if (c as u32) < 32 || c as u32 == 127 => { - writer.write_str(&format!("\\u{:04X}", c as u32))?; + super::write_unicode4_escape(writer, c as u32)?; } c => writer.write_char(c)?, } @@ -42,33 +42,21 @@ pub(super) fn escape_string(writer: &mut T, string: &str) -> Ok(()) } -pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { +pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { super::wrap_single_quote(writer, string) } -pub(super) fn escape_identifier( - writer: &mut T, +pub(super) fn escape_identifier( + writer: &mut W, ident: &str, ) -> std::fmt::Result { replace_to(ident, '`', "``", writer) } -pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { - writer.write_str("X'")?; - super::bytes_to_hex(writer, bytes)?; - writer.write_char('\'') +pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { + super::write_x_hex_literal(writer, bytes) } -pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { - debug_assert!( - bits.chars().all(|c| c == '0' || c == '1'), - "encode_bits: invalid input: {bits}" - ); - - // Databricks supports CAST with integer literals - write!( - writer, - "CAST({}L AS BINARY)", - i64::from_str_radix(bits, 2).unwrap_or(0) - ) +pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { + write!(writer, "CAST({}L AS BINARY)", super::bits_as_i64(bits)) } diff --git a/src/native/dialect/duckdb.rs b/src/native/dialect/duckdb.rs index e97107f..72b0a13 100644 --- a/src/native/dialect/duckdb.rs +++ b/src/native/dialect/duckdb.rs @@ -1,10 +1,12 @@ -//! A dialect for [DuckDB](https://duckdb.org/). +//! [DuckDB](https://duckdb.org/). //! -//! • Strings: single-quoted; `''` for embedded single-quote. -//! • Identifiers: double-quote quoted. -//! • Bytes: '\xNN…'::BLOB or X'DEADBEEF' hex literal. -//! • Bits: 0b::INTEGER or BIT literal via BITSTRING. -//! • Bool: TRUE / FALSE keywords. +//! • Strings: single-quoted; `''` for an embedded quote. Backslash is **not** +//! special in default mode. NUL is passed through (VARCHAR is UTF-8). +//! • Identifiers: double-quote quoted; `"` doubled. +//! • Bytes: `X'DEADBEEF'` hex literal. +//! • Bits: `B'10110101'` bit-string literal. +//! • Bool: `TRUE` / `FALSE`. +//! • Placeholders: `$name` (native-ish; DuckDB also accepts `$1` / `?`). //! //! Ref: @@ -16,40 +18,30 @@ pub(super) const NAME: &str = "DuckDb"; pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; -pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::DoubleQuote); +pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::DoubleQuote; pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::DollarNamed; -pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { - // DuckDB uses '' for quote escaping (ANSI-compatible). - // Backslash is NOT a special escape character in default mode. +pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { super::escape_single_quote(writer, string) } -pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { +pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { super::wrap_single_quote(writer, string) } -pub(super) fn escape_identifier( - writer: &mut T, +pub(super) fn escape_identifier( + writer: &mut W, ident: &str, ) -> std::fmt::Result { replace_to(ident, '"', "\"\"", writer) } -pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { - // write!(writer, "X'{}'", super::bytes_to_hex(writer, bytes)) - writer.write_str("X'")?; - super::bytes_to_hex(writer, bytes)?; - writer.write_char('\'') +pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { + super::write_x_hex_literal(writer, bytes) } -pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { - debug_assert!( - bits.chars().all(|c| c == '0' || c == '1'), - "encode_bits: invalid input: {bits}" - ); - - // DuckDB supports bitstring literals with B prefix - write!(writer, "B'{}'", bits) +pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { + super::debug_assert_bits(bits); + write!(writer, "B'{bits}'") } diff --git a/src/native/dialect/hive.rs b/src/native/dialect/hive.rs index 37936e9..a50f761 100644 --- a/src/native/dialect/hive.rs +++ b/src/native/dialect/hive.rs @@ -1,10 +1,12 @@ -//! A dialect for [Hive](https://hive.apache.org/). +//! [Apache Hive](https://hive.apache.org/). //! -//! • Strings: single-quoted; backslash-escape sequences (Java conventions). -//! • Identifiers: back-tick quoted. -//! • Bytes: No native byte literal; use CAST with hex string. -//! • Bits: No native bit literal; use CAST with integer. -//! • Bool: TRUE / FALSE keywords. +//! • Strings: single-quoted; Java-style backslash escapes (`\uNNNN` for +//! other control chars). +//! • Identifiers: backtick-quoted; backtick doubled. +//! • Bytes: no native byte literal; `UNHEX('DEADBEEF')`. +//! • Bits: no native bit literal; `CAST( AS BIGINT)`. +//! • Bool: `TRUE` / `FALSE`. +//! • Placeholders: `:name` (not native; rewritten client-side). //! //! Ref: @@ -16,12 +18,11 @@ pub(super) const NAME: &str = "Hive"; pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; -pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::Backtick); +pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::Backtick; -/// This is not natively supported by Hive, and should be rendered on client-side. pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; -pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { +pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { for ch in string.chars() { match ch { '\\' => writer.write_str("\\\\")?, @@ -31,7 +32,7 @@ pub(super) fn escape_string(writer: &mut T, string: &str) -> '\t' => writer.write_str("\\t")?, '\0' => writer.write_str("\\0")?, c if (c as u32) < 32 || c as u32 == 127 => { - writer.write_str(&format!("\\u{:04X}", c as u32))?; + super::write_unicode4_escape(writer, c as u32)?; } c => writer.write_char(c)?, } @@ -39,33 +40,23 @@ pub(super) fn escape_string(writer: &mut T, string: &str) -> Ok(()) } -pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { +pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { super::wrap_single_quote(writer, string) } -pub(super) fn escape_identifier( - writer: &mut T, +pub(super) fn escape_identifier( + writer: &mut W, ident: &str, ) -> std::fmt::Result { replace_to(ident, '`', "``", writer) } -pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { - // Hive: BINARY from hex string via unhex() +pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { writer.write_str("UNHEX('")?; super::bytes_to_hex(writer, bytes)?; writer.write_str("')") } -pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { - debug_assert!( - bits.chars().all(|c| c == '0' || c == '1'), - "encode_bits: invalid input: {bits}" - ); - - write!( - writer, - "CAST({} AS BIGINT)", - i64::from_str_radix(bits, 2).unwrap_or(0) - ) +pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { + write!(writer, "CAST({} AS BIGINT)", super::bits_as_i64(bits)) } diff --git a/src/native/dialect/mod.rs b/src/native/dialect/mod.rs index 6237f17..0fae710 100644 --- a/src/native/dialect/mod.rs +++ b/src/native/dialect/mod.rs @@ -1,12 +1,30 @@ //! SQL dialect definitions. //! -//! Each dialect encodes how SQL literals (strings, bytes, bit-strings) and -//! identifiers should be quoted/escaped/rendered for a particular database -//! engine. Dialects are selected at runtime via the [`SqlDialect`] enum. +//! A dialect encodes how one database engine quotes identifiers, escapes +//! string/byte/bit literals, formats booleans, and spells named placeholders. +//! It is **not** a parser and **not** a statement builder — those sit on top +//! of [`SqlDialect`] via [`crate::native::prepare_statement`]. //! -//! Assisted-by: Claude +//! Dialects are selected at runtime through the [`SqlDialect`] enum. Each +//! variant is a marker; behavior lives in the matching private module. +//! +//! # Placeholders +//! +//! [`PlaceholderSyntax`] is the *named* form (`@param_N`, `:param_N`, +//! `$param_N`). Several engines have no native named placeholders (Hive, +//! MySQL, Postgres `$1`, SQLite `?`); those dialects still pick a named +//! style that common Python clients rewrite client-side. +//! +//! # Quoting contract +//! +//! `escape_*` writes the **body** only (special characters neutralized). +//! `quote_*` wraps an **already-escaped** body in the dialect's delimiters. +//! Call `escape` then `quote`, or use `quote_identifier` which does both +//! for identifiers. -use crate::internal::utils::replace_to; +use std::fmt::{self, Write}; + +use crate::internal::utils::{replace_ascii_to, replace_to}; mod bigquery; mod databricks; @@ -22,7 +40,10 @@ mod spark; mod sqlite; mod teradata; -/// How a boolean should be serialized. +#[cfg(test)] +mod tests; + +/// How a boolean literal is spelled. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum BoolFormat { /// `TRUE` / `FALSE` @@ -31,55 +52,166 @@ pub enum BoolFormat { Int, } -/// Represents the various identifier quote styles. +impl BoolFormat { + fn write(self, writer: &mut W, value: bool) -> fmt::Result { + match (self, value) { + (Self::Keyword, true) => writer.write_str("TRUE"), + (Self::Keyword, false) => writer.write_str("FALSE"), + (Self::Int, true) => writer.write_char('1'), + (Self::Int, false) => writer.write_char('0'), + } + } +} + +/// Delimiters wrapped around a quoted identifier. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum IdentifierQuote { + /// `'name'` (Teradata). SingleQuote, + /// `"name"` (Postgres, SQLite, Oracle, …). DoubleQuote, + /// `` `name` `` (MySQL, BigQuery, Spark, …). Backtick, + /// `[name]` (SQL Server). SquareBracket, } -/// Represents the various placeholder syntaxes used for parameterized queries -/// and prepared statements across different SQL dialects. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +impl IdentifierQuote { + /// Opening and closing delimiter characters. + #[inline] + pub const fn delimiters(self) -> (char, char) { + match self { + Self::SingleQuote => ('\'', '\''), + Self::DoubleQuote => ('"', '"'), + Self::Backtick => ('`', '`'), + Self::SquareBracket => ('[', ']'), + } + } +} + +/// Named-placeholder spelling used when building parameterized SQL. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum PlaceholderSyntax { - /// At-Sign Named Parameter syntax (`@param_name`). + /// `@param_name` (SQL Server, BigQuery). AtSign, - - /// Colon Named Parameter syntax (`:param_name`). + /// `:param_name` (Oracle, and many client-side rewrites). Colon, - - /// Dollar-Sign Named Parameter syntax (`$param_name`). + /// `$param_name` (DuckDB; not Postgres positional `$1`). DollarNamed, } -/// Converts a byte slice to an uppercase hex string. +impl PlaceholderSyntax { + /// Character written immediately before the parameter name. + #[inline] + pub const fn sigil(self) -> char { + match self { + Self::AtSign => '@', + Self::Colon => ':', + Self::DollarNamed => '$', + } + } +} + +// --------------------------------------------------------------------------- +// Shared write helpers (no heap allocation beyond the caller's `Write`). +// --------------------------------------------------------------------------- + +const HEX_DIGITS: &[u8; 16] = b"0123456789ABCDEF"; + +/// Write `byte` as two uppercase hex digits. #[inline] -pub(super) fn bytes_to_hex(out: &mut T, bytes: &[u8]) -> std::fmt::Result { - for b in bytes { - write!(out, "{:02X}", b)?; +pub(super) fn write_hex2(writer: &mut W, byte: u8) -> fmt::Result { + let buf = [ + HEX_DIGITS[(byte >> 4) as usize], + HEX_DIGITS[(byte & 0x0F) as usize], + ]; + // SAFETY: HEX_DIGITS is ASCII, so `buf` is valid UTF-8. + writer.write_str(unsafe { std::str::from_utf8_unchecked(&buf) }) +} + +/// Write `bytes` as a contiguous uppercase hex run (`DEADBEEF`). +#[inline] +pub(super) fn bytes_to_hex(writer: &mut W, bytes: &[u8]) -> fmt::Result { + for &b in bytes { + write_hex2(writer, b)?; } Ok(()) } -/// Standard single-quote escape: `'` → `''`. -/// Used by most SQL dialects. +/// Write `\xNN` for a single byte (uppercase hex). +#[inline] +pub(super) fn write_byte_hex_escape(writer: &mut W, byte: u8) -> fmt::Result { + writer.write_str("\\x")?; + write_hex2(writer, byte) +} + +/// Write `\uNNNN` for a codepoint that fits in 16 bits. +#[inline] +pub(super) fn write_unicode4_escape(writer: &mut W, code: u32) -> fmt::Result { + debug_assert!(code <= 0xFFFF); + writer.write_str("\\u")?; + write_hex2(writer, (code >> 8) as u8)?; + write_hex2(writer, code as u8) +} + +/// `X'DEADBEEF'` blob literal, used by MySQL, SQLite, Spark, Snowflake, … +#[inline] +pub(super) fn write_x_hex_literal(writer: &mut W, bytes: &[u8]) -> fmt::Result { + writer.write_str("X'")?; + bytes_to_hex(writer, bytes)?; + writer.write_char('\'') +} + +/// ANSI string-body escape: `'` → `''`. Does not strip NUL. #[inline] -pub(super) fn escape_single_quote( - writer: &mut T, - string: &str, -) -> std::fmt::Result { +pub(super) fn escape_single_quote(writer: &mut W, string: &str) -> fmt::Result { replace_to(string, '\'', "''", writer) } -/// Wraps a (pre-escaped) string in single quotes. +/// ANSI string-body escape plus NUL stripping (`'` → `''`, `\0` deleted). +/// +/// Used by engines whose string types cannot hold NUL (T-SQL, Oracle, +/// Redshift, SQLite, Teradata). #[inline] -pub(super) fn wrap_single_quote( - writer: &mut T, - string: &str, -) -> std::fmt::Result { - write!(writer, "'{string}'") +pub(super) fn escape_quote_and_strip_nul(writer: &mut W, string: &str) -> fmt::Result { + const REPLACEMENTS: [(u8, &str); 2] = [(b'\'', "''"), (b'\0', "")]; + replace_ascii_to(string, &REPLACEMENTS, writer) +} + +/// Wrap an already-escaped body in single quotes (`'…'`). +#[inline] +pub(super) fn wrap_single_quote(writer: &mut W, string: &str) -> fmt::Result { + writer.write_char('\'')?; + writer.write_str(string)?; + writer.write_char('\'') +} + +/// Debug-only check that `bits` is a `'0'`/`'1'` string. +#[inline] +pub(super) fn debug_assert_bits(bits: &str) { + debug_assert!( + bits.chars().all(|c| c == '0' || c == '1'), + "encode_bits: invalid input: {bits}" + ); +} + +/// Parse `bits` as a base-2 `i64`. +/// +/// Empty strings and values that overflow `i64` (including bit strings +/// longer than 64) return `0`. That fallback is the current contract until +/// a later layer can surface a real error. Callers must pass only `'0'`/`'1'`. +#[inline] +pub(super) fn bits_as_i64(bits: &str) -> i64 { + debug_assert_bits(bits); + i64::from_str_radix(bits, 2).unwrap_or(0) +} + +/// Run `write` into a new `String`. `fmt::Write` for `String` is infallible. +#[inline] +fn to_string(write: impl FnOnce(&mut String) -> fmt::Result) -> String { + let mut buf = String::new(); + let _ = write(&mut buf); + buf } /// Dispatches to the per-dialect free function of the same name, forwarding @@ -127,10 +259,12 @@ macro_rules! dispatch { /// The SQL dialects supported by this crate, selectable at runtime. /// -/// Each variant carries no data (it's a plain marker); all -/// dialect-specific behavior lives in the corresponding private module and -/// is reached through the methods below. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +/// Each variant carries no data; dialect-specific behavior lives in the +/// corresponding private module and is reached through the methods below. +/// +/// Variant names (and [`SqlDialect::name`]) are the Python class names +/// (`DuckDb`, `PostgreSql`, `Sqlite`, …). Do not ā€œcorrectā€ the spelling. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[non_exhaustive] pub enum SqlDialect { /// [Google BigQuery](https://cloud.google.com/bigquery/) @@ -160,930 +294,115 @@ pub enum SqlDialect { /// [Teradata](https://docs.teradata.com/) Teradata, } + impl SqlDialect { - /// The dialect's display name (e.g. `"PostgreSQL"`, `"BigQuery"`). + /// Display name, identical to the variant identifier (e.g. `"PostgreSql"`). #[inline] pub fn name(&self) -> &'static str { dispatch!(self, const NAME) } - /// How this dialect renders boolean literals. + /// Named-placeholder style for parameterized SQL. #[inline] - pub fn format_boolean( - &self, - writer: &mut T, - value: bool, - ) -> std::fmt::Result { - let format = dispatch!(self, const BOOL_FORMAT); + pub fn placeholder_syntax(&self) -> PlaceholderSyntax { + dispatch!(self, const PLACEHOLDER_SYNTAX) + } - match (format, value) { - (BoolFormat::Keyword, true) => write!(writer, "TRUE"), - (BoolFormat::Keyword, false) => write!(writer, "FALSE"), - (BoolFormat::Int, true) => write!(writer, "1"), - (BoolFormat::Int, false) => write!(writer, "0"), - } + /// Write a boolean literal (`TRUE`/`FALSE` or `1`/`0`). + #[inline] + pub fn format_boolean(&self, writer: &mut W, value: bool) -> fmt::Result { + dispatch!(self, const BOOL_FORMAT).write(writer, value) } - /// How this dialect renders boolean literals, returning an owned `String`. + /// [`format_boolean`](Self::format_boolean) into an owned `String`. #[inline] pub fn format_boolean_as_string(&self, value: bool) -> String { - let mut buf = String::new(); - // Writing to a `String` is infallible. + let mut buf = String::with_capacity(5); let _ = self.format_boolean(&mut buf, value); buf } - /// Escapes special characters within a identifier. - /// - /// Call this before [`SqlDialect::quote_identifier`]. + /// Write `ident` with delimiter characters doubled/escaped, **without** + /// wrapping it. Use [`quote_identifier`](Self::quote_identifier) to also + /// add delimiters. #[inline] - pub fn escape_identifier( - &self, - writer: &mut T, - ident: &str, - quote: bool, - ) -> std::fmt::Result { - let format = dispatch!(self, const IDENTIFIER_QUOTE); - - let (start_token, end_token) = if quote { - match format { - Some(IdentifierQuote::SingleQuote) => (Some('\''), Some('\'')), - Some(IdentifierQuote::DoubleQuote) => (Some('"'), Some('"')), - Some(IdentifierQuote::Backtick) => (Some('`'), Some('`')), - Some(IdentifierQuote::SquareBracket) => (Some('['), Some(']')), - None => (None, None), - } - } else { - (None, None) - }; + pub fn escape_identifier(&self, writer: &mut W, ident: &str) -> fmt::Result { + dispatch!(self, fn escape_identifier, writer, ident) + } - if let Some(token) = start_token { - writer.write_char(token)?; - } - dispatch!(self, fn escape_identifier, writer, ident)?; - if let Some(token) = end_token { - writer.write_char(token)?; - } - Ok(()) + /// [`escape_identifier`](Self::escape_identifier) into an owned `String`. + #[inline] + pub fn escape_identifier_as_string(&self, ident: &str) -> String { + to_string(|buf| self.escape_identifier(buf, ident)) } - /// Escapes special characters within an identifier, returning an owned `String`. - /// - /// Call this before [`SqlDialect::quote_identifier`] / [`SqlDialect::quote_identifier_as_string`]. + /// Escape `ident` and wrap it in this dialect's identifier delimiters. #[inline] - pub fn escape_identifier_as_string(&self, ident: &str, quote: bool) -> String { - let mut buf = String::new(); - let _ = self.escape_identifier(&mut buf, ident, quote); - buf + pub fn quote_identifier(&self, writer: &mut W, ident: &str) -> fmt::Result { + let (start, end) = dispatch!(self, const IDENTIFIER_QUOTE).delimiters(); + writer.write_char(start)?; + self.escape_identifier(writer, ident)?; + writer.write_char(end) } - /// The placeholder syntax used for parameterized queries in this dialect. + /// [`quote_identifier`](Self::quote_identifier) into an owned `String`. #[inline] - pub fn placeholder_syntax(&self) -> PlaceholderSyntax { - dispatch!(self, const PLACEHOLDER_SYNTAX) + pub fn quote_identifier_as_string(&self, ident: &str) -> String { + to_string(|buf| self.quote_identifier(buf, ident)) } - /// Escapes special characters within a string literal's contents. - /// - /// Call this before [`SqlDialect::quote_string`]. + /// Write `string` with special characters neutralized, **without** wrapping + /// it in quotes. Call [`quote_string`](Self::quote_string) on the result + /// to produce a complete literal. #[inline] - pub fn escape_string( - &self, - writer: &mut T, - string: &str, - ) -> std::fmt::Result { + pub fn escape_string(&self, writer: &mut W, string: &str) -> fmt::Result { dispatch!(self, fn escape_string, writer, string) } - /// Escapes special characters within a string literal's contents, - /// returning an owned `String`. - /// - /// Call this before [`SqlDialect::quote_string`] / [`SqlDialect::quote_string_as_string`]. + /// [`escape_string`](Self::escape_string) into an owned `String`. #[inline] pub fn escape_string_as_string(&self, string: &str) -> String { - let mut buf = String::new(); - let _ = self.escape_string(&mut buf, string); - buf + to_string(|buf| self.escape_string(buf, string)) } - /// Wraps an already-escaped string in this dialect's literal quoting. + /// Wrap an **already-escaped** string in this dialect's literal quoting + /// (`'…'`, `E'…'`, `N'…'`). #[inline] - pub fn quote_string( - &self, - writer: &mut T, - string: &str, - ) -> std::fmt::Result { + pub fn quote_string(&self, writer: &mut W, string: &str) -> fmt::Result { dispatch!(self, fn quote_string, writer, string) } - /// Wraps an already-escaped string in this dialect's literal quoting, - /// returning an owned `String`. + /// [`quote_string`](Self::quote_string) into an owned `String`. #[inline] pub fn quote_string_as_string(&self, string: &str) -> String { - let mut buf = String::new(); - let _ = self.quote_string(&mut buf, string); - buf + to_string(|buf| self.quote_string(buf, string)) } - /// Renders a byte slice as a literal in this dialect's native form. + /// Write `bytes` as a native blob/bytea/varbyte literal. #[inline] - pub fn encode_bytes( - &self, - writer: &mut T, - bytes: &[u8], - ) -> std::fmt::Result { + pub fn encode_bytes(&self, writer: &mut W, bytes: &[u8]) -> fmt::Result { dispatch!(self, fn encode_bytes, writer, bytes) } - /// Renders a byte slice as a literal in this dialect's native form, - /// returning an owned `String`. + /// [`encode_bytes`](Self::encode_bytes) into an owned `String`. #[inline] pub fn encode_bytes_as_string(&self, bytes: &[u8]) -> String { - let mut buf = String::new(); - let _ = self.encode_bytes(&mut buf, bytes); - buf + to_string(|buf| self.encode_bytes(buf, bytes)) } - /// Renders a bit-string (a string of `'0'`/`'1'` characters) as a - /// literal in this dialect's native form. + /// Write a bit-string (`'0'`/`'1'` characters) as a native literal. /// - /// `bits` is validated via `debug_assert!` in each dialect's - /// implementation; callers should ensure it only contains `'0'`/`'1'`. + /// Callers must pass only `'0'` and `'1'`; that is checked with + /// `debug_assert!`. Dialects that fold bits into an `i64` return `0` on + /// overflow (see [`bits_as_i64`]). #[inline] - pub fn encode_bits(&self, writer: &mut T, bits: &str) -> std::fmt::Result { + pub fn encode_bits(&self, writer: &mut W, bits: &str) -> fmt::Result { dispatch!(self, fn encode_bits, writer, bits) } - /// Renders a bit-string (a string of `'0'`/`'1'` characters) as a - /// literal in this dialect's native form, returning an owned `String`. - /// - /// `bits` is validated via `debug_assert!` in each dialect's - /// implementation; callers should ensure it only contains `'0'`/`'1'`. + /// [`encode_bits`](Self::encode_bits) into an owned `String`. #[inline] pub fn encode_bits_as_string(&self, bits: &str) -> String { - let mut buf = String::new(); - let _ = self.encode_bits(&mut buf, bits); - buf - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Every dialect this crate supports, used to drive table-style tests. - const ALL_DIALECTS: &[SqlDialect] = &[ - SqlDialect::BigQuery, - SqlDialect::Databricks, - SqlDialect::DuckDb, - SqlDialect::Hive, - SqlDialect::MsSql, - SqlDialect::MySql, - SqlDialect::Oracle, - SqlDialect::PostgreSql, - SqlDialect::Redshift, - SqlDialect::Snowflake, - SqlDialect::Spark, - SqlDialect::Sqlite, - SqlDialect::Teradata, - ]; - - /// Dialects whose `escape_string` interprets backslash sequences - /// (`\\`, `\n`, `\t`, ...). These MUST double a literal backslash. - const BACKSLASH_ESCAPING_DIALECTS: &[SqlDialect] = &[ - SqlDialect::BigQuery, - SqlDialect::Databricks, - SqlDialect::Hive, - SqlDialect::MySql, - SqlDialect::PostgreSql, - SqlDialect::Snowflake, - SqlDialect::Spark, - ]; - - /// Dialects that only double single quotes and treat backslash as a - /// completely literal character (per their own doc comments). These MUST - /// pass a literal backslash straight through, unchanged. - const QUOTE_ONLY_DIALECTS: &[SqlDialect] = &[ - SqlDialect::DuckDb, - SqlDialect::MsSql, - SqlDialect::Oracle, - SqlDialect::Redshift, - SqlDialect::Sqlite, - SqlDialect::Teradata, - ]; - - /// Dialects documented as stripping NUL bytes outright (rather than - /// escaping them) because their target string types can't hold NUL. - const NUL_STRIPPING_DIALECTS: &[SqlDialect] = &[ - SqlDialect::MsSql, - SqlDialect::Oracle, - SqlDialect::Redshift, - SqlDialect::Sqlite, - SqlDialect::Teradata, - ]; - - fn sanity_check_coverage() { - // Every dialect must land in exactly one of the two escaping-style - // buckets above, and that bucket assignment must partition ALL_DIALECTS. - assert_eq!( - BACKSLASH_ESCAPING_DIALECTS.len() + QUOTE_ONLY_DIALECTS.len(), - ALL_DIALECTS.len(), - "every dialect must be classified as either backslash-escaping or quote-only" - ); - } - - // --------------------------------------------------------------------- - // 1. Single-quote handling: the one thing EVERY dialect must get right — - // though the *shape* of the escape differs by family: - // - QUOTE_ONLY dialects (ANSI-style) double it: ' -> '' - // - BACKSLASH_ESCAPING dialects (C/Java-style) prefix it: ' -> \' - // --------------------------------------------------------------------- - - #[test] - fn quote_only_dialects_double_a_lone_single_quote() { - sanity_check_coverage(); - for dialect in QUOTE_ONLY_DIALECTS { - let escaped = dialect.escape_string_as_string("'"); - assert_eq!( - escaped, - "''", - "{} did not double a lone single quote (got {escaped:?})", - dialect.name() - ); - } - } - - #[test] - fn backslash_escaping_dialects_prefix_a_lone_single_quote() { - for dialect in BACKSLASH_ESCAPING_DIALECTS { - let escaped = dialect.escape_string_as_string("'"); - assert_eq!( - escaped, - "\\'", - "{} did not backslash-escape a lone single quote (got {escaped:?})", - dialect.name() - ); - } - } - - #[test] - fn quote_only_dialects_double_multiple_single_quotes() { - for dialect in QUOTE_ONLY_DIALECTS.iter() { - let escaped = dialect.escape_string_as_string("''''"); - assert_eq!( - escaped, - "''''''''", - "{} mis-handled 4 consecutive quotes (got {escaped:?})", - dialect.name() - ); - } - } - - #[test] - fn backslash_escaping_dialects_prefix_multiple_single_quotes() { - for dialect in BACKSLASH_ESCAPING_DIALECTS { - let escaped = dialect.escape_string_as_string("''''"); - assert_eq!( - escaped, - "\\'\\'\\'\\'", - "{} mis-handled 4 consecutive quotes (got {escaped:?})", - dialect.name() - ); - } - } - - #[test] - fn every_dialect_neutralizes_the_apostrophe_in_a_classic_name() { - for dialect in ALL_DIALECTS { - let escaped = dialect.escape_string_as_string("O'Brien"); - let uses_backslash = BACKSLASH_ESCAPING_DIALECTS.contains(&dialect); - let expected = if uses_backslash { - "O\\'Brien" - } else { - "O''Brien" - }; - assert_eq!( - escaped, - expected, - "{} did not neutralize the apostrophe in \"O'Brien\" as expected (got {escaped:?})", - dialect.name() - ); - } - } - - // --------------------------------------------------------------------- - // 2. Backslash handling must match each dialect's documented behavior. - // --------------------------------------------------------------------- - - #[test] - fn backslash_escaping_dialects_double_a_lone_backslash() { - for dialect in BACKSLASH_ESCAPING_DIALECTS { - let escaped = dialect.escape_string_as_string("\\"); - assert_eq!( - escaped, - "\\\\", - "{} should double a lone backslash (got {escaped:?})", - dialect.name() - ); - } - } - - #[test] - fn quote_only_dialects_pass_backslash_through_unchanged() { - for dialect in QUOTE_ONLY_DIALECTS.iter() { - let escaped = dialect.escape_string_as_string("\\"); - assert_eq!( - escaped, - "\\", - "{} must treat backslash as a literal, non-special character (got {escaped:?})", - dialect.name() - ); - } - } - - #[test] - fn quote_only_dialects_dont_touch_backslash_next_to_quote() { - // This is the classic footgun: a naive implementation might see `\'` - // and think it needs special handling. These dialects must NOT - // interpret backslash-quote as an escape sequence — the backslash - // stays literal and the quote still gets doubled independently. - for dialect in QUOTE_ONLY_DIALECTS.iter() { - let escaped = dialect.escape_string_as_string("\\'"); - assert_eq!( - escaped, - "\\''", - "{} mis-handled backslash immediately followed by a quote (got {escaped:?})", - dialect.name() - ); - } - } - - #[test] - fn backslash_escaping_dialects_handle_backslash_next_to_quote() { - for dialect in BACKSLASH_ESCAPING_DIALECTS { - let escaped = dialect.escape_string_as_string("\\'"); - assert_eq!( - escaped, - "\\\\\\'", - "{} mis-handled backslash immediately followed by a quote (got {escaped:?})", - dialect.name() - ); - } - } - - // --------------------------------------------------------------------- - // 3. NUL byte handling: strip vs. escape, per dialect contract. - // --------------------------------------------------------------------- - - /// Dialects documented as escaping NUL (rather than stripping or passing - /// it through raw) — i.e. the backslash-escaping family, all of which - /// have an explicit `'\0' => ...` arm. - const NUL_ESCAPING_DIALECTS: &[SqlDialect] = &[ - SqlDialect::BigQuery, - SqlDialect::Databricks, - SqlDialect::Hive, - SqlDialect::MySql, - SqlDialect::PostgreSql, - SqlDialect::Snowflake, - SqlDialect::Spark, - ]; - - #[test] - fn nul_stripping_dialects_remove_nul_entirely() { - for dialect in NUL_STRIPPING_DIALECTS { - let escaped = dialect.escape_string_as_string("a\0b"); - assert_eq!( - escaped, - "ab", - "{} should strip NUL bytes outright (got {escaped:?})", - dialect.name() - ); - assert!( - !escaped.contains('\0'), - "{} left a raw NUL byte in its output", - dialect.name() - ); - } - } - - #[test] - fn nul_escaping_dialects_replace_nul_with_an_escape_sequence() { - for dialect in NUL_ESCAPING_DIALECTS { - let escaped = dialect.escape_string_as_string("a\0b"); - assert!( - !escaped.contains('\0'), - "{} left a raw NUL byte in escaped output (got {escaped:?})", - dialect.name() - ); - assert_ne!( - escaped, - "a\0b", - "{} should have replaced NUL with an escape sequence, not left it untouched", - dialect.name() - ); - } - } - - #[test] - fn duckdb_documents_nul_as_a_regular_passthrough_character() { - // DuckDB VARCHAR is UTF-8 and its docs don't call out NUL as - // unsupported, unlike MsSql/Oracle/Redshift/Sqlite/Teradata, which - // explicitly strip it. This test exists to make that asymmetry - // explicit and catch it if DuckDB's contract ever changes. - let escaped = SqlDialect::DuckDb.escape_string_as_string("a\0b"); - assert_eq!(escaped, "a\0b"); - } - - // --------------------------------------------------------------------- - // 4. Control characters (newline, tab, CR, and other ASCII control codes). - // --------------------------------------------------------------------- - - #[test] - fn backslash_escaping_dialects_never_emit_a_raw_newline_or_cr() { - // \n and \r get an explicit backslash-escape arm in every - // backslash-escaping dialect's implementation. - for dialect in BACKSLASH_ESCAPING_DIALECTS { - let escaped = dialect.escape_string_as_string("a\nb\rc"); - assert!( - !escaped.contains('\n') && !escaped.contains('\r'), - "{} left a raw \\n or \\r in escaped output (got {escaped:?})", - dialect.name() - ); - } - } - - #[test] - fn most_backslash_escaping_dialects_also_escape_tab() { - // MySQL's implementation intentionally has no explicit tab arm (a - // literal tab is not a syntax hazard inside a MySQL string literal, - // unlike a quote or backslash) so it's excluded here. Every other - // backslash-escaping dialect does translate \t -> \\t. - let tab_escaping: Vec = BACKSLASH_ESCAPING_DIALECTS - .iter() - .cloned() - .filter(|d| *d != SqlDialect::MySql) - .collect(); - - for dialect in tab_escaping { - let escaped = dialect.escape_string_as_string("a\tb"); - assert!( - !escaped.contains('\t'), - "{} left a raw tab in escaped output (got {escaped:?})", - dialect.name() - ); - } - } - - #[test] - fn mysql_passes_a_raw_tab_through_unchanged() { - // Documents MySQL's actual (intentional) behavior explicitly, so a - // future change is a deliberate decision rather than a silent drift. - let escaped = SqlDialect::MySql.escape_string_as_string("a\tb"); - assert_eq!(escaped, "a\tb"); - } - - #[test] - fn quote_only_dialects_pass_newline_cr_tab_through_unchanged() { - // These dialects only double single quotes; per their own doc - // comments they don't treat backslash (or \n/\r/\t) as special. - // A raw newline etc. inside '...' is syntactically valid in these - // engines and is NOT an injection risk on its own — only the quote - // character matters for breaking out of the literal. - for dialect in QUOTE_ONLY_DIALECTS.iter() { - let escaped = dialect.escape_string_as_string("a\nb\rc\td"); - assert_eq!( - escaped, - "a\nb\rc\td", - "{} was expected to pass control chars through unchanged (got {escaped:?})", - dialect.name() - ); - } - } - - #[test] - fn every_dialect_neutralizes_low_ascii_control_bytes() { - // 0x01..=0x1F (excluding \n=0x0A, \r=0x0D, \t=0x09 already covered) - // and 0x7F (DEL) are the classic "invisible injection" characters. - // At minimum, no dialect should pass them through raw next to - // unescaped quotes in a way that changes the quote count. - for dialect in ALL_DIALECTS { - for byte in [0x01u8, 0x07, 0x1b, 0x1f, 0x7f] { - let ch = byte as char; - let input = format!("x{ch}y"); - let escaped = dialect.escape_string_as_string(&input); - // The escape function must not panic and must not introduce - // an unbalanced quote by accident. - assert_eq!( - escaped.matches('\'').count() % 2, - 0, - "{} produced an odd number of quotes for control byte 0x{byte:02X} (got \ - {escaped:?})", - dialect.name() - ); - } - } - } - - // --------------------------------------------------------------------- - // 5. End-to-end injection safety: escape_string + quote_string together - // must never let attacker input terminate the string literal early. - // --------------------------------------------------------------------- - - /// A grab-bag of classic SQL-injection-style payloads. - const HOSTILE_INPUTS: &[&str] = &[ - "", - "'", - "''", - "'; DROP TABLE users; --", - "' OR '1'='1", - "\\", - "\\'", - "\\\\", - "\\'; DROP TABLE users; --", - "a\0b", - "\n\r\t", - "'\0'", - "\\\0'", - "šŸ˜€'; DROP TABLE users;--šŸ˜€", - "back\\slash'quote\"doublequote", - ]; - - /// For a single-quoted-literal dialect, walks the fully escaped+quoted - /// output character by character (respecting the dialect's own escape - /// convention) and confirms the literal's closing quote is genuinely the - /// last unescaped quote — i.e. nothing in the payload could prematurely - /// close the string. - /// - /// Operates entirely in `char` space (never byte-slicing a `&str`) so - /// multi-byte UTF-8 content (e.g. emoji) can't desync index arithmetic. - fn assert_no_premature_close(dialect: &SqlDialect, quoted: &str, uses_backslash_escapes: bool) { - let chars: Vec = quoted.chars().collect(); - - let first_quote_idx = chars.iter().position(|&c| c == '\'').unwrap_or_else(|| { - panic!( - "{}: quoted output has no opening quote: {quoted:?}", - dialect.name() - ) - }); - - let body = &chars[first_quote_idx + 1..]; - let mut i = 0; - let mut closed_at = None; - while i < body.len() { - let c = body[i]; - if uses_backslash_escapes && c == '\\' { - // Skip the escaped character (backslash always consumes the - // next char in these dialects' escape schemes). - i += 2; - continue; - } - if c == '\'' { - // A doubled quote (''), representing one literal quote, - // does NOT close the string — only a single trailing quote - // with no partner does. - if body.get(i + 1) == Some(&'\'') { - i += 2; - continue; - } - closed_at = Some(i); - break; - } - i += 1; - } - - let closed_at = closed_at.unwrap_or_else(|| { - panic!( - "{}: could not find a closing quote at all in {quoted:?}", - dialect.name() - ) - }); - - // The closing quote we found must be the LAST character of the - // string (i.e. nothing meaningful follows it) — otherwise the - // payload managed to close the literal early and inject trailing SQL. - let remainder: String = body[closed_at + 1..].iter().collect(); - assert!( - remainder.is_empty(), - "{}: literal closed early, leaving trailing unescaped content {remainder:?} in \ - {quoted:?}", - dialect.name() - ); - } - - #[test] - fn no_hostile_input_can_break_out_of_the_string_literal() { - for dialect in ALL_DIALECTS { - let uses_backslash = BACKSLASH_ESCAPING_DIALECTS.contains(&dialect); - for payload in HOSTILE_INPUTS { - let escaped = dialect.escape_string_as_string(payload); - let quoted = dialect.quote_string_as_string(&escaped); - assert_no_premature_close(dialect, "ed, uses_backslash); - } - } - } - - #[test] - fn no_hostile_input_ever_panics_any_dialect_function() { - // A panic (e.g. from an unwrap on malformed UTF-8 boundary slicing) - // is itself a correctness bug worth catching explicitly. - for dialect in ALL_DIALECTS { - for payload in HOSTILE_INPUTS { - let escaped = dialect.escape_string_as_string(payload); - let _ = dialect.quote_string_as_string(&escaped); - } - } - } - - // --------------------------------------------------------------------- - // 6. Unicode correctness: multi-byte scalars must survive untouched. - // --------------------------------------------------------------------- - - #[test] - fn unicode_content_passes_through_unmangled() { - let samples = ["hĆ©llo", "ę—„ęœ¬čŖž", "emojišŸ˜€šŸŽ‰", "ƑoƱo", "Straße"]; - for dialect in ALL_DIALECTS { - for sample in samples { - let escaped = dialect.escape_string_as_string(sample); - assert_eq!( - escaped, - sample, - "{} corrupted unicode input {sample:?} (got {escaped:?})", - dialect.name() - ); - // Must still be valid UTF-8 after quoting (String guarantees - // this, but we assert to document the expectation). - let quoted = dialect.quote_string_as_string(&escaped); - assert!(quoted.contains(sample)); - } - } - } - - #[test] - fn unicode_mixed_with_quotes_still_escapes_correctly() { - for dialect in ALL_DIALECTS { - let escaped = dialect.escape_string_as_string("ę—„ęœ¬čŖž'quote'šŸ˜€"); - let uses_backslash = BACKSLASH_ESCAPING_DIALECTS.contains(&dialect); - let quote_marker_count = if uses_backslash { - escaped.matches("\\'").count() - } else { - escaped.matches("''").count() - }; - assert_eq!( - quote_marker_count, - 2, - "{}: expected both quotes in the unicode string to be escaped (got {escaped:?})", - dialect.name() - ); - assert!(escaped.contains("ę—„ęœ¬čŖž")); - assert!(escaped.contains('šŸ˜€')); - } - } - - // --------------------------------------------------------------------- - // 7. Empty-input edge cases. - // --------------------------------------------------------------------- - - #[test] - fn empty_string_escapes_to_empty_string() { - for dialect in ALL_DIALECTS { - assert_eq!(dialect.escape_string_as_string(""), ""); - } - } - - #[test] - fn empty_string_quoted_is_just_the_delimiters() { - for dialect in ALL_DIALECTS { - let quoted = dialect.quote_string_as_string(""); - // Must contain exactly 2 single quotes and nothing else meaningful - // (allowing for a dialect-specific prefix like `E` or `N`). - assert_eq!( - quoted.matches('\'').count(), - 2, - "{}: empty quoted string should have exactly one open/close pair (got {quoted:?})", - dialect.name() - ); - } - } - - // --------------------------------------------------------------------- - // 8. encode_bytes: hex correctness, case, empty/boundary input. - // --------------------------------------------------------------------- - - #[test] - fn encode_bytes_empty_slice_does_not_panic_and_contains_no_hex_pairs() { - for dialect in ALL_DIALECTS { - let out = dialect.encode_bytes_as_string(&[]); - // No stray "00" or similar should appear from an off-by-one bug. - assert!( - !out.is_empty(), - "{}: encode_bytes(&[]) produced an empty string, expected at least wrapper syntax", - dialect.name() - ); - } - } - - #[test] - fn encode_bytes_uses_uppercase_hex_and_round_trips_all_byte_values() { - let bytes: Vec = (0u8..=255).collect(); - - // BigQuery renders each byte as its own \xNN escape (not a - // contiguous hex run via the shared bytes_to_hex() helper), so it's - // checked separately below. - let plain_hex_run_dialects: Vec = ALL_DIALECTS - .iter() - .cloned() - .filter(|d| *d != SqlDialect::BigQuery) - .collect(); - - // The uppercase hex encoding of 0x00..=0xFF, in order — this is - // exactly what bytes_to_hex() must produce, byte for byte. Checking - // for this literal substring (rather than scanning all hex-looking - // characters in the output) avoids false positives from incidental - // lowercase letters in surrounding syntax like "bytea" or "'hex'". - let expected_hex_run: String = bytes.iter().map(|b| format!("{:02X}", b)).collect(); - - for dialect in plain_hex_run_dialects { - let out = dialect.encode_bytes_as_string(&bytes); - assert!( - out.contains(&expected_hex_run), - "{}: encode_bytes did not contain the expected uppercase hex run for all 256 byte \ - values (got {out:?})", - dialect.name() - ); - } - - // BigQuery: each byte must appear as its own \xNN, in order, with - // uppercase hex digits. - let expected_bigquery: String = bytes.iter().map(|b| format!("\\x{:02X}", b)).collect(); - let bigquery_out = SqlDialect::BigQuery.encode_bytes_as_string(&bytes); - assert!( - bigquery_out.contains(&expected_bigquery), - "BigQuery: encode_bytes did not contain the expected \\xNN-per-byte run (got \ - {bigquery_out:?})" - ); - } - - #[test] - fn encode_bytes_single_byte_zero_and_single_byte_max() { - for dialect in ALL_DIALECTS { - let zero = dialect.encode_bytes_as_string(&[0x00]); - let max = dialect.encode_bytes_as_string(&[0xFF]); - assert!(zero.contains("00"), "{}: {zero:?}", dialect.name()); - assert!(max.contains("FF"), "{}: {max:?}", dialect.name()); - assert_ne!(zero, max); - } - } - - // --------------------------------------------------------------------- - // 9. encode_bits: boundary lengths, all-zero, all-one, longer-than-i64. - // --------------------------------------------------------------------- - - #[test] - fn encode_bits_all_zero_and_all_one_single_bit() { - for dialect in ALL_DIALECTS { - let zero = dialect.encode_bits_as_string("0"); - let one = dialect.encode_bits_as_string("1"); - assert_ne!( - zero, - one, - "{}: encode_bits(\"0\") and encode_bits(\"1\") must differ", - dialect.name() - ); - } - } - - #[test] - fn encode_bits_does_not_panic_on_64_bit_boundary() { - // Exactly 64 ones is the classic off-by-one boundary for - // i64::from_str_radix-based implementations. - let sixty_four_ones = "1".repeat(64); - for dialect in ALL_DIALECTS { - let _ = dialect.encode_bits_as_string(&sixty_four_ones); - } - } - - #[test] - fn encode_bits_does_not_panic_on_overflow_longer_than_i64() { - // More than 64 bits overflows i64::from_str_radix; dialects that - // parse via i64 fall back to 0 on error rather than panicking. This - // test locks in "no panic" as the contract, regardless of the - // fallback value chosen. - let too_many_ones = "1".repeat(100); - for dialect in ALL_DIALECTS { - let _ = dialect.encode_bits_as_string(&too_many_ones); - } - } - - #[test] - fn encode_bits_empty_string_does_not_panic() { - for dialect in ALL_DIALECTS { - let _ = dialect.encode_bits_as_string(""); - } - } - - #[test] - fn encode_bits_known_value_is_consistent_across_dialects_that_parse_as_integer() { - // "10110101" == 181 decimal. Every dialect that renders this via an - // integer cast should have "181" appear somewhere in its output. - let bits = "10110101"; - let integer_based = [ - SqlDialect::BigQuery, // CAST(0b... AS INT64) -- uses 0b, not decimal - SqlDialect::Databricks, // CAST(...L AS BINARY) -- decimal - SqlDialect::Hive, // CAST(... AS BIGINT) -- decimal - SqlDialect::MsSql, // CAST(... AS BIGINT) -- decimal - SqlDialect::Oracle, // bare decimal - SqlDialect::Redshift, // CAST(... AS BIGINT) -- decimal - SqlDialect::Snowflake, // TO_HEX(...) -- decimal appears inside TO_HEX( - SqlDialect::Spark, // CAST(... AS BIGINT) -- decimal - SqlDialect::Sqlite, // bare decimal - ]; - for dialect in &integer_based { - let out = dialect.encode_bits_as_string(bits); - if *dialect == SqlDialect::BigQuery { - assert!( - out.contains("0b10110101"), - "{}: expected binary literal form (got {out:?})", - dialect.name() - ); - } else { - assert!( - out.contains("181"), - "{}: expected decimal value 181 to appear in output (got {out:?})", - dialect.name() - ); - } - } - } - - #[test] - fn encode_bits_literal_style_dialects_preserve_the_bit_string_verbatim() { - // Dialects with a native bit-string literal syntax should embed the - // exact input bits, not a numeric conversion. - let bits = "10110101"; - assert_eq!( - SqlDialect::PostgreSql.encode_bits_as_string(bits), - "B'10110101'" - ); - assert_eq!( - SqlDialect::DuckDb.encode_bits_as_string(bits), - "B'10110101'" - ); - assert_eq!(SqlDialect::MySql.encode_bits_as_string(bits), "b'10110101'"); - assert_eq!( - SqlDialect::Teradata.encode_bits_as_string(bits), - "'10110101'B" - ); - } - - // --------------------------------------------------------------------- - // 10. quote_string wrapping sanity: dialect-specific prefixes/suffixes. - // --------------------------------------------------------------------- - - #[test] - fn quote_string_wraps_with_expected_prefix_per_dialect() { - let escaped = "abc"; - assert!( - SqlDialect::PostgreSql - .quote_string_as_string(escaped) - .starts_with("E'") - ); - assert!( - SqlDialect::MsSql - .quote_string_as_string(escaped) - .starts_with("N'") - ); - // Plain single-quote wrapping dialects: no special prefix letter. - for dialect in &[ - SqlDialect::BigQuery, - SqlDialect::MySql, - SqlDialect::Sqlite, - SqlDialect::Snowflake, - SqlDialect::Oracle, - SqlDialect::Redshift, - SqlDialect::Teradata, - SqlDialect::Spark, - SqlDialect::Hive, - SqlDialect::Databricks, - SqlDialect::DuckDb, - ] { - let quoted = dialect.quote_string_as_string(escaped); - assert!( - quoted.starts_with('\''), - "{}: expected plain single-quote wrapping (got {quoted:?})", - dialect.name() - ); - } - } - - #[test] - fn quote_string_always_ends_with_a_single_quote() { - for dialect in ALL_DIALECTS { - let quoted = dialect.quote_string_as_string("abc"); - assert!( - quoted.ends_with('\''), - "{}: quoted string should end with a closing quote (got {quoted:?})", - dialect.name() - ); - } + to_string(|buf| self.encode_bits(buf, bits)) } } diff --git a/src/native/dialect/mssql.rs b/src/native/dialect/mssql.rs index f383f39..c5af7af 100644 --- a/src/native/dialect/mssql.rs +++ b/src/native/dialect/mssql.rs @@ -1,15 +1,17 @@ -//! A dialect for [Microsoft SQL Server](https://www.microsoft.com/en-us/sql-server/). +//! [Microsoft SQL Server](https://www.microsoft.com/en-us/sql-server/) (T-SQL). //! -//! • Strings: single-quoted; `''` for embedded single-quote. -//! Unicode strings prefixed with N'…'. -//! • Identifiers: square-bracket quoted: [name]. -//! • Bytes: 0xDEADBEEF (no quotes). -//! • Bits: Cast integer to BIT; MSSQL BIT is boolean-like (0/1). -//! • Bool: 1 / 0 (BIT type; no TRUE/FALSE keyword). +//! • Strings: `N'…'` (NVARCHAR). Only `'` is special (`''`); NUL is stripped +//! because T-SQL string literals cannot hold U+0000. +//! • Identifiers: `[name]`; `]` doubled as `]]`. +//! • Bytes: `0xDEADBEEF` (no quotes). +//! • Bits: T-SQL `BIT` is boolean-like; multi-bit values become +//! `CAST( AS BIGINT)`. +//! • Bool: `1` / `0` (no `TRUE`/`FALSE` keyword). +//! • Placeholders: `@name` (native). //! //! Ref: -use crate::internal::utils::{replace_ascii_to, replace_to}; +use crate::internal::utils::replace_to; use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; @@ -17,46 +19,32 @@ pub(super) const NAME: &str = "MsSql"; pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Int; -pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::SquareBracket); +pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::SquareBracket; pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::AtSign; -pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { - // T-SQL: only single-quote needs doubling. - // NUL character (U+0000) is not allowed in T-SQL string literals. - // We remove it to stay safe. - const REPLACEMENTS: [(u8, &str); 2] = [(b'\'', "''"), (b'\0', "")]; - replace_ascii_to(string, &REPLACEMENTS, writer) +pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { + super::escape_quote_and_strip_nul(writer, string) } -pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { - // Prefix with N for Unicode safety (NVARCHAR) - write!(writer, "N'{string}'") +pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { + writer.write_str("N'")?; + writer.write_str(string)?; + writer.write_char('\'') } -pub(super) fn escape_identifier( - writer: &mut T, +pub(super) fn escape_identifier( + writer: &mut W, ident: &str, ) -> std::fmt::Result { replace_to(ident, ']', "]]", writer) } -pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { - // T-SQL binary literal: 0xDEADBEEF +pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { writer.write_str("0x")?; super::bytes_to_hex(writer, bytes) } -pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { - debug_assert!( - bits.chars().all(|c| c == '0' || c == '1'), - "encode_bits: invalid input: {bits}" - ); - - // MSSQL BIT is a 1-bit integer; for multi-bit we cast to integer - write!( - writer, - "CAST({} AS BIGINT)", - i64::from_str_radix(bits, 2).unwrap_or(0) - ) +pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { + write!(writer, "CAST({} AS BIGINT)", super::bits_as_i64(bits)) } diff --git a/src/native/dialect/mysql.rs b/src/native/dialect/mysql.rs index cda59fb..c87d767 100644 --- a/src/native/dialect/mysql.rs +++ b/src/native/dialect/mysql.rs @@ -1,10 +1,12 @@ -//! A dialect for [MySQL](https://www.mysql.com/). +//! [MySQL](https://www.mysql.com/). //! -//! • Strings: single-quoted; backslash-escape sequences. -//! • Identifiers: back-tick quoted. -//! • Bytes: X'DEADBEEF' or 0xDEADBEEF. -//! • Bits: b'10110101' bit literal. -//! • Bool: 1 / 0 (TINYINT; no native BOOLEAN literal). +//! • Strings: single-quoted; backslash-escape sequences. A raw tab is left +//! as-is (not a syntax hazard). `\x1A` (Ctrl+Z / Windows EOF) becomes `\Z`. +//! • Identifiers: backtick-quoted; backtick doubled. +//! • Bytes: `X'DEADBEEF'`. +//! • Bits: `b'10110101'` bit literal. +//! • Bool: `1` / `0` (`TINYINT`; no native BOOLEAN literal). +//! • Placeholders: `:name` (not native; MySQL uses `?`. Common clients rewrite). //! //! Ref: //! @@ -17,12 +19,11 @@ pub(super) const NAME: &str = "MySql"; pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Int; -pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::Backtick); +pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::Backtick; -/// This is not natively supported, and should be rendered on client-side, and many clients do. pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; -pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { +pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { for ch in string.chars() { match ch { '\\' => writer.write_str("\\\\")?, @@ -31,36 +32,29 @@ pub(super) fn escape_string(writer: &mut T, string: &str) -> '\0' => writer.write_str("\\0")?, '\n' => writer.write_str("\\n")?, '\r' => writer.write_str("\\r")?, - '\x1a' => writer.write_str("\\Z")?, // Ctrl+Z / SUB (Windows EOF) + '\x1a' => writer.write_str("\\Z")?, c => writer.write_char(c)?, } } Ok(()) } -pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { +pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { super::wrap_single_quote(writer, string) } -pub(super) fn escape_identifier( - writer: &mut T, +pub(super) fn escape_identifier( + writer: &mut W, ident: &str, ) -> std::fmt::Result { replace_to(ident, '`', "``", writer) } -pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { - // write!(writer, "X'{}'", super::bytes_to_hex(writer, bytes)) - writer.write_str("X'")?; - super::bytes_to_hex(writer, bytes)?; - writer.write_char('\'') +pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { + super::write_x_hex_literal(writer, bytes) } -pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { - debug_assert!( - bits.chars().all(|c| c == '0' || c == '1'), - "encode_bits: invalid input: {bits}" - ); - - write!(writer, "b'{}'", bits) +pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { + super::debug_assert_bits(bits); + write!(writer, "b'{bits}'") } diff --git a/src/native/dialect/oracle.rs b/src/native/dialect/oracle.rs index 4af802f..5cea7fb 100644 --- a/src/native/dialect/oracle.rs +++ b/src/native/dialect/oracle.rs @@ -1,16 +1,17 @@ -//! A dialect for [Oracle Databases](https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/index.html). +//! [Oracle Database](https://docs.oracle.com/en/database/oracle/oracle-database/). //! -//! Ref: +//! • Strings: single-quoted; only `'` is special (`''`). NUL is stripped +//! (`VARCHAR2` cannot hold it). +//! • Identifiers: double-quote quoted; `"` doubled. +//! • Bytes: `HEXTORAW('DEADBEEF')`. +//! • Bits: no native bit literal; integer value. +//! • Bool: `1` / `0`. SQL BOOLEAN exists only from 23c; this keeps pre-23c +//! compatibility. +//! • Placeholders: `:name` (native). //! -//! • Strings: single-quoted; `''` for embedded single-quote. -//! Alternatively q'[…]' alternative quoting. -//! • Identifiers: double-quote quoted. -//! • Bytes: HEXTORAW('DEADBEEF'). -//! • Bits: No native bit literal; use integer value. -//! • Bool: Oracle < 23c has no BOOLEAN in SQL; 23c onwards has TRUE/FALSE. -//! We default to 1/0 for broadest compatibility. +//! Ref: -use crate::internal::utils::{replace_ascii_to, replace_to}; +use crate::internal::utils::replace_to; use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; @@ -18,39 +19,31 @@ pub(super) const NAME: &str = "Oracle"; pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Int; -pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::DoubleQuote); +pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::DoubleQuote; pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; -pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { - // Oracle: only single-quote doubling; no backslash escaping. - // NUL bytes are not supported in VARCHAR2 literals. - const REPLACEMENTS: [(u8, &str); 2] = [(b'\'', "''"), (b'\0', "")]; - replace_ascii_to(string, &REPLACEMENTS, writer) +pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { + super::escape_quote_and_strip_nul(writer, string) } -pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { +pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { super::wrap_single_quote(writer, string) } -pub(super) fn escape_identifier( - writer: &mut T, +pub(super) fn escape_identifier( + writer: &mut W, ident: &str, ) -> std::fmt::Result { replace_to(ident, '"', "\"\"", writer) } -pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { +pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { writer.write_str("HEXTORAW('")?; super::bytes_to_hex(writer, bytes)?; writer.write_str("')") } -pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { - debug_assert!( - bits.chars().all(|c| c == '0' || c == '1'), - "encode_bits: invalid input: {bits}" - ); - - write!(writer, "{}", i64::from_str_radix(bits, 2).unwrap_or(0)) +pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { + write!(writer, "{}", super::bits_as_i64(bits)) } diff --git a/src/native/dialect/postgresql.rs b/src/native/dialect/postgresql.rs index 5a732bc..8b75bbe 100644 --- a/src/native/dialect/postgresql.rs +++ b/src/native/dialect/postgresql.rs @@ -1,11 +1,14 @@ -//! A dialect for [PostgreSQL](https://www.postgresql.org/). +//! [PostgreSQL](https://www.postgresql.org/). //! -//! • Strings: single-quoted with `''` doubling. -//! For control chars we use E'…' escape strings with \xNN. -//! • Identifiers: double-quote quoted. -//! • Bytes: '\xDEADBEEF'::bytea (hex format, PostgreSQL 9.0+). -//! • Bits: B'10110101' bit string literal. -//! • Bool: TRUE / FALSE keywords. +//! • Strings: always `E'…'` escape-string syntax so `\n` / `\xNN` are +//! interpreted. Standard `'…'` with `''` doubling would not honor those +//! sequences (and `standard_conforming_strings` is on by default). +//! • Identifiers: double-quote quoted; `"` doubled. +//! • Bytes: `'\xDEADBEEF'::bytea` (hex format, 9.0+). +//! • Bits: `B'10110101'` bit-string literal. +//! • Bool: `TRUE` / `FALSE`. +//! • Placeholders: `:name` (not native; Postgres uses `$1`. Common clients +//! rewrite named params). //! //! Ref: //! @@ -19,14 +22,11 @@ pub(super) const NAME: &str = "PostgreSql"; pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; -pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::DoubleQuote); +pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::DoubleQuote; -/// This is not natively supported, and should be rendered on client-side, and many clients do. pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; -pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { - // Use PostgreSQL escape-string syntax (E'…') when control chars present. - // We always emit E'…' for safety; non-special chars pass through. +pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { for ch in string.chars() { match ch { '\\' => writer.write_str("\\\\")?, @@ -36,7 +36,7 @@ pub(super) fn escape_string(writer: &mut T, string: &str) -> '\t' => writer.write_str("\\t")?, '\0' => writer.write_str("\\x00")?, c if (c as u32) < 32 || c as u32 == 127 => { - writer.write_str(&format!("\\x{:02X}", c as u32))?; + super::write_byte_hex_escape(writer, c as u8)?; } c => writer.write_char(c)?, } @@ -44,30 +44,26 @@ pub(super) fn escape_string(writer: &mut T, string: &str) -> Ok(()) } -pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { - // Use E'…' escape string syntax so our \-sequences are interpreted. - write!(writer, "E'{string}'") +pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { + writer.write_str("E'")?; + writer.write_str(string)?; + writer.write_char('\'') } -pub(super) fn escape_identifier( - writer: &mut T, +pub(super) fn escape_identifier( + writer: &mut W, ident: &str, ) -> std::fmt::Result { replace_to(ident, '"', "\"\"", writer) } -pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { - // PostgreSQL hex-format bytea literal +pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { writer.write_str("'\\x")?; super::bytes_to_hex(writer, bytes)?; writer.write_str("'::bytea") } -pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { - debug_assert!( - bits.chars().all(|c| c == '0' || c == '1'), - "encode_bits: invalid input: {bits}" - ); - - write!(writer, "B'{}'", bits) +pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { + super::debug_assert_bits(bits); + write!(writer, "B'{bits}'") } diff --git a/src/native/dialect/redshift.rs b/src/native/dialect/redshift.rs index 351fe45..fb237a6 100644 --- a/src/native/dialect/redshift.rs +++ b/src/native/dialect/redshift.rs @@ -1,17 +1,19 @@ -//! A dialect for [RedShift](https://aws.amazon.com/redshift/). +//! [Amazon Redshift](https://aws.amazon.com/redshift/). //! -//! • Strings: single-quoted; `''` for embedded single-quote. -//! Redshift is PostgreSQL-derived but does NOT support E'…' by default. -//! Backslash in default mode is literal (not escape); so we double quotes only. -//! • Identifiers: double-quote quoted. -//! • Bytes: VARBYTE from hex via FROM_HEX or TO_VARBYTE. -//! • Bits: No native bit literal; cast integer. -//! • Bool: TRUE / FALSE keywords (BOOLEAN type). +//! Postgres-derived, but **not** Postgres: default mode does not treat +//! backslash as an escape, and `E'…'` is not used. +//! +//! • Strings: single-quoted; only `'` is special (`''`). NUL is stripped. +//! • Identifiers: double-quote quoted; `"` doubled. +//! • Bytes: `TO_VARBYTE('', 'hex')`. +//! • Bits: no native bit literal; `CAST( AS BIGINT)`. +//! • Bool: `TRUE` / `FALSE`. +//! • Placeholders: `:name` (not native; rewritten client-side). //! //! Ref: //! -use crate::internal::utils::{replace_ascii_to, replace_to}; +use crate::internal::utils::replace_to; use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; @@ -19,45 +21,31 @@ pub(super) const NAME: &str = "Redshift"; pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; -pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::DoubleQuote); +pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::DoubleQuote; -/// This is not natively supported, and should be rendered on client-side. pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; -pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { - // Redshift: backslash is NOT an escape character in standard mode. - // Only `'` needs doubling. - // NUL (U+0000) is not supported in VARCHAR literals. - const REPLACEMENTS: [(u8, &str); 2] = [(b'\'', "''"), (b'\0', "")]; - replace_ascii_to(string, &REPLACEMENTS, writer) +pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { + super::escape_quote_and_strip_nul(writer, string) } -pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { +pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { super::wrap_single_quote(writer, string) } -pub(super) fn escape_identifier( - writer: &mut T, +pub(super) fn escape_identifier( + writer: &mut W, ident: &str, ) -> std::fmt::Result { replace_to(ident, '"', "\"\"", writer) } -pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { +pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { writer.write_str("TO_VARBYTE('")?; super::bytes_to_hex(writer, bytes)?; writer.write_str("', 'hex')") } -pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { - debug_assert!( - bits.chars().all(|c| c == '0' || c == '1'), - "encode_bits: invalid input: {bits}" - ); - - write!( - writer, - "CAST({} AS BIGINT)", - i64::from_str_radix(bits, 2).unwrap_or(0) - ) +pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { + write!(writer, "CAST({} AS BIGINT)", super::bits_as_i64(bits)) } diff --git a/src/native/dialect/snowflake.rs b/src/native/dialect/snowflake.rs index c291b63..2aefb56 100644 --- a/src/native/dialect/snowflake.rs +++ b/src/native/dialect/snowflake.rs @@ -1,10 +1,12 @@ -//! A dialect for [Snowflake](https://www.snowflake.com/). +//! [Snowflake](https://www.snowflake.com/). //! -//! • Strings: single-quoted; backslash-escape sequences. -//! • Identifiers: double-quote quoted. -//! • Bytes: X'DEADBEEF' hex literal (BINARY type). -//! • Bits: No native bit literal; use TO_BINARY with integer. -//! • Bool: TRUE / FALSE keywords. +//! • Strings: single-quoted; backslash-escape sequences (`\xNN` for other +//! control chars). +//! • Identifiers: double-quote quoted; `"` doubled. +//! • Bytes: `X'DEADBEEF'` hex literal (BINARY). +//! • Bits: no native bit literal; `TO_BINARY(LPAD(TO_HEX(), 16, '0'), 'HEX')`. +//! • Bool: `TRUE` / `FALSE`. +//! • Placeholders: `:name` (native-ish; Snowflake also binds by position). //! //! Ref: //! @@ -17,11 +19,11 @@ pub(super) const NAME: &str = "Snowflake"; pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; -pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::DoubleQuote); +pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::DoubleQuote; pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; -pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { +pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { for ch in string.chars() { match ch { '\\' => writer.write_str("\\\\")?, @@ -31,7 +33,7 @@ pub(super) fn escape_string(writer: &mut T, string: &str) -> '\t' => writer.write_str("\\t")?, '\0' => writer.write_str("\\0")?, c if (c as u32) < 32 || c as u32 == 127 => { - writer.write_str(&format!("\\x{:02X}", c as u32))?; + super::write_byte_hex_escape(writer, c as u8)?; } c => writer.write_char(c)?, } @@ -39,33 +41,25 @@ pub(super) fn escape_string(writer: &mut T, string: &str) -> Ok(()) } -pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { +pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { super::wrap_single_quote(writer, string) } -pub(super) fn escape_identifier( - writer: &mut T, +pub(super) fn escape_identifier( + writer: &mut W, ident: &str, ) -> std::fmt::Result { replace_to(ident, '"', "\"\"", writer) } -pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { - // write!(writer, "X'{}'", super::bytes_to_hex(writer, bytes)) - writer.write_str("X'")?; - super::bytes_to_hex(writer, bytes)?; - writer.write_char('\'') +pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { + super::write_x_hex_literal(writer, bytes) } -pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { - debug_assert!( - bits.chars().all(|c| c == '0' || c == '1'), - "encode_bits: invalid input: {bits}" - ); - +pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { write!( writer, "TO_BINARY(LPAD(TO_HEX({}), 16, '0'), 'HEX')", - i64::from_str_radix(bits, 2).unwrap_or(0) + super::bits_as_i64(bits) ) } diff --git a/src/native/dialect/spark.rs b/src/native/dialect/spark.rs index ed20f79..9925f4d 100644 --- a/src/native/dialect/spark.rs +++ b/src/native/dialect/spark.rs @@ -1,12 +1,12 @@ -//! A dialect for [Apache Spark SQL](https://spark.apache.org/docs/latest/sql-ref.html). +//! [Apache Spark SQL](https://spark.apache.org/docs/latest/sql-ref.html). //! -//! See . -//! -//! • Strings: single-quoted; backslash-escape sequences (Java/Scala conventions). -//! • Identifiers: back-tick quoted. -//! • Bytes: X'DEADBEEF' hex literal. -//! • Bits: No native bit literal; cast integer. -//! • Bool: TRUE / FALSE keywords. +//! • Strings: single-quoted; Java/Scala backslash escapes (`\uNNNN` for +//! other control chars). +//! • Identifiers: backtick-quoted; backtick doubled. +//! • Bytes: `X'DEADBEEF'` hex literal. +//! • Bits: no native bit literal; `CAST( AS BIGINT)`. +//! • Bool: `TRUE` / `FALSE`. +//! • Placeholders: `:name` (not native; rewritten client-side). //! //! Ref: @@ -18,11 +18,11 @@ pub(super) const NAME: &str = "Spark"; pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; -pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::Backtick); +pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::Backtick; pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; -pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { +pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { for ch in string.chars() { match ch { '\\' => writer.write_str("\\\\")?, @@ -33,7 +33,7 @@ pub(super) fn escape_string(writer: &mut T, string: &str) -> '\t' => writer.write_str("\\t")?, '\0' => writer.write_str("\\0")?, c if (c as u32) < 32 || c as u32 == 127 => { - writer.write_str(&format!("\\u{:04X}", c as u32))?; + super::write_unicode4_escape(writer, c as u32)?; } c => writer.write_char(c)?, } @@ -41,33 +41,21 @@ pub(super) fn escape_string(writer: &mut T, string: &str) -> Ok(()) } -pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { +pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { super::wrap_single_quote(writer, string) } -pub(super) fn escape_identifier( - writer: &mut T, +pub(super) fn escape_identifier( + writer: &mut W, ident: &str, ) -> std::fmt::Result { replace_to(ident, '`', "``", writer) } -pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { - // write!(writer, "X'{}'", super::bytes_to_hex(writer, bytes)) - writer.write_str("X'")?; - super::bytes_to_hex(writer, bytes)?; - writer.write_char('\'') +pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { + super::write_x_hex_literal(writer, bytes) } -pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { - debug_assert!( - bits.chars().all(|c| c == '0' || c == '1'), - "encode_bits: invalid input: {bits}" - ); - - write!( - writer, - "CAST({} AS BIGINT)", - i64::from_str_radix(bits, 2).unwrap_or(0) - ) +pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { + write!(writer, "CAST({} AS BIGINT)", super::bits_as_i64(bits)) } diff --git a/src/native/dialect/sqlite.rs b/src/native/dialect/sqlite.rs index 3834126..e1552c4 100644 --- a/src/native/dialect/sqlite.rs +++ b/src/native/dialect/sqlite.rs @@ -1,15 +1,18 @@ -//! A dialect for [SQLite](https://www.sqlite.org). +//! [SQLite](https://www.sqlite.org/). //! -//! • Strings: single-quoted; `''` for embedded single-quote. -//! SQLite does NOT interpret backslash escapes in string literals. -//! • Identifiers: double-quote quoted (also supports back-tick and […]). -//! • Bytes: X'DEADBEEF' BLOB hex literal. -//! • Bits: No native bit literal; use integer value. -//! • Bool: 1 / 0 (no native BOOLEAN type). +//! • Strings: single-quoted; only `'` is special (`''`). Backslash is +//! **not** an escape. NUL is stripped because the C API treats U+0000 as +//! a terminator. +//! • Identifiers: double-quote quoted (SQLite also accepts backticks and +//! `[…]`; we standardize on `"`). +//! • Bytes: `X'DEADBEEF'` BLOB hex literal. +//! • Bits: no native bit literal; integer value. +//! • Bool: `1` / `0` (no native BOOLEAN type). +//! • Placeholders: `:name` (native; SQLite also accepts `?` / `$n` / `@name`). //! //! Ref: -use crate::internal::utils::{replace_ascii_to, replace_to}; +use crate::internal::utils::replace_to; use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; @@ -17,41 +20,29 @@ pub(super) const NAME: &str = "Sqlite"; pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Int; -pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::DoubleQuote); +pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::DoubleQuote; pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; -pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { - // SQLite does not recognise backslash escape sequences. - // NUL character (U+0000) terminates the string in the C library; - // we remove it to be safe. - const REPLACEMENTS: [(u8, &str); 2] = [(b'\'', "''"), (b'\0', "")]; - replace_ascii_to(string, &REPLACEMENTS, writer) +pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { + super::escape_quote_and_strip_nul(writer, string) } -pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { +pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { super::wrap_single_quote(writer, string) } -pub(super) fn escape_identifier( - writer: &mut T, +pub(super) fn escape_identifier( + writer: &mut W, ident: &str, ) -> std::fmt::Result { replace_to(ident, '"', "\"\"", writer) } -pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { - // write!(writer, "X'{}'", super::bytes_to_hex(writer, bytes)) - writer.write_str("X'")?; - super::bytes_to_hex(writer, bytes)?; - writer.write_char('\'') +pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { + super::write_x_hex_literal(writer, bytes) } -pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { - debug_assert!( - bits.chars().all(|c| c == '0' || c == '1'), - "encode_bits: invalid input: {bits}" - ); - - write!(writer, "{}", i64::from_str_radix(bits, 2).unwrap_or(0)) +pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { + write!(writer, "{}", super::bits_as_i64(bits)) } diff --git a/src/native/dialect/teradata.rs b/src/native/dialect/teradata.rs index ad141e7..4894cbb 100644 --- a/src/native/dialect/teradata.rs +++ b/src/native/dialect/teradata.rs @@ -1,17 +1,17 @@ -//! A Dialect for [Teradata](https://docs.teradata.com/). +//! [Teradata](https://docs.teradata.com/). //! -//! • Strings: single-quoted; `''` for embedded single-quote. -//! Teradata supports escape sequences only in named character sets; -//! safest approach is quote-doubling only. -//! • Identifiers: double-quote quoted. -//! • Bytes: '…'XB byte literal (hex digits in single quotes with XB suffix). -//! OR '…'XBV for VARBYTE. -//! • Bits: '10110101'B bit literal. -//! • Bool: TRUE / FALSE (Teradata 13.x+ with BYTEINT or BOOLEAN). +//! • Strings: single-quoted; only `'` is special (`''`). Escape sequences +//! depend on the session character set, so we never emit them. NUL is +//! stripped. +//! • Identifiers: single-quote quoted (`'name'`); `'` doubled. +//! • Bytes: `''XBV` (VARBYTE). +//! • Bits: `'10110101'B` bit literal. +//! • Bool: `TRUE` / `FALSE` (13.x+). +//! • Placeholders: `:name` (not native; rewritten client-side). //! //! Ref: -use crate::internal::utils::{replace_ascii_to, replace_to}; +use crate::internal::utils::replace_to; use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; @@ -19,41 +19,32 @@ pub(super) const NAME: &str = "Teradata"; pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; -pub(super) const IDENTIFIER_QUOTE: Option = Some(IdentifierQuote::SingleQuote); +pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::SingleQuote; -/// This is not natively supported, and should be rendered on client-side. pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; -pub(super) fn escape_string(writer: &mut T, string: &str) -> std::fmt::Result { - // Teradata: only single-quote doubling; no backslash interpretation. - // NUL bytes are not supported. - const REPLACEMENTS: [(u8, &str); 2] = [(b'\'', "''"), (b'\0', "")]; - replace_ascii_to(string, &REPLACEMENTS, writer) +pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { + super::escape_quote_and_strip_nul(writer, string) } -pub(super) fn quote_string(writer: &mut T, string: &str) -> std::fmt::Result { +pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { super::wrap_single_quote(writer, string) } -pub(super) fn escape_identifier( - writer: &mut T, +pub(super) fn escape_identifier( + writer: &mut W, ident: &str, ) -> std::fmt::Result { replace_to(ident, '\'', "''", writer) } -pub(super) fn encode_bytes(writer: &mut T, bytes: &[u8]) -> std::fmt::Result { - // Teradata byte literal: ''XBV - writer.write_str("'")?; +pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { + writer.write_char('\'')?; super::bytes_to_hex(writer, bytes)?; writer.write_str("'XBV") } -pub(super) fn encode_bits(writer: &mut T, bits: &str) -> std::fmt::Result { - debug_assert!( - bits.chars().all(|c| c == '0' || c == '1'), - "encode_bits: invalid input: {bits}" - ); - - write!(writer, "'{}'B", bits) +pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { + super::debug_assert_bits(bits); + write!(writer, "'{bits}'B") } diff --git a/src/native/dialect/tests.rs b/src/native/dialect/tests.rs new file mode 100644 index 0000000..7c8d7bd --- /dev/null +++ b/src/native/dialect/tests.rs @@ -0,0 +1,833 @@ +//! Dialect quoting/escaping tests. +use super::*; + +/// Every dialect this crate supports, used to drive table-style tests. +const ALL_DIALECTS: &[SqlDialect] = &[ + SqlDialect::BigQuery, + SqlDialect::Databricks, + SqlDialect::DuckDb, + SqlDialect::Hive, + SqlDialect::MsSql, + SqlDialect::MySql, + SqlDialect::Oracle, + SqlDialect::PostgreSql, + SqlDialect::Redshift, + SqlDialect::Snowflake, + SqlDialect::Spark, + SqlDialect::Sqlite, + SqlDialect::Teradata, +]; + +/// Dialects whose `escape_string` interprets backslash sequences +/// (`\\`, `\n`, `\t`, ...). These MUST double a literal backslash. +const BACKSLASH_ESCAPING_DIALECTS: &[SqlDialect] = &[ + SqlDialect::BigQuery, + SqlDialect::Databricks, + SqlDialect::Hive, + SqlDialect::MySql, + SqlDialect::PostgreSql, + SqlDialect::Snowflake, + SqlDialect::Spark, +]; + +/// Dialects that only double single quotes and treat backslash as a +/// completely literal character (per their own doc comments). These MUST +/// pass a literal backslash straight through, unchanged. +const QUOTE_ONLY_DIALECTS: &[SqlDialect] = &[ + SqlDialect::DuckDb, + SqlDialect::MsSql, + SqlDialect::Oracle, + SqlDialect::Redshift, + SqlDialect::Sqlite, + SqlDialect::Teradata, +]; + +/// Dialects documented as stripping NUL bytes outright (rather than +/// escaping them) because their target string types can't hold NUL. +const NUL_STRIPPING_DIALECTS: &[SqlDialect] = &[ + SqlDialect::MsSql, + SqlDialect::Oracle, + SqlDialect::Redshift, + SqlDialect::Sqlite, + SqlDialect::Teradata, +]; + +fn sanity_check_coverage() { + // Every dialect must land in exactly one of the two escaping-style + // buckets above, and that bucket assignment must partition ALL_DIALECTS. + assert_eq!( + BACKSLASH_ESCAPING_DIALECTS.len() + QUOTE_ONLY_DIALECTS.len(), + ALL_DIALECTS.len(), + "every dialect must be classified as either backslash-escaping or quote-only" + ); +} + +// --------------------------------------------------------------------- +// 1. Single-quote handling: the one thing EVERY dialect must get right — +// though the *shape* of the escape differs by family: +// - QUOTE_ONLY dialects (ANSI-style) double it: ' -> '' +// - BACKSLASH_ESCAPING dialects (C/Java-style) prefix it: ' -> \' +// --------------------------------------------------------------------- + +#[test] +fn quote_only_dialects_double_a_lone_single_quote() { + sanity_check_coverage(); + for dialect in QUOTE_ONLY_DIALECTS { + let escaped = dialect.escape_string_as_string("'"); + assert_eq!( + escaped, + "''", + "{} did not double a lone single quote (got {escaped:?})", + dialect.name() + ); + } +} + +#[test] +fn backslash_escaping_dialects_prefix_a_lone_single_quote() { + for dialect in BACKSLASH_ESCAPING_DIALECTS { + let escaped = dialect.escape_string_as_string("'"); + assert_eq!( + escaped, + "\\'", + "{} did not backslash-escape a lone single quote (got {escaped:?})", + dialect.name() + ); + } +} + +#[test] +fn quote_only_dialects_double_multiple_single_quotes() { + for dialect in QUOTE_ONLY_DIALECTS.iter() { + let escaped = dialect.escape_string_as_string("''''"); + assert_eq!( + escaped, + "''''''''", + "{} mis-handled 4 consecutive quotes (got {escaped:?})", + dialect.name() + ); + } +} + +#[test] +fn backslash_escaping_dialects_prefix_multiple_single_quotes() { + for dialect in BACKSLASH_ESCAPING_DIALECTS { + let escaped = dialect.escape_string_as_string("''''"); + assert_eq!( + escaped, + "\\'\\'\\'\\'", + "{} mis-handled 4 consecutive quotes (got {escaped:?})", + dialect.name() + ); + } +} + +#[test] +fn every_dialect_neutralizes_the_apostrophe_in_a_classic_name() { + for dialect in ALL_DIALECTS { + let escaped = dialect.escape_string_as_string("O'Brien"); + let uses_backslash = BACKSLASH_ESCAPING_DIALECTS.contains(dialect); + let expected = if uses_backslash { + "O\\'Brien" + } else { + "O''Brien" + }; + assert_eq!( + escaped, + expected, + "{} did not neutralize the apostrophe in \"O'Brien\" as expected (got {escaped:?})", + dialect.name() + ); + } +} + +// --------------------------------------------------------------------- +// 2. Backslash handling must match each dialect's documented behavior. +// --------------------------------------------------------------------- + +#[test] +fn backslash_escaping_dialects_double_a_lone_backslash() { + for dialect in BACKSLASH_ESCAPING_DIALECTS { + let escaped = dialect.escape_string_as_string("\\"); + assert_eq!( + escaped, + "\\\\", + "{} should double a lone backslash (got {escaped:?})", + dialect.name() + ); + } +} + +#[test] +fn quote_only_dialects_pass_backslash_through_unchanged() { + for dialect in QUOTE_ONLY_DIALECTS.iter() { + let escaped = dialect.escape_string_as_string("\\"); + assert_eq!( + escaped, + "\\", + "{} must treat backslash as a literal, non-special character (got {escaped:?})", + dialect.name() + ); + } +} + +#[test] +fn quote_only_dialects_dont_touch_backslash_next_to_quote() { + // This is the classic footgun: a naive implementation might see `\'` + // and think it needs special handling. These dialects must NOT + // interpret backslash-quote as an escape sequence — the backslash + // stays literal and the quote still gets doubled independently. + for dialect in QUOTE_ONLY_DIALECTS.iter() { + let escaped = dialect.escape_string_as_string("\\'"); + assert_eq!( + escaped, + "\\''", + "{} mis-handled backslash immediately followed by a quote (got {escaped:?})", + dialect.name() + ); + } +} + +#[test] +fn backslash_escaping_dialects_handle_backslash_next_to_quote() { + for dialect in BACKSLASH_ESCAPING_DIALECTS { + let escaped = dialect.escape_string_as_string("\\'"); + assert_eq!( + escaped, + "\\\\\\'", + "{} mis-handled backslash immediately followed by a quote (got {escaped:?})", + dialect.name() + ); + } +} + +// --------------------------------------------------------------------- +// 3. NUL byte handling: strip vs. escape, per dialect contract. +// --------------------------------------------------------------------- + +/// Dialects documented as escaping NUL (rather than stripping or passing +/// it through raw) — i.e. the backslash-escaping family, all of which +/// have an explicit `'\0' => ...` arm. +const NUL_ESCAPING_DIALECTS: &[SqlDialect] = &[ + SqlDialect::BigQuery, + SqlDialect::Databricks, + SqlDialect::Hive, + SqlDialect::MySql, + SqlDialect::PostgreSql, + SqlDialect::Snowflake, + SqlDialect::Spark, +]; + +#[test] +fn nul_stripping_dialects_remove_nul_entirely() { + for dialect in NUL_STRIPPING_DIALECTS { + let escaped = dialect.escape_string_as_string("a\0b"); + assert_eq!( + escaped, + "ab", + "{} should strip NUL bytes outright (got {escaped:?})", + dialect.name() + ); + assert!( + !escaped.contains('\0'), + "{} left a raw NUL byte in its output", + dialect.name() + ); + } +} + +#[test] +fn nul_escaping_dialects_replace_nul_with_an_escape_sequence() { + for dialect in NUL_ESCAPING_DIALECTS { + let escaped = dialect.escape_string_as_string("a\0b"); + assert!( + !escaped.contains('\0'), + "{} left a raw NUL byte in escaped output (got {escaped:?})", + dialect.name() + ); + assert_ne!( + escaped, + "a\0b", + "{} should have replaced NUL with an escape sequence, not left it untouched", + dialect.name() + ); + } +} + +#[test] +fn duckdb_documents_nul_as_a_regular_passthrough_character() { + // DuckDB VARCHAR is UTF-8 and its docs don't call out NUL as + // unsupported, unlike MsSql/Oracle/Redshift/Sqlite/Teradata, which + // explicitly strip it. This test exists to make that asymmetry + // explicit and catch it if DuckDB's contract ever changes. + let escaped = SqlDialect::DuckDb.escape_string_as_string("a\0b"); + assert_eq!(escaped, "a\0b"); +} + +// --------------------------------------------------------------------- +// 4. Control characters (newline, tab, CR, and other ASCII control codes). +// --------------------------------------------------------------------- + +#[test] +fn backslash_escaping_dialects_never_emit_a_raw_newline_or_cr() { + // \n and \r get an explicit backslash-escape arm in every + // backslash-escaping dialect's implementation. + for dialect in BACKSLASH_ESCAPING_DIALECTS { + let escaped = dialect.escape_string_as_string("a\nb\rc"); + assert!( + !escaped.contains('\n') && !escaped.contains('\r'), + "{} left a raw \\n or \\r in escaped output (got {escaped:?})", + dialect.name() + ); + } +} + +#[test] +fn most_backslash_escaping_dialects_also_escape_tab() { + // MySQL's implementation intentionally has no explicit tab arm (a + // literal tab is not a syntax hazard inside a MySQL string literal, + // unlike a quote or backslash) so it's excluded here. Every other + // backslash-escaping dialect does translate \t -> \\t. + let tab_escaping: Vec = BACKSLASH_ESCAPING_DIALECTS + .iter() + .copied() + .filter(|d| *d != SqlDialect::MySql) + .collect(); + + for dialect in tab_escaping { + let escaped = dialect.escape_string_as_string("a\tb"); + assert!( + !escaped.contains('\t'), + "{} left a raw tab in escaped output (got {escaped:?})", + dialect.name() + ); + } +} + +#[test] +fn mysql_passes_a_raw_tab_through_unchanged() { + // Documents MySQL's actual (intentional) behavior explicitly, so a + // future change is a deliberate decision rather than a silent drift. + let escaped = SqlDialect::MySql.escape_string_as_string("a\tb"); + assert_eq!(escaped, "a\tb"); +} + +#[test] +fn quote_only_dialects_pass_newline_cr_tab_through_unchanged() { + // These dialects only double single quotes; per their own doc + // comments they don't treat backslash (or \n/\r/\t) as special. + // A raw newline etc. inside '...' is syntactically valid in these + // engines and is NOT an injection risk on its own — only the quote + // character matters for breaking out of the literal. + for dialect in QUOTE_ONLY_DIALECTS.iter() { + let escaped = dialect.escape_string_as_string("a\nb\rc\td"); + assert_eq!( + escaped, + "a\nb\rc\td", + "{} was expected to pass control chars through unchanged (got {escaped:?})", + dialect.name() + ); + } +} + +#[test] +fn every_dialect_neutralizes_low_ascii_control_bytes() { + // 0x01..=0x1F (excluding \n=0x0A, \r=0x0D, \t=0x09 already covered) + // and 0x7F (DEL) are the classic "invisible injection" characters. + // At minimum, no dialect should pass them through raw next to + // unescaped quotes in a way that changes the quote count. + for dialect in ALL_DIALECTS { + for byte in [0x01u8, 0x07, 0x1b, 0x1f, 0x7f] { + let ch = byte as char; + let input = format!("x{ch}y"); + let escaped = dialect.escape_string_as_string(&input); + // The escape function must not panic and must not introduce + // an unbalanced quote by accident. + assert_eq!( + escaped.matches('\'').count() % 2, + 0, + "{} produced an odd number of quotes for control byte 0x{byte:02X} (got \ + {escaped:?})", + dialect.name() + ); + } + } +} + +// --------------------------------------------------------------------- +// 5. End-to-end injection safety: escape_string + quote_string together +// must never let attacker input terminate the string literal early. +// --------------------------------------------------------------------- + +/// A grab-bag of classic SQL-injection-style payloads. +const HOSTILE_INPUTS: &[&str] = &[ + "", + "'", + "''", + "'; DROP TABLE users; --", + "' OR '1'='1", + "\\", + "\\'", + "\\\\", + "\\'; DROP TABLE users; --", + "a\0b", + "\n\r\t", + "'\0'", + "\\\0'", + "šŸ˜€'; DROP TABLE users;--šŸ˜€", + "back\\slash'quote\"doublequote", +]; + +/// For a single-quoted-literal dialect, walks the fully escaped+quoted +/// output character by character (respecting the dialect's own escape +/// convention) and confirms the literal's closing quote is genuinely the +/// last unescaped quote — i.e. nothing in the payload could prematurely +/// close the string. +/// +/// Operates entirely in `char` space (never byte-slicing a `&str`) so +/// multi-byte UTF-8 content (e.g. emoji) can't desync index arithmetic. +fn assert_no_premature_close(dialect: &SqlDialect, quoted: &str, uses_backslash_escapes: bool) { + let chars: Vec = quoted.chars().collect(); + + let first_quote_idx = chars.iter().position(|&c| c == '\'').unwrap_or_else(|| { + panic!( + "{}: quoted output has no opening quote: {quoted:?}", + dialect.name() + ) + }); + + let body = &chars[first_quote_idx + 1..]; + let mut i = 0; + let mut closed_at = None; + while i < body.len() { + let c = body[i]; + if uses_backslash_escapes && c == '\\' { + // Skip the escaped character (backslash always consumes the + // next char in these dialects' escape schemes). + i += 2; + continue; + } + if c == '\'' { + // A doubled quote (''), representing one literal quote, + // does NOT close the string — only a single trailing quote + // with no partner does. + if body.get(i + 1) == Some(&'\'') { + i += 2; + continue; + } + closed_at = Some(i); + break; + } + i += 1; + } + + let closed_at = closed_at.unwrap_or_else(|| { + panic!( + "{}: could not find a closing quote at all in {quoted:?}", + dialect.name() + ) + }); + + // The closing quote we found must be the LAST character of the + // string (i.e. nothing meaningful follows it) — otherwise the + // payload managed to close the literal early and inject trailing SQL. + let remainder: String = body[closed_at + 1..].iter().collect(); + assert!( + remainder.is_empty(), + "{}: literal closed early, leaving trailing unescaped content {remainder:?} in \ + {quoted:?}", + dialect.name() + ); +} + +#[test] +fn no_hostile_input_can_break_out_of_the_string_literal() { + for dialect in ALL_DIALECTS { + let uses_backslash = BACKSLASH_ESCAPING_DIALECTS.contains(dialect); + for payload in HOSTILE_INPUTS { + let escaped = dialect.escape_string_as_string(payload); + let quoted = dialect.quote_string_as_string(&escaped); + assert_no_premature_close(dialect, "ed, uses_backslash); + } + } +} + +#[test] +fn no_hostile_input_ever_panics_any_dialect_function() { + // A panic (e.g. from an unwrap on malformed UTF-8 boundary slicing) + // is itself a correctness bug worth catching explicitly. + for dialect in ALL_DIALECTS { + for payload in HOSTILE_INPUTS { + let escaped = dialect.escape_string_as_string(payload); + let _ = dialect.quote_string_as_string(&escaped); + } + } +} + +// --------------------------------------------------------------------- +// 6. Unicode correctness: multi-byte scalars must survive untouched. +// --------------------------------------------------------------------- + +#[test] +fn unicode_content_passes_through_unmangled() { + let samples = ["hĆ©llo", "ę—„ęœ¬čŖž", "emojišŸ˜€šŸŽ‰", "ƑoƱo", "Straße"]; + for dialect in ALL_DIALECTS { + for sample in samples { + let escaped = dialect.escape_string_as_string(sample); + assert_eq!( + escaped, + sample, + "{} corrupted unicode input {sample:?} (got {escaped:?})", + dialect.name() + ); + // Must still be valid UTF-8 after quoting (String guarantees + // this, but we assert to document the expectation). + let quoted = dialect.quote_string_as_string(&escaped); + assert!(quoted.contains(sample)); + } + } +} + +#[test] +fn unicode_mixed_with_quotes_still_escapes_correctly() { + for dialect in ALL_DIALECTS { + let escaped = dialect.escape_string_as_string("ę—„ęœ¬čŖž'quote'šŸ˜€"); + let uses_backslash = BACKSLASH_ESCAPING_DIALECTS.contains(dialect); + let quote_marker_count = if uses_backslash { + escaped.matches("\\'").count() + } else { + escaped.matches("''").count() + }; + assert_eq!( + quote_marker_count, + 2, + "{}: expected both quotes in the unicode string to be escaped (got {escaped:?})", + dialect.name() + ); + assert!(escaped.contains("ę—„ęœ¬čŖž")); + assert!(escaped.contains('šŸ˜€')); + } +} + +// --------------------------------------------------------------------- +// 7. Empty-input edge cases. +// --------------------------------------------------------------------- + +#[test] +fn empty_string_escapes_to_empty_string() { + for dialect in ALL_DIALECTS { + assert_eq!(dialect.escape_string_as_string(""), ""); + } +} + +#[test] +fn empty_string_quoted_is_just_the_delimiters() { + for dialect in ALL_DIALECTS { + let quoted = dialect.quote_string_as_string(""); + // Must contain exactly 2 single quotes and nothing else meaningful + // (allowing for a dialect-specific prefix like `E` or `N`). + assert_eq!( + quoted.matches('\'').count(), + 2, + "{}: empty quoted string should have exactly one open/close pair (got {quoted:?})", + dialect.name() + ); + } +} + +// --------------------------------------------------------------------- +// 8. encode_bytes: hex correctness, case, empty/boundary input. +// --------------------------------------------------------------------- + +#[test] +fn encode_bytes_empty_slice_does_not_panic_and_contains_no_hex_pairs() { + for dialect in ALL_DIALECTS { + let out = dialect.encode_bytes_as_string(&[]); + // No stray "00" or similar should appear from an off-by-one bug. + assert!( + !out.is_empty(), + "{}: encode_bytes(&[]) produced an empty string, expected at least wrapper syntax", + dialect.name() + ); + } +} + +#[test] +fn encode_bytes_uses_uppercase_hex_and_round_trips_all_byte_values() { + let bytes: Vec = (0u8..=255).collect(); + + // BigQuery renders each byte as its own \xNN escape (not a + // contiguous hex run via the shared bytes_to_hex() helper), so it's + // checked separately below. + let plain_hex_run_dialects: Vec = ALL_DIALECTS + .iter() + .copied() + .filter(|d| *d != SqlDialect::BigQuery) + .collect(); + + // The uppercase hex encoding of 0x00..=0xFF, in order — this is + // exactly what bytes_to_hex() must produce, byte for byte. Checking + // for this literal substring (rather than scanning all hex-looking + // characters in the output) avoids false positives from incidental + // lowercase letters in surrounding syntax like "bytea" or "'hex'". + let expected_hex_run: String = bytes.iter().map(|b| format!("{:02X}", b)).collect(); + + for dialect in plain_hex_run_dialects { + let out = dialect.encode_bytes_as_string(&bytes); + assert!( + out.contains(&expected_hex_run), + "{}: encode_bytes did not contain the expected uppercase hex run for all 256 byte \ + values (got {out:?})", + dialect.name() + ); + } + + // BigQuery: each byte must appear as its own \xNN, in order, with + // uppercase hex digits. + let expected_bigquery: String = bytes.iter().map(|b| format!("\\x{:02X}", b)).collect(); + let bigquery_out = SqlDialect::BigQuery.encode_bytes_as_string(&bytes); + assert!( + bigquery_out.contains(&expected_bigquery), + "BigQuery: encode_bytes did not contain the expected \\xNN-per-byte run (got \ + {bigquery_out:?})" + ); +} + +#[test] +fn encode_bytes_single_byte_zero_and_single_byte_max() { + for dialect in ALL_DIALECTS { + let zero = dialect.encode_bytes_as_string(&[0x00]); + let max = dialect.encode_bytes_as_string(&[0xFF]); + assert!(zero.contains("00"), "{}: {zero:?}", dialect.name()); + assert!(max.contains("FF"), "{}: {max:?}", dialect.name()); + assert_ne!(zero, max); + } +} + +// --------------------------------------------------------------------- +// 9. encode_bits: boundary lengths, all-zero, all-one, longer-than-i64. +// --------------------------------------------------------------------- + +#[test] +fn encode_bits_all_zero_and_all_one_single_bit() { + for dialect in ALL_DIALECTS { + let zero = dialect.encode_bits_as_string("0"); + let one = dialect.encode_bits_as_string("1"); + assert_ne!( + zero, + one, + "{}: encode_bits(\"0\") and encode_bits(\"1\") must differ", + dialect.name() + ); + } +} + +#[test] +fn encode_bits_does_not_panic_on_64_bit_boundary() { + // Exactly 64 ones is the classic off-by-one boundary for + // i64::from_str_radix-based implementations. + let sixty_four_ones = "1".repeat(64); + for dialect in ALL_DIALECTS { + let _ = dialect.encode_bits_as_string(&sixty_four_ones); + } +} + +#[test] +fn encode_bits_does_not_panic_on_overflow_longer_than_i64() { + // More than 64 bits overflows i64::from_str_radix; dialects that + // parse via i64 fall back to 0 on error rather than panicking. This + // test locks in "no panic" as the contract, regardless of the + // fallback value chosen. + let too_many_ones = "1".repeat(100); + for dialect in ALL_DIALECTS { + let _ = dialect.encode_bits_as_string(&too_many_ones); + } +} + +#[test] +fn encode_bits_empty_string_does_not_panic() { + for dialect in ALL_DIALECTS { + let _ = dialect.encode_bits_as_string(""); + } +} + +#[test] +fn encode_bits_known_value_is_consistent_across_dialects_that_parse_as_integer() { + // "10110101" == 181 decimal. Every dialect that renders this via an + // integer cast should have "181" appear somewhere in its output. + let bits = "10110101"; + let integer_based = [ + SqlDialect::BigQuery, // CAST(0b... AS INT64) -- uses 0b, not decimal + SqlDialect::Databricks, // CAST(...L AS BINARY) -- decimal + SqlDialect::Hive, // CAST(... AS BIGINT) -- decimal + SqlDialect::MsSql, // CAST(... AS BIGINT) -- decimal + SqlDialect::Oracle, // bare decimal + SqlDialect::Redshift, // CAST(... AS BIGINT) -- decimal + SqlDialect::Snowflake, // TO_HEX(...) -- decimal appears inside TO_HEX( + SqlDialect::Spark, // CAST(... AS BIGINT) -- decimal + SqlDialect::Sqlite, // bare decimal + ]; + for dialect in &integer_based { + let out = dialect.encode_bits_as_string(bits); + if *dialect == SqlDialect::BigQuery { + assert!( + out.contains("0b10110101"), + "{}: expected binary literal form (got {out:?})", + dialect.name() + ); + } else { + assert!( + out.contains("181"), + "{}: expected decimal value 181 to appear in output (got {out:?})", + dialect.name() + ); + } + } +} + +#[test] +fn encode_bits_literal_style_dialects_preserve_the_bit_string_verbatim() { + // Dialects with a native bit-string literal syntax should embed the + // exact input bits, not a numeric conversion. + let bits = "10110101"; + assert_eq!( + SqlDialect::PostgreSql.encode_bits_as_string(bits), + "B'10110101'" + ); + assert_eq!( + SqlDialect::DuckDb.encode_bits_as_string(bits), + "B'10110101'" + ); + assert_eq!(SqlDialect::MySql.encode_bits_as_string(bits), "b'10110101'"); + assert_eq!( + SqlDialect::Teradata.encode_bits_as_string(bits), + "'10110101'B" + ); +} + +// --------------------------------------------------------------------- +// 10. quote_string wrapping sanity: dialect-specific prefixes/suffixes. +// --------------------------------------------------------------------- + +#[test] +fn quote_string_wraps_with_expected_prefix_per_dialect() { + let escaped = "abc"; + assert!( + SqlDialect::PostgreSql + .quote_string_as_string(escaped) + .starts_with("E'") + ); + assert!( + SqlDialect::MsSql + .quote_string_as_string(escaped) + .starts_with("N'") + ); + // Plain single-quote wrapping dialects: no special prefix letter. + for dialect in &[ + SqlDialect::BigQuery, + SqlDialect::MySql, + SqlDialect::Sqlite, + SqlDialect::Snowflake, + SqlDialect::Oracle, + SqlDialect::Redshift, + SqlDialect::Teradata, + SqlDialect::Spark, + SqlDialect::Hive, + SqlDialect::Databricks, + SqlDialect::DuckDb, + ] { + let quoted = dialect.quote_string_as_string(escaped); + assert!( + quoted.starts_with('\''), + "{}: expected plain single-quote wrapping (got {quoted:?})", + dialect.name() + ); + } +} + +#[test] +fn quote_string_always_ends_with_a_single_quote() { + for dialect in ALL_DIALECTS { + let quoted = dialect.quote_string_as_string("abc"); + assert!( + quoted.ends_with('\''), + "{}: quoted string should end with a closing quote (got {quoted:?})", + dialect.name() + ); + } +} + +// --------------------------------------------------------------------- +// 11. Identifier quoting and hex helpers. +// --------------------------------------------------------------------- + +#[test] +fn sql_dialect_is_copy() { + fn assert_copy(_: T) {} + assert_copy(SqlDialect::Sqlite); + assert_copy(PlaceholderSyntax::Colon); +} + +#[test] +fn quote_identifier_uses_expected_delimiters() { + assert_eq!(SqlDialect::MySql.quote_identifier_as_string("id"), "`id`"); + assert_eq!( + SqlDialect::PostgreSql.quote_identifier_as_string("id"), + "\"id\"" + ); + assert_eq!(SqlDialect::MsSql.quote_identifier_as_string("id"), "[id]"); + assert_eq!( + SqlDialect::Teradata.quote_identifier_as_string("id"), + "'id'" + ); + assert_eq!( + SqlDialect::BigQuery.quote_identifier_as_string("id"), + "`id`" + ); +} + +#[test] +fn quote_identifier_escapes_the_delimiter_in_the_body() { + assert_eq!( + SqlDialect::MySql.quote_identifier_as_string("a`b"), + "`a``b`" + ); + assert_eq!( + SqlDialect::PostgreSql.quote_identifier_as_string("a\"b"), + "\"a\"\"b\"" + ); + assert_eq!( + SqlDialect::MsSql.quote_identifier_as_string("a]b"), + "[a]]b]" + ); + assert_eq!( + SqlDialect::Teradata.quote_identifier_as_string("a'b"), + "'a''b'" + ); +} + +#[test] +fn escape_identifier_is_the_body_only() { + assert_eq!(SqlDialect::MySql.escape_identifier_as_string("a`b"), "a``b"); + assert_eq!( + SqlDialect::PostgreSql.escape_identifier_as_string("a\"b"), + "a\"\"b" + ); + assert_eq!(SqlDialect::MsSql.escape_identifier_as_string("a]b"), "a]]b"); +} + +#[test] +fn write_hex2_is_uppercase_two_digits() { + let mut s = String::new(); + write_hex2(&mut s, 0x00).unwrap(); + write_hex2(&mut s, 0xAB).unwrap(); + write_hex2(&mut s, 0xFF).unwrap(); + assert_eq!(s, "00ABFF"); +} + +#[test] +fn placeholder_sigil_matches_syntax() { + assert_eq!(PlaceholderSyntax::AtSign.sigil(), '@'); + assert_eq!(PlaceholderSyntax::Colon.sigil(), ':'); + assert_eq!(PlaceholderSyntax::DollarNamed.sigil(), '$'); +} diff --git a/src/native/prepare_statement.rs b/src/native/prepare_statement.rs index 7209375..85b98f0 100644 --- a/src/native/prepare_statement.rs +++ b/src/native/prepare_statement.rs @@ -2,10 +2,7 @@ use std::fmt; use hashbrown::HashMap; -use crate::{ - internal::alias, - native::dialect::{PlaceholderSyntax, SqlDialect}, -}; +use crate::{internal::alias, native::dialect::SqlDialect}; /// Error produced while building a prepared statement. #[derive(Debug)] @@ -49,16 +46,6 @@ impl From for PrepareError { pub type PrepareResult = Result; pub type PrepareParams = HashMap; -/// Sigil written before a named placeholder (`@param_0`, `:param_0`, `$param_0`). -#[inline] -fn placeholder_sigil(syntax: PlaceholderSyntax) -> char { - match syntax { - PlaceholderSyntax::AtSign => '@', - PlaceholderSyntax::Colon => ':', - PlaceholderSyntax::DollarNamed => '$', - } -} - /// Accumulates rendered SQL and the bound-parameter map. /// /// Implements [`fmt::Write`], so `write!` / `writeln!` work directly: @@ -120,7 +107,7 @@ impl PrepareContext<'_> { pub fn add_param(&mut self, dialect: &SqlDialect, param: pyo3::Py) -> String { let name = self.alloc_param_name(); let mut placeholder = String::with_capacity(1 + name.len()); - placeholder.push(placeholder_sigil(dialect.placeholder_syntax())); + placeholder.push(dialect.placeholder_syntax().sigil()); placeholder.push_str(&name); self.params.insert(name, param); placeholder @@ -140,8 +127,7 @@ impl PrepareContext<'_> { #[inline] pub fn push_param(&mut self, dialect: &SqlDialect, param: pyo3::Py) -> &mut Self { let name = self.alloc_param_name(); - self.sql - .push(placeholder_sigil(dialect.placeholder_syntax())); + self.sql.push(dialect.placeholder_syntax().sigil()); self.sql.push_str(&name); self.params.insert(name, param); self From ccbbb9cef8d459b706f989cef0529268e23722b0 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 7 Sep 2026 12:27:53 +0330 Subject: [PATCH 09/87] Expand comments, write pybindings for dialects --- src/internal/utils/mod.rs | 9 +- src/internal/utils/submodule_ext.rs | 12 ++- src/lib.rs | 5 + src/pybindings/dialect.rs | 155 ++++++++++++++++++++++++++++ src/pybindings/mod.rs | 8 ++ src/typeref.rs | 133 ++++++++++++++++++++++++ 6 files changed, 319 insertions(+), 3 deletions(-) create mode 100644 src/pybindings/dialect.rs create mode 100644 src/pybindings/mod.rs create mode 100644 src/typeref.rs diff --git a/src/internal/utils/mod.rs b/src/internal/utils/mod.rs index b4c778b..6857e6d 100644 --- a/src/internal/utils/mod.rs +++ b/src/internal/utils/mod.rs @@ -4,9 +4,14 @@ mod submodule_ext; pub use strings::*; pub use submodule_ext::*; -/// Returns the type name of a [`pyo3::ffi::PyObject`]. +/// Fully-qualified type name of `obj` (`int`, `rapidquery._core.PostgreSql`, …). /// -/// Returns `""` on failure. +/// Returns `""` if `Py_TYPE` is null or the name lookup fails. +/// +/// # Safety +/// +/// `obj` must be a valid, non-null `PyObject*` (a borrowed `Bound` +/// pointer is fine). The GIL must be held (`py`). #[inline] pub unsafe fn get_type_name<'a>(py: pyo3::Python<'a>, obj: *mut pyo3::ffi::PyObject) -> String { use pyo3::types::{PyStringMethods, PyTypeMethods}; diff --git a/src/internal/utils/submodule_ext.rs b/src/internal/utils/submodule_ext.rs index 1d64686..51c7966 100644 --- a/src/internal/utils/submodule_ext.rs +++ b/src/internal/utils/submodule_ext.rs @@ -1,4 +1,14 @@ -// https://github.com/PyO3/pyo3/issues/759#issuecomment-4041245073 +//! Register nested `#[pymodule]` exports in `sys.modules`. +//! +//! PyO3 nested modules are attributes of the parent, but `import +//! parent.child` looks up `sys.modules["parent.child"]`. Without this walk, +//! `import rapidquery._core.dialect` fails even though `_core.dialect` works. +//! +//! Workaround: +//! +//! The child's `__name__` stays whatever `#[pymodule(name = ...)]` set +//! (`"dialect"`, not `"rapidquery._core.dialect"`). Import still succeeds +//! because the `sys.modules` key is the dotted path. use pyo3::types::{PyAnyMethods, PyModule, PyModuleMethods}; diff --git a/src/lib.rs b/src/lib.rs index f001745..6110e18 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,10 @@ pub mod native; pub mod pybindings; pub mod typeref; +// `gil_used = false` for free-threaded CPython. Nested `#[pymodule]` +// exports (`dialect`, later `data_type`) are registered in `sys.modules` +// so `import rapidquery._core.dialect` works. +/// RapidQuery native extension. #[pyo3::pymodule(gil_used = false)] mod _core { #[pymodule_export] @@ -15,6 +19,7 @@ mod _core { fn init(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> { use crate::internal::utils::PyModuleSubmoduleExt; + // Cache PyTypeObject* before any Python code can extract a dialect. crate::typeref::initialize_typeref(m.py()); m.init_submodules() } diff --git a/src/pybindings/dialect.rs b/src/pybindings/dialect.rs new file mode 100644 index 0000000..24c725d --- /dev/null +++ b/src/pybindings/dialect.rs @@ -0,0 +1,155 @@ +//! Python wrappers around [`crate::native::dialect`]. +//! +//! The native engine selects a dialect with a `Copy` enum. Python sees a +//! class hierarchy instead: +//! +//! * [`PySqlDialect`] — `rapidquery._core.dialect.SqlDialect`, the ABC +//! * one frozen subclass per variant (`PostgreSql`, `MySql`, …) +//! +//! Extraction ([`FromPyObject`] for [`PySqlDialect`]) accepts **either** the +//! class (`PostgreSql`) **or** an instance (`PostgreSql()`). Matching is by +//! interned [`PyTypeObject`] pointer (see [`crate::typeref`]), so only the +//! 13 built-in classes convert — a Python subclass of `SqlDialect` does not. +//! +//! `#[pyclass]` sets `module = "rapidquery._core"` via the crate macro, so +//! `PostgreSql.__module__` is `rapidquery._core` rather than +//! `rapidquery._core.dialect`. The submodule is still importable as +//! `rapidquery._core.dialect` because [`crate::internal::utils::PyModuleSubmoduleExt`] +//! registers it in `sys.modules`. + +use crate::{internal::utils::get_type_name, native}; + +// `subclass` is so the 13 built-in dialects can `extends` this type from +// Rust. Python subclasses are not accepted by the extractor below. +/// Abstract SQL dialect. +/// +/// Concrete dialects are frozen subclasses (`PostgreSql`, `MySql`, ...). +/// Pass either the class or an instance wherever a dialect is required. +/// This base class cannot be instantiated. +#[rapidquery_macros::pyclass("SqlDialect", subclass, skip_from_py_object)] +#[derive(Debug, Clone)] +pub struct PySqlDialect(pub native::dialect::SqlDialect); + +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PySqlDialect { + type Error = pyo3::PyErr; + + fn extract( + obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, + ) -> Result { + // Pointer-identity against the 13 interned type objects. Cheaper than + // `isinstance` and rejects unknown subclasses by construction. + macro_rules! map { + ( + $ptr:expr, + $($name:ident => $native:ident,)+ + ) => { + $( + if $ptr == crate::typeref::$name { + return Ok(PySqlDialect(native::dialect::SqlDialect::$native)); + } + )+ + }; + } + + unsafe { + // SAFETY: `obj` is a borrowed `&Bound`, so `as_ptr()` is a + // live `PyObject*`. `PyType_CheckExact` is true when the caller + // passed the class itself (`PostgreSql`); otherwise we take + // `Py_TYPE` of an instance (`PostgreSql()`). Both pointers are + // interned `PyTypeObject*` values — we only compare addresses + // against `typeref`, we do not dereference them here. + // `get_type_name` requires a valid object pointer on the error + // path, which we have. + let type_ptr = if pyo3::ffi::PyType_CheckExact(obj.as_ptr()) == 1 { + obj.as_ptr() as *mut pyo3::ffi::PyTypeObject + } else { + pyo3::ffi::Py_TYPE(obj.as_ptr()) + }; + + map!( + type_ptr, + BIGQUERY_DIALECT_TYPE => BigQuery, + DATABRICKS_DIALECT_TYPE => Databricks, + DUCKDB_DIALECT_TYPE => DuckDb, + HIVE_DIALECT_TYPE => Hive, + MSSQL_DIALECT_TYPE => MsSql, + MYSQL_DIALECT_TYPE => MySql, + ORACLE_DIALECT_TYPE => Oracle, + POSTGRESQL_DIALECT_TYPE => PostgreSql, + REDSHIFT_DIALECT_TYPE => Redshift, + SNOWFLAKE_DIALECT_TYPE => Snowflake, + SPARK_DIALECT_TYPE => Spark, + SQLITE_DIALECT_TYPE => Sqlite, + TERADATA_DIALECT_TYPE => Teradata, + ); + + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected a dialect, got {}", + get_type_name(obj.py(), obj.as_ptr()) + ) + } + } +} + +#[pyo3::pymethods] +impl PySqlDialect { + /// Dialect identifier, identical to the Python class name (`"PostgreSql"`). + #[getter] + #[inline] + fn name(&self) -> &'static str { + self.0.name() + } +} + +/// Generate one frozen Python class per native [`SqlDialect`] variant and +/// export them on the `dialect` submodule. +/// +/// Each class is an empty `extends=PySqlDialect` subclass. `__new__` stores +/// the matching native variant on the base. `immutable_type` (from the +/// `pyclass` macro) makes the concrete classes non-subclassable from Python. +macro_rules! impl_dialects { + ($($native:ident => $name:ident => $pyname:literal,)+) => { + $( + #[doc = concat!($pyname, " SQL dialect.")] + #[rapidquery_macros::pyclass($pyname, extends=PySqlDialect)] + pub struct $name; + + #[pyo3::pymethods] + impl $name { + // Marker instance. Passing the class itself is equivalent. + #[new] + #[inline] + fn __new__() -> pyo3::PyClassInitializer { + pyo3::PyClassInitializer::from(PySqlDialect(native::dialect::SqlDialect::$native)) + .add_subclass(Self) + } + } + )+ + + #[pyo3::pymodule(name="dialect", gil_used = false)] + pub mod dialect_mod { + #[pymodule_export] + use super::{ + PySqlDialect, + $($name,)+ + }; + } + }; +} + +impl_dialects! { + BigQuery => PyBigQuery => "BigQuery", + Databricks => PyDatabricks => "Databricks", + DuckDb => PyDuckDb => "DuckDb", + Hive => PyHive => "Hive", + MsSql => PyMsSql => "MsSql", + MySql => PyMySql => "MySql", + Oracle => PyOracle => "Oracle", + PostgreSql => PyPostgreSql => "PostgreSql", + Redshift => PyRedshift => "Redshift", + Snowflake => PySnowflake => "Snowflake", + Spark => PySpark => "Spark", + Sqlite => PySqlite => "Sqlite", + Teradata => PyTeradata => "Teradata", +} diff --git a/src/pybindings/mod.rs b/src/pybindings/mod.rs new file mode 100644 index 0000000..f32a0bf --- /dev/null +++ b/src/pybindings/mod.rs @@ -0,0 +1,8 @@ +//! PyO3 wrappers around [`crate::native`]. +//! +//! Native types stay PyO3-free. This module is the only place that defines +//! `#[pyclass]` types and the `rapidquery._core.*` submodules they live in. +//! Keep that split as Ident / DataType / statement builders land. + +pub mod dialect; +pub use dialect::dialect_mod; diff --git a/src/typeref.rs b/src/typeref.rs new file mode 100644 index 0000000..c78aff6 --- /dev/null +++ b/src/typeref.rs @@ -0,0 +1,133 @@ +//! Interned `PyTypeObject*` cache. +//! +//! Hot-path extractors (`FromPyObject` for dialects, later Ident / serializers) +//! compare `Py_TYPE(obj)` against these pointers instead of walking the MRO. +//! Pointers are written once during module init ([`initialize_typeref`]) and +//! treated as immutable afterwards. +//! +//! Dialect entries are the 13 concrete classes, not [`super::pybindings::dialect::PySqlDialect`]: +//! the extractor matches exact types, not "any `SqlDialect` subclass". + +// Dialect types +pub(crate) static mut BIGQUERY_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut DATABRICKS_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut DUCKDB_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut HIVE_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut MSSQL_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut MYSQL_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut ORACLE_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut POSTGRESQL_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut REDSHIFT_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut SNOWFLAKE_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut SPARK_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut SQLITE_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut TERADATA_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); + +// Python standard-library types (serializers; unused by dialect extractors) +pub(crate) static mut STD_DECIMAL_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut STD_UUID_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut STD_DATETIME_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut STD_DATE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut STD_TIME_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut STD_ENUM_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); + +unsafe fn get_type_object_for( + py: pyo3::Python, +) -> *mut pyo3::ffi::PyTypeObject { + // Borrowed type object; lives as long as the `#[pyclass]` is registered + // on `rapidquery._core`, which is the process lifetime of this extension. + T::type_object_raw(py) +} + +/// Look up `module_name.member_name` and return its type object. +/// +/// `PyMapping_GetItemString` returns a **new** reference. That ref is +/// intentionally leaked so the `PyTypeObject` stays alive for the process. +/// +/// # Safety +/// +/// `PyImport_ImportModule` / dict lookup are not null-checked. A missing +/// stdlib module currently yields a null pointer (and a later crash if +/// dereferenced). Call only during module init, when the stdlib is present. +unsafe fn look_up_type_object( + module_name: &std::ffi::CStr, + member_name: &std::ffi::CStr, +) -> *mut pyo3::ffi::PyTypeObject { + let module = pyo3::ffi::PyImport_ImportModule(module_name.as_ptr()); + let module_dict = pyo3::ffi::PyObject_GenericGetDict(module, std::ptr::null_mut()); + let ptr = pyo3::ffi::PyMapping_GetItemString(module_dict, member_name.as_ptr()) + .cast::(); + + pyo3::ffi::Py_DECREF(module_dict); + pyo3::ffi::Py_DECREF(module); + ptr +} + +macro_rules! multiple_get_type_object_for { + ($py:expr, $($type:ty => $name:ident,)*) => { + $($name = get_type_object_for::<$type>($py);)* + }; +} + +#[cold] +fn _initialize_typeref(py: pyo3::Python) { + unsafe { + // SAFETY: called once from [`initialize_typeref`] while the GIL is + // held (module init). The `static mut` cells are still null; we + // write each pointer exactly once. After this function returns they + // are only read. + multiple_get_type_object_for!( + py, + // dialects + crate::pybindings::dialect::PyBigQuery => BIGQUERY_DIALECT_TYPE, + crate::pybindings::dialect::PyDatabricks => DATABRICKS_DIALECT_TYPE, + crate::pybindings::dialect::PyDuckDb => DUCKDB_DIALECT_TYPE, + crate::pybindings::dialect::PyHive => HIVE_DIALECT_TYPE, + crate::pybindings::dialect::PyMsSql => MSSQL_DIALECT_TYPE, + crate::pybindings::dialect::PyMySql => MYSQL_DIALECT_TYPE, + crate::pybindings::dialect::PyOracle => ORACLE_DIALECT_TYPE, + crate::pybindings::dialect::PyPostgreSql => POSTGRESQL_DIALECT_TYPE, + crate::pybindings::dialect::PyRedshift => REDSHIFT_DIALECT_TYPE, + crate::pybindings::dialect::PySnowflake => SNOWFLAKE_DIALECT_TYPE, + crate::pybindings::dialect::PySpark => SPARK_DIALECT_TYPE, + crate::pybindings::dialect::PySqlite => SQLITE_DIALECT_TYPE, + crate::pybindings::dialect::PyTeradata => TERADATA_DIALECT_TYPE, + ); + + STD_DECIMAL_TYPE = look_up_type_object(c"decimal", c"Decimal"); + STD_UUID_TYPE = look_up_type_object(c"uuid", c"UUID"); + STD_ENUM_TYPE = look_up_type_object(c"enum", c"EnumMeta"); + + pyo3::ffi::PyDateTime_IMPORT(); + let datetime_capsule = pyo3::ffi::PyCapsule_Import(c"datetime.datetime_CAPI".as_ptr(), 1) + .cast::(); + + STD_DATETIME_TYPE = (*datetime_capsule).DateTimeType; + STD_DATE_TYPE = (*datetime_capsule).DateType; + STD_TIME_TYPE = (*datetime_capsule).TimeType; + } +} + +/// Fill the type-pointer cache. Safe to call more than once; only the first +/// call does work. Must run from `#[pymodule_init]` before any extractor +/// that reads the `static mut` cells. +pub fn initialize_typeref(py: pyo3::Python) { + static INIT: std::sync::Once = std::sync::Once::new(); + + INIT.call_once(|| _initialize_typeref(py)); +} + +/// `type(obj) is T` — no subclass match. +#[inline] +pub unsafe fn check_exact( + ptr: *mut pyo3::ffi::PyObject, + r#type: *mut pyo3::ffi::PyTypeObject, +) -> bool { + pyo3::ffi::Py_TYPE(ptr) == r#type +} + +/// `isinstance(obj, T)`. +#[inline] +pub unsafe fn check(ptr: *mut pyo3::ffi::PyObject, r#type: *mut pyo3::ffi::PyTypeObject) -> bool { + pyo3::ffi::PyObject_TypeCheck(ptr, r#type) == 1 +} From 6e33b4ad4a44e657c86bea5024f8f467495a6239 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 7 Sep 2026 13:05:35 +0330 Subject: [PATCH 10/87] Expose dialect package API with marker equality and canonical names Re-export dialects from rapidquery.dialect, add stubs, and fix the native submodule __name__/__module__ so classes live at rapidquery._core.dialect. Make dialect instances compare and hash by variant and repr as PostgreSQL(). Rename Python class names to DuckDB, PostgreSQL, SQLite, and MSSQL (Rust variants stay idiomatic). --- rapidquery-macros/lib.rs | 45 +++++++++++++++---- rapidquery/__init__.py | 5 +++ rapidquery/dialect.py | 43 ++++++++++++++++++ rapidquery/dialect.pyi | 60 +++++++++++++++++++++++++ src/internal/utils/mod.rs | 2 +- src/internal/utils/submodule_ext.rs | 12 +++-- src/lib.rs | 3 -- src/native/dialect/duckdb.rs | 2 +- src/native/dialect/mod.rs | 6 +-- src/native/dialect/mssql.rs | 2 +- src/native/dialect/postgresql.rs | 2 +- src/native/dialect/sqlite.rs | 2 +- src/native/dialect/tests.rs | 23 ++++++++++ src/pybindings/dialect.rs | 69 +++++++++++++++++++++-------- src/typeref.rs | 11 ----- 15 files changed, 235 insertions(+), 52 deletions(-) create mode 100644 rapidquery/dialect.py create mode 100644 rapidquery/dialect.pyi diff --git a/rapidquery-macros/lib.rs b/rapidquery-macros/lib.rs index 742a480..54b6035 100644 --- a/rapidquery-macros/lib.rs +++ b/rapidquery-macros/lib.rs @@ -1,18 +1,26 @@ use proc_macro::TokenStream; use quote::quote; -use syn::parse::{Parse, ParseStream}; +use syn::{ + parse::{Parse, ParseStream}, + spanned::Spanned, +}; struct PyClassInput { name: syn::LitStr, + module: syn::LitStr, meta_args: Vec, } impl Parse for PyClassInput { - /// Accepts `#name, args...` format + /// Accepts `#name, args...` format. + /// + /// `module = "..."` overrides the default `rapidquery._core` and is not + /// forwarded as a duplicate `#[pyclass]` argument. fn parse(input: ParseStream) -> syn::Result { let name: syn::LitStr = input.parse()?; - + let mut module = syn::LitStr::new("rapidquery._core", name.span()); let mut meta_args = Vec::new(); + while input.peek(syn::token::Comma) { input.parse::()?; @@ -20,17 +28,38 @@ impl Parse for PyClassInput { break; } - meta_args.push(input.parse::()?); + let meta = input.parse::()?; + match meta { + syn::Meta::NameValue(nv) if nv.path.is_ident("module") => match nv.value { + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + }) => module = s.clone(), + _ => return Err(syn::Error::new(nv.span(), "module expects literal string")), + }, + _ => meta_args.push(meta), + } } - Ok(PyClassInput { name, meta_args }) + Ok(PyClassInput { + name, + module, + meta_args, + }) } } -/// Shortcut for `pyo3::pyclass(module="rapidquery._native", name=..., immutable_type, frozen, ...)` +/// Shortcut for `pyo3::pyclass(module=..., name=..., immutable_type, frozen, ...)`. +/// +/// Default module is `rapidquery._core`. Pass `module = "rapidquery._core.dialect"` +/// (or another path) to override. #[proc_macro_attribute] pub fn pyclass(attr: TokenStream, item: TokenStream) -> TokenStream { - let PyClassInput { name, meta_args } = syn::parse_macro_input!(attr as PyClassInput); + let PyClassInput { + name, + module, + meta_args, + } = syn::parse_macro_input!(attr as PyClassInput); let item = syn::parse_macro_input!(item as syn::ItemStruct); let extra_tokens = if meta_args.is_empty() { @@ -40,7 +69,7 @@ pub fn pyclass(attr: TokenStream, item: TokenStream) -> TokenStream { }; quote! { - #[pyo3::pyclass(module="rapidquery._core", name=#name, immutable_type, frozen #extra_tokens)] + #[pyo3::pyclass(module=#module, name=#name, immutable_type, frozen #extra_tokens)] #item } .into() diff --git a/rapidquery/__init__.py b/rapidquery/__init__.py index e69de29..7347187 100644 --- a/rapidquery/__init__.py +++ b/rapidquery/__init__.py @@ -0,0 +1,5 @@ +"""Python SQL query builder with a Rust engine.""" + +from . import dialect as dialect + +__all__ = ["dialect"] diff --git a/rapidquery/dialect.py b/rapidquery/dialect.py new file mode 100644 index 0000000..a49202e --- /dev/null +++ b/rapidquery/dialect.py @@ -0,0 +1,43 @@ +"""SQL dialect markers. + +Pass either the class or an instance wherever a dialect is required: + + from rapidquery.dialect import DuckDB, PostgreSQL + + query.prepare(PostgreSQL) + query.prepare(DuckDB()) +""" + +from ._core.dialect import ( + BigQuery, + Databricks, + DuckDB, + Hive, + MSSQL, + MySql, + Oracle, + PostgreSQL, + Redshift, + Snowflake, + Spark, + SqlDialect, + SQLite, + Teradata, +) + +__all__ = [ + "BigQuery", + "Databricks", + "DuckDB", + "Hive", + "MSSQL", + "MySql", + "Oracle", + "PostgreSQL", + "Redshift", + "Snowflake", + "Spark", + "SqlDialect", + "SQLite", + "Teradata", +] diff --git a/rapidquery/dialect.pyi b/rapidquery/dialect.pyi new file mode 100644 index 0000000..7929a4c --- /dev/null +++ b/rapidquery/dialect.pyi @@ -0,0 +1,60 @@ +from typing import Self, final + +class SqlDialect: + @property + def name(self) -> str: ... + def __eq__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... + +@final +class BigQuery(SqlDialect): + def __new__(cls) -> Self: ... + +@final +class Databricks(SqlDialect): + def __new__(cls) -> Self: ... + +@final +class DuckDB(SqlDialect): + def __new__(cls) -> Self: ... + +@final +class Hive(SqlDialect): + def __new__(cls) -> Self: ... + +@final +class MSSQL(SqlDialect): + def __new__(cls) -> Self: ... + +@final +class MySql(SqlDialect): + def __new__(cls) -> Self: ... + +@final +class Oracle(SqlDialect): + def __new__(cls) -> Self: ... + +@final +class PostgreSQL(SqlDialect): + def __new__(cls) -> Self: ... + +@final +class Redshift(SqlDialect): + def __new__(cls) -> Self: ... + +@final +class Snowflake(SqlDialect): + def __new__(cls) -> Self: ... + +@final +class Spark(SqlDialect): + def __new__(cls) -> Self: ... + +@final +class SQLite(SqlDialect): + def __new__(cls) -> Self: ... + +@final +class Teradata(SqlDialect): + def __new__(cls) -> Self: ... diff --git a/src/internal/utils/mod.rs b/src/internal/utils/mod.rs index 6857e6d..f48499b 100644 --- a/src/internal/utils/mod.rs +++ b/src/internal/utils/mod.rs @@ -4,7 +4,7 @@ mod submodule_ext; pub use strings::*; pub use submodule_ext::*; -/// Fully-qualified type name of `obj` (`int`, `rapidquery._core.PostgreSql`, …). +/// Fully-qualified type name of `obj` (`int`, `rapidquery._core.dialect.PostgreSQL`, …). /// /// Returns `""` if `Py_TYPE` is null or the name lookup fails. /// diff --git a/src/internal/utils/submodule_ext.rs b/src/internal/utils/submodule_ext.rs index 51c7966..3f5d325 100644 --- a/src/internal/utils/submodule_ext.rs +++ b/src/internal/utils/submodule_ext.rs @@ -6,9 +6,9 @@ //! //! Workaround: //! -//! The child's `__name__` stays whatever `#[pymodule(name = ...)]` set -//! (`"dialect"`, not `"rapidquery._core.dialect"`). Import still succeeds -//! because the `sys.modules` key is the dotted path. +//! After registration, the child's `__name__` / `__package__` are set to +//! the dotted path (`rapidquery._core.dialect`) so `repr` and pickling +//! match the import path. use pyo3::types::{PyAnyMethods, PyModule, PyModuleMethods}; @@ -19,7 +19,13 @@ fn _inner_init(module: &pyo3::Bound<'_, PyModule>, path: &str) -> pyo3::PyResult let submod_name = submod_name.extract::()?; let submod = module.getattr(&submod_name)?; let modpath = format!("{path}.{submod_name}"); + if let Ok(submod) = submod.cast::() { + submod.setattr("__name__", &modpath)?; + if let Some((package, _)) = modpath.rsplit_once('.') { + submod.setattr("__package__", package)?; + } + _inner_init(submod, &modpath)?; sys_modules.set_item(&modpath, submod)?; } diff --git a/src/lib.rs b/src/lib.rs index 6110e18..84327be 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,9 +5,6 @@ pub mod native; pub mod pybindings; pub mod typeref; -// `gil_used = false` for free-threaded CPython. Nested `#[pymodule]` -// exports (`dialect`, later `data_type`) are registered in `sys.modules` -// so `import rapidquery._core.dialect` works. /// RapidQuery native extension. #[pyo3::pymodule(gil_used = false)] mod _core { diff --git a/src/native/dialect/duckdb.rs b/src/native/dialect/duckdb.rs index 72b0a13..96ecf1e 100644 --- a/src/native/dialect/duckdb.rs +++ b/src/native/dialect/duckdb.rs @@ -14,7 +14,7 @@ use crate::internal::utils::replace_to; use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; -pub(super) const NAME: &str = "DuckDb"; +pub(super) const NAME: &str = "DuckDB"; pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; diff --git a/src/native/dialect/mod.rs b/src/native/dialect/mod.rs index 0fae710..ed779b5 100644 --- a/src/native/dialect/mod.rs +++ b/src/native/dialect/mod.rs @@ -262,8 +262,8 @@ macro_rules! dispatch { /// Each variant carries no data; dialect-specific behavior lives in the /// corresponding private module and is reached through the methods below. /// -/// Variant names (and [`SqlDialect::name`]) are the Python class names -/// (`DuckDb`, `PostgreSql`, `Sqlite`, …). Do not ā€œcorrectā€ the spelling. +/// [`SqlDialect::name`] is the Python class name (`DuckDB`, `PostgreSQL`, +/// `SQLite`, …). Rust variant identifiers stay idiomatic (`DuckDb`). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[non_exhaustive] pub enum SqlDialect { @@ -296,7 +296,7 @@ pub enum SqlDialect { } impl SqlDialect { - /// Display name, identical to the variant identifier (e.g. `"PostgreSql"`). + /// Display name, identical to the Python class name (e.g. `"PostgreSQL"`, `"DuckDB"`). #[inline] pub fn name(&self) -> &'static str { dispatch!(self, const NAME) diff --git a/src/native/dialect/mssql.rs b/src/native/dialect/mssql.rs index c5af7af..b4d86d2 100644 --- a/src/native/dialect/mssql.rs +++ b/src/native/dialect/mssql.rs @@ -15,7 +15,7 @@ use crate::internal::utils::replace_to; use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; -pub(super) const NAME: &str = "MsSql"; +pub(super) const NAME: &str = "MSSQL"; pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Int; diff --git a/src/native/dialect/postgresql.rs b/src/native/dialect/postgresql.rs index 8b75bbe..9ea1ab3 100644 --- a/src/native/dialect/postgresql.rs +++ b/src/native/dialect/postgresql.rs @@ -18,7 +18,7 @@ use crate::internal::utils::replace_to; use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; -pub(super) const NAME: &str = "PostgreSql"; +pub(super) const NAME: &str = "PostgreSQL"; pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; diff --git a/src/native/dialect/sqlite.rs b/src/native/dialect/sqlite.rs index e1552c4..3677707 100644 --- a/src/native/dialect/sqlite.rs +++ b/src/native/dialect/sqlite.rs @@ -16,7 +16,7 @@ use crate::internal::utils::replace_to; use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; -pub(super) const NAME: &str = "Sqlite"; +pub(super) const NAME: &str = "SQLite"; pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Int; diff --git a/src/native/dialect/tests.rs b/src/native/dialect/tests.rs index 7c8d7bd..8c5f2c8 100644 --- a/src/native/dialect/tests.rs +++ b/src/native/dialect/tests.rs @@ -768,6 +768,29 @@ fn sql_dialect_is_copy() { assert_copy(PlaceholderSyntax::Colon); } +#[test] +fn name_is_the_python_class_name() { + let expected = [ + (SqlDialect::BigQuery, "BigQuery"), + (SqlDialect::Databricks, "Databricks"), + (SqlDialect::DuckDb, "DuckDB"), + (SqlDialect::Hive, "Hive"), + (SqlDialect::MsSql, "MSSQL"), + (SqlDialect::MySql, "MySql"), + (SqlDialect::Oracle, "Oracle"), + (SqlDialect::PostgreSql, "PostgreSQL"), + (SqlDialect::Redshift, "Redshift"), + (SqlDialect::Snowflake, "Snowflake"), + (SqlDialect::Spark, "Spark"), + (SqlDialect::Sqlite, "SQLite"), + (SqlDialect::Teradata, "Teradata"), + ]; + assert_eq!(expected.len(), ALL_DIALECTS.len()); + for (dialect, name) in expected { + assert_eq!(dialect.name(), name); + } +} + #[test] fn quote_identifier_uses_expected_delimiters() { assert_eq!(SqlDialect::MySql.quote_identifier_as_string("id"), "`id`"); diff --git a/src/pybindings/dialect.rs b/src/pybindings/dialect.rs index 24c725d..72398a8 100644 --- a/src/pybindings/dialect.rs +++ b/src/pybindings/dialect.rs @@ -4,18 +4,17 @@ //! class hierarchy instead: //! //! * [`PySqlDialect`] — `rapidquery._core.dialect.SqlDialect`, the ABC -//! * one frozen subclass per variant (`PostgreSql`, `MySql`, …) +//! * one frozen subclass per variant (`PostgreSQL`, `DuckDB`, …) //! //! Extraction ([`FromPyObject`] for [`PySqlDialect`]) accepts **either** the -//! class (`PostgreSql`) **or** an instance (`PostgreSql()`). Matching is by +//! class (`PostgreSQL`) **or** an instance (`PostgreSQL()`). Matching is by //! interned [`PyTypeObject`] pointer (see [`crate::typeref`]), so only the //! 13 built-in classes convert — a Python subclass of `SqlDialect` does not. //! -//! `#[pyclass]` sets `module = "rapidquery._core"` via the crate macro, so -//! `PostgreSql.__module__` is `rapidquery._core` rather than -//! `rapidquery._core.dialect`. The submodule is still importable as -//! `rapidquery._core.dialect` because [`crate::internal::utils::PyModuleSubmoduleExt`] -//! registers it in `sys.modules`. +//! Instances compare and hash by native variant (`PostgreSQL() == PostgreSQL()`) +//! and `repr` as `PostgreSQL()`. + +use std::hash::{Hash, Hasher}; use crate::{internal::utils::get_type_name, native}; @@ -23,11 +22,18 @@ use crate::{internal::utils::get_type_name, native}; // Rust. Python subclasses are not accepted by the extractor below. /// Abstract SQL dialect. /// -/// Concrete dialects are frozen subclasses (`PostgreSql`, `MySql`, ...). +/// Concrete dialects are frozen subclasses (`PostgreSQL`, `DuckDB`, ...). /// Pass either the class or an instance wherever a dialect is required. /// This base class cannot be instantiated. -#[rapidquery_macros::pyclass("SqlDialect", subclass, skip_from_py_object)] -#[derive(Debug, Clone)] +/// +/// Instances of the same dialect compare equal and hash together. +#[rapidquery_macros::pyclass( + "SqlDialect", + subclass, + skip_from_py_object, + module = "rapidquery._core.dialect" +)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct PySqlDialect(pub native::dialect::SqlDialect); impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PySqlDialect { @@ -54,8 +60,8 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PySqlDialect { unsafe { // SAFETY: `obj` is a borrowed `&Bound`, so `as_ptr()` is a // live `PyObject*`. `PyType_CheckExact` is true when the caller - // passed the class itself (`PostgreSql`); otherwise we take - // `Py_TYPE` of an instance (`PostgreSql()`). Both pointers are + // passed the class itself (`PostgreSQL`); otherwise we take + // `Py_TYPE` of an instance (`PostgreSQL()`). Both pointers are // interned `PyTypeObject*` values — we only compare addresses // against `typeref`, we do not dereference them here. // `get_type_name` requires a valid object pointer on the error @@ -94,12 +100,32 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PySqlDialect { #[pyo3::pymethods] impl PySqlDialect { - /// Dialect identifier, identical to the Python class name (`"PostgreSql"`). + /// Dialect identifier, identical to the Python class name (`"PostgreSQL"`). #[getter] #[inline] fn name(&self) -> &'static str { self.0.name() } + + fn __repr__(&self) -> String { + let name = self.0.name(); + let mut buf = String::with_capacity(name.len() + 2); + buf.push_str(name); + buf.push_str("()"); + buf + } + + // Bound so only instances match; the class object is a type. + // PostgreSQL() == PostgreSQL is false; PostgreSQL() == PostgreSQL() is true. + fn __eq__(&self, other: &pyo3::Bound<'_, Self>) -> bool { + self.0 == other.get().0 + } + + fn __hash__(&self) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } } /// Generate one frozen Python class per native [`SqlDialect`] variant and @@ -112,12 +138,17 @@ macro_rules! impl_dialects { ($($native:ident => $name:ident => $pyname:literal,)+) => { $( #[doc = concat!($pyname, " SQL dialect.")] - #[rapidquery_macros::pyclass($pyname, extends=PySqlDialect)] + #[rapidquery_macros::pyclass( + $pyname, + extends = PySqlDialect, + module = "rapidquery._core.dialect" + )] pub struct $name; #[pyo3::pymethods] impl $name { - // Marker instance. Passing the class itself is equivalent. + // Marker instance. Passing the class itself is equivalent + // at extract time; instances compare equal by variant. #[new] #[inline] fn __new__() -> pyo3::PyClassInitializer { @@ -141,15 +172,15 @@ macro_rules! impl_dialects { impl_dialects! { BigQuery => PyBigQuery => "BigQuery", Databricks => PyDatabricks => "Databricks", - DuckDb => PyDuckDb => "DuckDb", + DuckDb => PyDuckDb => "DuckDB", Hive => PyHive => "Hive", - MsSql => PyMsSql => "MsSql", + MsSql => PyMsSql => "MSSQL", MySql => PyMySql => "MySql", Oracle => PyOracle => "Oracle", - PostgreSql => PyPostgreSql => "PostgreSql", + PostgreSql => PyPostgreSql => "PostgreSQL", Redshift => PyRedshift => "Redshift", Snowflake => PySnowflake => "Snowflake", Spark => PySpark => "Spark", - Sqlite => PySqlite => "Sqlite", + Sqlite => PySqlite => "SQLite", Teradata => PyTeradata => "Teradata", } diff --git a/src/typeref.rs b/src/typeref.rs index c78aff6..da163c4 100644 --- a/src/typeref.rs +++ b/src/typeref.rs @@ -1,12 +1,4 @@ //! Interned `PyTypeObject*` cache. -//! -//! Hot-path extractors (`FromPyObject` for dialects, later Ident / serializers) -//! compare `Py_TYPE(obj)` against these pointers instead of walking the MRO. -//! Pointers are written once during module init ([`initialize_typeref`]) and -//! treated as immutable afterwards. -//! -//! Dialect entries are the 13 concrete classes, not [`super::pybindings::dialect::PySqlDialect`]: -//! the extractor matches exact types, not "any `SqlDialect` subclass". // Dialect types pub(crate) static mut BIGQUERY_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); @@ -41,9 +33,6 @@ unsafe fn get_type_object_for( /// Look up `module_name.member_name` and return its type object. /// -/// `PyMapping_GetItemString` returns a **new** reference. That ref is -/// intentionally leaked so the `PyTypeObject` stays alive for the process. -/// /// # Safety /// /// `PyImport_ImportModule` / dict lookup are not null-checked. A missing From ea0d465c7215a5d9db4a8629f8e949529cda3f79 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 7 Sep 2026 13:15:11 +0330 Subject: [PATCH 11/87] Write tests for pybinding dialects --- pyproject.toml | 1 + tests/conftest.py | 47 +++++++++++++++++++ tests/test_dialect.py | 105 ++++++++++++++++++++++++++++++++++++++++++ uv.lock | 90 +++++++++++++++++++++++++++++++++++- 4 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_dialect.py diff --git a/pyproject.toml b/pyproject.toml index 6d812ef..83ab9d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,4 +57,5 @@ Issues = "https://github.com/awolverp/rapidquery/issues" [dependency-groups] dev = [ "maturin == 1.15.0", + "pytest>=8.3.5", ] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9fc11c8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,47 @@ +import pytest + +from rapidquery.dialect import ( + MSSQL, + BigQuery, + Databricks, + DuckDB, + Hive, + MySql, + Oracle, + PostgreSQL, + Redshift, + Snowflake, + Spark, + SqlDialect, + SQLite, + Teradata, +) + +ALL_DIALECTS = [ + BigQuery, + Databricks, + DuckDB, + Hive, + MSSQL, + MySql, + Oracle, + PostgreSQL, + Redshift, + Snowflake, + Spark, + SQLite, + Teradata, +] +ALL_DIALECT_NAMES = [cls.__name__ for cls in ALL_DIALECTS] + + +@pytest.fixture(params=ALL_DIALECTS, ids=ALL_DIALECT_NAMES) +def dialect_cls(request: pytest.FixtureRequest) -> type[SqlDialect]: + """Every concrete SqlDialect subclass.""" + return request.param + + +@pytest.fixture(params=ALL_DIALECTS, ids=ALL_DIALECT_NAMES) +def dialect_instance(request: pytest.FixtureRequest) -> SqlDialect: + """An instance of every concrete SqlDialect subclass.""" + return request.param() diff --git a/tests/test_dialect.py b/tests/test_dialect.py new file mode 100644 index 0000000..a91cf73 --- /dev/null +++ b/tests/test_dialect.py @@ -0,0 +1,105 @@ +import pytest + +from rapidquery.dialect import SqlDialect + +from .conftest import ALL_DIALECT_NAMES, ALL_DIALECTS + + +class TestDialectIdentity: + def test_name_matches_class_name(self, dialect_cls: type[SqlDialect]): + instance = dialect_cls() + assert instance.name == dialect_cls.__name__ + + def test_name_is_a_string(self, dialect_cls: type[SqlDialect]): + assert isinstance(dialect_cls().name, str) + + def test_name_is_stable_across_instances(self, dialect_cls: type[SqlDialect]): + assert dialect_cls().name == dialect_cls().name + + def test_all_expected_dialects_are_exposed(self): + import rapidquery.dialect as dialect_module + + for expected_name in ALL_DIALECT_NAMES: + assert hasattr(dialect_module, expected_name), ( + f"expected dialect module to expose {expected_name!r}" + ) + + def test_different_dialects_have_different_names(self): + names = {cls().name for cls in ALL_DIALECTS} + assert len(names) == len(ALL_DIALECTS) + + +class TestDialectSubclassing: + def test_is_subclass_of_sql_dialect(self, dialect_cls: type[SqlDialect]): + assert issubclass(dialect_cls, SqlDialect) + + def test_instance_is_instance_of_sql_dialect(self, dialect_instance: SqlDialect): + assert isinstance(dialect_instance, SqlDialect) + + def test_dialect_classes_are_all_distinct(self): + assert len(set(ALL_DIALECTS)) == len(ALL_DIALECTS) + + +class TestDialectFinality: + def test_cannot_be_subclassed_further(self, dialect_cls: type[SqlDialect]): + with pytest.raises(TypeError): + _ = type(f"Sub{dialect_cls.__name__}", (dialect_cls,), {}) + + def test_base_sql_dialect_can_be_subclassed(self): + class _CustomDialect(SqlDialect): + pass + + assert issubclass(_CustomDialect, SqlDialect) + + +class TestDialectConstruction: + def test_constructs_with_no_arguments(self, dialect_cls: type[SqlDialect]): + _ = dialect_cls() + + def test_rejects_unexpected_arguments(self, dialect_cls: type[SqlDialect]): + with pytest.raises(TypeError): + _ = dialect_cls("unexpected") + + def test_name_is_read_only(self, dialect_instance: SqlDialect): + with pytest.raises(AttributeError): + dialect_instance.name = "Something" + + def test_base_cannot_be_instantiated(self): + with pytest.raises(TypeError): + _ = SqlDialect() + + +class TestDialectMarkers: + def test_instances_of_same_dialect_are_equal(self, dialect_cls: type[SqlDialect]): + assert dialect_cls() == dialect_cls() + + def test_instances_of_different_dialects_are_not_equal(self): + from rapidquery.dialect import MySql, PostgreSQL + + assert PostgreSQL() != MySql() + + def test_instance_is_not_equal_to_its_class(self, dialect_cls: type[SqlDialect]): + assert dialect_cls() != dialect_cls + assert dialect_cls != dialect_cls() + + def test_not_equal_to_unrelated_values(self, dialect_instance: SqlDialect): + assert dialect_instance != object() + assert dialect_instance != dialect_instance.name + assert dialect_instance != 1 + + def test_equal_instances_share_a_hash(self, dialect_cls: type[SqlDialect]): + assert hash(dialect_cls()) == hash(dialect_cls()) + + def test_same_dialect_collapses_in_a_set(self, dialect_cls: type[SqlDialect]): + assert len({dialect_cls(), dialect_cls()}) == 1 + + def test_different_dialects_are_distinct_set_keys(self): + from rapidquery.dialect import MySql, PostgreSQL + + assert len({PostgreSQL(), MySql()}) == 2 + + def test_repr_is_call_form(self, dialect_cls: type[SqlDialect]): + assert repr(dialect_cls()) == f"{dialect_cls.__name__}()" + + def test_repr_matches_name(self, dialect_instance: SqlDialect): + assert repr(dialect_instance) == f"{dialect_instance.name}()" diff --git a/uv.lock b/uv.lock index 4038847..a959e96 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,36 @@ version = 1 revision = 3 requires-python = ">=3.10" +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "maturin" version = "1.15.0" @@ -26,6 +56,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/10/15d4314edf130955edf2dc237aa393a8a7c10f2b9b57b89fa2f61f915659/maturin-1.15.0-py3-none-win_arm64.whl", hash = "sha256:c7dc0c66c78d3debdd9c5aa807e861fbcbf07f3505d34b125df74c03986b0f48", size = 9713795, upload-time = "2026-08-24T12:11:20.83Z" }, ] +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "rapidquery" source = { editable = "." } @@ -33,12 +108,16 @@ source = { editable = "." } [package.dev-dependencies] dev = [ { name = "maturin" }, + { name = "pytest" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [{ name = "maturin", specifier = "==1.15.0" }] +dev = [ + { name = "maturin", specifier = "==1.15.0" }, + { name = "pytest", specifier = ">=8.3.5" }, +] [[package]] name = "tomli" @@ -93,3 +172,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] From b31bfe51ea9ad56cc4b1a5445b91a96ba5d50cfc Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 7 Sep 2026 13:24:58 +0330 Subject: [PATCH 12/87] Write native::data_type --- Cargo.lock | 317 ++++++++++++- Cargo.toml | 5 + src/native/data_type/mod.rs | 5 + src/native/data_type/name.rs | 213 +++++++++ src/native/data_type/serializers.rs | 711 ++++++++++++++++++++++++++++ src/native/mod.rs | 1 + 6 files changed, 1248 insertions(+), 4 deletions(-) create mode 100644 src/native/data_type/mod.rs create mode 100644 src/native/data_type/name.rs create mode 100644 src/native/data_type/serializers.rs diff --git a/Cargo.lock b/Cargo.lock index 6afb9dd..1aa3112 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,21 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[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 = "arcstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" + [[package]] name = "autocfg" version = "1.5.0" @@ -20,27 +35,87 @@ version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +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 = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -57,6 +132,47 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[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 = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.175" @@ -73,6 +189,44 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -84,9 +238,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "parking_lot" @@ -111,6 +265,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "portable-atomic" version = "1.11.1" @@ -197,7 +357,12 @@ dependencies = [ name = "rapidquery" version = "1.0.0-alpha1" dependencies = [ + "arcstr", + "chrono", "hashbrown", + "ipnetwork", + "mac_address", + "once_cell", "parking_lot", "pyo3", "rapidquery-macros", @@ -221,12 +386,30 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[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.1" @@ -256,6 +439,132 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +[[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-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", + "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 = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[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", +] + +[[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", +] + +[[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-targets" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 154b6ba..436d4d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,11 @@ rapidquery-macros = { path = "rapidquery-macros" } pyo3 = { version = "0.29", default-features = false, features = [ "macros", "chrono", "py-clone" ] } parking_lot = { version = "0.12", default-features = false } hashbrown = { version = "0.17.1", default-features = false, features = ["allocator-api2", "default-hasher", "inline-more"] } +arcstr = { version = "1.2" } +chrono = { version = "0.4", default-features = false, features = ["clock"] } +ipnetwork = { version = "0.20", default-features = false } +mac_address = { version = "1.1", default-features = false } +once_cell = { version = "1.21.4", default-features = false } [features] default = ["extension-module"] diff --git a/src/native/data_type/mod.rs b/src/native/data_type/mod.rs new file mode 100644 index 0000000..d4665e6 --- /dev/null +++ b/src/native/data_type/mod.rs @@ -0,0 +1,5 @@ +mod name; +mod serializers; + +pub use name::{DataTypeGroup, DataTypeName}; +pub use serializers::DataTypeSerializer; diff --git a/src/native/data_type/name.rs b/src/native/data_type/name.rs new file mode 100644 index 0000000..0dc8b26 --- /dev/null +++ b/src/native/data_type/name.rs @@ -0,0 +1,213 @@ +use crate::native::prepare_statement::PrepareStatement; + +/// Specifies data type groups +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord, Hash)] +pub enum DataTypeGroup { + STRING, + UUID, + BYTES, + BIT, + INTEGER, + FLOAT, + BOOLEAN, + DATE, + TIME, + DATETIME, + JSON, + JSONB, + INET, + MACADDRESS, + CUSTOM, +} + +/// SQL data types +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +pub struct DataTypeName(arcstr::ArcStr, DataTypeGroup); + +// Create all no-argument data types once. This reduces memory footprint, thanks to `arcstr::ArcStr`. +macro_rules! simple_data_types { + ( + $( + $(#[$docs:meta])* + ($upcase:ident => ($realname:literal, $group:ident), $possible_name:literal, $($possible_names:literal),*); + )+ + ) => { + $( + $(#[$docs])* + pub(super) const $upcase: DataTypeName = DataTypeName(arcstr::literal!($realname), DataTypeGroup::$group); + )+ + + impl From<&str> for DataTypeName { + fn from(value: &str) -> Self { + if value.find('(').is_some() { + // For data types that has gotten parameters, we cannot use constant variables + DataTypeName(arcstr::ArcStr::from(value), DataTypeGroup::CUSTOM) + } else { + let lowercase = value.to_ascii_lowercase(); + match lowercase.as_str() { + $( + $possible_name $(| $possible_names)* => $upcase, + )+ + _ => DataTypeName(arcstr::ArcStr::from(value), DataTypeGroup::CUSTOM), + } + } + } + } + }; +} + +simple_data_types! { + // --- STRING group --- + (CHARACTER_DATA_TYPE => ("CHARACTER", STRING), "character",); + (CHAR_DATA_TYPE => ("CHAR", STRING), "char",); + (CHARACTER_VARYING_DATA_TYPE => ("CHARACTER VARYING", STRING), "characater varying",); + (CHAR_VARYING_DATA_TYPE => ("CHAR VARYING", STRING), "char varying",); + (VARCHAR_DATA_TYPE => ("VARCHAR", STRING), "varchar",); + (NVARCHAR_DATA_TYPE => ("NVARCHAR", STRING), "nvarchar",); + (CHARACTER_LARGE_OBJECT_DATA_TYPE => ("CHARACTER LARGE OBJECT", STRING), "character large object",); + (CHAR_LARGE_OBJECT_DATA_TYPE => ("CHAR LARGE OBJECT", STRING), "char large object",); + (CLOB_DATA_TYPE => ("CLOB", STRING), "clob",); + (REGCLASS_DATA_TYPE => ("REGCLASS", STRING), "regclass",); + (TEXT_DATA_TYPE => ("TEXT", STRING), "text",); + (TINYTEXT_DATA_TYPE => ("TINYTEXT", STRING), "tinytext",); + (MEDIUMTEXT_DATA_TYPE => ("MEDIUMTEXT", STRING), "mediumtext",); + (LONGTEXT_DATA_TYPE => ("LONGTEXT", STRING), "longtext",); + (STRING_DATA_TYPE => ("STRING", STRING), "string",); + + // --- UUID group --- + (UUID_DATA_TYPE => ("UUID", UUID), "uuid",); + + // --- BINARY group --- + (BINARY_DATA_TYPE => ("BINARY", BYTES), "binary",); + (VARBINARY_DATA_TYPE => ("VARBINARY", BYTES), "varbinary",); + (BLOB_DATA_TYPE => ("BLOB", BYTES), "blob",); + (TINYBLOB_DATA_TYPE => ("TINYBLOB", BYTES), "tinyblob",); + (MEDIUMBLOB_DATA_TYPE => ("MEDIUMBLOB", BYTES), "mediumblob",); + (LONGBLOB_DATA_TYPE => ("LONGBLOB", BYTES), "longblob",); + (BYTES_DATA_TYPE => ("BYTES", BYTES), "bytes",); + (BYTEA_DATA_TYPE => ("BYTEA", BYTES), "bytea",); + + // --- BIT group --- + (BIT_DATA_TYPE => ("BIT", BIT), "bit",); + (BIT_VARYING_DATA_TYPE => ("BIT VARYING", BIT), "bit varying",); + (VARBIT_DATA_TYPE => ("VARBIT", BIT), "varbit",); + + // --- FLOAT group --- + (NUMERIC_DATA_TYPE => ("NUMERIC", FLOAT), "numeric",); + (DECIMAL_DATA_TYPE => ("DECIMAL", FLOAT), "decimal",); + (BIGNUMERIC_DATA_TYPE => ("BIGNUMERIC", FLOAT), "bignumeric",); + (BIGDECIMAL_DATA_TYPE => ("BIGDECIMAL", FLOAT), "bigdecimal",); + (DEC_DATA_TYPE => ("DEC", FLOAT), "dec",); + (FLOAT_DATA_TYPE => ("FLOAT", FLOAT), "float",); + (FLOAT4_DATA_TYPE => ("FLOAT4", FLOAT), "float4",); + (FLOAT8_DATA_TYPE => ("FLOAT8", FLOAT), "float8",); + (FLOAT64_DATA_TYPE => ("FLOAT64", FLOAT), "float64",); + (REAL_DATA_TYPE => ("REAL", FLOAT), "real",); + (REAL_UNSIGNED_DATA_TYPE => ("REAL UNSIGNED", FLOAT), "real unsigned",); + (DOUBLE_DATA_TYPE => ("DOUBLE", FLOAT), "double",); + (DOUBLE_UNSIGNED_DATA_TYPE => ("DOUBLE UNSIGNED", FLOAT), "double unsigned",); + (DOUBLE_PRECISION_DATA_TYPE => ("DOUBLE PRECISION", FLOAT), "double precision",); + (DOUBLE_PRECISION_UNSIGNED_DATA_TYPE => ("DOUBLE PRECISION UNSIGNED", FLOAT), "double precision unsigned",); + + // --- INTEGER --- + (TINYINT_DATA_TYPE => ("TINYINT", INTEGER), "tinyint",); + (TINYINT_UNSIGNED_DATA_TYPE => ("TINYINT UNSIGNED", INTEGER), "tinyint unsigned", "utinyint"); + (INT2_DATA_TYPE => ("INT2", INTEGER), "int2",); + (INT2_UNSIGNED_DATA_TYPE => ("INT2 UNSIGNED", INTEGER), "int2 unsigned",); + (SMALLINT_DATA_TYPE => ("SMALLINT", INTEGER), "smallint",); + (SMALLINT_UNSIGNED_DATA_TYPE => ("SMALLINT UNSIGNED", INTEGER), "smallint unsigned", "usmallint"); + (MEDIUMINT_DATA_TYPE => ("MEDIUMINT", INTEGER), "mediumint",); + (MEDIUMINT_UNSIGNED_DATA_TYPE => ("MEDIUMINT UNSIGNED", INTEGER), "mediumint unsigned",); + (INT_DATA_TYPE => ("INT", INTEGER), "int",); + (INT4_DATA_TYPE => ("INT4", INTEGER), "int4",); + (INT8_DATA_TYPE => ("INT8", INTEGER), "int8",); + (INT64_DATA_TYPE => ("INT64", INTEGER), "int64",); + (INTEGER_DATA_TYPE => ("INTEGER", INTEGER), "integer",); + (UNSIGNED_INTEGER_DATA_TYPE => ("UNSIGNED INTEGER", INTEGER), "unsigned integer", "integer unsigned", "int unsigned"); + (INT4_UNSIGNED_DATA_TYPE => ("INT4 UNSIGNED", INTEGER), "int4 unsigned",); + (HUGEINT_DATA_TYPE => ("HUGEINT", INTEGER), "hugeint",); + (UHUGEINT_DATA_TYPE => ("UHUGEINT", INTEGER), "uhugeint",); + (BIGINT_DATA_TYPE => ("BIGINT", INTEGER), "bigint",); + (BIGINT_UNSIGNED_DATA_TYPE => ("BIGINT UNSIGNED", INTEGER), "bigint unsigned", "unsigned bitint", "ubigint"); + (INT8_UNSIGNED_DATA_TYPE => ("INT8 UNSIGNED", INTEGER), "int8 unsigned",); + (SIGNED_DATA_TYPE => ("SIGNED", INTEGER), "signed",); + (SIGNED_INTEGER_DATA_TYPE => ("SIGNED INTEGER", INTEGER), "signed integer",); + (UNSIGNED_DATA_TYPE => ("UNSIGNED", INTEGER), "unsigned",); + + // --- BOOLEAN group --- + (BOOLEAN_DATA_TYPE => ("BOOLEAN", BOOLEAN), "boolean", "bool"); + + // --- DATE group --- + (DATE_DATA_TYPE => ("DATE", DATE), "date",); + + // --- TIME group --- + (TIME_DATA_TYPE => ("TIME", TIME), "time", "time without timezone"); + (TIME_WITH_TIMEZONE_DATA_TYPE => ("TIME WITH TIMEZONE", TIME), "time with timezone",); + + // --- DATETIME group --- + (DATETIME_DATA_TYPE => ("DATETIME", DATETIME), "datetime",); + (TIMESTAMP_DATA_TYPE => ("TIMESTAMP", DATETIME), "timestamp", "timestamp without timezone"); + (TIMESTAMP_WITH_TIMEZONE_DATA_TYPE => ("TIMESTAMP WITH TIMEZONE", DATETIME), "timestamp with timezone", "timestamptz"); + (TIMESTAMPNTZ_DATA_TYPE => ("TIMESTAMPNTZ", DATETIME), "timestampntz",); + + // --- JSON group --- + (JSON_DATA_TYPE => ("JSON", JSON), "json",); + (JSONB_DATA_TYPE => ("JSONB", JSONB), "jsonb",); + + // --- INET group --- + (INET_DATA_TYPE => ("INET", INET), "inet",); + + // --- MACADDR group --- + (MACADDRESS_DATA_TYPE => ("MACADDRESS", MACADDRESS), "macaddress", "macaddr"); + + // --- CUSTOM group --- + (ANY_DATA_TYPE => ("ANY", CUSTOM), "any",); + (TRIGGER_DATA_TYPE => ("TRIGGER", CUSTOM), "trigger",); + (UNSPECIFIED_DATA_TYPE => ("UNSPECIFIED", CUSTOM), "unspecified",); +} + +impl DataTypeName { + #[inline] + pub fn group(&self) -> DataTypeGroup { + self.1 + } +} + +impl From for DataTypeName { + #[inline] + fn from(value: String) -> Self { + DataTypeName::from(value.as_str()) + } +} + +impl AsRef for DataTypeName { + #[inline] + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl From for String { + #[inline] + fn from(val: DataTypeName) -> Self { + val.0.to_string() + } +} + +impl std::fmt::Display for DataTypeName { + #[inline] + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl PrepareStatement for DataTypeName { + fn prepare( + &self, + _dialect: &crate::native::dialect::SqlDialect, + context: &mut crate::native::prepare_statement::PrepareContext, + ) -> crate::native::prepare_statement::PrepareResult<()> { + context.push_str(&self.0); + Ok(()) + } +} diff --git a/src/native/data_type/serializers.rs b/src/native/data_type/serializers.rs new file mode 100644 index 0000000..df6a5b7 --- /dev/null +++ b/src/native/data_type/serializers.rs @@ -0,0 +1,711 @@ +use pyo3::types::PyAnyMethods; + +use crate::internal::alias; +use crate::internal::utils::get_type_name; + +/// SQL data-type value serializers. +/// +/// One variant per *distinct serialization behaviour* - many SQL data types +/// collapse onto the same serializer. +#[derive(Debug, Clone)] +pub enum DataTypeSerializer { + /// Every signed/unsigned integer width. + /// + /// Supports: + /// - `TinyInt` + /// - `TinyIntUnsigned` + /// - `UTinyInt` + /// - `Int2` + /// - `Int2Unsigned` + /// - `SmallInt` + /// - `SmallIntUnsigned` + /// - `USmallInt` + /// - `MediumInt` + /// - `MediumIntUnsigned` + /// - `Int` + /// - `Int4` + /// - `Int8` + /// - `Integer` + /// - `IntUnsigned` + /// - `Int4Unsigned` + /// - `BigInt` + /// - `BigIntUnsigned` + /// - `Int8Unsigned` + /// - `UInt8` + /// - `UInt16` + /// - `UInt32` + /// - `UInt64` + /// - `UInt128` + /// - `UInt256` + /// - `Int128` + /// - `Int256` + /// - `UBigInt` + /// - `UInt` + Integer, + /// Every float **and** decimal/numeric type (FLOAT, DOUBLE, DECIMAL, …). + /// + /// Supports: + /// - `Numeric` + /// - `Decimal` + /// - `DecimalUnsigned` + /// - `BigNumeric` + /// - `BigDecimal` + /// - `Dec` + /// - `DecUnsigned` + /// - `Float` + /// - `FloatUnsigned` + /// - `Real` + /// - `RealUnsigned` + /// - `Float4` + /// - `Float8` + /// - `Double` + /// - `DoubleUnsigned` + /// - `DoublePrecision` + /// - `DoublePrecisionUnsigned` + Float, + /// BOOLEAN / BOOL. + /// + /// Supports: + /// - `Bool` + /// - `Boolean` + Boolean, + /// Any character/text type (CHAR, VARCHAR, TEXT, CLOB, ENUM, SET, …). + /// + /// Supports: + /// - `Character` + /// - `Char` + /// - `CharacterVarying` + /// - `CharVarying` + /// - `Varchar` + /// - `Nvarchar` + /// - `CharacterLargeObject` + /// - `CharLargeObject` + /// - `Clob` + /// - `String` + /// - `FixedString` + /// - `Regclass` + /// - `Enum` + /// - `Set` + String, + /// UUID. + /// + /// Supports: + /// - `UUID` + Uuid, + /// Binary blobs (BINARY, VARBINARY, BLOB, BYTEA, BYTES, …). + /// + /// Supports: + /// - `Binary` + /// - `Varbinary` + /// - `Blob` + /// - `LongBlob` + /// - `Bytes` + /// - `Bytea` + Bytes, + /// Bit strings (BIT, BIT VARYING, VARBIT). + /// + /// Supports: + /// - `Bit` + /// - `BitVarying` + /// - `VarBit` + Bit, + /// JSON / JSONB. + /// + /// Supports: + /// - `JSON` + /// - `JSONB` + JSON, + /// DATE. + /// + /// Supports: + /// - `Date` + /// - `Date32` + Date, + /// TIME. + /// + /// Supports: + /// - `Time` + Time, + /// DATETIME / TIMESTAMP / DATETIME64. + /// + /// Supports: + /// - `Datetime` + /// - `Datetime64` + /// - `Timestamp` + /// - `TimestampNtz` + /// - `TimestampWithTz` + Datetime, + /// INET. + /// + /// Supports: + /// - `Inet` + Inet, + /// MACADDR. + /// + /// Supports: + /// - `MacAddr` + MacAddress, + /// ARRAY of a single element type. + /// + /// Supports: + /// - `Array` + Array(Box), + /// Unspecified serializer. + /// + /// This is also useful when user doesn't specify the serializer, + /// and also we couldn't detect the type from datatype. + Unspecified, + /// Custom serializer + Custom(pyo3::Py), +} + +impl DataTypeSerializer { + #[inline] + pub fn infer_from_name(value: &super::name::DataTypeName) -> Self { + use super::name::DataTypeGroup; + + match value.group() { + DataTypeGroup::STRING => Self::String, + DataTypeGroup::UUID => Self::Uuid, + DataTypeGroup::BYTES => Self::Bytes, + DataTypeGroup::BIT => Self::Bit, + DataTypeGroup::INTEGER => Self::Integer, + DataTypeGroup::FLOAT => Self::Float, + DataTypeGroup::BOOLEAN => Self::Boolean, + DataTypeGroup::DATE => Self::Date, + DataTypeGroup::TIME => Self::Time, + DataTypeGroup::DATETIME => Self::Datetime, + DataTypeGroup::JSON | DataTypeGroup::JSONB => Self::JSON, + DataTypeGroup::INET => Self::Inet, + DataTypeGroup::MACADDRESS => Self::MacAddress, + DataTypeGroup::CUSTOM => Self::Unspecified, + } + } + + pub fn serialize<'py>( + &self, + dialect: &crate::native::dialect::SqlDialect, + object: &alias::BoundObject<'py>, + ) -> pyo3::PyResult { + unsafe { + match self { + Self::Integer => serialize_integer(object.py(), object.as_ptr()), + Self::Float => serialize_float(object.py(), object.as_ptr()), + Self::Boolean => serialize_bool(object.py(), object.as_ptr(), dialect), + Self::String => serialize_str(object.py(), object.as_ptr(), dialect), + Self::Bytes => serialize_bytes(object.py(), object.as_ptr(), dialect), + Self::Bit => serialize_bit(object.py(), object.as_ptr(), dialect), + Self::JSON => serialize_json(object.py(), object.as_ptr(), dialect), + Self::Uuid => serialize_uuid(object.py(), object.as_ptr(), dialect), + Self::Date => serialize_date(object.py(), object.as_ptr(), dialect), + Self::Time => serialize_time(object.py(), object.as_ptr(), dialect), + Self::Datetime => serialize_datetime(object.py(), object.as_ptr(), dialect), + Self::Inet => serialize_inet(object.py(), object.as_ptr(), dialect), + Self::MacAddress => serialize_mac_address(object.py(), object.as_ptr(), dialect), + Self::Array(element) => { + serialize_array(object.py(), object.as_ptr(), dialect, element) + } + Self::Unspecified => py_to_string(object.py(), object.as_ptr()), + Self::Custom(s) => { + let dialect_arg = dialect.name(); + let output = s.call1(object.py(), (dialect_arg, object))?; + _serialize_string(object.py(), output.as_ptr()) + } + } + } + } +} + +/// Serializes any Python `int` as a bare decimal literal. +/// +/// Uses `str(int)` so arbitrary precision (beyond i64/u64) is preserved - one +/// serializer genuinely covers TINYINT through BIGINT and the unsigned variants. +#[inline] +unsafe fn serialize_integer( + py: pyo3::Python, + ptr: *mut pyo3::ffi::PyObject, +) -> pyo3::PyResult { + if pyo3::ffi::PyLong_CheckExact(ptr) == 1 { + // bool is a subclass of int in Python, but PyLong_CheckExact rejects + // it, so True/False never sneaks through here. + py_to_string(py, ptr) + } else { + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected int for integer serialization, got {}", + get_type_name(py, ptr) + ) + } +} + +/// Serializes Python `float`, `int`, `decimal.Decimal`, or numeric `str` as a +/// bare numeric literal. +/// +/// `Decimal`/`int` go through their exact text form (no precision loss); +/// only a real `float` is formatted from the f64. Non-finite floats are rejected. +/// +/// Assisted-by: Claude +#[inline] +unsafe fn serialize_float( + py: pyo3::Python, + ptr: *mut pyo3::ffi::PyObject, +) -> pyo3::PyResult { + if pyo3::ffi::PyFloat_CheckExact(ptr) == 1 { + let v = pyo3::ffi::PyFloat_AsDouble(ptr); + if !v.is_finite() { + return rapidquery_macros::pyerr!( + Err(PyValueError), + "cannot serialize non-finite float {} as a numeric literal", + v + ); + } + + // {:?} on f64 round-trips and avoids the "1" vs "1.0" ambiguity. + return Ok(format!("{v:?}")); + } + + // int / Decimal: their textual form is already a valid literal. + if pyo3::ffi::PyLong_CheckExact(ptr) == 1 + || crate::typeref::check_exact(ptr, crate::typeref::STD_DECIMAL_TYPE) + { + let text = py_to_string(py, ptr)?; + // Reject obvious garbage from str inputs cheaply: a literal must + // start with a digit, sign, or dot. + if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 + && !text + .trim() + .chars() + .next() + .is_some_and(|c| c.is_ascii_digit() || c == '-' || c == '+' || c == '.') + { + return rapidquery_macros::pyerr!( + Err(PyValueError), + "string {:?} is not a valid numeric literal", + text + ); + } + return Ok(text.trim().to_string()); + } + + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected float/int/Decimal/str for numeric serialization, got {}", + get_type_name(py, ptr) + ) +} + +#[inline] +unsafe fn serialize_bool( + py: pyo3::Python, + ptr: *mut pyo3::ffi::PyObject, + dialect: &crate::native::dialect::SqlDialect, +) -> pyo3::PyResult { + if pyo3::ffi::PyBool_Check(ptr) == 1 { + let value = pyo3::ffi::Py_True() == ptr; + Ok(dialect.format_boolean_as_string(value)) + } else { + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected bool for boolean serialization, got {}", + get_type_name(py, ptr) + ) + } +} + +/// Serializes a Python `str` (or `enum.Enum` whose value is a str) as a quoted, +/// escaped string literal: `"LIKE"` -> `'LIKE'`. +#[inline] +unsafe fn serialize_str( + py: pyo3::Python, + ptr: *mut pyo3::ffi::PyObject, + dialect: &crate::native::dialect::SqlDialect, +) -> pyo3::PyResult { + const ENUM_VALUE_NAME: &std::ffi::CStr = pyo3::ffi::c_str!("value"); + + if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { + let raw = _serialize_string(py, ptr)?; + let raw = dialect.escape_string_as_string(&raw); + return Ok(dialect.quote_string_as_string(&raw)); + } + + // Accept enum members whose `.value` is a string. + if (*pyo3::ffi::Py_TYPE(ptr)).ob_base.ob_base.ob_type == crate::typeref::STD_ENUM_TYPE { + let value = pyo3::ffi::PyObject_GetAttrString(ptr, ENUM_VALUE_NAME.as_ptr()); + if value.is_null() { + return Err(pyo3::PyErr::fetch(py)); + } + + let is_str = pyo3::ffi::PyUnicode_CheckExact(value) == 1; + let raw = if is_str { + _serialize_string(py, value) + } else { + Ok(String::new()) + }; + + pyo3::ffi::Py_DECREF(value); + if is_str { + let raw = dialect.escape_string_as_string(&raw?); + return Ok(dialect.quote_string_as_string(&raw)); + } + } + + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected str/Enum for string serialization, got {}", + get_type_name(py, ptr) + ) +} + +/// Serializes a `uuid.UUID` (or 36-char `str`) as a quoted literal. +#[inline] +unsafe fn serialize_uuid( + py: pyo3::Python, + ptr: *mut pyo3::ffi::PyObject, + dialect: &crate::native::dialect::SqlDialect, +) -> pyo3::PyResult { + if crate::typeref::check_exact(ptr, crate::typeref::STD_UUID_TYPE) + || pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 + { + let text = py_to_string(py, ptr)?; + let text = dialect.escape_string_as_string(&text); + Ok(dialect.quote_string_as_string(&text)) + } else { + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected uuid.UUID/str for UUID serialization, got {}", + get_type_name(py, ptr) + ) + } +} + +#[inline] +unsafe fn serialize_bytes( + py: pyo3::Python, + ptr: *mut pyo3::ffi::PyObject, + dialect: &crate::native::dialect::SqlDialect, +) -> pyo3::PyResult { + if pyo3::ffi::PyBytes_CheckExact(ptr) == 1 { + Ok(dialect.encode_bytes_as_string(_read_bytes(ptr))) + } else { + Err(rapidquery_macros::pyerr!( + PyTypeError, + "expected bytes for binary serialization, got {}", + get_type_name(py, ptr) + )) + } +} + +/// Serializes a bit string given as a `str` of `0`/`1` (e.g. `"1010"`) into a +/// `B'1010'` literal. +#[inline] +unsafe fn serialize_bit( + py: pyo3::Python, + ptr: *mut pyo3::ffi::PyObject, + dialect: &crate::native::dialect::SqlDialect, +) -> pyo3::PyResult { + if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { + let raw = _serialize_string(py, ptr)?; + + if raw.is_empty() || !raw.bytes().all(|b| b == b'0' || b == b'1') { + return rapidquery_macros::pyerr!( + Err(PyValueError), + "expected a string of '0'/'1' for bit serialization, got {:?}", + raw + ); + } + Ok(dialect.encode_bits_as_string(&raw)) + } else { + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected str of bits for bit serialization, got {}", + get_type_name(py, ptr) + ) + } +} + +/// Serializes any JSON-encodable Python object via `json.dumps`, then wraps the +/// result as a quoted string literal. +#[inline] +unsafe fn serialize_json( + py: pyo3::Python, + ptr: *mut pyo3::ffi::PyObject, + dialect: &crate::native::dialect::SqlDialect, +) -> pyo3::PyResult { + let dumped = _serialize_object_with_pyjson(py, ptr)?; + let text = _serialize_string(py, dumped); + pyo3::ffi::Py_DECREF(dumped); + + let text = dialect.escape_string_as_string(&text?); + Ok(dialect.quote_string_as_string(&text)) +} + +/// `datetime.date` -> `'YYYY-MM-DD'`. +#[inline] +unsafe fn serialize_date( + py: pyo3::Python, + ptr: *mut pyo3::ffi::PyObject, + dialect: &crate::native::dialect::SqlDialect, +) -> pyo3::PyResult { + if crate::typeref::check_exact(ptr, crate::typeref::STD_DATE_TYPE) { + let val: pyo3::Bound<'_, pyo3::types::PyDate> = + pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; + + let date: chrono::NaiveDate = val.extract()?; + let result = date.format("%Y-%m-%d").to_string(); + + Ok(dialect.quote_string_as_string(&result)) + } else { + Err(rapidquery_macros::pyerr!( + PyTypeError, + "expected datetime.date for date serialization, got {}", + get_type_name(py, ptr) + )) + } +} + +/// `datetime.time` -> `'HH:MM:SS[.ffffff]'`. +#[inline] +unsafe fn serialize_time( + py: pyo3::Python, + ptr: *mut pyo3::ffi::PyObject, + dialect: &crate::native::dialect::SqlDialect, +) -> pyo3::PyResult { + if crate::typeref::check_exact(ptr, crate::typeref::STD_TIME_TYPE) { + let val: pyo3::Bound<'_, pyo3::types::PyTime> = + pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; + + let date: chrono::NaiveTime = val.extract()?; + let result = date.format("%H:%M:%S%.f").to_string(); + + Ok(dialect.quote_string_as_string(&result)) + } else { + Err(rapidquery_macros::pyerr!( + PyTypeError, + "expected datetime.time for time serialization, got {}", + get_type_name(py, ptr) + )) + } +} + +/// `datetime.datetime` -> `'YYYY-MM-DD HH:MM:SS[.ffffff][+HH:MM]'`. +/// +/// `int`/`float` are treated as a UTC epoch and accepted too. +#[inline] +unsafe fn serialize_datetime( + py: pyo3::Python, + ptr: *mut pyo3::ffi::PyObject, + dialect: &crate::native::dialect::SqlDialect, +) -> pyo3::PyResult { + use chrono::TimeZone; + use pyo3::types::PyTzInfoAccess; + + if crate::typeref::check_exact(ptr, crate::typeref::STD_DATETIME_TYPE) { + // isoformat(sep=' ') -> the SQL-friendly space-separated form. + let val: pyo3::Bound<'_, pyo3::types::PyDateTime> = + pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; + + let tzinfo = val.get_tzinfo(); + + if tzinfo.is_none() { + let datetime: chrono::NaiveDateTime = val.extract()?; + let result = datetime.format("%Y-%m-%d %H:%M:%S%.f").to_string(); + + return Ok(dialect.quote_string_as_string(&result)); + } else { + let datetime: chrono::DateTime = val.extract()?; + let result = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").to_string(); + + return Ok(dialect.quote_string_as_string(&result)); + } + } + + if pyo3::ffi::PyLong_CheckExact(ptr) == 1 { + let mut has_overflow: i32 = 0; + let num = pyo3::ffi::PyLong_AsLongLongAndOverflow(ptr, &mut has_overflow); + + if has_overflow == 1 { + return Err(pyo3::exceptions::PyOverflowError::new_err("out of range")); + } + + if let chrono::offset::LocalResult::Single(datetime) = chrono::Utc.timestamp_opt(num, 0) { + let result = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").to_string(); + return Ok(dialect.quote_string_as_string(&result)); + } + + return Err(rapidquery_macros::pyerr!( + PyOverflowError, + "timestamp is invalid for datetime serialization: {}", + num + )); + } + + if pyo3::ffi::PyFloat_CheckExact(ptr) == 1 { + let num = pyo3::ffi::PyFloat_AsDouble(ptr); + + if let chrono::offset::LocalResult::Single(datetime) = + chrono::Utc.timestamp_opt(num.trunc() as i64, (num.fract() * 1_000_000_000.0) as u32) + { + let result = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").to_string(); + return Ok(dialect.quote_string_as_string(&result)); + } + + return Err(rapidquery_macros::pyerr!( + PyOverflowError, + "timestamp is invalid for datetime serialization: {}", + num + )); + } + + Err(rapidquery_macros::pyerr!( + PyTypeError, + "expected datetime.datetime/int/float for datetime serialization, got {}", + get_type_name(py, ptr) + )) +} + +/// `str` (or `ipaddress.*`) -> quoted literal. Validated as IPv4/IPv6 network. +#[inline] +unsafe fn serialize_inet( + py: pyo3::Python, + ptr: *mut pyo3::ffi::PyObject, + dialect: &crate::native::dialect::SqlDialect, +) -> pyo3::PyResult { + use std::str::FromStr; + + let text = py_to_string(py, ptr)?; + let net = ipnetwork::IpNetwork::from_str(&text) + .map(|net| net.to_string()) + .map_err(|e| rapidquery_macros::pyerr!(PyValueError, e.to_string()))?; + + Ok(dialect.quote_string_as_string(&net)) +} + +/// `str` -> quoted literal. Validated as a MAC address. +#[inline] +unsafe fn serialize_mac_address( + py: pyo3::Python, + ptr: *mut pyo3::ffi::PyObject, + dialect: &crate::native::dialect::SqlDialect, +) -> pyo3::PyResult { + use std::str::FromStr; + let text = py_to_string(py, ptr)?; + + let addr = mac_address::MacAddress::from_str(&text) + .map(|mac| mac.to_string()) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + + Ok(dialect.quote_string_as_string(&addr)) +} + +/// Serializes a Python `list`/`tuple` by serializing each element with `element` +/// and wrapping in `ARRAY[...]`. +/// +/// Assisted-by: Claude +#[inline] +unsafe fn serialize_array( + py: pyo3::Python, + ptr: *mut pyo3::ffi::PyObject, + dialect: &crate::native::dialect::SqlDialect, + element: &DataTypeSerializer, +) -> pyo3::PyResult { + let (len, getter): ( + isize, + unsafe fn(*mut pyo3::ffi::PyObject, isize) -> *mut pyo3::ffi::PyObject, + ) = if pyo3::ffi::PyList_CheckExact(ptr) == 1 { + (pyo3::ffi::PyList_Size(ptr), |p, i| { + pyo3::ffi::PyList_GetItem(p, i) + }) + } else if pyo3::ffi::PyTuple_CheckExact(ptr) == 1 { + (pyo3::ffi::PyTuple_Size(ptr), |p, i| { + pyo3::ffi::PyTuple_GetItem(p, i) + }) + } else { + return rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected list/tuple for array serialization, got {}", + get_type_name(py, ptr) + ); + }; + + let mut parts: Vec = Vec::with_capacity(len.max(0) as usize); + for i in 0..len { + let item = getter(ptr, i); // borrowed + if item.is_null() { + return Err(pyo3::PyErr::fetch(py)); + } + + let borrowed = alias::BoundObject::from_borrowed_ptr(py, item); + parts.push(element.serialize(dialect, &borrowed)?); + } + + Ok(format!("ARRAY[{}]", parts.join(", "))) +} + +/// Converts Python strings into [`String`], in the fastest way. +#[inline] +unsafe fn _serialize_string( + py: pyo3::Python, + object: *mut pyo3::ffi::PyObject, +) -> pyo3::PyResult { + let mut size: pyo3::ffi::Py_ssize_t = 0; + let c_str = pyo3::ffi::PyUnicode_AsUTF8AndSize(object, &mut size); + + if c_str.is_null() || size < 0 { + Err(pyo3::PyErr::fetch(py)) + } else { + let val = std::ffi::CStr::from_ptr(c_str); + Ok(std::str::from_utf8_unchecked(val.to_bytes()).into()) + } +} + +/// `str(obj)` then read it as UTF-8. Used for arbitrary-precision ints, +/// `decimal.Decimal`, `uuid.UUID`, etc. where Python already has the right text. +#[inline] +unsafe fn py_to_string(py: pyo3::Python, ptr: *mut pyo3::ffi::PyObject) -> pyo3::PyResult { + let s = pyo3::ffi::PyObject_Str(ptr); + if s.is_null() { + return Err(pyo3::PyErr::fetch(py)); + } + let out = _serialize_string(py, s); + pyo3::ffi::Py_DECREF(s); + out +} + +/// Borrow the bytes of a Python `bytes` object. +#[inline] +unsafe fn _read_bytes<'a>(ptr: *mut pyo3::ffi::PyObject) -> &'a [u8] { + let buf = pyo3::ffi::PyBytes_AsString(ptr) as *const u8; + let len = pyo3::ffi::PyBytes_Size(ptr) as usize; + debug_assert!(!buf.is_null()); + std::slice::from_raw_parts(buf, len) +} + +/// Import json module only once +#[inline(always)] +pub fn _import_json_module( + py: pyo3::Python<'_>, +) -> pyo3::PyResult<&pyo3::Bound<'_, pyo3::types::PyModule>> { + static JSON_CLS: once_cell::sync::OnceCell> = + once_cell::sync::OnceCell::new(); + + let json = JSON_CLS.get_or_try_init(|| py.import("json").map(|x| x.unbind())); + json.map(|x| x.bind(py)) +} + +/// Serialize pyobject with Python `json` module +/// +/// # Safety +/// - `ptr` should be borrowed and a valid pointer +#[inline(always)] +pub unsafe fn _serialize_object_with_pyjson( + py: pyo3::Python<'_>, + ptr: *mut pyo3::ffi::PyObject, +) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { + let json = _import_json_module(py)?; + let dumps_func = json.getattr("dumps")?; + + unsafe { + let arg1 = pyo3::Bound::from_borrowed_ptr(py, ptr); + dumps_func.call1((arg1,)).map(|x| x.into_ptr()) + } +} diff --git a/src/native/mod.rs b/src/native/mod.rs index 85bed51..627692e 100644 --- a/src/native/mod.rs +++ b/src/native/mod.rs @@ -1,2 +1,3 @@ +pub mod data_type; pub mod dialect; pub mod prepare_statement; From 3c0716980c39af5556d87335f1f742804f9ceb12 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 7 Sep 2026 14:45:14 +0330 Subject: [PATCH 13/87] refactor(data_type): Refactor and optimize data types and serializers --- rapidquery/__init__.py | 3 +- rapidquery/data_type.py | 49 ++ rapidquery/data_type.pyi | 395 ++++++++++++++ src/internal/mod.rs | 1 + src/internal/modules/json.rs | 94 ++++ src/internal/modules/mod.rs | 5 + src/lib.rs | 3 + src/native/data_type/mod.rs | 13 +- src/native/data_type/name.rs | 363 +++++++------ src/native/data_type/serializers.rs | 711 ------------------------- src/native/data_type/tests.rs | 120 +++++ src/native/dialect/mod.rs | 22 + src/native/dialect/tests.rs | 23 + src/pybindings/data_type/mod.rs | 144 +++++ src/pybindings/data_type/serialize.rs | 556 +++++++++++++++++++ src/pybindings/data_type/serializer.rs | 445 ++++++++++++++++ src/pybindings/mod.rs | 5 +- src/typeref.rs | 43 +- tests/test_array_serializer.py | 102 ++++ tests/test_custom_serializer.py | 88 +++ tests/test_data_type.py | 207 +++++++ tests/test_serializers.py | 614 +++++++++++++++++++++ 22 files changed, 3138 insertions(+), 868 deletions(-) create mode 100644 rapidquery/data_type.py create mode 100644 rapidquery/data_type.pyi create mode 100644 src/internal/modules/json.rs create mode 100644 src/internal/modules/mod.rs delete mode 100644 src/native/data_type/serializers.rs create mode 100644 src/native/data_type/tests.rs create mode 100644 src/pybindings/data_type/mod.rs create mode 100644 src/pybindings/data_type/serialize.rs create mode 100644 src/pybindings/data_type/serializer.rs create mode 100644 tests/test_array_serializer.py create mode 100644 tests/test_custom_serializer.py create mode 100644 tests/test_data_type.py create mode 100644 tests/test_serializers.py diff --git a/rapidquery/__init__.py b/rapidquery/__init__.py index 7347187..4979a4d 100644 --- a/rapidquery/__init__.py +++ b/rapidquery/__init__.py @@ -1,5 +1,6 @@ """Python SQL query builder with a Rust engine.""" +from . import data_type as data_type from . import dialect as dialect -__all__ = ["dialect"] +__all__ = ["data_type", "dialect"] diff --git a/rapidquery/data_type.py b/rapidquery/data_type.py new file mode 100644 index 0000000..5a81903 --- /dev/null +++ b/rapidquery/data_type.py @@ -0,0 +1,49 @@ +"""SQL data types and value serializers. + +from rapidquery.data_type import DataType, IntegerSerializer +from rapidquery.dialect import PostgreSQL + +DataType("integer").serialize(PostgreSQL, 42) +""" + +from ._core.data_type import ( + ArraySerializer, + BitSerializer, + BooleanSerializer, + BytesSerializer, + CustomSerializer, + DataType, + DateSerializer, + DatetimeSerializer, + FloatSerializer, + InetSerializer, + IntegerSerializer, + JSONSerializer, + MacAddressSerializer, + Serializer, + StringSerializer, + TimeSerializer, + UnspecifiedSerializer, + UuidSerializer, +) + +__all__ = [ + "ArraySerializer", + "BitSerializer", + "BooleanSerializer", + "BytesSerializer", + "CustomSerializer", + "DataType", + "DateSerializer", + "DatetimeSerializer", + "FloatSerializer", + "InetSerializer", + "IntegerSerializer", + "JSONSerializer", + "MacAddressSerializer", + "Serializer", + "StringSerializer", + "TimeSerializer", + "UnspecifiedSerializer", + "UuidSerializer", +] diff --git a/rapidquery/data_type.pyi b/rapidquery/data_type.pyi new file mode 100644 index 0000000..f252611 --- /dev/null +++ b/rapidquery/data_type.pyi @@ -0,0 +1,395 @@ +from collections.abc import Callable +from typing import Any, Self, final + +from rapidquery.dialect import SqlDialect + + +class Serializer: + """ + Converts a Python object into a dialect-specific SQL literal string. + + Serializers produce a correctly escaped and formatted SQL value that can + be embedded in queries. Each concrete subclass handles a particular + category of values (integers, strings, dates, arrays, etc.). Use + `CustomSerializer` when the built-in ones are insufficient. + + This base class cannot be instantiated. Pass a dialect class or instance + to `serialize()`. Unit serializers compare equal by variant; + `ArraySerializer` by its element tree; `CustomSerializer` by function + identity. + """ + + def serialize(self, dialect: type[SqlDialect] | SqlDialect, object: Any) -> str: + """ + Renders `object` as a SQL literal for `dialect`. + + Args: + dialect: A dialect class (`PostgreSQL`) or instance (`PostgreSQL()`). + object: The Python value to convert. + + Returns: + A SQL fragment suitable for embedding in a statement. + + Raises: + TypeError: If `object` is not an accepted type for this serializer. + ValueError: If `object` is the right type but cannot be represented + (for example a non-finite float). + """ + + def __eq__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... + + +@final +class IntegerSerializer(Serializer): + """ + Serializes Python integers as bare SQL integer literals. + + Accepts Python `int` of any width (including values beyond 64-bit). + Rejects `bool`; `True` and `False` are not treated as integers. + + Raises: + TypeError: If the value is not an `int`. + """ + + def __new__(cls) -> Self: ... + + +@final +class FloatSerializer(Serializer): + """ + Serializes numeric values as bare SQL numeric literals. + + Accepts `float`, `int`, and `decimal.Decimal`. Finite floats are formatted + so that `1.0` stays distinct from `1`. `str` is not accepted. + + Raises: + TypeError: If the value is not a `float`, `int`, or `decimal.Decimal`. + ValueError: If the value is a non-finite float (`nan`, `inf`, `-inf`). + """ + + def __new__(cls) -> Self: ... + + +@final +class BooleanSerializer(Serializer): + """ + Serializes Python booleans as dialect boolean literals. + + Accepts Python `bool` only. The dialect chooses the spelling + (`TRUE`/`FALSE` or `1`/`0`). + + Raises: + TypeError: If the value is not a `bool`. + """ + + def __new__(cls) -> Self: ... + + +@final +class StringSerializer(Serializer): + """ + Serializes text as a quoted, escaped SQL string literal. + + Accepts `str`, or an `enum.Enum` member whose `.value` is a `str`. The + dialect supplies quoting and escaping. + + Raises: + TypeError: If the value is not a `str` or a string-valued enum member. + """ + + def __new__(cls) -> Self: ... + + +@final +class UuidSerializer(Serializer): + """ + Serializes UUIDs as quoted SQL string literals. + + Accepts `uuid.UUID` or `str`. + + Raises: + TypeError: If the value is not a `uuid.UUID` or `str`. + """ + + def __new__(cls) -> Self: ... + + +@final +class BytesSerializer(Serializer): + """ + Serializes binary data as a dialect blob/bytea literal. + + Accepts exact `bytes` (not `bytearray` or `memoryview`). + + Raises: + TypeError: If the value is not `bytes`. + """ + + def __new__(cls) -> Self: ... + + +@final +class BitSerializer(Serializer): + """ + Serializes bit strings as dialect bit-string literals. + + Accepts a `str` consisting only of `'0'` and `'1'` characters. + + Raises: + TypeError: If the value is not a `str`. + ValueError: If the string is empty or contains characters other than + `'0'` and `'1'`. + """ + + def __new__(cls) -> Self: ... + + +@final +class JSONSerializer(Serializer): + """ + Serializes a JSON-encodable object as a quoted SQL string literal. + + Accepts any object that the JSON encoder can dump. Uses `orjson` when it + is installed, otherwise the stdlib `json` module. The JSON text is then + wrapped with the dialect's string quoting. + + Raises: + TypeError: If the value cannot be JSON-encoded. + """ + + def __new__(cls) -> Self: ... + + +@final +class DateSerializer(Serializer): + """ + Serializes dates as quoted `YYYY-MM-DD` literals. + + Accepts exact `datetime.date` (not `datetime.datetime`). + + Raises: + TypeError: If the value is not a `datetime.date`. + """ + + def __new__(cls) -> Self: ... + + +@final +class TimeSerializer(Serializer): + """ + Serializes times as quoted `HH:MM:SS[.ffffff]` literals. + + Accepts `datetime.time`. + + Raises: + TypeError: If the value is not a `datetime.time`. + """ + + def __new__(cls) -> Self: ... + + +@final +class DatetimeSerializer(Serializer): + """ + Serializes timestamps as quoted SQL timestamp literals. + + Accepts naive or aware `datetime.datetime`, or `int`/`float` UTC epoch + seconds. Naive values keep wall time. Aware values keep their original + offset (they are not converted to UTC). + + Raises: + TypeError: If the value is not a `datetime.datetime`, `int`, or `float`. + OverflowError: If an epoch value is out of range for a timestamp. + """ + + def __new__(cls) -> Self: ... + + +@final +class InetSerializer(Serializer): + """ + Serializes IP addresses and networks as quoted SQL literals. + + Accepts `str` or `ipaddress` objects (`IPv4Address`, `IPv6Address`, + `IPv4Network`, `IPv6Network`, and related types whose `str()` is a valid + address or CIDR). + + Raises: + ValueError: If the value is not a valid IPv4/IPv6 address or network. + """ + + def __new__(cls) -> Self: ... + + +@final +class MacAddressSerializer(Serializer): + """ + Serializes MAC addresses as quoted SQL literals. + + Accepts a `str` using `:` or `-` as the separator. + + Raises: + ValueError: If the value is not a valid MAC address. + """ + + def __new__(cls) -> Self: ... + + +@final +class UnspecifiedSerializer(Serializer): + """ + Inserts `str(obj)` into SQL with no quoting or type check. + + Used when no serializer could be inferred from the type name. The result + is not a safe SQL value; prefer an explicit serializer. + """ + + def __new__(cls) -> Self: ... + + +@final +class ArraySerializer(Serializer): + """ + Serializes sequences as `ARRAY[elem, ...]` literals. + + Each element is serialized independently with `element`. Accepts `list` + or `tuple`. Nested arrays are expressed by wrapping another + `ArraySerializer`. + """ + + def __new__(cls, element: Serializer) -> Self: + """ + Creates an array serializer. + + Args: + element: The serializer applied to every array item. Nested arrays + are expressed by passing another `ArraySerializer`. + """ + + @property + def element(self) -> Serializer: + """ + Returns the serializer used for each array element. + + Returns: + The `Serializer` passed to the constructor. + """ + + +@final +class CustomSerializer(Serializer): + """ + Serializes values by calling a user-supplied Python function. + + `function` has the form `fn(dialect_name: str, obj) -> str`. The return + value is inserted into SQL as-is (not re-quoted or escaped). It is the + caller's responsibility to produce a safe fragment. + """ + + def __new__(cls, function: Callable[[str, Any], str]) -> Self: + """ + Creates a serializer backed by a Python callable. + + Args: + function: A callable of the form `fn(dialect_name: str, obj) -> str`. + The return value is inserted into SQL as-is (not re-quoted). + Must return a `str`. + """ + + @property + def function(self) -> Callable[[str, Any], str]: + """ + Returns the callable passed to the constructor. + + Returns: + The function given as `function` to `__new__`. + """ + + +@final +class DataType: + """ + Represents a SQL data type. + + A data type is a SQL name plus the serializer used to turn Python objects + into SQL literals. The name can be any string; it is not limited to a set + of predefined values. The serializer is fully customizable. + + Known names are interned and canonicalized (`"int"` becomes `"INT"`). + Unknown names and parameterized spellings (`VARCHAR(255)`) are kept as + given. If `serializer` is omitted at construction, one is inferred from + the name; unrecognized names get `UnspecifiedSerializer`. + """ + + def __new__(cls, name: str, serializer: Serializer | None = None, /) -> Self: + """ + Creates a new SQL data type. + + Args: + name: The name of a data type. *It is not limited to a set of predefined values.* + + **Important memory optimization note**: At import time, the library pre-allocates + and interns (reserves in memory) several simple and commonly used data types + (e.g. `INTEGER`, `VARCHAR`, `TIMESTAMP`, `BOOLEAN`, etc.). If you instantiate + a `DataType` using one of these common names, the library will automatically + return a reference to the pre-allocated instance instead of creating a new one. + This reduces memory consumption and improves performance when working with + standard SQL types. + + As a result of this interning mechanism, when you access the `.name` property + on a pre-allocated instance, the returned string **may be slightly different** + from the exact string you passed in (for example in casing, spacing, or canonical + form chosen by the library). This is expected behavior and does not affect + correctness. + + serializer: The `serializer` is used whenever a Python object needs to be converted + into a valid SQL value during query construction. This is commonly required + when setting default values, inserting data, or generating other SQL statements. + You can use one of the built-in serializers (we provide ready-to-use + serializers for most common data types), or you can create and register your + own custom serializer. This parameter is optional. However, it is strongly + recommended to always specify it explicitly. If you do not provide a serializer, + the `DataType` will attempt to automatically detect and assign one based on the + `name`. If no suitable serializer is found, it will fall back to `UnspecifiedSerializer`. + """ + + @property + def name(self) -> str: + """ + Returns the type name. + + Returns: + The canonical type name. + + Note: + For interned common data types (see `__new__`), the returned value + may be a canonicalized version of the original input string (for + example in casing or spacing). + """ + + @property + def serializer(self) -> Serializer: + """ + Returns the serializer used to render Python values as SQL literals. + + Returns: + The `Serializer` associated with this data type. + """ + + def serialize(self, dialect: type[SqlDialect] | SqlDialect, object: Any) -> str: + """ + Renders `object` as a SQL literal for `dialect`. + + Shorthand for `self.serializer.serialize(dialect, object)`. + + Args: + dialect: A dialect class (`PostgreSQL`) or instance (`PostgreSQL()`). + object: The Python value to convert. + + Returns: + A SQL fragment produced by this type's serializer. + """ + + def __repr__(self) -> str: ... diff --git a/src/internal/mod.rs b/src/internal/mod.rs index f552fbc..55c74b6 100644 --- a/src/internal/mod.rs +++ b/src/internal/mod.rs @@ -1,2 +1,3 @@ pub mod alias; +pub mod modules; pub mod utils; diff --git a/src/internal/modules/json.rs b/src/internal/modules/json.rs new file mode 100644 index 0000000..0284682 --- /dev/null +++ b/src/internal/modules/json.rs @@ -0,0 +1,94 @@ +//! Process-wide JSON dumps: `orjson` if importable, else stdlib `json`. + +use once_cell::sync::OnceCell; +use pyo3::types::{PyAnyMethods, PyBytesMethods, PyDict, PyModule, PyStringMethods, PyTuple}; + +enum JsonDumper { + Orjson { + dumps: pyo3::Py, + option: u32, + }, + Stdlib { + dumps: pyo3::Py, + separators: pyo3::Py, + }, +} + +static DUMPER: OnceCell = OnceCell::new(); + +fn orjson_option(module: &pyo3::Bound<'_, PyModule>) -> pyo3::PyResult { + let flag = |name: &str| module.getattr(name)?.extract::(); + + Ok(flag("OPT_PASSTHROUGH_DATETIME")? + | flag("OPT_PASSTHROUGH_UUID")? + | flag("OPT_PASSTHROUGH_DATACLASS")? + | flag("OPT_PASSTHROUGH_SUBCLASS")? + | flag("OPT_NON_STR_KEYS")?) +} + +fn init_orjson( + _py: pyo3::Python<'_>, + module: &pyo3::Bound<'_, PyModule>, +) -> pyo3::PyResult { + let dumps = module.getattr("dumps")?.unbind(); + let option = orjson_option(module)?; + Ok(JsonDumper::Orjson { dumps, option }) +} + +fn init_stdlib(py: pyo3::Python<'_>) -> pyo3::PyResult { + let module = py.import("json")?; + let dumps = module.getattr("dumps")?.unbind(); + let separators = PyTuple::new(py, [",", ":"])?.unbind(); + Ok(JsonDumper::Stdlib { dumps, separators }) +} + +#[cold] +fn init_import(py: pyo3::Python<'_>) -> pyo3::PyResult { + if let Ok(module) = py.import("orjson") + && let Ok(dumper) = init_orjson(py, &module) + { + return Ok(dumper); + } + init_stdlib(py) +} + +fn unicode_to_string(obj: pyo3::Bound<'_, pyo3::PyAny>) -> pyo3::PyResult { + let s = obj.cast::()?; + Ok(s.to_str()?.to_owned()) +} + +fn dumps_result_to_string(obj: pyo3::Bound<'_, pyo3::PyAny>) -> pyo3::PyResult { + if let Ok(bytes) = obj.cast::() { + let raw = bytes.as_bytes(); + return std::str::from_utf8(raw) + .map(str::to_owned) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())); + } + unicode_to_string(obj) +} + +/// JSON-encode `ptr` via cached `orjson.dumps` or `json.dumps`. +/// +/// # Safety +/// `ptr` must be a valid borrowed `PyObject*`. The GIL must be held. +#[inline] +pub unsafe fn dumps(obj: pyo3::Bound<'_, pyo3::PyAny>) -> pyo3::PyResult { + let py = obj.py(); + let dumper = DUMPER.get_or_try_init(|| init_import(py))?; + + match dumper { + JsonDumper::Orjson { dumps, option } => { + // orjson.dumps(__obj, default=None, option=int) + let out = dumps.bind(py).call1((&obj, py.None(), option))?; + dumps_result_to_string(out) + } + JsonDumper::Stdlib { dumps, separators } => { + let kwargs = PyDict::new(py); + kwargs.set_item("ensure_ascii", false)?; + kwargs.set_item("allow_nan", false)?; + kwargs.set_item("separators", separators.bind(py))?; + let out = dumps.bind(py).call((&obj,), Some(&kwargs))?; + unicode_to_string(out) + } + } +} diff --git a/src/internal/modules/mod.rs b/src/internal/modules/mod.rs new file mode 100644 index 0000000..b25218c --- /dev/null +++ b/src/internal/modules/mod.rs @@ -0,0 +1,5 @@ +//! Cached Python module imports. + +mod json; + +pub use json::dumps as json_dumps; diff --git a/src/lib.rs b/src/lib.rs index 84327be..9da8b0b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,9 @@ pub mod typeref; /// RapidQuery native extension. #[pyo3::pymodule(gil_used = false)] mod _core { + #[pymodule_export] + use super::pybindings::data_type_mod; + #[pymodule_export] use super::pybindings::dialect_mod; diff --git a/src/native/data_type/mod.rs b/src/native/data_type/mod.rs index d4665e6..ba4eb4b 100644 --- a/src/native/data_type/mod.rs +++ b/src/native/data_type/mod.rs @@ -1,5 +1,14 @@ +//! SQL data-type names. +//! +//! A [`DataTypeName`] is a canonical spelling plus a [`DataTypeGroup`]. The +//! group is the inference key for which Python serializer `DataType(name)` +//! constructs when none is passed. Groups are serializer-oriented: `NUMERIC` +//! and `DECIMAL` sit in [`DataTypeGroup::Float`] because they share a +//! serializer with IEEE floats. + mod name; -mod serializers; + +#[cfg(test)] +mod tests; pub use name::{DataTypeGroup, DataTypeName}; -pub use serializers::DataTypeSerializer; diff --git a/src/native/data_type/name.rs b/src/native/data_type/name.rs index 0dc8b26..dc3dd98 100644 --- a/src/native/data_type/name.rs +++ b/src/native/data_type/name.rs @@ -1,203 +1,262 @@ use crate::native::prepare_statement::PrepareStatement; -/// Specifies data type groups +/// Category used to infer a built-in serializer from a type name. +/// +/// Distinct SQL spellings collapse onto one serializer (every integer width +/// is [`InferredSerializer::Integer`]). [`Json`] and [`Jsonb`] stay distinct +/// as groups so a later layer can treat storage differently; both currently +/// infer [`InferredSerializer::Json`]. #[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord, Hash)] pub enum DataTypeGroup { - STRING, - UUID, - BYTES, - BIT, - INTEGER, - FLOAT, - BOOLEAN, - DATE, - TIME, - DATETIME, - JSON, - JSONB, - INET, - MACADDRESS, - CUSTOM, + String, + Uuid, + Bytes, + Bit, + Integer, + Float, + Boolean, + Date, + Time, + Datetime, + Json, + Jsonb, + Inet, + MacAddress, + Custom, } -/// SQL data types +/// Canonical SQL type spelling plus its [`DataTypeGroup`]. +/// +/// Known names are interned (`arcstr::literal!`). Unknown and parameterized +/// names keep the caller's spelling and are [`DataTypeGroup::Custom`]. #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] -pub struct DataTypeName(arcstr::ArcStr, DataTypeGroup); +pub struct DataTypeName { + name: arcstr::ArcStr, + group: DataTypeGroup, +} + +impl DataTypeName { + #[inline] + pub fn as_str(&self) -> &str { + &self.name + } + + #[inline] + pub fn group(&self) -> DataTypeGroup { + self.group + } +} + +/// Longest interned alias is `"timestamp without timezone"` (26 bytes). +const LOOKUP_BUF: usize = 32; -// Create all no-argument data types once. This reduces memory footprint, thanks to `arcstr::ArcStr`. +fn interned(value: &str) -> Option { + if value.len() > LOOKUP_BUF || !value.is_ascii() { + return None; + } + let mut buf = [0u8; LOOKUP_BUF]; + for (i, b) in value.bytes().enumerate() { + buf[i] = b.to_ascii_lowercase(); + } + // SAFETY: `value` is ASCII, so the lowercased copy is valid UTF-8. + let lower = unsafe { std::str::from_utf8_unchecked(&buf[..value.len()]) }; + lookup_alias(lower) +} + +fn custom_from_str(value: &str) -> DataTypeName { + DataTypeName { + name: arcstr::ArcStr::from(value), + group: DataTypeGroup::Custom, + } +} + +fn custom_from_string(value: String) -> DataTypeName { + DataTypeName { + name: arcstr::ArcStr::from(value), + group: DataTypeGroup::Custom, + } +} + +fn is_parameterized(value: &str) -> bool { + value.as_bytes().contains(&b'(') +} + +// Interned no-arg types. `arcstr::literal!` shares one allocation per spelling. macro_rules! simple_data_types { ( $( $(#[$docs:meta])* - ($upcase:ident => ($realname:literal, $group:ident), $possible_name:literal, $($possible_names:literal),*); + ($upcase:ident => ($realname:literal, $group:ident), $($possible_names:literal)|+); )+ ) => { $( $(#[$docs])* - pub(super) const $upcase: DataTypeName = DataTypeName(arcstr::literal!($realname), DataTypeGroup::$group); + pub(super) const $upcase: DataTypeName = DataTypeName { + name: arcstr::literal!($realname), + group: DataTypeGroup::$group, + }; )+ - impl From<&str> for DataTypeName { - fn from(value: &str) -> Self { - if value.find('(').is_some() { - // For data types that has gotten parameters, we cannot use constant variables - DataTypeName(arcstr::ArcStr::from(value), DataTypeGroup::CUSTOM) - } else { - let lowercase = value.to_ascii_lowercase(); - match lowercase.as_str() { - $( - $possible_name $(| $possible_names)* => $upcase, - )+ - _ => DataTypeName(arcstr::ArcStr::from(value), DataTypeGroup::CUSTOM), - } - } + fn lookup_alias(lowercase: &str) -> Option { + match lowercase { + $( + $($possible_names)|+ => Some($upcase), + )+ + _ => None, } } }; } simple_data_types! { - // --- STRING group --- - (CHARACTER_DATA_TYPE => ("CHARACTER", STRING), "character",); - (CHAR_DATA_TYPE => ("CHAR", STRING), "char",); - (CHARACTER_VARYING_DATA_TYPE => ("CHARACTER VARYING", STRING), "characater varying",); - (CHAR_VARYING_DATA_TYPE => ("CHAR VARYING", STRING), "char varying",); - (VARCHAR_DATA_TYPE => ("VARCHAR", STRING), "varchar",); - (NVARCHAR_DATA_TYPE => ("NVARCHAR", STRING), "nvarchar",); - (CHARACTER_LARGE_OBJECT_DATA_TYPE => ("CHARACTER LARGE OBJECT", STRING), "character large object",); - (CHAR_LARGE_OBJECT_DATA_TYPE => ("CHAR LARGE OBJECT", STRING), "char large object",); - (CLOB_DATA_TYPE => ("CLOB", STRING), "clob",); - (REGCLASS_DATA_TYPE => ("REGCLASS", STRING), "regclass",); - (TEXT_DATA_TYPE => ("TEXT", STRING), "text",); - (TINYTEXT_DATA_TYPE => ("TINYTEXT", STRING), "tinytext",); - (MEDIUMTEXT_DATA_TYPE => ("MEDIUMTEXT", STRING), "mediumtext",); - (LONGTEXT_DATA_TYPE => ("LONGTEXT", STRING), "longtext",); - (STRING_DATA_TYPE => ("STRING", STRING), "string",); - - // --- UUID group --- - (UUID_DATA_TYPE => ("UUID", UUID), "uuid",); - - // --- BINARY group --- - (BINARY_DATA_TYPE => ("BINARY", BYTES), "binary",); - (VARBINARY_DATA_TYPE => ("VARBINARY", BYTES), "varbinary",); - (BLOB_DATA_TYPE => ("BLOB", BYTES), "blob",); - (TINYBLOB_DATA_TYPE => ("TINYBLOB", BYTES), "tinyblob",); - (MEDIUMBLOB_DATA_TYPE => ("MEDIUMBLOB", BYTES), "mediumblob",); - (LONGBLOB_DATA_TYPE => ("LONGBLOB", BYTES), "longblob",); - (BYTES_DATA_TYPE => ("BYTES", BYTES), "bytes",); - (BYTEA_DATA_TYPE => ("BYTEA", BYTES), "bytea",); - - // --- BIT group --- - (BIT_DATA_TYPE => ("BIT", BIT), "bit",); - (BIT_VARYING_DATA_TYPE => ("BIT VARYING", BIT), "bit varying",); - (VARBIT_DATA_TYPE => ("VARBIT", BIT), "varbit",); - - // --- FLOAT group --- - (NUMERIC_DATA_TYPE => ("NUMERIC", FLOAT), "numeric",); - (DECIMAL_DATA_TYPE => ("DECIMAL", FLOAT), "decimal",); - (BIGNUMERIC_DATA_TYPE => ("BIGNUMERIC", FLOAT), "bignumeric",); - (BIGDECIMAL_DATA_TYPE => ("BIGDECIMAL", FLOAT), "bigdecimal",); - (DEC_DATA_TYPE => ("DEC", FLOAT), "dec",); - (FLOAT_DATA_TYPE => ("FLOAT", FLOAT), "float",); - (FLOAT4_DATA_TYPE => ("FLOAT4", FLOAT), "float4",); - (FLOAT8_DATA_TYPE => ("FLOAT8", FLOAT), "float8",); - (FLOAT64_DATA_TYPE => ("FLOAT64", FLOAT), "float64",); - (REAL_DATA_TYPE => ("REAL", FLOAT), "real",); - (REAL_UNSIGNED_DATA_TYPE => ("REAL UNSIGNED", FLOAT), "real unsigned",); - (DOUBLE_DATA_TYPE => ("DOUBLE", FLOAT), "double",); - (DOUBLE_UNSIGNED_DATA_TYPE => ("DOUBLE UNSIGNED", FLOAT), "double unsigned",); - (DOUBLE_PRECISION_DATA_TYPE => ("DOUBLE PRECISION", FLOAT), "double precision",); - (DOUBLE_PRECISION_UNSIGNED_DATA_TYPE => ("DOUBLE PRECISION UNSIGNED", FLOAT), "double precision unsigned",); - - // --- INTEGER --- - (TINYINT_DATA_TYPE => ("TINYINT", INTEGER), "tinyint",); - (TINYINT_UNSIGNED_DATA_TYPE => ("TINYINT UNSIGNED", INTEGER), "tinyint unsigned", "utinyint"); - (INT2_DATA_TYPE => ("INT2", INTEGER), "int2",); - (INT2_UNSIGNED_DATA_TYPE => ("INT2 UNSIGNED", INTEGER), "int2 unsigned",); - (SMALLINT_DATA_TYPE => ("SMALLINT", INTEGER), "smallint",); - (SMALLINT_UNSIGNED_DATA_TYPE => ("SMALLINT UNSIGNED", INTEGER), "smallint unsigned", "usmallint"); - (MEDIUMINT_DATA_TYPE => ("MEDIUMINT", INTEGER), "mediumint",); - (MEDIUMINT_UNSIGNED_DATA_TYPE => ("MEDIUMINT UNSIGNED", INTEGER), "mediumint unsigned",); - (INT_DATA_TYPE => ("INT", INTEGER), "int",); - (INT4_DATA_TYPE => ("INT4", INTEGER), "int4",); - (INT8_DATA_TYPE => ("INT8", INTEGER), "int8",); - (INT64_DATA_TYPE => ("INT64", INTEGER), "int64",); - (INTEGER_DATA_TYPE => ("INTEGER", INTEGER), "integer",); - (UNSIGNED_INTEGER_DATA_TYPE => ("UNSIGNED INTEGER", INTEGER), "unsigned integer", "integer unsigned", "int unsigned"); - (INT4_UNSIGNED_DATA_TYPE => ("INT4 UNSIGNED", INTEGER), "int4 unsigned",); - (HUGEINT_DATA_TYPE => ("HUGEINT", INTEGER), "hugeint",); - (UHUGEINT_DATA_TYPE => ("UHUGEINT", INTEGER), "uhugeint",); - (BIGINT_DATA_TYPE => ("BIGINT", INTEGER), "bigint",); - (BIGINT_UNSIGNED_DATA_TYPE => ("BIGINT UNSIGNED", INTEGER), "bigint unsigned", "unsigned bitint", "ubigint"); - (INT8_UNSIGNED_DATA_TYPE => ("INT8 UNSIGNED", INTEGER), "int8 unsigned",); - (SIGNED_DATA_TYPE => ("SIGNED", INTEGER), "signed",); - (SIGNED_INTEGER_DATA_TYPE => ("SIGNED INTEGER", INTEGER), "signed integer",); - (UNSIGNED_DATA_TYPE => ("UNSIGNED", INTEGER), "unsigned",); - - // --- BOOLEAN group --- - (BOOLEAN_DATA_TYPE => ("BOOLEAN", BOOLEAN), "boolean", "bool"); - - // --- DATE group --- - (DATE_DATA_TYPE => ("DATE", DATE), "date",); - - // --- TIME group --- - (TIME_DATA_TYPE => ("TIME", TIME), "time", "time without timezone"); - (TIME_WITH_TIMEZONE_DATA_TYPE => ("TIME WITH TIMEZONE", TIME), "time with timezone",); - - // --- DATETIME group --- - (DATETIME_DATA_TYPE => ("DATETIME", DATETIME), "datetime",); - (TIMESTAMP_DATA_TYPE => ("TIMESTAMP", DATETIME), "timestamp", "timestamp without timezone"); - (TIMESTAMP_WITH_TIMEZONE_DATA_TYPE => ("TIMESTAMP WITH TIMEZONE", DATETIME), "timestamp with timezone", "timestamptz"); - (TIMESTAMPNTZ_DATA_TYPE => ("TIMESTAMPNTZ", DATETIME), "timestampntz",); - - // --- JSON group --- - (JSON_DATA_TYPE => ("JSON", JSON), "json",); - (JSONB_DATA_TYPE => ("JSONB", JSONB), "jsonb",); - - // --- INET group --- - (INET_DATA_TYPE => ("INET", INET), "inet",); - - // --- MACADDR group --- - (MACADDRESS_DATA_TYPE => ("MACADDRESS", MACADDRESS), "macaddress", "macaddr"); - - // --- CUSTOM group --- - (ANY_DATA_TYPE => ("ANY", CUSTOM), "any",); - (TRIGGER_DATA_TYPE => ("TRIGGER", CUSTOM), "trigger",); - (UNSPECIFIED_DATA_TYPE => ("UNSPECIFIED", CUSTOM), "unspecified",); + // --- String --- + (CHARACTER_DATA_TYPE => ("CHARACTER", String), "character"); + (CHAR_DATA_TYPE => ("CHAR", String), "char"); + (CHARACTER_VARYING_DATA_TYPE => ("CHARACTER VARYING", String), "character varying" | "characater varying"); + (CHAR_VARYING_DATA_TYPE => ("CHAR VARYING", String), "char varying"); + (VARCHAR_DATA_TYPE => ("VARCHAR", String), "varchar"); + (NVARCHAR_DATA_TYPE => ("NVARCHAR", String), "nvarchar"); + (CHARACTER_LARGE_OBJECT_DATA_TYPE => ("CHARACTER LARGE OBJECT", String), "character large object"); + (CHAR_LARGE_OBJECT_DATA_TYPE => ("CHAR LARGE OBJECT", String), "char large object"); + (CLOB_DATA_TYPE => ("CLOB", String), "clob"); + (REGCLASS_DATA_TYPE => ("REGCLASS", String), "regclass"); + (TEXT_DATA_TYPE => ("TEXT", String), "text"); + (TINYTEXT_DATA_TYPE => ("TINYTEXT", String), "tinytext"); + (MEDIUMTEXT_DATA_TYPE => ("MEDIUMTEXT", String), "mediumtext"); + (LONGTEXT_DATA_TYPE => ("LONGTEXT", String), "longtext"); + (STRING_DATA_TYPE => ("STRING", String), "string"); + + // --- Uuid --- + (UUID_DATA_TYPE => ("UUID", Uuid), "uuid"); + + // --- Bytes --- + (BINARY_DATA_TYPE => ("BINARY", Bytes), "binary"); + (VARBINARY_DATA_TYPE => ("VARBINARY", Bytes), "varbinary"); + (BLOB_DATA_TYPE => ("BLOB", Bytes), "blob"); + (TINYBLOB_DATA_TYPE => ("TINYBLOB", Bytes), "tinyblob"); + (MEDIUMBLOB_DATA_TYPE => ("MEDIUMBLOB", Bytes), "mediumblob"); + (LONGBLOB_DATA_TYPE => ("LONGBLOB", Bytes), "longblob"); + (BYTES_DATA_TYPE => ("BYTES", Bytes), "bytes"); + (BYTEA_DATA_TYPE => ("BYTEA", Bytes), "bytea"); + + // --- Bit --- + (BIT_DATA_TYPE => ("BIT", Bit), "bit"); + (BIT_VARYING_DATA_TYPE => ("BIT VARYING", Bit), "bit varying"); + (VARBIT_DATA_TYPE => ("VARBIT", Bit), "varbit"); + + // --- Float (includes decimal/numeric) --- + (NUMERIC_DATA_TYPE => ("NUMERIC", Float), "numeric"); + (DECIMAL_DATA_TYPE => ("DECIMAL", Float), "decimal"); + (BIGNUMERIC_DATA_TYPE => ("BIGNUMERIC", Float), "bignumeric"); + (BIGDECIMAL_DATA_TYPE => ("BIGDECIMAL", Float), "bigdecimal"); + (DEC_DATA_TYPE => ("DEC", Float), "dec"); + (FLOAT_DATA_TYPE => ("FLOAT", Float), "float"); + (FLOAT4_DATA_TYPE => ("FLOAT4", Float), "float4"); + (FLOAT8_DATA_TYPE => ("FLOAT8", Float), "float8"); + (FLOAT64_DATA_TYPE => ("FLOAT64", Float), "float64"); + (REAL_DATA_TYPE => ("REAL", Float), "real"); + (REAL_UNSIGNED_DATA_TYPE => ("REAL UNSIGNED", Float), "real unsigned"); + (DOUBLE_DATA_TYPE => ("DOUBLE", Float), "double"); + (DOUBLE_UNSIGNED_DATA_TYPE => ("DOUBLE UNSIGNED", Float), "double unsigned"); + (DOUBLE_PRECISION_DATA_TYPE => ("DOUBLE PRECISION", Float), "double precision"); + (DOUBLE_PRECISION_UNSIGNED_DATA_TYPE => ("DOUBLE PRECISION UNSIGNED", Float), "double precision unsigned"); + + // --- Integer --- + (TINYINT_DATA_TYPE => ("TINYINT", Integer), "tinyint"); + (TINYINT_UNSIGNED_DATA_TYPE => ("TINYINT UNSIGNED", Integer), "tinyint unsigned" | "utinyint"); + (INT2_DATA_TYPE => ("INT2", Integer), "int2"); + (INT2_UNSIGNED_DATA_TYPE => ("INT2 UNSIGNED", Integer), "int2 unsigned"); + (SMALLINT_DATA_TYPE => ("SMALLINT", Integer), "smallint"); + (SMALLINT_UNSIGNED_DATA_TYPE => ("SMALLINT UNSIGNED", Integer), "smallint unsigned" | "usmallint"); + (MEDIUMINT_DATA_TYPE => ("MEDIUMINT", Integer), "mediumint"); + (MEDIUMINT_UNSIGNED_DATA_TYPE => ("MEDIUMINT UNSIGNED", Integer), "mediumint unsigned"); + (INT_DATA_TYPE => ("INT", Integer), "int"); + (INT4_DATA_TYPE => ("INT4", Integer), "int4"); + (INT8_DATA_TYPE => ("INT8", Integer), "int8"); + (INT64_DATA_TYPE => ("INT64", Integer), "int64"); + (INTEGER_DATA_TYPE => ("INTEGER", Integer), "integer"); + (UNSIGNED_INTEGER_DATA_TYPE => ("UNSIGNED INTEGER", Integer), "unsigned integer" | "integer unsigned" | "int unsigned"); + (INT4_UNSIGNED_DATA_TYPE => ("INT4 UNSIGNED", Integer), "int4 unsigned"); + (HUGEINT_DATA_TYPE => ("HUGEINT", Integer), "hugeint"); + (UHUGEINT_DATA_TYPE => ("UHUGEINT", Integer), "uhugeint"); + (BIGINT_DATA_TYPE => ("BIGINT", Integer), "bigint"); + (BIGINT_UNSIGNED_DATA_TYPE => ("BIGINT UNSIGNED", Integer), "bigint unsigned" | "unsigned bigint" | "unsigned bitint" | "ubigint"); + (INT8_UNSIGNED_DATA_TYPE => ("INT8 UNSIGNED", Integer), "int8 unsigned"); + (SIGNED_DATA_TYPE => ("SIGNED", Integer), "signed"); + (SIGNED_INTEGER_DATA_TYPE => ("SIGNED INTEGER", Integer), "signed integer"); + (UNSIGNED_DATA_TYPE => ("UNSIGNED", Integer), "unsigned"); + + // --- Boolean --- + (BOOLEAN_DATA_TYPE => ("BOOLEAN", Boolean), "boolean" | "bool"); + + // --- Date --- + (DATE_DATA_TYPE => ("DATE", Date), "date"); + + // --- Time --- + (TIME_DATA_TYPE => ("TIME", Time), "time" | "time without timezone"); + (TIME_WITH_TIMEZONE_DATA_TYPE => ("TIME WITH TIMEZONE", Time), "time with timezone"); + + // --- Datetime --- + (DATETIME_DATA_TYPE => ("DATETIME", Datetime), "datetime"); + (TIMESTAMP_DATA_TYPE => ("TIMESTAMP", Datetime), "timestamp" | "timestamp without timezone"); + (TIMESTAMP_WITH_TIMEZONE_DATA_TYPE => ("TIMESTAMP WITH TIMEZONE", Datetime), "timestamp with timezone" | "timestamptz"); + (TIMESTAMPNTZ_DATA_TYPE => ("TIMESTAMPNTZ", Datetime), "timestampntz"); + + // --- Json --- + (JSON_DATA_TYPE => ("JSON", Json), "json"); + (JSONB_DATA_TYPE => ("JSONB", Jsonb), "jsonb"); + + // --- Inet --- + (INET_DATA_TYPE => ("INET", Inet), "inet"); + + // --- MacAddress --- + (MACADDRESS_DATA_TYPE => ("MACADDRESS", MacAddress), "macaddress" | "macaddr"); + + // --- Custom --- + (ANY_DATA_TYPE => ("ANY", Custom), "any"); + (TRIGGER_DATA_TYPE => ("TRIGGER", Custom), "trigger"); + (UNSPECIFIED_DATA_TYPE => ("UNSPECIFIED", Custom), "unspecified"); } -impl DataTypeName { - #[inline] - pub fn group(&self) -> DataTypeGroup { - self.1 +impl From<&str> for DataTypeName { + fn from(value: &str) -> Self { + if is_parameterized(value) { + return custom_from_str(value); + } + interned(value).unwrap_or_else(|| custom_from_str(value)) } } impl From for DataTypeName { - #[inline] fn from(value: String) -> Self { - DataTypeName::from(value.as_str()) + if is_parameterized(&value) { + return custom_from_string(value); + } + if let Some(dt) = interned(&value) { + return dt; + } + custom_from_string(value) } } impl AsRef for DataTypeName { #[inline] fn as_ref(&self) -> &str { - &self.0 + self.as_str() } } impl From for String { #[inline] fn from(val: DataTypeName) -> Self { - val.0.to_string() + val.name.to_string() } } impl std::fmt::Display for DataTypeName { #[inline] fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) + self.name.fmt(f) } } @@ -207,7 +266,7 @@ impl PrepareStatement for DataTypeName { _dialect: &crate::native::dialect::SqlDialect, context: &mut crate::native::prepare_statement::PrepareContext, ) -> crate::native::prepare_statement::PrepareResult<()> { - context.push_str(&self.0); + context.push_str(self.as_str()); Ok(()) } } diff --git a/src/native/data_type/serializers.rs b/src/native/data_type/serializers.rs deleted file mode 100644 index df6a5b7..0000000 --- a/src/native/data_type/serializers.rs +++ /dev/null @@ -1,711 +0,0 @@ -use pyo3::types::PyAnyMethods; - -use crate::internal::alias; -use crate::internal::utils::get_type_name; - -/// SQL data-type value serializers. -/// -/// One variant per *distinct serialization behaviour* - many SQL data types -/// collapse onto the same serializer. -#[derive(Debug, Clone)] -pub enum DataTypeSerializer { - /// Every signed/unsigned integer width. - /// - /// Supports: - /// - `TinyInt` - /// - `TinyIntUnsigned` - /// - `UTinyInt` - /// - `Int2` - /// - `Int2Unsigned` - /// - `SmallInt` - /// - `SmallIntUnsigned` - /// - `USmallInt` - /// - `MediumInt` - /// - `MediumIntUnsigned` - /// - `Int` - /// - `Int4` - /// - `Int8` - /// - `Integer` - /// - `IntUnsigned` - /// - `Int4Unsigned` - /// - `BigInt` - /// - `BigIntUnsigned` - /// - `Int8Unsigned` - /// - `UInt8` - /// - `UInt16` - /// - `UInt32` - /// - `UInt64` - /// - `UInt128` - /// - `UInt256` - /// - `Int128` - /// - `Int256` - /// - `UBigInt` - /// - `UInt` - Integer, - /// Every float **and** decimal/numeric type (FLOAT, DOUBLE, DECIMAL, …). - /// - /// Supports: - /// - `Numeric` - /// - `Decimal` - /// - `DecimalUnsigned` - /// - `BigNumeric` - /// - `BigDecimal` - /// - `Dec` - /// - `DecUnsigned` - /// - `Float` - /// - `FloatUnsigned` - /// - `Real` - /// - `RealUnsigned` - /// - `Float4` - /// - `Float8` - /// - `Double` - /// - `DoubleUnsigned` - /// - `DoublePrecision` - /// - `DoublePrecisionUnsigned` - Float, - /// BOOLEAN / BOOL. - /// - /// Supports: - /// - `Bool` - /// - `Boolean` - Boolean, - /// Any character/text type (CHAR, VARCHAR, TEXT, CLOB, ENUM, SET, …). - /// - /// Supports: - /// - `Character` - /// - `Char` - /// - `CharacterVarying` - /// - `CharVarying` - /// - `Varchar` - /// - `Nvarchar` - /// - `CharacterLargeObject` - /// - `CharLargeObject` - /// - `Clob` - /// - `String` - /// - `FixedString` - /// - `Regclass` - /// - `Enum` - /// - `Set` - String, - /// UUID. - /// - /// Supports: - /// - `UUID` - Uuid, - /// Binary blobs (BINARY, VARBINARY, BLOB, BYTEA, BYTES, …). - /// - /// Supports: - /// - `Binary` - /// - `Varbinary` - /// - `Blob` - /// - `LongBlob` - /// - `Bytes` - /// - `Bytea` - Bytes, - /// Bit strings (BIT, BIT VARYING, VARBIT). - /// - /// Supports: - /// - `Bit` - /// - `BitVarying` - /// - `VarBit` - Bit, - /// JSON / JSONB. - /// - /// Supports: - /// - `JSON` - /// - `JSONB` - JSON, - /// DATE. - /// - /// Supports: - /// - `Date` - /// - `Date32` - Date, - /// TIME. - /// - /// Supports: - /// - `Time` - Time, - /// DATETIME / TIMESTAMP / DATETIME64. - /// - /// Supports: - /// - `Datetime` - /// - `Datetime64` - /// - `Timestamp` - /// - `TimestampNtz` - /// - `TimestampWithTz` - Datetime, - /// INET. - /// - /// Supports: - /// - `Inet` - Inet, - /// MACADDR. - /// - /// Supports: - /// - `MacAddr` - MacAddress, - /// ARRAY of a single element type. - /// - /// Supports: - /// - `Array` - Array(Box), - /// Unspecified serializer. - /// - /// This is also useful when user doesn't specify the serializer, - /// and also we couldn't detect the type from datatype. - Unspecified, - /// Custom serializer - Custom(pyo3::Py), -} - -impl DataTypeSerializer { - #[inline] - pub fn infer_from_name(value: &super::name::DataTypeName) -> Self { - use super::name::DataTypeGroup; - - match value.group() { - DataTypeGroup::STRING => Self::String, - DataTypeGroup::UUID => Self::Uuid, - DataTypeGroup::BYTES => Self::Bytes, - DataTypeGroup::BIT => Self::Bit, - DataTypeGroup::INTEGER => Self::Integer, - DataTypeGroup::FLOAT => Self::Float, - DataTypeGroup::BOOLEAN => Self::Boolean, - DataTypeGroup::DATE => Self::Date, - DataTypeGroup::TIME => Self::Time, - DataTypeGroup::DATETIME => Self::Datetime, - DataTypeGroup::JSON | DataTypeGroup::JSONB => Self::JSON, - DataTypeGroup::INET => Self::Inet, - DataTypeGroup::MACADDRESS => Self::MacAddress, - DataTypeGroup::CUSTOM => Self::Unspecified, - } - } - - pub fn serialize<'py>( - &self, - dialect: &crate::native::dialect::SqlDialect, - object: &alias::BoundObject<'py>, - ) -> pyo3::PyResult { - unsafe { - match self { - Self::Integer => serialize_integer(object.py(), object.as_ptr()), - Self::Float => serialize_float(object.py(), object.as_ptr()), - Self::Boolean => serialize_bool(object.py(), object.as_ptr(), dialect), - Self::String => serialize_str(object.py(), object.as_ptr(), dialect), - Self::Bytes => serialize_bytes(object.py(), object.as_ptr(), dialect), - Self::Bit => serialize_bit(object.py(), object.as_ptr(), dialect), - Self::JSON => serialize_json(object.py(), object.as_ptr(), dialect), - Self::Uuid => serialize_uuid(object.py(), object.as_ptr(), dialect), - Self::Date => serialize_date(object.py(), object.as_ptr(), dialect), - Self::Time => serialize_time(object.py(), object.as_ptr(), dialect), - Self::Datetime => serialize_datetime(object.py(), object.as_ptr(), dialect), - Self::Inet => serialize_inet(object.py(), object.as_ptr(), dialect), - Self::MacAddress => serialize_mac_address(object.py(), object.as_ptr(), dialect), - Self::Array(element) => { - serialize_array(object.py(), object.as_ptr(), dialect, element) - } - Self::Unspecified => py_to_string(object.py(), object.as_ptr()), - Self::Custom(s) => { - let dialect_arg = dialect.name(); - let output = s.call1(object.py(), (dialect_arg, object))?; - _serialize_string(object.py(), output.as_ptr()) - } - } - } - } -} - -/// Serializes any Python `int` as a bare decimal literal. -/// -/// Uses `str(int)` so arbitrary precision (beyond i64/u64) is preserved - one -/// serializer genuinely covers TINYINT through BIGINT and the unsigned variants. -#[inline] -unsafe fn serialize_integer( - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, -) -> pyo3::PyResult { - if pyo3::ffi::PyLong_CheckExact(ptr) == 1 { - // bool is a subclass of int in Python, but PyLong_CheckExact rejects - // it, so True/False never sneaks through here. - py_to_string(py, ptr) - } else { - rapidquery_macros::pyerr!( - Err(PyTypeError), - "expected int for integer serialization, got {}", - get_type_name(py, ptr) - ) - } -} - -/// Serializes Python `float`, `int`, `decimal.Decimal`, or numeric `str` as a -/// bare numeric literal. -/// -/// `Decimal`/`int` go through their exact text form (no precision loss); -/// only a real `float` is formatted from the f64. Non-finite floats are rejected. -/// -/// Assisted-by: Claude -#[inline] -unsafe fn serialize_float( - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, -) -> pyo3::PyResult { - if pyo3::ffi::PyFloat_CheckExact(ptr) == 1 { - let v = pyo3::ffi::PyFloat_AsDouble(ptr); - if !v.is_finite() { - return rapidquery_macros::pyerr!( - Err(PyValueError), - "cannot serialize non-finite float {} as a numeric literal", - v - ); - } - - // {:?} on f64 round-trips and avoids the "1" vs "1.0" ambiguity. - return Ok(format!("{v:?}")); - } - - // int / Decimal: their textual form is already a valid literal. - if pyo3::ffi::PyLong_CheckExact(ptr) == 1 - || crate::typeref::check_exact(ptr, crate::typeref::STD_DECIMAL_TYPE) - { - let text = py_to_string(py, ptr)?; - // Reject obvious garbage from str inputs cheaply: a literal must - // start with a digit, sign, or dot. - if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 - && !text - .trim() - .chars() - .next() - .is_some_and(|c| c.is_ascii_digit() || c == '-' || c == '+' || c == '.') - { - return rapidquery_macros::pyerr!( - Err(PyValueError), - "string {:?} is not a valid numeric literal", - text - ); - } - return Ok(text.trim().to_string()); - } - - rapidquery_macros::pyerr!( - Err(PyTypeError), - "expected float/int/Decimal/str for numeric serialization, got {}", - get_type_name(py, ptr) - ) -} - -#[inline] -unsafe fn serialize_bool( - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - dialect: &crate::native::dialect::SqlDialect, -) -> pyo3::PyResult { - if pyo3::ffi::PyBool_Check(ptr) == 1 { - let value = pyo3::ffi::Py_True() == ptr; - Ok(dialect.format_boolean_as_string(value)) - } else { - rapidquery_macros::pyerr!( - Err(PyTypeError), - "expected bool for boolean serialization, got {}", - get_type_name(py, ptr) - ) - } -} - -/// Serializes a Python `str` (or `enum.Enum` whose value is a str) as a quoted, -/// escaped string literal: `"LIKE"` -> `'LIKE'`. -#[inline] -unsafe fn serialize_str( - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - dialect: &crate::native::dialect::SqlDialect, -) -> pyo3::PyResult { - const ENUM_VALUE_NAME: &std::ffi::CStr = pyo3::ffi::c_str!("value"); - - if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { - let raw = _serialize_string(py, ptr)?; - let raw = dialect.escape_string_as_string(&raw); - return Ok(dialect.quote_string_as_string(&raw)); - } - - // Accept enum members whose `.value` is a string. - if (*pyo3::ffi::Py_TYPE(ptr)).ob_base.ob_base.ob_type == crate::typeref::STD_ENUM_TYPE { - let value = pyo3::ffi::PyObject_GetAttrString(ptr, ENUM_VALUE_NAME.as_ptr()); - if value.is_null() { - return Err(pyo3::PyErr::fetch(py)); - } - - let is_str = pyo3::ffi::PyUnicode_CheckExact(value) == 1; - let raw = if is_str { - _serialize_string(py, value) - } else { - Ok(String::new()) - }; - - pyo3::ffi::Py_DECREF(value); - if is_str { - let raw = dialect.escape_string_as_string(&raw?); - return Ok(dialect.quote_string_as_string(&raw)); - } - } - - rapidquery_macros::pyerr!( - Err(PyTypeError), - "expected str/Enum for string serialization, got {}", - get_type_name(py, ptr) - ) -} - -/// Serializes a `uuid.UUID` (or 36-char `str`) as a quoted literal. -#[inline] -unsafe fn serialize_uuid( - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - dialect: &crate::native::dialect::SqlDialect, -) -> pyo3::PyResult { - if crate::typeref::check_exact(ptr, crate::typeref::STD_UUID_TYPE) - || pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 - { - let text = py_to_string(py, ptr)?; - let text = dialect.escape_string_as_string(&text); - Ok(dialect.quote_string_as_string(&text)) - } else { - rapidquery_macros::pyerr!( - Err(PyTypeError), - "expected uuid.UUID/str for UUID serialization, got {}", - get_type_name(py, ptr) - ) - } -} - -#[inline] -unsafe fn serialize_bytes( - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - dialect: &crate::native::dialect::SqlDialect, -) -> pyo3::PyResult { - if pyo3::ffi::PyBytes_CheckExact(ptr) == 1 { - Ok(dialect.encode_bytes_as_string(_read_bytes(ptr))) - } else { - Err(rapidquery_macros::pyerr!( - PyTypeError, - "expected bytes for binary serialization, got {}", - get_type_name(py, ptr) - )) - } -} - -/// Serializes a bit string given as a `str` of `0`/`1` (e.g. `"1010"`) into a -/// `B'1010'` literal. -#[inline] -unsafe fn serialize_bit( - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - dialect: &crate::native::dialect::SqlDialect, -) -> pyo3::PyResult { - if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { - let raw = _serialize_string(py, ptr)?; - - if raw.is_empty() || !raw.bytes().all(|b| b == b'0' || b == b'1') { - return rapidquery_macros::pyerr!( - Err(PyValueError), - "expected a string of '0'/'1' for bit serialization, got {:?}", - raw - ); - } - Ok(dialect.encode_bits_as_string(&raw)) - } else { - rapidquery_macros::pyerr!( - Err(PyTypeError), - "expected str of bits for bit serialization, got {}", - get_type_name(py, ptr) - ) - } -} - -/// Serializes any JSON-encodable Python object via `json.dumps`, then wraps the -/// result as a quoted string literal. -#[inline] -unsafe fn serialize_json( - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - dialect: &crate::native::dialect::SqlDialect, -) -> pyo3::PyResult { - let dumped = _serialize_object_with_pyjson(py, ptr)?; - let text = _serialize_string(py, dumped); - pyo3::ffi::Py_DECREF(dumped); - - let text = dialect.escape_string_as_string(&text?); - Ok(dialect.quote_string_as_string(&text)) -} - -/// `datetime.date` -> `'YYYY-MM-DD'`. -#[inline] -unsafe fn serialize_date( - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - dialect: &crate::native::dialect::SqlDialect, -) -> pyo3::PyResult { - if crate::typeref::check_exact(ptr, crate::typeref::STD_DATE_TYPE) { - let val: pyo3::Bound<'_, pyo3::types::PyDate> = - pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; - - let date: chrono::NaiveDate = val.extract()?; - let result = date.format("%Y-%m-%d").to_string(); - - Ok(dialect.quote_string_as_string(&result)) - } else { - Err(rapidquery_macros::pyerr!( - PyTypeError, - "expected datetime.date for date serialization, got {}", - get_type_name(py, ptr) - )) - } -} - -/// `datetime.time` -> `'HH:MM:SS[.ffffff]'`. -#[inline] -unsafe fn serialize_time( - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - dialect: &crate::native::dialect::SqlDialect, -) -> pyo3::PyResult { - if crate::typeref::check_exact(ptr, crate::typeref::STD_TIME_TYPE) { - let val: pyo3::Bound<'_, pyo3::types::PyTime> = - pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; - - let date: chrono::NaiveTime = val.extract()?; - let result = date.format("%H:%M:%S%.f").to_string(); - - Ok(dialect.quote_string_as_string(&result)) - } else { - Err(rapidquery_macros::pyerr!( - PyTypeError, - "expected datetime.time for time serialization, got {}", - get_type_name(py, ptr) - )) - } -} - -/// `datetime.datetime` -> `'YYYY-MM-DD HH:MM:SS[.ffffff][+HH:MM]'`. -/// -/// `int`/`float` are treated as a UTC epoch and accepted too. -#[inline] -unsafe fn serialize_datetime( - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - dialect: &crate::native::dialect::SqlDialect, -) -> pyo3::PyResult { - use chrono::TimeZone; - use pyo3::types::PyTzInfoAccess; - - if crate::typeref::check_exact(ptr, crate::typeref::STD_DATETIME_TYPE) { - // isoformat(sep=' ') -> the SQL-friendly space-separated form. - let val: pyo3::Bound<'_, pyo3::types::PyDateTime> = - pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; - - let tzinfo = val.get_tzinfo(); - - if tzinfo.is_none() { - let datetime: chrono::NaiveDateTime = val.extract()?; - let result = datetime.format("%Y-%m-%d %H:%M:%S%.f").to_string(); - - return Ok(dialect.quote_string_as_string(&result)); - } else { - let datetime: chrono::DateTime = val.extract()?; - let result = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").to_string(); - - return Ok(dialect.quote_string_as_string(&result)); - } - } - - if pyo3::ffi::PyLong_CheckExact(ptr) == 1 { - let mut has_overflow: i32 = 0; - let num = pyo3::ffi::PyLong_AsLongLongAndOverflow(ptr, &mut has_overflow); - - if has_overflow == 1 { - return Err(pyo3::exceptions::PyOverflowError::new_err("out of range")); - } - - if let chrono::offset::LocalResult::Single(datetime) = chrono::Utc.timestamp_opt(num, 0) { - let result = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").to_string(); - return Ok(dialect.quote_string_as_string(&result)); - } - - return Err(rapidquery_macros::pyerr!( - PyOverflowError, - "timestamp is invalid for datetime serialization: {}", - num - )); - } - - if pyo3::ffi::PyFloat_CheckExact(ptr) == 1 { - let num = pyo3::ffi::PyFloat_AsDouble(ptr); - - if let chrono::offset::LocalResult::Single(datetime) = - chrono::Utc.timestamp_opt(num.trunc() as i64, (num.fract() * 1_000_000_000.0) as u32) - { - let result = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").to_string(); - return Ok(dialect.quote_string_as_string(&result)); - } - - return Err(rapidquery_macros::pyerr!( - PyOverflowError, - "timestamp is invalid for datetime serialization: {}", - num - )); - } - - Err(rapidquery_macros::pyerr!( - PyTypeError, - "expected datetime.datetime/int/float for datetime serialization, got {}", - get_type_name(py, ptr) - )) -} - -/// `str` (or `ipaddress.*`) -> quoted literal. Validated as IPv4/IPv6 network. -#[inline] -unsafe fn serialize_inet( - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - dialect: &crate::native::dialect::SqlDialect, -) -> pyo3::PyResult { - use std::str::FromStr; - - let text = py_to_string(py, ptr)?; - let net = ipnetwork::IpNetwork::from_str(&text) - .map(|net| net.to_string()) - .map_err(|e| rapidquery_macros::pyerr!(PyValueError, e.to_string()))?; - - Ok(dialect.quote_string_as_string(&net)) -} - -/// `str` -> quoted literal. Validated as a MAC address. -#[inline] -unsafe fn serialize_mac_address( - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - dialect: &crate::native::dialect::SqlDialect, -) -> pyo3::PyResult { - use std::str::FromStr; - let text = py_to_string(py, ptr)?; - - let addr = mac_address::MacAddress::from_str(&text) - .map(|mac| mac.to_string()) - .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; - - Ok(dialect.quote_string_as_string(&addr)) -} - -/// Serializes a Python `list`/`tuple` by serializing each element with `element` -/// and wrapping in `ARRAY[...]`. -/// -/// Assisted-by: Claude -#[inline] -unsafe fn serialize_array( - py: pyo3::Python, - ptr: *mut pyo3::ffi::PyObject, - dialect: &crate::native::dialect::SqlDialect, - element: &DataTypeSerializer, -) -> pyo3::PyResult { - let (len, getter): ( - isize, - unsafe fn(*mut pyo3::ffi::PyObject, isize) -> *mut pyo3::ffi::PyObject, - ) = if pyo3::ffi::PyList_CheckExact(ptr) == 1 { - (pyo3::ffi::PyList_Size(ptr), |p, i| { - pyo3::ffi::PyList_GetItem(p, i) - }) - } else if pyo3::ffi::PyTuple_CheckExact(ptr) == 1 { - (pyo3::ffi::PyTuple_Size(ptr), |p, i| { - pyo3::ffi::PyTuple_GetItem(p, i) - }) - } else { - return rapidquery_macros::pyerr!( - Err(PyTypeError), - "expected list/tuple for array serialization, got {}", - get_type_name(py, ptr) - ); - }; - - let mut parts: Vec = Vec::with_capacity(len.max(0) as usize); - for i in 0..len { - let item = getter(ptr, i); // borrowed - if item.is_null() { - return Err(pyo3::PyErr::fetch(py)); - } - - let borrowed = alias::BoundObject::from_borrowed_ptr(py, item); - parts.push(element.serialize(dialect, &borrowed)?); - } - - Ok(format!("ARRAY[{}]", parts.join(", "))) -} - -/// Converts Python strings into [`String`], in the fastest way. -#[inline] -unsafe fn _serialize_string( - py: pyo3::Python, - object: *mut pyo3::ffi::PyObject, -) -> pyo3::PyResult { - let mut size: pyo3::ffi::Py_ssize_t = 0; - let c_str = pyo3::ffi::PyUnicode_AsUTF8AndSize(object, &mut size); - - if c_str.is_null() || size < 0 { - Err(pyo3::PyErr::fetch(py)) - } else { - let val = std::ffi::CStr::from_ptr(c_str); - Ok(std::str::from_utf8_unchecked(val.to_bytes()).into()) - } -} - -/// `str(obj)` then read it as UTF-8. Used for arbitrary-precision ints, -/// `decimal.Decimal`, `uuid.UUID`, etc. where Python already has the right text. -#[inline] -unsafe fn py_to_string(py: pyo3::Python, ptr: *mut pyo3::ffi::PyObject) -> pyo3::PyResult { - let s = pyo3::ffi::PyObject_Str(ptr); - if s.is_null() { - return Err(pyo3::PyErr::fetch(py)); - } - let out = _serialize_string(py, s); - pyo3::ffi::Py_DECREF(s); - out -} - -/// Borrow the bytes of a Python `bytes` object. -#[inline] -unsafe fn _read_bytes<'a>(ptr: *mut pyo3::ffi::PyObject) -> &'a [u8] { - let buf = pyo3::ffi::PyBytes_AsString(ptr) as *const u8; - let len = pyo3::ffi::PyBytes_Size(ptr) as usize; - debug_assert!(!buf.is_null()); - std::slice::from_raw_parts(buf, len) -} - -/// Import json module only once -#[inline(always)] -pub fn _import_json_module( - py: pyo3::Python<'_>, -) -> pyo3::PyResult<&pyo3::Bound<'_, pyo3::types::PyModule>> { - static JSON_CLS: once_cell::sync::OnceCell> = - once_cell::sync::OnceCell::new(); - - let json = JSON_CLS.get_or_try_init(|| py.import("json").map(|x| x.unbind())); - json.map(|x| x.bind(py)) -} - -/// Serialize pyobject with Python `json` module -/// -/// # Safety -/// - `ptr` should be borrowed and a valid pointer -#[inline(always)] -pub unsafe fn _serialize_object_with_pyjson( - py: pyo3::Python<'_>, - ptr: *mut pyo3::ffi::PyObject, -) -> pyo3::PyResult<*mut pyo3::ffi::PyObject> { - let json = _import_json_module(py)?; - let dumps_func = json.getattr("dumps")?; - - unsafe { - let arg1 = pyo3::Bound::from_borrowed_ptr(py, ptr); - dumps_func.call1((arg1,)).map(|x| x.into_ptr()) - } -} diff --git a/src/native/data_type/tests.rs b/src/native/data_type/tests.rs new file mode 100644 index 0000000..d53c183 --- /dev/null +++ b/src/native/data_type/tests.rs @@ -0,0 +1,120 @@ +use super::*; + +fn assert_copy(_: T) {} + +#[test] +fn data_type_group_is_copy() { + assert_copy(DataTypeGroup::Integer); +} + +#[test] +fn known_names_are_canonicalized() { + let cases = [ + ("int", "INT", DataTypeGroup::Integer), + ("INT", "INT", DataTypeGroup::Integer), + ("Integer", "INTEGER", DataTypeGroup::Integer), + ("integer", "INTEGER", DataTypeGroup::Integer), + ("varchar", "VARCHAR", DataTypeGroup::String), + ("text", "TEXT", DataTypeGroup::String), + ("uuid", "UUID", DataTypeGroup::Uuid), + ("bool", "BOOLEAN", DataTypeGroup::Boolean), + ("boolean", "BOOLEAN", DataTypeGroup::Boolean), + ( + "timestamptz", + "TIMESTAMP WITH TIMEZONE", + DataTypeGroup::Datetime, + ), + ( + "timestamp without timezone", + "TIMESTAMP", + DataTypeGroup::Datetime, + ), + ("macaddr", "MACADDRESS", DataTypeGroup::MacAddress), + ("jsonb", "JSONB", DataTypeGroup::Jsonb), + ( + "double precision unsigned", + "DOUBLE PRECISION UNSIGNED", + DataTypeGroup::Float, + ), + ]; + for (input, canonical, group) in cases { + let dt = DataTypeName::from(input); + assert_eq!(dt.as_str(), canonical, "input {input:?}"); + assert_eq!(dt.group(), group, "input {input:?}"); + } +} + +#[test] +fn character_varying_typo_and_correct_spelling() { + let correct = DataTypeName::from("character varying"); + let typo = DataTypeName::from("characater varying"); + assert_eq!(correct.as_str(), "CHARACTER VARYING"); + assert_eq!(typo.as_str(), "CHARACTER VARYING"); + assert_eq!(correct.group(), DataTypeGroup::String); + assert_eq!(correct, typo); +} + +#[test] +fn unsigned_bigint_typo_and_correct_spelling() { + let correct = DataTypeName::from("unsigned bigint"); + let typo = DataTypeName::from("unsigned bitint"); + let alias = DataTypeName::from("ubigint"); + assert_eq!(correct.as_str(), "BIGINT UNSIGNED"); + assert_eq!(typo.as_str(), "BIGINT UNSIGNED"); + assert_eq!(alias.as_str(), "BIGINT UNSIGNED"); + assert_eq!(correct.group(), DataTypeGroup::Integer); +} + +#[test] +fn parameterized_name_is_custom_and_preserved() { + for input in ["varchar(255)", "VARCHAR(255)", "decimal(10, 2)"] { + let dt = DataTypeName::from(input); + assert_eq!(dt.as_str(), input); + assert_eq!(dt.group(), DataTypeGroup::Custom); + } +} + +#[test] +fn unknown_name_is_custom_and_preserved() { + let dt = DataTypeName::from("some_totally_unknown_type"); + assert_eq!(dt.as_str(), "some_totally_unknown_type"); + assert_eq!(dt.group(), DataTypeGroup::Custom); +} + +#[test] +fn from_string_reuses_unknown_allocation_path() { + let dt = DataTypeName::from(String::from("not_a_real_type")); + assert_eq!(dt.as_str(), "not_a_real_type"); + assert_eq!(dt.group(), DataTypeGroup::Custom); +} + +// #[test] +// fn inferred_serializer_matches_group() { +// let cases = [ +// (DataTypeGroup::String, InferredSerializer::String), +// (DataTypeGroup::Uuid, InferredSerializer::Uuid), +// (DataTypeGroup::Bytes, InferredSerializer::Bytes), +// (DataTypeGroup::Bit, InferredSerializer::Bit), +// (DataTypeGroup::Integer, InferredSerializer::Integer), +// (DataTypeGroup::Float, InferredSerializer::Float), +// (DataTypeGroup::Boolean, InferredSerializer::Boolean), +// (DataTypeGroup::Date, InferredSerializer::Date), +// (DataTypeGroup::Time, InferredSerializer::Time), +// (DataTypeGroup::Datetime, InferredSerializer::Datetime), +// (DataTypeGroup::Json, InferredSerializer::Json), +// (DataTypeGroup::Jsonb, InferredSerializer::Json), +// (DataTypeGroup::Inet, InferredSerializer::Inet), +// (DataTypeGroup::MacAddress, InferredSerializer::MacAddress), +// (DataTypeGroup::Custom, InferredSerializer::Unspecified), +// ]; +// for (group, expected) in cases { +// assert_eq!(group.inferred_serializer(), expected); +// } +// } + +#[test] +fn non_ascii_name_is_custom() { + let dt = DataTypeName::from("тип"); + assert_eq!(dt.as_str(), "тип"); + assert_eq!(dt.group(), DataTypeGroup::Custom); +} diff --git a/src/native/dialect/mod.rs b/src/native/dialect/mod.rs index ed779b5..729f409 100644 --- a/src/native/dialect/mod.rs +++ b/src/native/dialect/mod.rs @@ -378,6 +378,28 @@ impl SqlDialect { to_string(|buf| self.quote_string(buf, string)) } + /// Escape `raw` and wrap it as a complete string literal. + /// + /// Equivalent to `quote_string(escape_string(raw))` with no intermediate + /// `String`: writes the dialect prefix (`'` / `E'` / `N'`), then the + /// escaped body, then `'`. + #[inline] + pub fn write_string_literal(&self, writer: &mut W, raw: &str) -> fmt::Result { + match self { + Self::PostgreSql => writer.write_str("E'")?, + Self::MsSql => writer.write_str("N'")?, + _ => writer.write_char('\'')?, + } + self.escape_string(writer, raw)?; + writer.write_char('\'') + } + + /// [`write_string_literal`](Self::write_string_literal) into an owned `String`. + #[inline] + pub fn write_string_literal_as_string(&self, raw: &str) -> String { + to_string(|buf| self.write_string_literal(buf, raw)) + } + /// Write `bytes` as a native blob/bytea/varbyte literal. #[inline] pub fn encode_bytes(&self, writer: &mut W, bytes: &[u8]) -> fmt::Result { diff --git a/src/native/dialect/tests.rs b/src/native/dialect/tests.rs index 8c5f2c8..7ab71e9 100644 --- a/src/native/dialect/tests.rs +++ b/src/native/dialect/tests.rs @@ -745,6 +745,29 @@ fn quote_string_wraps_with_expected_prefix_per_dialect() { } } +#[test] +fn write_string_literal_matches_quote_of_escape() { + let samples = ["abc", "O'Brien", "a\\b", "a\0b", ""]; + for dialect in [ + SqlDialect::PostgreSql, + SqlDialect::MsSql, + SqlDialect::Sqlite, + ] { + for sample in samples { + let mut escaped = String::new(); + dialect.escape_string(&mut escaped, sample).unwrap(); + let via_parts = dialect.quote_string_as_string(&escaped); + let via_helper = dialect.write_string_literal_as_string(sample); + assert_eq!( + via_helper, + via_parts, + "{}: write_string_literal({sample:?}) != quote(escape(.))", + dialect.name() + ); + } + } +} + #[test] fn quote_string_always_ends_with_a_single_quote() { for dialect in ALL_DIALECTS { diff --git a/src/pybindings/data_type/mod.rs b/src/pybindings/data_type/mod.rs new file mode 100644 index 0000000..74cc49b --- /dev/null +++ b/src/pybindings/data_type/mod.rs @@ -0,0 +1,144 @@ +//! Python wrappers for SQL data types and value serializers. +//! +//! Native [`DataTypeName`](crate::native::data_type::DataTypeName) is +//! PyO3-free. This module owns the serializer tree and the `DataType` +//! pyclass — both inspect Python values. +//! +//! Python sees: +//! +//! * [`PySerializer`] — `rapidquery._core.data_type.Serializer`, the ABC +//! * frozen subclasses per behaviour (`IntegerSerializer`, `JSONSerializer`, …) +//! * [`PyArraySerializer`] / [`PyCustomSerializer`] (no native counterpart) +//! * [`PyDataType`] — a name plus a serializer +//! +//! `serialize(dialect, obj)` accepts a dialect **class or instance**. Serializer +//! extract is instance-only (Array/Custom carry state). + +mod serialize; +mod serializer; + +use crate::internal::alias; +use crate::native::data_type::DataTypeName; + +pub use serializer::{ + PyArraySerializer, PyBitSerializer, PyBooleanSerializer, PyBytesSerializer, PyCustomSerializer, + PyDateSerializer, PyDatetimeSerializer, PyFloatSerializer, PyInetSerializer, + PyIntegerSerializer, PyJSONSerializer, PyMacAddressSerializer, PySerializer, + PyStringSerializer, PyTimeSerializer, PyUnspecifiedSerializer, PyUuidSerializer, +}; + +/// Represents a SQL data type. +/// +/// A data type is a SQL name plus the serializer used to turn Python objects +/// into SQL literals. The name can be any string; it is not limited to a set +/// of predefined values. The serializer is fully customizable. +/// +/// Known names are interned and canonicalized (`"int"` becomes `"INT"`). +/// Unknown names and parameterized spellings (`VARCHAR(255)`) are kept as +/// given. If `serializer` is omitted at construction, one is inferred from +/// the name; unrecognized names get `UnspecifiedSerializer`. +#[rapidquery_macros::pyclass("DataType", module = "rapidquery._core.data_type")] +#[derive(Debug)] +pub struct PyDataType { + name: DataTypeName, + serializer: PySerializer, +} + +#[pyo3::pymethods] +impl PyDataType { + /// Creates a new SQL data type. + /// + /// Args: + /// name: The name of a data type. *It is not limited to a set of predefined values.* + /// + /// **Important memory optimization note**: At import time, the library pre-allocates + /// and interns (reserves in memory) several simple and commonly used data types + /// (e.g. `INTEGER`, `VARCHAR`, `TIMESTAMP`, `BOOLEAN`, etc.). If you instantiate + /// a `DataType` using one of these common names, the library will automatically + /// return a reference to the pre-allocated instance instead of creating a new one. + /// This reduces memory consumption and improves performance when working with + /// standard SQL types. + /// + /// As a result of this interning mechanism, when you access the `.name` property + /// on a pre-allocated instance, the returned string **may be slightly different** + /// from the exact string you passed in (for example in casing, spacing, or canonical + /// form chosen by the library). This is expected behavior and does not affect + /// correctness. + /// + /// serializer: The `serializer` is used whenever a Python object needs to be converted + /// into a valid SQL value during query construction. This is commonly required + /// when setting default values, inserting data, or generating other SQL statements. + /// You can use one of the built-in serializers (we provide ready-to-use + /// serializers for most common data types), or you can create and register your + /// own custom serializer. This parameter is optional. However, it is strongly + /// recommended to always specify it explicitly. If you do not provide a serializer, + /// the `DataType` will attempt to automatically detect and assign one based on the + /// `name`. If no suitable serializer is found, it will fall back to `UnspecifiedSerializer`. + #[new] + #[pyo3(signature = (name, serializer = None, /))] + fn __new__(name: String, serializer: Option) -> pyo3::PyClassInitializer { + let name = DataTypeName::from(name); + let serializer = match serializer { + Some(s) => s, + None => PySerializer::inferred(name.group()), + }; + pyo3::PyClassInitializer::from(Self { name, serializer }) + } + + /// Returns the type name. + /// + /// Returns: + /// The canonical type name. + /// + /// Note: + /// For interned common data types (see `__new__`), the returned value + /// may be a canonicalized version of the original input string (for + /// example in casing or spacing). + #[getter] + fn get_name(&self) -> &str { + self.name.as_str() + } + + /// Returns the serializer used to render Python values as SQL literals. + /// + /// Returns: + /// The `Serializer` associated with this data type. + #[getter] + fn get_serializer(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + slf.serializer.clone().into_subclasses(slf.py()) + } + + /// Renders `object` as a SQL literal for `dialect`. + /// + /// Shorthand for `self.serializer.serialize(dialect, object)`. + /// + /// Args: + /// dialect: A dialect class (`PostgreSQL`) or instance (`PostgreSQL()`). + /// object: The Python value to convert. + /// + /// Returns: + /// A SQL fragment produced by this type's serializer. + fn serialize( + &self, + dialect: super::dialect::PySqlDialect, + object: alias::BoundObject<'_>, + ) -> pyo3::PyResult { + self.serializer.serialize_value(dialect, object) + } + + fn __repr__(&self) -> String { + format!("DataType({:?})", self.name.as_str()) + } +} + +#[pyo3::pymodule(name = "data_type", gil_used = false)] +pub mod data_type_mod { + #[pymodule_export] + use super::{ + PyArraySerializer, PyBitSerializer, PyBooleanSerializer, PyBytesSerializer, + PyCustomSerializer, PyDataType, PyDateSerializer, PyDatetimeSerializer, PyFloatSerializer, + PyInetSerializer, PyIntegerSerializer, PyJSONSerializer, PyMacAddressSerializer, + PySerializer, PyStringSerializer, PyTimeSerializer, PyUnspecifiedSerializer, + PyUuidSerializer, + }; +} diff --git a/src/pybindings/data_type/serialize.rs b/src/pybindings/data_type/serialize.rs new file mode 100644 index 0000000..af03fa7 --- /dev/null +++ b/src/pybindings/data_type/serialize.rs @@ -0,0 +1,556 @@ +//! Write a Python value as a SQL literal into a `String`. +//! +//! One buffer, dialect writers (`write_string_literal`, `encode_bytes`, …). +//! Type checks are exact (`int` is not `bool`; `date` is not `datetime`). +//! JSON dumps go through [`crate::internal::modules::json_dumps`]. + +use std::fmt::Write; +use std::str::FromStr; + +use pyo3::types::{PyAnyMethods, PyTzInfoAccess}; + +use super::serializer::SerializerInner; +use crate::internal::alias; +use crate::internal::utils::get_type_name; +use crate::native::dialect::SqlDialect; + +impl SerializerInner { + pub(crate) fn write_sql( + &self, + dialect: &SqlDialect, + object: &alias::BoundObject<'_>, + out: &mut String, + ) -> pyo3::PyResult<()> { + let py = object.py(); + let ptr = object.as_ptr(); + unsafe { + match self { + Self::Integer => write_integer(py, ptr, out), + Self::Float => write_float(py, ptr, out), + Self::Boolean => write_bool(py, ptr, dialect, out), + Self::String => write_str(py, ptr, dialect, out), + Self::Uuid => write_uuid(py, ptr, dialect, out), + Self::Bytes => write_bytes(py, ptr, dialect, out), + Self::Bit => write_bit(py, ptr, dialect, out), + Self::Json => write_json(py, ptr, dialect, out), + Self::Date => write_date(py, ptr, dialect, out), + Self::Time => write_time(py, ptr, dialect, out), + Self::Datetime => write_datetime(py, ptr, dialect, out), + Self::Inet => write_inet(py, ptr, dialect, out), + Self::MacAddress => write_mac_address(py, ptr, dialect, out), + Self::Array(element) => write_array(py, ptr, dialect, element, out), + Self::Unspecified => { + let text = py_to_string(py, ptr)?; + out.push_str(&text); + Ok(()) + } + Self::Custom(s) => write_custom(py, ptr, dialect, s, object, out), + } + } + } +} + +/// Borrow the cached UTF-8 buffer of a Python `str`. +/// +/// # Safety +/// `ptr` is a live unicode object. The slice is valid until that object is +/// mutated or the GIL is released. +#[inline] +unsafe fn py_str<'a>( + py: pyo3::Python<'_>, + ptr: *mut pyo3::ffi::PyObject, +) -> pyo3::PyResult<&'a str> { + let mut size: pyo3::ffi::Py_ssize_t = 0; + let data = pyo3::ffi::PyUnicode_AsUTF8AndSize(ptr, &mut size); + if data.is_null() || size < 0 { + Err(pyo3::PyErr::fetch(py)) + } else { + Ok(std::str::from_utf8_unchecked(std::slice::from_raw_parts( + data as *const u8, + size as usize, + ))) + } +} + +/// `str(obj)` copied into a Rust `String`. Used when the unicode is a +/// temporary (`PyObject_Str`) that must be `DECREF`'d before return. +#[inline] +unsafe fn py_to_string( + py: pyo3::Python<'_>, + ptr: *mut pyo3::ffi::PyObject, +) -> pyo3::PyResult { + let s = pyo3::ffi::PyObject_Str(ptr); + if s.is_null() { + return Err(pyo3::PyErr::fetch(py)); + } + let out = py_str(py, s).map(str::to_owned); + pyo3::ffi::Py_DECREF(s); + out +} + +#[inline] +unsafe fn py_bytes<'a>(ptr: *mut pyo3::ffi::PyObject) -> &'a [u8] { + let buf = pyo3::ffi::PyBytes_AsString(ptr) as *const u8; + let len = pyo3::ffi::PyBytes_Size(ptr) as usize; + debug_assert!(!buf.is_null()); + std::slice::from_raw_parts(buf, len) +} + +#[inline] +fn write_quoted(dialect: &SqlDialect, out: &mut String, raw: &str) { + let _ = dialect.write_string_literal(out, raw); +} + +/// Serializes any Python `int` as a bare decimal literal (`str(int)`), +/// including values beyond i64/u64. `bool` is rejected (`PyLong_CheckExact`). +/// +/// # Safety +/// `ptr` must be *borrowed*, non-null, and a valid Python object. +#[inline] +unsafe fn write_integer( + py: pyo3::Python<'_>, + ptr: *mut pyo3::ffi::PyObject, + out: &mut String, +) -> pyo3::PyResult<()> { + if pyo3::ffi::PyLong_CheckExact(ptr) == 1 { + let text = py_to_string(py, ptr)?; + out.push_str(&text); + Ok(()) + } else { + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected int for integer serialization, got {}", + get_type_name(py, ptr) + ) + } +} + +/// `float` / exact `int` / `decimal.Decimal` as a bare numeric literal. +/// +/// Only a real `float` is formatted from the f64 (`{:?}` so `1.0` stays +/// distinct from `1`). Non-finite floats are rejected. `str` is not accepted. +/// +/// # Safety +/// `ptr` must be *borrowed*, non-null, and a valid Python object. +#[inline] +unsafe fn write_float( + py: pyo3::Python<'_>, + ptr: *mut pyo3::ffi::PyObject, + out: &mut String, +) -> pyo3::PyResult<()> { + if pyo3::ffi::PyFloat_CheckExact(ptr) == 1 { + let v = pyo3::ffi::PyFloat_AsDouble(ptr); + if !v.is_finite() { + return rapidquery_macros::pyerr!( + Err(PyValueError), + "cannot serialize non-finite float {} as a numeric literal", + v + ); + } + let _ = write!(out, "{v:?}"); + return Ok(()); + } + + if pyo3::ffi::PyLong_CheckExact(ptr) == 1 + || crate::typeref::check_exact(ptr, crate::typeref::STD_DECIMAL_TYPE) + { + let text = py_to_string(py, ptr)?; + out.push_str(&text); + return Ok(()); + } + + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected float/int/Decimal for numeric serialization, got {}", + get_type_name(py, ptr) + ) +} + +/// # Safety +/// `ptr` must be *borrowed*, non-null, and a valid Python object. +#[inline] +unsafe fn write_bool( + py: pyo3::Python<'_>, + ptr: *mut pyo3::ffi::PyObject, + dialect: &SqlDialect, + out: &mut String, +) -> pyo3::PyResult<()> { + if pyo3::ffi::PyBool_Check(ptr) == 1 { + let value = pyo3::ffi::Py_True() == ptr; + let _ = dialect.format_boolean(out, value); + Ok(()) + } else { + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected bool for boolean serialization, got {}", + get_type_name(py, ptr) + ) + } +} + +/// Exact `str`, or an enum member whose `.value` is a `str`. +/// +/// # Safety +/// `ptr` must be *borrowed*, non-null, and a valid Python object. +#[inline] +unsafe fn write_str( + py: pyo3::Python<'_>, + ptr: *mut pyo3::ffi::PyObject, + dialect: &SqlDialect, + out: &mut String, +) -> pyo3::PyResult<()> { + if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { + write_quoted(dialect, out, py_str(py, ptr)?); + return Ok(()); + } + + // type(type(obj)) is EnumMeta — the instance is an enum member. + if (*pyo3::ffi::Py_TYPE(ptr)).ob_base.ob_base.ob_type == crate::typeref::STD_ENUM_TYPE { + let value = pyo3::ffi::PyObject_GetAttrString(ptr, c"value".as_ptr()); + if value.is_null() { + return Err(pyo3::PyErr::fetch(py)); + } + let is_str = pyo3::ffi::PyUnicode_CheckExact(value) == 1; + let result = if is_str { + write_quoted(dialect, out, py_str(py, value)?); + Ok(()) + } else { + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected str/Enum for string serialization, got {}", + get_type_name(py, ptr) + ) + }; + pyo3::ffi::Py_DECREF(value); + return result; + } + + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected str/Enum for string serialization, got {}", + get_type_name(py, ptr) + ) +} + +/// # Safety +/// `ptr` must be *borrowed*, non-null, and a valid Python object. +#[inline] +unsafe fn write_uuid( + py: pyo3::Python<'_>, + ptr: *mut pyo3::ffi::PyObject, + dialect: &SqlDialect, + out: &mut String, +) -> pyo3::PyResult<()> { + if crate::typeref::check_exact(ptr, crate::typeref::STD_UUID_TYPE) { + let text = py_to_string(py, ptr)?; + write_quoted(dialect, out, &text); + Ok(()) + } else if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { + write_quoted(dialect, out, py_str(py, ptr)?); + Ok(()) + } else { + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected uuid.UUID/str for UUID serialization, got {}", + get_type_name(py, ptr) + ) + } +} + +/// # Safety +/// `ptr` must be *borrowed*, non-null, and a valid Python object. +#[inline] +unsafe fn write_bytes( + py: pyo3::Python<'_>, + ptr: *mut pyo3::ffi::PyObject, + dialect: &SqlDialect, + out: &mut String, +) -> pyo3::PyResult<()> { + if pyo3::ffi::PyBytes_CheckExact(ptr) == 1 { + let _ = dialect.encode_bytes(out, py_bytes(ptr)); + Ok(()) + } else { + Err(rapidquery_macros::pyerr!( + PyTypeError, + "expected bytes for binary serialization, got {}", + get_type_name(py, ptr) + )) + } +} + +/// # Safety +/// `ptr` must be *borrowed*, non-null, and a valid Python object. +#[inline] +unsafe fn write_bit( + py: pyo3::Python<'_>, + ptr: *mut pyo3::ffi::PyObject, + dialect: &SqlDialect, + out: &mut String, +) -> pyo3::PyResult<()> { + if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { + let raw = py_str(py, ptr)?; + if raw.is_empty() || !raw.bytes().all(|b| b == b'0' || b == b'1') { + return rapidquery_macros::pyerr!( + Err(PyValueError), + "expected a string of '0'/'1' for bit serialization, got {:?}", + raw + ); + } + let _ = dialect.encode_bits(out, raw); + Ok(()) + } else { + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected str of bits for bit serialization, got {}", + get_type_name(py, ptr) + ) + } +} + +/// JSON-encodable object → dumps text → dialect string literal. +/// +/// # Safety +/// `ptr` must be *borrowed*, non-null, and a valid Python object. +#[inline] +unsafe fn write_json( + py: pyo3::Python<'_>, + ptr: *mut pyo3::ffi::PyObject, + dialect: &SqlDialect, + out: &mut String, +) -> pyo3::PyResult<()> { + let text = + crate::internal::modules::json_dumps(unsafe { pyo3::Bound::from_borrowed_ptr(py, ptr) })?; + + write_quoted(dialect, out, &text); + Ok(()) +} + +/// # Safety +/// `ptr` must be *borrowed*, non-null, and a valid Python object. +#[inline] +unsafe fn write_date( + py: pyo3::Python<'_>, + ptr: *mut pyo3::ffi::PyObject, + dialect: &SqlDialect, + out: &mut String, +) -> pyo3::PyResult<()> { + if crate::typeref::check_exact(ptr, crate::typeref::STD_DATE_TYPE) { + let val: pyo3::Bound<'_, pyo3::types::PyDate> = + pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; + let date: chrono::NaiveDate = val.extract()?; + let text = date.format("%Y-%m-%d").to_string(); + write_quoted(dialect, out, &text); + Ok(()) + } else { + Err(rapidquery_macros::pyerr!( + PyTypeError, + "expected datetime.date for date serialization, got {}", + get_type_name(py, ptr) + )) + } +} + +/// # Safety +/// `ptr` must be *borrowed*, non-null, and a valid Python object. +#[inline] +unsafe fn write_time( + py: pyo3::Python<'_>, + ptr: *mut pyo3::ffi::PyObject, + dialect: &SqlDialect, + out: &mut String, +) -> pyo3::PyResult<()> { + if crate::typeref::check_exact(ptr, crate::typeref::STD_TIME_TYPE) { + let val: pyo3::Bound<'_, pyo3::types::PyTime> = + pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; + let time: chrono::NaiveTime = val.extract()?; + let text = time.format("%H:%M:%S%.f").to_string(); + write_quoted(dialect, out, &text); + Ok(()) + } else { + Err(rapidquery_macros::pyerr!( + PyTypeError, + "expected datetime.time for time serialization, got {}", + get_type_name(py, ptr) + )) + } +} + +/// Naive `datetime` keeps wall time. Aware `datetime` keeps its offset +/// (`DateTime`), not a conversion to UTC. `int`/`float` are +/// UTC epoch seconds. +/// +/// # Safety +/// `ptr` must be *borrowed*, non-null, and a valid Python object. +#[inline] +unsafe fn write_datetime( + py: pyo3::Python<'_>, + ptr: *mut pyo3::ffi::PyObject, + dialect: &SqlDialect, + out: &mut String, +) -> pyo3::PyResult<()> { + use chrono::TimeZone; + + if crate::typeref::check_exact(ptr, crate::typeref::STD_DATETIME_TYPE) { + let val: pyo3::Bound<'_, pyo3::types::PyDateTime> = + pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; + + if val.get_tzinfo().is_none() { + let datetime: chrono::NaiveDateTime = val.extract()?; + let text = datetime.format("%Y-%m-%d %H:%M:%S%.f").to_string(); + write_quoted(dialect, out, &text); + return Ok(()); + } + + let datetime: chrono::DateTime = val.extract()?; + let text = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").to_string(); + write_quoted(dialect, out, &text); + return Ok(()); + } + + if pyo3::ffi::PyLong_CheckExact(ptr) == 1 { + let mut has_overflow: i32 = 0; + let num = pyo3::ffi::PyLong_AsLongLongAndOverflow(ptr, &mut has_overflow); + if has_overflow != 0 { + return Err(pyo3::exceptions::PyOverflowError::new_err("out of range")); + } + return match chrono::Utc.timestamp_opt(num, 0) { + chrono::offset::LocalResult::Single(datetime) => { + let text = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").to_string(); + write_quoted(dialect, out, &text); + Ok(()) + } + _ => Err(rapidquery_macros::pyerr!( + PyOverflowError, + "timestamp is invalid for datetime serialization: {}", + num + )), + }; + } + + if pyo3::ffi::PyFloat_CheckExact(ptr) == 1 { + let num = pyo3::ffi::PyFloat_AsDouble(ptr); + return match chrono::Utc + .timestamp_opt(num.trunc() as i64, (num.fract() * 1_000_000_000.0) as u32) + { + chrono::offset::LocalResult::Single(datetime) => { + let text = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").to_string(); + write_quoted(dialect, out, &text); + Ok(()) + } + _ => Err(rapidquery_macros::pyerr!( + PyOverflowError, + "timestamp is invalid for datetime serialization: {}", + num + )), + }; + } + + Err(rapidquery_macros::pyerr!( + PyTypeError, + "expected datetime.datetime/int/float for datetime serialization, got {}", + get_type_name(py, ptr) + )) +} + +/// # Safety +/// `ptr` must be *borrowed*, non-null, and a valid Python object. +#[inline] +unsafe fn write_inet( + py: pyo3::Python<'_>, + ptr: *mut pyo3::ffi::PyObject, + dialect: &SqlDialect, + out: &mut String, +) -> pyo3::PyResult<()> { + let text = py_to_string(py, ptr)?; + let net = ipnetwork::IpNetwork::from_str(&text) + .map(|net| net.to_string()) + .map_err(|e| rapidquery_macros::pyerr!(PyValueError, e.to_string()))?; + write_quoted(dialect, out, &net); + Ok(()) +} + +/// # Safety +/// `ptr` must be *borrowed*, non-null, and a valid Python object. +#[inline] +unsafe fn write_mac_address( + py: pyo3::Python<'_>, + ptr: *mut pyo3::ffi::PyObject, + dialect: &SqlDialect, + out: &mut String, +) -> pyo3::PyResult<()> { + let text = py_to_string(py, ptr)?; + let addr = mac_address::MacAddress::from_str(&text) + .map(|mac| mac.to_string()) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + write_quoted(dialect, out, &addr); + Ok(()) +} + +/// # Safety +/// `ptr` must be *borrowed*, non-null, and a valid Python object. +#[inline] +unsafe fn write_array( + py: pyo3::Python<'_>, + ptr: *mut pyo3::ffi::PyObject, + dialect: &SqlDialect, + element: &SerializerInner, + out: &mut String, +) -> pyo3::PyResult<()> { + let (len, getter): ( + isize, + unsafe fn(*mut pyo3::ffi::PyObject, isize) -> *mut pyo3::ffi::PyObject, + ) = if pyo3::ffi::PyList_CheckExact(ptr) == 1 { + (pyo3::ffi::PyList_Size(ptr), |p, i| { + pyo3::ffi::PyList_GetItem(p, i) + }) + } else if pyo3::ffi::PyTuple_CheckExact(ptr) == 1 { + (pyo3::ffi::PyTuple_Size(ptr), |p, i| { + pyo3::ffi::PyTuple_GetItem(p, i) + }) + } else { + return rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected list/tuple for array serialization, got {}", + get_type_name(py, ptr) + ); + }; + + out.push_str("ARRAY["); + for i in 0..len { + if i > 0 { + out.push_str(", "); + } + let item = getter(ptr, i); + if item.is_null() { + return Err(pyo3::PyErr::fetch(py)); + } + let borrowed = alias::BoundObject::from_borrowed_ptr(py, item); + element.write_sql(dialect, &borrowed, out)?; + } + out.push(']'); + Ok(()) +} + +/// # Safety +/// `ptr` must be *borrowed*, non-null, and a valid Python object. +#[inline] +unsafe fn write_custom( + py: pyo3::Python<'_>, + ptr: *mut pyo3::ffi::PyObject, + dialect: &SqlDialect, + function: &pyo3::Py, + object: &alias::BoundObject<'_>, + out: &mut String, +) -> pyo3::PyResult<()> { + let _ = ptr; + let output = function.call1(py, (dialect.name(), object))?; + if pyo3::ffi::PyUnicode_CheckExact(output.as_ptr()) != 1 { + return rapidquery_macros::pyerr!( + Err(PyTypeError), + "custom serializer must return str, got {}", + get_type_name(py, output.as_ptr()) + ); + } + out.push_str(py_str(py, output.as_ptr())?); + Ok(()) +} diff --git a/src/pybindings/data_type/serializer.rs b/src/pybindings/data_type/serializer.rs new file mode 100644 index 0000000..8df1e4e --- /dev/null +++ b/src/pybindings/data_type/serializer.rs @@ -0,0 +1,445 @@ +//! Python `Serializer` class hierarchy. +//! +//! Native inference is [`DataTypeGroup`](crate::native::data_type::DataTypeGroup). +//! Python sees frozen subclasses plus `ArraySerializer` / `CustomSerializer`, +//! which have no native counterpart. +//! +//! Unit instances compare and hash by variant (`IntegerSerializer() == +//! IntegerSerializer()`) and `repr` as `IntegerSerializer()`. Array compares +//! by element tree; Custom by function identity. + +use std::hash::{Hash, Hasher}; + +use crate::{internal::alias, native::data_type::DataTypeGroup}; + +// Value-serialization tree. Lives only in pybindings (`Custom` holds a +// Python callable; `Array` may wrap that). Not a pyclass — must not leak +// into Python `__doc__`. +#[derive(Debug, Clone)] +pub(super) enum SerializerInner { + Integer, + Float, + Boolean, + String, + Uuid, + Bytes, + Bit, + Json, + Date, + Time, + Datetime, + Inet, + MacAddress, + Unspecified, + Array(Box), + Custom(alias::PyObject), +} + +impl PartialEq for SerializerInner { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Integer, Self::Integer) + | (Self::Float, Self::Float) + | (Self::Boolean, Self::Boolean) + | (Self::String, Self::String) + | (Self::Uuid, Self::Uuid) + | (Self::Bytes, Self::Bytes) + | (Self::Bit, Self::Bit) + | (Self::Json, Self::Json) + | (Self::Date, Self::Date) + | (Self::Time, Self::Time) + | (Self::Datetime, Self::Datetime) + | (Self::Inet, Self::Inet) + | (Self::MacAddress, Self::MacAddress) + | (Self::Unspecified, Self::Unspecified) => true, + (Self::Array(a), Self::Array(b)) => a == b, + (Self::Custom(a), Self::Custom(b)) => std::ptr::eq(a.as_ptr(), b.as_ptr()), + _ => false, + } + } +} + +impl Eq for SerializerInner {} + +impl Hash for SerializerInner { + fn hash(&self, state: &mut H) { + std::mem::discriminant(self).hash(state); + match self { + Self::Array(element) => element.hash(state), + Self::Custom(function) => (function.as_ptr() as usize).hash(state), + _ => {} + } + } +} + +impl SerializerInner { + fn from_group(kind: DataTypeGroup) -> Self { + match kind { + DataTypeGroup::Integer => Self::Integer, + DataTypeGroup::Float => Self::Float, + DataTypeGroup::Boolean => Self::Boolean, + DataTypeGroup::String => Self::String, + DataTypeGroup::Uuid => Self::Uuid, + DataTypeGroup::Bytes => Self::Bytes, + DataTypeGroup::Bit => Self::Bit, + DataTypeGroup::Json | DataTypeGroup::Jsonb => Self::Json, + DataTypeGroup::Date => Self::Date, + DataTypeGroup::Time => Self::Time, + DataTypeGroup::Datetime => Self::Datetime, + DataTypeGroup::Inet => Self::Inet, + DataTypeGroup::MacAddress => Self::MacAddress, + DataTypeGroup::Custom => Self::Unspecified, + } + } + + fn class_name(&self) -> &'static str { + match self { + Self::Integer => "IntegerSerializer", + Self::Float => "FloatSerializer", + Self::Boolean => "BooleanSerializer", + Self::String => "StringSerializer", + Self::Uuid => "UuidSerializer", + Self::Bytes => "BytesSerializer", + Self::Bit => "BitSerializer", + Self::Json => "JSONSerializer", + Self::Date => "DateSerializer", + Self::Time => "TimeSerializer", + Self::Datetime => "DatetimeSerializer", + Self::Inet => "InetSerializer", + Self::MacAddress => "MacAddressSerializer", + Self::Unspecified => "UnspecifiedSerializer", + Self::Array(_) => "ArraySerializer", + Self::Custom(_) => "CustomSerializer", + } + } +} + +// `subclass` so the built-in serializers can `extends` this type from Rust. +// `from_py_object` so `ArraySerializer(IntegerSerializer())` extracts the +// base. Python subclasses of `Serializer` are not used as built-ins. +/// Converts a Python object into a dialect-specific SQL literal string. +/// +/// Serializers produce a correctly escaped and formatted SQL value that can +/// be embedded in queries. Each concrete subclass handles a particular +/// category of values (integers, strings, dates, arrays, etc.). Use +/// `CustomSerializer` when the built-in ones are insufficient. +/// +/// This base class cannot be instantiated. Pass a dialect class or instance +/// to `serialize()`. Unit serializers compare equal by variant; +/// `ArraySerializer` by its element tree; `CustomSerializer` by function +/// identity. +#[rapidquery_macros::pyclass( + "Serializer", + subclass, + from_py_object, + module = "rapidquery._core.data_type" +)] +#[derive(Debug, Clone)] +pub struct PySerializer(pub(super) SerializerInner); + +impl PySerializer { + pub(super) fn inferred(kind: DataTypeGroup) -> Self { + Self(SerializerInner::from_group(kind)) + } + + pub(crate) fn serialize_value( + &self, + dialect: crate::pybindings::dialect::PySqlDialect, + object: alias::BoundObject<'_>, + ) -> pyo3::PyResult { + let mut buf = String::new(); + self.0.write_sql(&dialect.0, &object, &mut buf)?; + Ok(buf) + } + + // Wrap `self` as the matching Python subclass (`IntegerSerializer()`, …). + pub(super) fn into_subclasses<'py>( + self, + py: pyo3::Python<'py>, + ) -> pyo3::PyResult> { + match &self.0 { + SerializerInner::Integer => wrap::(py, self), + SerializerInner::Float => wrap::(py, self), + SerializerInner::Boolean => wrap::(py, self), + SerializerInner::String => wrap::(py, self), + SerializerInner::Uuid => wrap::(py, self), + SerializerInner::Bytes => wrap::(py, self), + SerializerInner::Bit => wrap::(py, self), + SerializerInner::Json => wrap::(py, self), + SerializerInner::Date => wrap::(py, self), + SerializerInner::Time => wrap::(py, self), + SerializerInner::Datetime => wrap::(py, self), + SerializerInner::Inet => wrap::(py, self), + SerializerInner::MacAddress => wrap::(py, self), + SerializerInner::Unspecified => wrap::(py, self), + SerializerInner::Array(_) => wrap::(py, self), + SerializerInner::Custom(_) => wrap::(py, self), + } + } +} + +fn wrap<'py, T>( + py: pyo3::Python<'py>, + base: PySerializer, +) -> pyo3::PyResult> +where + T: pyo3::PyClass + Default, +{ + pyo3::Bound::new( + py, + pyo3::PyClassInitializer::from(base).add_subclass(T::default()), + ) + .map(|x| x.into_any()) +} + +#[pyo3::pymethods] +impl PySerializer { + /// Renders `object` as a SQL literal for `dialect`. + /// + /// Args: + /// dialect: A dialect class (`PostgreSQL`) or instance (`PostgreSQL()`). + /// object: The Python value to convert. + /// + /// Returns: + /// A SQL fragment suitable for embedding in a statement. + /// + /// Raises: + /// TypeError: If `object` is not an accepted type for this serializer. + /// ValueError: If `object` is the right type but cannot be represented + /// (for example a non-finite float). + fn serialize( + &self, + dialect: crate::pybindings::dialect::PySqlDialect, + object: alias::BoundObject<'_>, + ) -> pyo3::PyResult { + self.serialize_value(dialect, object) + } + + fn __repr__(&self) -> String { + match &self.0 { + SerializerInner::Array(element) => { + format!("ArraySerializer({}())", element.class_name()) + } + other => { + let name = other.class_name(); + let mut buf = String::with_capacity(name.len() + 2); + buf.push_str(name); + buf.push_str("()"); + buf + } + } + } + + // Bound so only instances match. + fn __eq__(&self, other: &pyo3::Bound<'_, Self>) -> bool { + self.0 == other.get().0 + } + + fn __hash__(&self) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +macro_rules! impl_unit_serializers { + ($($variant:ident => $name:ident => $pyname:literal, $doc:literal,)+) => { + $( + #[doc = $doc] + #[rapidquery_macros::pyclass( + $pyname, + extends = PySerializer, + module = "rapidquery._core.data_type" + )] + #[derive(Debug, Default)] + pub struct $name; + + #[pyo3::pymethods] + impl $name { + #[new] + #[inline] + fn __new__() -> pyo3::PyClassInitializer { + pyo3::PyClassInitializer::from(PySerializer(SerializerInner::$variant)) + .add_subclass(Self) + } + } + )+ + }; +} + +impl_unit_serializers! { + Integer => PyIntegerSerializer => "IntegerSerializer", + "Serializes Python integers as bare SQL integer literals.\n\n\ + Accepts Python `int` of any width (including values beyond 64-bit).\n\ + Rejects `bool`; `True` and `False` are not treated as integers.\n\n\ + Raises:\n \ + TypeError: If the value is not an `int`.", + Float => PyFloatSerializer => "FloatSerializer", + "Serializes numeric values as bare SQL numeric literals.\n\n\ + Accepts `float`, `int`, and `decimal.Decimal`. Finite floats are formatted\n\ + so that `1.0` stays distinct from `1`. `str` is not accepted.\n\n\ + Raises:\n \ + TypeError: If the value is not a `float`, `int`, or `decimal.Decimal`.\n \ + ValueError: If the value is a non-finite float (`nan`, `inf`, `-inf`).", + Boolean => PyBooleanSerializer => "BooleanSerializer", + "Serializes Python booleans as dialect boolean literals.\n\n\ + Accepts Python `bool` only. The dialect chooses the spelling\n\ + (`TRUE`/`FALSE` or `1`/`0`).\n\n\ + Raises:\n \ + TypeError: If the value is not a `bool`.", + String => PyStringSerializer => "StringSerializer", + "Serializes text as a quoted, escaped SQL string literal.\n\n\ + Accepts `str`, or an `enum.Enum` member whose `.value` is a `str`. The\n\ + dialect supplies quoting and escaping.\n\n\ + Raises:\n \ + TypeError: If the value is not a `str` or a string-valued enum member.", + Uuid => PyUuidSerializer => "UuidSerializer", + "Serializes UUIDs as quoted SQL string literals.\n\n\ + Accepts `uuid.UUID` or `str`.\n\n\ + Raises:\n \ + TypeError: If the value is not a `uuid.UUID` or `str`.", + Bytes => PyBytesSerializer => "BytesSerializer", + "Serializes binary data as a dialect blob/bytea literal.\n\n\ + Accepts exact `bytes` (not `bytearray` or `memoryview`).\n\n\ + Raises:\n \ + TypeError: If the value is not `bytes`.", + Bit => PyBitSerializer => "BitSerializer", + "Serializes bit strings as dialect bit-string literals.\n\n\ + Accepts a `str` consisting only of `'0'` and `'1'` characters.\n\n\ + Raises:\n \ + TypeError: If the value is not a `str`.\n \ + ValueError: If the string is empty or contains characters other than\n \ + `'0'` and `'1'`.", + Json => PyJSONSerializer => "JSONSerializer", + "Serializes a JSON-encodable object as a quoted SQL string literal.\n\n\ + Accepts any object that the JSON encoder can dump. Uses `orjson` when it\n\ + is installed, otherwise the stdlib `json` module. The JSON text is then\n\ + wrapped with the dialect's string quoting.\n\n\ + Raises:\n \ + TypeError: If the value cannot be JSON-encoded.", + Date => PyDateSerializer => "DateSerializer", + "Serializes dates as quoted `YYYY-MM-DD` literals.\n\n\ + Accepts exact `datetime.date` (not `datetime.datetime`).\n\n\ + Raises:\n \ + TypeError: If the value is not a `datetime.date`.", + Time => PyTimeSerializer => "TimeSerializer", + "Serializes times as quoted `HH:MM:SS[.ffffff]` literals.\n\n\ + Accepts `datetime.time`.\n\n\ + Raises:\n \ + TypeError: If the value is not a `datetime.time`.", + Datetime => PyDatetimeSerializer => "DatetimeSerializer", + "Serializes timestamps as quoted SQL timestamp literals.\n\n\ + Accepts naive or aware `datetime.datetime`, or `int`/`float` UTC epoch\n\ + seconds. Naive values keep wall time. Aware values keep their original\n\ + offset (they are not converted to UTC).\n\n\ + Raises:\n \ + TypeError: If the value is not a `datetime.datetime`, `int`, or `float`.\n \ + OverflowError: If an epoch value is out of range for a timestamp.", + Inet => PyInetSerializer => "InetSerializer", + "Serializes IP addresses and networks as quoted SQL literals.\n\n\ + Accepts `str` or `ipaddress` objects (`IPv4Address`, `IPv6Address`,\n\ + `IPv4Network`, `IPv6Network`, and related types whose `str()` is a valid\n\ + address or CIDR).\n\n\ + Raises:\n \ + ValueError: If the value is not a valid IPv4/IPv6 address or network.", + MacAddress => PyMacAddressSerializer => "MacAddressSerializer", + "Serializes MAC addresses as quoted SQL literals.\n\n\ + Accepts a `str` using `:` or `-` as the separator.\n\n\ + Raises:\n \ + ValueError: If the value is not a valid MAC address.", + Unspecified => PyUnspecifiedSerializer => "UnspecifiedSerializer", + "Inserts `str(obj)` into SQL with no quoting or type check.\n\n\ + Used when no serializer could be inferred from the type name. The result\n\ + is not a safe SQL value; prefer an explicit serializer.", +} + +/// Serializes sequences as `ARRAY[elem, ...]` literals. +/// +/// Each element is serialized independently with `element`. Accepts `list` +/// or `tuple`. Nested arrays are expressed by wrapping another +/// `ArraySerializer`. +#[rapidquery_macros::pyclass( + "ArraySerializer", + extends = PySerializer, + module = "rapidquery._core.data_type" +)] +#[derive(Debug, Default)] +pub struct PyArraySerializer; + +#[pyo3::pymethods] +impl PyArraySerializer { + /// Creates an array serializer. + /// + /// Args: + /// element: The serializer applied to every array item. Nested arrays + /// are expressed by passing another `ArraySerializer`. + #[new] + #[inline] + fn __new__(element: PySerializer) -> pyo3::PyClassInitializer { + let inner = SerializerInner::Array(Box::new(element.0)); + pyo3::PyClassInitializer::from(PySerializer(inner)).add_subclass(Self) + } + + /// Returns the serializer used for each array element. + /// + /// Returns: + /// The `Serializer` passed to the constructor. + #[getter] + fn get_element(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let base = slf.as_super(); + match &base.0 { + SerializerInner::Array(element) => { + PySerializer((**element).clone()).into_subclasses(slf.py()) + } + _ => unreachable!("ArraySerializer base is always Array"), + } + } +} + +/// Serializes values by calling a user-supplied Python function. +/// +/// `function` has the form `fn(dialect_name: str, obj) -> str`. The return +/// value is inserted into SQL as-is (not re-quoted or escaped). It is the +/// caller's responsibility to produce a safe fragment. +#[rapidquery_macros::pyclass( + "CustomSerializer", + extends = PySerializer, + module = "rapidquery._core.data_type" +)] +#[derive(Debug, Default)] +pub struct PyCustomSerializer; + +#[pyo3::pymethods] +impl PyCustomSerializer { + /// Creates a serializer backed by a Python callable. + /// + /// Args: + /// function: A callable of the form `fn(dialect_name: str, obj) -> str`. + /// The return value is inserted into SQL as-is (not re-quoted). + /// Must return a `str`. + #[new] + fn __new__(function: alias::PyObject) -> pyo3::PyResult> { + unsafe { + if pyo3::ffi::PyCallable_Check(function.as_ptr()) != 1 { + return rapidquery_macros::pyerr!( + Err(PyTypeError), + "the 'function' parameter must be callable" + ); + } + } + let inner = SerializerInner::Custom(function); + Ok(pyo3::PyClassInitializer::from(PySerializer(inner)).add_subclass(Self)) + } + + /// Returns the callable passed to the constructor. + /// + /// Returns: + /// The function given as `function` to `__new__`. + #[getter] + fn get_function(slf: pyo3::PyRef<'_, Self>) -> alias::PyObject { + match &slf.as_super().0 { + SerializerInner::Custom(function) => function.clone(), + _ => unreachable!("CustomSerializer base is always Custom"), + } + } +} diff --git a/src/pybindings/mod.rs b/src/pybindings/mod.rs index f32a0bf..6820dba 100644 --- a/src/pybindings/mod.rs +++ b/src/pybindings/mod.rs @@ -2,7 +2,10 @@ //! //! Native types stay PyO3-free. This module is the only place that defines //! `#[pyclass]` types and the `rapidquery._core.*` submodules they live in. -//! Keep that split as Ident / DataType / statement builders land. +//! Serializers live here because they inspect Python values. +pub mod data_type; pub mod dialect; + +pub use data_type::data_type_mod; pub use dialect::dialect_mod; diff --git a/src/typeref.rs b/src/typeref.rs index da163c4..d0afa5e 100644 --- a/src/typeref.rs +++ b/src/typeref.rs @@ -15,7 +15,29 @@ pub(crate) static mut SPARK_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::pt pub(crate) static mut SQLITE_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); pub(crate) static mut TERADATA_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -// Python standard-library types (serializers; unused by dialect extractors) +// Data-type / serializer types +pub(crate) static mut DATATYPE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut INTEGER_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut FLOAT_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut BOOLEAN_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut STRING_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut UUID_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut BYTES_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut BIT_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut JSON_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut DATE_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut TIME_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut DATETIME_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut INET_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut MACADDRESS_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = + std::ptr::null_mut(); +pub(crate) static mut UNSPECIFIED_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = + std::ptr::null_mut(); +pub(crate) static mut ARRAY_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut CUSTOM_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); + +// Python standard-library types (serializers) pub(crate) static mut STD_DECIMAL_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); pub(crate) static mut STD_UUID_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); pub(crate) static mut STD_DATETIME_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); @@ -81,6 +103,25 @@ fn _initialize_typeref(py: pyo3::Python) { crate::pybindings::dialect::PySpark => SPARK_DIALECT_TYPE, crate::pybindings::dialect::PySqlite => SQLITE_DIALECT_TYPE, crate::pybindings::dialect::PyTeradata => TERADATA_DIALECT_TYPE, + // data types / serializers + crate::pybindings::data_type::PyDataType => DATATYPE_TYPE, + crate::pybindings::data_type::PySerializer => SERIALIZER_TYPE, + crate::pybindings::data_type::PyIntegerSerializer => INTEGER_SERIALIZER_TYPE, + crate::pybindings::data_type::PyFloatSerializer => FLOAT_SERIALIZER_TYPE, + crate::pybindings::data_type::PyBooleanSerializer => BOOLEAN_SERIALIZER_TYPE, + crate::pybindings::data_type::PyStringSerializer => STRING_SERIALIZER_TYPE, + crate::pybindings::data_type::PyUuidSerializer => UUID_SERIALIZER_TYPE, + crate::pybindings::data_type::PyBytesSerializer => BYTES_SERIALIZER_TYPE, + crate::pybindings::data_type::PyBitSerializer => BIT_SERIALIZER_TYPE, + crate::pybindings::data_type::PyJSONSerializer => JSON_SERIALIZER_TYPE, + crate::pybindings::data_type::PyDateSerializer => DATE_SERIALIZER_TYPE, + crate::pybindings::data_type::PyTimeSerializer => TIME_SERIALIZER_TYPE, + crate::pybindings::data_type::PyDatetimeSerializer => DATETIME_SERIALIZER_TYPE, + crate::pybindings::data_type::PyInetSerializer => INET_SERIALIZER_TYPE, + crate::pybindings::data_type::PyMacAddressSerializer => MACADDRESS_SERIALIZER_TYPE, + crate::pybindings::data_type::PyUnspecifiedSerializer => UNSPECIFIED_SERIALIZER_TYPE, + crate::pybindings::data_type::PyArraySerializer => ARRAY_SERIALIZER_TYPE, + crate::pybindings::data_type::PyCustomSerializer => CUSTOM_SERIALIZER_TYPE, ); STD_DECIMAL_TYPE = look_up_type_object(c"decimal", c"Decimal"); diff --git a/tests/test_array_serializer.py b/tests/test_array_serializer.py new file mode 100644 index 0000000..9a3c75d --- /dev/null +++ b/tests/test_array_serializer.py @@ -0,0 +1,102 @@ +import pytest + +from rapidquery.data_type import ( + ArraySerializer, + IntegerSerializer, + Serializer, + StringSerializer, +) + + +class TestArraySerializerConstruction: + def test_is_serializer_subclass(self): + assert issubclass(ArraySerializer, Serializer) + + def test_constructs_with_element_serializer(self): + # Should not raise. + ArraySerializer(IntegerSerializer()) + + def test_element_property_returns_the_given_serializer_type(self): + arr = ArraySerializer(IntegerSerializer()) + assert isinstance(arr.element, IntegerSerializer) + + def test_element_property_reflects_string_serializer(self): + arr = ArraySerializer(StringSerializer()) + assert isinstance(arr.element, StringSerializer) + + def test_rejects_non_serializer_element(self): + with pytest.raises(TypeError): + ArraySerializer("not-a-serializer") # type: ignore + + def test_nested_array_serializer(self): + """ArraySerializer should be composable (array of arrays).""" + inner = ArraySerializer(IntegerSerializer()) + outer = ArraySerializer(inner) + assert isinstance(outer.element, ArraySerializer) + assert isinstance(outer.element.element, IntegerSerializer) + + +class TestArraySerializerIntendedBehaviour: + def test_serializes_each_int_element_independently(self, dialect_cls): + arr = ArraySerializer(IntegerSerializer()) + result = arr.serialize(dialect_cls, [1, 2, 3]) + assert result == "ARRAY[1, 2, 3]" + + def test_serializes_tuple_same_as_list(self, dialect_cls): + arr = ArraySerializer(IntegerSerializer()) + list_result = arr.serialize(dialect_cls, [1, 2, 3]) + tuple_result = arr.serialize(dialect_cls, (1, 2, 3)) + assert list_result == tuple_result == "ARRAY[1, 2, 3]" + + def test_serializes_string_elements_with_correct_individual_quoting( + self, dialect_cls + ): + arr = ArraySerializer(StringSerializer()) + result = arr.serialize(dialect_cls, ["a", "b"]) + # Each element must be independently quoted, not the whole list. + assert result.count("a") == 1 + assert result.count("b") == 1 + + def test_empty_array(self, dialect_cls): + arr = ArraySerializer(IntegerSerializer()) + result = arr.serialize(dialect_cls, []) + assert result == "ARRAY[]" + + def test_single_element_array(self, dialect_cls): + arr = ArraySerializer(IntegerSerializer()) + result = arr.serialize(dialect_cls, [42]) + assert result == "ARRAY[42]" + + def test_element_type_error_propagates_with_correct_element_identified( + self, dialect_cls + ): + """If a single element is the wrong type for the element serializer, + the error should reflect that one bad element, not the container. + """ + arr = ArraySerializer(IntegerSerializer()) + with pytest.raises(TypeError): + arr.serialize(dialect_cls, [1, "not an int", 3]) + + def test_wraps_result_in_array_literal_prefix(self, dialect_cls): + arr = ArraySerializer(IntegerSerializer()) + result = arr.serialize(dialect_cls, [1]) + assert result.startswith("ARRAY[") and result.endswith("]") + + +class TestArraySerializerContainerValidation: + """These parts of the contract are independent of the per-element bug: + input-type validation happens before the (buggy) per-element loop, so + rejecting a bad *container* type still works correctly today. + """ + + @pytest.mark.parametrize("value", ["not a list", 123, None, {"a": 1}]) + def test_rejects_non_list_tuple_container(self, value, dialect_cls): + arr = ArraySerializer(IntegerSerializer()) + with pytest.raises(TypeError): + arr.serialize(dialect_cls, value) + + def test_rejects_set_as_container(self, dialect_cls): + """Only list/tuple are documented as accepted containers, not set.""" + arr = ArraySerializer(IntegerSerializer()) + with pytest.raises(TypeError): + arr.serialize(dialect_cls, {1, 2, 3}) diff --git a/tests/test_custom_serializer.py b/tests/test_custom_serializer.py new file mode 100644 index 0000000..ba4e629 --- /dev/null +++ b/tests/test_custom_serializer.py @@ -0,0 +1,88 @@ +import pytest + +from rapidquery.data_type import CustomSerializer, Serializer + + +class TestCustomSerializerConstruction: + def test_is_serializer_subclass(self): + assert issubclass(CustomSerializer, Serializer) + + def test_constructs_with_callable(self): + CustomSerializer(lambda dialect, obj: str(obj)) + + def test_function_getter_returns_the_same_callable(self): + def my_func(dialect, obj): + return str(obj) + + serializer = CustomSerializer(my_func) + assert serializer.function is my_func + + @pytest.mark.parametrize("value", [None, 123, "not callable", [1, 2, 3]]) + def test_rejects_non_callable(self, value): + with pytest.raises(TypeError): + CustomSerializer(value) + + def test_accepts_any_callable_object_not_just_functions(self): + class Callable: + def __call__(self, dialect, obj): + return "called" + + # Should not raise. + CustomSerializer(Callable()) + + +class TestCustomSerializerInvocation: + def test_calls_function_with_object_and_dialect_name(self, dialect_cls): + captured = {} + + def capture(dialect_name, obj): + captured["obj"] = obj + captured["dialect_name"] = dialect_name + return "ok" + + CustomSerializer(capture).serialize(dialect_cls, 42) + + assert captured["obj"] == 42 + assert captured["dialect_name"] == dialect_cls().name + + def test_returns_the_function_result(self, dialect_cls): + serializer = CustomSerializer(lambda dialect, obj: f"CUSTOM({obj})") + result = serializer.serialize(dialect_cls, "value") + assert result == "CUSTOM(value)" + + def test_dialect_name_differs_per_dialect(self): + from rapidquery.dialect import MySql, PostgreSQL + + seen_names = [] + + def capture(dialect_name, obj): + seen_names.append(dialect_name) + return "ok" + + serializer = CustomSerializer(capture) + serializer.serialize(PostgreSQL, "x") + serializer.serialize(MySql, "x") + + assert seen_names[0] != seen_names[1] + + def test_exception_inside_function_propagates(self, dialect_cls): + def failing(dialect, obj): + raise ValueError("boom") + + with pytest.raises(ValueError, match="boom"): + CustomSerializer(failing).serialize(dialect_cls, "x") + + def test_non_string_return_value_is_rejected(self, dialect_cls): + serializer = CustomSerializer(lambda dialect, obj: 12345) # type: ignore + with pytest.raises(TypeError): + serializer.serialize(dialect_cls, "x") + + def test_none_return_value_is_rejected(self, dialect_cls): + serializer = CustomSerializer(lambda dialect, obj: None) # type: ignore + with pytest.raises(TypeError): + serializer.serialize(dialect_cls, "x") + + def test_can_be_used_to_implement_arbitrary_formatting(self, dialect_cls): + serializer = CustomSerializer(lambda dialect, obj: f"'{str(obj).upper()}'") + result = serializer.serialize(dialect_cls, "hello") + assert result == "'HELLO'" diff --git a/tests/test_data_type.py b/tests/test_data_type.py new file mode 100644 index 0000000..e2af0fd --- /dev/null +++ b/tests/test_data_type.py @@ -0,0 +1,207 @@ +import pytest + +from rapidquery.data_type import ( + BitSerializer, + BooleanSerializer, + BytesSerializer, + DataType, + DateSerializer, + DatetimeSerializer, + FloatSerializer, + InetSerializer, + IntegerSerializer, + JSONSerializer, + MacAddressSerializer, + Serializer, + StringSerializer, + TimeSerializer, + UnspecifiedSerializer, + UuidSerializer, +) +from rapidquery.dialect import PostgreSQL + + +class TestDataTypeConstruction: + def test_constructs_with_name_only(self): + dt = DataType("integer") + assert dt.name == "INTEGER" or dt.name.lower() == "integer" + + def test_constructs_with_explicit_serializer(self): + dt = DataType("my_custom_type", StringSerializer()) + assert isinstance(dt.serializer, StringSerializer) + + def test_name_property_returns_str(self): + dt = DataType("varchar") + assert isinstance(dt.name, str) + + def test_serializer_property_returns_serializer_instance(self): + dt = DataType("integer") + assert isinstance(dt.serializer, Serializer) + + def test_explicit_serializer_overrides_inference(self): + dt = DataType("integer", StringSerializer()) + assert isinstance(dt.serializer, StringSerializer) + assert not isinstance(dt.serializer, IntegerSerializer) + + +class TestDataTypeNameNormalization: + @pytest.mark.parametrize( + "input_name,canonical", + [ + ("int", "INT"), + ("integer", "INTEGER"), + ("varchar", "VARCHAR"), + ("text", "TEXT"), + ("uuid", "UUID"), + ], + ) + def test_known_names_are_uppercased_to_canonical_form(self, input_name, canonical): + dt = DataType(input_name) + assert dt.name == canonical + + def test_name_matching_is_case_insensitive(self): + assert ( + DataType("INTEGER").serializer.__class__ + == DataType("integer").serializer.__class__ + ) + assert ( + DataType("Integer").serializer.__class__ + == DataType("integer").serializer.__class__ + ) + + def test_unrecognized_name_is_preserved_as_is(self): + dt = DataType("some_totally_unknown_type") + assert dt.name == "some_totally_unknown_type" + + def test_parameterized_name_is_preserved_as_is(self): + dt = DataType("VARCHAR(255)") + assert dt.name == "VARCHAR(255)" + + def test_parameterized_name_is_not_alias_matched(self): + dt = DataType("varchar(255)") + assert dt.name == "varchar(255)" + + +class TestDataTypeSerializerInference: + """`DataType(name).serializer` with no explicit serializer should infer + the correct serializer type from the SQL type name's group. + """ + + @pytest.mark.parametrize( + "name", + [ + "tinyint", + "smallint", + "mediumint", + "int", + "integer", + "bigint", + "int8", + "hugeint", + "unsigned", + ], + ) + def test_integer_family_infers_integer_serializer(self, name): + assert isinstance(DataType(name).serializer, IntegerSerializer) + + @pytest.mark.parametrize( + "name", + [ + "float", + "double", + "decimal", + "numeric", + "real", + "double precision", + "bignumeric", + ], + ) + def test_float_family_infers_float_serializer(self, name): + assert isinstance(DataType(name).serializer, FloatSerializer) + + @pytest.mark.parametrize( + "name", + ["char", "varchar", "text", "clob", "nvarchar", "string", "longtext"], + ) + def test_string_family_infers_string_serializer(self, name): + assert isinstance(DataType(name).serializer, StringSerializer) + + def test_uuid_infers_uuid_serializer(self): + assert isinstance(DataType("uuid").serializer, UuidSerializer) + + @pytest.mark.parametrize("name", ["binary", "varbinary", "blob", "bytes", "bytea"]) + def test_bytes_family_infers_bytes_serializer(self, name): + assert isinstance(DataType(name).serializer, BytesSerializer) + + @pytest.mark.parametrize("name", ["bit", "bit varying", "varbit"]) + def test_bit_family_infers_bit_serializer(self, name): + assert isinstance(DataType(name).serializer, BitSerializer) + + @pytest.mark.parametrize("name", ["date"]) + def test_date_infers_date_serializer(self, name): + assert isinstance(DataType(name).serializer, DateSerializer) + + @pytest.mark.parametrize("name", ["time", "time with timezone"]) + def test_time_family_infers_time_serializer(self, name): + assert isinstance(DataType(name).serializer, TimeSerializer) + + @pytest.mark.parametrize( + "name", ["datetime", "timestamp", "timestamp with timezone", "timestampntz"] + ) + def test_datetime_family_infers_datetime_serializer(self, name): + assert isinstance(DataType(name).serializer, DatetimeSerializer) + + @pytest.mark.parametrize("name", ["json", "jsonb"]) + def test_json_family_infers_json_serializer(self, name): + assert isinstance(DataType(name).serializer, JSONSerializer) + + def test_inet_infers_inet_serializer(self): + assert isinstance(DataType("inet").serializer, InetSerializer) + + @pytest.mark.parametrize("name", ["macaddress", "macaddr"]) + def test_macaddress_family_infers_macaddress_serializer(self, name): + assert isinstance(DataType(name).serializer, MacAddressSerializer) + + @pytest.mark.parametrize("name", ["any", "trigger", "unspecified"]) + def test_custom_group_names_infer_unspecified_serializer(self, name): + assert isinstance(DataType(name).serializer, UnspecifiedSerializer) + + def test_unrecognized_name_infers_unspecified_serializer(self): + assert isinstance(DataType("not_a_real_type").serializer, UnspecifiedSerializer) + + def test_parameterized_name_infers_unspecified_serializer(self): + """Parameterized names skip the alias table entirely -> CUSTOM group + -> UnspecifiedSerializer, even if the base name (e.g. 'varchar') + would otherwise be recognized.""" + assert isinstance(DataType("varchar(255)").serializer, UnspecifiedSerializer) + + def test_boolean_infers_boolean_serializer(self): + assert isinstance(DataType("boolean").serializer, BooleanSerializer) + + def test_bool_alias_infers_boolean_serializer(self): + assert isinstance(DataType("bool").serializer, BooleanSerializer) + + def test_boolean_serializer_correctly_rejects_non_bool_int(self): + dt = DataType("boolean") + with pytest.raises(TypeError): + dt.serialize(PostgreSQL, 1) + + +class TestDataTypeSerializeDelegation: + def test_serialize_delegates_to_serializer(self): + dt = DataType("integer") + assert dt.serialize(PostgreSQL, 42) == "42" + + def test_serialize_with_explicit_custom_serializer(self): + dt = DataType("weird_type", StringSerializer()) + result = dt.serialize(PostgreSQL, "hi") + assert "hi" in result + + def test_serialize_propagates_type_errors_from_serializer(self): + dt = DataType("integer") + with pytest.raises(TypeError): + dt.serialize(PostgreSQL, "not an int") + + def test_serialize_accepts_dialect_class_or_instance(self): + dt = DataType("integer") + assert dt.serialize(PostgreSQL, 1) == dt.serialize(PostgreSQL(), 1) diff --git a/tests/test_serializers.py b/tests/test_serializers.py new file mode 100644 index 0000000..0cb8f99 --- /dev/null +++ b/tests/test_serializers.py @@ -0,0 +1,614 @@ +import datetime +import ipaddress +import uuid +from decimal import Decimal + +import pytest + +from rapidquery.data_type import ( + ArraySerializer, + BitSerializer, + BooleanSerializer, + BytesSerializer, + CustomSerializer, + DateSerializer, + DatetimeSerializer, + FloatSerializer, + InetSerializer, + IntegerSerializer, + JSONSerializer, + MacAddressSerializer, + Serializer, + StringSerializer, + TimeSerializer, + UnspecifiedSerializer, + UuidSerializer, +) +from rapidquery.dialect import PostgreSQL, SqlDialect + +# --------------------------------------------------------------------------- +# IntegerSerializer +# --------------------------------------------------------------------------- + + +class TestIntegerSerializer: + def test_is_serializer_subclass(self): + assert issubclass(IntegerSerializer, Serializer) + + @pytest.mark.parametrize("value", [0, 1, -1, 42, -42, 2**63, -(2**63), 10**30]) + def test_serializes_ints_as_bare_decimal_literal( + self, value, dialect_cls: type[SqlDialect] + ): + result = IntegerSerializer().serialize(dialect_cls, value) + assert result == str(value) + + def test_accepts_dialect_instance_as_well_as_class(self): + cls_result = IntegerSerializer().serialize(PostgreSQL, 5) + instance_result = IntegerSerializer().serialize(PostgreSQL(), 5) + assert cls_result == instance_result == "5" + + def test_arbitrary_precision_is_preserved(self, dialect_cls: type[SqlDialect]): + big = 10**40 + assert IntegerSerializer().serialize(dialect_cls, big) == str(big) + + def test_rejects_bool_despite_bool_being_a_python_int_subclass( + self, dialect_cls: type[SqlDialect] + ): + """bool is technically an int in Python, but must not be accepted here.""" + with pytest.raises(TypeError): + IntegerSerializer().serialize(dialect_cls, True) + + @pytest.mark.parametrize("value", [1.5, "5", None, [1], Decimal("5")]) + def test_rejects_non_int_types(self, value, dialect_cls: type[SqlDialect]): + with pytest.raises(TypeError): + IntegerSerializer().serialize(dialect_cls, value) + + +# --------------------------------------------------------------------------- +# FloatSerializer +# --------------------------------------------------------------------------- + + +class TestFloatSerializer: + def test_is_serializer_subclass(self): + assert issubclass(FloatSerializer, Serializer) + + def test_serializes_float_as_numeric_literal(self, dialect_cls: type[SqlDialect]): + result = FloatSerializer().serialize(dialect_cls, 3.14) + assert result == "3.14" + # Must round-trip back to the same float (no precision loss). + assert float(result) == 3.14 + + def test_serializes_int_via_its_exact_text_form( + self, dialect_cls: type[SqlDialect] + ): + assert FloatSerializer().serialize(dialect_cls, 42) == "42" + + def test_serializes_decimal_via_its_exact_text_form( + self, dialect_cls: type[SqlDialect] + ): + result = FloatSerializer().serialize(dialect_cls, Decimal("1.23456789012345")) + assert result == "1.23456789012345" + + @pytest.mark.parametrize("bad", ["abc", "", " ", "1.2.3-oops", "3.14"]) + def test_rejects_non_numeric_strings(self, bad, dialect_cls: type[SqlDialect]): + with pytest.raises(TypeError): + FloatSerializer().serialize(dialect_cls, bad) + + @pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")]) + def test_rejects_non_finite_floats(self, value, dialect_cls: type[SqlDialect]): + with pytest.raises(ValueError): + FloatSerializer().serialize(dialect_cls, value) + + def test_float_round_trips_without_ambiguous_int_form( + self, dialect_cls: type[SqlDialect] + ): + """1.0 must serialize distinguishably from the integer 1 (per doc comment).""" + result = FloatSerializer().serialize(dialect_cls, 1.0) + assert result != "1" + + @pytest.mark.parametrize("value", [None, [1.0], b"1.0"]) + def test_rejects_unsupported_types(self, value, dialect_cls: type[SqlDialect]): + with pytest.raises(TypeError): + FloatSerializer().serialize(dialect_cls, value) + + +# --------------------------------------------------------------------------- +# BooleanSerializer +# --------------------------------------------------------------------------- + + +class TestBooleanSerializer: + def test_is_serializer_subclass(self): + assert issubclass(BooleanSerializer, Serializer) + + def test_serializes_true(self, dialect_cls: type[SqlDialect]): + result = BooleanSerializer().serialize(dialect_cls, True) + assert isinstance(result, str) + assert result != "" + + def test_serializes_false(self, dialect_cls: type[SqlDialect]): + result = BooleanSerializer().serialize(dialect_cls, False) + assert isinstance(result, str) + assert result != "" + + def test_true_and_false_serialize_differently(self, dialect_cls: type[SqlDialect]): + s = BooleanSerializer() + assert s.serialize(dialect_cls, True) != s.serialize(dialect_cls, False) + + @pytest.mark.parametrize("value", [1, 0, "true", "false", None, 1.0]) + def test_rejects_non_bool_types(self, value, dialect_cls: type[SqlDialect]): + """Only actual Python bool is accepted, not truthy/falsy look-alikes.""" + with pytest.raises(TypeError): + BooleanSerializer().serialize(dialect_cls, value) + + +# --------------------------------------------------------------------------- +# StringSerializer +# --------------------------------------------------------------------------- + + +class TestStringSerializer: + def test_is_serializer_subclass(self): + assert issubclass(StringSerializer, Serializer) + + def test_serializes_str_as_quoted_literal(self, dialect_cls: type[SqlDialect]): + result = StringSerializer().serialize(dialect_cls, "hello") + assert isinstance(result, str) + assert "hello" in result + # A string literal must be visually distinguishable/quoted, not bare. + assert result != "hello" + + def test_empty_string_still_quoted(self, dialect_cls: type[SqlDialect]): + result = StringSerializer().serialize(dialect_cls, "") + assert result != "" + + def test_single_quote_is_escaped(self, dialect_cls: type[SqlDialect]): + """A literal single quote inside the value must not break out of the + surrounding quotes (classic SQL-injection-shaped correctness bug).""" + result = StringSerializer().serialize(dialect_cls, "O'Brien") + # The escaped form must contain a doubled/escaped quote, not a bare + # unescaped one that would prematurely close the string literal. + assert "O'Brien" not in result or "O''Brien" in result or "O\\'Brien" in result + + def test_serializes_str_enum_member_via_its_value( + self, dialect_cls: type[SqlDialect] + ): + import enum + + class Color(str, enum.Enum): + RED = "red" + + result = StringSerializer().serialize(dialect_cls, Color.RED) + assert "red" in result + + @pytest.mark.parametrize("value", [123, None, [1, 2], b"bytes"]) + def test_rejects_non_str_types(self, value, dialect_cls: type[SqlDialect]): + with pytest.raises(TypeError): + StringSerializer().serialize(dialect_cls, value) + + +# --------------------------------------------------------------------------- +# UuidSerializer +# --------------------------------------------------------------------------- + + +class TestUuidSerializer: + def test_is_serializer_subclass(self): + assert issubclass(UuidSerializer, Serializer) + + def test_serializes_uuid_object(self, dialect_cls: type[SqlDialect]): + value = uuid.uuid4() + result = UuidSerializer().serialize(dialect_cls, value) + assert str(value) in result + + def test_serializes_uuid_string(self, dialect_cls: type[SqlDialect]): + value = str(uuid.uuid4()) + result = UuidSerializer().serialize(dialect_cls, value) + assert value in result + + def test_result_is_quoted(self, dialect_cls: type[SqlDialect]): + value = uuid.uuid4() + result = UuidSerializer().serialize(dialect_cls, value) + assert result != str(value) + + @pytest.mark.parametrize("value", [123, None, [1, 2], b"bytes"]) + def test_rejects_unsupported_types(self, value, dialect_cls: type[SqlDialect]): + with pytest.raises(TypeError): + UuidSerializer().serialize(dialect_cls, value) + + +# --------------------------------------------------------------------------- +# BytesSerializer +# --------------------------------------------------------------------------- + + +class TestBytesSerializer: + def test_is_serializer_subclass(self): + assert issubclass(BytesSerializer, Serializer) + + def test_serializes_bytes(self, dialect_cls: type[SqlDialect]): + result = BytesSerializer().serialize(dialect_cls, b"\x00\x01\xff") + assert isinstance(result, str) + + def test_empty_bytes(self, dialect_cls: type[SqlDialect]): + result = BytesSerializer().serialize(dialect_cls, b"") + assert isinstance(result, str) + + def test_different_bytes_serialize_differently(self, dialect_cls: type[SqlDialect]): + s = BytesSerializer() + assert s.serialize(dialect_cls, b"abc") != s.serialize(dialect_cls, b"xyz") + + @pytest.mark.parametrize("value", ["not bytes", 123, None, bytearray(b"abc")]) + def test_rejects_non_exact_bytes_types(self, value, dialect_cls: type[SqlDialect]): + """Only `bytes`, not `str`/`bytearray`/other buffer-like types.""" + with pytest.raises(TypeError): + BytesSerializer().serialize(dialect_cls, value) + + +# --------------------------------------------------------------------------- +# BitSerializer +# --------------------------------------------------------------------------- + + +class TestBitSerializer: + def test_is_serializer_subclass(self): + assert issubclass(BitSerializer, Serializer) + + @pytest.mark.parametrize("value", ["0", "1", "1010", "000", "111111"]) + def test_serializes_valid_bit_strings(self, value, dialect_cls: type[SqlDialect]): + result = BitSerializer().serialize(dialect_cls, value) + assert isinstance(result, str) + + as_int = int(value, base=2) + + assert value in result or str(as_int) in result + + def test_rejects_empty_string(self, dialect_cls: type[SqlDialect]): + with pytest.raises(ValueError): + BitSerializer().serialize(dialect_cls, "") + + @pytest.mark.parametrize("bad", ["102", "abc", "1 0", "2", "-1"]) + def test_rejects_non_binary_content(self, bad, dialect_cls: type[SqlDialect]): + with pytest.raises(ValueError): + BitSerializer().serialize(dialect_cls, bad) + + @pytest.mark.parametrize("value", [10, None, [1, 0]]) + def test_rejects_non_str_types(self, value, dialect_cls: type[SqlDialect]): + with pytest.raises(TypeError): + BitSerializer().serialize(dialect_cls, value) + + +# --------------------------------------------------------------------------- +# JSONSerializer +# --------------------------------------------------------------------------- + + +class TestJSONSerializer: + def test_is_serializer_subclass(self): + assert issubclass(JSONSerializer, Serializer) + + @pytest.mark.parametrize( + "value", + [ + {"a": 1, "b": [1, 2, 3]}, + [1, 2, 3], + "plain string", + 123, + 123.45, + True, + None, + ], + ) + def test_serializes_json_encodable_values( + self, value, dialect_cls: type[SqlDialect] + ): + result = JSONSerializer().serialize(dialect_cls, value) + assert isinstance(result, str) + assert result != "" + + def test_result_is_quoted_as_a_string_literal(self, dialect_cls: type[SqlDialect]): + result = JSONSerializer().serialize(dialect_cls, {"a": 1}) + # The JSON text itself must be wrapped as a SQL string literal. + assert result != '{"a": 1}' + + def test_dict_contents_are_present_in_output(self, dialect_cls: type[SqlDialect]): + result = JSONSerializer().serialize(dialect_cls, {"key": "value"}) + assert "key" in result and "value" in result + + def test_rejects_non_json_encodable_values(self, dialect_cls: type[SqlDialect]): + class NotEncodable: + pass + + with pytest.raises(TypeError): + JSONSerializer().serialize(dialect_cls, NotEncodable()) + + +# --------------------------------------------------------------------------- +# DateSerializer +# --------------------------------------------------------------------------- + + +class TestDateSerializer: + def test_is_serializer_subclass(self): + assert issubclass(DateSerializer, Serializer) + + def test_serializes_date_as_iso_literal(self, dialect_cls: type[SqlDialect]): + result = DateSerializer().serialize(dialect_cls, datetime.date(2024, 1, 15)) + assert "2024-01-15" in result + + def test_result_is_quoted(self, dialect_cls: type[SqlDialect]): + result = DateSerializer().serialize(dialect_cls, datetime.date(2024, 1, 15)) + assert result != "2024-01-15" + + def test_rejects_datetime_as_not_exactly_date(self, dialect_cls: type[SqlDialect]): + """A `datetime.datetime` is technically a `date` subclass in Python, + but the serializer should require an exact `date` (see e.g. the + exact-type checks used throughout the other serializers).""" + with pytest.raises(TypeError): + DateSerializer().serialize( + dialect_cls, datetime.datetime(2024, 1, 15, 10, 0) + ) + + @pytest.mark.parametrize("value", ["2024-01-15", None, 20240115]) + def test_rejects_non_date_types(self, value, dialect_cls: type[SqlDialect]): + with pytest.raises(TypeError): + DateSerializer().serialize(dialect_cls, value) + + +# --------------------------------------------------------------------------- +# TimeSerializer +# --------------------------------------------------------------------------- + + +class TestTimeSerializer: + def test_is_serializer_subclass(self): + assert issubclass(TimeSerializer, Serializer) + + def test_serializes_time_as_hms_literal(self, dialect_cls: type[SqlDialect]): + result = TimeSerializer().serialize(dialect_cls, datetime.time(13, 45, 30)) + assert "13:45:30" in result + + def test_serializes_time_with_microseconds(self, dialect_cls: type[SqlDialect]): + result = TimeSerializer().serialize(dialect_cls, datetime.time(1, 2, 3, 456789)) + assert "01:02:03" in result + + def test_result_is_quoted(self, dialect_cls: type[SqlDialect]): + result = TimeSerializer().serialize(dialect_cls, datetime.time(13, 45, 30)) + assert result != "13:45:30" + + @pytest.mark.parametrize("value", ["13:45:30", None, datetime.date(2024, 1, 1)]) + def test_rejects_non_time_types(self, value, dialect_cls: type[SqlDialect]): + with pytest.raises(TypeError): + TimeSerializer().serialize(dialect_cls, value) + + +# --------------------------------------------------------------------------- +# DatetimeSerializer +# --------------------------------------------------------------------------- + + +class TestDatetimeSerializer: + def test_is_serializer_subclass(self): + assert issubclass(DatetimeSerializer, Serializer) + + def test_serializes_naive_datetime(self, dialect_cls: type[SqlDialect]): + result = DatetimeSerializer().serialize( + dialect_cls, datetime.datetime(2024, 1, 15, 10, 30, 0) + ) + assert "2024-01-15" in result and "10:30:00" in result + + def test_serializes_aware_datetime(self, dialect_cls: type[SqlDialect]): + value = datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc) + result = DatetimeSerializer().serialize(dialect_cls, value) + assert "2024-01-15" in result and "10:30:00" in result + + def test_accepts_int_as_utc_epoch(self, dialect_cls: type[SqlDialect]): + result = DatetimeSerializer().serialize(dialect_cls, 0) + assert "1970-01-01" in result + + def test_accepts_float_as_utc_epoch(self, dialect_cls: type[SqlDialect]): + result = DatetimeSerializer().serialize(dialect_cls, 0.0) + assert "1970-01-01" in result + + def test_result_is_quoted(self, dialect_cls: type[SqlDialect]): + result = DatetimeSerializer().serialize( + dialect_cls, datetime.datetime(2024, 1, 15, 10, 30, 0) + ) + assert result != "2024-01-15 10:30:00" + + @pytest.mark.parametrize("value", ["2024-01-15T10:30:00", None, [2024, 1, 15]]) + def test_rejects_unsupported_types(self, value, dialect_cls: type[SqlDialect]): + with pytest.raises(TypeError): + DatetimeSerializer().serialize(dialect_cls, value) + + +# --------------------------------------------------------------------------- +# InetSerializer +# --------------------------------------------------------------------------- + + +class TestInetSerializer: + def test_is_serializer_subclass(self): + assert issubclass(InetSerializer, Serializer) + + @pytest.mark.parametrize( + "value", + [ + ipaddress.ip_address("192.168.1.1"), + "192.168.1.1", + "192.168.1.0/24", + "::1", + "2001:db8::/32", + ], + ) + def test_serializes_valid_ip_networks(self, value, dialect_cls: type[SqlDialect]): + result = InetSerializer().serialize(dialect_cls, value) + assert isinstance(result, str) + assert result != "" + + def test_result_is_quoted(self, dialect_cls: type[SqlDialect]): + result = InetSerializer().serialize(dialect_cls, "192.168.1.1") + assert result != "192.168.1.1" + + @pytest.mark.parametrize("bad", ["not-an-ip", "999.999.999.999", ""]) + def test_rejects_invalid_ip_strings(self, bad, dialect_cls: type[SqlDialect]): + with pytest.raises(ValueError): + InetSerializer().serialize(dialect_cls, bad) + + @pytest.mark.parametrize("value", [None, 12345]) + def test_rejects_non_str_types(self, value, dialect_cls: type[SqlDialect]): + with pytest.raises(ValueError): + InetSerializer().serialize(dialect_cls, value) + + +# --------------------------------------------------------------------------- +# MacAddressSerializer +# --------------------------------------------------------------------------- + + +class TestMacAddressSerializer: + def test_is_serializer_subclass(self): + assert issubclass(MacAddressSerializer, Serializer) + + @pytest.mark.parametrize("value", ["01:23:45:67:89:ab", "01-23-45-67-89-AB"]) + def test_serializes_valid_mac_addresses(self, value, dialect_cls: type[SqlDialect]): + result = MacAddressSerializer().serialize(dialect_cls, value) + assert isinstance(result, str) + assert result != "" + + def test_result_is_quoted(self, dialect_cls: type[SqlDialect]): + result = MacAddressSerializer().serialize(dialect_cls, "01:23:45:67:89:ab") + assert result != "01:23:45:67:89:ab" + + @pytest.mark.parametrize("bad", ["not-a-mac", "gg:hh:ii:jj:kk:ll", ""]) + def test_rejects_invalid_mac_strings(self, bad, dialect_cls: type[SqlDialect]): + with pytest.raises(ValueError): + MacAddressSerializer().serialize(dialect_cls, bad) + + @pytest.mark.parametrize("value", [None, 12345]) + def test_rejects_non_str_types(self, value, dialect_cls: type[SqlDialect]): + with pytest.raises(ValueError): + MacAddressSerializer().serialize(dialect_cls, value) + + +# --------------------------------------------------------------------------- +# UnspecifiedSerializer +# --------------------------------------------------------------------------- + + +class TestUnspecifiedSerializer: + """The fallback serializer: `str(obj)`, no type validation, no quoting. + + This is intentionally permissive per its doc comment ("useful when user + doesn't specify the serializer, and also we couldn't detect the type + from datatype"). + """ + + def test_is_serializer_subclass(self): + assert issubclass(UnspecifiedSerializer, Serializer) + + @pytest.mark.parametrize( + "value,expected", + [ + (123, "123"), + ("hello", "hello"), + (3.14, "3.14"), + (None, "None"), + (True, "True"), + ], + ) + def test_falls_back_to_str_of_the_object( + self, value, expected, dialect_cls: type[SqlDialect] + ): + result = UnspecifiedSerializer().serialize(dialect_cls, value) + assert result == expected + + def test_accepts_any_object_without_raising(self, dialect_cls: type[SqlDialect]): + class Anything: + def __str__(self): + return "custom-repr" + + result = UnspecifiedSerializer().serialize(dialect_cls, Anything()) + assert result == "custom-repr" + + +# --------------------------------------------------------------------------- +# Cross-cutting: dialect argument acceptance +# --------------------------------------------------------------------------- + + +class TestSerializeAcceptsClassOrInstance: + """Per data_type.pyi: `serialize(dialect: Type[SqlDialect] | SqlDialect, ...)`.""" + + def test_integer_serializer_accepts_class(self): + assert IntegerSerializer().serialize(PostgreSQL, 1) == "1" + + def test_integer_serializer_accepts_instance(self): + assert IntegerSerializer().serialize(PostgreSQL(), 1) == "1" + + def test_class_and_instance_give_identical_results( + self, dialect_cls: type[SqlDialect] + ): + s = StringSerializer() + assert s.serialize(dialect_cls, "x") == s.serialize(dialect_cls(), "x") + + def test_rejects_non_dialect_argument(self): + with pytest.raises(TypeError): + IntegerSerializer().serialize( + "not-a-dialect", # type: ignore + 1, + ) + + def test_rejects_none_as_dialect(self): + with pytest.raises(TypeError): + IntegerSerializer().serialize( + None, # type: ignore + 1, + ) + + +class TestSerializerMarkers: + def test_unit_instances_compare_equal(self): + assert IntegerSerializer() == IntegerSerializer() + assert IntegerSerializer() != FloatSerializer() + + def test_unit_instances_are_hashable(self): + assert hash(IntegerSerializer()) == hash(IntegerSerializer()) + assert len({IntegerSerializer(), IntegerSerializer(), FloatSerializer()}) == 2 + + def test_unit_repr_is_constructor_call(self): + assert repr(IntegerSerializer()) == "IntegerSerializer()" + assert repr(JSONSerializer()) == "JSONSerializer()" + + def test_array_equals_by_element_tree(self): + a = ArraySerializer(IntegerSerializer()) + b = ArraySerializer(IntegerSerializer()) + c = ArraySerializer(StringSerializer()) + assert a == b + assert a != c + assert repr(a) == "ArraySerializer(IntegerSerializer())" + + def test_custom_equals_by_function_identity(self): + def fn(dialect, obj): + return "x" + + assert CustomSerializer(fn) == CustomSerializer(fn) + assert CustomSerializer(fn) != CustomSerializer(lambda d, o: "x") + + +class TestDatetimeOffsetPreserved: + def test_aware_datetime_keeps_offset(self, dialect_cls: type[SqlDialect]): + value = datetime.datetime( + 2024, + 1, + 15, + 10, + 30, + 0, + tzinfo=datetime.timezone(datetime.timedelta(hours=5)), + ) + result = DatetimeSerializer().serialize(dialect_cls, value) + assert "10:30:00" in result + assert "+05:00" in result + assert "05:30:00" not in result From 2848bc64a95eb36343b337e88a2be6bc1a8a5dba Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Tue, 8 Sep 2026 11:06:54 +0330 Subject: [PATCH 14/87] Update docs --- rapidquery/data_type.py | 8 ++------ rapidquery/data_type.pyi | 21 +-------------------- rapidquery/dialect.py | 12 +++--------- 3 files changed, 6 insertions(+), 35 deletions(-) diff --git a/rapidquery/data_type.py b/rapidquery/data_type.py index 5a81903..0a47676 100644 --- a/rapidquery/data_type.py +++ b/rapidquery/data_type.py @@ -1,9 +1,5 @@ -"""SQL data types and value serializers. - -from rapidquery.data_type import DataType, IntegerSerializer -from rapidquery.dialect import PostgreSQL - -DataType("integer").serialize(PostgreSQL, 42) +""" +SQL data types and value serializers. """ from ._core.data_type import ( diff --git a/rapidquery/data_type.pyi b/rapidquery/data_type.pyi index f252611..14717ea 100644 --- a/rapidquery/data_type.pyi +++ b/rapidquery/data_type.pyi @@ -3,7 +3,6 @@ from typing import Any, Self, final from rapidquery.dialect import SqlDialect - class Serializer: """ Converts a Python object into a dialect-specific SQL literal string. @@ -40,7 +39,6 @@ class Serializer: def __hash__(self) -> int: ... def __repr__(self) -> str: ... - @final class IntegerSerializer(Serializer): """ @@ -55,7 +53,6 @@ class IntegerSerializer(Serializer): def __new__(cls) -> Self: ... - @final class FloatSerializer(Serializer): """ @@ -71,7 +68,6 @@ class FloatSerializer(Serializer): def __new__(cls) -> Self: ... - @final class BooleanSerializer(Serializer): """ @@ -86,7 +82,6 @@ class BooleanSerializer(Serializer): def __new__(cls) -> Self: ... - @final class StringSerializer(Serializer): """ @@ -101,7 +96,6 @@ class StringSerializer(Serializer): def __new__(cls) -> Self: ... - @final class UuidSerializer(Serializer): """ @@ -115,7 +109,6 @@ class UuidSerializer(Serializer): def __new__(cls) -> Self: ... - @final class BytesSerializer(Serializer): """ @@ -129,7 +122,6 @@ class BytesSerializer(Serializer): def __new__(cls) -> Self: ... - @final class BitSerializer(Serializer): """ @@ -139,13 +131,11 @@ class BitSerializer(Serializer): Raises: TypeError: If the value is not a `str`. - ValueError: If the string is empty or contains characters other than - `'0'` and `'1'`. + ValueError: If the string is empty or contains characters other than `'0'` and `'1'`. """ def __new__(cls) -> Self: ... - @final class JSONSerializer(Serializer): """ @@ -161,7 +151,6 @@ class JSONSerializer(Serializer): def __new__(cls) -> Self: ... - @final class DateSerializer(Serializer): """ @@ -175,7 +164,6 @@ class DateSerializer(Serializer): def __new__(cls) -> Self: ... - @final class TimeSerializer(Serializer): """ @@ -189,7 +177,6 @@ class TimeSerializer(Serializer): def __new__(cls) -> Self: ... - @final class DatetimeSerializer(Serializer): """ @@ -206,7 +193,6 @@ class DatetimeSerializer(Serializer): def __new__(cls) -> Self: ... - @final class InetSerializer(Serializer): """ @@ -222,7 +208,6 @@ class InetSerializer(Serializer): def __new__(cls) -> Self: ... - @final class MacAddressSerializer(Serializer): """ @@ -236,7 +221,6 @@ class MacAddressSerializer(Serializer): def __new__(cls) -> Self: ... - @final class UnspecifiedSerializer(Serializer): """ @@ -248,7 +232,6 @@ class UnspecifiedSerializer(Serializer): def __new__(cls) -> Self: ... - @final class ArraySerializer(Serializer): """ @@ -277,7 +260,6 @@ class ArraySerializer(Serializer): The `Serializer` passed to the constructor. """ - @final class CustomSerializer(Serializer): """ @@ -307,7 +289,6 @@ class CustomSerializer(Serializer): The function given as `function` to `__new__`. """ - @final class DataType: """ diff --git a/rapidquery/dialect.py b/rapidquery/dialect.py index a49202e..7b9cbf7 100644 --- a/rapidquery/dialect.py +++ b/rapidquery/dialect.py @@ -1,19 +1,13 @@ -"""SQL dialect markers. - -Pass either the class or an instance wherever a dialect is required: - - from rapidquery.dialect import DuckDB, PostgreSQL - - query.prepare(PostgreSQL) - query.prepare(DuckDB()) +""" +SQL dialect markers. """ from ._core.dialect import ( + MSSQL, BigQuery, Databricks, DuckDB, Hive, - MSSQL, MySql, Oracle, PostgreSQL, From f4c7bd51e7e2acd6a27378927216943f066bde3a Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Tue, 8 Sep 2026 12:58:42 +0330 Subject: [PATCH 15/87] Write Ident --- Cargo.lock | 5 +- Cargo.toml | 1 + rapidquery/__init__.py | 11 +- rapidquery/ident.py | 11 ++ rapidquery/ident.pyi | 130 +++++++++++++++ rustfmt.toml | 4 - src/internal/utils/mod.rs | 7 + src/lib.rs | 3 + src/native/ident.rs | 262 ++++++++++++++++++++++++++++++ src/native/mod.rs | 3 + src/pybindings/ident.rs | 333 ++++++++++++++++++++++++++++++++++++++ src/pybindings/mod.rs | 3 + src/typeref.rs | 21 +++ tests/test_ident.py | 198 +++++++++++++++++++++++ 14 files changed, 985 insertions(+), 7 deletions(-) create mode 100644 rapidquery/ident.py create mode 100644 rapidquery/ident.pyi delete mode 100644 rustfmt.toml create mode 100644 src/native/ident.rs create mode 100644 src/pybindings/ident.rs create mode 100644 tests/test_ident.py diff --git a/Cargo.lock b/Cargo.lock index 1aa3112..3911425 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -366,6 +366,7 @@ dependencies = [ "parking_lot", "pyo3", "rapidquery-macros", + "smallvec", ] [[package]] @@ -412,9 +413,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" [[package]] name = "syn" diff --git a/Cargo.toml b/Cargo.toml index 436d4d2..2559c9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] } ipnetwork = { version = "0.20", default-features = false } mac_address = { version = "1.1", default-features = false } once_cell = { version = "1.21.4", default-features = false } +smallvec = { version = "1.16.0", default-features = false } [features] default = ["extension-module"] diff --git a/rapidquery/__init__.py b/rapidquery/__init__.py index 4979a4d..13483db 100644 --- a/rapidquery/__init__.py +++ b/rapidquery/__init__.py @@ -2,5 +2,14 @@ from . import data_type as data_type from . import dialect as dialect +from .ident import Asterisk as Asterisk +from .ident import Ident as Ident +from .ident import Unquote as Unquote -__all__ = ["data_type", "dialect"] +__all__ = [ + "Asterisk", + "Ident", + "Unquote", + "data_type", + "dialect", +] diff --git a/rapidquery/ident.py b/rapidquery/ident.py new file mode 100644 index 0000000..98f8b1b --- /dev/null +++ b/rapidquery/ident.py @@ -0,0 +1,11 @@ +""" +SQL identifiers. +""" + +from ._core import Asterisk, Ident, Unquote + +__all__ = [ + "Asterisk", + "Ident", + "Unquote", +] diff --git a/rapidquery/ident.pyi b/rapidquery/ident.pyi new file mode 100644 index 0000000..5bea230 --- /dev/null +++ b/rapidquery/ident.pyi @@ -0,0 +1,130 @@ +from collections.abc import Iterator, Sequence +from typing import Self, final, overload + +from rapidquery.dialect import SqlDialect + +@final +class Unquote: + """ + Marker for an unquoted identifier part. + + The body is emitted without dialect delimiters. It must be a simple + ASCII name (`[A-Za-z_][A-Za-z0-9_]*`). This is **not** a raw-SQL + fragment (`COUNT(*)` belongs on a later expression type). + """ + + def __new__(cls, name: str, /) -> Self: + """ + Creates an unquoted identifier part. + + Args: + name: The identifier body, unquoted. + + Raises: + ValueError: If `name` is empty or not a simple ASCII identifier + (letters, digits, underscore; first character not a digit). + """ + + @property + def name(self) -> str: + """ + Returns the unquoted identifier body. + + Returns: + The string passed to the constructor. + """ + + def __eq__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... + +@final +class Asterisk: + """ + SQL wildcard `*`. + + Pass the class (`Asterisk`) or an instance (`Asterisk()`) as an + identifier part. It must be the last part of an `Ident` (`t.*`). + `Ident("*")` is a quoted column named star, not this wildcard. + """ + + def __new__(cls) -> Self: ... + def __eq__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... + +@final +class Ident: + """ + Represents a SQL identifier (`schema.table.column`). + + An identifier is a sequence of parts. Each constructor argument is one + part: a quoted name (`str`), an `Unquote` name, an `Asterisk` wildcard, + or a nested `Ident` whose parts are flattened. + """ + + def __new__(cls, *parts: str | Unquote | Ident | Asterisk | type[Asterisk]) -> Self: + """ + Creates a SQL identifier. + + Args: + parts: One argument per part. A `str` is always quoted, including + `"*"` (a column named star). Pass `Asterisk` for the SQL + wildcard. Dotted names are `Ident("schema", "table")`, not + `Ident("schema.table")`. + + **Note**: Sequences are not accepted. `Ident(["table", "id"])` + is a `TypeError`; write `Ident("table", "id")`. + + Raises: + TypeError: If an argument is not `str`, `Unquote`, `Asterisk`, or + `Ident`. + ValueError: If there are no parts, a part is empty, `Asterisk` is + not last, an `Unquote` body is not a simple ASCII name, or + there are more than 10 parts. + """ + + def __len__(self) -> int: ... + @overload + def __getitem__(self, index: int) -> str | Unquote | Asterisk: ... + @overload + def __getitem__(self, index: slice) -> Ident: ... + def __getitem__(self, index: int | slice) -> str | Unquote | Asterisk | Ident: + """ + Returns a part by index, or a sub-identifier by slice. + + Args: + index: An `int` (negative indices count from the end) or a + `slice`. + + Returns: + A `str`, `Unquote`, or `Asterisk` for an integer index. An + `Ident` for a slice. + + Raises: + IndexError: If the integer index is out of range. + ValueError: If the slice selects no parts. + """ + + def __iter__(self) -> Iterator[str | Unquote | Asterisk]: ... + def to_list(self) -> Sequence[str | Unquote | Asterisk]: + """ + Returns the parts as a list (`str`, `Unquote`, or `Asterisk`). + """ + + def to_sql(self, dialect: type[SqlDialect] | SqlDialect) -> str: + """ + Renders this identifier as SQL for `dialect`. + + Args: + dialect: A dialect class (`PostgreSQL`) or instance + (`PostgreSQL()`). + + Returns: + The dotted identifier with each part quoted or emitted according + to its kind. + """ + + def __eq__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... diff --git a/rustfmt.toml b/rustfmt.toml deleted file mode 100644 index 89b7108..0000000 --- a/rustfmt.toml +++ /dev/null @@ -1,4 +0,0 @@ -format_strings = true -format_macro_matchers = true -format_macro_bodies = true -imports_granularity = "Module" diff --git a/src/internal/utils/mod.rs b/src/internal/utils/mod.rs index f48499b..3014bc7 100644 --- a/src/internal/utils/mod.rs +++ b/src/internal/utils/mod.rs @@ -28,3 +28,10 @@ pub unsafe fn get_type_name<'a>(py: pyo3::Python<'a>, obj: *mut pyo3::ffi::PyObj .unwrap_or_else(|_| String::from("")) } } + +/// Helper enum to accept either an `isize` index or a `slice` in `__getitem__`. +#[derive(Debug, pyo3::FromPyObject)] +pub enum IndexOrSlice { + Index(isize), + Slice(pyo3::Py), +} diff --git a/src/lib.rs b/src/lib.rs index 9da8b0b..c4c33ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,9 @@ mod _core { #[pymodule_export] use super::pybindings::dialect_mod; + #[pymodule_export] + use super::pybindings::{PyAsterisk, PyIdent, PyUnquote}; + #[pymodule_init] #[cold] fn init(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> { diff --git a/src/native/ident.rs b/src/native/ident.rs new file mode 100644 index 0000000..a1dfacd --- /dev/null +++ b/src/native/ident.rs @@ -0,0 +1,262 @@ +//! SQL identifiers. +//! +//! An [`Ident`] is a dotted name (`schema.table.column`) made of +//! [`IdentPart`]s. Each part is one of: +//! +//! * [`IdentPart::Quote`] — wrapped in the dialect's identifier delimiters +//! (and escaped). This is the default for a Python `str`. +//! * [`IdentPart::Unquote`] — emitted as a raw identifier body. **Not** a +//! raw-SQL hole: the body must be a simple ASCII name +//! (`[A-Za-z_][A-Za-z0-9_]*`). `COUNT(*)` and similar fragments belong on +//! a later expression type. +//! * [`IdentPart::Asterisk`] — the SQL wildcard `*`. Must be the last part +//! (`t.*`, never `*.id`). +//! +//! # Python vs Rust +//! +//! The Python constructor takes one argument per part (`Ident("schema", +//! "table")`). A Python `str` is always [`IdentPart::Quote`], including +//! `"*"` (a column named star). The wildcard is the `Asterisk` object. +//! +//! [`Ident::from_str`] is a **Rust** dotted-name parser: `"t.*"` ends in +//! [`IdentPart::Asterisk`], and a `*` that is not last is demoted to +//! [`IdentPart::Quote`]. Python does not call this parser. + +use smallvec::SmallVec; + +use crate::native::{ + dialect::SqlDialect, + prepare_statement::{PrepareResult, PrepareStatement, prepare_separated}, +}; + +/// One segment of a dotted identifier. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum IdentPart { + /// Identifier that should be quoted and escaped for the dialect. + Quote(arcstr::ArcStr), + /// Identifier emitted without delimiters. Must be a simple ASCII name. + Unquote(arcstr::ArcStr), + /// SQL wildcard `*`. + Asterisk, +} + +impl IdentPart { + /// Quoted part whose body is `s`. + /// + /// `"*"` is a quoted identifier named star, not [`IdentPart::Asterisk`]. + pub fn quoted(s: impl AsRef) -> Result { + let s = s.as_ref(); + if s.is_empty() { + return Err(ParseIdentError::EmptyChunk); + } + Ok(Self::Quote(s.into())) + } + + /// Unquoted part whose body is `s`. + /// + /// Rejects anything that is not `[A-Za-z_][A-Za-z0-9_]*`. + pub fn unquoted(s: impl AsRef) -> Result { + let s = s.as_ref(); + if s.is_empty() { + return Err(ParseIdentError::EmptyChunk); + } + if !is_safe_unquoted(s) { + return Err(ParseIdentError::InvalidUnquoted); + } + Ok(Self::Unquote(s.into())) + } +} + +impl> From for IdentPart { + /// Parser helper used by [`Ident::from_str`]. + /// + /// A literal `"*"` becomes [`IdentPart::Asterisk`]. Everything else is + /// [`IdentPart::Quote`] with no emptiness check — [`Ident::from_str`] + /// rejects empty chunks first. Python `str` extract does **not** use + /// this impl (it always goes through [`IdentPart::quoted`]). + fn from(value: S) -> Self { + let s = value.as_ref(); + if s == "*" { + IdentPart::Asterisk + } else { + IdentPart::Quote(s.into()) + } + } +} + +impl PrepareStatement for IdentPart { + #[inline] + fn prepare( + &self, + dialect: &SqlDialect, + context: &mut crate::native::prepare_statement::PrepareContext<'_>, + ) -> PrepareResult<()> { + match self { + Self::Quote(name) => { + dialect.quote_identifier(context, name.as_str())?; + } + Self::Unquote(name) => { + context.push_str(name.as_str()); + } + Self::Asterisk => { + context.push_char('*'); + } + } + Ok(()) + } +} + +/// Dotted identifier (`schema.table.column`). +/// +/// Never empty. At most [`Ident::MAX_PARTS`] parts. An [`IdentPart::Asterisk`] +/// may appear only as the last part. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Ident(SmallVec<[IdentPart; 4]>); + +/// Why building an [`Ident`] failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParseIdentError { + /// No parts (empty string, empty constructor). + Empty, + /// A quoted or unquoted part was the empty string (`"a..b"`, `""` as a part). + EmptyChunk, + /// More than [`Ident::MAX_PARTS`] parts. + TooManyParts, + /// `*` appeared before the last part (`*.id`). + AsteriskNotLast, + /// Unquoted body was not a simple ASCII identifier. + InvalidUnquoted, +} + +impl std::fmt::Display for ParseIdentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Empty => "identifier is empty", + Self::EmptyChunk => "identifier has an empty part", + Self::TooManyParts => "identifier has too many parts", + Self::AsteriskNotLast => "asterisk must be the last identifier part", + Self::InvalidUnquoted => "unquoted identifier is not a simple ASCII name", + }) + } +} + +impl std::error::Error for ParseIdentError {} + +/// `[A-Za-z_][A-Za-z0-9_]*`. Empty string is not safe. +fn is_safe_unquoted(s: &str) -> bool { + let mut bytes = s.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + if !first.is_ascii_alphabetic() && first != b'_' { + return false; + } + bytes.all(|c| c.is_ascii_alphanumeric() || c == b'_') +} + +impl Ident { + /// Maximum number of dotted parts. + pub const MAX_PARTS: usize = 10; + + /// Borrowed parts in order. + #[inline] + pub fn parts(&self) -> &[IdentPart] { + &self.0 + } + + /// Number of parts. Always ≄ 1 for a successfully built `Ident`. + #[inline] + pub fn len(&self) -> usize { + self.0.len() + } + + /// `true` when there are no parts. Unreachable for a validated `Ident`. + #[inline] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Consume and yield the parts. + #[inline] + pub fn into_parts(self) -> SmallVec<[IdentPart; 4]> { + self.0 + } + + /// Build from parts, validating emptiness, length, unquoted charset, and + /// asterisk position. + pub fn from_parts(iter: impl IntoIterator) -> Result { + let mut parts = SmallVec::<[IdentPart; 4]>::new(); + + for part in iter { + if let Some(IdentPart::Asterisk) = parts.last() { + return Err(ParseIdentError::AsteriskNotLast); + } + if parts.len() >= Self::MAX_PARTS { + return Err(ParseIdentError::TooManyParts); + } + match &part { + IdentPart::Quote(name) if name.is_empty() => { + return Err(ParseIdentError::EmptyChunk); + } + IdentPart::Unquote(name) => { + if name.is_empty() { + return Err(ParseIdentError::EmptyChunk); + } + if !is_safe_unquoted(name) { + return Err(ParseIdentError::InvalidUnquoted); + } + } + IdentPart::Quote(_) | IdentPart::Asterisk => {} + } + parts.push(part); + } + + if parts.is_empty() { + return Err(ParseIdentError::Empty); + } + + Ok(Self(parts)) + } +} + +impl std::str::FromStr for Ident { + type Err = ParseIdentError; + + fn from_str(s: &str) -> Result { + if s.is_empty() { + return Err(ParseIdentError::Empty); + } + + let mut parts = SmallVec::<[IdentPart; 4]>::new(); + + for chunk in s.splitn(Self::MAX_PARTS + 1, '.') { + if chunk.is_empty() { + return Err(ParseIdentError::EmptyChunk); + } + if parts.len() >= Self::MAX_PARTS { + return Err(ParseIdentError::TooManyParts); + } + // A bare `*` only makes sense as the final part (e.g. `table.*`). + // If another part follows, demote the previous `*` to a quoted star. + if let Some(last) = parts.last_mut() + && matches!(last, IdentPart::Asterisk) + { + *last = IdentPart::Quote(arcstr::literal!("*")); + } + parts.push(chunk.into()); + } + + Self::from_parts(parts) + } +} + +impl PrepareStatement for Ident { + #[inline] + fn prepare( + &self, + dialect: &SqlDialect, + context: &mut crate::native::prepare_statement::PrepareContext<'_>, + ) -> PrepareResult<()> { + prepare_separated(dialect, context, &self.0, ".") + } +} diff --git a/src/native/mod.rs b/src/native/mod.rs index 627692e..3b68edf 100644 --- a/src/native/mod.rs +++ b/src/native/mod.rs @@ -1,3 +1,6 @@ pub mod data_type; pub mod dialect; pub mod prepare_statement; + +mod ident; +pub use ident::{Ident, IdentPart, ParseIdentError}; diff --git a/src/pybindings/ident.rs b/src/pybindings/ident.rs new file mode 100644 index 0000000..4fc2ce1 --- /dev/null +++ b/src/pybindings/ident.rs @@ -0,0 +1,333 @@ +//! Python wrappers around [`crate::native::ident`]. +//! +//! Native [`Ident`](crate::native::Ident) / [`IdentPart`](crate::native::IdentPart) +//! stay PyO3-free. This module owns the pyclasses and the extractor used by +//! later APIs: +//! +//! * [`PyIdent`] — `rapidquery._core.Ident` +//! * [`PyUnquote`] — `rapidquery._core.Unquote` +//! * [`PyAsterisk`] — `rapidquery._core.Asterisk` +//! * [`IdentExtractor`] — `str | Ident | Unquote | Asterisk | type[Asterisk]` +//! +//! A Python `str` is always a quoted part, including `"*"`. The wildcard is +//! `Asterisk`. Dotted names are `Ident("schema", "table")`; the native +//! [`FromStr`](std::str::FromStr) parser is not used on the Python constructor. + +use pyo3::types::{PyAnyMethods, PySliceMethods}; + +use crate::{ + internal::{alias, utils::IndexOrSlice}, + native::{self, prepare_statement::prepare}, +}; + +#[inline] +fn parse_err(err: native::ParseIdentError) -> pyo3::PyErr { + rapidquery_macros::pyerr!(PyValueError, "{}", err) +} + +/// Convert a native part into the matching Python object (`str`, `Unquote`, +/// or `Asterisk`). +#[inline] +fn ident_part_to_pyobject<'py>( + py: pyo3::Python<'py>, + ident_part: &native::IdentPart, +) -> pyo3::PyResult> { + match ident_part { + native::IdentPart::Quote(s) => Ok(pyo3::types::PyString::new(py, s.as_str()).into_any()), + native::IdentPart::Unquote(s) => Ok(pyo3::Bound::new(py, PyUnquote(s.clone()))?.into_any()), + native::IdentPart::Asterisk => Ok(pyo3::Bound::new(py, PyAsterisk)?.into_any()), + } +} + +/// `true` when `obj` is `Asterisk` or `type[Asterisk]`. +/// +/// # Safety +/// +/// `obj` must be a valid, non-null `PyObject*`. `ASTERISK_TYPE` must have +/// been interned by [`crate::typeref::initialize_typeref`]. The GIL must +/// be held. +#[inline] +unsafe fn is_asterisk(obj: *mut pyo3::ffi::PyObject) -> bool { + let type_ptr = if pyo3::ffi::PyType_CheckExact(obj) == 1 { + obj.cast::() + } else { + pyo3::ffi::Py_TYPE(obj) + }; + type_ptr == crate::typeref::ASTERISK_TYPE +} + +/// Extracts a native [`Ident`](crate::native::Ident) from a Python object. +/// +/// Accepts `Ident`, `str` (one quoted part, including `"*"`), `Unquote`, +/// `Asterisk`, and `type[Asterisk]`. Sequences are rejected. +pub struct IdentExtractor(pub native::Ident); + +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for IdentExtractor { + type Error = pyo3::PyErr; + + fn extract( + obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, + ) -> Result { + unsafe { + // SAFETY: type pointers are interned in `initialize_typeref` before + // any extractor runs; `obj` is a valid borrowed `PyObject`. + if crate::typeref::check(obj.as_ptr(), crate::typeref::IDENT_TYPE) { + let ident = obj.cast_unchecked::(); + return Ok(Self(ident.get().0.clone())); + } + + if crate::typeref::check_exact(obj.as_ptr(), crate::typeref::UNQUOTE_TYPE) { + let unquote = obj.cast_unchecked::(); + let part = native::IdentPart::Unquote(unquote.get().0.clone()); + let ident = native::Ident::from_parts([part]).map_err(parse_err)?; + return Ok(Self(ident)); + } + + if is_asterisk(obj.as_ptr()) { + let ident = + native::Ident::from_parts([native::IdentPart::Asterisk]).map_err(parse_err)?; + return Ok(Self(ident)); + } + } + + if let Ok(string) = obj.extract::<&str>() { + let part = native::IdentPart::quoted(string).map_err(parse_err)?; + let ident = native::Ident::from_parts([part]).map_err(parse_err)?; + return Ok(Self(ident)); + } + + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected str, Ident, Unquote or Asterisk, got {}", + unsafe { crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) } + ) + } +} + +/// Represents a SQL identifier (`schema.table.column`). +/// +/// An identifier is a sequence of parts. Each constructor argument is one +/// part: a quoted name (`str`), an `Unquote` name, an `Asterisk` wildcard, +/// or a nested `Ident` whose parts are flattened. +/// +/// Args: +/// parts: One argument per part. A `str` is always quoted, including +/// `"*"` (a column named star). Pass `Asterisk` for the SQL +/// wildcard. Dotted names are `Ident("schema", "table")`, not +/// `Ident("schema.table")`. +/// +/// Raises: +/// TypeError: If an argument is not `str`, `Unquote`, `Asterisk`, or +/// `Ident`. +/// ValueError: If there are no parts, a part is empty, `Asterisk` is +/// not last, an `Unquote` body is not a simple ASCII name, or +/// there are more than 10 parts. +#[rapidquery_macros::pyclass("Ident", skip_from_py_object, eq, hash)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PyIdent(pub native::Ident); + +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyIdent { + type Error = pyo3::PyErr; + + fn extract( + obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, + ) -> Result { + IdentExtractor::extract(obj).map(|IdentExtractor(inner)| Self(inner)) + } +} + +#[pyo3::pymethods] +impl PyIdent { + #[new] + #[pyo3(signature = (*parts))] + fn __new__(parts: Vec) -> pyo3::PyResult { + let iter = parts + .into_iter() + .flat_map(|IdentExtractor(ident)| ident.into_parts()); + native::Ident::from_parts(iter).map(Self).map_err(parse_err) + } + + fn __len__(&self) -> usize { + self.0.len() + } + + /// Returns a part by index, or a sub-identifier by slice. + /// + /// Args: + /// index: An `int` (negative indices count from the end) or a + /// `slice`. + /// + /// Returns: + /// A `str`, `Unquote`, or `Asterisk` for an integer index. An + /// `Ident` for a slice. + /// + /// Raises: + /// IndexError: If the integer index is out of range. + /// ValueError: If the slice selects no parts. + fn __getitem__<'py>( + &self, + py: pyo3::Python<'py>, + index: IndexOrSlice, + ) -> pyo3::PyResult> { + let parts = self.0.parts(); + let len = parts.len() as isize; + + match index { + IndexOrSlice::Index(i) => { + let idx = if i < 0 { i + len } else { i }; + if idx < 0 || idx >= len { + return rapidquery_macros::pyerr!( + Err(PyIndexError), + "Ident index out of range" + ); + } + ident_part_to_pyobject(py, &parts[idx as usize]) + } + IndexOrSlice::Slice(slice) => { + let indices = slice.bind(py).indices(len)?; + let mut selected = Vec::with_capacity(indices.slicelength); + + let mut i = indices.start; + if indices.step > 0 { + while i < indices.stop { + selected.push(parts[i as usize].clone()); + i += indices.step; + } + } else { + while i > indices.stop { + selected.push(parts[i as usize].clone()); + i += indices.step; + } + } + + let inner = native::Ident::from_parts(selected).map_err(parse_err)?; + Ok(pyo3::Bound::new(py, PyIdent(inner))?.into_any()) + } + } + } + + fn __iter__<'py>( + &self, + py: pyo3::Python<'py>, + ) -> pyo3::PyResult> { + let items = self + .0 + .parts() + .iter() + .map(|part| ident_part_to_pyobject(py, part)) + .collect::>>()?; + let list = pyo3::types::PyList::new(py, items)?; + list.try_iter() + } + + /// Returns the parts as a list (`str`, `Unquote`, or `Asterisk`). + fn to_list(&self, py: pyo3::Python<'_>) -> pyo3::PyResult> { + self.0 + .parts() + .iter() + .map(|part| ident_part_to_pyobject(py, part).map(|bound| bound.unbind())) + .collect() + } + + /// Renders this identifier as SQL for `dialect`. + /// + /// Args: + /// dialect: A dialect class (`PostgreSQL`) or instance + /// (`PostgreSQL()`). + /// + /// Returns: + /// The dotted identifier with each part quoted or emitted according + /// to its kind. + fn to_sql( + &self, + py: pyo3::Python<'_>, + dialect: super::dialect::PySqlDialect, + ) -> pyo3::PyResult { + let (sql, _) = prepare(py, &self.0, &dialect.0) + .map_err(|e| rapidquery_macros::pyerr!(PyRuntimeError, "{}", e))?; + + Ok(sql) + } + + fn __repr__(&self) -> String { + use std::fmt::Write; + + let mut result = String::from("Ident("); + let parts = self.0.parts(); + for (index, item) in parts.iter().enumerate() { + match item { + native::IdentPart::Quote(x) => write!(result, "{x:?}").unwrap(), + native::IdentPart::Unquote(x) => write!(result, "Unquote({x:?})").unwrap(), + native::IdentPart::Asterisk => result.push_str("Asterisk"), + } + if index + 1 < parts.len() { + result.push_str(", "); + } + } + result.push(')'); + result + } +} + +/// Marker for an unquoted identifier part. +/// +/// The body is emitted without dialect delimiters. It must be a simple +/// ASCII name (`[A-Za-z_][A-Za-z0-9_]*`). This is **not** a raw-SQL +/// fragment (`COUNT(*)` belongs on a later expression type). +/// +/// Args: +/// name: The identifier body, unquoted. +/// +/// Raises: +/// ValueError: If `name` is empty or not a simple ASCII identifier +/// (letters, digits, underscore; first character not a digit). +#[rapidquery_macros::pyclass("Unquote", skip_from_py_object, eq, hash)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PyUnquote(pub arcstr::ArcStr); + +#[pyo3::pymethods] +impl PyUnquote { + #[new] + #[pyo3(signature = (name, /))] + fn __new__(name: &str) -> pyo3::PyResult { + let part = native::IdentPart::unquoted(name).map_err(parse_err)?; + let native::IdentPart::Unquote(s) = part else { + unreachable!("IdentPart::unquoted returns Unquote"); + }; + Ok(Self(s)) + } + + /// Returns the unquoted identifier body. + /// + /// Returns: + /// The string passed to the constructor. + #[getter] + fn get_name(&self) -> &str { + self.0.as_str() + } + + fn __repr__(&self) -> String { + format!("Unquote({:?})", self.0.as_str()) + } +} + +/// SQL wildcard `*`. +/// +/// Pass the class (`Asterisk`) or an instance (`Asterisk()`) as an +/// identifier part. It must be the last part of an `Ident` (`t.*`). +/// `Ident("*")` is a quoted column named star, not this wildcard. +#[rapidquery_macros::pyclass("Asterisk", skip_from_py_object, eq, hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PyAsterisk; + +#[pyo3::pymethods] +impl PyAsterisk { + #[new] + fn __new__() -> Self { + Self + } + + fn __repr__(&self) -> &'static str { + "Asterisk()" + } +} diff --git a/src/pybindings/mod.rs b/src/pybindings/mod.rs index 6820dba..b8f3ba7 100644 --- a/src/pybindings/mod.rs +++ b/src/pybindings/mod.rs @@ -7,5 +7,8 @@ pub mod data_type; pub mod dialect; +mod ident; + pub use data_type::data_type_mod; pub use dialect::dialect_mod; +pub use ident::{IdentExtractor, PyAsterisk, PyIdent, PyUnquote}; diff --git a/src/typeref.rs b/src/typeref.rs index d0afa5e..4deda9b 100644 --- a/src/typeref.rs +++ b/src/typeref.rs @@ -37,6 +37,11 @@ pub(crate) static mut UNSPECIFIED_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject pub(crate) static mut ARRAY_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); pub(crate) static mut CUSTOM_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +// Ident types +pub(crate) static mut IDENT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut UNQUOTE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut ASTERISK_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); + // Python standard-library types (serializers) pub(crate) static mut STD_DECIMAL_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); pub(crate) static mut STD_UUID_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); @@ -122,6 +127,10 @@ fn _initialize_typeref(py: pyo3::Python) { crate::pybindings::data_type::PyUnspecifiedSerializer => UNSPECIFIED_SERIALIZER_TYPE, crate::pybindings::data_type::PyArraySerializer => ARRAY_SERIALIZER_TYPE, crate::pybindings::data_type::PyCustomSerializer => CUSTOM_SERIALIZER_TYPE, + // ident + crate::pybindings::PyIdent => IDENT_TYPE, + crate::pybindings::PyUnquote => UNQUOTE_TYPE, + crate::pybindings::PyAsterisk => ASTERISK_TYPE, ); STD_DECIMAL_TYPE = look_up_type_object(c"decimal", c"Decimal"); @@ -148,6 +157,12 @@ pub fn initialize_typeref(py: pyo3::Python) { } /// `type(obj) is T` — no subclass match. +/// +/// # Safety +/// +/// `ptr` must be a valid, non-null `PyObject*`. `r#type` must be a valid +/// `PyTypeObject*` (interned pointers from [`initialize_typeref`] are fine). +/// The GIL must be held. #[inline] pub unsafe fn check_exact( ptr: *mut pyo3::ffi::PyObject, @@ -157,6 +172,12 @@ pub unsafe fn check_exact( } /// `isinstance(obj, T)`. +/// +/// # Safety +/// +/// `ptr` must be a valid, non-null `PyObject*`. `r#type` must be a valid +/// `PyTypeObject*` (interned pointers from [`initialize_typeref`] are fine). +/// The GIL must be held. #[inline] pub unsafe fn check(ptr: *mut pyo3::ffi::PyObject, r#type: *mut pyo3::ffi::PyTypeObject) -> bool { pyo3::ffi::PyObject_TypeCheck(ptr, r#type) == 1 diff --git a/tests/test_ident.py b/tests/test_ident.py new file mode 100644 index 0000000..0fdae23 --- /dev/null +++ b/tests/test_ident.py @@ -0,0 +1,198 @@ +import pytest + +from rapidquery import Asterisk, Ident, Unquote +from rapidquery.dialect import MSSQL, MySql, PostgreSQL, SqlDialect, Teradata + +from .conftest import ALL_DIALECTS + + +class TestIdentConstruction: + def test_varargs_parts(self): + ident = Ident("table", "id") + assert len(ident) == 2 + assert ident[0] == "table" + assert ident[1] == "id" + + def test_single_str_is_one_quoted_part_even_with_dots(self): + ident = Ident("schema.table") + assert len(ident) == 1 + assert ident[0] == "schema.table" + assert ident.to_sql(PostgreSQL) == '"schema.table"' + + def test_rejects_no_arguments(self): + with pytest.raises(ValueError, match="empty"): + _ = Ident() + + def test_rejects_empty_string_part(self): + with pytest.raises(ValueError, match="empty"): + _ = Ident("") + + def test_rejects_non_ident_argument(self): + with pytest.raises(TypeError): + _ = Ident(1) + + def test_flattens_nested_ident(self): + ident = Ident(Ident("t"), "id") + assert list(ident) == ["t", "id"] + assert ident.to_sql(PostgreSQL) == '"t"."id"' + + def test_too_many_parts(self): + with pytest.raises(ValueError, match="too many"): + _ = Ident(*[f"p{i}" for i in range(11)]) + + def test_ten_parts_are_allowed(self): + ident = Ident(*[f"p{i}" for i in range(10)]) + assert len(ident) == 10 + + +class TestQuotedStarVersusAsterisk: + def test_str_star_is_quoted_column(self): + ident = Ident("*") + assert len(ident) == 1 + assert ident[0] == "*" + assert ident.to_sql(PostgreSQL) == '"*"' + assert ident.to_sql(MySql) == "`*`" + + def test_asterisk_class_is_wildcard(self): + ident = Ident(Asterisk) + assert ident[0] == Asterisk() + assert ident.to_sql(PostgreSQL) == "*" + + def test_asterisk_instance_is_wildcard(self): + ident = Ident(Asterisk()) + assert ident.to_sql(MySql) == "*" + + def test_qualified_wildcard(self): + ident = Ident("t", Asterisk) + assert ident.to_sql(PostgreSQL) == '"t".*' + assert ident.to_sql(MySql) == "`t`.*" + + def test_asterisk_not_last_is_rejected(self): + with pytest.raises(ValueError, match="asterisk"): + _ = Ident(Asterisk, "id") + + +class TestUnquote: + def test_simple_name(self): + part = Unquote("Users") + assert part.name == "Users" + assert Ident(part).to_sql(PostgreSQL) == "Users" + + def test_mixed_with_quoted_parts(self): + ident = Ident("public", Unquote("Users"), "id") + assert ident.to_sql(PostgreSQL) == '"public".Users."id"' + + def test_equals_by_name(self): + assert Unquote("id") == Unquote("id") + assert Unquote("id") != Unquote("ID") + assert Unquote("id") != "id" + + def test_rejects_injection_and_non_ascii(self): + for bad in [ + "", + "*", + "a;b", + "a.b", + "1id", + "a-b", + "a b", + "cafĆ©", + "a; DROP TABLE t--", + ]: + with pytest.raises(ValueError): + _ = Unquote(bad) + + def test_accepts_underscore_and_digits(self): + assert Unquote("_id").name == "_id" + assert Unquote("created_at").name == "created_at" + assert Unquote("a1").name == "a1" + + +class TestIndexingAndIteration: + def test_len_getitem_iter_to_list(self): + ident = Ident("a", "b", "c") + assert len(ident) == 3 + assert ident[0] == "a" + assert ident[-1] == "c" + assert list(ident) == ["a", "b", "c"] + assert ident.to_list() == ["a", "b", "c"] + + def test_index_error(self): + ident = Ident("a") + with pytest.raises(IndexError): + _ = ident[1] + with pytest.raises(IndexError): + _ = ident[-2] + + def test_slice_returns_ident(self): + ident = Ident("a", "b", "c") + sliced = ident[1:] + assert isinstance(sliced, Ident) + assert list(sliced) == ["b", "c"] + assert sliced.to_sql(PostgreSQL) == '"b"."c"' + + def test_empty_slice_is_value_error(self): + ident = Ident("a", "b") + with pytest.raises(ValueError, match="empty"): + _ = ident[1:1] + + def test_unquote_and_asterisk_parts_round_trip(self): + ident = Ident("t", Unquote("Users"), Asterisk) + assert ident[0] == "t" + assert ident[1] == Unquote("Users") + assert ident[2] == Asterisk() + assert list(ident)[1] == Unquote("Users") + + +class TestEqualityHashReprFrozen: + def test_equal_when_parts_match(self): + assert Ident("t", "id") == Ident("t", "id") + assert Ident("t", "id") != Ident("t", "name") + + def test_quoted_is_not_unquoted(self): + assert Ident("id") != Ident(Unquote("id")) + assert hash(Ident("id")) != hash(Ident(Unquote("id"))) + + def test_asterisk_instances_are_equal(self): + assert Asterisk() == Asterisk() + assert Ident(Asterisk) == Ident(Asterisk()) + + def test_usable_as_dict_key(self): + mapping = {Ident("t", "id"): 1, Unquote("id"): 2, Asterisk(): 3} + assert mapping[Ident("t", "id")] == 1 + assert mapping[Unquote("id")] == 2 + assert mapping[Asterisk()] == 3 + + def test_repr(self): + assert repr(Ident("schema", "table")) == 'Ident("schema", "table")' + assert repr(Ident("t", Asterisk)) == 'Ident("t", Asterisk)' + assert repr(Ident(Unquote("Users"))) == 'Ident(Unquote("Users"))' + assert repr(Unquote("Users")) == 'Unquote("Users")' + assert repr(Asterisk()) == "Asterisk()" + + +class TestRender: + def test_class_and_instance_dialect_agree(self): + ident = Ident("id") + assert ident.to_sql(PostgreSQL) == ident.to_sql(PostgreSQL()) + assert ident.to_sql(MySql) == ident.to_sql(MySql()) + + @pytest.mark.parametrize("dialect_cls", ALL_DIALECTS) + def test_render_succeeds_on_every_dialect(self, dialect_cls: type[SqlDialect]): + ident = Ident("users", "id") + sql = ident.to_sql(dialect_cls) + assert "users" in sql + assert "id" in sql + assert "." in sql + + def test_delimiter_escaping(self): + assert Ident('a"b').to_sql(PostgreSQL) == '"a""b"' + assert Ident("a`b").to_sql(MySql) == "`a``b`" + assert Ident("a]b").to_sql(MSSQL) == "[a]]b]" + assert Ident("a'b").to_sql(Teradata) == "'a''b'" + + def test_quoted_injection_stays_inside_delimiters(self): + payload = "a; DROP TABLE t--" + sql = Ident(payload).to_sql(PostgreSQL) + assert sql == f'"{payload}"' + assert sql.startswith('"') and sql.endswith('"') From 96c5a977c0b935db0e5dd14f34189ba50b6a079a Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Tue, 8 Sep 2026 13:01:28 +0330 Subject: [PATCH 16/87] Fix docstrings --- rapidquery/ident.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rapidquery/ident.pyi b/rapidquery/ident.pyi index 5bea230..697d098 100644 --- a/rapidquery/ident.pyi +++ b/rapidquery/ident.pyi @@ -10,7 +10,7 @@ class Unquote: The body is emitted without dialect delimiters. It must be a simple ASCII name (`[A-Za-z_][A-Za-z0-9_]*`). This is **not** a raw-SQL - fragment (`COUNT(*)` belongs on a later expression type). + fragment. """ def __new__(cls, name: str, /) -> Self: From 2d3f0ac8b2e4d0671bd5406a6bd9ef97a81d9386 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Tue, 8 Sep 2026 16:18:25 +0330 Subject: [PATCH 17/87] Optimize Ident --- src/internal/utils/mod.rs | 26 +++ src/native/ident.rs | 280 +++++++++++++++++++++----- src/pybindings/data_type/serialize.rs | 24 +-- src/pybindings/ident.rs | 122 ++++++----- 4 files changed, 339 insertions(+), 113 deletions(-) diff --git a/src/internal/utils/mod.rs b/src/internal/utils/mod.rs index 3014bc7..6043123 100644 --- a/src/internal/utils/mod.rs +++ b/src/internal/utils/mod.rs @@ -4,6 +4,32 @@ mod submodule_ext; pub use strings::*; pub use submodule_ext::*; +/// Borrow the cached UTF-8 buffer of a Python `str`. +/// +/// # Safety +/// +/// `ptr` must be a live unicode object (`PyUnicode_Check` / exact). The +/// returned slice is valid until that object is mutated or the GIL is +/// released. The GIL must be held (`py`). +#[inline] +pub unsafe fn py_str<'a>( + py: pyo3::Python<'_>, + ptr: *mut pyo3::ffi::PyObject, +) -> pyo3::PyResult<&'a str> { + let mut size: pyo3::ffi::Py_ssize_t = 0; + let data = pyo3::ffi::PyUnicode_AsUTF8AndSize(ptr, &mut size); + if data.is_null() || size < 0 { + Err(pyo3::PyErr::fetch(py)) + } else { + // SAFETY: `PyUnicode_AsUTF8AndSize` returns UTF-8; `size` is the byte + // length. The pointer is non-null on the success path. + Ok(std::str::from_utf8_unchecked(std::slice::from_raw_parts( + data as *const u8, + size as usize, + ))) + } +} + /// Fully-qualified type name of `obj` (`int`, `rapidquery._core.dialect.PostgreSQL`, …). /// /// Returns `""` if `Py_TYPE` is null or the name lookup fails. diff --git a/src/native/ident.rs b/src/native/ident.rs index a1dfacd..be3f46e 100644 --- a/src/native/ident.rs +++ b/src/native/ident.rs @@ -1,28 +1,9 @@ //! SQL identifiers. -//! -//! An [`Ident`] is a dotted name (`schema.table.column`) made of -//! [`IdentPart`]s. Each part is one of: -//! -//! * [`IdentPart::Quote`] — wrapped in the dialect's identifier delimiters -//! (and escaped). This is the default for a Python `str`. -//! * [`IdentPart::Unquote`] — emitted as a raw identifier body. **Not** a -//! raw-SQL hole: the body must be a simple ASCII name -//! (`[A-Za-z_][A-Za-z0-9_]*`). `COUNT(*)` and similar fragments belong on -//! a later expression type. -//! * [`IdentPart::Asterisk`] — the SQL wildcard `*`. Must be the last part -//! (`t.*`, never `*.id`). -//! -//! # Python vs Rust -//! -//! The Python constructor takes one argument per part (`Ident("schema", -//! "table")`). A Python `str` is always [`IdentPart::Quote`], including -//! `"*"` (a column named star). The wildcard is the `Asterisk` object. -//! -//! [`Ident::from_str`] is a **Rust** dotted-name parser: `"t.*"` ends in -//! [`IdentPart::Asterisk`], and a `*` that is not last is demoted to -//! [`IdentPart::Quote`]. Python does not call this parser. - -use smallvec::SmallVec; + +use std::fmt; + +use arcstr::ArcStr; +use smallvec::{SmallVec, smallvec}; use crate::native::{ dialect::SqlDialect, @@ -33,9 +14,9 @@ use crate::native::{ #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum IdentPart { /// Identifier that should be quoted and escaped for the dialect. - Quote(arcstr::ArcStr), + Quote(ArcStr), /// Identifier emitted without delimiters. Must be a simple ASCII name. - Unquote(arcstr::ArcStr), + Unquote(ArcStr), /// SQL wildcard `*`. Asterisk, } @@ -49,7 +30,7 @@ impl IdentPart { if s.is_empty() { return Err(ParseIdentError::EmptyChunk); } - Ok(Self::Quote(s.into())) + Ok(Self::Quote(ArcStr::from(s))) } /// Unquoted part whose body is `s`. @@ -63,7 +44,7 @@ impl IdentPart { if !is_safe_unquoted(s) { return Err(ParseIdentError::InvalidUnquoted); } - Ok(Self::Unquote(s.into())) + Ok(Self::Unquote(ArcStr::from(s))) } } @@ -79,7 +60,7 @@ impl> From for IdentPart { if s == "*" { IdentPart::Asterisk } else { - IdentPart::Quote(s.into()) + IdentPart::Quote(ArcStr::from(s)) } } } @@ -128,8 +109,8 @@ pub enum ParseIdentError { InvalidUnquoted, } -impl std::fmt::Display for ParseIdentError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for ParseIdentError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Self::Empty => "identifier is empty", Self::EmptyChunk => "identifier has an empty part", @@ -182,8 +163,17 @@ impl Ident { self.0 } - /// Build from parts, validating emptiness, length, unquoted charset, and - /// asterisk position. + /// One already-validated part. Prefer this over [`from_parts`] for a + /// single `str` / `Unquote` / `Asterisk`. + #[inline] + pub fn from_one(part: IdentPart) -> Self { + Self(smallvec![part]) + } + + /// Build from parts. Composition only: empty ident, empty chunk, + /// [`MAX_PARTS`](Self::MAX_PARTS), asterisk-not-last. + /// + /// Unquoted charset is enforced by [`IdentPart::unquoted`], not here. pub fn from_parts(iter: impl IntoIterator) -> Result { let mut parts = SmallVec::<[IdentPart; 4]>::new(); @@ -195,18 +185,10 @@ impl Ident { return Err(ParseIdentError::TooManyParts); } match &part { - IdentPart::Quote(name) if name.is_empty() => { + IdentPart::Quote(name) | IdentPart::Unquote(name) if name.is_empty() => { return Err(ParseIdentError::EmptyChunk); } - IdentPart::Unquote(name) => { - if name.is_empty() { - return Err(ParseIdentError::EmptyChunk); - } - if !is_safe_unquoted(name) { - return Err(ParseIdentError::InvalidUnquoted); - } - } - IdentPart::Quote(_) | IdentPart::Asterisk => {} + IdentPart::Quote(_) | IdentPart::Unquote(_) | IdentPart::Asterisk => {} } parts.push(part); } @@ -241,12 +223,12 @@ impl std::str::FromStr for Ident { if let Some(last) = parts.last_mut() && matches!(last, IdentPart::Asterisk) { - *last = IdentPart::Quote(arcstr::literal!("*")); + *last = IdentPart::Quote(ArcStr::from("*")); } parts.push(chunk.into()); } - Self::from_parts(parts) + Ok(Self(parts)) } } @@ -260,3 +242,211 @@ impl PrepareStatement for Ident { prepare_separated(dialect, context, &self.0, ".") } } + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + + fn ident_from(parts: impl IntoIterator) -> Ident { + Ident::from_parts(parts).unwrap() + } + + fn quote(s: &str) -> IdentPart { + IdentPart::quoted(s).unwrap() + } + + #[test] + fn from_str_splits_on_dots() { + let ident = Ident::from_str("public.users.id").unwrap(); + assert_eq!( + ident.parts(), + &[quote("public"), quote("users"), quote("id"),] + ); + } + + #[test] + fn from_str_bare_star_is_asterisk() { + let ident = Ident::from_str("*").unwrap(); + assert_eq!(ident.parts(), &[IdentPart::Asterisk]); + } + + #[test] + fn from_str_table_star_keeps_asterisk_last() { + let ident = Ident::from_str("t.*").unwrap(); + assert_eq!(ident.parts(), &[quote("t"), IdentPart::Asterisk]); + } + + #[test] + fn from_str_mid_star_is_demoted_to_quoted() { + let ident = Ident::from_str("*.id").unwrap(); + assert_eq!(ident.parts(), &[quote("*"), quote("id")]); + } + + #[test] + fn from_str_rejects_empty_and_empty_chunks() { + assert_eq!(Ident::from_str("").unwrap_err(), ParseIdentError::Empty); + assert_eq!( + Ident::from_str("a.").unwrap_err(), + ParseIdentError::EmptyChunk + ); + assert_eq!( + Ident::from_str(".a").unwrap_err(), + ParseIdentError::EmptyChunk + ); + assert_eq!( + Ident::from_str("a..b").unwrap_err(), + ParseIdentError::EmptyChunk + ); + } + + #[test] + fn from_str_rejects_too_many_parts() { + let ten = (0..Ident::MAX_PARTS) + .map(|i| format!("p{i}")) + .collect::>() + .join("."); + assert!(Ident::from_str(&ten).is_ok()); + + let eleven = format!("{ten}.extra"); + assert_eq!( + Ident::from_str(&eleven).unwrap_err(), + ParseIdentError::TooManyParts + ); + } + + #[test] + fn from_parts_rejects_empty() { + assert_eq!(Ident::from_parts([]).unwrap_err(), ParseIdentError::Empty); + } + + #[test] + fn from_parts_rejects_empty_quoted_part() { + assert_eq!( + Ident::from_parts([IdentPart::Quote(ArcStr::from(""))]).unwrap_err(), + ParseIdentError::EmptyChunk + ); + } + + #[test] + fn from_parts_rejects_asterisk_not_last() { + assert_eq!( + Ident::from_parts([IdentPart::Asterisk, quote("id")]).unwrap_err(), + ParseIdentError::AsteriskNotLast + ); + } + + #[test] + fn unquoted_rejects_invalid() { + for bad in ["", "*", "a;b", "a.b", "1id", "a-b", "a b", "cafĆ©"] { + let err = IdentPart::unquoted(bad).unwrap_err(); + assert!( + err == ParseIdentError::InvalidUnquoted || err == ParseIdentError::EmptyChunk, + "{bad:?} -> {err:?}" + ); + } + } + + #[test] + fn unquoted_accepts_simple_ascii_names() { + for ok in ["Users", "id", "_x", "a1", "created_at"] { + let part = IdentPart::unquoted(ok).unwrap(); + match part { + IdentPart::Unquote(n) => assert_eq!(n.as_str(), ok), + _ => panic!("expected Unquote"), + } + } + } + + #[test] + fn quoted_star_is_not_asterisk() { + let part = quote("*"); + assert_ne!(part, IdentPart::Asterisk); + match part { + IdentPart::Quote(n) => assert_eq!(n.as_str(), "*"), + _ => panic!("expected Quote"), + } + } + + #[test] + fn quote_unquote_asterisk_are_not_equal() { + let q = quote("id"); + let u = IdentPart::unquoted("id").unwrap(); + assert_ne!(q, u); + assert_ne!(q, IdentPart::Asterisk); + assert_ne!(u, IdentPart::Asterisk); + assert_ne!(ident_from([q.clone()]), ident_from([u.clone()])); + } + + #[test] + fn from_one_is_single_part() { + let ident = Ident::from_one(quote("id")); + assert_eq!(ident.len(), 1); + assert_eq!(ident.parts(), &[quote("id")]); + } + + #[test] + fn parse_ident_error_display_is_not_todo() { + let msg = ParseIdentError::Empty.to_string(); + assert!(!msg.is_empty()); + assert!(!msg.contains("todo")); + } + + #[test] + fn prepare_uses_dialect_delimiters() { + fn render(ident: &Ident, dialect: SqlDialect) -> String { + let mut buf = String::new(); + for (i, part) in ident.parts().iter().enumerate() { + if i > 0 { + buf.push('.'); + } + match part { + IdentPart::Quote(n) => { + dialect.quote_identifier(&mut buf, n.as_str()).unwrap(); + } + IdentPart::Unquote(n) => buf.push_str(n.as_str()), + IdentPart::Asterisk => buf.push('*'), + } + } + buf + } + + let ident = ident_from([quote("id")]); + assert_eq!(render(&ident, SqlDialect::PostgreSql), "\"id\""); + assert_eq!(render(&ident, SqlDialect::MySql), "`id`"); + assert_eq!(render(&ident, SqlDialect::MsSql), "[id]"); + assert_eq!(render(&ident, SqlDialect::Teradata), "'id'"); + + let ident = ident_from([quote("a\"b")]); + assert_eq!(render(&ident, SqlDialect::PostgreSql), "\"a\"\"b\""); + + let ident = ident_from([quote("a`b")]); + assert_eq!(render(&ident, SqlDialect::MySql), "`a``b`"); + + let ident = ident_from([quote("a]b")]); + assert_eq!(render(&ident, SqlDialect::MsSql), "[a]]b]"); + + let ident = ident_from([quote("a'b")]); + assert_eq!(render(&ident, SqlDialect::Teradata), "'a''b'"); + + let ident = ident_from([quote("a; DROP TABLE t--")]); + assert_eq!( + render(&ident, SqlDialect::PostgreSql), + "\"a; DROP TABLE t--\"" + ); + + let ident = ident_from([ + quote("public"), + IdentPart::unquoted("Users").unwrap(), + quote("id"), + ]); + assert_eq!( + render(&ident, SqlDialect::PostgreSql), + "\"public\".Users.\"id\"" + ); + + let ident = ident_from([quote("t"), IdentPart::Asterisk]); + assert_eq!(render(&ident, SqlDialect::MySql), "`t`.*"); + } +} diff --git a/src/pybindings/data_type/serialize.rs b/src/pybindings/data_type/serialize.rs index af03fa7..b9e7e3d 100644 --- a/src/pybindings/data_type/serialize.rs +++ b/src/pybindings/data_type/serialize.rs @@ -11,7 +11,7 @@ use pyo3::types::{PyAnyMethods, PyTzInfoAccess}; use super::serializer::SerializerInner; use crate::internal::alias; -use crate::internal::utils::get_type_name; +use crate::internal::utils::{get_type_name, py_str}; use crate::native::dialect::SqlDialect; impl SerializerInner { @@ -50,28 +50,6 @@ impl SerializerInner { } } -/// Borrow the cached UTF-8 buffer of a Python `str`. -/// -/// # Safety -/// `ptr` is a live unicode object. The slice is valid until that object is -/// mutated or the GIL is released. -#[inline] -unsafe fn py_str<'a>( - py: pyo3::Python<'_>, - ptr: *mut pyo3::ffi::PyObject, -) -> pyo3::PyResult<&'a str> { - let mut size: pyo3::ffi::Py_ssize_t = 0; - let data = pyo3::ffi::PyUnicode_AsUTF8AndSize(ptr, &mut size); - if data.is_null() || size < 0 { - Err(pyo3::PyErr::fetch(py)) - } else { - Ok(std::str::from_utf8_unchecked(std::slice::from_raw_parts( - data as *const u8, - size as usize, - ))) - } -} - /// `str(obj)` copied into a Rust `String`. Used when the unicode is a /// temporary (`PyObject_Str`) that must be `DECREF`'d before return. #[inline] diff --git a/src/pybindings/ident.rs b/src/pybindings/ident.rs index 4fc2ce1..aa5b672 100644 --- a/src/pybindings/ident.rs +++ b/src/pybindings/ident.rs @@ -13,10 +13,14 @@ //! `Asterisk`. Dotted names are `Ident("schema", "table")`; the native //! [`FromStr`](std::str::FromStr) parser is not used on the Python constructor. -use pyo3::types::{PyAnyMethods, PySliceMethods}; +use pyo3::types::{PyAnyMethods, PySliceMethods, PyTupleMethods}; +use smallvec::SmallVec; use crate::{ - internal::{alias, utils::IndexOrSlice}, + internal::{ + alias, + utils::{IndexOrSlice, py_str}, + }, native::{self, prepare_statement::prepare}, }; @@ -34,7 +38,9 @@ fn ident_part_to_pyobject<'py>( ) -> pyo3::PyResult> { match ident_part { native::IdentPart::Quote(s) => Ok(pyo3::types::PyString::new(py, s.as_str()).into_any()), - native::IdentPart::Unquote(s) => Ok(pyo3::Bound::new(py, PyUnquote(s.clone()))?.into_any()), + native::IdentPart::Unquote(s) => { + Ok(pyo3::Bound::new(py, PyUnquote(arcstr::ArcStr::from(s.as_str())))?.into_any()) + } native::IdentPart::Asterisk => Ok(pyo3::Bound::new(py, PyAsterisk)?.into_any()), } } @@ -56,6 +62,49 @@ unsafe fn is_asterisk(obj: *mut pyo3::ffi::PyObject) -> bool { type_ptr == crate::typeref::ASTERISK_TYPE } +enum Extracted { + Ident(native::Ident), + Part(native::IdentPart), +} + +/// Hot-path extract: `str` first, then Ident / Unquote / Asterisk. +#[inline] +fn extract_arg( + obj: pyo3::prelude::Borrowed<'_, '_, pyo3::prelude::PyAny>, +) -> pyo3::PyResult { + let ptr = obj.as_ptr(); + unsafe { + // SAFETY: `ptr` is a live borrowed `PyObject`. Type pointers are + // interned in `initialize_typeref` before any extractor runs. + if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { + let s = py_str(obj.py(), ptr)?; + let part = native::IdentPart::quoted(s).map_err(parse_err)?; + return Ok(Extracted::Part(part)); + } + + if crate::typeref::check_exact(ptr, crate::typeref::IDENT_TYPE) { + let ident = obj.cast_unchecked::(); + return Ok(Extracted::Ident(ident.get().0.clone())); + } + + if crate::typeref::check_exact(ptr, crate::typeref::UNQUOTE_TYPE) { + let unquote = obj.cast_unchecked::(); + let part = native::IdentPart::unquoted(unquote.get().0.as_str()).map_err(parse_err)?; + return Ok(Extracted::Part(part)); + } + + if is_asterisk(ptr) { + return Ok(Extracted::Part(native::IdentPart::Asterisk)); + } + } + + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected str, Ident, Unquote or Asterisk, got {}", + unsafe { crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) } + ) +} + /// Extracts a native [`Ident`](crate::native::Ident) from a Python object. /// /// Accepts `Ident`, `str` (one quoted part, including `"*"`), `Unquote`, @@ -65,42 +114,14 @@ pub struct IdentExtractor(pub native::Ident); impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for IdentExtractor { type Error = pyo3::PyErr; + #[inline] fn extract( obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, ) -> Result { - unsafe { - // SAFETY: type pointers are interned in `initialize_typeref` before - // any extractor runs; `obj` is a valid borrowed `PyObject`. - if crate::typeref::check(obj.as_ptr(), crate::typeref::IDENT_TYPE) { - let ident = obj.cast_unchecked::(); - return Ok(Self(ident.get().0.clone())); - } - - if crate::typeref::check_exact(obj.as_ptr(), crate::typeref::UNQUOTE_TYPE) { - let unquote = obj.cast_unchecked::(); - let part = native::IdentPart::Unquote(unquote.get().0.clone()); - let ident = native::Ident::from_parts([part]).map_err(parse_err)?; - return Ok(Self(ident)); - } - - if is_asterisk(obj.as_ptr()) { - let ident = - native::Ident::from_parts([native::IdentPart::Asterisk]).map_err(parse_err)?; - return Ok(Self(ident)); - } + match extract_arg(obj)? { + Extracted::Ident(ident) => Ok(Self(ident)), + Extracted::Part(part) => Ok(Self(native::Ident::from_one(part))), } - - if let Ok(string) = obj.extract::<&str>() { - let part = native::IdentPart::quoted(string).map_err(parse_err)?; - let ident = native::Ident::from_parts([part]).map_err(parse_err)?; - return Ok(Self(ident)); - } - - rapidquery_macros::pyerr!( - Err(PyTypeError), - "expected str, Ident, Unquote or Asterisk, got {}", - unsafe { crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) } - ) } } @@ -129,6 +150,7 @@ pub struct PyIdent(pub native::Ident); impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyIdent { type Error = pyo3::PyErr; + #[inline] fn extract( obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, ) -> Result { @@ -140,11 +162,24 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyIdent { impl PyIdent { #[new] #[pyo3(signature = (*parts))] - fn __new__(parts: Vec) -> pyo3::PyResult { - let iter = parts - .into_iter() - .flat_map(|IdentExtractor(ident)| ident.into_parts()); - native::Ident::from_parts(iter).map(Self).map_err(parse_err) + fn __new__(parts: &pyo3::Bound<'_, pyo3::types::PyTuple>) -> pyo3::PyResult { + match parts.len() { + 0 => Err(parse_err(native::ParseIdentError::Empty)), + 1 => match extract_arg(parts.get_item(0)?.as_borrowed())? { + Extracted::Ident(ident) => Ok(Self(ident)), + Extracted::Part(part) => Ok(Self(native::Ident::from_one(part))), + }, + _ => { + let mut acc = SmallVec::<[native::IdentPart; 4]>::new(); + for item in parts.iter() { + match extract_arg(item.as_borrowed())? { + Extracted::Ident(ident) => acc.extend(ident.into_parts()), + Extracted::Part(part) => acc.push(part), + } + } + native::Ident::from_parts(acc).map(Self).map_err(parse_err) + } + } } fn __len__(&self) -> usize { @@ -290,11 +325,8 @@ impl PyUnquote { #[new] #[pyo3(signature = (name, /))] fn __new__(name: &str) -> pyo3::PyResult { - let part = native::IdentPart::unquoted(name).map_err(parse_err)?; - let native::IdentPart::Unquote(s) = part else { - unreachable!("IdentPart::unquoted returns Unquote"); - }; - Ok(Self(s)) + native::IdentPart::unquoted(name).map_err(parse_err)?; + Ok(Self(arcstr::ArcStr::from(name))) } /// Returns the unquoted identifier body. From 5d2e3b00bed4527ffd4c5468c42854f7c2ab4bb0 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Wed, 9 Sep 2026 13:00:48 +0330 Subject: [PATCH 18/87] Clean code --- src/pybindings/ident.rs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/pybindings/ident.rs b/src/pybindings/ident.rs index aa5b672..d3cd42f 100644 --- a/src/pybindings/ident.rs +++ b/src/pybindings/ident.rs @@ -16,13 +16,10 @@ use pyo3::types::{PyAnyMethods, PySliceMethods, PyTupleMethods}; use smallvec::SmallVec; -use crate::{ - internal::{ - alias, - utils::{IndexOrSlice, py_str}, - }, - native::{self, prepare_statement::prepare}, -}; +use crate::internal::alias; +use crate::internal::utils; +use crate::native; +use crate::native::prepare_statement::prepare; #[inline] fn parse_err(err: native::ParseIdentError) -> pyo3::PyErr { @@ -77,7 +74,7 @@ fn extract_arg( // SAFETY: `ptr` is a live borrowed `PyObject`. Type pointers are // interned in `initialize_typeref` before any extractor runs. if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { - let s = py_str(obj.py(), ptr)?; + let s = utils::py_str(obj.py(), ptr)?; let part = native::IdentPart::quoted(s).map_err(parse_err)?; return Ok(Extracted::Part(part)); } @@ -202,13 +199,13 @@ impl PyIdent { fn __getitem__<'py>( &self, py: pyo3::Python<'py>, - index: IndexOrSlice, + index: utils::IndexOrSlice, ) -> pyo3::PyResult> { let parts = self.0.parts(); let len = parts.len() as isize; match index { - IndexOrSlice::Index(i) => { + utils::IndexOrSlice::Index(i) => { let idx = if i < 0 { i + len } else { i }; if idx < 0 || idx >= len { return rapidquery_macros::pyerr!( @@ -218,7 +215,7 @@ impl PyIdent { } ident_part_to_pyobject(py, &parts[idx as usize]) } - IndexOrSlice::Slice(slice) => { + utils::IndexOrSlice::Slice(slice) => { let indices = slice.bind(py).indices(len)?; let mut selected = Vec::with_capacity(indices.slicelength); From a304c946c8d200b43ca90ff351ce568b6d88a41e Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Wed, 9 Sep 2026 14:54:55 +0330 Subject: [PATCH 19/87] refactor prepare_statement, and move serializers to `native` --- AGENTS.md | 24 +- Cargo.toml | 1 + src/native/data_type/mod.rs | 8 +- src/native/data_type/name.rs | 18 +- src/native/dialect/mod.rs | 2 - src/native/ident.rs | 26 +- src/native/mod.rs | 6 +- src/native/prepare_statement.rs | 198 ----------- .../data_type => native}/serialize.rs | 8 +- src/native/serializer.rs | 97 ++++++ src/native/sql.rs | 315 ++++++++++++++++++ src/pybindings/data_type/mod.rs | 9 +- src/pybindings/data_type/serializer.rs | 179 +++------- src/pybindings/ident.rs | 8 +- src/pybindings/mod.rs | 6 +- 15 files changed, 516 insertions(+), 389 deletions(-) delete mode 100644 src/native/prepare_statement.rs rename src/{pybindings/data_type => native}/serialize.rs (99%) create mode 100644 src/native/serializer.rs create mode 100644 src/native/sql.rs diff --git a/AGENTS.md b/AGENTS.md index 244b2c3..ee847f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,9 +8,9 @@ Goals from the PR (keep these): - No SeaQuery panics; production-safe SQL building. - 13 dialects (BigQuery, Databricks, DuckDB, Hive, MSSQL, MySQL, Oracle, PostgreSQL, Redshift, Snowflake, Spark, SQLite, Teradata). - Full SQL data-type coverage via interned `DataTypeName` + `DataTypeSerializer`. -- Native Rust vs Python bindings split (`src/native/` vs `src/pybindings/`). -- Parameterized `PrepareStatement` rendering (writer-based, no extra String allocs on the hot path). -- Ident / Asterisk / IdentList with dialect quoting. +- Native Rust vs Python bindings split (`src/native/` vs `src/pybindings/`). Native may use PyO3 where it holds or inspects Python objects (`Serializer`, `PrepareContext`). +- Dual SQL rendering via `WriteSql` + `SqlWriter`: raw (`SqlBuf`, literals) and parameterized (`PrepareContext`, placeholders). +- Ident / Asterisk with dialect quoting. - Leave the door open for a SQL parser and an ORM later. - Prefer backward compatibility with v0, but it is **not** guaranteed. @@ -20,27 +20,27 @@ Keep these `refactor/v1` divergences from the PR (do not revert them): - Module name `rapidquery._core` (PR used `_native`). - Proc-macros `pyclass` / `pyerr` (PR used `pyclass_shortcut` + `new_pyerr!`). - MIT license, Python `>=3.10`, crate version `1.0.0-alpha1`. -- `PrepareContext` / `PrepareError` naming in `prepare_statement.rs`. +- `PrepareContext` / `PrepareError` naming in `sql.rs` (PR used `PrepareStatementContext` / `PrepareStatementError`). ## Stack - Python `>=3.10` (local: 3.13). Rust edition **2024**. PyO3 **0.29**. Maturin **1.15.0**. uv for the venv. - Workspace crate `rapidquery-macros` (proc-macros: `pyclass`, `pyerr`). -- Target stack from PR #2 also used: `arcstr`, `once_cell`, `derive_more`, `chrono`, `ipnetwork`, `mac_address`. Re-add them when DataType/serializers/Ident land — not before. -- Dialects (13) and `PrepareStatement` exist on this branch. Still missing vs the PR: `DataType` + serializers, `Ident`/`Asterisk`/`IdentList`, `src/pybindings/`, `src/typeref.rs`, Python stubs, pytest suite. Query/schema AST was never in the PR either. +- Target stack from PR #2 also used: `arcstr`, `once_cell`, `chrono`, `ipnetwork`, `mac_address`. (`derive_more` is not on this branch.) +- Dialects (13), `DataType` + `Serializer`, `Ident`/`Unquote`/`Asterisk`, `SqlWriter`/`WriteSql` exist on this branch. Still missing vs the PR’s next layers: `Value`, query/schema AST (never in the PR either). - `#[pyo3::pymodule(gil_used = false)]` — keep free-threaded Python in mind. - Frozen/immutable PyO3 types: `rapidquery_macros::pyclass` already sets `module="rapidquery._core"`, `immutable_type`, `frozen`. ## Layout ``` -src/native/ Rust engine (dialects, prepare, later data_type + ident) -src/pybindings/ Python wrappers (missing on this branch; required by the PR) +src/native/ Rust engine (dialects, DataTypeName, Ident, Serializer, SqlWriter/WriteSql) +src/pybindings/ Python wrappers (pyclasses only) src/internal/ aliases, string utils, submodule helper -src/typeref.rs cached PyTypeObject pointers (missing; required) -rapidquery/ Python package (stub here; PR re-exports Ident, Asterisk, data_type, dialect) +src/typeref.rs cached PyTypeObject pointers +rapidquery/ Python package (re-exports Ident, Unquote, Asterisk, data_type, dialect) rapidquery-macros/ Proc-macros -tests/ Python tests (empty here; PR has dialect/data_type/serializer tests) +tests/ Python tests backup/, backup-py/ gitignored v0 snapshots — read-only target/, .venv/ Generated — do not commit or edit ``` @@ -66,7 +66,7 @@ After meaningful Rust or Python changes: run `cargo test --workspace --lib --bin ## How to edit -- Small, reviewable diffs. Match existing patterns on **this** branch first; for missing layers, match `origin/migrate` (native types stay PyO3-free; `pybindings` wraps them; serializers subclass a base `Serializer`; dialects subclass `SqlDialect` and are `@final`). +- Small, reviewable diffs. Match existing patterns on **this** branch first; for missing layers, match `origin/migrate` (native owns dialects, `DataTypeName`, `Ident`, `Serializer`+`write_sql`, `SqlWriter`/`WriteSql`; pybindings are pyclasses wrapping those; serializers subclass a base `Serializer`; dialects subclass `SqlDialect` and are `@final`). Native may use PyO3 where it holds or inspects Python objects. - Do not introduce SeaQuery, a sqlparser-rs crate dependency, SQLAlchemy, another Python package layout, or a new build backend. - Do not touch `Cargo.lock` / `uv.lock` unless the change requires a dependency update. Do not touch generated artifacts (`target/`, `*.so`, wheels), secrets, or `.env`. - `backup/` and `backup-py/` are gitignored snapshots of v0. Do not edit them; do not copy them wholesale into `src/`. diff --git a/Cargo.toml b/Cargo.toml index 2559c9d..19988b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ incremental = false lto = true panic = "abort" strip = false +opt-level = 3 [dependencies] rapidquery-macros = { path = "rapidquery-macros" } diff --git a/src/native/data_type/mod.rs b/src/native/data_type/mod.rs index ba4eb4b..947ea6b 100644 --- a/src/native/data_type/mod.rs +++ b/src/native/data_type/mod.rs @@ -1,10 +1,10 @@ //! SQL data-type names. //! //! A [`DataTypeName`] is a canonical spelling plus a [`DataTypeGroup`]. The -//! group is the inference key for which Python serializer `DataType(name)` -//! constructs when none is passed. Groups are serializer-oriented: `NUMERIC` -//! and `DECIMAL` sit in [`DataTypeGroup::Float`] because they share a -//! serializer with IEEE floats. +//! group is the inference key for which [`crate::native::Serializer`] +//! `DataType(name)` constructs when none is passed. Groups are +//! serializer-oriented: `NUMERIC` and `DECIMAL` sit in +//! [`DataTypeGroup::Float`] because they share a serializer with IEEE floats. mod name; diff --git a/src/native/data_type/name.rs b/src/native/data_type/name.rs index dc3dd98..926ffa8 100644 --- a/src/native/data_type/name.rs +++ b/src/native/data_type/name.rs @@ -1,11 +1,11 @@ -use crate::native::prepare_statement::PrepareStatement; +use crate::native::sql::{PrepareResult, SqlWriter, WriteSql}; /// Category used to infer a built-in serializer from a type name. /// /// Distinct SQL spellings collapse onto one serializer (every integer width -/// is [`InferredSerializer::Integer`]). [`Json`] and [`Jsonb`] stay distinct -/// as groups so a later layer can treat storage differently; both currently -/// infer [`InferredSerializer::Json`]. +/// is [`crate::native::Serializer::Integer`]). [`Json`] and [`Jsonb`] stay +/// distinct as groups so a later layer can treat storage differently; both +/// currently infer [`crate::native::Serializer::Json`]. #[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord, Hash)] pub enum DataTypeGroup { String, @@ -260,13 +260,13 @@ impl std::fmt::Display for DataTypeName { } } -impl PrepareStatement for DataTypeName { - fn prepare( +impl WriteSql for DataTypeName { + fn write_sql( &self, _dialect: &crate::native::dialect::SqlDialect, - context: &mut crate::native::prepare_statement::PrepareContext, - ) -> crate::native::prepare_statement::PrepareResult<()> { - context.push_str(self.as_str()); + out: &mut W, + ) -> PrepareResult<()> { + out.push_str(self.as_str()); Ok(()) } } diff --git a/src/native/dialect/mod.rs b/src/native/dialect/mod.rs index 729f409..817f392 100644 --- a/src/native/dialect/mod.rs +++ b/src/native/dialect/mod.rs @@ -2,8 +2,6 @@ //! //! A dialect encodes how one database engine quotes identifiers, escapes //! string/byte/bit literals, formats booleans, and spells named placeholders. -//! It is **not** a parser and **not** a statement builder — those sit on top -//! of [`SqlDialect`] via [`crate::native::prepare_statement`]. //! //! Dialects are selected at runtime through the [`SqlDialect`] enum. Each //! variant is a marker; behavior lives in the matching private module. diff --git a/src/native/ident.rs b/src/native/ident.rs index be3f46e..9802a96 100644 --- a/src/native/ident.rs +++ b/src/native/ident.rs @@ -7,7 +7,7 @@ use smallvec::{SmallVec, smallvec}; use crate::native::{ dialect::SqlDialect, - prepare_statement::{PrepareResult, PrepareStatement, prepare_separated}, + sql::{PrepareResult, SqlWriter, WriteSql, write_separated}, }; /// One segment of a dotted identifier. @@ -65,22 +65,18 @@ impl> From for IdentPart { } } -impl PrepareStatement for IdentPart { +impl WriteSql for IdentPart { #[inline] - fn prepare( - &self, - dialect: &SqlDialect, - context: &mut crate::native::prepare_statement::PrepareContext<'_>, - ) -> PrepareResult<()> { + fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { match self { Self::Quote(name) => { - dialect.quote_identifier(context, name.as_str())?; + dialect.quote_identifier(out, name.as_str())?; } Self::Unquote(name) => { - context.push_str(name.as_str()); + out.push_str(name.as_str()); } Self::Asterisk => { - context.push_char('*'); + out.push_char('*'); } } Ok(()) @@ -232,14 +228,10 @@ impl std::str::FromStr for Ident { } } -impl PrepareStatement for Ident { +impl WriteSql for Ident { #[inline] - fn prepare( - &self, - dialect: &SqlDialect, - context: &mut crate::native::prepare_statement::PrepareContext<'_>, - ) -> PrepareResult<()> { - prepare_separated(dialect, context, &self.0, ".") + fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { + write_separated(dialect, out, &self.0, ".") } } diff --git a/src/native/mod.rs b/src/native/mod.rs index 3b68edf..3768b70 100644 --- a/src/native/mod.rs +++ b/src/native/mod.rs @@ -1,6 +1,10 @@ pub mod data_type; pub mod dialect; -pub mod prepare_statement; +pub mod serializer; +pub mod sql; mod ident; +mod serialize; + pub use ident::{Ident, IdentPart, ParseIdentError}; +pub use serializer::Serializer; diff --git a/src/native/prepare_statement.rs b/src/native/prepare_statement.rs deleted file mode 100644 index 85b98f0..0000000 --- a/src/native/prepare_statement.rs +++ /dev/null @@ -1,198 +0,0 @@ -use std::fmt; - -use hashbrown::HashMap; - -use crate::{internal::alias, native::dialect::SqlDialect}; - -/// Error produced while building a prepared statement. -#[derive(Debug)] -pub enum PrepareError { - /// A [`fmt::Write`] implementation returned [`fmt::Error`]. - Write, - /// An error that originated in Python (e.g. from `.extract()`). - Python(pyo3::PyErr), -} - -impl fmt::Display for PrepareError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Write => f.write_str("failed to write SQL"), - Self::Python(err) => write!(f, "{err}"), - } - } -} - -impl std::error::Error for PrepareError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Write => None, - Self::Python(err) => Some(err), - } - } -} - -impl From for PrepareError { - fn from(_: fmt::Error) -> Self { - Self::Write - } -} - -impl From for PrepareError { - fn from(err: pyo3::PyErr) -> Self { - Self::Python(err) - } -} - -pub type PrepareResult = Result; -pub type PrepareParams = HashMap; - -/// Accumulates rendered SQL and the bound-parameter map. -/// -/// Implements [`fmt::Write`], so `write!` / `writeln!` work directly: -/// `write!(ctx, "{name} = ")?;` -pub struct PrepareContext<'py> { - py: pyo3::Python<'py>, - sql: String, - params: PrepareParams, - next_id: usize, -} - -impl<'py> PrepareContext<'py> { - #[inline] - pub fn new(py: pyo3::Python<'py>) -> Self { - Self { - py, - sql: String::with_capacity(256), - params: HashMap::new(), - next_id: 0, - } - } - - #[inline] - pub fn py(&self) -> pyo3::Python<'py> { - self.py - } - - #[inline] - pub fn sql(&self) -> &str { - &self.sql - } - - #[inline] - pub fn params(&self) -> &PrepareParams { - &self.params - } - - #[inline] - pub fn into_parts(self) -> (String, PrepareParams) { - (self.sql, self.params) - } - - #[inline] - fn alloc_param_name(&mut self) -> String { - let name = format!("param_{}", self.next_id); - self.next_id += 1; - name - } -} - -impl PrepareContext<'_> { - /// Register `param` and return its placeholder (`@param_N` / `:param_N` / `$param_N`). - /// - /// Prefer [`push_param`](Self::push_param) when the placeholder is only going - /// to be appended to the SQL — that path does not allocate the placeholder - /// `String`. - #[inline] - #[must_use = "write the placeholder into the SQL, or the parameter is never referenced"] - pub fn add_param(&mut self, dialect: &SqlDialect, param: pyo3::Py) -> String { - let name = self.alloc_param_name(); - let mut placeholder = String::with_capacity(1 + name.len()); - placeholder.push(dialect.placeholder_syntax().sigil()); - placeholder.push_str(&name); - self.params.insert(name, param); - placeholder - } - - /// Register `param` and append its placeholder to the SQL in one step. - /// - /// Returns `&mut Self` so calls can be chained: - /// - /// ```ignore - /// context - /// .push_str("SELECT * FROM t WHERE id = ") - /// .push_param(dialect, id) - /// .push_str(" AND name = ") - /// .push_param(dialect, name); - /// ``` - #[inline] - pub fn push_param(&mut self, dialect: &SqlDialect, param: pyo3::Py) -> &mut Self { - let name = self.alloc_param_name(); - self.sql.push(dialect.placeholder_syntax().sigil()); - self.sql.push_str(&name); - self.params.insert(name, param); - self - } - - #[inline] - pub fn push_char(&mut self, c: char) -> &mut Self { - self.sql.push(c); - self - } - - #[inline] - pub fn push_str(&mut self, s: &str) -> &mut Self { - self.sql.push_str(s); - self - } -} - -impl fmt::Write for PrepareContext<'_> { - #[inline] - fn write_str(&mut self, s: &str) -> fmt::Result { - self.sql.push_str(s); - Ok(()) - } - - #[inline] - fn write_char(&mut self, c: char) -> fmt::Result { - self.sql.push(c); - Ok(()) - } -} - -pub trait PrepareStatement { - fn prepare(&self, dialect: &SqlDialect, context: &mut PrepareContext<'_>) -> PrepareResult<()>; -} - -/// Rust-only entry point. Safe to call from non-pyo3 code as long as a -/// `Python<'_>` token and dialect are available. -pub fn prepare( - py: pyo3::Python<'_>, - stmt: &T, - dialect: &SqlDialect, -) -> PrepareResult<(String, PrepareParams)> { - let mut context = PrepareContext::new(py); - stmt.prepare(dialect, &mut context)?; - Ok(context.into_parts()) -} - -/// Prepare each item in order, writing `separator` between them -/// (not before the first or after the last). -/// -/// Use `","` / `", "` for lists and `" AND "` / `" OR "` for predicates. -pub fn prepare_separated<'a, T: PrepareStatement + 'a>( - dialect: &SqlDialect, - context: &mut PrepareContext<'_>, - items: impl IntoIterator, - separator: &str, -) -> PrepareResult<()> { - let mut first = true; - for item in items { - if !first { - context.push_str(separator); - } - first = false; - item.prepare(dialect, context)?; - } - Ok(()) -} diff --git a/src/pybindings/data_type/serialize.rs b/src/native/serialize.rs similarity index 99% rename from src/pybindings/data_type/serialize.rs rename to src/native/serialize.rs index b9e7e3d..f3a8944 100644 --- a/src/pybindings/data_type/serialize.rs +++ b/src/native/serialize.rs @@ -9,13 +9,13 @@ use std::str::FromStr; use pyo3::types::{PyAnyMethods, PyTzInfoAccess}; -use super::serializer::SerializerInner; use crate::internal::alias; use crate::internal::utils::{get_type_name, py_str}; use crate::native::dialect::SqlDialect; +use crate::native::serializer::Serializer; -impl SerializerInner { - pub(crate) fn write_sql( +impl Serializer { + pub fn write_sql( &self, dialect: &SqlDialect, object: &alias::BoundObject<'_>, @@ -471,7 +471,7 @@ unsafe fn write_array( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, dialect: &SqlDialect, - element: &SerializerInner, + element: &Serializer, out: &mut String, ) -> pyo3::PyResult<()> { let (len, getter): ( diff --git a/src/native/serializer.rs b/src/native/serializer.rs new file mode 100644 index 0000000..6bdecd4 --- /dev/null +++ b/src/native/serializer.rs @@ -0,0 +1,97 @@ +//! Value-serialization tree. +//! +//! [`Serializer`] turns a Python object into a SQL literal. Unit variants are +//! inferred from [`DataTypeGroup`]. [`Serializer::Array`] and +//! [`Serializer::Custom`] carry state (`Custom` holds a Python callable). +//! +//! Python sees frozen subclasses wrapping this enum (`IntegerSerializer`, …). +//! The write path lives in [`super::serialize`]. + +use std::hash::{Hash, Hasher}; + +use crate::internal::alias; +use crate::native::data_type::DataTypeGroup; + +/// How a Python object is written as a SQL literal. +#[derive(Debug, Clone)] +pub enum Serializer { + Integer, + Float, + Boolean, + String, + Uuid, + Bytes, + Bit, + Json, + Date, + Time, + Datetime, + Inet, + MacAddress, + Unspecified, + Array(Box), + Custom(alias::PyObject), +} + +impl PartialEq for Serializer { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Integer, Self::Integer) + | (Self::Float, Self::Float) + | (Self::Boolean, Self::Boolean) + | (Self::String, Self::String) + | (Self::Uuid, Self::Uuid) + | (Self::Bytes, Self::Bytes) + | (Self::Bit, Self::Bit) + | (Self::Json, Self::Json) + | (Self::Date, Self::Date) + | (Self::Time, Self::Time) + | (Self::Datetime, Self::Datetime) + | (Self::Inet, Self::Inet) + | (Self::MacAddress, Self::MacAddress) + | (Self::Unspecified, Self::Unspecified) => true, + (Self::Array(a), Self::Array(b)) => a == b, + (Self::Custom(a), Self::Custom(b)) => std::ptr::eq(a.as_ptr(), b.as_ptr()), + _ => false, + } + } +} + +impl Eq for Serializer {} + +impl Hash for Serializer { + fn hash(&self, state: &mut H) { + std::mem::discriminant(self).hash(state); + match self { + Self::Array(element) => element.hash(state), + Self::Custom(function) => (function.as_ptr() as usize).hash(state), + _ => {} + } + } +} + +impl Serializer { + /// Built-in serializer for a [`DataTypeGroup`]. + /// + /// [`DataTypeGroup::Custom`] maps to [`Self::Unspecified`]. Json and Jsonb + /// share [`Self::Json`]. + #[inline] + pub fn from_group(kind: DataTypeGroup) -> Self { + match kind { + DataTypeGroup::Integer => Self::Integer, + DataTypeGroup::Float => Self::Float, + DataTypeGroup::Boolean => Self::Boolean, + DataTypeGroup::String => Self::String, + DataTypeGroup::Uuid => Self::Uuid, + DataTypeGroup::Bytes => Self::Bytes, + DataTypeGroup::Bit => Self::Bit, + DataTypeGroup::Json | DataTypeGroup::Jsonb => Self::Json, + DataTypeGroup::Date => Self::Date, + DataTypeGroup::Time => Self::Time, + DataTypeGroup::Datetime => Self::Datetime, + DataTypeGroup::Inet => Self::Inet, + DataTypeGroup::MacAddress => Self::MacAddress, + DataTypeGroup::Custom => Self::Unspecified, + } + } +} diff --git a/src/native/sql.rs b/src/native/sql.rs new file mode 100644 index 0000000..0ca723a --- /dev/null +++ b/src/native/sql.rs @@ -0,0 +1,315 @@ +//! SQL writers: raw (`SqlBuf`) and parameterized (`PrepareContext`). +//! +//! One AST trait, [`WriteSql`], walks a node into any [`SqlWriter`]. Identifiers +//! and type names only push text. Values (when they land) call +//! [`SqlWriter::push_value`]: raw inlines a literal, prepared emits a +//! placeholder. + +use hashbrown::HashMap; +use pyo3::types::PyAnyMethods; + +use crate::internal::alias; +use crate::native::dialect::SqlDialect; +use crate::native::serializer::Serializer; + +/// Error produced while building SQL. +#[derive(Debug)] +pub enum PrepareError { + /// A [`std::fmt::Write`] implementation returned [`std::fmt::Error`]. + Write, + /// An error that originated in Python (e.g. from `.extract()`). + Python(pyo3::PyErr), +} + +impl std::fmt::Display for PrepareError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Write => f.write_str("failed to write SQL"), + Self::Python(err) => write!(f, "{err}"), + } + } +} + +impl std::error::Error for PrepareError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Write => None, + Self::Python(err) => Some(err), + } + } +} + +impl From for PrepareError { + fn from(_: std::fmt::Error) -> Self { + Self::Write + } +} + +impl From for PrepareError { + fn from(err: pyo3::PyErr) -> Self { + Self::Python(err) + } +} + +pub type PrepareResult = Result; +pub type PrepareParams = HashMap; + +/// Output target for [`WriteSql`]. +/// +/// [`SqlBuf`] inlines values as SQL literals. [`PrepareContext`] binds them as +/// named placeholders. Everything else is `push_str` / `push_char`. +pub trait SqlWriter: std::fmt::Write { + fn push_str(&mut self, s: &str) -> &mut Self; + fn push_char(&mut self, c: char) -> &mut Self; + + /// Raw (`SqlBuf`): `None` → `NULL`, else `serializer.write_sql` into the + /// SQL buffer. Prepared: bind `object` as a placeholder; `serializer` is + /// unused. + fn push_value<'py>( + &mut self, + dialect: &SqlDialect, + serializer: &Serializer, + object: &alias::PyObject, + ) -> PrepareResult<()>; +} + +/// Raw SQL buffer. No parameter map. +pub struct BufferedSql<'py> { + py: pyo3::Python<'py>, + sql: String, +} + +impl<'py> BufferedSql<'py> { + #[inline] + pub fn new(py: pyo3::Python<'py>) -> Self { + Self { + py, + sql: String::new(), + } + } + + #[inline] + pub fn with_capacity(py: pyo3::Python<'py>, capacity: usize) -> Self { + Self { + py, + sql: String::with_capacity(capacity), + } + } + + #[inline] + pub fn py(&self) -> pyo3::Python<'py> { + self.py + } + + #[inline] + pub fn as_str(&self) -> &str { + &self.sql + } + + #[inline] + pub fn into_string(self) -> String { + self.sql + } +} + +impl<'py> SqlWriter for BufferedSql<'py> { + #[inline] + fn push_str(&mut self, s: &str) -> &mut Self { + self.sql.push_str(s); + self + } + + #[inline] + fn push_char(&mut self, c: char) -> &mut Self { + self.sql.push(c); + self + } + + fn push_value( + &mut self, + dialect: &SqlDialect, + serializer: &Serializer, + object: &alias::PyObject, + ) -> PrepareResult<()> { + let bound = object.bind(self.py); + if bound.is_none() { + self.sql.push_str("NULL"); + return Ok(()); + } + serializer.write_sql(dialect, bound, &mut self.sql)?; + Ok(()) + } +} + +impl<'py> std::fmt::Write for BufferedSql<'py> { + #[inline] + fn write_str(&mut self, s: &str) -> std::fmt::Result { + self.sql.push_str(s); + Ok(()) + } + + #[inline] + fn write_char(&mut self, c: char) -> std::fmt::Result { + self.sql.push(c); + Ok(()) + } +} + +/// Accumulates rendered SQL and the bound-parameter map. +/// +/// Implements [`std::fmt::Write`], so `write!` / `writeln!` work directly: +/// `write!(ctx, "{name} = ")?;` +pub struct ParameterizedSql<'py> { + py: pyo3::Python<'py>, + sql: String, + params: PrepareParams, + next_id: usize, +} + +impl<'py> ParameterizedSql<'py> { + #[inline] + pub fn new(py: pyo3::Python<'py>) -> Self { + Self { + py, + sql: String::new(), + params: HashMap::new(), + next_id: 0, + } + } + + #[inline] + pub fn with_capacity(py: pyo3::Python<'py>, capacity: usize) -> Self { + Self { + py, + sql: String::with_capacity(capacity), + params: HashMap::new(), + next_id: 0, + } + } + + #[inline] + pub fn py(&self) -> pyo3::Python<'py> { + self.py + } + + #[inline] + pub fn sql(&self) -> &str { + self.sql.as_str() + } + + #[inline] + pub fn params(&self) -> &PrepareParams { + &self.params + } + + #[inline] + pub fn into_parts(self) -> (String, PrepareParams) { + (self.sql, self.params) + } +} + +impl ParameterizedSql<'_> { + /// Register `param` and append its placeholder to the SQL in one step. + #[inline] + pub fn push_param(&mut self, dialect: &SqlDialect, param: alias::PyObject) -> &mut Self { + // allocate param name + let name = format!("param_{}", self.next_id); + self.next_id += 1; + + self.sql.push(dialect.placeholder_syntax().sigil()); + self.sql.push_str(&name); + self.params.insert(name, param); + self + } +} + +impl SqlWriter for ParameterizedSql<'_> { + #[inline] + fn push_str(&mut self, s: &str) -> &mut Self { + self.sql.push_str(s); + self + } + + #[inline] + fn push_char(&mut self, c: char) -> &mut Self { + self.sql.push(c); + self + } + + #[inline] + fn push_value( + &mut self, + dialect: &SqlDialect, + _serializer: &Serializer, + object: &alias::PyObject, + ) -> PrepareResult<()> { + self.push_param(dialect, object.clone_ref(self.py)); + Ok(()) + } +} + +impl std::fmt::Write for ParameterizedSql<'_> { + #[inline] + fn write_str(&mut self, s: &str) -> std::fmt::Result { + self.sql.push_str(s); + Ok(()) + } + + #[inline] + fn write_char(&mut self, c: char) -> std::fmt::Result { + self.sql.push(c); + Ok(()) + } +} + +/// Render `self` into `out`. The writer chooses literal vs placeholder for values. +pub trait WriteSql { + fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()>; +} + +/// Render `stmt` as raw SQL (values inlined as literals). +pub fn write_sql<'py, T: WriteSql>( + py: pyo3::Python<'py>, + stmt: &T, + dialect: &SqlDialect, +) -> PrepareResult { + let mut buf = BufferedSql::new(py); + stmt.write_sql(dialect, &mut buf)?; + Ok(buf.into_string()) +} + +/// Render `stmt` as a parameterized statement. +pub fn prepare<'py, T: WriteSql>( + py: pyo3::Python<'py>, + stmt: &T, + dialect: &SqlDialect, +) -> PrepareResult<(String, PrepareParams)> { + let mut context = ParameterizedSql::new(py); + stmt.write_sql(dialect, &mut context)?; + Ok(context.into_parts()) +} + +/// Write each item in order, writing `separator` between them +/// (not before the first or after the last). +/// +/// Use `","` / `", "` for lists and `" AND "` / `" OR "` for predicates. +pub fn write_separated<'a, T, W>( + dialect: &SqlDialect, + out: &mut W, + items: impl IntoIterator, + separator: &str, +) -> PrepareResult<()> +where + T: WriteSql + 'a, + W: SqlWriter, +{ + let mut first = true; + for item in items { + if !first { + out.push_str(separator); + } + first = false; + item.write_sql(dialect, out)?; + } + Ok(()) +} diff --git a/src/pybindings/data_type/mod.rs b/src/pybindings/data_type/mod.rs index 74cc49b..e32418d 100644 --- a/src/pybindings/data_type/mod.rs +++ b/src/pybindings/data_type/mod.rs @@ -1,20 +1,19 @@ //! Python wrappers for SQL data types and value serializers. //! -//! Native [`DataTypeName`](crate::native::data_type::DataTypeName) is -//! PyO3-free. This module owns the serializer tree and the `DataType` -//! pyclass — both inspect Python values. +//! Native [`DataTypeName`](crate::native::data_type::DataTypeName) is a +//! spelling plus group. Native [`Serializer`](crate::native::Serializer) +//! writes Python objects as SQL literals. This module owns the pyclasses. //! //! Python sees: //! //! * [`PySerializer`] — `rapidquery._core.data_type.Serializer`, the ABC //! * frozen subclasses per behaviour (`IntegerSerializer`, `JSONSerializer`, …) -//! * [`PyArraySerializer`] / [`PyCustomSerializer`] (no native counterpart) +//! * [`PyArraySerializer`] / [`PyCustomSerializer`] //! * [`PyDataType`] — a name plus a serializer //! //! `serialize(dialect, obj)` accepts a dialect **class or instance**. Serializer //! extract is instance-only (Array/Custom carry state). -mod serialize; mod serializer; use crate::internal::alias; diff --git a/src/pybindings/data_type/serializer.rs b/src/pybindings/data_type/serializer.rs index 8df1e4e..728336a 100644 --- a/src/pybindings/data_type/serializer.rs +++ b/src/pybindings/data_type/serializer.rs @@ -1,8 +1,8 @@ //! Python `Serializer` class hierarchy. //! -//! Native inference is [`DataTypeGroup`](crate::native::data_type::DataTypeGroup). -//! Python sees frozen subclasses plus `ArraySerializer` / `CustomSerializer`, -//! which have no native counterpart. +//! Wraps native [`Serializer`](crate::native::Serializer). Python sees frozen +//! subclasses (`IntegerSerializer`, …) plus `ArraySerializer` / +//! `CustomSerializer`. //! //! Unit instances compare and hash by variant (`IntegerSerializer() == //! IntegerSerializer()`) and `repr` as `IntegerSerializer()`. Array compares @@ -10,107 +10,28 @@ use std::hash::{Hash, Hasher}; -use crate::{internal::alias, native::data_type::DataTypeGroup}; +use crate::internal::alias; +use crate::native::data_type::DataTypeGroup; +use crate::native::serializer::Serializer; -// Value-serialization tree. Lives only in pybindings (`Custom` holds a -// Python callable; `Array` may wrap that). Not a pyclass — must not leak -// into Python `__doc__`. -#[derive(Debug, Clone)] -pub(super) enum SerializerInner { - Integer, - Float, - Boolean, - String, - Uuid, - Bytes, - Bit, - Json, - Date, - Time, - Datetime, - Inet, - MacAddress, - Unspecified, - Array(Box), - Custom(alias::PyObject), -} - -impl PartialEq for SerializerInner { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (Self::Integer, Self::Integer) - | (Self::Float, Self::Float) - | (Self::Boolean, Self::Boolean) - | (Self::String, Self::String) - | (Self::Uuid, Self::Uuid) - | (Self::Bytes, Self::Bytes) - | (Self::Bit, Self::Bit) - | (Self::Json, Self::Json) - | (Self::Date, Self::Date) - | (Self::Time, Self::Time) - | (Self::Datetime, Self::Datetime) - | (Self::Inet, Self::Inet) - | (Self::MacAddress, Self::MacAddress) - | (Self::Unspecified, Self::Unspecified) => true, - (Self::Array(a), Self::Array(b)) => a == b, - (Self::Custom(a), Self::Custom(b)) => std::ptr::eq(a.as_ptr(), b.as_ptr()), - _ => false, - } - } -} - -impl Eq for SerializerInner {} - -impl Hash for SerializerInner { - fn hash(&self, state: &mut H) { - std::mem::discriminant(self).hash(state); - match self { - Self::Array(element) => element.hash(state), - Self::Custom(function) => (function.as_ptr() as usize).hash(state), - _ => {} - } - } -} - -impl SerializerInner { - fn from_group(kind: DataTypeGroup) -> Self { - match kind { - DataTypeGroup::Integer => Self::Integer, - DataTypeGroup::Float => Self::Float, - DataTypeGroup::Boolean => Self::Boolean, - DataTypeGroup::String => Self::String, - DataTypeGroup::Uuid => Self::Uuid, - DataTypeGroup::Bytes => Self::Bytes, - DataTypeGroup::Bit => Self::Bit, - DataTypeGroup::Json | DataTypeGroup::Jsonb => Self::Json, - DataTypeGroup::Date => Self::Date, - DataTypeGroup::Time => Self::Time, - DataTypeGroup::Datetime => Self::Datetime, - DataTypeGroup::Inet => Self::Inet, - DataTypeGroup::MacAddress => Self::MacAddress, - DataTypeGroup::Custom => Self::Unspecified, - } - } - - fn class_name(&self) -> &'static str { - match self { - Self::Integer => "IntegerSerializer", - Self::Float => "FloatSerializer", - Self::Boolean => "BooleanSerializer", - Self::String => "StringSerializer", - Self::Uuid => "UuidSerializer", - Self::Bytes => "BytesSerializer", - Self::Bit => "BitSerializer", - Self::Json => "JSONSerializer", - Self::Date => "DateSerializer", - Self::Time => "TimeSerializer", - Self::Datetime => "DatetimeSerializer", - Self::Inet => "InetSerializer", - Self::MacAddress => "MacAddressSerializer", - Self::Unspecified => "UnspecifiedSerializer", - Self::Array(_) => "ArraySerializer", - Self::Custom(_) => "CustomSerializer", - } +fn class_name(serializer: &Serializer) -> &'static str { + match serializer { + Serializer::Integer => "IntegerSerializer", + Serializer::Float => "FloatSerializer", + Serializer::Boolean => "BooleanSerializer", + Serializer::String => "StringSerializer", + Serializer::Uuid => "UuidSerializer", + Serializer::Bytes => "BytesSerializer", + Serializer::Bit => "BitSerializer", + Serializer::Json => "JSONSerializer", + Serializer::Date => "DateSerializer", + Serializer::Time => "TimeSerializer", + Serializer::Datetime => "DatetimeSerializer", + Serializer::Inet => "InetSerializer", + Serializer::MacAddress => "MacAddressSerializer", + Serializer::Unspecified => "UnspecifiedSerializer", + Serializer::Array(_) => "ArraySerializer", + Serializer::Custom(_) => "CustomSerializer", } } @@ -135,11 +56,11 @@ impl SerializerInner { module = "rapidquery._core.data_type" )] #[derive(Debug, Clone)] -pub struct PySerializer(pub(super) SerializerInner); +pub struct PySerializer(pub(super) Serializer); impl PySerializer { pub(super) fn inferred(kind: DataTypeGroup) -> Self { - Self(SerializerInner::from_group(kind)) + Self(Serializer::from_group(kind)) } pub(crate) fn serialize_value( @@ -158,22 +79,22 @@ impl PySerializer { py: pyo3::Python<'py>, ) -> pyo3::PyResult> { match &self.0 { - SerializerInner::Integer => wrap::(py, self), - SerializerInner::Float => wrap::(py, self), - SerializerInner::Boolean => wrap::(py, self), - SerializerInner::String => wrap::(py, self), - SerializerInner::Uuid => wrap::(py, self), - SerializerInner::Bytes => wrap::(py, self), - SerializerInner::Bit => wrap::(py, self), - SerializerInner::Json => wrap::(py, self), - SerializerInner::Date => wrap::(py, self), - SerializerInner::Time => wrap::(py, self), - SerializerInner::Datetime => wrap::(py, self), - SerializerInner::Inet => wrap::(py, self), - SerializerInner::MacAddress => wrap::(py, self), - SerializerInner::Unspecified => wrap::(py, self), - SerializerInner::Array(_) => wrap::(py, self), - SerializerInner::Custom(_) => wrap::(py, self), + Serializer::Integer => wrap::(py, self), + Serializer::Float => wrap::(py, self), + Serializer::Boolean => wrap::(py, self), + Serializer::String => wrap::(py, self), + Serializer::Uuid => wrap::(py, self), + Serializer::Bytes => wrap::(py, self), + Serializer::Bit => wrap::(py, self), + Serializer::Json => wrap::(py, self), + Serializer::Date => wrap::(py, self), + Serializer::Time => wrap::(py, self), + Serializer::Datetime => wrap::(py, self), + Serializer::Inet => wrap::(py, self), + Serializer::MacAddress => wrap::(py, self), + Serializer::Unspecified => wrap::(py, self), + Serializer::Array(_) => wrap::(py, self), + Serializer::Custom(_) => wrap::(py, self), } } } @@ -217,11 +138,11 @@ impl PySerializer { fn __repr__(&self) -> String { match &self.0 { - SerializerInner::Array(element) => { - format!("ArraySerializer({}())", element.class_name()) + Serializer::Array(element) => { + format!("ArraySerializer({}())", class_name(element)) } other => { - let name = other.class_name(); + let name = class_name(other); let mut buf = String::with_capacity(name.len() + 2); buf.push_str(name); buf.push_str("()"); @@ -259,7 +180,7 @@ macro_rules! impl_unit_serializers { #[new] #[inline] fn __new__() -> pyo3::PyClassInitializer { - pyo3::PyClassInitializer::from(PySerializer(SerializerInner::$variant)) + pyo3::PyClassInitializer::from(PySerializer(Serializer::$variant)) .add_subclass(Self) } } @@ -376,7 +297,7 @@ impl PyArraySerializer { #[new] #[inline] fn __new__(element: PySerializer) -> pyo3::PyClassInitializer { - let inner = SerializerInner::Array(Box::new(element.0)); + let inner = Serializer::Array(Box::new(element.0)); pyo3::PyClassInitializer::from(PySerializer(inner)).add_subclass(Self) } @@ -388,7 +309,7 @@ impl PyArraySerializer { fn get_element(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { let base = slf.as_super(); match &base.0 { - SerializerInner::Array(element) => { + Serializer::Array(element) => { PySerializer((**element).clone()).into_subclasses(slf.py()) } _ => unreachable!("ArraySerializer base is always Array"), @@ -427,7 +348,7 @@ impl PyCustomSerializer { ); } } - let inner = SerializerInner::Custom(function); + let inner = Serializer::Custom(function); Ok(pyo3::PyClassInitializer::from(PySerializer(inner)).add_subclass(Self)) } @@ -438,7 +359,7 @@ impl PyCustomSerializer { #[getter] fn get_function(slf: pyo3::PyRef<'_, Self>) -> alias::PyObject { match &slf.as_super().0 { - SerializerInner::Custom(function) => function.clone(), + Serializer::Custom(function) => function.clone(), _ => unreachable!("CustomSerializer base is always Custom"), } } diff --git a/src/pybindings/ident.rs b/src/pybindings/ident.rs index d3cd42f..e6f4e6b 100644 --- a/src/pybindings/ident.rs +++ b/src/pybindings/ident.rs @@ -19,7 +19,7 @@ use smallvec::SmallVec; use crate::internal::alias; use crate::internal::utils; use crate::native; -use crate::native::prepare_statement::prepare; +use crate::native::sql::write_sql; #[inline] fn parse_err(err: native::ParseIdentError) -> pyo3::PyErr { @@ -275,10 +275,8 @@ impl PyIdent { py: pyo3::Python<'_>, dialect: super::dialect::PySqlDialect, ) -> pyo3::PyResult { - let (sql, _) = prepare(py, &self.0, &dialect.0) - .map_err(|e| rapidquery_macros::pyerr!(PyRuntimeError, "{}", e))?; - - Ok(sql) + write_sql(py, &self.0, &dialect.0) + .map_err(|e| rapidquery_macros::pyerr!(PyRuntimeError, "{}", e)) } fn __repr__(&self) -> String { diff --git a/src/pybindings/mod.rs b/src/pybindings/mod.rs index b8f3ba7..1fab0d6 100644 --- a/src/pybindings/mod.rs +++ b/src/pybindings/mod.rs @@ -1,8 +1,8 @@ //! PyO3 wrappers around [`crate::native`]. //! -//! Native types stay PyO3-free. This module is the only place that defines -//! `#[pyclass]` types and the `rapidquery._core.*` submodules they live in. -//! Serializers live here because they inspect Python values. +//! This module is the only place that defines `#[pyclass]` types and the +//! `rapidquery._core.*` submodules they live in. Native may hold and inspect +//! Python objects (`Serializer`, `PrepareContext`); pyclasses wrap those types. pub mod data_type; pub mod dialect; From 535777a12eb5ccac02b9db3888962ed70b1db497 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Thu, 10 Sep 2026 10:50:19 +0330 Subject: [PATCH 20/87] Optimize JSONSerializer stdlib json (1M runs): 4.5s -> 2.5s orjson (1M runs): 4.3s -> 0.3s --- Cargo.toml | 4 + pyproject.toml | 7 +- rapidquery/data_type.pyi | 4 +- src/internal/modules/json.rs | 145 ++++++++++++++------- src/native/serialize.rs | 12 +- src/native/sql.rs | 2 +- src/pybindings/data_type/serializer.rs | 173 +++++++++++++++---------- tests/test_serializers.py | 25 ++++ uv.lock | 81 +++++++++++- 9 files changed, 326 insertions(+), 127 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 19988b0..4ed5525 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,10 @@ smallvec = { version = "1.16.0", default-features = false } default = ["extension-module"] extension-module = ["pyo3/extension-module"] +# Testing features +only-stdlib-json = [] +only-orjson = [] + [lints.clippy] dbg_macro = "warn" print_stderr = "warn" diff --git a/pyproject.toml b/pyproject.toml index 83ab9d1..c7ea927 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,8 +54,13 @@ Homepage = "https://github.com/awolverp/rapidquery" Repository = "https://github.com/awolverp/rapidquery" Issues = "https://github.com/awolverp/rapidquery/issues" +[project.optional-dependencies] +orjson = [ + "orjson>=3.0.0", +] + [dependency-groups] dev = [ "maturin == 1.15.0", - "pytest>=8.3.5", + "pytest>=8.0.0", ] diff --git a/rapidquery/data_type.pyi b/rapidquery/data_type.pyi index 14717ea..0f0f540 100644 --- a/rapidquery/data_type.pyi +++ b/rapidquery/data_type.pyi @@ -143,7 +143,9 @@ class JSONSerializer(Serializer): Accepts any object that the JSON encoder can dump. Uses `orjson` when it is installed, otherwise the stdlib `json` module. The JSON text is then - wrapped with the dialect's string quoting. + wrapped with the dialect's string quoting. With `orjson`, datetime, UUID, + dataclass, and non-str dict keys are encoded natively; stdlib `json` + rejects those. Raises: TypeError: If the value cannot be JSON-encoded. diff --git a/src/internal/modules/json.rs b/src/internal/modules/json.rs index 0284682..92bac5b 100644 --- a/src/internal/modules/json.rs +++ b/src/internal/modules/json.rs @@ -1,94 +1,145 @@ //! Process-wide JSON dumps: `orjson` if importable, else stdlib `json`. +//! +//! The result is borrowed and passed to a callback so the SQL writer can +//! escape it without an intermediate `String`. use once_cell::sync::OnceCell; -use pyo3::types::{PyAnyMethods, PyBytesMethods, PyDict, PyModule, PyStringMethods, PyTuple}; +use pyo3::types::{PyAnyMethods, PyModule, PyTuple}; + +use crate::internal::utils::py_str; + +#[cfg(all(feature = "only-stdlib-json", feature = "only-orjson"))] +compile_error!( + "Testing features `only-stdlib-json` and `only-orjson` cannot be used at a same time." +); enum JsonDumper { Orjson { dumps: pyo3::Py, - option: u32, }, Stdlib { dumps: pyo3::Py, + /// `("ensure_ascii", "allow_nan", "separators")` for vectorcall. + kwnames: pyo3::Py, separators: pyo3::Py, }, } static DUMPER: OnceCell = OnceCell::new(); -fn orjson_option(module: &pyo3::Bound<'_, PyModule>) -> pyo3::PyResult { - let flag = |name: &str| module.getattr(name)?.extract::(); - - Ok(flag("OPT_PASSTHROUGH_DATETIME")? - | flag("OPT_PASSTHROUGH_UUID")? - | flag("OPT_PASSTHROUGH_DATACLASS")? - | flag("OPT_PASSTHROUGH_SUBCLASS")? - | flag("OPT_NON_STR_KEYS")?) -} - -fn init_orjson( - _py: pyo3::Python<'_>, - module: &pyo3::Bound<'_, PyModule>, -) -> pyo3::PyResult { +fn init_orjson(module: &pyo3::Bound<'_, PyModule>) -> pyo3::PyResult { let dumps = module.getattr("dumps")?.unbind(); - let option = orjson_option(module)?; - Ok(JsonDumper::Orjson { dumps, option }) + Ok(JsonDumper::Orjson { dumps }) } fn init_stdlib(py: pyo3::Python<'_>) -> pyo3::PyResult { let module = py.import("json")?; let dumps = module.getattr("dumps")?.unbind(); + let kwnames = PyTuple::new(py, ["ensure_ascii", "allow_nan", "separators"])?.unbind(); let separators = PyTuple::new(py, [",", ":"])?.unbind(); - Ok(JsonDumper::Stdlib { dumps, separators }) + + Ok(JsonDumper::Stdlib { + dumps, + kwnames, + separators, + }) +} + +#[cfg(feature = "only-orjson")] +#[cold] +fn init_import(py: pyo3::Python<'_>) -> pyo3::PyResult { + let module = py.import("orjson")?; + init_orjson(&module) +} + +#[cfg(feature = "only-stdlib-json")] +#[cold] +fn init_import(py: pyo3::Python<'_>) -> pyo3::PyResult { + init_stdlib(py) } +#[cfg(not(any(feature = "only-stdlib-json", feature = "only-orjson")))] #[cold] fn init_import(py: pyo3::Python<'_>) -> pyo3::PyResult { if let Ok(module) = py.import("orjson") - && let Ok(dumper) = init_orjson(py, &module) + && let Ok(dumper) = init_orjson(&module) { return Ok(dumper); } init_stdlib(py) } -fn unicode_to_string(obj: pyo3::Bound<'_, pyo3::PyAny>) -> pyo3::PyResult { - let s = obj.cast::()?; - Ok(s.to_str()?.to_owned()) -} +/// Borrow JSON text from a `bytes` (orjson) or `str` (stdlib) dumps result. +/// +/// # Safety +/// `result` is a new reference still owned by the caller. The GIL is held. +/// The `&str` is only valid until `result` is `DECREF`'d. +#[inline] +unsafe fn visit_json_text( + py: pyo3::Python<'_>, + result: *mut pyo3::ffi::PyObject, + f: impl FnOnce(&str) -> pyo3::PyResult<()>, +) -> pyo3::PyResult<()> { + if pyo3::ffi::PyBytes_CheckExact(result) == 1 { + let buf = pyo3::ffi::PyBytes_AsString(result) as *const u8; + let len = pyo3::ffi::PyBytes_Size(result) as usize; + debug_assert!(!buf.is_null()); + // SAFETY: `orjson.dumps` returns UTF-8 JSON. The buffer is the + // exact `bytes` payload and stays alive until `result` is DECREF'd. + let text = unsafe { std::str::from_utf8_unchecked(std::slice::from_raw_parts(buf, len)) }; + return f(text); + } -fn dumps_result_to_string(obj: pyo3::Bound<'_, pyo3::PyAny>) -> pyo3::PyResult { - if let Ok(bytes) = obj.cast::() { - let raw = bytes.as_bytes(); - return std::str::from_utf8(raw) - .map(str::to_owned) - .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())); + if pyo3::ffi::PyUnicode_CheckExact(result) == 1 { + return f(py_str(py, result)?); } - unicode_to_string(obj) + + rapidquery_macros::pyerr!( + Err(PyTypeError), + "JSON dumps must return bytes or str, got {}", + crate::internal::utils::get_type_name(py, result) + ) } -/// JSON-encode `ptr` via cached `orjson.dumps` or `json.dumps`. +/// JSON-encode `obj` and pass the text to `f` before dropping the dumps result. /// /// # Safety -/// `ptr` must be a valid borrowed `PyObject*`. The GIL must be held. +/// `obj` must be a valid borrowed Python object. The GIL must be held. #[inline] -pub unsafe fn dumps(obj: pyo3::Bound<'_, pyo3::PyAny>) -> pyo3::PyResult { +pub unsafe fn dumps( + obj: pyo3::Bound<'_, pyo3::PyAny>, + f: impl FnOnce(&str) -> pyo3::PyResult<()>, +) -> pyo3::PyResult<()> { let py = obj.py(); let dumper = DUMPER.get_or_try_init(|| init_import(py))?; - match dumper { - JsonDumper::Orjson { dumps, option } => { - // orjson.dumps(__obj, default=None, option=int) - let out = dumps.bind(py).call1((&obj, py.None(), option))?; - dumps_result_to_string(out) - } - JsonDumper::Stdlib { dumps, separators } => { - let kwargs = PyDict::new(py); - kwargs.set_item("ensure_ascii", false)?; - kwargs.set_item("allow_nan", false)?; - kwargs.set_item("separators", separators.bind(py))?; - let out = dumps.bind(py).call((&obj,), Some(&kwargs))?; - unicode_to_string(out) + let result = match dumper { + JsonDumper::Orjson { dumps } => unsafe { + pyo3::ffi::PyObject_CallOneArg(dumps.as_ptr(), obj.as_ptr()) + }, + JsonDumper::Stdlib { + dumps, + kwnames, + separators, + } => { + let false_ = unsafe { pyo3::ffi::Py_False() }; + let mut args = [obj.as_ptr(), false_, false_, separators.as_ptr()]; + unsafe { + pyo3::ffi::PyObject_Vectorcall( + dumps.as_ptr(), + args.as_mut_ptr(), + 1, + kwnames.as_ptr(), + ) + } } + }; + + if result.is_null() { + return Err(pyo3::PyErr::fetch(py)); } + + let out = unsafe { visit_json_text(py, result, f) }; + unsafe { pyo3::ffi::Py_DECREF(result) }; + out } diff --git a/src/native/serialize.rs b/src/native/serialize.rs index f3a8944..41f6794 100644 --- a/src/native/serialize.rs +++ b/src/native/serialize.rs @@ -296,11 +296,13 @@ unsafe fn write_json( dialect: &SqlDialect, out: &mut String, ) -> pyo3::PyResult<()> { - let text = - crate::internal::modules::json_dumps(unsafe { pyo3::Bound::from_borrowed_ptr(py, ptr) })?; - - write_quoted(dialect, out, &text); - Ok(()) + crate::internal::modules::json_dumps( + unsafe { pyo3::Bound::from_borrowed_ptr(py, ptr) }, + |text| { + write_quoted(dialect, out, text); + Ok(()) + }, + ) } /// # Safety diff --git a/src/native/sql.rs b/src/native/sql.rs index 0ca723a..17b274e 100644 --- a/src/native/sql.rs +++ b/src/native/sql.rs @@ -65,7 +65,7 @@ pub trait SqlWriter: std::fmt::Write { /// Raw (`SqlBuf`): `None` → `NULL`, else `serializer.write_sql` into the /// SQL buffer. Prepared: bind `object` as a placeholder; `serializer` is /// unused. - fn push_value<'py>( + fn push_value( &mut self, dialect: &SqlDialect, serializer: &Serializer, diff --git a/src/pybindings/data_type/serializer.rs b/src/pybindings/data_type/serializer.rs index 728336a..0b30aee 100644 --- a/src/pybindings/data_type/serializer.rs +++ b/src/pybindings/data_type/serializer.rs @@ -164,9 +164,14 @@ impl PySerializer { } macro_rules! impl_unit_serializers { - ($($variant:ident => $name:ident => $pyname:literal, $doc:literal,)+) => { + ( $( - #[doc = $doc] + $(#[$outer:meta])* + $variant:ident => $name:ident => $pyname:literal, + )+ + ) => { + $( + $(#[$outer])* #[rapidquery_macros::pyclass( $pyname, extends = PySerializer, @@ -189,89 +194,115 @@ macro_rules! impl_unit_serializers { } impl_unit_serializers! { + /// Serializes Python integers as bare SQL integer literals. + /// Accepts Python `int` of any width (including values beyond 64-bit). + /// Rejects `bool`; `True` and `False` are not treated as integers. + /// + /// Raises: + /// TypeError: If the value is not an `int`. Integer => PyIntegerSerializer => "IntegerSerializer", - "Serializes Python integers as bare SQL integer literals.\n\n\ - Accepts Python `int` of any width (including values beyond 64-bit).\n\ - Rejects `bool`; `True` and `False` are not treated as integers.\n\n\ - Raises:\n \ - TypeError: If the value is not an `int`.", + + /// Serializes numeric values as bare SQL numeric literals. + /// Accepts `float`, `int`, and `decimal.Decimal`. Finite floats are formatted + /// so that `1.0` stays distinct from `1`. `str` is not accepted. + /// + /// Raises: + /// TypeError: If the value is not a `float`, `int`, or `decimal.Decimal`. + /// ValueError: If the value is a non-finite float (`nan`, `inf`, `-inf`). Float => PyFloatSerializer => "FloatSerializer", - "Serializes numeric values as bare SQL numeric literals.\n\n\ - Accepts `float`, `int`, and `decimal.Decimal`. Finite floats are formatted\n\ - so that `1.0` stays distinct from `1`. `str` is not accepted.\n\n\ - Raises:\n \ - TypeError: If the value is not a `float`, `int`, or `decimal.Decimal`.\n \ - ValueError: If the value is a non-finite float (`nan`, `inf`, `-inf`).", + + /// Serializes Python booleans as dialect boolean literals. + /// Accepts Python `bool` only. The dialect chooses the spelling + /// (`TRUE`/`FALSE` or `1`/`0`). + /// + /// Raises: + /// TypeError: If the value is not a `bool`. Boolean => PyBooleanSerializer => "BooleanSerializer", - "Serializes Python booleans as dialect boolean literals.\n\n\ - Accepts Python `bool` only. The dialect chooses the spelling\n\ - (`TRUE`/`FALSE` or `1`/`0`).\n\n\ - Raises:\n \ - TypeError: If the value is not a `bool`.", + + /// Serializes text as a quoted, escaped SQL string literal. + /// Accepts `str`, or an `enum.Enum` member whose `.value` is a `str`. The + /// dialect supplies quoting and escaping. + /// + /// Raises: + /// TypeError: If the value is not a `str` or a string-valued enum member. String => PyStringSerializer => "StringSerializer", - "Serializes text as a quoted, escaped SQL string literal.\n\n\ - Accepts `str`, or an `enum.Enum` member whose `.value` is a `str`. The\n\ - dialect supplies quoting and escaping.\n\n\ - Raises:\n \ - TypeError: If the value is not a `str` or a string-valued enum member.", + + /// Serializes UUIDs as quoted SQL string literals. + /// Accepts `uuid.UUID` or `str`. + /// Raises: + /// TypeError: If the value is not a `uuid.UUID` or `str`. Uuid => PyUuidSerializer => "UuidSerializer", - "Serializes UUIDs as quoted SQL string literals.\n\n\ - Accepts `uuid.UUID` or `str`.\n\n\ - Raises:\n \ - TypeError: If the value is not a `uuid.UUID` or `str`.", + + /// Serializes binary data as a dialect blob/bytea literal. + /// Accepts exact `bytes` (not `bytearray` or `memoryview`). + /// + /// Raises: + /// TypeError: If the value is not `bytes`. Bytes => PyBytesSerializer => "BytesSerializer", - "Serializes binary data as a dialect blob/bytea literal.\n\n\ - Accepts exact `bytes` (not `bytearray` or `memoryview`).\n\n\ - Raises:\n \ - TypeError: If the value is not `bytes`.", + + /// Serializes bit strings as dialect bit-string literals. + /// Accepts a `str` consisting only of `'0'` and `'1'` characters. + /// + /// Raises: + /// TypeError: If the value is not a `str`. + /// ValueError: If the string is empty or contains characters other than `'0'` and `'1'`. Bit => PyBitSerializer => "BitSerializer", - "Serializes bit strings as dialect bit-string literals.\n\n\ - Accepts a `str` consisting only of `'0'` and `'1'` characters.\n\n\ - Raises:\n \ - TypeError: If the value is not a `str`.\n \ - ValueError: If the string is empty or contains characters other than\n \ - `'0'` and `'1'`.", + + /// Serializes a JSON-encodable object as a quoted SQL string literal. + /// Accepts any object that the JSON encoder can dump. Uses `orjson` when it + /// is installed, otherwise the stdlib `json` module. The JSON text is then + /// wrapped with the dialect's string quoting. With `orjson`, datetime, + /// UUID, dataclass, and non-str dict keys are encoded natively; stdlib + /// `json` rejects those. + /// + /// Raises: + /// TypeError: If the value cannot be JSON-encoded. Json => PyJSONSerializer => "JSONSerializer", - "Serializes a JSON-encodable object as a quoted SQL string literal.\n\n\ - Accepts any object that the JSON encoder can dump. Uses `orjson` when it\n\ - is installed, otherwise the stdlib `json` module. The JSON text is then\n\ - wrapped with the dialect's string quoting.\n\n\ - Raises:\n \ - TypeError: If the value cannot be JSON-encoded.", + + /// Serializes dates as quoted `YYYY-MM-DD` literals. + /// Accepts exact `datetime.date` (not `datetime.datetime`). + /// + /// Raises: + /// TypeError: If the value is not a `datetime.date`. Date => PyDateSerializer => "DateSerializer", - "Serializes dates as quoted `YYYY-MM-DD` literals.\n\n\ - Accepts exact `datetime.date` (not `datetime.datetime`).\n\n\ - Raises:\n \ - TypeError: If the value is not a `datetime.date`.", + + /// Serializes times as quoted `HH:MM:SS[.ffffff]` literals. + /// Accepts `datetime.time`. + /// + /// Raises: + /// TypeError: If the value is not a `datetime.time`. Time => PyTimeSerializer => "TimeSerializer", - "Serializes times as quoted `HH:MM:SS[.ffffff]` literals.\n\n\ - Accepts `datetime.time`.\n\n\ - Raises:\n \ - TypeError: If the value is not a `datetime.time`.", + + /// Serializes timestamps as quoted SQL timestamp literals. + /// Accepts naive or aware `datetime.datetime`, or `int`/`float` UTC epoch + /// seconds. Naive values keep wall time. Aware values keep their original + /// offset (they are not converted to UTC). + /// + /// Raises: + /// TypeError: If the value is not a `datetime.datetime`, `int`, or `float`. + /// OverflowError: If an epoch value is out of range for a timestamp. Datetime => PyDatetimeSerializer => "DatetimeSerializer", - "Serializes timestamps as quoted SQL timestamp literals.\n\n\ - Accepts naive or aware `datetime.datetime`, or `int`/`float` UTC epoch\n\ - seconds. Naive values keep wall time. Aware values keep their original\n\ - offset (they are not converted to UTC).\n\n\ - Raises:\n \ - TypeError: If the value is not a `datetime.datetime`, `int`, or `float`.\n \ - OverflowError: If an epoch value is out of range for a timestamp.", + + /// Serializes IP addresses and networks as quoted SQL literals. + /// Accepts `str` or `ipaddress` objects (`IPv4Address`, `IPv6Address`, + /// `IPv4Network`, `IPv6Network`, and related types whose `str()` is a valid + /// address or CIDR). + /// + /// Raises: + /// ValueError: If the value is not a valid IPv4/IPv6 address or network. Inet => PyInetSerializer => "InetSerializer", - "Serializes IP addresses and networks as quoted SQL literals.\n\n\ - Accepts `str` or `ipaddress` objects (`IPv4Address`, `IPv6Address`,\n\ - `IPv4Network`, `IPv6Network`, and related types whose `str()` is a valid\n\ - address or CIDR).\n\n\ - Raises:\n \ - ValueError: If the value is not a valid IPv4/IPv6 address or network.", + + /// Serializes MAC addresses as quoted SQL literals. + /// Accepts a `str` using `:` or `-` as the separator. + /// + /// Raises: + /// ValueError: If the value is not a valid MAC address. MacAddress => PyMacAddressSerializer => "MacAddressSerializer", - "Serializes MAC addresses as quoted SQL literals.\n\n\ - Accepts a `str` using `:` or `-` as the separator.\n\n\ - Raises:\n \ - ValueError: If the value is not a valid MAC address.", + + /// Inserts `str(obj)` into SQL with no quoting or type check. + /// Used when no serializer could be inferred from the type name. The result + /// is not a safe SQL value; prefer an explicit serializer. Unspecified => PyUnspecifiedSerializer => "UnspecifiedSerializer", - "Inserts `str(obj)` into SQL with no quoting or type check.\n\n\ - Used when no serializer could be inferred from the type name. The result\n\ - is not a safe SQL value; prefer an explicit serializer.", } /// Serializes sequences as `ARRAY[elem, ...]` literals. diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 0cb8f99..9f0f71b 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -323,6 +323,31 @@ class NotEncodable: with pytest.raises(TypeError): JSONSerializer().serialize(dialect_cls, NotEncodable()) + def test_compact_separators(self, dialect_cls: type[SqlDialect]): + result = JSONSerializer().serialize(dialect_cls, {"a": 1}) + assert ": " not in result + result = JSONSerializer().serialize(dialect_cls, [1, 2]) + assert ", " not in result + + def test_nan(self, dialect_cls: type[SqlDialect]): + # orjson emits JSON `null` for NaN; stdlib `json` with allow_nan=False + # raises ValueError. + try: + import orjson # noqa: F401 + except ImportError: + with pytest.raises(ValueError): + JSONSerializer().serialize(dialect_cls, float("nan")) + else: + result = JSONSerializer().serialize(dialect_cls, float("nan")) + assert "null" in result + + def test_orjson_encodes_datetime(self, dialect_cls: type[SqlDialect]): + pytest.importorskip("orjson") + result = JSONSerializer().serialize( + dialect_cls, {"t": datetime.datetime(2024, 1, 2, 3, 4, 5)} + ) + assert "2024-01-02" in result + # --------------------------------------------------------------------------- # DateSerializer diff --git a/uv.lock b/uv.lock index a959e96..f631b91 100644 --- a/uv.lock +++ b/uv.lock @@ -56,6 +56,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/10/15d4314edf130955edf2dc237aa393a8a7c10f2b9b57b89fa2f61f915659/maturin-1.15.0-py3-none-win_arm64.whl", hash = "sha256:c7dc0c66c78d3debdd9c5aa807e861fbcbf07f3505d34b125df74c03986b0f48", size = 9713795, upload-time = "2026-08-24T12:11:20.83Z" }, ] +[[package]] +name = "orjson" +version = "3.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/742fb1f62b825f2c010697eaf4e828004bc2a81e7e806666989c132c7c42/orjson-3.12.0.tar.gz", hash = "sha256:d14203fb1aae2ad9b3d52f8a0e82aeb10197ef1c9bc61da7f358bd70b00123d5", size = 4142915, upload-time = "2026-08-14T16:13:30.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/35/819eeb4fa8ee676d38fdbb8213a76fd496f7dbbfdfafa89d34e02b22dfac/orjson-3.12.0-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:747843254519dd43b93eee3153a19e5a509334320c4d2f823ec879232db5c796", size = 224133, upload-time = "2026-08-14T16:12:00.607Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/d9221d4a2b085b073fcddc91728d490f20b9cf010c62c2f42371ab997695/orjson-3.12.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:7c2ad193c8004254f34b499f3bd2c80f043d10754aff2b38f93da574f4883f98", size = 113669, upload-time = "2026-08-14T16:12:02.126Z" }, + { url = "https://files.pythonhosted.org/packages/15/12/644cbbcabb26df61d9ef0c66e6f2bf8b687cc7b66137597f2858951f1952/orjson-3.12.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:bc7a872f03522d90e0429e6c0c5cd23084f767bedcb4c58048eec19294613344", size = 130410, upload-time = "2026-08-14T16:12:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/14/6d/e3a8c34d687895aecd8b267a01c46106eb98d8424a83bfa7bacb723854f6/orjson-3.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18a87929f31d94a77f7dc93cf527e91f39ce7fe7813d588a4de2507efd32a387", size = 131101, upload-time = "2026-08-14T16:12:04.918Z" }, + { url = "https://files.pythonhosted.org/packages/75/20/930824c07685c22af23f26818ed3853b0270488a412b6ab757904b7f787b/orjson-3.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9683ee9ea0659da64f36574ef675b8a86330c34c19ea75db1fb93c3ff99e0ef", size = 131479, upload-time = "2026-08-14T16:12:06.11Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a6/22e863bbbe8917aa292e33e0db597000f9a07eb5e6f52efed623fa16bae1/orjson-3.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:103b5db66aa53c1f9e88c2524be4f383e831ba7dfd5f9f5af6336a177c622f11", size = 135865, upload-time = "2026-08-14T16:12:07.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/a0/ceb5008914a65e9a19a46a09d94bc67a74d120209fdfa772750023ceb377/orjson-3.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd57d79aefa3f84eec851d6de7a366795b9345cfaf17f82b4820430a7a5fa241", size = 127843, upload-time = "2026-08-14T16:12:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/3d/61c6b3b84c250cb09cb7229701ff77e4d763773ad7f577d0b6abf2892664/orjson-3.12.0-cp310-cp310-win32.whl", hash = "sha256:3dbce9b6b3074b31a5d5dd322a9c4e5b16f206091ece4194c2e36952847a105e", size = 128293, upload-time = "2026-08-14T16:12:09.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0e/ea0f4a563253b6363195a4f704123c6bfbf156641bd3be5a75de81c5e917/orjson-3.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:3bb17a06f9bd15237b3216c044209fe92597379124018cfc196fbb846cde64df", size = 122216, upload-time = "2026-08-14T16:12:11.261Z" }, + { url = "https://files.pythonhosted.org/packages/75/1a/a7075a8e8b0d3f5097d17ac3099017104b6b7b42012041147995d5b2da05/orjson-3.12.0-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a94f0f0c6fcbb2b5bd9734c57a489c7584a732bbdf04a39e8c83b861e9d03e92", size = 223409, upload-time = "2026-08-14T16:12:12.654Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c2eb3b2900e5597db7841a4c6416ac2d90081bd956b02d4dd1833fa2b96b/orjson-3.12.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:a696529ec96a90d9a5f9570207efe403c8b08f8e4aa2783ee3403511e2fdfa10", size = 124015, upload-time = "2026-08-14T16:12:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/1c/df/b49081766a75b6a37b3d33bdc0a39e492abab8441dd25e3e1998e7b83fcb/orjson-3.12.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:e4ac5059baab4b3acbd99485de019ff8cda0fdf34b61fa74f7197a53db78bfe8", size = 113471, upload-time = "2026-08-14T16:12:15.81Z" }, + { url = "https://files.pythonhosted.org/packages/48/d4/58ea28eeef95c2a27358ed927380a621162cf20bd740bbccf9c3f09a200a/orjson-3.12.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:8e29957429c35bbb5a185a119c523aa2428b7bbf1a293724c7b9375ed8f892a3", size = 129998, upload-time = "2026-08-14T16:12:17.503Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f4/1e82aa2efc9916422d804697876ce433c907a1abd7c7e5c6d3d48565e5f9/orjson-3.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dce0166feb0a737ab84f598c9a338cbc0b764a036617aa686194f53c7eba0c3e", size = 130891, upload-time = "2026-08-14T16:12:18.762Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e1/15169e9d22b59a406264f99d6db387c0b0b12b6357a8a0169917c2a713eb/orjson-3.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9caf3d09f47c3c70c4451ada20ef9bc4a4cdffa26f49862cf0a253b329aae2d5", size = 131285, upload-time = "2026-08-14T16:12:20.251Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/763dbd426290d044ec3e615a05e70adb6d8b6f95bf17dc355c0081a5e8b6/orjson-3.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b9dca132b1fda5565088e65a6b6e742285e0aeceb6fae549fa8863e16c7d3998", size = 135707, upload-time = "2026-08-14T16:12:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/3b2038ed168d22e14182ed715d6963f9c073a83a2ba43cfe918a4fc43c64/orjson-3.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a791f793b287bbc135b8e87c34e35c8bfc693e2a8a620fab1ae682b925f9a32e", size = 127669, upload-time = "2026-08-14T16:12:22.926Z" }, + { url = "https://files.pythonhosted.org/packages/88/ae/b84b3d3e65f5629ada0edcb1d2bccc55d7c5f89d8b981537ecdc3d6f31ec/orjson-3.12.0-cp311-cp311-win32.whl", hash = "sha256:31ed278a36304390adc3eec5d7f6fd593a7c3e99e5a06cd07866396c4b1b4710", size = 128043, upload-time = "2026-08-14T16:12:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/35/24/2ed0e6f51ea3d0af45d807233a851175af75bec83ef5fd0d6a2601904ec0/orjson-3.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:fb2539159dfe8d371914f354360fa50e4a577cc89222a3828b9650a5e5040252", size = 122084, upload-time = "2026-08-14T16:12:25.813Z" }, + { url = "https://files.pythonhosted.org/packages/21/dd/95d25fcfbc9471799ef6bb01c552d64ee5cde93ee40ba2f423dd3442c708/orjson-3.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:61318b6de893c7a9d9f3e5ecbadccbfc26a7eb417ccc7bbf0771de3b4d72f868", size = 127035, upload-time = "2026-08-14T16:12:27.201Z" }, + { url = "https://files.pythonhosted.org/packages/be/4a/295da39c651c2faac8bd351a2a346f0fdedd9d50b847ee9dfc27d2207ef6/orjson-3.12.0-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:aa3e43a6846e91d7bde3d5a9c66090fcd8744f569a9b6cffc5e1ca38f6a461c0", size = 223427, upload-time = "2026-08-14T16:12:28.525Z" }, + { url = "https://files.pythonhosted.org/packages/29/98/758cf90fbeaaafb7f8141bfac75a432099959f3a2f5db93a412e876415d8/orjson-3.12.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:11edb4660a6680abee9788a3a9072208a2c96538cc1322bd79542065229d8e54", size = 123725, upload-time = "2026-08-14T16:12:30.013Z" }, + { url = "https://files.pythonhosted.org/packages/32/b5/5b934d251f8651f7e41df180ad0c57a6e1cabe15c7bd331638413a50ebc9/orjson-3.12.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2d3a9da945a4d96ae758fdaaca56742e6b73b6fd554c5d8876f252a6dad70b83", size = 113375, upload-time = "2026-08-14T16:12:31.209Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d2/37efb5b12a176ce3ced29f4144f20da57d02757f78ce549637dc1b4e1fc8/orjson-3.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:92ffc09e07233a6ab6d4e067f7841edcbcc134cb4812155cf171ea5255a421d7", size = 129983, upload-time = "2026-08-14T16:12:32.721Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/0644b87c73f13e0092df8f35a1fe280d991e5e90072087411e0dd7e44e0c/orjson-3.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf44e374aadde77b1f6109f1030be51433eb61984379852766b6f4e187db7b1e", size = 130629, upload-time = "2026-08-14T16:12:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/8c/57/80b986ebfecd9c6a177ddf1c2319717f0cd8feffb2b78946595a18a2fc88/orjson-3.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1192a7021b6d071aaf909864f6e924d6a2675ca360485b972b8401749311750b", size = 131245, upload-time = "2026-08-14T16:12:35.713Z" }, + { url = "https://files.pythonhosted.org/packages/80/3d/75c5ac5a69161f44492a68fbdde66f4cc4ce48cd5e1fb05918e46f0c8848/orjson-3.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:53c0c474a9d9aff9aebfc0c88de1f28f843d940e6e3a80729abdf6a20274356f", size = 135397, upload-time = "2026-08-14T16:12:37.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/93/4d71f2df314a97ff0d27a4559bf5888fc8406e3c6dec90e92291e3511215/orjson-3.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:532ff8cd4bd59a327a953a7dcde922c7fc25b85e29721bb8633265430d3a3873", size = 127693, upload-time = "2026-08-14T16:12:38.627Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1d/0dbc6be5adfd1730491072fb60beb6bcdf5d7b2596ee41b7fc2e298bfc09/orjson-3.12.0-cp312-cp312-win32.whl", hash = "sha256:a6cf4b18e7de173f209f2084ffbd736dd72389a396326ee80a7022168be232e5", size = 128000, upload-time = "2026-08-14T16:12:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c9/97b1ce0112ebf5e949c775ed5b1755e562233179f3584579673cc24d6378/orjson-3.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:010811c1b69773450a01cef97727a67b223242f350b77d4ca000e59a9ef2155a", size = 122106, upload-time = "2026-08-14T16:12:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/a8/6a/facd8b312e4a0d3a7fa978c7e15821f74a336adf1d65529faec33b48e18b/orjson-3.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:ad29eece0c601737f2a60edc2752a84e7a0785df3efb62e3012834700a5afe0d", size = 126869, upload-time = "2026-08-14T16:12:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/54/cb/d7b78218a987eb8a8ce4eeae0286b1bb679333eb631ea0eeaf6371680bfc/orjson-3.12.0-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9a36ec60f1796f9a3f13e3b98390295e17a1c7c10155b448d264098bf9ee5900", size = 223397, upload-time = "2026-08-14T16:12:44.003Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4a/bc87c45e7ec639d35ebefd62618e01939531ac8e171426606a01bda05914/orjson-3.12.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ad0422b92d5195443a39f80c3bcf731cc2e00f153bd32063a47b73b057bd0f03", size = 123662, upload-time = "2026-08-14T16:12:45.433Z" }, + { url = "https://files.pythonhosted.org/packages/94/ee/c9a4ff3f2dbedbbe9e635d0fa72c8866adede09b6335ef9644f53752f0d8/orjson-3.12.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:5a0fdbc216388f653d3752ff310e710f59253bd4ed6a2bfb3f4f06b84714bbd8", size = 113374, upload-time = "2026-08-14T16:12:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/75/09/3f330a026a796c8b4c97a6f429652a5e912e7065039bf96ed25e42aa7b25/orjson-3.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2eb5c56e534127b2b8fa38d2363c8b1b8190367ee0d1d16c041517d880843b94", size = 130029, upload-time = "2026-08-14T16:12:48.06Z" }, + { url = "https://files.pythonhosted.org/packages/7d/40/094cc53126a3d22f76cdf83b6ea67338bed01d774037621a785aa8e6e5ea/orjson-3.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:784106539f4b9d4b930e0b4eb8d45168507dae001945e71b4675a367f1e5e806", size = 130528, upload-time = "2026-08-14T16:12:49.362Z" }, + { url = "https://files.pythonhosted.org/packages/bc/74/89bb236deb9565f99434b13052bb40ddfcce4adf3afbfa3132ee7e421468/orjson-3.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c680706fc8396d95e7c4c1f9482563f552137aef91b57237a3ad5aaf64629df", size = 131075, upload-time = "2026-08-14T16:12:50.692Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ac/1176360d762c01b5bd34acd56fc098e936c491363d8b6b397ad4aa475547/orjson-3.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:83445adc40cba26d6d621185a45128ce455b766af368cad2ab64b970603a7978", size = 135321, upload-time = "2026-08-14T16:12:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/7a/02/bbd881c8b9276d50b998de38b4e97de8ace1aac940b0ee545aedbf65ed00/orjson-3.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:644d005bc82f917337a95ce270c9f6f92f9834c2bed7b1477572f8db00784222", size = 127472, upload-time = "2026-08-14T16:12:53.517Z" }, + { url = "https://files.pythonhosted.org/packages/8e/02/a0934d7503e6dcbedd6afac3e7f3f8597fd09389949ad94d0f7540e9dbca/orjson-3.12.0-cp313-cp313-win32.whl", hash = "sha256:d8e78d3d93705e3d27cc17cdb209e44d7a8ea203010cac6ce9c7ffc1ae1996f1", size = 128000, upload-time = "2026-08-14T16:12:55.14Z" }, + { url = "https://files.pythonhosted.org/packages/52/87/69f98f8d40faff103a965a5fbb83f08241b01beaf92badb5413fbc9358cc/orjson-3.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b85931be5b6763c31283805c9bdaae1ca03ad9f6f12a15f1cbf6745b907932c2", size = 121841, upload-time = "2026-08-14T16:12:56.507Z" }, + { url = "https://files.pythonhosted.org/packages/e6/07/b83046a4e3cadcc0987d0f160696107c4af706a619b56e4ad01940cadadf/orjson-3.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:6a31348d7dfa64cd9c78bd1f510ff44c48fe64d71094e6b90e364dba3b55949e", size = 126765, upload-time = "2026-08-14T16:12:57.806Z" }, + { url = "https://files.pythonhosted.org/packages/12/9d/3931253e6f3148abf2cbe14830367042a4806b362ea520df2303db188fb9/orjson-3.12.0-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9e6fee342a48760e854d743e7a81534d8e2925a6f46e09f750cf56b50fd1de5d", size = 223391, upload-time = "2026-08-14T16:12:59.184Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0e/b4a4f1e305367245877b967a0bad70fcf001d77c54ac4339a120b66fdae4/orjson-3.12.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:8c3bb86dd10f39b3fbf434b7d5dc7cac77d6fc8ac572ae30a10731ede2c4b647", size = 123659, upload-time = "2026-08-14T16:13:00.548Z" }, + { url = "https://files.pythonhosted.org/packages/96/f3/6782c6fa85e2702bc66be183c3b421486167dcf266ee4dc1403fe3824870/orjson-3.12.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2bb3ce43203936072dd8b4917b01d3aecfc02329bfb42510cb7cfb24708adc9c", size = 113337, upload-time = "2026-08-14T16:13:02.009Z" }, + { url = "https://files.pythonhosted.org/packages/bf/79/b32ab64bacda9d0fa4942ef483bd03cabf0eaf2be819ca9fb7ff610c559d/orjson-3.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6a2a79c89984dc719817d388c8709e0efc2a2795a934eaa746b4882eb6045adc", size = 130112, upload-time = "2026-08-14T16:13:03.404Z" }, + { url = "https://files.pythonhosted.org/packages/ee/49/6e6142999ca01509219be5e5a9c338a3e5ea011f63e91ff473fbbf3734ed/orjson-3.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f06dd838d1e07d9b1de0932ec0485ec92c4d5f5d1ad4817a656268c3e88be1e1", size = 130520, upload-time = "2026-08-14T16:13:04.798Z" }, + { url = "https://files.pythonhosted.org/packages/49/d0/3745af0a4cc9867784f29722929cec4d10bd1c877cd754b01ba6d96eb21a/orjson-3.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6b11be792c3d2c6a4be2af4ebf97a68d0bf5f580aca6e86a418a354f6cc846a", size = 131053, upload-time = "2026-08-14T16:13:06.14Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/6fe5a22fa478fffb190e65c338c84df5c311ef597b363150a17cc57063c0/orjson-3.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:477ecaf6b9f88f873341b91fcc736119ca81b5e002a9f7f308ff5b4f2ce2a70e", size = 135321, upload-time = "2026-08-14T16:13:07.544Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/b1b0ec30289646a81a76e2dbaae2686b96fcccb7cb0323dc1dd78cbc7875/orjson-3.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3c0683136acdc29afdf88a5bc2f7d3d0e34087788d1d63c0144b805a87a196f", size = 127485, upload-time = "2026-08-14T16:13:08.88Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2b/277404bdcc21c93b112b963655b76443ebfe828f8a3ff1de7d90f8850eb3/orjson-3.12.0-cp314-cp314-win32.whl", hash = "sha256:d39f3f5c3927e2dc0913fe5bbc1a2f6b1b9d1bba1de6358340d0ad0d0c00ca92", size = 128048, upload-time = "2026-08-14T16:13:10.305Z" }, + { url = "https://files.pythonhosted.org/packages/41/2b/395b36fa2b4ce7af70b651d715e88f80d884b2c2b14a6b53e84d554fb5f0/orjson-3.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:0b1ac5bf6609b2716c7954011c5fef6254922df029f45d032ee4ebf5d363cbed", size = 121858, upload-time = "2026-08-14T16:13:11.634Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a3/833e895ff452859eebe75093d26691fe9108f1a7a6a08435d7a5780ea652/orjson-3.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:50fae885cb073eac7556353ff3df93312b0d5137b0a5056b2bb63f97ed9a93c7", size = 126749, upload-time = "2026-08-14T16:13:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/99c8947ece10c17176af9aae85c4948f1d109da77440ec14d87239efaf73/orjson-3.12.0-cp315-cp315-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:01efac2074fffb4cb1ea3fab7861e9d0f2a26913854a972f5ac760525dbdaf6e", size = 223398, upload-time = "2026-08-14T16:13:14.694Z" }, + { url = "https://files.pythonhosted.org/packages/3e/30/cf983fe09f2731420fda097a9f7ef4343f47fa216c228961ad8f6da44f3d/orjson-3.12.0-cp315-cp315-macosx_15_0_arm64.whl", hash = "sha256:ed4ca42bd55955aa34deedcfdfd0e0c31abf51143aae158ae2bc3520b626e517", size = 123655, upload-time = "2026-08-14T16:13:16.221Z" }, + { url = "https://files.pythonhosted.org/packages/11/50/9cb8ae73fa4749dbbc20f617004213b5ff01c20aaeec34c3f31124f2c1d8/orjson-3.12.0-cp315-cp315-manylinux_2_39_aarch64.whl", hash = "sha256:40f92192227505acca4e2533ce565f8e6b9535f7d0d09b0968452f18b7376b38", size = 130515, upload-time = "2026-08-14T16:13:17.601Z" }, + { url = "https://files.pythonhosted.org/packages/9f/0a/adb6ce1a5b5fbf9cb1790f9961bb668a0dd5429aadaf6cee044724681795/orjson-3.12.0-cp315-cp315-manylinux_2_39_armv7l.whl", hash = "sha256:33efefcf5d88eaf400b47e2eba02f91f319bb9951be61ca500b7d536d3f2079d", size = 113327, upload-time = "2026-08-14T16:13:18.927Z" }, + { url = "https://files.pythonhosted.org/packages/51/5c/d17f61581d8dbdde7048f87a330fa24915edec38db4d72b381fec14fbb56/orjson-3.12.0-cp315-cp315-manylinux_2_39_i686.whl", hash = "sha256:8e386b0bc0ddd7cd2056f884b5a0af33592bd01ac66a7ca4b42a65a7e7774a13", size = 130105, upload-time = "2026-08-14T16:13:20.317Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b7/938befcf33bee4704a92ecec6a2731224c539d939bf9429fd39396d28931/orjson-3.12.0-cp315-cp315-manylinux_2_39_x86_64.whl", hash = "sha256:58c58e1de0006ffb580368d6793c36c7b0b021db066479cf281bf5061e732328", size = 131049, upload-time = "2026-08-14T16:13:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/b0/15/cfa2021d64d5aa8bb5c9f604ef375e00ec8b657651b5dd650b1b7ad13df1/orjson-3.12.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:08231552159be266a7269555bd9f7c016aee7d9ad6dab06eb58796c5ccb7101c", size = 135320, upload-time = "2026-08-14T16:13:23.415Z" }, + { url = "https://files.pythonhosted.org/packages/1a/50/3e75dfe357c1e8f9e287c7a5740260ef15bd23a5299eae8d0835dcad5375/orjson-3.12.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a15f9a891bce5f5cc5d210e3ad8614d4d1b489a56448c099d6d2a7168b2d954a", size = 127488, upload-time = "2026-08-14T16:13:24.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/a6/79aed402eb3ab284dc5b4791a7ad62c5875127de01b8e3f04bd92d551298/orjson-3.12.0-cp315-cp315-win32.whl", hash = "sha256:03091c8a64db4be38746597ceea68f33c238e27acd9bfe99fb59420224ae7a55", size = 128048, upload-time = "2026-08-14T16:13:26.217Z" }, + { url = "https://files.pythonhosted.org/packages/64/f7/2723e264aab7248c1ed6ecaad8e5d0cb866c0cffde75442102ffa7491aba/orjson-3.12.0-cp315-cp315-win_amd64.whl", hash = "sha256:2b7bcefb9f40fa242fa6b06377232c048e655747790829609168c01162f60578", size = 121860, upload-time = "2026-08-14T16:13:27.577Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/630c9113ec8996778f1f0304b364b091b9a9db5fef5fdc17cca622f5ea24/orjson-3.12.0-cp315-cp315-win_arm64.whl", hash = "sha256:859fc4196855890150bb08e649b30d2c93b249b3e3edd0d3bb2231abf8aa8adc", size = 126754, upload-time = "2026-08-14T16:13:28.962Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -105,6 +177,11 @@ wheels = [ name = "rapidquery" source = { editable = "." } +[package.optional-dependencies] +orjson = [ + { name = "orjson" }, +] + [package.dev-dependencies] dev = [ { name = "maturin" }, @@ -112,11 +189,13 @@ dev = [ ] [package.metadata] +requires-dist = [{ name = "orjson", marker = "extra == 'orjson'", specifier = ">=3.0.0" }] +provides-extras = ["orjson"] [package.metadata.requires-dev] dev = [ { name = "maturin", specifier = "==1.15.0" }, - { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest", specifier = ">=8.0.0" }, ] [[package]] From 03c6a746c88aa0e198f5af8bc929a98559f7f870 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Thu, 10 Sep 2026 10:54:15 +0330 Subject: [PATCH 21/87] Move serializers to data_type --- src/native/data_type/mod.rs | 3 +++ src/native/{ => data_type}/serialize.rs | 3 ++- src/native/{ => data_type}/serializer.rs | 0 src/native/mod.rs | 3 --- src/native/sql.rs | 2 +- src/pybindings/data_type/serializer.rs | 3 +-- 6 files changed, 7 insertions(+), 7 deletions(-) rename src/native/{ => data_type}/serialize.rs (99%) rename src/native/{ => data_type}/serializer.rs (100%) diff --git a/src/native/data_type/mod.rs b/src/native/data_type/mod.rs index 947ea6b..1b64ce5 100644 --- a/src/native/data_type/mod.rs +++ b/src/native/data_type/mod.rs @@ -7,8 +7,11 @@ //! [`DataTypeGroup::Float`] because they share a serializer with IEEE floats. mod name; +mod serialize; +mod serializer; #[cfg(test)] mod tests; pub use name::{DataTypeGroup, DataTypeName}; +pub use serializer::*; diff --git a/src/native/serialize.rs b/src/native/data_type/serialize.rs similarity index 99% rename from src/native/serialize.rs rename to src/native/data_type/serialize.rs index 41f6794..3dfbd9e 100644 --- a/src/native/serialize.rs +++ b/src/native/data_type/serialize.rs @@ -12,7 +12,8 @@ use pyo3::types::{PyAnyMethods, PyTzInfoAccess}; use crate::internal::alias; use crate::internal::utils::{get_type_name, py_str}; use crate::native::dialect::SqlDialect; -use crate::native::serializer::Serializer; + +use super::serializer::Serializer; impl Serializer { pub fn write_sql( diff --git a/src/native/serializer.rs b/src/native/data_type/serializer.rs similarity index 100% rename from src/native/serializer.rs rename to src/native/data_type/serializer.rs diff --git a/src/native/mod.rs b/src/native/mod.rs index 3768b70..29a95c9 100644 --- a/src/native/mod.rs +++ b/src/native/mod.rs @@ -1,10 +1,7 @@ pub mod data_type; pub mod dialect; -pub mod serializer; pub mod sql; mod ident; -mod serialize; pub use ident::{Ident, IdentPart, ParseIdentError}; -pub use serializer::Serializer; diff --git a/src/native/sql.rs b/src/native/sql.rs index 17b274e..9c5694a 100644 --- a/src/native/sql.rs +++ b/src/native/sql.rs @@ -9,8 +9,8 @@ use hashbrown::HashMap; use pyo3::types::PyAnyMethods; use crate::internal::alias; +use crate::native::data_type::Serializer; use crate::native::dialect::SqlDialect; -use crate::native::serializer::Serializer; /// Error produced while building SQL. #[derive(Debug)] diff --git a/src/pybindings/data_type/serializer.rs b/src/pybindings/data_type/serializer.rs index 0b30aee..f670393 100644 --- a/src/pybindings/data_type/serializer.rs +++ b/src/pybindings/data_type/serializer.rs @@ -11,8 +11,7 @@ use std::hash::{Hash, Hasher}; use crate::internal::alias; -use crate::native::data_type::DataTypeGroup; -use crate::native::serializer::Serializer; +use crate::native::data_type::{DataTypeGroup, Serializer}; fn class_name(serializer: &Serializer) -> &'static str { match serializer { From 756fa034e6968a855e03b0702e48c690abb644c7 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Thu, 10 Sep 2026 12:42:52 +0330 Subject: [PATCH 22/87] Write value --- AGENTS.md | 6 +- rapidquery/__init__.py | 2 + rapidquery/data_type.pyi | 52 +++----- rapidquery/value.py | 9 ++ rapidquery/value.pyi | 83 +++++++++++++ src/lib.rs | 2 +- src/native/data_type/mod.rs | 53 ++++++++- src/native/data_type/serialize.rs | 1 + src/native/data_type/serializer.rs | 73 ++++++++++++ src/native/mod.rs | 3 + src/native/sql.rs | 11 ++ src/native/value.rs | 39 ++++++ src/pybindings/data_type/mod.rs | 135 ++++++++++++--------- src/pybindings/data_type/serializer.rs | 42 ++++--- src/pybindings/ident.rs | 2 +- src/pybindings/mod.rs | 5 +- src/pybindings/value.rs | 159 +++++++++++++++++++++++++ src/typeref.rs | 5 + tests/test_data_type.py | 16 +++ tests/test_value.py | 107 +++++++++++++++++ 20 files changed, 691 insertions(+), 114 deletions(-) create mode 100644 rapidquery/value.py create mode 100644 rapidquery/value.pyi create mode 100644 src/native/value.rs create mode 100644 src/pybindings/value.rs create mode 100644 tests/test_value.py diff --git a/AGENTS.md b/AGENTS.md index ee847f8..1723d73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,18 +27,18 @@ Keep these `refactor/v1` divergences from the PR (do not revert them): - Python `>=3.10` (local: 3.13). Rust edition **2024**. PyO3 **0.29**. Maturin **1.15.0**. uv for the venv. - Workspace crate `rapidquery-macros` (proc-macros: `pyclass`, `pyerr`). - Target stack from PR #2 also used: `arcstr`, `once_cell`, `chrono`, `ipnetwork`, `mac_address`. (`derive_more` is not on this branch.) -- Dialects (13), `DataType` + `Serializer`, `Ident`/`Unquote`/`Asterisk`, `SqlWriter`/`WriteSql` exist on this branch. Still missing vs the PR’s next layers: `Value`, query/schema AST (never in the PR either). +- Dialects (13), `DataType` + `Serializer`, `Ident`/`Unquote`/`Asterisk`, `Value`, `SqlWriter`/`WriteSql` exist on this branch. Still missing vs the PR’s next layers: query/schema AST (never in the PR either). - `#[pyo3::pymodule(gil_used = false)]` — keep free-threaded Python in mind. - Frozen/immutable PyO3 types: `rapidquery_macros::pyclass` already sets `module="rapidquery._core"`, `immutable_type`, `frozen`. ## Layout ``` -src/native/ Rust engine (dialects, DataTypeName, Ident, Serializer, SqlWriter/WriteSql) +src/native/ Rust engine (dialects, DataType, Ident, Serializer, Value, SqlWriter/WriteSql) src/pybindings/ Python wrappers (pyclasses only) src/internal/ aliases, string utils, submodule helper src/typeref.rs cached PyTypeObject pointers -rapidquery/ Python package (re-exports Ident, Unquote, Asterisk, data_type, dialect) +rapidquery/ Python package (re-exports Ident, Unquote, Asterisk, Value, data_type, dialect) rapidquery-macros/ Proc-macros tests/ Python tests backup/, backup-py/ gitignored v0 snapshots — read-only diff --git a/rapidquery/__init__.py b/rapidquery/__init__.py index 13483db..7251d75 100644 --- a/rapidquery/__init__.py +++ b/rapidquery/__init__.py @@ -5,11 +5,13 @@ from .ident import Asterisk as Asterisk from .ident import Ident as Ident from .ident import Unquote as Unquote +from .value import Value as Value __all__ = [ "Asterisk", "Ident", "Unquote", + "Value", "data_type", "dialect", ] diff --git a/rapidquery/data_type.pyi b/rapidquery/data_type.pyi index 0f0f540..e974997 100644 --- a/rapidquery/data_type.pyi +++ b/rapidquery/data_type.pyi @@ -302,8 +302,11 @@ class DataType: Known names are interned and canonicalized (`"int"` becomes `"INT"`). Unknown names and parameterized spellings (`VARCHAR(255)`) are kept as - given. If `serializer` is omitted at construction, one is inferred from - the name; unrecognized names get `UnspecifiedSerializer`. + given. Interning applies to the **name string**, not to `DataType` + instances: `DataType("int")` is not a singleton. + + If `serializer` is omitted at construction, one is inferred from the + name; unrecognized names get `UnspecifiedSerializer`. """ def __new__(cls, name: str, serializer: Serializer | None = None, /) -> Self: @@ -311,31 +314,16 @@ class DataType: Creates a new SQL data type. Args: - name: The name of a data type. *It is not limited to a set of predefined values.* - - **Important memory optimization note**: At import time, the library pre-allocates - and interns (reserves in memory) several simple and commonly used data types - (e.g. `INTEGER`, `VARCHAR`, `TIMESTAMP`, `BOOLEAN`, etc.). If you instantiate - a `DataType` using one of these common names, the library will automatically - return a reference to the pre-allocated instance instead of creating a new one. - This reduces memory consumption and improves performance when working with - standard SQL types. - - As a result of this interning mechanism, when you access the `.name` property - on a pre-allocated instance, the returned string **may be slightly different** - from the exact string you passed in (for example in casing, spacing, or canonical - form chosen by the library). This is expected behavior and does not affect - correctness. - - serializer: The `serializer` is used whenever a Python object needs to be converted - into a valid SQL value during query construction. This is commonly required - when setting default values, inserting data, or generating other SQL statements. - You can use one of the built-in serializers (we provide ready-to-use - serializers for most common data types), or you can create and register your - own custom serializer. This parameter is optional. However, it is strongly - recommended to always specify it explicitly. If you do not provide a serializer, - the `DataType` will attempt to automatically detect and assign one based on the - `name`. If no suitable serializer is found, it will fall back to `UnspecifiedSerializer`. + name: The SQL type spelling. Not limited to a predefined set. + Known names are interned and canonicalized (`"int"` + becomes `"INT"`). Unknown names and parameterized + spellings (`VARCHAR(255)`) are kept as given. + serializer: Used when a Python object is rendered as a SQL + literal (defaults, inserts, raw SQL). Built-in + serializers cover common types; `CustomSerializer` + covers the rest. If omitted, a serializer is inferred + from `name`; unrecognized names get + `UnspecifiedSerializer`. """ @property @@ -344,12 +332,8 @@ class DataType: Returns the type name. Returns: - The canonical type name. - - Note: - For interned common data types (see `__new__`), the returned value - may be a canonicalized version of the original input string (for - example in casing or spacing). + The canonical type name. For interned names this may differ in + casing or spacing from the string passed to the constructor. """ @property @@ -375,4 +359,6 @@ class DataType: A SQL fragment produced by this type's serializer. """ + def __eq__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... def __repr__(self) -> str: ... diff --git a/rapidquery/value.py b/rapidquery/value.py new file mode 100644 index 0000000..c669c93 --- /dev/null +++ b/rapidquery/value.py @@ -0,0 +1,9 @@ +""" +SQL values. +""" + +from ._core import Value + +__all__ = [ + "Value", +] diff --git a/rapidquery/value.pyi b/rapidquery/value.pyi new file mode 100644 index 0000000..eb029ab --- /dev/null +++ b/rapidquery/value.pyi @@ -0,0 +1,83 @@ +from typing import Any, Self, final + +from rapidquery.data_type import DataType, Serializer +from rapidquery.dialect import SqlDialect + +@final +class Value: + """ + A Python object plus the serializer used to emit it as SQL. + + `Value` does not convert the object until SQL is built. Parameterized + statements bind the object as a placeholder. Raw SQL runs the serializer + (`None` becomes `NULL`). + + If `serializer` is omitted, one is inferred from the object's type. + Passing an existing `Value` keeps its object (and its serializer, unless + a new one is given). A `DataType` as `serializer` contributes only its + serializer; the data type is not stored. + """ + + def __new__( + cls, + object: Any, + serializer: Serializer | DataType | None = None, + /, + ) -> Self: + """ + Creates a SQL value. + + Args: + object: The Python object to bind or serialize. If this is already + a `Value`, its object is reused. + serializer: How to render `object` as a SQL literal. A `DataType` + is accepted and only its serializer is kept. If omitted, + a serializer is inferred from `object` (or kept, when + `object` is already a `Value`). + + Raises: + TypeError: If `serializer` is not a `Serializer` or `DataType`. + """ + + @property + def object(self) -> Any: + """ + Returns the wrapped Python object. + + Returns: + The object passed to the constructor (or taken from an existing + `Value`). + """ + + @property + def serializer(self) -> Serializer: + """ + Returns the serializer used for raw SQL literals. + + Returns: + The `Serializer` stored on this value. Not used when the value + is bound as a parameter. + """ + + def to_sql(self, dialect: type[SqlDialect] | SqlDialect) -> str: + """ + Renders this value as a SQL literal for `dialect`. + + Raw path: `None` becomes `NULL`; otherwise the serializer writes a + literal. This is not the parameterized form. + + Args: + dialect: A dialect class (`PostgreSQL`) or instance + (`PostgreSQL()`). + + Returns: + A SQL fragment suitable for embedding in a statement. + + Raises: + TypeError: If `object` is not an accepted type for the serializer. + ValueError: If `object` is the right type but cannot be represented. + """ + + def __eq__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... diff --git a/src/lib.rs b/src/lib.rs index c4c33ea..7612272 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,7 +15,7 @@ mod _core { use super::pybindings::dialect_mod; #[pymodule_export] - use super::pybindings::{PyAsterisk, PyIdent, PyUnquote}; + use super::pybindings::{PyAsterisk, PyIdent, PyUnquote, PyValue}; #[pymodule_init] #[cold] diff --git a/src/native/data_type/mod.rs b/src/native/data_type/mod.rs index 1b64ce5..192c02b 100644 --- a/src/native/data_type/mod.rs +++ b/src/native/data_type/mod.rs @@ -1,10 +1,10 @@ -//! SQL data-type names. +//! SQL data types: interned names, serializers, and the composed [`DataType`]. //! //! A [`DataTypeName`] is a canonical spelling plus a [`DataTypeGroup`]. The -//! group is the inference key for which [`crate::native::Serializer`] -//! `DataType(name)` constructs when none is passed. Groups are -//! serializer-oriented: `NUMERIC` and `DECIMAL` sit in -//! [`DataTypeGroup::Float`] because they share a serializer with IEEE floats. +//! group is the inference key for which [`Serializer`] [`DataType::from_name`] +//! constructs when none is passed. Groups are serializer-oriented: `NUMERIC` +//! and `DECIMAL` sit in [`DataTypeGroup::Float`] because they share a +//! serializer with IEEE floats. mod name; mod serialize; @@ -15,3 +15,46 @@ mod tests; pub use name::{DataTypeGroup, DataTypeName}; pub use serializer::*; + +use crate::native::sql::{PrepareResult, SqlWriter, WriteSql}; + +/// SQL type spelling plus the serializer used for Python values. +/// +/// [`WriteSql`] emits the type name (CAST, column definitions). Literal +/// serialization of a Python object is [`Serializer::write_sql`], not this +/// type. +/// +/// Clone of a [`Serializer::Custom`] value requires the GIL. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct DataType { + pub name: DataTypeName, + pub serializer: Serializer, +} + +impl DataType { + #[inline] + pub fn new(name: DataTypeName, serializer: Serializer) -> Self { + Self { name, serializer } + } + + /// Infer the serializer from [`DataTypeName::group`]. + /// + /// Unrecognized and parameterized names use [`Serializer::Unspecified`]. + #[inline] + pub fn from_name(name: impl Into) -> Self { + let name = name.into(); + let serializer = Serializer::from_group(name.group()); + Self { name, serializer } + } +} + +impl WriteSql for DataType { + #[inline] + fn write_sql( + &self, + dialect: &crate::native::dialect::SqlDialect, + out: &mut W, + ) -> PrepareResult<()> { + self.name.write_sql(dialect, out) + } +} diff --git a/src/native/data_type/serialize.rs b/src/native/data_type/serialize.rs index 3dfbd9e..9f68581 100644 --- a/src/native/data_type/serialize.rs +++ b/src/native/data_type/serialize.rs @@ -463,6 +463,7 @@ unsafe fn write_mac_address( let addr = mac_address::MacAddress::from_str(&text) .map(|mac| mac.to_string()) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + write_quoted(dialect, out, &addr); Ok(()) } diff --git a/src/native/data_type/serializer.rs b/src/native/data_type/serializer.rs index 6bdecd4..e2afb01 100644 --- a/src/native/data_type/serializer.rs +++ b/src/native/data_type/serializer.rs @@ -10,6 +10,7 @@ use std::hash::{Hash, Hasher}; use crate::internal::alias; +use crate::internal::modules::json_dumps; use crate::native::data_type::DataTypeGroup; /// How a Python object is written as a SQL literal. @@ -94,4 +95,76 @@ impl Serializer { DataTypeGroup::Custom => Self::Unspecified, } } + + pub fn infer_bound(bound: &alias::BoundObject) -> Self { + unsafe { + let object_ptr = bound.as_ptr(); + let object_type_ptr = pyo3::ffi::Py_TYPE(object_ptr); + + // None / bool -> Boolean + // + // Why `None`? + // Actually we don't use [`Serializer`] on `None` values. + // So it's not important what [`Serializer`] is. + // `None` values are handled by [`native::sql::SqlWriter`] + if pyo3::ffi::Py_IsNone(object_ptr) == 1 || pyo3::ffi::PyBool_Check(object_ptr) == 1 { + return Self::Boolean; + } + + // float / decimal -> Float + if pyo3::ffi::PyFloat_CheckExact(object_ptr) == 1 + || object_type_ptr == crate::typeref::STD_DECIMAL_TYPE + { + return Self::Float; + } + + // int -> Integer + if pyo3::ffi::PyLong_CheckExact(object_ptr) == 1 { + return Self::Integer; + } + + // str/Enum -> String + if pyo3::ffi::PyUnicode_CheckExact(object_ptr) == 1 + || object_type_ptr == crate::typeref::STD_ENUM_TYPE + { + return Self::String; + } + + // bytes -> Bytes + if pyo3::ffi::PyBytes_CheckExact(object_ptr) == 1 { + return Self::Bytes; + } + + // date -> Date + if object_type_ptr == crate::typeref::STD_DATE_TYPE { + return Self::Date; + } + + // time -> Time + if object_type_ptr == crate::typeref::STD_TIME_TYPE { + return Self::Time; + } + + // datetime -> Datetime + if object_type_ptr == crate::typeref::STD_DATETIME_TYPE { + return Self::Datetime; + } + + // UUID -> Uuid + if object_type_ptr == crate::typeref::STD_UUID_TYPE { + return Self::Uuid; + } + + // list/dict -> Json + if (pyo3::ffi::PyList_CheckExact(object_ptr) == 1 + || pyo3::ffi::PyDict_CheckExact(object_ptr) == 1) + && json_dumps(bound.clone(), |_| Ok(())).is_ok() + { + return Self::Json; + } + } + + // * -> Unspecified + Self::Unspecified + } } diff --git a/src/native/mod.rs b/src/native/mod.rs index 29a95c9..da02816 100644 --- a/src/native/mod.rs +++ b/src/native/mod.rs @@ -3,5 +3,8 @@ pub mod dialect; pub mod sql; mod ident; +mod value; +pub use data_type::DataType; pub use ident::{Ident, IdentPart, ParseIdentError}; +pub use value::Value; diff --git a/src/native/sql.rs b/src/native/sql.rs index 9c5694a..ab1d4c4 100644 --- a/src/native/sql.rs +++ b/src/native/sql.rs @@ -51,6 +51,17 @@ impl From for PrepareError { } } +impl From for pyo3::PyErr { + fn from(err: PrepareError) -> Self { + match err { + PrepareError::Python(err) => err, + PrepareError::Write => { + rapidquery_macros::pyerr!(PyRuntimeError, "failed to write SQL") + } + } + } +} + pub type PrepareResult = Result; pub type PrepareParams = HashMap; diff --git a/src/native/value.rs b/src/native/value.rs new file mode 100644 index 0000000..a458bcd --- /dev/null +++ b/src/native/value.rs @@ -0,0 +1,39 @@ +use crate::internal::alias; +use crate::native::data_type::Serializer; +use crate::native::sql::WriteSql; + +/// A Python object plus the [`Serializer`] used to emit it as SQL. +/// +/// Parameterized SQL binds [`Self::object`] as a placeholder and ignores the +/// serializer. Raw SQL (`BufferedSql`) calls [`Serializer::write_sql`]; +/// Python `None` becomes `NULL`. +/// +/// Clone requires the GIL (`Py::clone`). +#[derive(Debug, Clone)] +pub struct Value { + pub object: alias::PyObject, + pub serializer: Serializer, +} + +impl Value { + #[inline] + pub fn new(object: alias::PyObject, serializer: Serializer) -> Self { + Self { object, serializer } + } + + #[inline] + pub fn infer_bound(bound: alias::BoundObject) -> Self { + let serializer = Serializer::infer_bound(&bound); + Self::new(bound.unbind(), serializer) + } +} + +impl WriteSql for Value { + fn write_sql( + &self, + dialect: &super::dialect::SqlDialect, + out: &mut W, + ) -> super::sql::PrepareResult<()> { + out.push_value(dialect, &self.serializer, &self.object) + } +} diff --git a/src/pybindings/data_type/mod.rs b/src/pybindings/data_type/mod.rs index e32418d..ddc6e4d 100644 --- a/src/pybindings/data_type/mod.rs +++ b/src/pybindings/data_type/mod.rs @@ -1,15 +1,16 @@ //! Python wrappers for SQL data types and value serializers. //! //! Native [`DataTypeName`](crate::native::data_type::DataTypeName) is a -//! spelling plus group. Native [`Serializer`](crate::native::Serializer) -//! writes Python objects as SQL literals. This module owns the pyclasses. +//! spelling plus group. Native [`Serializer`](crate::native::data_type::Serializer) +//! writes Python objects as SQL literals. Native [`DataType`](crate::native::DataType) +//! is the name plus serializer; this module owns the pyclasses. //! //! Python sees: //! //! * [`PySerializer`] — `rapidquery._core.data_type.Serializer`, the ABC //! * frozen subclasses per behaviour (`IntegerSerializer`, `JSONSerializer`, …) //! * [`PyArraySerializer`] / [`PyCustomSerializer`] -//! * [`PyDataType`] — a name plus a serializer +//! * [`PyDataType`] — `rapidquery._core.data_type.DataType` //! //! `serialize(dialect, obj)` accepts a dialect **class or instance**. Serializer //! extract is instance-only (Array/Custom carry state). @@ -17,8 +18,9 @@ mod serializer; use crate::internal::alias; -use crate::native::data_type::DataTypeName; +use crate::native; +pub(crate) use serializer::serializer_repr; pub use serializer::{ PyArraySerializer, PyBitSerializer, PyBooleanSerializer, PyBytesSerializer, PyCustomSerializer, PyDateSerializer, PyDatetimeSerializer, PyFloatSerializer, PyInetSerializer, @@ -26,6 +28,39 @@ pub use serializer::{ PyStringSerializer, PyTimeSerializer, PyUnspecifiedSerializer, PyUuidSerializer, }; +/// `Serializer` instance, or a `DataType` (its serializer only). +pub struct SerializerExtractor(pub crate::native::data_type::Serializer); + +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for SerializerExtractor { + type Error = pyo3::PyErr; + + fn extract( + obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, + ) -> Result { + let ptr = obj.as_ptr(); + + unsafe { + // SAFETY: `ptr` is a live borrowed `PyObject`. Type pointers are + // interned in `initialize_typeref` before any extractor runs. + if crate::typeref::check(ptr, crate::typeref::SERIALIZER_TYPE) { + let serializer = obj.cast_unchecked::(); + return Ok(Self(serializer.get().0.clone())); + } + + if crate::typeref::check_exact(ptr, crate::typeref::DATATYPE_TYPE) { + let data_type = obj.cast_unchecked::(); + return Ok(Self(data_type.get().0.serializer.clone())); + } + } + + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected Serializer or DataType, got {}", + unsafe { crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) } + ) + } +} + /// Represents a SQL data type. /// /// A data type is a SQL name plus the serializer used to turn Python objects @@ -34,68 +69,56 @@ pub use serializer::{ /// /// Known names are interned and canonicalized (`"int"` becomes `"INT"`). /// Unknown names and parameterized spellings (`VARCHAR(255)`) are kept as -/// given. If `serializer` is omitted at construction, one is inferred from -/// the name; unrecognized names get `UnspecifiedSerializer`. -#[rapidquery_macros::pyclass("DataType", module = "rapidquery._core.data_type")] -#[derive(Debug)] -pub struct PyDataType { - name: DataTypeName, - serializer: PySerializer, -} +/// given. Interning applies to the **name string**, not to `DataType` +/// instances: `DataType("int")` is not a singleton. +/// +/// If `serializer` is omitted at construction, one is inferred from the +/// name; unrecognized names get `UnspecifiedSerializer`. +#[rapidquery_macros::pyclass( + "DataType", + eq, + hash, + from_py_object, + module = "rapidquery._core.data_type" +)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PyDataType(pub native::DataType); #[pyo3::pymethods] impl PyDataType { /// Creates a new SQL data type. /// /// Args: - /// name: The name of a data type. *It is not limited to a set of predefined values.* - /// - /// **Important memory optimization note**: At import time, the library pre-allocates - /// and interns (reserves in memory) several simple and commonly used data types - /// (e.g. `INTEGER`, `VARCHAR`, `TIMESTAMP`, `BOOLEAN`, etc.). If you instantiate - /// a `DataType` using one of these common names, the library will automatically - /// return a reference to the pre-allocated instance instead of creating a new one. - /// This reduces memory consumption and improves performance when working with - /// standard SQL types. - /// - /// As a result of this interning mechanism, when you access the `.name` property - /// on a pre-allocated instance, the returned string **may be slightly different** - /// from the exact string you passed in (for example in casing, spacing, or canonical - /// form chosen by the library). This is expected behavior and does not affect - /// correctness. - /// - /// serializer: The `serializer` is used whenever a Python object needs to be converted - /// into a valid SQL value during query construction. This is commonly required - /// when setting default values, inserting data, or generating other SQL statements. - /// You can use one of the built-in serializers (we provide ready-to-use - /// serializers for most common data types), or you can create and register your - /// own custom serializer. This parameter is optional. However, it is strongly - /// recommended to always specify it explicitly. If you do not provide a serializer, - /// the `DataType` will attempt to automatically detect and assign one based on the - /// `name`. If no suitable serializer is found, it will fall back to `UnspecifiedSerializer`. + /// name: The SQL type spelling. Not limited to a predefined set. + /// Known names are interned and canonicalized (`"int"` + /// becomes `"INT"`). Unknown names and parameterized + /// spellings (`VARCHAR(255)`) are kept as given. + /// serializer: Used when a Python object is rendered as a SQL + /// literal (defaults, inserts, raw SQL). Built-in + /// serializers cover common types; `CustomSerializer` + /// covers the rest. If omitted, a serializer is inferred + /// from `name`; unrecognized names get + /// `UnspecifiedSerializer`. #[new] #[pyo3(signature = (name, serializer = None, /))] - fn __new__(name: String, serializer: Option) -> pyo3::PyClassInitializer { - let name = DataTypeName::from(name); - let serializer = match serializer { - Some(s) => s, - None => PySerializer::inferred(name.group()), + fn __new__(name: String, serializer: Option) -> Self { + let inner = match serializer { + Some(PySerializer(s)) => { + native::DataType::new(native::data_type::DataTypeName::from(name), s) + } + None => native::DataType::from_name(name), }; - pyo3::PyClassInitializer::from(Self { name, serializer }) + Self(inner) } /// Returns the type name. /// /// Returns: - /// The canonical type name. - /// - /// Note: - /// For interned common data types (see `__new__`), the returned value - /// may be a canonicalized version of the original input string (for - /// example in casing or spacing). + /// The canonical type name. For interned names this may differ in + /// casing or spacing from the string passed to the constructor. #[getter] fn get_name(&self) -> &str { - self.name.as_str() + self.0.name.as_str() } /// Returns the serializer used to render Python values as SQL literals. @@ -104,7 +127,7 @@ impl PyDataType { /// The `Serializer` associated with this data type. #[getter] fn get_serializer(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - slf.serializer.clone().into_subclasses(slf.py()) + PySerializer(slf.0.serializer.clone()).into_subclasses(slf.py()) } /// Renders `object` as a SQL literal for `dialect`. @@ -122,11 +145,17 @@ impl PyDataType { dialect: super::dialect::PySqlDialect, object: alias::BoundObject<'_>, ) -> pyo3::PyResult { - self.serializer.serialize_value(dialect, object) + let mut buf = String::new(); + self.0.serializer.write_sql(&dialect.0, &object, &mut buf)?; + Ok(buf) } fn __repr__(&self) -> String { - format!("DataType({:?})", self.name.as_str()) + format!( + "DataType({:?}, {})", + self.0.name.as_str(), + serializer_repr(&self.0.serializer) + ) } } diff --git a/src/pybindings/data_type/serializer.rs b/src/pybindings/data_type/serializer.rs index f670393..49af675 100644 --- a/src/pybindings/data_type/serializer.rs +++ b/src/pybindings/data_type/serializer.rs @@ -1,6 +1,6 @@ //! Python `Serializer` class hierarchy. //! -//! Wraps native [`Serializer`](crate::native::Serializer). Python sees frozen +//! Wraps native [`Serializer`](crate::native::data_type::Serializer). Python sees frozen //! subclasses (`IntegerSerializer`, …) plus `ArraySerializer` / //! `CustomSerializer`. //! @@ -55,14 +55,18 @@ fn class_name(serializer: &Serializer) -> &'static str { module = "rapidquery._core.data_type" )] #[derive(Debug, Clone)] -pub struct PySerializer(pub(super) Serializer); +pub struct PySerializer(pub Serializer); impl PySerializer { - pub(super) fn inferred(kind: DataTypeGroup) -> Self { + pub fn infer_bound(bound: &alias::BoundObject) -> Self { + Self(Serializer::infer_bound(bound)) + } + + pub fn from_group(kind: DataTypeGroup) -> Self { Self(Serializer::from_group(kind)) } - pub(crate) fn serialize_value( + pub fn serialize_value( &self, dialect: crate::pybindings::dialect::PySqlDialect, object: alias::BoundObject<'_>, @@ -73,7 +77,7 @@ impl PySerializer { } // Wrap `self` as the matching Python subclass (`IntegerSerializer()`, …). - pub(super) fn into_subclasses<'py>( + pub fn into_subclasses<'py>( self, py: pyo3::Python<'py>, ) -> pyo3::PyResult> { @@ -98,6 +102,21 @@ impl PySerializer { } } +pub(crate) fn serializer_repr(serializer: &Serializer) -> String { + match serializer { + Serializer::Array(element) => { + format!("ArraySerializer({}())", class_name(element)) + } + other => { + let name = class_name(other); + let mut buf = String::with_capacity(name.len() + 2); + buf.push_str(name); + buf.push_str("()"); + buf + } + } +} + fn wrap<'py, T>( py: pyo3::Python<'py>, base: PySerializer, @@ -136,18 +155,7 @@ impl PySerializer { } fn __repr__(&self) -> String { - match &self.0 { - Serializer::Array(element) => { - format!("ArraySerializer({}())", class_name(element)) - } - other => { - let name = class_name(other); - let mut buf = String::with_capacity(name.len() + 2); - buf.push_str(name); - buf.push_str("()"); - buf - } - } + serializer_repr(&self.0) } // Bound so only instances match. diff --git a/src/pybindings/ident.rs b/src/pybindings/ident.rs index e6f4e6b..33d1a12 100644 --- a/src/pybindings/ident.rs +++ b/src/pybindings/ident.rs @@ -159,7 +159,7 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyIdent { impl PyIdent { #[new] #[pyo3(signature = (*parts))] - fn __new__(parts: &pyo3::Bound<'_, pyo3::types::PyTuple>) -> pyo3::PyResult { + fn __new__(parts: alias::ArgsType) -> pyo3::PyResult { match parts.len() { 0 => Err(parse_err(native::ParseIdentError::Empty)), 1 => match extract_arg(parts.get_item(0)?.as_borrowed())? { diff --git a/src/pybindings/mod.rs b/src/pybindings/mod.rs index 1fab0d6..55bf249 100644 --- a/src/pybindings/mod.rs +++ b/src/pybindings/mod.rs @@ -2,13 +2,16 @@ //! //! This module is the only place that defines `#[pyclass]` types and the //! `rapidquery._core.*` submodules they live in. Native may hold and inspect -//! Python objects (`Serializer`, `PrepareContext`); pyclasses wrap those types. +//! Python objects (`Serializer`, `Value`, `PrepareContext`); pyclasses wrap +//! those types. pub mod data_type; pub mod dialect; mod ident; +mod value; pub use data_type::data_type_mod; pub use dialect::dialect_mod; pub use ident::{IdentExtractor, PyAsterisk, PyIdent, PyUnquote}; +pub use value::PyValue; diff --git a/src/pybindings/value.rs b/src/pybindings/value.rs new file mode 100644 index 0000000..0558626 --- /dev/null +++ b/src/pybindings/value.rs @@ -0,0 +1,159 @@ +//! Python wrapper around [`crate::native::Value`]. +//! +//! Native [`Value`](crate::native::Value) holds a Python object and a +//! [`Serializer`](crate::native::data_type::Serializer). This module owns +//! the pyclass: +//! +//! * [`PyValue`] — `rapidquery._core.Value` +//! +//! `FromPyObject` unwraps an existing `Value` or infers a serializer from +//! any other object. The constructor also accepts a `Serializer` or a +//! `DataType` (using only its serializer). + +use std::hash::{Hash, Hasher}; + +use pyo3::types::{PyAnyMethods, PyStringMethods}; + +use crate::internal::alias; +use crate::native; +use crate::native::sql::write_sql; +use crate::pybindings::data_type::{PySerializer, SerializerExtractor, serializer_repr}; + +/// A Python object plus the serializer used to emit it as SQL. +/// +/// `Value` does not convert the object until SQL is built. Parameterized +/// statements bind the object as a placeholder. Raw SQL runs the serializer +/// (`None` becomes `NULL`). +/// +/// If `serializer` is omitted, one is inferred from the object's type. +/// Passing an existing `Value` keeps its object (and its serializer, unless +/// a new one is given). A `DataType` as `serializer` contributes only its +/// serializer; the data type is not stored. +#[rapidquery_macros::pyclass("Value", skip_from_py_object)] +#[derive(Debug, Clone)] +pub struct PyValue(native::Value); + +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyValue { + type Error = pyo3::PyErr; + + #[inline] + fn extract( + obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, + ) -> Result { + unsafe { + if crate::typeref::check_exact(obj.as_ptr(), crate::typeref::VALUE_TYPE) { + let value = obj.cast_unchecked::(); + return Ok(value.get().clone()); + } + } + + Ok(Self(native::Value::infer_bound(obj.as_any().clone()))) + } +} + +#[pyo3::pymethods] +impl PyValue { + /// Creates a SQL value. + /// + /// Args: + /// object: The Python object to bind or serialize. If this is already + /// a `Value`, its object is reused. + /// serializer: How to render `object` as a SQL literal. A `DataType` + /// is accepted and only its serializer is kept. If omitted, + /// a serializer is inferred from `object` (or kept, when + /// `object` is already a `Value`). + /// + /// Raises: + /// TypeError: If `serializer` is not a `Serializer` or `DataType`. + #[new] + #[pyo3(signature = (object, serializer = None, /))] + fn __new__( + object: alias::BoundObject, + serializer: Option, + ) -> pyo3::PyClassInitializer { + // handle Value(Value(...), new_serializer) + if unsafe { crate::typeref::check_exact(object.as_ptr(), crate::typeref::VALUE_TYPE) } { + let value = unsafe { object.cast_unchecked::() }; + let native_value = value.get().0.clone(); + + let slf = match serializer { + Some(SerializerExtractor(s)) => Self(native::Value::new(native_value.object, s)), + None => Self(native_value), + }; + return pyo3::PyClassInitializer::from(slf); + } + + let slf = match serializer { + Some(SerializerExtractor(s)) => Self(native::Value::new(object.unbind(), s)), + None => Self(native::Value::infer_bound(object)), + }; + pyo3::PyClassInitializer::from(slf) + } + + /// Returns the wrapped Python object. + /// + /// Returns: + /// The object passed to the constructor (or taken from an existing + /// `Value`). + #[getter] + fn get_object(slf: pyo3::PyRef<'_, Self>) -> alias::PyObject { + slf.0.object.clone_ref(slf.py()) + } + + /// Returns the serializer used for raw SQL literals. + /// + /// Returns: + /// The `Serializer` stored on this value. Not used when the value + /// is bound as a parameter. + #[getter] + fn get_serializer(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + PySerializer(slf.0.serializer.clone()).into_subclasses(slf.py()) + } + + /// Renders this value as a SQL literal for `dialect`. + /// + /// Raw path: `None` becomes `NULL`; otherwise the serializer writes a + /// literal. This is not the parameterized form. + /// + /// Args: + /// dialect: A dialect class (`PostgreSQL`) or instance + /// (`PostgreSQL()`). + /// + /// Returns: + /// A SQL fragment suitable for embedding in a statement. + /// + /// Raises: + /// TypeError: If `object` is not an accepted type for the serializer. + /// ValueError: If `object` is the right type but cannot be represented. + fn to_sql( + &self, + py: pyo3::Python<'_>, + dialect: super::dialect::PySqlDialect, + ) -> pyo3::PyResult { + write_sql(py, &self.0, &dialect.0).map_err(Into::into) + } + + fn __eq__(&self, py: pyo3::Python<'_>, other: &pyo3::Bound<'_, Self>) -> pyo3::PyResult { + if self.0.serializer != other.get().0.serializer { + return Ok(false); + } + + self.0.object.bind(py).eq(other.get().0.object.bind(py)) + } + + fn __hash__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.0.object.bind(py).hash()?.hash(&mut hasher); + self.0.serializer.hash(&mut hasher); + Ok(hasher.finish()) + } + + fn __repr__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let obj_repr = self.0.object.bind(py).repr()?; + Ok(format!( + "Value({}, {})", + obj_repr.to_cow()?, + serializer_repr(&self.0.serializer) + )) + } +} diff --git a/src/typeref.rs b/src/typeref.rs index 4deda9b..931bee5 100644 --- a/src/typeref.rs +++ b/src/typeref.rs @@ -42,6 +42,9 @@ pub(crate) static mut IDENT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_ pub(crate) static mut UNQUOTE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); pub(crate) static mut ASTERISK_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +// Value type +pub(crate) static mut VALUE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); + // Python standard-library types (serializers) pub(crate) static mut STD_DECIMAL_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); pub(crate) static mut STD_UUID_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); @@ -131,6 +134,8 @@ fn _initialize_typeref(py: pyo3::Python) { crate::pybindings::PyIdent => IDENT_TYPE, crate::pybindings::PyUnquote => UNQUOTE_TYPE, crate::pybindings::PyAsterisk => ASTERISK_TYPE, + // value + crate::pybindings::PyValue => VALUE_TYPE, ); STD_DECIMAL_TYPE = look_up_type_object(c"decimal", c"Decimal"); diff --git a/tests/test_data_type.py b/tests/test_data_type.py index e2af0fd..86e0114 100644 --- a/tests/test_data_type.py +++ b/tests/test_data_type.py @@ -205,3 +205,19 @@ def test_serialize_propagates_type_errors_from_serializer(self): def test_serialize_accepts_dialect_class_or_instance(self): dt = DataType("integer") assert dt.serialize(PostgreSQL, 1) == dt.serialize(PostgreSQL(), 1) + + +class TestDataTypeEquality: + def test_interned_spellings_of_the_same_name_are_equal(self): + assert DataType("int") == DataType("INT") + assert hash(DataType("int")) == hash(DataType("INT")) + + def test_distinct_interned_names_are_not_equal(self): + assert DataType("int") != DataType("integer") + + def test_explicit_serializer_participates_in_equality(self): + assert DataType("int", StringSerializer()) != DataType("int") + + def test_not_equal_to_unrelated_types(self): + assert DataType("int") != "INT" + assert DataType("int") != IntegerSerializer() diff --git a/tests/test_value.py b/tests/test_value.py new file mode 100644 index 0000000..531def9 --- /dev/null +++ b/tests/test_value.py @@ -0,0 +1,107 @@ +import pytest + +from rapidquery import Value +from rapidquery.data_type import ( + DataType, + IntegerSerializer, + JSONSerializer, + StringSerializer, +) +from rapidquery.dialect import PostgreSQL + + +class TestValueConstruction: + def test_infers_integer_serializer(self): + value = Value(42) + assert value.object == 42 + assert isinstance(value.serializer, IntegerSerializer) + + def test_infers_string_serializer(self): + value = Value("hi") + assert value.object == "hi" + assert isinstance(value.serializer, StringSerializer) + + def test_infers_boolean_for_bool_and_none(self): + assert type(Value(True).serializer).__name__ == "BooleanSerializer" + assert type(Value(None).serializer).__name__ == "BooleanSerializer" + + def test_infers_json_for_list_and_dict(self): + assert isinstance(Value([1, 2]).serializer, JSONSerializer) + assert isinstance(Value({"a": 1}).serializer, JSONSerializer) + + def test_infers_bytes_serializer(self): + value = Value(b"abc") + assert value.object == b"abc" + assert type(value.serializer).__name__ == "BytesSerializer" + + def test_explicit_serializer_overrides_inference(self): + value = Value(1, StringSerializer()) + assert isinstance(value.serializer, StringSerializer) + + def test_wraps_existing_value_and_keeps_serializer(self): + inner = Value(1, StringSerializer()) + outer = Value(inner) + assert outer.object == 1 + assert isinstance(outer.serializer, StringSerializer) + + def test_wraps_existing_value_with_new_serializer(self): + inner = Value(1) + outer = Value(inner, StringSerializer()) + assert outer.object == 1 + assert isinstance(outer.serializer, StringSerializer) + + def test_data_type_supplies_serializer_only(self): + value = Value(1, DataType("int")) + assert isinstance(value.serializer, IntegerSerializer) + assert not hasattr(value, "data_type") + + def test_data_type_serializer_override(self): + value = Value("x", DataType("integer", StringSerializer())) + assert isinstance(value.serializer, StringSerializer) + + def test_rejects_non_serializer_second_arg(self): + with pytest.raises(TypeError, match="Serializer or DataType"): + _ = Value(1, "integer") + + +class TestValueToSql: + def test_integer_literal(self): + assert Value(42).to_sql(PostgreSQL) == "42" + + def test_string_literal_matches_string_serializer(self): + assert Value("hi").to_sql(PostgreSQL) == StringSerializer().serialize( + PostgreSQL, "hi" + ) + + def test_none_is_null(self): + assert Value(None).to_sql(PostgreSQL) == "NULL" + + def test_accepts_dialect_class_or_instance(self): + value = Value(1) + assert value.to_sql(PostgreSQL) == value.to_sql(PostgreSQL()) + + def test_propagates_serializer_type_error(self): + with pytest.raises(TypeError): + Value("not an int", IntegerSerializer()).to_sql(PostgreSQL) + + +class TestValueEquality: + def test_equal_when_object_and_serializer_match(self): + assert Value(1) == Value(1) + assert hash(Value(1)) == hash(Value(1)) + + def test_unequal_when_serializer_differs(self): + assert Value(1) != Value(1, StringSerializer()) + + def test_unequal_when_object_differs(self): + assert Value(1) != Value(2) + + def test_not_equal_to_bare_object(self): + assert Value(1) != 1 + + def test_unhashable_object_raises(self): + with pytest.raises(TypeError): + hash(Value({"a": 1})) + + def test_repr_includes_object_and_serializer(self): + assert repr(Value(42)) == "Value(42, IntegerSerializer())" From 0e6c461b5f37abe4c2200d9396b0c035ec1c4ffb Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Thu, 10 Sep 2026 20:38:13 +0330 Subject: [PATCH 23/87] Update ident/value file names --- .gitignore | 1 + rapidquery/__init__.py | 8 ++++---- rapidquery/{ident.py => _ident.py} | 0 rapidquery/{ident.pyi => _ident.pyi} | 0 rapidquery/{value.py => _value.py} | 0 rapidquery/{value.pyi => _value.pyi} | 0 6 files changed, 5 insertions(+), 4 deletions(-) rename rapidquery/{ident.py => _ident.py} (100%) rename rapidquery/{ident.pyi => _ident.pyi} (100%) rename rapidquery/{value.py => _value.py} (100%) rename rapidquery/{value.pyi => _value.pyi} (100%) diff --git a/.gitignore b/.gitignore index 965592f..d669600 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ backup/ backup-py/ /.vscode /*_cache +/a.py diff --git a/rapidquery/__init__.py b/rapidquery/__init__.py index 7251d75..69246f7 100644 --- a/rapidquery/__init__.py +++ b/rapidquery/__init__.py @@ -2,10 +2,10 @@ from . import data_type as data_type from . import dialect as dialect -from .ident import Asterisk as Asterisk -from .ident import Ident as Ident -from .ident import Unquote as Unquote -from .value import Value as Value +from ._ident import Asterisk as Asterisk +from ._ident import Ident as Ident +from ._ident import Unquote as Unquote +from ._value import Value as Value __all__ = [ "Asterisk", diff --git a/rapidquery/ident.py b/rapidquery/_ident.py similarity index 100% rename from rapidquery/ident.py rename to rapidquery/_ident.py diff --git a/rapidquery/ident.pyi b/rapidquery/_ident.pyi similarity index 100% rename from rapidquery/ident.pyi rename to rapidquery/_ident.pyi diff --git a/rapidquery/value.py b/rapidquery/_value.py similarity index 100% rename from rapidquery/value.py rename to rapidquery/_value.py diff --git a/rapidquery/value.pyi b/rapidquery/_value.pyi similarity index 100% rename from rapidquery/value.pyi rename to rapidquery/_value.pyi From 78cb3b6968c9663c45ac2effb68f8b0cd1680467 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Thu, 10 Sep 2026 20:53:55 +0330 Subject: [PATCH 24/87] Add new `nightly` feature to use nightly features for optimizing (disabled by default) --- Cargo.toml | 2 +- src/internal/modules/json.rs | 2 ++ src/lib.rs | 1 + src/native/dialect/bigquery.rs | 1 + src/native/dialect/databricks.rs | 1 + src/native/dialect/hive.rs | 1 + src/native/dialect/mysql.rs | 1 + src/native/dialect/postgresql.rs | 1 + src/native/dialect/snowflake.rs | 1 + src/native/dialect/spark.rs | 1 + src/native/ident.rs | 2 ++ src/native/sql.rs | 1 + src/typeref.rs | 4 ++-- 13 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4ed5525..7e75091 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,6 @@ name = "rapidquery" version = "1.0.0-alpha1" edition = "2024" description = "RapidQuery is the fastest, full-feature, and easy-to-use Python SQL query builder written in Rust." -readme = "README.md" license = "MIT" homepage = "https://github.com/awolverp/rapidquery" repository = "https://github.com/awolverp/rapidquery.git" @@ -45,6 +44,7 @@ smallvec = { version = "1.16.0", default-features = false } [features] default = ["extension-module"] extension-module = ["pyo3/extension-module"] +nightly = ["hashbrown/nightly"] # Testing features only-stdlib-json = [] diff --git a/src/internal/modules/json.rs b/src/internal/modules/json.rs index 92bac5b..495548b 100644 --- a/src/internal/modules/json.rs +++ b/src/internal/modules/json.rs @@ -75,6 +75,7 @@ fn init_import(py: pyo3::Python<'_>) -> pyo3::PyResult { /// `result` is a new reference still owned by the caller. The GIL is held. /// The `&str` is only valid until `result` is `DECREF`'d. #[inline] +#[cfg_attr(feature = "nightly", optimize(speed))] unsafe fn visit_json_text( py: pyo3::Python<'_>, result: *mut pyo3::ffi::PyObject, @@ -106,6 +107,7 @@ unsafe fn visit_json_text( /// # Safety /// `obj` must be a valid borrowed Python object. The GIL must be held. #[inline] +#[cfg_attr(feature = "nightly", optimize(speed))] pub unsafe fn dumps( obj: pyo3::Bound<'_, pyo3::PyAny>, f: impl FnOnce(&str) -> pyo3::PyResult<()>, diff --git a/src/lib.rs b/src/lib.rs index 7612272..23ecab2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ #![allow(unsafe_op_in_unsafe_fn)] +#![cfg_attr(feature = "nightly", feature(optimize_attribute))] pub mod internal; pub mod native; diff --git a/src/native/dialect/bigquery.rs b/src/native/dialect/bigquery.rs index 0795fba..605d65a 100644 --- a/src/native/dialect/bigquery.rs +++ b/src/native/dialect/bigquery.rs @@ -20,6 +20,7 @@ pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::Backtick; pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::AtSign; +#[cfg_attr(feature = "nightly", optimize(speed))] pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { for ch in string.chars() { match ch { diff --git a/src/native/dialect/databricks.rs b/src/native/dialect/databricks.rs index 95fb4f2..d6a9021 100644 --- a/src/native/dialect/databricks.rs +++ b/src/native/dialect/databricks.rs @@ -22,6 +22,7 @@ pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::Backtick; pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; +#[cfg_attr(feature = "nightly", optimize(speed))] pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { for ch in string.chars() { match ch { diff --git a/src/native/dialect/hive.rs b/src/native/dialect/hive.rs index a50f761..13f8f49 100644 --- a/src/native/dialect/hive.rs +++ b/src/native/dialect/hive.rs @@ -22,6 +22,7 @@ pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::Backtick; pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; +#[cfg_attr(feature = "nightly", optimize(speed))] pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { for ch in string.chars() { match ch { diff --git a/src/native/dialect/mysql.rs b/src/native/dialect/mysql.rs index c87d767..f9a64ef 100644 --- a/src/native/dialect/mysql.rs +++ b/src/native/dialect/mysql.rs @@ -23,6 +23,7 @@ pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::Backtick; pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; +#[cfg_attr(feature = "nightly", optimize(speed))] pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { for ch in string.chars() { match ch { diff --git a/src/native/dialect/postgresql.rs b/src/native/dialect/postgresql.rs index 9ea1ab3..988c078 100644 --- a/src/native/dialect/postgresql.rs +++ b/src/native/dialect/postgresql.rs @@ -26,6 +26,7 @@ pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::DoubleQuot pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; +#[cfg_attr(feature = "nightly", optimize(speed))] pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { for ch in string.chars() { match ch { diff --git a/src/native/dialect/snowflake.rs b/src/native/dialect/snowflake.rs index 2aefb56..311e875 100644 --- a/src/native/dialect/snowflake.rs +++ b/src/native/dialect/snowflake.rs @@ -23,6 +23,7 @@ pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::DoubleQuot pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; +#[cfg_attr(feature = "nightly", optimize(speed))] pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { for ch in string.chars() { match ch { diff --git a/src/native/dialect/spark.rs b/src/native/dialect/spark.rs index 9925f4d..d34edcb 100644 --- a/src/native/dialect/spark.rs +++ b/src/native/dialect/spark.rs @@ -22,6 +22,7 @@ pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::Backtick; pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; +#[cfg_attr(feature = "nightly", optimize(speed))] pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { for ch in string.chars() { match ch { diff --git a/src/native/ident.rs b/src/native/ident.rs index 9802a96..1b85c83 100644 --- a/src/native/ident.rs +++ b/src/native/ident.rs @@ -170,6 +170,7 @@ impl Ident { /// [`MAX_PARTS`](Self::MAX_PARTS), asterisk-not-last. /// /// Unquoted charset is enforced by [`IdentPart::unquoted`], not here. + #[cfg_attr(feature = "nightly", optimize(speed))] pub fn from_parts(iter: impl IntoIterator) -> Result { let mut parts = SmallVec::<[IdentPart; 4]>::new(); @@ -200,6 +201,7 @@ impl Ident { impl std::str::FromStr for Ident { type Err = ParseIdentError; + #[cfg_attr(feature = "nightly", optimize(speed))] fn from_str(s: &str) -> Result { if s.is_empty() { return Err(ParseIdentError::Empty); diff --git a/src/native/sql.rs b/src/native/sql.rs index ab1d4c4..aeda6bb 100644 --- a/src/native/sql.rs +++ b/src/native/sql.rs @@ -304,6 +304,7 @@ pub fn prepare<'py, T: WriteSql>( /// (not before the first or after the last). /// /// Use `","` / `", "` for lists and `" AND "` / `" OR "` for predicates. +#[cfg_attr(feature = "nightly", optimize(speed))] pub fn write_separated<'a, T, W>( dialect: &SqlDialect, out: &mut W, diff --git a/src/typeref.rs b/src/typeref.rs index 931bee5..e4e7377 100644 --- a/src/typeref.rs +++ b/src/typeref.rs @@ -168,7 +168,7 @@ pub fn initialize_typeref(py: pyo3::Python) { /// `ptr` must be a valid, non-null `PyObject*`. `r#type` must be a valid /// `PyTypeObject*` (interned pointers from [`initialize_typeref`] are fine). /// The GIL must be held. -#[inline] +#[inline(always)] pub unsafe fn check_exact( ptr: *mut pyo3::ffi::PyObject, r#type: *mut pyo3::ffi::PyTypeObject, @@ -183,7 +183,7 @@ pub unsafe fn check_exact( /// `ptr` must be a valid, non-null `PyObject*`. `r#type` must be a valid /// `PyTypeObject*` (interned pointers from [`initialize_typeref`] are fine). /// The GIL must be held. -#[inline] +#[inline(always)] pub unsafe fn check(ptr: *mut pyo3::ffi::PyObject, r#type: *mut pyo3::ffi::PyTypeObject) -> bool { pyo3::ffi::PyObject_TypeCheck(ptr, r#type) == 1 } From 88a2f2d90246fbba1c54a48d8b7f5b7f8d24a1df Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Thu, 10 Sep 2026 21:33:24 +0330 Subject: [PATCH 25/87] Colocate engine and pyclasses; drop native/pybindings split Keep enum-vs-class shims only for dialects and serializers (src/dialect/py.rs, src/data_type/py.rs). Ident, Value, DataType, and SQL writers live as src modules. Python API is unchanged. --- AGENTS.md | 44 ++- src/{pybindings => }/data_type/mod.rs | 93 +++-- src/{native => }/data_type/name.rs | 8 +- .../serializer.rs => data_type/py.rs} | 8 +- src/{native => }/data_type/serialize.rs | 2 +- src/{native => }/data_type/serializer.rs | 4 +- src/{native => }/data_type/tests.rs | 0 src/{native => }/dialect/bigquery.rs | 0 src/{native => }/dialect/databricks.rs | 0 src/{native => }/dialect/duckdb.rs | 0 src/{native => }/dialect/hive.rs | 0 src/{native => }/dialect/mod.rs | 3 + src/{native => }/dialect/mssql.rs | 0 src/{native => }/dialect/mysql.rs | 0 src/{native => }/dialect/oracle.rs | 0 src/{native => }/dialect/postgresql.rs | 0 src/{pybindings/dialect.rs => dialect/py.rs} | 14 +- src/{native => }/dialect/redshift.rs | 0 src/{native => }/dialect/snowflake.rs | 0 src/{native => }/dialect/spark.rs | 0 src/{native => }/dialect/sqlite.rs | 0 src/{native => }/dialect/teradata.rs | 0 src/{native => }/dialect/tests.rs | 0 src/{native => }/ident.rs | 359 ++++++++++++++++- src/lib.rs | 16 +- src/native/data_type/mod.rs | 60 --- src/native/mod.rs | 10 - src/native/value.rs | 39 -- src/pybindings/ident.rs | 360 ------------------ src/pybindings/mod.rs | 17 - src/{native => }/sql.rs | 4 +- src/typeref.rs | 70 ++-- src/{pybindings => }/value.rs | 76 ++-- 33 files changed, 570 insertions(+), 617 deletions(-) rename src/{pybindings => }/data_type/mod.rs (72%) rename src/{native => }/data_type/name.rs (97%) rename src/{pybindings/data_type/serializer.rs => data_type/py.rs} (98%) rename src/{native => }/data_type/serialize.rs (99%) rename src/{native => }/data_type/serializer.rs (97%) rename src/{native => }/data_type/tests.rs (100%) rename src/{native => }/dialect/bigquery.rs (100%) rename src/{native => }/dialect/databricks.rs (100%) rename src/{native => }/dialect/duckdb.rs (100%) rename src/{native => }/dialect/hive.rs (100%) rename src/{native => }/dialect/mod.rs (99%) rename src/{native => }/dialect/mssql.rs (100%) rename src/{native => }/dialect/mysql.rs (100%) rename src/{native => }/dialect/oracle.rs (100%) rename src/{native => }/dialect/postgresql.rs (100%) rename src/{pybindings/dialect.rs => dialect/py.rs} (94%) rename src/{native => }/dialect/redshift.rs (100%) rename src/{native => }/dialect/snowflake.rs (100%) rename src/{native => }/dialect/spark.rs (100%) rename src/{native => }/dialect/sqlite.rs (100%) rename src/{native => }/dialect/teradata.rs (100%) rename src/{native => }/dialect/tests.rs (100%) rename src/{native => }/ident.rs (53%) delete mode 100644 src/native/data_type/mod.rs delete mode 100644 src/native/mod.rs delete mode 100644 src/native/value.rs delete mode 100644 src/pybindings/ident.rs delete mode 100644 src/pybindings/mod.rs rename src/{native => }/sql.rs (98%) rename src/{pybindings => }/value.rs (71%) diff --git a/AGENTS.md b/AGENTS.md index 1723d73..4bb470d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,8 +8,8 @@ Goals from the PR (keep these): - No SeaQuery panics; production-safe SQL building. - 13 dialects (BigQuery, Databricks, DuckDB, Hive, MSSQL, MySQL, Oracle, PostgreSQL, Redshift, Snowflake, Spark, SQLite, Teradata). - Full SQL data-type coverage via interned `DataTypeName` + `DataTypeSerializer`. -- Native Rust vs Python bindings split (`src/native/` vs `src/pybindings/`). Native may use PyO3 where it holds or inspects Python objects (`Serializer`, `PrepareContext`). -- Dual SQL rendering via `WriteSql` + `SqlWriter`: raw (`SqlBuf`, literals) and parameterized (`PrepareContext`, placeholders). +- One module per concept under `src/`. Representation shims (`dialect/py.rs`, `data_type/py.rs`) only where the engine is a `Copy`/unit enum and Python is a frozen class tree. Do not resurrect a crate-wide `native/` vs `pybindings/` split. +- Dual SQL rendering via `WriteSql` + `SqlWriter`: raw (`BufferedSql`, literals) and parameterized (`ParameterizedSql`, placeholders). - Ident / Asterisk with dialect quoting. - Leave the door open for a SQL parser and an ORM later. - Prefer backward compatibility with v0, but it is **not** guaranteed. @@ -20,31 +20,37 @@ Keep these `refactor/v1` divergences from the PR (do not revert them): - Module name `rapidquery._core` (PR used `_native`). - Proc-macros `pyclass` / `pyerr` (PR used `pyclass_shortcut` + `new_pyerr!`). - MIT license, Python `>=3.10`, crate version `1.0.0-alpha1`. -- `PrepareContext` / `PrepareError` naming in `sql.rs` (PR used `PrepareStatementContext` / `PrepareStatementError`). +- Writer types `BufferedSql` / `ParameterizedSql` and error `PrepareError` (PR used `PrepareStatementContext` / `PrepareStatementError`). Module comments in `sql.rs` still say `SqlBuf` / `PrepareContext`; those names are not types. ## Stack -- Python `>=3.10` (local: 3.13). Rust edition **2024**. PyO3 **0.29**. Maturin **1.15.0**. uv for the venv. +- Python `>=3.10` (local: 3.13). Rust edition **2024**. PyO3 **0.29**. Maturin **1.15.0**. uv for the venv. Local and CI rustc is **nightly**. - Workspace crate `rapidquery-macros` (proc-macros: `pyclass`, `pyerr`). -- Target stack from PR #2 also used: `arcstr`, `once_cell`, `chrono`, `ipnetwork`, `mac_address`. (`derive_more` is not on this branch.) +- Current deps: `arcstr`, `once_cell`, `chrono`, `ipnetwork`, `mac_address`, `parking_lot`, `hashbrown`, `smallvec`. (`derive_more` is not on this branch.) - Dialects (13), `DataType` + `Serializer`, `Ident`/`Unquote`/`Asterisk`, `Value`, `SqlWriter`/`WriteSql` exist on this branch. Still missing vs the PR’s next layers: query/schema AST (never in the PR either). - `#[pyo3::pymodule(gil_used = false)]` — keep free-threaded Python in mind. - Frozen/immutable PyO3 types: `rapidquery_macros::pyclass` already sets `module="rapidquery._core"`, `immutable_type`, `frozen`. +- Optional extra: `orjson`. Test-only crate features: `only-stdlib-json`, `only-orjson` (mutually exclusive). `nightly` enables hashbrown nightly + `optimize` attrs. ## Layout ``` -src/native/ Rust engine (dialects, DataType, Ident, Serializer, Value, SqlWriter/WriteSql) -src/pybindings/ Python wrappers (pyclasses only) -src/internal/ aliases, string utils, submodule helper +src/dialect/ SqlDialect enum + engines + tests; py.rs class tree +src/data_type/ DataType, interned names, Serializer enum; py.rs class tree +src/ident.rs Ident, IdentPart, tests + PyIdent/Unquote/Asterisk +src/value.rs Value + PyValue +src/sql.rs BufferedSql, ParameterizedSql, WriteSql +src/internal/ aliases, JSON dumps helper, string utils, submodule helper src/typeref.rs cached PyTypeObject pointers -rapidquery/ Python package (re-exports Ident, Unquote, Asterisk, Value, data_type, dialect) +rapidquery/ Python package: _ident.py, _value.py, data_type.py, dialect.py rapidquery-macros/ Proc-macros -tests/ Python tests +tests/ Python tests (ident, value, dialect, data_type, serializers) backup/, backup-py/ gitignored v0 snapshots — read-only target/, .venv/ Generated — do not commit or edit ``` +Rust unit tests live next to the types (`data_type/tests.rs`, `dialect/tests.rs`, `ident.rs`, `internal/utils/strings.rs`). + Reference the PR without checking it out as the working tree: `git show origin/migrate:`. Do not copy files wholesale if this branch already renamed or cleaned them. ## Commands @@ -53,24 +59,26 @@ Reference the PR without checking it out as the working tree: `git show origin/m uv sync --dev maturin develop # or: uv run maturin develop cargo test --workspace --lib --bins --tests -cargo clippy --workspace --all-targets -- -D warnings # project lints are warn-level -cargo fmt # rustfmt.toml uses nightly-only options; CI uses nightly -uv run pytest -vv # CI command; pytest is not in pyproject yet +cargo clippy --workspace --all-targets -- -D warnings # project lints are warn-level; CI does not run clippy +cargo fmt # no rustfmt.toml on this branch; CI does not run rustfmt +uv run pytest -vv # CI command; pytest is in [dependency-groups] dev ``` There is no Python linter/typechecker configured. Do not add ruff/mypy/a new test runner unless asked. -After meaningful Rust or Python changes: run `cargo test --workspace --lib --bins --tests`. Run pytest only after Python tests exist. +After meaningful Rust changes: run `cargo test --workspace --lib --bins --tests`. After Python-facing changes: also `uv run pytest -vv`. + +`cargo test --workspace` (including doctests) currently fails: rustdoc builds `rapidquery-macros` without syn’s `full` feature. `pyerr` examples are marked `rust,no-test`. -`cargo test --workspace` (including doctests) currently fails: `pyerr` docs in `rapidquery-macros/lib.rs` are not valid rustdoc tests. +CI (`.github/workflows/test.yml`) runs `uv sync --all-extras --dev` then `uv run pytest -vv` on Python 3.10 / 3.14 / 3.14t / pypy3.11 and ubuntu/macos/windows. It still sets `HYPOTHESIS_PROFILE` though hypothesis is not a dependency. ## How to edit -- Small, reviewable diffs. Match existing patterns on **this** branch first; for missing layers, match `origin/migrate` (native owns dialects, `DataTypeName`, `Ident`, `Serializer`+`write_sql`, `SqlWriter`/`WriteSql`; pybindings are pyclasses wrapping those; serializers subclass a base `Serializer`; dialects subclass `SqlDialect` and are `@final`). Native may use PyO3 where it holds or inspects Python objects. +- Small, reviewable diffs. Match existing patterns on **this** branch first; for missing layers, match `origin/migrate` conceptually (not its `native/` / `_native` folder names). One module per concept. `dialect/py.rs` and `data_type/py.rs` are the only enum-vs-class shims; serializers subclass a base `Serializer`; dialects subclass `SqlDialect` and are `@final`. Do not add `PyFoo(Foo)` for future AST nodes — put `#[pyclass]` on the node or keep a private rust type in the same file. Parser/ORM later: keep leaves cheap (`Ident`, `DataTypeName`, `SqlDialect`). - Do not introduce SeaQuery, a sqlparser-rs crate dependency, SQLAlchemy, another Python package layout, or a new build backend. - Do not touch `Cargo.lock` / `uv.lock` unless the change requires a dependency update. Do not touch generated artifacts (`target/`, `*.so`, wheels), secrets, or `.env`. -- `backup/` and `backup-py/` are gitignored snapshots of v0. Do not edit them; do not copy them wholesale into `src/`. -- SQL injection safety is a product requirement. Prefer parameterized `PrepareContext::push_param` over interpolating values. Dialect `escape_*` / `quote_*` must not let payloads close literals. +- `backup/` and `backup-py/` are gitignored snapshots of v0. Do not edit them; do not copy them wholesale into `src/`. `a.py` is local scratch (gitignored). +- SQL injection safety is a product requirement. Prefer parameterized `ParameterizedSql::push_param` over interpolating values. Dialect `escape_*` / `quote_*` must not let payloads close literals. - Unsafe is allowed where the crate already uses it (`from_utf8_unchecked` after ASCII-boundary splits, PyO3 FFI). Keep the safety invariant next to the block. - Clippy: no `dbg!`, `println!`, or `eprintln!` in library code (warn). Release profile uses `panic = "abort"`. diff --git a/src/pybindings/data_type/mod.rs b/src/data_type/mod.rs similarity index 72% rename from src/pybindings/data_type/mod.rs rename to src/data_type/mod.rs index ddc6e4d..685c215 100644 --- a/src/pybindings/data_type/mod.rs +++ b/src/data_type/mod.rs @@ -1,35 +1,76 @@ -//! Python wrappers for SQL data types and value serializers. +//! SQL data types: interned names, serializers, and the composed [`DataType`]. //! -//! Native [`DataTypeName`](crate::native::data_type::DataTypeName) is a -//! spelling plus group. Native [`Serializer`](crate::native::data_type::Serializer) -//! writes Python objects as SQL literals. Native [`DataType`](crate::native::DataType) -//! is the name plus serializer; this module owns the pyclasses. -//! -//! Python sees: -//! -//! * [`PySerializer`] — `rapidquery._core.data_type.Serializer`, the ABC -//! * frozen subclasses per behaviour (`IntegerSerializer`, `JSONSerializer`, …) -//! * [`PyArraySerializer`] / [`PyCustomSerializer`] -//! * [`PyDataType`] — `rapidquery._core.data_type.DataType` -//! -//! `serialize(dialect, obj)` accepts a dialect **class or instance**. Serializer -//! extract is instance-only (Array/Custom carry state). - +//! A [`DataTypeName`] is a canonical spelling plus a [`DataTypeGroup`]. The +//! group is the inference key for which [`Serializer`] [`DataType::from_name`] +//! constructs when none is passed. Groups are serializer-oriented: `NUMERIC` +//! and `DECIMAL` sit in [`DataTypeGroup::Float`] because they share a +//! serializer with IEEE floats. + +mod name; +mod py; +mod serialize; mod serializer; -use crate::internal::alias; -use crate::native; +#[cfg(test)] +mod tests; -pub(crate) use serializer::serializer_repr; -pub use serializer::{ +pub use name::{DataTypeGroup, DataTypeName}; +pub use py::{ PyArraySerializer, PyBitSerializer, PyBooleanSerializer, PyBytesSerializer, PyCustomSerializer, PyDateSerializer, PyDatetimeSerializer, PyFloatSerializer, PyInetSerializer, PyIntegerSerializer, PyJSONSerializer, PyMacAddressSerializer, PySerializer, PyStringSerializer, PyTimeSerializer, PyUnspecifiedSerializer, PyUuidSerializer, }; +pub use serializer::*; + +pub(crate) use py::serializer_repr; + +use crate::internal::alias; +use crate::sql::{PrepareResult, SqlWriter, WriteSql}; + +/// SQL type spelling plus the serializer used for Python values. +/// +/// [`WriteSql`] emits the type name (CAST, column definitions). Literal +/// serialization of a Python object is [`Serializer::write_sql`], not this +/// type. +/// +/// Clone of a [`Serializer::Custom`] value requires the GIL. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct DataType { + pub name: DataTypeName, + pub serializer: Serializer, +} + +impl DataType { + #[inline] + pub fn new(name: DataTypeName, serializer: Serializer) -> Self { + Self { name, serializer } + } + + /// Infer the serializer from [`DataTypeName::group`]. + /// + /// Unrecognized and parameterized names use [`Serializer::Unspecified`]. + #[inline] + pub fn from_name(name: impl Into) -> Self { + let name = name.into(); + let serializer = Serializer::from_group(name.group()); + Self { name, serializer } + } +} + +impl WriteSql for DataType { + #[inline] + fn write_sql( + &self, + dialect: &crate::dialect::SqlDialect, + out: &mut W, + ) -> PrepareResult<()> { + self.name.write_sql(dialect, out) + } +} /// `Serializer` instance, or a `DataType` (its serializer only). -pub struct SerializerExtractor(pub crate::native::data_type::Serializer); +pub struct SerializerExtractor(pub Serializer); impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for SerializerExtractor { type Error = pyo3::PyErr; @@ -82,7 +123,7 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for SerializerExtractor { module = "rapidquery._core.data_type" )] #[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct PyDataType(pub native::DataType); +pub struct PyDataType(pub DataType); #[pyo3::pymethods] impl PyDataType { @@ -103,10 +144,8 @@ impl PyDataType { #[pyo3(signature = (name, serializer = None, /))] fn __new__(name: String, serializer: Option) -> Self { let inner = match serializer { - Some(PySerializer(s)) => { - native::DataType::new(native::data_type::DataTypeName::from(name), s) - } - None => native::DataType::from_name(name), + Some(PySerializer(s)) => DataType::new(DataTypeName::from(name), s), + None => DataType::from_name(name), }; Self(inner) } @@ -142,7 +181,7 @@ impl PyDataType { /// A SQL fragment produced by this type's serializer. fn serialize( &self, - dialect: super::dialect::PySqlDialect, + dialect: crate::dialect::py::PySqlDialect, object: alias::BoundObject<'_>, ) -> pyo3::PyResult { let mut buf = String::new(); diff --git a/src/native/data_type/name.rs b/src/data_type/name.rs similarity index 97% rename from src/native/data_type/name.rs rename to src/data_type/name.rs index 926ffa8..710b3e9 100644 --- a/src/native/data_type/name.rs +++ b/src/data_type/name.rs @@ -1,11 +1,11 @@ -use crate::native::sql::{PrepareResult, SqlWriter, WriteSql}; +use crate::sql::{PrepareResult, SqlWriter, WriteSql}; /// Category used to infer a built-in serializer from a type name. /// /// Distinct SQL spellings collapse onto one serializer (every integer width -/// is [`crate::native::Serializer::Integer`]). [`Json`] and [`Jsonb`] stay +/// is [`crate::data_type::Serializer::Integer`]). [`Json`] and [`Jsonb`] stay /// distinct as groups so a later layer can treat storage differently; both -/// currently infer [`crate::native::Serializer::Json`]. +/// currently infer [`crate::data_type::Serializer::Json`]. #[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord, Hash)] pub enum DataTypeGroup { String, @@ -263,7 +263,7 @@ impl std::fmt::Display for DataTypeName { impl WriteSql for DataTypeName { fn write_sql( &self, - _dialect: &crate::native::dialect::SqlDialect, + _dialect: &crate::dialect::SqlDialect, out: &mut W, ) -> PrepareResult<()> { out.push_str(self.as_str()); diff --git a/src/pybindings/data_type/serializer.rs b/src/data_type/py.rs similarity index 98% rename from src/pybindings/data_type/serializer.rs rename to src/data_type/py.rs index 49af675..a36d0ac 100644 --- a/src/pybindings/data_type/serializer.rs +++ b/src/data_type/py.rs @@ -1,6 +1,6 @@ //! Python `Serializer` class hierarchy. //! -//! Wraps native [`Serializer`](crate::native::data_type::Serializer). Python sees frozen +//! Wraps [`Serializer`](super::Serializer). Python sees frozen //! subclasses (`IntegerSerializer`, …) plus `ArraySerializer` / //! `CustomSerializer`. //! @@ -11,7 +11,7 @@ use std::hash::{Hash, Hasher}; use crate::internal::alias; -use crate::native::data_type::{DataTypeGroup, Serializer}; +use super::{DataTypeGroup, Serializer}; fn class_name(serializer: &Serializer) -> &'static str { match serializer { @@ -68,7 +68,7 @@ impl PySerializer { pub fn serialize_value( &self, - dialect: crate::pybindings::dialect::PySqlDialect, + dialect: crate::dialect::py::PySqlDialect, object: alias::BoundObject<'_>, ) -> pyo3::PyResult { let mut buf = String::new(); @@ -148,7 +148,7 @@ impl PySerializer { /// (for example a non-finite float). fn serialize( &self, - dialect: crate::pybindings::dialect::PySqlDialect, + dialect: crate::dialect::py::PySqlDialect, object: alias::BoundObject<'_>, ) -> pyo3::PyResult { self.serialize_value(dialect, object) diff --git a/src/native/data_type/serialize.rs b/src/data_type/serialize.rs similarity index 99% rename from src/native/data_type/serialize.rs rename to src/data_type/serialize.rs index 9f68581..71c5fe3 100644 --- a/src/native/data_type/serialize.rs +++ b/src/data_type/serialize.rs @@ -11,7 +11,7 @@ use pyo3::types::{PyAnyMethods, PyTzInfoAccess}; use crate::internal::alias; use crate::internal::utils::{get_type_name, py_str}; -use crate::native::dialect::SqlDialect; +use crate::dialect::SqlDialect; use super::serializer::Serializer; diff --git a/src/native/data_type/serializer.rs b/src/data_type/serializer.rs similarity index 97% rename from src/native/data_type/serializer.rs rename to src/data_type/serializer.rs index e2afb01..110140c 100644 --- a/src/native/data_type/serializer.rs +++ b/src/data_type/serializer.rs @@ -11,7 +11,7 @@ use std::hash::{Hash, Hasher}; use crate::internal::alias; use crate::internal::modules::json_dumps; -use crate::native::data_type::DataTypeGroup; +use super::name::DataTypeGroup; /// How a Python object is written as a SQL literal. #[derive(Debug, Clone)] @@ -106,7 +106,7 @@ impl Serializer { // Why `None`? // Actually we don't use [`Serializer`] on `None` values. // So it's not important what [`Serializer`] is. - // `None` values are handled by [`native::sql::SqlWriter`] + // `None` values are handled by [`crate::sql::SqlWriter`] if pyo3::ffi::Py_IsNone(object_ptr) == 1 || pyo3::ffi::PyBool_Check(object_ptr) == 1 { return Self::Boolean; } diff --git a/src/native/data_type/tests.rs b/src/data_type/tests.rs similarity index 100% rename from src/native/data_type/tests.rs rename to src/data_type/tests.rs diff --git a/src/native/dialect/bigquery.rs b/src/dialect/bigquery.rs similarity index 100% rename from src/native/dialect/bigquery.rs rename to src/dialect/bigquery.rs diff --git a/src/native/dialect/databricks.rs b/src/dialect/databricks.rs similarity index 100% rename from src/native/dialect/databricks.rs rename to src/dialect/databricks.rs diff --git a/src/native/dialect/duckdb.rs b/src/dialect/duckdb.rs similarity index 100% rename from src/native/dialect/duckdb.rs rename to src/dialect/duckdb.rs diff --git a/src/native/dialect/hive.rs b/src/dialect/hive.rs similarity index 100% rename from src/native/dialect/hive.rs rename to src/dialect/hive.rs diff --git a/src/native/dialect/mod.rs b/src/dialect/mod.rs similarity index 99% rename from src/native/dialect/mod.rs rename to src/dialect/mod.rs index 817f392..a29f088 100644 --- a/src/native/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -37,10 +37,13 @@ mod snowflake; mod spark; mod sqlite; mod teradata; +pub mod py; #[cfg(test)] mod tests; +pub use py::dialect_mod; + /// How a boolean literal is spelled. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum BoolFormat { diff --git a/src/native/dialect/mssql.rs b/src/dialect/mssql.rs similarity index 100% rename from src/native/dialect/mssql.rs rename to src/dialect/mssql.rs diff --git a/src/native/dialect/mysql.rs b/src/dialect/mysql.rs similarity index 100% rename from src/native/dialect/mysql.rs rename to src/dialect/mysql.rs diff --git a/src/native/dialect/oracle.rs b/src/dialect/oracle.rs similarity index 100% rename from src/native/dialect/oracle.rs rename to src/dialect/oracle.rs diff --git a/src/native/dialect/postgresql.rs b/src/dialect/postgresql.rs similarity index 100% rename from src/native/dialect/postgresql.rs rename to src/dialect/postgresql.rs diff --git a/src/pybindings/dialect.rs b/src/dialect/py.rs similarity index 94% rename from src/pybindings/dialect.rs rename to src/dialect/py.rs index 72398a8..d1fb945 100644 --- a/src/pybindings/dialect.rs +++ b/src/dialect/py.rs @@ -1,6 +1,6 @@ -//! Python wrappers around [`crate::native::dialect`]. +//! Python class hierarchy around [`super::SqlDialect`]. //! -//! The native engine selects a dialect with a `Copy` enum. Python sees a +//! The engine selects a dialect with a `Copy` enum. Python sees a //! class hierarchy instead: //! //! * [`PySqlDialect`] — `rapidquery._core.dialect.SqlDialect`, the ABC @@ -16,7 +16,9 @@ use std::hash::{Hash, Hasher}; -use crate::{internal::utils::get_type_name, native}; +use crate::internal::utils::get_type_name; + +use super::SqlDialect; // `subclass` is so the 13 built-in dialects can `extends` this type from // Rust. Python subclasses are not accepted by the extractor below. @@ -34,7 +36,7 @@ use crate::{internal::utils::get_type_name, native}; module = "rapidquery._core.dialect" )] #[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct PySqlDialect(pub native::dialect::SqlDialect); +pub struct PySqlDialect(pub SqlDialect); impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PySqlDialect { type Error = pyo3::PyErr; @@ -51,7 +53,7 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PySqlDialect { ) => { $( if $ptr == crate::typeref::$name { - return Ok(PySqlDialect(native::dialect::SqlDialect::$native)); + return Ok(PySqlDialect(SqlDialect::$native)); } )+ }; @@ -152,7 +154,7 @@ macro_rules! impl_dialects { #[new] #[inline] fn __new__() -> pyo3::PyClassInitializer { - pyo3::PyClassInitializer::from(PySqlDialect(native::dialect::SqlDialect::$native)) + pyo3::PyClassInitializer::from(PySqlDialect(SqlDialect::$native)) .add_subclass(Self) } } diff --git a/src/native/dialect/redshift.rs b/src/dialect/redshift.rs similarity index 100% rename from src/native/dialect/redshift.rs rename to src/dialect/redshift.rs diff --git a/src/native/dialect/snowflake.rs b/src/dialect/snowflake.rs similarity index 100% rename from src/native/dialect/snowflake.rs rename to src/dialect/snowflake.rs diff --git a/src/native/dialect/spark.rs b/src/dialect/spark.rs similarity index 100% rename from src/native/dialect/spark.rs rename to src/dialect/spark.rs diff --git a/src/native/dialect/sqlite.rs b/src/dialect/sqlite.rs similarity index 100% rename from src/native/dialect/sqlite.rs rename to src/dialect/sqlite.rs diff --git a/src/native/dialect/teradata.rs b/src/dialect/teradata.rs similarity index 100% rename from src/native/dialect/teradata.rs rename to src/dialect/teradata.rs diff --git a/src/native/dialect/tests.rs b/src/dialect/tests.rs similarity index 100% rename from src/native/dialect/tests.rs rename to src/dialect/tests.rs diff --git a/src/native/ident.rs b/src/ident.rs similarity index 53% rename from src/native/ident.rs rename to src/ident.rs index 1b85c83..1709624 100644 --- a/src/native/ident.rs +++ b/src/ident.rs @@ -5,10 +5,8 @@ use std::fmt; use arcstr::ArcStr; use smallvec::{SmallVec, smallvec}; -use crate::native::{ - dialect::SqlDialect, - sql::{PrepareResult, SqlWriter, WriteSql, write_separated}, -}; +use crate::dialect::SqlDialect; +use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; /// One segment of a dotted identifier. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -237,6 +235,359 @@ impl WriteSql for Ident { } } + +// --------------------------------------------------------------------------- +// Python surface (`Ident`, `Unquote`, `Asterisk`) +// --------------------------------------------------------------------------- +// +// A Python `str` is always a quoted part, including `"*"`. The wildcard is +// `Asterisk`. Dotted names are `Ident("schema", "table")`; the native +// [`FromStr`](std::str::FromStr) parser is not used on the Python constructor. + +use pyo3::types::{PyAnyMethods, PySliceMethods, PyTupleMethods}; + +use crate::internal::alias; +use crate::internal::utils; +use crate::sql::write_sql; + +#[inline] +fn parse_err(err: ParseIdentError) -> pyo3::PyErr { + rapidquery_macros::pyerr!(PyValueError, "{}", err) +} + +/// Convert a native part into the matching Python object (`str`, `Unquote`, +/// or `Asterisk`). +#[inline] +fn ident_part_to_pyobject<'py>( + py: pyo3::Python<'py>, + ident_part: &IdentPart, +) -> pyo3::PyResult> { + match ident_part { + IdentPart::Quote(s) => Ok(pyo3::types::PyString::new(py, s.as_str()).into_any()), + IdentPart::Unquote(s) => { + Ok(pyo3::Bound::new(py, PyUnquote(arcstr::ArcStr::from(s.as_str())))?.into_any()) + } + IdentPart::Asterisk => Ok(pyo3::Bound::new(py, PyAsterisk)?.into_any()), + } +} + +/// `true` when `obj` is `Asterisk` or `type[Asterisk]`. +/// +/// # Safety +/// +/// `obj` must be a valid, non-null `PyObject*`. `ASTERISK_TYPE` must have +/// been interned by [`crate::typeref::initialize_typeref`]. The GIL must +/// be held. +#[inline] +unsafe fn is_asterisk(obj: *mut pyo3::ffi::PyObject) -> bool { + let type_ptr = if pyo3::ffi::PyType_CheckExact(obj) == 1 { + obj.cast::() + } else { + pyo3::ffi::Py_TYPE(obj) + }; + type_ptr == crate::typeref::ASTERISK_TYPE +} + +enum Extracted { + Ident(Ident), + Part(IdentPart), +} + +/// Hot-path extract: `str` first, then Ident / Unquote / Asterisk. +#[inline] +fn extract_arg( + obj: pyo3::prelude::Borrowed<'_, '_, pyo3::prelude::PyAny>, +) -> pyo3::PyResult { + let ptr = obj.as_ptr(); + unsafe { + // SAFETY: `ptr` is a live borrowed `PyObject`. Type pointers are + // interned in `initialize_typeref` before any extractor runs. + if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { + let s = utils::py_str(obj.py(), ptr)?; + let part = IdentPart::quoted(s).map_err(parse_err)?; + return Ok(Extracted::Part(part)); + } + + if crate::typeref::check_exact(ptr, crate::typeref::IDENT_TYPE) { + let ident = obj.cast_unchecked::(); + return Ok(Extracted::Ident(ident.get().0.clone())); + } + + if crate::typeref::check_exact(ptr, crate::typeref::UNQUOTE_TYPE) { + let unquote = obj.cast_unchecked::(); + let part = IdentPart::unquoted(unquote.get().0.as_str()).map_err(parse_err)?; + return Ok(Extracted::Part(part)); + } + + if is_asterisk(ptr) { + return Ok(Extracted::Part(IdentPart::Asterisk)); + } + } + + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected str, Ident, Unquote or Asterisk, got {}", + unsafe { crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) } + ) +} + +/// Extracts a native [`Ident`](crate::Ident) from a Python object. +/// +/// Accepts `Ident`, `str` (one quoted part, including `"*"`), `Unquote`, +/// `Asterisk`, and `type[Asterisk]`. Sequences are rejected. +pub struct IdentExtractor(pub Ident); + +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for IdentExtractor { + type Error = pyo3::PyErr; + + #[inline] + fn extract( + obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, + ) -> Result { + match extract_arg(obj)? { + Extracted::Ident(ident) => Ok(Self(ident)), + Extracted::Part(part) => Ok(Self(Ident::from_one(part))), + } + } +} + +/// Represents a SQL identifier (`schema.table.column`). +/// +/// An identifier is a sequence of parts. Each constructor argument is one +/// part: a quoted name (`str`), an `Unquote` name, an `Asterisk` wildcard, +/// or a nested `Ident` whose parts are flattened. +/// +/// Args: +/// parts: One argument per part. A `str` is always quoted, including +/// `"*"` (a column named star). Pass `Asterisk` for the SQL +/// wildcard. Dotted names are `Ident("schema", "table")`, not +/// `Ident("schema.table")`. +/// +/// Raises: +/// TypeError: If an argument is not `str`, `Unquote`, `Asterisk`, or +/// `Ident`. +/// ValueError: If there are no parts, a part is empty, `Asterisk` is +/// not last, an `Unquote` body is not a simple ASCII name, or +/// there are more than 10 parts. +#[rapidquery_macros::pyclass("Ident", skip_from_py_object, eq, hash)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PyIdent(pub Ident); + +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyIdent { + type Error = pyo3::PyErr; + + #[inline] + fn extract( + obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, + ) -> Result { + IdentExtractor::extract(obj).map(|IdentExtractor(inner)| Self(inner)) + } +} + +#[pyo3::pymethods] +impl PyIdent { + #[new] + #[pyo3(signature = (*parts))] + fn __new__(parts: alias::ArgsType) -> pyo3::PyResult { + match parts.len() { + 0 => Err(parse_err(ParseIdentError::Empty)), + 1 => match extract_arg(parts.get_item(0)?.as_borrowed())? { + Extracted::Ident(ident) => Ok(Self(ident)), + Extracted::Part(part) => Ok(Self(Ident::from_one(part))), + }, + _ => { + let mut acc = SmallVec::<[IdentPart; 4]>::new(); + for item in parts.iter() { + match extract_arg(item.as_borrowed())? { + Extracted::Ident(ident) => acc.extend(ident.into_parts()), + Extracted::Part(part) => acc.push(part), + } + } + Ident::from_parts(acc).map(Self).map_err(parse_err) + } + } + } + + fn __len__(&self) -> usize { + self.0.len() + } + + /// Returns a part by index, or a sub-identifier by slice. + /// + /// Args: + /// index: An `int` (negative indices count from the end) or a + /// `slice`. + /// + /// Returns: + /// A `str`, `Unquote`, or `Asterisk` for an integer index. An + /// `Ident` for a slice. + /// + /// Raises: + /// IndexError: If the integer index is out of range. + /// ValueError: If the slice selects no parts. + fn __getitem__<'py>( + &self, + py: pyo3::Python<'py>, + index: utils::IndexOrSlice, + ) -> pyo3::PyResult> { + let parts = self.0.parts(); + let len = parts.len() as isize; + + match index { + utils::IndexOrSlice::Index(i) => { + let idx = if i < 0 { i + len } else { i }; + if idx < 0 || idx >= len { + return rapidquery_macros::pyerr!( + Err(PyIndexError), + "Ident index out of range" + ); + } + ident_part_to_pyobject(py, &parts[idx as usize]) + } + utils::IndexOrSlice::Slice(slice) => { + let indices = slice.bind(py).indices(len)?; + let mut selected = Vec::with_capacity(indices.slicelength); + + let mut i = indices.start; + if indices.step > 0 { + while i < indices.stop { + selected.push(parts[i as usize].clone()); + i += indices.step; + } + } else { + while i > indices.stop { + selected.push(parts[i as usize].clone()); + i += indices.step; + } + } + + let inner = Ident::from_parts(selected).map_err(parse_err)?; + Ok(pyo3::Bound::new(py, PyIdent(inner))?.into_any()) + } + } + } + + fn __iter__<'py>( + &self, + py: pyo3::Python<'py>, + ) -> pyo3::PyResult> { + let items = self + .0 + .parts() + .iter() + .map(|part| ident_part_to_pyobject(py, part)) + .collect::>>()?; + let list = pyo3::types::PyList::new(py, items)?; + list.try_iter() + } + + /// Returns the parts as a list (`str`, `Unquote`, or `Asterisk`). + fn to_list(&self, py: pyo3::Python<'_>) -> pyo3::PyResult> { + self.0 + .parts() + .iter() + .map(|part| ident_part_to_pyobject(py, part).map(|bound| bound.unbind())) + .collect() + } + + /// Renders this identifier as SQL for `dialect`. + /// + /// Args: + /// dialect: A dialect class (`PostgreSQL`) or instance + /// (`PostgreSQL()`). + /// + /// Returns: + /// The dotted identifier with each part quoted or emitted according + /// to its kind. + fn to_sql( + &self, + py: pyo3::Python<'_>, + dialect: crate::dialect::py::PySqlDialect, + ) -> pyo3::PyResult { + write_sql(py, &self.0, &dialect.0) + .map_err(|e| rapidquery_macros::pyerr!(PyRuntimeError, "{}", e)) + } + + fn __repr__(&self) -> String { + use std::fmt::Write; + + let mut result = String::from("Ident("); + let parts = self.0.parts(); + for (index, item) in parts.iter().enumerate() { + match item { + IdentPart::Quote(x) => write!(result, "{x:?}").unwrap(), + IdentPart::Unquote(x) => write!(result, "Unquote({x:?})").unwrap(), + IdentPart::Asterisk => result.push_str("Asterisk"), + } + if index + 1 < parts.len() { + result.push_str(", "); + } + } + result.push(')'); + result + } +} + +/// Marker for an unquoted identifier part. +/// +/// The body is emitted without dialect delimiters. It must be a simple +/// ASCII name (`[A-Za-z_][A-Za-z0-9_]*`). This is **not** a raw-SQL +/// fragment (`COUNT(*)` belongs on a later expression type). +/// +/// Args: +/// name: The identifier body, unquoted. +/// +/// Raises: +/// ValueError: If `name` is empty or not a simple ASCII identifier +/// (letters, digits, underscore; first character not a digit). +#[rapidquery_macros::pyclass("Unquote", skip_from_py_object, eq, hash)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PyUnquote(pub arcstr::ArcStr); + +#[pyo3::pymethods] +impl PyUnquote { + #[new] + #[pyo3(signature = (name, /))] + fn __new__(name: &str) -> pyo3::PyResult { + IdentPart::unquoted(name).map_err(parse_err)?; + Ok(Self(arcstr::ArcStr::from(name))) + } + + /// Returns the unquoted identifier body. + /// + /// Returns: + /// The string passed to the constructor. + #[getter] + fn get_name(&self) -> &str { + self.0.as_str() + } + + fn __repr__(&self) -> String { + format!("Unquote({:?})", self.0.as_str()) + } +} + +/// SQL wildcard `*`. +/// +/// Pass the class (`Asterisk`) or an instance (`Asterisk()`) as an +/// identifier part. It must be the last part of an `Ident` (`t.*`). +/// `Ident("*")` is a quoted column named star, not this wildcard. +#[rapidquery_macros::pyclass("Asterisk", skip_from_py_object, eq, hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PyAsterisk; + +#[pyo3::pymethods] +impl PyAsterisk { + #[new] + fn __new__() -> Self { + Self + } + + fn __repr__(&self) -> &'static str { + "Asterisk()" + } +} + #[cfg(test)] mod tests { use std::str::FromStr; diff --git a/src/lib.rs b/src/lib.rs index 23ecab2..f16a84e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,22 +1,28 @@ #![allow(unsafe_op_in_unsafe_fn)] #![cfg_attr(feature = "nightly", feature(optimize_attribute))] +pub mod data_type; +pub mod dialect; +pub mod ident; pub mod internal; -pub mod native; -pub mod pybindings; +pub mod sql; pub mod typeref; +pub mod value; /// RapidQuery native extension. #[pyo3::pymodule(gil_used = false)] mod _core { #[pymodule_export] - use super::pybindings::data_type_mod; + use super::data_type::data_type_mod; #[pymodule_export] - use super::pybindings::dialect_mod; + use super::dialect::dialect_mod; #[pymodule_export] - use super::pybindings::{PyAsterisk, PyIdent, PyUnquote, PyValue}; + use super::ident::{PyAsterisk, PyIdent, PyUnquote}; + + #[pymodule_export] + use super::value::PyValue; #[pymodule_init] #[cold] diff --git a/src/native/data_type/mod.rs b/src/native/data_type/mod.rs deleted file mode 100644 index 192c02b..0000000 --- a/src/native/data_type/mod.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! SQL data types: interned names, serializers, and the composed [`DataType`]. -//! -//! A [`DataTypeName`] is a canonical spelling plus a [`DataTypeGroup`]. The -//! group is the inference key for which [`Serializer`] [`DataType::from_name`] -//! constructs when none is passed. Groups are serializer-oriented: `NUMERIC` -//! and `DECIMAL` sit in [`DataTypeGroup::Float`] because they share a -//! serializer with IEEE floats. - -mod name; -mod serialize; -mod serializer; - -#[cfg(test)] -mod tests; - -pub use name::{DataTypeGroup, DataTypeName}; -pub use serializer::*; - -use crate::native::sql::{PrepareResult, SqlWriter, WriteSql}; - -/// SQL type spelling plus the serializer used for Python values. -/// -/// [`WriteSql`] emits the type name (CAST, column definitions). Literal -/// serialization of a Python object is [`Serializer::write_sql`], not this -/// type. -/// -/// Clone of a [`Serializer::Custom`] value requires the GIL. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct DataType { - pub name: DataTypeName, - pub serializer: Serializer, -} - -impl DataType { - #[inline] - pub fn new(name: DataTypeName, serializer: Serializer) -> Self { - Self { name, serializer } - } - - /// Infer the serializer from [`DataTypeName::group`]. - /// - /// Unrecognized and parameterized names use [`Serializer::Unspecified`]. - #[inline] - pub fn from_name(name: impl Into) -> Self { - let name = name.into(); - let serializer = Serializer::from_group(name.group()); - Self { name, serializer } - } -} - -impl WriteSql for DataType { - #[inline] - fn write_sql( - &self, - dialect: &crate::native::dialect::SqlDialect, - out: &mut W, - ) -> PrepareResult<()> { - self.name.write_sql(dialect, out) - } -} diff --git a/src/native/mod.rs b/src/native/mod.rs deleted file mode 100644 index da02816..0000000 --- a/src/native/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -pub mod data_type; -pub mod dialect; -pub mod sql; - -mod ident; -mod value; - -pub use data_type::DataType; -pub use ident::{Ident, IdentPart, ParseIdentError}; -pub use value::Value; diff --git a/src/native/value.rs b/src/native/value.rs deleted file mode 100644 index a458bcd..0000000 --- a/src/native/value.rs +++ /dev/null @@ -1,39 +0,0 @@ -use crate::internal::alias; -use crate::native::data_type::Serializer; -use crate::native::sql::WriteSql; - -/// A Python object plus the [`Serializer`] used to emit it as SQL. -/// -/// Parameterized SQL binds [`Self::object`] as a placeholder and ignores the -/// serializer. Raw SQL (`BufferedSql`) calls [`Serializer::write_sql`]; -/// Python `None` becomes `NULL`. -/// -/// Clone requires the GIL (`Py::clone`). -#[derive(Debug, Clone)] -pub struct Value { - pub object: alias::PyObject, - pub serializer: Serializer, -} - -impl Value { - #[inline] - pub fn new(object: alias::PyObject, serializer: Serializer) -> Self { - Self { object, serializer } - } - - #[inline] - pub fn infer_bound(bound: alias::BoundObject) -> Self { - let serializer = Serializer::infer_bound(&bound); - Self::new(bound.unbind(), serializer) - } -} - -impl WriteSql for Value { - fn write_sql( - &self, - dialect: &super::dialect::SqlDialect, - out: &mut W, - ) -> super::sql::PrepareResult<()> { - out.push_value(dialect, &self.serializer, &self.object) - } -} diff --git a/src/pybindings/ident.rs b/src/pybindings/ident.rs deleted file mode 100644 index 33d1a12..0000000 --- a/src/pybindings/ident.rs +++ /dev/null @@ -1,360 +0,0 @@ -//! Python wrappers around [`crate::native::ident`]. -//! -//! Native [`Ident`](crate::native::Ident) / [`IdentPart`](crate::native::IdentPart) -//! stay PyO3-free. This module owns the pyclasses and the extractor used by -//! later APIs: -//! -//! * [`PyIdent`] — `rapidquery._core.Ident` -//! * [`PyUnquote`] — `rapidquery._core.Unquote` -//! * [`PyAsterisk`] — `rapidquery._core.Asterisk` -//! * [`IdentExtractor`] — `str | Ident | Unquote | Asterisk | type[Asterisk]` -//! -//! A Python `str` is always a quoted part, including `"*"`. The wildcard is -//! `Asterisk`. Dotted names are `Ident("schema", "table")`; the native -//! [`FromStr`](std::str::FromStr) parser is not used on the Python constructor. - -use pyo3::types::{PyAnyMethods, PySliceMethods, PyTupleMethods}; -use smallvec::SmallVec; - -use crate::internal::alias; -use crate::internal::utils; -use crate::native; -use crate::native::sql::write_sql; - -#[inline] -fn parse_err(err: native::ParseIdentError) -> pyo3::PyErr { - rapidquery_macros::pyerr!(PyValueError, "{}", err) -} - -/// Convert a native part into the matching Python object (`str`, `Unquote`, -/// or `Asterisk`). -#[inline] -fn ident_part_to_pyobject<'py>( - py: pyo3::Python<'py>, - ident_part: &native::IdentPart, -) -> pyo3::PyResult> { - match ident_part { - native::IdentPart::Quote(s) => Ok(pyo3::types::PyString::new(py, s.as_str()).into_any()), - native::IdentPart::Unquote(s) => { - Ok(pyo3::Bound::new(py, PyUnquote(arcstr::ArcStr::from(s.as_str())))?.into_any()) - } - native::IdentPart::Asterisk => Ok(pyo3::Bound::new(py, PyAsterisk)?.into_any()), - } -} - -/// `true` when `obj` is `Asterisk` or `type[Asterisk]`. -/// -/// # Safety -/// -/// `obj` must be a valid, non-null `PyObject*`. `ASTERISK_TYPE` must have -/// been interned by [`crate::typeref::initialize_typeref`]. The GIL must -/// be held. -#[inline] -unsafe fn is_asterisk(obj: *mut pyo3::ffi::PyObject) -> bool { - let type_ptr = if pyo3::ffi::PyType_CheckExact(obj) == 1 { - obj.cast::() - } else { - pyo3::ffi::Py_TYPE(obj) - }; - type_ptr == crate::typeref::ASTERISK_TYPE -} - -enum Extracted { - Ident(native::Ident), - Part(native::IdentPart), -} - -/// Hot-path extract: `str` first, then Ident / Unquote / Asterisk. -#[inline] -fn extract_arg( - obj: pyo3::prelude::Borrowed<'_, '_, pyo3::prelude::PyAny>, -) -> pyo3::PyResult { - let ptr = obj.as_ptr(); - unsafe { - // SAFETY: `ptr` is a live borrowed `PyObject`. Type pointers are - // interned in `initialize_typeref` before any extractor runs. - if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { - let s = utils::py_str(obj.py(), ptr)?; - let part = native::IdentPart::quoted(s).map_err(parse_err)?; - return Ok(Extracted::Part(part)); - } - - if crate::typeref::check_exact(ptr, crate::typeref::IDENT_TYPE) { - let ident = obj.cast_unchecked::(); - return Ok(Extracted::Ident(ident.get().0.clone())); - } - - if crate::typeref::check_exact(ptr, crate::typeref::UNQUOTE_TYPE) { - let unquote = obj.cast_unchecked::(); - let part = native::IdentPart::unquoted(unquote.get().0.as_str()).map_err(parse_err)?; - return Ok(Extracted::Part(part)); - } - - if is_asterisk(ptr) { - return Ok(Extracted::Part(native::IdentPart::Asterisk)); - } - } - - rapidquery_macros::pyerr!( - Err(PyTypeError), - "expected str, Ident, Unquote or Asterisk, got {}", - unsafe { crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) } - ) -} - -/// Extracts a native [`Ident`](crate::native::Ident) from a Python object. -/// -/// Accepts `Ident`, `str` (one quoted part, including `"*"`), `Unquote`, -/// `Asterisk`, and `type[Asterisk]`. Sequences are rejected. -pub struct IdentExtractor(pub native::Ident); - -impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for IdentExtractor { - type Error = pyo3::PyErr; - - #[inline] - fn extract( - obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, - ) -> Result { - match extract_arg(obj)? { - Extracted::Ident(ident) => Ok(Self(ident)), - Extracted::Part(part) => Ok(Self(native::Ident::from_one(part))), - } - } -} - -/// Represents a SQL identifier (`schema.table.column`). -/// -/// An identifier is a sequence of parts. Each constructor argument is one -/// part: a quoted name (`str`), an `Unquote` name, an `Asterisk` wildcard, -/// or a nested `Ident` whose parts are flattened. -/// -/// Args: -/// parts: One argument per part. A `str` is always quoted, including -/// `"*"` (a column named star). Pass `Asterisk` for the SQL -/// wildcard. Dotted names are `Ident("schema", "table")`, not -/// `Ident("schema.table")`. -/// -/// Raises: -/// TypeError: If an argument is not `str`, `Unquote`, `Asterisk`, or -/// `Ident`. -/// ValueError: If there are no parts, a part is empty, `Asterisk` is -/// not last, an `Unquote` body is not a simple ASCII name, or -/// there are more than 10 parts. -#[rapidquery_macros::pyclass("Ident", skip_from_py_object, eq, hash)] -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct PyIdent(pub native::Ident); - -impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyIdent { - type Error = pyo3::PyErr; - - #[inline] - fn extract( - obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, - ) -> Result { - IdentExtractor::extract(obj).map(|IdentExtractor(inner)| Self(inner)) - } -} - -#[pyo3::pymethods] -impl PyIdent { - #[new] - #[pyo3(signature = (*parts))] - fn __new__(parts: alias::ArgsType) -> pyo3::PyResult { - match parts.len() { - 0 => Err(parse_err(native::ParseIdentError::Empty)), - 1 => match extract_arg(parts.get_item(0)?.as_borrowed())? { - Extracted::Ident(ident) => Ok(Self(ident)), - Extracted::Part(part) => Ok(Self(native::Ident::from_one(part))), - }, - _ => { - let mut acc = SmallVec::<[native::IdentPart; 4]>::new(); - for item in parts.iter() { - match extract_arg(item.as_borrowed())? { - Extracted::Ident(ident) => acc.extend(ident.into_parts()), - Extracted::Part(part) => acc.push(part), - } - } - native::Ident::from_parts(acc).map(Self).map_err(parse_err) - } - } - } - - fn __len__(&self) -> usize { - self.0.len() - } - - /// Returns a part by index, or a sub-identifier by slice. - /// - /// Args: - /// index: An `int` (negative indices count from the end) or a - /// `slice`. - /// - /// Returns: - /// A `str`, `Unquote`, or `Asterisk` for an integer index. An - /// `Ident` for a slice. - /// - /// Raises: - /// IndexError: If the integer index is out of range. - /// ValueError: If the slice selects no parts. - fn __getitem__<'py>( - &self, - py: pyo3::Python<'py>, - index: utils::IndexOrSlice, - ) -> pyo3::PyResult> { - let parts = self.0.parts(); - let len = parts.len() as isize; - - match index { - utils::IndexOrSlice::Index(i) => { - let idx = if i < 0 { i + len } else { i }; - if idx < 0 || idx >= len { - return rapidquery_macros::pyerr!( - Err(PyIndexError), - "Ident index out of range" - ); - } - ident_part_to_pyobject(py, &parts[idx as usize]) - } - utils::IndexOrSlice::Slice(slice) => { - let indices = slice.bind(py).indices(len)?; - let mut selected = Vec::with_capacity(indices.slicelength); - - let mut i = indices.start; - if indices.step > 0 { - while i < indices.stop { - selected.push(parts[i as usize].clone()); - i += indices.step; - } - } else { - while i > indices.stop { - selected.push(parts[i as usize].clone()); - i += indices.step; - } - } - - let inner = native::Ident::from_parts(selected).map_err(parse_err)?; - Ok(pyo3::Bound::new(py, PyIdent(inner))?.into_any()) - } - } - } - - fn __iter__<'py>( - &self, - py: pyo3::Python<'py>, - ) -> pyo3::PyResult> { - let items = self - .0 - .parts() - .iter() - .map(|part| ident_part_to_pyobject(py, part)) - .collect::>>()?; - let list = pyo3::types::PyList::new(py, items)?; - list.try_iter() - } - - /// Returns the parts as a list (`str`, `Unquote`, or `Asterisk`). - fn to_list(&self, py: pyo3::Python<'_>) -> pyo3::PyResult> { - self.0 - .parts() - .iter() - .map(|part| ident_part_to_pyobject(py, part).map(|bound| bound.unbind())) - .collect() - } - - /// Renders this identifier as SQL for `dialect`. - /// - /// Args: - /// dialect: A dialect class (`PostgreSQL`) or instance - /// (`PostgreSQL()`). - /// - /// Returns: - /// The dotted identifier with each part quoted or emitted according - /// to its kind. - fn to_sql( - &self, - py: pyo3::Python<'_>, - dialect: super::dialect::PySqlDialect, - ) -> pyo3::PyResult { - write_sql(py, &self.0, &dialect.0) - .map_err(|e| rapidquery_macros::pyerr!(PyRuntimeError, "{}", e)) - } - - fn __repr__(&self) -> String { - use std::fmt::Write; - - let mut result = String::from("Ident("); - let parts = self.0.parts(); - for (index, item) in parts.iter().enumerate() { - match item { - native::IdentPart::Quote(x) => write!(result, "{x:?}").unwrap(), - native::IdentPart::Unquote(x) => write!(result, "Unquote({x:?})").unwrap(), - native::IdentPart::Asterisk => result.push_str("Asterisk"), - } - if index + 1 < parts.len() { - result.push_str(", "); - } - } - result.push(')'); - result - } -} - -/// Marker for an unquoted identifier part. -/// -/// The body is emitted without dialect delimiters. It must be a simple -/// ASCII name (`[A-Za-z_][A-Za-z0-9_]*`). This is **not** a raw-SQL -/// fragment (`COUNT(*)` belongs on a later expression type). -/// -/// Args: -/// name: The identifier body, unquoted. -/// -/// Raises: -/// ValueError: If `name` is empty or not a simple ASCII identifier -/// (letters, digits, underscore; first character not a digit). -#[rapidquery_macros::pyclass("Unquote", skip_from_py_object, eq, hash)] -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct PyUnquote(pub arcstr::ArcStr); - -#[pyo3::pymethods] -impl PyUnquote { - #[new] - #[pyo3(signature = (name, /))] - fn __new__(name: &str) -> pyo3::PyResult { - native::IdentPart::unquoted(name).map_err(parse_err)?; - Ok(Self(arcstr::ArcStr::from(name))) - } - - /// Returns the unquoted identifier body. - /// - /// Returns: - /// The string passed to the constructor. - #[getter] - fn get_name(&self) -> &str { - self.0.as_str() - } - - fn __repr__(&self) -> String { - format!("Unquote({:?})", self.0.as_str()) - } -} - -/// SQL wildcard `*`. -/// -/// Pass the class (`Asterisk`) or an instance (`Asterisk()`) as an -/// identifier part. It must be the last part of an `Ident` (`t.*`). -/// `Ident("*")` is a quoted column named star, not this wildcard. -#[rapidquery_macros::pyclass("Asterisk", skip_from_py_object, eq, hash)] -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct PyAsterisk; - -#[pyo3::pymethods] -impl PyAsterisk { - #[new] - fn __new__() -> Self { - Self - } - - fn __repr__(&self) -> &'static str { - "Asterisk()" - } -} diff --git a/src/pybindings/mod.rs b/src/pybindings/mod.rs deleted file mode 100644 index 55bf249..0000000 --- a/src/pybindings/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! PyO3 wrappers around [`crate::native`]. -//! -//! This module is the only place that defines `#[pyclass]` types and the -//! `rapidquery._core.*` submodules they live in. Native may hold and inspect -//! Python objects (`Serializer`, `Value`, `PrepareContext`); pyclasses wrap -//! those types. - -pub mod data_type; -pub mod dialect; - -mod ident; -mod value; - -pub use data_type::data_type_mod; -pub use dialect::dialect_mod; -pub use ident::{IdentExtractor, PyAsterisk, PyIdent, PyUnquote}; -pub use value::PyValue; diff --git a/src/native/sql.rs b/src/sql.rs similarity index 98% rename from src/native/sql.rs rename to src/sql.rs index aeda6bb..a1ebd4e 100644 --- a/src/native/sql.rs +++ b/src/sql.rs @@ -9,8 +9,8 @@ use hashbrown::HashMap; use pyo3::types::PyAnyMethods; use crate::internal::alias; -use crate::native::data_type::Serializer; -use crate::native::dialect::SqlDialect; +use crate::data_type::Serializer; +use crate::dialect::SqlDialect; /// Error produced while building SQL. #[derive(Debug)] diff --git a/src/typeref.rs b/src/typeref.rs index e4e7377..6c3d474 100644 --- a/src/typeref.rs +++ b/src/typeref.rs @@ -98,44 +98,44 @@ fn _initialize_typeref(py: pyo3::Python) { multiple_get_type_object_for!( py, // dialects - crate::pybindings::dialect::PyBigQuery => BIGQUERY_DIALECT_TYPE, - crate::pybindings::dialect::PyDatabricks => DATABRICKS_DIALECT_TYPE, - crate::pybindings::dialect::PyDuckDb => DUCKDB_DIALECT_TYPE, - crate::pybindings::dialect::PyHive => HIVE_DIALECT_TYPE, - crate::pybindings::dialect::PyMsSql => MSSQL_DIALECT_TYPE, - crate::pybindings::dialect::PyMySql => MYSQL_DIALECT_TYPE, - crate::pybindings::dialect::PyOracle => ORACLE_DIALECT_TYPE, - crate::pybindings::dialect::PyPostgreSql => POSTGRESQL_DIALECT_TYPE, - crate::pybindings::dialect::PyRedshift => REDSHIFT_DIALECT_TYPE, - crate::pybindings::dialect::PySnowflake => SNOWFLAKE_DIALECT_TYPE, - crate::pybindings::dialect::PySpark => SPARK_DIALECT_TYPE, - crate::pybindings::dialect::PySqlite => SQLITE_DIALECT_TYPE, - crate::pybindings::dialect::PyTeradata => TERADATA_DIALECT_TYPE, + crate::dialect::py::PyBigQuery => BIGQUERY_DIALECT_TYPE, + crate::dialect::py::PyDatabricks => DATABRICKS_DIALECT_TYPE, + crate::dialect::py::PyDuckDb => DUCKDB_DIALECT_TYPE, + crate::dialect::py::PyHive => HIVE_DIALECT_TYPE, + crate::dialect::py::PyMsSql => MSSQL_DIALECT_TYPE, + crate::dialect::py::PyMySql => MYSQL_DIALECT_TYPE, + crate::dialect::py::PyOracle => ORACLE_DIALECT_TYPE, + crate::dialect::py::PyPostgreSql => POSTGRESQL_DIALECT_TYPE, + crate::dialect::py::PyRedshift => REDSHIFT_DIALECT_TYPE, + crate::dialect::py::PySnowflake => SNOWFLAKE_DIALECT_TYPE, + crate::dialect::py::PySpark => SPARK_DIALECT_TYPE, + crate::dialect::py::PySqlite => SQLITE_DIALECT_TYPE, + crate::dialect::py::PyTeradata => TERADATA_DIALECT_TYPE, // data types / serializers - crate::pybindings::data_type::PyDataType => DATATYPE_TYPE, - crate::pybindings::data_type::PySerializer => SERIALIZER_TYPE, - crate::pybindings::data_type::PyIntegerSerializer => INTEGER_SERIALIZER_TYPE, - crate::pybindings::data_type::PyFloatSerializer => FLOAT_SERIALIZER_TYPE, - crate::pybindings::data_type::PyBooleanSerializer => BOOLEAN_SERIALIZER_TYPE, - crate::pybindings::data_type::PyStringSerializer => STRING_SERIALIZER_TYPE, - crate::pybindings::data_type::PyUuidSerializer => UUID_SERIALIZER_TYPE, - crate::pybindings::data_type::PyBytesSerializer => BYTES_SERIALIZER_TYPE, - crate::pybindings::data_type::PyBitSerializer => BIT_SERIALIZER_TYPE, - crate::pybindings::data_type::PyJSONSerializer => JSON_SERIALIZER_TYPE, - crate::pybindings::data_type::PyDateSerializer => DATE_SERIALIZER_TYPE, - crate::pybindings::data_type::PyTimeSerializer => TIME_SERIALIZER_TYPE, - crate::pybindings::data_type::PyDatetimeSerializer => DATETIME_SERIALIZER_TYPE, - crate::pybindings::data_type::PyInetSerializer => INET_SERIALIZER_TYPE, - crate::pybindings::data_type::PyMacAddressSerializer => MACADDRESS_SERIALIZER_TYPE, - crate::pybindings::data_type::PyUnspecifiedSerializer => UNSPECIFIED_SERIALIZER_TYPE, - crate::pybindings::data_type::PyArraySerializer => ARRAY_SERIALIZER_TYPE, - crate::pybindings::data_type::PyCustomSerializer => CUSTOM_SERIALIZER_TYPE, + crate::data_type::PyDataType => DATATYPE_TYPE, + crate::data_type::PySerializer => SERIALIZER_TYPE, + crate::data_type::PyIntegerSerializer => INTEGER_SERIALIZER_TYPE, + crate::data_type::PyFloatSerializer => FLOAT_SERIALIZER_TYPE, + crate::data_type::PyBooleanSerializer => BOOLEAN_SERIALIZER_TYPE, + crate::data_type::PyStringSerializer => STRING_SERIALIZER_TYPE, + crate::data_type::PyUuidSerializer => UUID_SERIALIZER_TYPE, + crate::data_type::PyBytesSerializer => BYTES_SERIALIZER_TYPE, + crate::data_type::PyBitSerializer => BIT_SERIALIZER_TYPE, + crate::data_type::PyJSONSerializer => JSON_SERIALIZER_TYPE, + crate::data_type::PyDateSerializer => DATE_SERIALIZER_TYPE, + crate::data_type::PyTimeSerializer => TIME_SERIALIZER_TYPE, + crate::data_type::PyDatetimeSerializer => DATETIME_SERIALIZER_TYPE, + crate::data_type::PyInetSerializer => INET_SERIALIZER_TYPE, + crate::data_type::PyMacAddressSerializer => MACADDRESS_SERIALIZER_TYPE, + crate::data_type::PyUnspecifiedSerializer => UNSPECIFIED_SERIALIZER_TYPE, + crate::data_type::PyArraySerializer => ARRAY_SERIALIZER_TYPE, + crate::data_type::PyCustomSerializer => CUSTOM_SERIALIZER_TYPE, // ident - crate::pybindings::PyIdent => IDENT_TYPE, - crate::pybindings::PyUnquote => UNQUOTE_TYPE, - crate::pybindings::PyAsterisk => ASTERISK_TYPE, + crate::ident::PyIdent => IDENT_TYPE, + crate::ident::PyUnquote => UNQUOTE_TYPE, + crate::ident::PyAsterisk => ASTERISK_TYPE, // value - crate::pybindings::PyValue => VALUE_TYPE, + crate::value::PyValue => VALUE_TYPE, ); STD_DECIMAL_TYPE = look_up_type_object(c"decimal", c"Decimal"); diff --git a/src/pybindings/value.rs b/src/value.rs similarity index 71% rename from src/pybindings/value.rs rename to src/value.rs index 0558626..d93f974 100644 --- a/src/pybindings/value.rs +++ b/src/value.rs @@ -1,25 +1,55 @@ -//! Python wrapper around [`crate::native::Value`]. -//! -//! Native [`Value`](crate::native::Value) holds a Python object and a -//! [`Serializer`](crate::native::data_type::Serializer). This module owns -//! the pyclass: -//! -//! * [`PyValue`] — `rapidquery._core.Value` -//! -//! `FromPyObject` unwraps an existing `Value` or infers a serializer from -//! any other object. The constructor also accepts a `Serializer` or a -//! `DataType` (using only its serializer). +//! A Python object plus the [`Serializer`] used to emit it as SQL. use std::hash::{Hash, Hasher}; use pyo3::types::{PyAnyMethods, PyStringMethods}; +use crate::data_type::{PySerializer, Serializer, SerializerExtractor, serializer_repr}; +use crate::dialect::SqlDialect; use crate::internal::alias; -use crate::native; -use crate::native::sql::write_sql; -use crate::pybindings::data_type::{PySerializer, SerializerExtractor, serializer_repr}; +use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_sql}; -/// A Python object plus the serializer used to emit it as SQL. +/// A Python object plus the [`Serializer`] used to emit it as SQL. +/// +/// Parameterized SQL binds [`Self::object`] as a placeholder and ignores the +/// serializer. Raw SQL (`BufferedSql`) calls [`Serializer::write_sql`]; +/// Python `None` becomes `NULL`. +/// +/// Clone requires the GIL (`Py::clone`). +#[derive(Debug, Clone)] +pub struct Value { + pub object: alias::PyObject, + pub serializer: Serializer, +} + +impl Value { + #[inline] + pub fn new(object: alias::PyObject, serializer: Serializer) -> Self { + Self { object, serializer } + } + + #[inline] + pub fn infer_bound(bound: alias::BoundObject) -> Self { + let serializer = Serializer::infer_bound(&bound); + Self::new(bound.unbind(), serializer) + } +} + +impl WriteSql for Value { + fn write_sql( + &self, + dialect: &SqlDialect, + out: &mut W, + ) -> PrepareResult<()> { + out.push_value(dialect, &self.serializer, &self.object) + } +} + +/// Python wrapper around [`Value`]. +/// +/// `FromPyObject` unwraps an existing `Value` or infers a serializer from +/// any other object. The constructor also accepts a `Serializer` or a +/// `DataType` (using only its serializer). /// /// `Value` does not convert the object until SQL is built. Parameterized /// statements bind the object as a placeholder. Raw SQL runs the serializer @@ -31,7 +61,7 @@ use crate::pybindings::data_type::{PySerializer, SerializerExtractor, serializer /// serializer; the data type is not stored. #[rapidquery_macros::pyclass("Value", skip_from_py_object)] #[derive(Debug, Clone)] -pub struct PyValue(native::Value); +pub struct PyValue(pub Value); impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyValue { type Error = pyo3::PyErr; @@ -47,7 +77,7 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyValue { } } - Ok(Self(native::Value::infer_bound(obj.as_any().clone()))) + Ok(Self(Value::infer_bound(obj.as_any().clone()))) } } @@ -74,18 +104,18 @@ impl PyValue { // handle Value(Value(...), new_serializer) if unsafe { crate::typeref::check_exact(object.as_ptr(), crate::typeref::VALUE_TYPE) } { let value = unsafe { object.cast_unchecked::() }; - let native_value = value.get().0.clone(); + let inner = value.get().0.clone(); let slf = match serializer { - Some(SerializerExtractor(s)) => Self(native::Value::new(native_value.object, s)), - None => Self(native_value), + Some(SerializerExtractor(s)) => Self(Value::new(inner.object, s)), + None => Self(inner), }; return pyo3::PyClassInitializer::from(slf); } let slf = match serializer { - Some(SerializerExtractor(s)) => Self(native::Value::new(object.unbind(), s)), - None => Self(native::Value::infer_bound(object)), + Some(SerializerExtractor(s)) => Self(Value::new(object.unbind(), s)), + None => Self(Value::infer_bound(object)), }; pyo3::PyClassInitializer::from(slf) } @@ -128,7 +158,7 @@ impl PyValue { fn to_sql( &self, py: pyo3::Python<'_>, - dialect: super::dialect::PySqlDialect, + dialect: crate::dialect::py::PySqlDialect, ) -> pyo3::PyResult { write_sql(py, &self.0, &dialect.0).map_err(Into::into) } From 37a3af72bc843cf0526e89588dc9750d5c7106bc Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Thu, 10 Sep 2026 21:38:00 +0330 Subject: [PATCH 26/87] Put pyclass on Ident, Value, and DataType Drop PyIdent/PyValue/PyDataType newtypes. Keep PySqlDialect and PySerializer as the enum-vs-class shims. Unquote and Asterisk stay as marker pyclasses. --- AGENTS.md | 4 +-- src/data_type/mod.rs | 69 ++++++++++++++++++++----------------------- src/ident.rs | 70 ++++++++++++++++++++------------------------ src/lib.rs | 4 +-- src/typeref.rs | 6 ++-- src/value.rs | 62 +++++++++++++++++---------------------- 6 files changed, 98 insertions(+), 117 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4bb470d..55dbd7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,8 +37,8 @@ Keep these `refactor/v1` divergences from the PR (do not revert them): ``` src/dialect/ SqlDialect enum + engines + tests; py.rs class tree src/data_type/ DataType, interned names, Serializer enum; py.rs class tree -src/ident.rs Ident, IdentPart, tests + PyIdent/Unquote/Asterisk -src/value.rs Value + PyValue +src/ident.rs Ident, IdentPart, tests + Unquote/Asterisk pyclasses +src/value.rs Value (#[pyclass] on the same type) src/sql.rs BufferedSql, ParameterizedSql, WriteSql src/internal/ aliases, JSON dumps helper, string utils, submodule helper src/typeref.rs cached PyTypeObject pointers diff --git a/src/data_type/mod.rs b/src/data_type/mod.rs index 685c215..77dd60e 100644 --- a/src/data_type/mod.rs +++ b/src/data_type/mod.rs @@ -28,13 +28,32 @@ pub(crate) use py::serializer_repr; use crate::internal::alias; use crate::sql::{PrepareResult, SqlWriter, WriteSql}; -/// SQL type spelling plus the serializer used for Python values. +/// Represents a SQL data type. +/// +/// A data type is a SQL name plus the serializer used to turn Python objects +/// into SQL literals. The name can be any string; it is not limited to a set +/// of predefined values. The serializer is fully customizable. +/// +/// Known names are interned and canonicalized (`"int"` becomes `"INT"`). +/// Unknown names and parameterized spellings (`VARCHAR(255)`) are kept as +/// given. Interning applies to the **name string**, not to `DataType` +/// instances: `DataType("int")` is not a singleton. +/// +/// If `serializer` is omitted at construction, one is inferred from the +/// name; unrecognized names get `UnspecifiedSerializer`. /// /// [`WriteSql`] emits the type name (CAST, column definitions). Literal /// serialization of a Python object is [`Serializer::write_sql`], not this /// type. /// /// Clone of a [`Serializer::Custom`] value requires the GIL. +#[rapidquery_macros::pyclass( + "DataType", + eq, + hash, + from_py_object, + module = "rapidquery._core.data_type" +)] #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct DataType { pub name: DataTypeName, @@ -89,8 +108,8 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for SerializerExtractor { } if crate::typeref::check_exact(ptr, crate::typeref::DATATYPE_TYPE) { - let data_type = obj.cast_unchecked::(); - return Ok(Self(data_type.get().0.serializer.clone())); + let data_type = obj.cast_unchecked::(); + return Ok(Self(data_type.get().serializer.clone())); } } @@ -102,31 +121,8 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for SerializerExtractor { } } -/// Represents a SQL data type. -/// -/// A data type is a SQL name plus the serializer used to turn Python objects -/// into SQL literals. The name can be any string; it is not limited to a set -/// of predefined values. The serializer is fully customizable. -/// -/// Known names are interned and canonicalized (`"int"` becomes `"INT"`). -/// Unknown names and parameterized spellings (`VARCHAR(255)`) are kept as -/// given. Interning applies to the **name string**, not to `DataType` -/// instances: `DataType("int")` is not a singleton. -/// -/// If `serializer` is omitted at construction, one is inferred from the -/// name; unrecognized names get `UnspecifiedSerializer`. -#[rapidquery_macros::pyclass( - "DataType", - eq, - hash, - from_py_object, - module = "rapidquery._core.data_type" -)] -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct PyDataType(pub DataType); - #[pyo3::pymethods] -impl PyDataType { +impl DataType { /// Creates a new SQL data type. /// /// Args: @@ -143,11 +139,10 @@ impl PyDataType { #[new] #[pyo3(signature = (name, serializer = None, /))] fn __new__(name: String, serializer: Option) -> Self { - let inner = match serializer { + match serializer { Some(PySerializer(s)) => DataType::new(DataTypeName::from(name), s), None => DataType::from_name(name), - }; - Self(inner) + } } /// Returns the type name. @@ -157,7 +152,7 @@ impl PyDataType { /// casing or spacing from the string passed to the constructor. #[getter] fn get_name(&self) -> &str { - self.0.name.as_str() + self.name.as_str() } /// Returns the serializer used to render Python values as SQL literals. @@ -166,7 +161,7 @@ impl PyDataType { /// The `Serializer` associated with this data type. #[getter] fn get_serializer(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - PySerializer(slf.0.serializer.clone()).into_subclasses(slf.py()) + PySerializer(slf.serializer.clone()).into_subclasses(slf.py()) } /// Renders `object` as a SQL literal for `dialect`. @@ -185,15 +180,15 @@ impl PyDataType { object: alias::BoundObject<'_>, ) -> pyo3::PyResult { let mut buf = String::new(); - self.0.serializer.write_sql(&dialect.0, &object, &mut buf)?; + self.serializer.write_sql(&dialect.0, &object, &mut buf)?; Ok(buf) } fn __repr__(&self) -> String { format!( "DataType({:?}, {})", - self.0.name.as_str(), - serializer_repr(&self.0.serializer) + self.name.as_str(), + serializer_repr(&self.serializer) ) } } @@ -202,8 +197,8 @@ impl PyDataType { pub mod data_type_mod { #[pymodule_export] use super::{ - PyArraySerializer, PyBitSerializer, PyBooleanSerializer, PyBytesSerializer, - PyCustomSerializer, PyDataType, PyDateSerializer, PyDatetimeSerializer, PyFloatSerializer, + DataType, PyArraySerializer, PyBitSerializer, PyBooleanSerializer, PyBytesSerializer, + PyCustomSerializer, PyDateSerializer, PyDatetimeSerializer, PyFloatSerializer, PyInetSerializer, PyIntegerSerializer, PyJSONSerializer, PyMacAddressSerializer, PySerializer, PyStringSerializer, PyTimeSerializer, PyUnspecifiedSerializer, PyUuidSerializer, diff --git a/src/ident.rs b/src/ident.rs index 1709624..0656642 100644 --- a/src/ident.rs +++ b/src/ident.rs @@ -81,10 +81,28 @@ impl WriteSql for IdentPart { } } -/// Dotted identifier (`schema.table.column`). +/// Represents a SQL identifier (`schema.table.column`). +/// +/// An identifier is a sequence of parts. Each constructor argument is one +/// part: a quoted name (`str`), an `Unquote` name, an `Asterisk` wildcard, +/// or a nested `Ident` whose parts are flattened. /// /// Never empty. At most [`Ident::MAX_PARTS`] parts. An [`IdentPart::Asterisk`] /// may appear only as the last part. +/// +/// Args: +/// parts: One argument per part. A `str` is always quoted, including +/// `"*"` (a column named star). Pass `Asterisk` for the SQL +/// wildcard. Dotted names are `Ident("schema", "table")`, not +/// `Ident("schema.table")`. +/// +/// Raises: +/// TypeError: If an argument is not `str`, `Unquote`, `Asterisk`, or +/// `Ident`. +/// ValueError: If there are no parts, a part is empty, `Asterisk` is +/// not last, an `Unquote` body is not a simple ASCII name, or +/// there are more than 10 parts. +#[rapidquery_macros::pyclass("Ident", skip_from_py_object, eq, hash)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Ident(SmallVec<[IdentPart; 4]>); @@ -309,8 +327,8 @@ fn extract_arg( } if crate::typeref::check_exact(ptr, crate::typeref::IDENT_TYPE) { - let ident = obj.cast_unchecked::(); - return Ok(Extracted::Ident(ident.get().0.clone())); + let ident = obj.cast_unchecked::(); + return Ok(Extracted::Ident(ident.get().clone())); } if crate::typeref::check_exact(ptr, crate::typeref::UNQUOTE_TYPE) { @@ -351,49 +369,27 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for IdentExtractor { } } -/// Represents a SQL identifier (`schema.table.column`). -/// -/// An identifier is a sequence of parts. Each constructor argument is one -/// part: a quoted name (`str`), an `Unquote` name, an `Asterisk` wildcard, -/// or a nested `Ident` whose parts are flattened. -/// -/// Args: -/// parts: One argument per part. A `str` is always quoted, including -/// `"*"` (a column named star). Pass `Asterisk` for the SQL -/// wildcard. Dotted names are `Ident("schema", "table")`, not -/// `Ident("schema.table")`. -/// -/// Raises: -/// TypeError: If an argument is not `str`, `Unquote`, `Asterisk`, or -/// `Ident`. -/// ValueError: If there are no parts, a part is empty, `Asterisk` is -/// not last, an `Unquote` body is not a simple ASCII name, or -/// there are more than 10 parts. -#[rapidquery_macros::pyclass("Ident", skip_from_py_object, eq, hash)] -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct PyIdent(pub Ident); - -impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyIdent { +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for Ident { type Error = pyo3::PyErr; #[inline] fn extract( obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, ) -> Result { - IdentExtractor::extract(obj).map(|IdentExtractor(inner)| Self(inner)) + IdentExtractor::extract(obj).map(|IdentExtractor(inner)| inner) } } #[pyo3::pymethods] -impl PyIdent { +impl Ident { #[new] #[pyo3(signature = (*parts))] fn __new__(parts: alias::ArgsType) -> pyo3::PyResult { match parts.len() { 0 => Err(parse_err(ParseIdentError::Empty)), 1 => match extract_arg(parts.get_item(0)?.as_borrowed())? { - Extracted::Ident(ident) => Ok(Self(ident)), - Extracted::Part(part) => Ok(Self(Ident::from_one(part))), + Extracted::Ident(ident) => Ok(ident), + Extracted::Part(part) => Ok(Ident::from_one(part)), }, _ => { let mut acc = SmallVec::<[IdentPart; 4]>::new(); @@ -403,7 +399,7 @@ impl PyIdent { Extracted::Part(part) => acc.push(part), } } - Ident::from_parts(acc).map(Self).map_err(parse_err) + Ident::from_parts(acc).map_err(parse_err) } } } @@ -430,7 +426,7 @@ impl PyIdent { py: pyo3::Python<'py>, index: utils::IndexOrSlice, ) -> pyo3::PyResult> { - let parts = self.0.parts(); + let parts = self.parts(); let len = parts.len() as isize; match index { @@ -462,7 +458,7 @@ impl PyIdent { } let inner = Ident::from_parts(selected).map_err(parse_err)?; - Ok(pyo3::Bound::new(py, PyIdent(inner))?.into_any()) + Ok(pyo3::Bound::new(py, inner)?.into_any()) } } } @@ -472,7 +468,6 @@ impl PyIdent { py: pyo3::Python<'py>, ) -> pyo3::PyResult> { let items = self - .0 .parts() .iter() .map(|part| ident_part_to_pyobject(py, part)) @@ -483,8 +478,7 @@ impl PyIdent { /// Returns the parts as a list (`str`, `Unquote`, or `Asterisk`). fn to_list(&self, py: pyo3::Python<'_>) -> pyo3::PyResult> { - self.0 - .parts() + self.parts() .iter() .map(|part| ident_part_to_pyobject(py, part).map(|bound| bound.unbind())) .collect() @@ -504,7 +498,7 @@ impl PyIdent { py: pyo3::Python<'_>, dialect: crate::dialect::py::PySqlDialect, ) -> pyo3::PyResult { - write_sql(py, &self.0, &dialect.0) + write_sql(py, self, &dialect.0) .map_err(|e| rapidquery_macros::pyerr!(PyRuntimeError, "{}", e)) } @@ -512,7 +506,7 @@ impl PyIdent { use std::fmt::Write; let mut result = String::from("Ident("); - let parts = self.0.parts(); + let parts = self.parts(); for (index, item) in parts.iter().enumerate() { match item { IdentPart::Quote(x) => write!(result, "{x:?}").unwrap(), diff --git a/src/lib.rs b/src/lib.rs index f16a84e..2a9babc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,10 +19,10 @@ mod _core { use super::dialect::dialect_mod; #[pymodule_export] - use super::ident::{PyAsterisk, PyIdent, PyUnquote}; + use super::ident::{Ident, PyAsterisk, PyUnquote}; #[pymodule_export] - use super::value::PyValue; + use super::value::Value; #[pymodule_init] #[cold] diff --git a/src/typeref.rs b/src/typeref.rs index 6c3d474..eefb276 100644 --- a/src/typeref.rs +++ b/src/typeref.rs @@ -112,7 +112,7 @@ fn _initialize_typeref(py: pyo3::Python) { crate::dialect::py::PySqlite => SQLITE_DIALECT_TYPE, crate::dialect::py::PyTeradata => TERADATA_DIALECT_TYPE, // data types / serializers - crate::data_type::PyDataType => DATATYPE_TYPE, + crate::data_type::DataType => DATATYPE_TYPE, crate::data_type::PySerializer => SERIALIZER_TYPE, crate::data_type::PyIntegerSerializer => INTEGER_SERIALIZER_TYPE, crate::data_type::PyFloatSerializer => FLOAT_SERIALIZER_TYPE, @@ -131,11 +131,11 @@ fn _initialize_typeref(py: pyo3::Python) { crate::data_type::PyArraySerializer => ARRAY_SERIALIZER_TYPE, crate::data_type::PyCustomSerializer => CUSTOM_SERIALIZER_TYPE, // ident - crate::ident::PyIdent => IDENT_TYPE, + crate::ident::Ident => IDENT_TYPE, crate::ident::PyUnquote => UNQUOTE_TYPE, crate::ident::PyAsterisk => ASTERISK_TYPE, // value - crate::value::PyValue => VALUE_TYPE, + crate::value::Value => VALUE_TYPE, ); STD_DECIMAL_TYPE = look_up_type_object(c"decimal", c"Decimal"); diff --git a/src/value.rs b/src/value.rs index d93f974..eeab384 100644 --- a/src/value.rs +++ b/src/value.rs @@ -16,6 +16,16 @@ use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_sql}; /// Python `None` becomes `NULL`. /// /// Clone requires the GIL (`Py::clone`). +/// +/// `Value` does not convert the object until SQL is built. Parameterized +/// statements bind the object as a placeholder. Raw SQL runs the serializer +/// (`None` becomes `NULL`). +/// +/// If `serializer` is omitted, one is inferred from the object's type. +/// Passing an existing `Value` keeps its object (and its serializer, unless +/// a new one is given). A `DataType` as `serializer` contributes only its +/// serializer; the data type is not stored. +#[rapidquery_macros::pyclass("Value", skip_from_py_object)] #[derive(Debug, Clone)] pub struct Value { pub object: alias::PyObject, @@ -45,25 +55,7 @@ impl WriteSql for Value { } } -/// Python wrapper around [`Value`]. -/// -/// `FromPyObject` unwraps an existing `Value` or infers a serializer from -/// any other object. The constructor also accepts a `Serializer` or a -/// `DataType` (using only its serializer). -/// -/// `Value` does not convert the object until SQL is built. Parameterized -/// statements bind the object as a placeholder. Raw SQL runs the serializer -/// (`None` becomes `NULL`). -/// -/// If `serializer` is omitted, one is inferred from the object's type. -/// Passing an existing `Value` keeps its object (and its serializer, unless -/// a new one is given). A `DataType` as `serializer` contributes only its -/// serializer; the data type is not stored. -#[rapidquery_macros::pyclass("Value", skip_from_py_object)] -#[derive(Debug, Clone)] -pub struct PyValue(pub Value); - -impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyValue { +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for Value { type Error = pyo3::PyErr; #[inline] @@ -77,12 +69,12 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyValue { } } - Ok(Self(Value::infer_bound(obj.as_any().clone()))) + Ok(Value::infer_bound(obj.as_any().clone())) } } #[pyo3::pymethods] -impl PyValue { +impl Value { /// Creates a SQL value. /// /// Args: @@ -104,18 +96,18 @@ impl PyValue { // handle Value(Value(...), new_serializer) if unsafe { crate::typeref::check_exact(object.as_ptr(), crate::typeref::VALUE_TYPE) } { let value = unsafe { object.cast_unchecked::() }; - let inner = value.get().0.clone(); + let inner = value.get().clone(); let slf = match serializer { - Some(SerializerExtractor(s)) => Self(Value::new(inner.object, s)), - None => Self(inner), + Some(SerializerExtractor(s)) => Value::new(inner.object, s), + None => inner, }; return pyo3::PyClassInitializer::from(slf); } let slf = match serializer { - Some(SerializerExtractor(s)) => Self(Value::new(object.unbind(), s)), - None => Self(Value::infer_bound(object)), + Some(SerializerExtractor(s)) => Value::new(object.unbind(), s), + None => Value::infer_bound(object), }; pyo3::PyClassInitializer::from(slf) } @@ -127,7 +119,7 @@ impl PyValue { /// `Value`). #[getter] fn get_object(slf: pyo3::PyRef<'_, Self>) -> alias::PyObject { - slf.0.object.clone_ref(slf.py()) + slf.object.clone_ref(slf.py()) } /// Returns the serializer used for raw SQL literals. @@ -137,7 +129,7 @@ impl PyValue { /// is bound as a parameter. #[getter] fn get_serializer(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - PySerializer(slf.0.serializer.clone()).into_subclasses(slf.py()) + PySerializer(slf.serializer.clone()).into_subclasses(slf.py()) } /// Renders this value as a SQL literal for `dialect`. @@ -160,30 +152,30 @@ impl PyValue { py: pyo3::Python<'_>, dialect: crate::dialect::py::PySqlDialect, ) -> pyo3::PyResult { - write_sql(py, &self.0, &dialect.0).map_err(Into::into) + write_sql(py, self, &dialect.0).map_err(Into::into) } fn __eq__(&self, py: pyo3::Python<'_>, other: &pyo3::Bound<'_, Self>) -> pyo3::PyResult { - if self.0.serializer != other.get().0.serializer { + if self.serializer != other.get().serializer { return Ok(false); } - self.0.object.bind(py).eq(other.get().0.object.bind(py)) + self.object.bind(py).eq(other.get().object.bind(py)) } fn __hash__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { let mut hasher = std::collections::hash_map::DefaultHasher::new(); - self.0.object.bind(py).hash()?.hash(&mut hasher); - self.0.serializer.hash(&mut hasher); + self.object.bind(py).hash()?.hash(&mut hasher); + self.serializer.hash(&mut hasher); Ok(hasher.finish()) } fn __repr__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let obj_repr = self.0.object.bind(py).repr()?; + let obj_repr = self.object.bind(py).repr()?; Ok(format!( "Value({}, {})", obj_repr.to_cow()?, - serializer_repr(&self.0.serializer) + serializer_repr(&self.serializer) )) } } From 198185a146e07840f5e341e184a41b46bfd06a9a Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Fri, 11 Sep 2026 11:50:49 +0330 Subject: [PATCH 27/87] Restructure and minimize code --- rapidquery-macros/Cargo.toml | 2 +- src/data_type/mod.rs | 90 ++------ src/data_type/{py.rs => serializer/mod.rs} | 216 +++++++++--------- src/data_type/{ => serializer}/serialize.rs | 8 +- .../serializer_enum.rs} | 45 ++-- src/data_type/tests.rs | 24 -- src/ident.rs | 63 +++-- src/lib.rs | 4 +- src/sql.rs | 10 +- src/typeref.rs | 6 +- src/value.rs | 32 ++- 11 files changed, 218 insertions(+), 282 deletions(-) rename src/data_type/{py.rs => serializer/mod.rs} (74%) rename src/data_type/{ => serializer}/serialize.rs (99%) rename src/data_type/{serializer.rs => serializer/serializer_enum.rs} (77%) diff --git a/rapidquery-macros/Cargo.toml b/rapidquery-macros/Cargo.toml index d3bdb29..b3c2d6d 100644 --- a/rapidquery-macros/Cargo.toml +++ b/rapidquery-macros/Cargo.toml @@ -3,7 +3,7 @@ name = "rapidquery-macros" version = "1.0.0-alpha1" edition = "2024" description = "Procedural macros for RapidQuery." -license = "GNU GPLv3" +license = "MIT" homepage = "https://github.com/awolverp/rapidquery" repository = "https://github.com/awolverp/rapidquery.git" authors = ["Ali Pooralijan "] diff --git a/src/data_type/mod.rs b/src/data_type/mod.rs index 77dd60e..1aec77d 100644 --- a/src/data_type/mod.rs +++ b/src/data_type/mod.rs @@ -1,32 +1,21 @@ -//! SQL data types: interned names, serializers, and the composed [`DataType`]. -//! -//! A [`DataTypeName`] is a canonical spelling plus a [`DataTypeGroup`]. The -//! group is the inference key for which [`Serializer`] [`DataType::from_name`] -//! constructs when none is passed. Groups are serializer-oriented: `NUMERIC` -//! and `DECIMAL` sit in [`DataTypeGroup::Float`] because they share a -//! serializer with IEEE floats. +// //! SQL data types: interned names, serializers, and the composed [`DataType`]. +// //! +// //! A [`DataTypeName`] is a canonical spelling plus a [`DataTypeGroup`]. The +// //! group is the inference key for which [`Serializer`] [`DataType::from_name`] +// //! constructs when none is passed. Groups are serializer-oriented: `NUMERIC` +// //! and `DECIMAL` sit in [`DataTypeGroup::Float`] because they share a +// //! serializer with IEEE floats. mod name; -mod py; -mod serialize; mod serializer; #[cfg(test)] mod tests; pub use name::{DataTypeGroup, DataTypeName}; -pub use py::{ - PyArraySerializer, PyBitSerializer, PyBooleanSerializer, PyBytesSerializer, PyCustomSerializer, - PyDateSerializer, PyDatetimeSerializer, PyFloatSerializer, PyInetSerializer, - PyIntegerSerializer, PyJSONSerializer, PyMacAddressSerializer, PySerializer, - PyStringSerializer, PyTimeSerializer, PyUnspecifiedSerializer, PyUuidSerializer, -}; pub use serializer::*; -pub(crate) use py::serializer_repr; - use crate::internal::alias; -use crate::sql::{PrepareResult, SqlWriter, WriteSql}; /// Represents a SQL data type. /// @@ -49,20 +38,20 @@ use crate::sql::{PrepareResult, SqlWriter, WriteSql}; /// Clone of a [`Serializer::Custom`] value requires the GIL. #[rapidquery_macros::pyclass( "DataType", - eq, - hash, from_py_object, - module = "rapidquery._core.data_type" + module = "rapidquery._core.data_type", + eq, + hash )] #[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct DataType { +pub struct PyDataType { pub name: DataTypeName, - pub serializer: Serializer, + pub serializer: SerializerEnum, } -impl DataType { +impl PyDataType { #[inline] - pub fn new(name: DataTypeName, serializer: Serializer) -> Self { + pub fn new(name: DataTypeName, serializer: SerializerEnum) -> Self { Self { name, serializer } } @@ -72,57 +61,24 @@ impl DataType { #[inline] pub fn from_name(name: impl Into) -> Self { let name = name.into(); - let serializer = Serializer::from_group(name.group()); + let serializer = SerializerEnum::from_group(name.group()); Self { name, serializer } } } -impl WriteSql for DataType { +impl crate::sql::WriteSql for PyDataType { #[inline] - fn write_sql( + fn write_sql( &self, dialect: &crate::dialect::SqlDialect, out: &mut W, - ) -> PrepareResult<()> { + ) -> crate::sql::PrepareResult<()> { self.name.write_sql(dialect, out) } } -/// `Serializer` instance, or a `DataType` (its serializer only). -pub struct SerializerExtractor(pub Serializer); - -impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for SerializerExtractor { - type Error = pyo3::PyErr; - - fn extract( - obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, - ) -> Result { - let ptr = obj.as_ptr(); - - unsafe { - // SAFETY: `ptr` is a live borrowed `PyObject`. Type pointers are - // interned in `initialize_typeref` before any extractor runs. - if crate::typeref::check(ptr, crate::typeref::SERIALIZER_TYPE) { - let serializer = obj.cast_unchecked::(); - return Ok(Self(serializer.get().0.clone())); - } - - if crate::typeref::check_exact(ptr, crate::typeref::DATATYPE_TYPE) { - let data_type = obj.cast_unchecked::(); - return Ok(Self(data_type.get().serializer.clone())); - } - } - - rapidquery_macros::pyerr!( - Err(PyTypeError), - "expected Serializer or DataType, got {}", - unsafe { crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) } - ) - } -} - #[pyo3::pymethods] -impl DataType { +impl PyDataType { /// Creates a new SQL data type. /// /// Args: @@ -140,8 +96,8 @@ impl DataType { #[pyo3(signature = (name, serializer = None, /))] fn __new__(name: String, serializer: Option) -> Self { match serializer { - Some(PySerializer(s)) => DataType::new(DataTypeName::from(name), s), - None => DataType::from_name(name), + Some(PySerializer(s)) => PyDataType::new(DataTypeName::from(name), s), + None => PyDataType::from_name(name), } } @@ -197,8 +153,8 @@ impl DataType { pub mod data_type_mod { #[pymodule_export] use super::{ - DataType, PyArraySerializer, PyBitSerializer, PyBooleanSerializer, PyBytesSerializer, - PyCustomSerializer, PyDateSerializer, PyDatetimeSerializer, PyFloatSerializer, + PyArraySerializer, PyBitSerializer, PyBooleanSerializer, PyBytesSerializer, + PyCustomSerializer, PyDataType, PyDateSerializer, PyDatetimeSerializer, PyFloatSerializer, PyInetSerializer, PyIntegerSerializer, PyJSONSerializer, PyMacAddressSerializer, PySerializer, PyStringSerializer, PyTimeSerializer, PyUnspecifiedSerializer, PyUuidSerializer, diff --git a/src/data_type/py.rs b/src/data_type/serializer/mod.rs similarity index 74% rename from src/data_type/py.rs rename to src/data_type/serializer/mod.rs index a36d0ac..41b132f 100644 --- a/src/data_type/py.rs +++ b/src/data_type/serializer/mod.rs @@ -1,42 +1,60 @@ -//! Python `Serializer` class hierarchy. -//! -//! Wraps [`Serializer`](super::Serializer). Python sees frozen -//! subclasses (`IntegerSerializer`, …) plus `ArraySerializer` / -//! `CustomSerializer`. -//! -//! Unit instances compare and hash by variant (`IntegerSerializer() == -//! IntegerSerializer()`) and `repr` as `IntegerSerializer()`. Array compares -//! by element tree; Custom by function identity. - -use std::hash::{Hash, Hasher}; +mod serialize; +mod serializer_enum; + +pub use serializer_enum::SerializerEnum; use crate::internal::alias; -use super::{DataTypeGroup, Serializer}; -fn class_name(serializer: &Serializer) -> &'static str { +fn class_name(serializer: &SerializerEnum) -> &'static str { match serializer { - Serializer::Integer => "IntegerSerializer", - Serializer::Float => "FloatSerializer", - Serializer::Boolean => "BooleanSerializer", - Serializer::String => "StringSerializer", - Serializer::Uuid => "UuidSerializer", - Serializer::Bytes => "BytesSerializer", - Serializer::Bit => "BitSerializer", - Serializer::Json => "JSONSerializer", - Serializer::Date => "DateSerializer", - Serializer::Time => "TimeSerializer", - Serializer::Datetime => "DatetimeSerializer", - Serializer::Inet => "InetSerializer", - Serializer::MacAddress => "MacAddressSerializer", - Serializer::Unspecified => "UnspecifiedSerializer", - Serializer::Array(_) => "ArraySerializer", - Serializer::Custom(_) => "CustomSerializer", + SerializerEnum::Integer => "IntegerSerializer", + SerializerEnum::Float => "FloatSerializer", + SerializerEnum::Boolean => "BooleanSerializer", + SerializerEnum::String => "StringSerializer", + SerializerEnum::Uuid => "UuidSerializer", + SerializerEnum::Bytes => "BytesSerializer", + SerializerEnum::Bit => "BitSerializer", + SerializerEnum::Json => "JSONSerializer", + SerializerEnum::Date => "DateSerializer", + SerializerEnum::Time => "TimeSerializer", + SerializerEnum::Datetime => "DatetimeSerializer", + SerializerEnum::Inet => "InetSerializer", + SerializerEnum::MacAddress => "MacAddressSerializer", + SerializerEnum::Unspecified => "UnspecifiedSerializer", + SerializerEnum::Array(_) => "ArraySerializer", + SerializerEnum::Custom(_) => "CustomSerializer", + } +} + +fn wrap<'py, T>( + py: pyo3::Python<'py>, + base: PySerializer, +) -> pyo3::PyResult> +where + T: pyo3::PyClass + Default, +{ + pyo3::Bound::new( + py, + pyo3::PyClassInitializer::from(base).add_subclass(T::default()), + ) + .map(|x| x.into_any()) +} + +pub fn serializer_repr(serializer: &SerializerEnum) -> String { + match serializer { + SerializerEnum::Array(element) => { + format!("ArraySerializer({}())", class_name(element)) + } + other => { + let name = class_name(other); + let mut buf = String::with_capacity(name.len() + 2); + buf.push_str(name); + buf.push_str("()"); + buf + } } } -// `subclass` so the built-in serializers can `extends` this type from Rust. -// `from_py_object` so `ArraySerializer(IntegerSerializer())` extracts the -// base. Python subclasses of `Serializer` are not used as built-ins. /// Converts a Python object into a dialect-specific SQL literal string. /// /// Serializers produce a correctly escaped and formatted SQL value that can @@ -55,82 +73,35 @@ fn class_name(serializer: &Serializer) -> &'static str { module = "rapidquery._core.data_type" )] #[derive(Debug, Clone)] -pub struct PySerializer(pub Serializer); +pub struct PySerializer(pub SerializerEnum); impl PySerializer { - pub fn infer_bound(bound: &alias::BoundObject) -> Self { - Self(Serializer::infer_bound(bound)) - } - - pub fn from_group(kind: DataTypeGroup) -> Self { - Self(Serializer::from_group(kind)) - } - - pub fn serialize_value( - &self, - dialect: crate::dialect::py::PySqlDialect, - object: alias::BoundObject<'_>, - ) -> pyo3::PyResult { - let mut buf = String::new(); - self.0.write_sql(&dialect.0, &object, &mut buf)?; - Ok(buf) - } - - // Wrap `self` as the matching Python subclass (`IntegerSerializer()`, …). + /// Wrap `self` as the matching Python subclass (`IntegerSerializer()`, …). pub fn into_subclasses<'py>( self, py: pyo3::Python<'py>, ) -> pyo3::PyResult> { match &self.0 { - Serializer::Integer => wrap::(py, self), - Serializer::Float => wrap::(py, self), - Serializer::Boolean => wrap::(py, self), - Serializer::String => wrap::(py, self), - Serializer::Uuid => wrap::(py, self), - Serializer::Bytes => wrap::(py, self), - Serializer::Bit => wrap::(py, self), - Serializer::Json => wrap::(py, self), - Serializer::Date => wrap::(py, self), - Serializer::Time => wrap::(py, self), - Serializer::Datetime => wrap::(py, self), - Serializer::Inet => wrap::(py, self), - Serializer::MacAddress => wrap::(py, self), - Serializer::Unspecified => wrap::(py, self), - Serializer::Array(_) => wrap::(py, self), - Serializer::Custom(_) => wrap::(py, self), + SerializerEnum::Integer => wrap::(py, self), + SerializerEnum::Float => wrap::(py, self), + SerializerEnum::Boolean => wrap::(py, self), + SerializerEnum::String => wrap::(py, self), + SerializerEnum::Uuid => wrap::(py, self), + SerializerEnum::Bytes => wrap::(py, self), + SerializerEnum::Bit => wrap::(py, self), + SerializerEnum::Json => wrap::(py, self), + SerializerEnum::Date => wrap::(py, self), + SerializerEnum::Time => wrap::(py, self), + SerializerEnum::Datetime => wrap::(py, self), + SerializerEnum::Inet => wrap::(py, self), + SerializerEnum::MacAddress => wrap::(py, self), + SerializerEnum::Unspecified => wrap::(py, self), + SerializerEnum::Array(_) => wrap::(py, self), + SerializerEnum::Custom(_) => wrap::(py, self), } } } -pub(crate) fn serializer_repr(serializer: &Serializer) -> String { - match serializer { - Serializer::Array(element) => { - format!("ArraySerializer({}())", class_name(element)) - } - other => { - let name = class_name(other); - let mut buf = String::with_capacity(name.len() + 2); - buf.push_str(name); - buf.push_str("()"); - buf - } - } -} - -fn wrap<'py, T>( - py: pyo3::Python<'py>, - base: PySerializer, -) -> pyo3::PyResult> -where - T: pyo3::PyClass + Default, -{ - pyo3::Bound::new( - py, - pyo3::PyClassInitializer::from(base).add_subclass(T::default()), - ) - .map(|x| x.into_any()) -} - #[pyo3::pymethods] impl PySerializer { /// Renders `object` as a SQL literal for `dialect`. @@ -151,7 +122,9 @@ impl PySerializer { dialect: crate::dialect::py::PySqlDialect, object: alias::BoundObject<'_>, ) -> pyo3::PyResult { - self.serialize_value(dialect, object) + let mut buf = String::new(); + self.0.write_sql(&dialect.0, &object, &mut buf)?; + Ok(buf) } fn __repr__(&self) -> String { @@ -164,6 +137,8 @@ impl PySerializer { } fn __hash__(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); self.0.hash(&mut hasher); hasher.finish() @@ -192,7 +167,7 @@ macro_rules! impl_unit_serializers { #[new] #[inline] fn __new__() -> pyo3::PyClassInitializer { - pyo3::PyClassInitializer::from(PySerializer(Serializer::$variant)) + pyo3::PyClassInitializer::from(PySerializer(SerializerEnum::$variant)) .add_subclass(Self) } } @@ -335,7 +310,7 @@ impl PyArraySerializer { #[new] #[inline] fn __new__(element: PySerializer) -> pyo3::PyClassInitializer { - let inner = Serializer::Array(Box::new(element.0)); + let inner = SerializerEnum::Array(Box::new(element.0)); pyo3::PyClassInitializer::from(PySerializer(inner)).add_subclass(Self) } @@ -347,7 +322,7 @@ impl PyArraySerializer { fn get_element(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { let base = slf.as_super(); match &base.0 { - Serializer::Array(element) => { + SerializerEnum::Array(element) => { PySerializer((**element).clone()).into_subclasses(slf.py()) } _ => unreachable!("ArraySerializer base is always Array"), @@ -386,7 +361,7 @@ impl PyCustomSerializer { ); } } - let inner = Serializer::Custom(function); + let inner = SerializerEnum::Custom(function); Ok(pyo3::PyClassInitializer::from(PySerializer(inner)).add_subclass(Self)) } @@ -397,8 +372,43 @@ impl PyCustomSerializer { #[getter] fn get_function(slf: pyo3::PyRef<'_, Self>) -> alias::PyObject { match &slf.as_super().0 { - Serializer::Custom(function) => function.clone(), + SerializerEnum::Custom(function) => function.clone(), _ => unreachable!("CustomSerializer base is always Custom"), } } } + +/// `Serializer` instance, or a `DataType` (its serializer only). +/// +/// Use [`PySerializer`] itself as an argument if you wanna accept ONLY `PySerializer` types. +pub struct SerializerExtractor(pub SerializerEnum); + +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for SerializerExtractor { + type Error = pyo3::PyErr; + + fn extract( + obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, + ) -> Result { + let ptr = obj.as_ptr(); + + unsafe { + // SAFETY: `ptr` is a live borrowed `PyObject`. Type pointers are + // interned in `initialize_typeref` before any extractor runs. + if crate::typeref::check(ptr, crate::typeref::SERIALIZER_TYPE) { + let serializer = obj.cast_unchecked::(); + return Ok(Self(serializer.get().0.clone())); + } + + if crate::typeref::check_exact(ptr, crate::typeref::DATATYPE_TYPE) { + let data_type = obj.cast_unchecked::(); + return Ok(Self(data_type.get().serializer.clone())); + } + } + + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected Serializer or DataType, got {}", + unsafe { crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) } + ) + } +} diff --git a/src/data_type/serialize.rs b/src/data_type/serializer/serialize.rs similarity index 99% rename from src/data_type/serialize.rs rename to src/data_type/serializer/serialize.rs index 71c5fe3..88e9c29 100644 --- a/src/data_type/serialize.rs +++ b/src/data_type/serializer/serialize.rs @@ -9,13 +9,11 @@ use std::str::FromStr; use pyo3::types::{PyAnyMethods, PyTzInfoAccess}; +use crate::dialect::SqlDialect; use crate::internal::alias; use crate::internal::utils::{get_type_name, py_str}; -use crate::dialect::SqlDialect; - -use super::serializer::Serializer; -impl Serializer { +impl super::serializer_enum::SerializerEnum { pub fn write_sql( &self, dialect: &SqlDialect, @@ -475,7 +473,7 @@ unsafe fn write_array( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, dialect: &SqlDialect, - element: &Serializer, + element: &super::serializer_enum::SerializerEnum, out: &mut String, ) -> pyo3::PyResult<()> { let (len, getter): ( diff --git a/src/data_type/serializer.rs b/src/data_type/serializer/serializer_enum.rs similarity index 77% rename from src/data_type/serializer.rs rename to src/data_type/serializer/serializer_enum.rs index 110140c..0dd9687 100644 --- a/src/data_type/serializer.rs +++ b/src/data_type/serializer/serializer_enum.rs @@ -11,11 +11,10 @@ use std::hash::{Hash, Hasher}; use crate::internal::alias; use crate::internal::modules::json_dumps; -use super::name::DataTypeGroup; /// How a Python object is written as a SQL literal. #[derive(Debug, Clone)] -pub enum Serializer { +pub enum SerializerEnum { Integer, Float, Boolean, @@ -30,11 +29,11 @@ pub enum Serializer { Inet, MacAddress, Unspecified, - Array(Box), + Array(Box), Custom(alias::PyObject), } -impl PartialEq for Serializer { +impl PartialEq for SerializerEnum { fn eq(&self, other: &Self) -> bool { match (self, other) { (Self::Integer, Self::Integer) @@ -58,9 +57,9 @@ impl PartialEq for Serializer { } } -impl Eq for Serializer {} +impl Eq for SerializerEnum {} -impl Hash for Serializer { +impl Hash for SerializerEnum { fn hash(&self, state: &mut H) { std::mem::discriminant(self).hash(state); match self { @@ -71,28 +70,30 @@ impl Hash for Serializer { } } -impl Serializer { +impl SerializerEnum { /// Built-in serializer for a [`DataTypeGroup`]. /// /// [`DataTypeGroup::Custom`] maps to [`Self::Unspecified`]. Json and Jsonb /// share [`Self::Json`]. #[inline] - pub fn from_group(kind: DataTypeGroup) -> Self { + pub fn from_group(kind: crate::data_type::DataTypeGroup) -> Self { match kind { - DataTypeGroup::Integer => Self::Integer, - DataTypeGroup::Float => Self::Float, - DataTypeGroup::Boolean => Self::Boolean, - DataTypeGroup::String => Self::String, - DataTypeGroup::Uuid => Self::Uuid, - DataTypeGroup::Bytes => Self::Bytes, - DataTypeGroup::Bit => Self::Bit, - DataTypeGroup::Json | DataTypeGroup::Jsonb => Self::Json, - DataTypeGroup::Date => Self::Date, - DataTypeGroup::Time => Self::Time, - DataTypeGroup::Datetime => Self::Datetime, - DataTypeGroup::Inet => Self::Inet, - DataTypeGroup::MacAddress => Self::MacAddress, - DataTypeGroup::Custom => Self::Unspecified, + crate::data_type::DataTypeGroup::Integer => Self::Integer, + crate::data_type::DataTypeGroup::Float => Self::Float, + crate::data_type::DataTypeGroup::Boolean => Self::Boolean, + crate::data_type::DataTypeGroup::String => Self::String, + crate::data_type::DataTypeGroup::Uuid => Self::Uuid, + crate::data_type::DataTypeGroup::Bytes => Self::Bytes, + crate::data_type::DataTypeGroup::Bit => Self::Bit, + crate::data_type::DataTypeGroup::Json | crate::data_type::DataTypeGroup::Jsonb => { + Self::Json + } + crate::data_type::DataTypeGroup::Date => Self::Date, + crate::data_type::DataTypeGroup::Time => Self::Time, + crate::data_type::DataTypeGroup::Datetime => Self::Datetime, + crate::data_type::DataTypeGroup::Inet => Self::Inet, + crate::data_type::DataTypeGroup::MacAddress => Self::MacAddress, + crate::data_type::DataTypeGroup::Custom => Self::Unspecified, } } diff --git a/src/data_type/tests.rs b/src/data_type/tests.rs index d53c183..51ad2a0 100644 --- a/src/data_type/tests.rs +++ b/src/data_type/tests.rs @@ -88,30 +88,6 @@ fn from_string_reuses_unknown_allocation_path() { assert_eq!(dt.group(), DataTypeGroup::Custom); } -// #[test] -// fn inferred_serializer_matches_group() { -// let cases = [ -// (DataTypeGroup::String, InferredSerializer::String), -// (DataTypeGroup::Uuid, InferredSerializer::Uuid), -// (DataTypeGroup::Bytes, InferredSerializer::Bytes), -// (DataTypeGroup::Bit, InferredSerializer::Bit), -// (DataTypeGroup::Integer, InferredSerializer::Integer), -// (DataTypeGroup::Float, InferredSerializer::Float), -// (DataTypeGroup::Boolean, InferredSerializer::Boolean), -// (DataTypeGroup::Date, InferredSerializer::Date), -// (DataTypeGroup::Time, InferredSerializer::Time), -// (DataTypeGroup::Datetime, InferredSerializer::Datetime), -// (DataTypeGroup::Json, InferredSerializer::Json), -// (DataTypeGroup::Jsonb, InferredSerializer::Json), -// (DataTypeGroup::Inet, InferredSerializer::Inet), -// (DataTypeGroup::MacAddress, InferredSerializer::MacAddress), -// (DataTypeGroup::Custom, InferredSerializer::Unspecified), -// ]; -// for (group, expected) in cases { -// assert_eq!(group.inferred_serializer(), expected); -// } -// } - #[test] fn non_ascii_name_is_custom() { let dt = DataTypeName::from("тип"); diff --git a/src/ident.rs b/src/ident.rs index 0656642..a4dd700 100644 --- a/src/ident.rs +++ b/src/ident.rs @@ -104,7 +104,7 @@ impl WriteSql for IdentPart { /// there are more than 10 parts. #[rapidquery_macros::pyclass("Ident", skip_from_py_object, eq, hash)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Ident(SmallVec<[IdentPart; 4]>); +pub struct PyIdent(SmallVec<[IdentPart; 4]>); /// Why building an [`Ident`] failed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -147,7 +147,7 @@ fn is_safe_unquoted(s: &str) -> bool { bytes.all(|c| c.is_ascii_alphanumeric() || c == b'_') } -impl Ident { +impl PyIdent { /// Maximum number of dotted parts. pub const MAX_PARTS: usize = 10; @@ -214,7 +214,7 @@ impl Ident { } } -impl std::str::FromStr for Ident { +impl std::str::FromStr for PyIdent { type Err = ParseIdentError; #[cfg_attr(feature = "nightly", optimize(speed))] @@ -246,14 +246,13 @@ impl std::str::FromStr for Ident { } } -impl WriteSql for Ident { +impl WriteSql for PyIdent { #[inline] fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { write_separated(dialect, out, &self.0, ".") } } - // --------------------------------------------------------------------------- // Python surface (`Ident`, `Unquote`, `Asterisk`) // --------------------------------------------------------------------------- @@ -307,7 +306,7 @@ unsafe fn is_asterisk(obj: *mut pyo3::ffi::PyObject) -> bool { } enum Extracted { - Ident(Ident), + Ident(PyIdent), Part(IdentPart), } @@ -327,7 +326,7 @@ fn extract_arg( } if crate::typeref::check_exact(ptr, crate::typeref::IDENT_TYPE) { - let ident = obj.cast_unchecked::(); + let ident = obj.cast_unchecked::(); return Ok(Extracted::Ident(ident.get().clone())); } @@ -353,7 +352,7 @@ fn extract_arg( /// /// Accepts `Ident`, `str` (one quoted part, including `"*"`), `Unquote`, /// `Asterisk`, and `type[Asterisk]`. Sequences are rejected. -pub struct IdentExtractor(pub Ident); +pub struct IdentExtractor(pub PyIdent); impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for IdentExtractor { type Error = pyo3::PyErr; @@ -364,12 +363,12 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for IdentExtractor { ) -> Result { match extract_arg(obj)? { Extracted::Ident(ident) => Ok(Self(ident)), - Extracted::Part(part) => Ok(Self(Ident::from_one(part))), + Extracted::Part(part) => Ok(Self(PyIdent::from_one(part))), } } } -impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for Ident { +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyIdent { type Error = pyo3::PyErr; #[inline] @@ -381,7 +380,7 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for Ident { } #[pyo3::pymethods] -impl Ident { +impl PyIdent { #[new] #[pyo3(signature = (*parts))] fn __new__(parts: alias::ArgsType) -> pyo3::PyResult { @@ -389,7 +388,7 @@ impl Ident { 0 => Err(parse_err(ParseIdentError::Empty)), 1 => match extract_arg(parts.get_item(0)?.as_borrowed())? { Extracted::Ident(ident) => Ok(ident), - Extracted::Part(part) => Ok(Ident::from_one(part)), + Extracted::Part(part) => Ok(PyIdent::from_one(part)), }, _ => { let mut acc = SmallVec::<[IdentPart; 4]>::new(); @@ -399,7 +398,7 @@ impl Ident { Extracted::Part(part) => acc.push(part), } } - Ident::from_parts(acc).map_err(parse_err) + PyIdent::from_parts(acc).map_err(parse_err) } } } @@ -457,7 +456,7 @@ impl Ident { } } - let inner = Ident::from_parts(selected).map_err(parse_err)?; + let inner = PyIdent::from_parts(selected).map_err(parse_err)?; Ok(pyo3::Bound::new(py, inner)?.into_any()) } } @@ -588,8 +587,8 @@ mod tests { use super::*; - fn ident_from(parts: impl IntoIterator) -> Ident { - Ident::from_parts(parts).unwrap() + fn ident_from(parts: impl IntoIterator) -> PyIdent { + PyIdent::from_parts(parts).unwrap() } fn quote(s: &str) -> IdentPart { @@ -598,7 +597,7 @@ mod tests { #[test] fn from_str_splits_on_dots() { - let ident = Ident::from_str("public.users.id").unwrap(); + let ident = PyIdent::from_str("public.users.id").unwrap(); assert_eq!( ident.parts(), &[quote("public"), quote("users"), quote("id"),] @@ -607,63 +606,63 @@ mod tests { #[test] fn from_str_bare_star_is_asterisk() { - let ident = Ident::from_str("*").unwrap(); + let ident = PyIdent::from_str("*").unwrap(); assert_eq!(ident.parts(), &[IdentPart::Asterisk]); } #[test] fn from_str_table_star_keeps_asterisk_last() { - let ident = Ident::from_str("t.*").unwrap(); + let ident = PyIdent::from_str("t.*").unwrap(); assert_eq!(ident.parts(), &[quote("t"), IdentPart::Asterisk]); } #[test] fn from_str_mid_star_is_demoted_to_quoted() { - let ident = Ident::from_str("*.id").unwrap(); + let ident = PyIdent::from_str("*.id").unwrap(); assert_eq!(ident.parts(), &[quote("*"), quote("id")]); } #[test] fn from_str_rejects_empty_and_empty_chunks() { - assert_eq!(Ident::from_str("").unwrap_err(), ParseIdentError::Empty); + assert_eq!(PyIdent::from_str("").unwrap_err(), ParseIdentError::Empty); assert_eq!( - Ident::from_str("a.").unwrap_err(), + PyIdent::from_str("a.").unwrap_err(), ParseIdentError::EmptyChunk ); assert_eq!( - Ident::from_str(".a").unwrap_err(), + PyIdent::from_str(".a").unwrap_err(), ParseIdentError::EmptyChunk ); assert_eq!( - Ident::from_str("a..b").unwrap_err(), + PyIdent::from_str("a..b").unwrap_err(), ParseIdentError::EmptyChunk ); } #[test] fn from_str_rejects_too_many_parts() { - let ten = (0..Ident::MAX_PARTS) + let ten = (0..PyIdent::MAX_PARTS) .map(|i| format!("p{i}")) .collect::>() .join("."); - assert!(Ident::from_str(&ten).is_ok()); + assert!(PyIdent::from_str(&ten).is_ok()); let eleven = format!("{ten}.extra"); assert_eq!( - Ident::from_str(&eleven).unwrap_err(), + PyIdent::from_str(&eleven).unwrap_err(), ParseIdentError::TooManyParts ); } #[test] fn from_parts_rejects_empty() { - assert_eq!(Ident::from_parts([]).unwrap_err(), ParseIdentError::Empty); + assert_eq!(PyIdent::from_parts([]).unwrap_err(), ParseIdentError::Empty); } #[test] fn from_parts_rejects_empty_quoted_part() { assert_eq!( - Ident::from_parts([IdentPart::Quote(ArcStr::from(""))]).unwrap_err(), + PyIdent::from_parts([IdentPart::Quote(ArcStr::from(""))]).unwrap_err(), ParseIdentError::EmptyChunk ); } @@ -671,7 +670,7 @@ mod tests { #[test] fn from_parts_rejects_asterisk_not_last() { assert_eq!( - Ident::from_parts([IdentPart::Asterisk, quote("id")]).unwrap_err(), + PyIdent::from_parts([IdentPart::Asterisk, quote("id")]).unwrap_err(), ParseIdentError::AsteriskNotLast ); } @@ -720,7 +719,7 @@ mod tests { #[test] fn from_one_is_single_part() { - let ident = Ident::from_one(quote("id")); + let ident = PyIdent::from_one(quote("id")); assert_eq!(ident.len(), 1); assert_eq!(ident.parts(), &[quote("id")]); } @@ -734,7 +733,7 @@ mod tests { #[test] fn prepare_uses_dialect_delimiters() { - fn render(ident: &Ident, dialect: SqlDialect) -> String { + fn render(ident: &PyIdent, dialect: SqlDialect) -> String { let mut buf = String::new(); for (i, part) in ident.parts().iter().enumerate() { if i > 0 { diff --git a/src/lib.rs b/src/lib.rs index 2a9babc..a6eb052 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,10 +19,10 @@ mod _core { use super::dialect::dialect_mod; #[pymodule_export] - use super::ident::{Ident, PyAsterisk, PyUnquote}; + use super::ident::{PyIdent, PyAsterisk, PyUnquote}; #[pymodule_export] - use super::value::Value; + use super::value::PyValue; #[pymodule_init] #[cold] diff --git a/src/sql.rs b/src/sql.rs index a1ebd4e..2231839 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -8,9 +8,9 @@ use hashbrown::HashMap; use pyo3::types::PyAnyMethods; -use crate::internal::alias; -use crate::data_type::Serializer; +use crate::data_type::SerializerEnum; use crate::dialect::SqlDialect; +use crate::internal::alias; /// Error produced while building SQL. #[derive(Debug)] @@ -79,7 +79,7 @@ pub trait SqlWriter: std::fmt::Write { fn push_value( &mut self, dialect: &SqlDialect, - serializer: &Serializer, + serializer: &SerializerEnum, object: &alias::PyObject, ) -> PrepareResult<()>; } @@ -139,7 +139,7 @@ impl<'py> SqlWriter for BufferedSql<'py> { fn push_value( &mut self, dialect: &SqlDialect, - serializer: &Serializer, + serializer: &SerializerEnum, object: &alias::PyObject, ) -> PrepareResult<()> { let bound = object.bind(self.py); @@ -251,7 +251,7 @@ impl SqlWriter for ParameterizedSql<'_> { fn push_value( &mut self, dialect: &SqlDialect, - _serializer: &Serializer, + _serializer: &SerializerEnum, object: &alias::PyObject, ) -> PrepareResult<()> { self.push_param(dialect, object.clone_ref(self.py)); diff --git a/src/typeref.rs b/src/typeref.rs index eefb276..6c3d474 100644 --- a/src/typeref.rs +++ b/src/typeref.rs @@ -112,7 +112,7 @@ fn _initialize_typeref(py: pyo3::Python) { crate::dialect::py::PySqlite => SQLITE_DIALECT_TYPE, crate::dialect::py::PyTeradata => TERADATA_DIALECT_TYPE, // data types / serializers - crate::data_type::DataType => DATATYPE_TYPE, + crate::data_type::PyDataType => DATATYPE_TYPE, crate::data_type::PySerializer => SERIALIZER_TYPE, crate::data_type::PyIntegerSerializer => INTEGER_SERIALIZER_TYPE, crate::data_type::PyFloatSerializer => FLOAT_SERIALIZER_TYPE, @@ -131,11 +131,11 @@ fn _initialize_typeref(py: pyo3::Python) { crate::data_type::PyArraySerializer => ARRAY_SERIALIZER_TYPE, crate::data_type::PyCustomSerializer => CUSTOM_SERIALIZER_TYPE, // ident - crate::ident::Ident => IDENT_TYPE, + crate::ident::PyIdent => IDENT_TYPE, crate::ident::PyUnquote => UNQUOTE_TYPE, crate::ident::PyAsterisk => ASTERISK_TYPE, // value - crate::value::Value => VALUE_TYPE, + crate::value::PyValue => VALUE_TYPE, ); STD_DECIMAL_TYPE = look_up_type_object(c"decimal", c"Decimal"); diff --git a/src/value.rs b/src/value.rs index eeab384..c05e66c 100644 --- a/src/value.rs +++ b/src/value.rs @@ -4,7 +4,7 @@ use std::hash::{Hash, Hasher}; use pyo3::types::{PyAnyMethods, PyStringMethods}; -use crate::data_type::{PySerializer, Serializer, SerializerExtractor, serializer_repr}; +use crate::data_type::{PySerializer, SerializerEnum, SerializerExtractor, serializer_repr}; use crate::dialect::SqlDialect; use crate::internal::alias; use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_sql}; @@ -27,35 +27,31 @@ use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_sql}; /// serializer; the data type is not stored. #[rapidquery_macros::pyclass("Value", skip_from_py_object)] #[derive(Debug, Clone)] -pub struct Value { +pub struct PyValue { pub object: alias::PyObject, - pub serializer: Serializer, + pub serializer: SerializerEnum, } -impl Value { +impl PyValue { #[inline] - pub fn new(object: alias::PyObject, serializer: Serializer) -> Self { + pub fn new(object: alias::PyObject, serializer: SerializerEnum) -> Self { Self { object, serializer } } #[inline] pub fn infer_bound(bound: alias::BoundObject) -> Self { - let serializer = Serializer::infer_bound(&bound); + let serializer = SerializerEnum::infer_bound(&bound); Self::new(bound.unbind(), serializer) } } -impl WriteSql for Value { - fn write_sql( - &self, - dialect: &SqlDialect, - out: &mut W, - ) -> PrepareResult<()> { +impl WriteSql for PyValue { + fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { out.push_value(dialect, &self.serializer, &self.object) } } -impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for Value { +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyValue { type Error = pyo3::PyErr; #[inline] @@ -69,12 +65,12 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for Value { } } - Ok(Value::infer_bound(obj.as_any().clone())) + Ok(PyValue::infer_bound(obj.as_any().clone())) } } #[pyo3::pymethods] -impl Value { +impl PyValue { /// Creates a SQL value. /// /// Args: @@ -99,15 +95,15 @@ impl Value { let inner = value.get().clone(); let slf = match serializer { - Some(SerializerExtractor(s)) => Value::new(inner.object, s), + Some(SerializerExtractor(s)) => PyValue::new(inner.object, s), None => inner, }; return pyo3::PyClassInitializer::from(slf); } let slf = match serializer { - Some(SerializerExtractor(s)) => Value::new(object.unbind(), s), - None => Value::infer_bound(object), + Some(SerializerExtractor(s)) => PyValue::new(object.unbind(), s), + None => PyValue::infer_bound(object), }; pyo3::PyClassInitializer::from(slf) } From 92191586c01267a7205f9a57bba688b4a9249baf Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Fri, 11 Sep 2026 12:15:54 +0330 Subject: [PATCH 28/87] Add more generic types --- rapidquery/_value.pyi | 25 ++++--- rapidquery/data_type.pyi | 124 +++++++++++++++++--------------- src/data_type/mod.rs | 3 +- src/data_type/serializer/mod.rs | 5 +- 4 files changed, 88 insertions(+), 69 deletions(-) diff --git a/rapidquery/_value.pyi b/rapidquery/_value.pyi index eb029ab..5e7b066 100644 --- a/rapidquery/_value.pyi +++ b/rapidquery/_value.pyi @@ -1,10 +1,12 @@ -from typing import Any, Self, final +import typing from rapidquery.data_type import DataType, Serializer from rapidquery.dialect import SqlDialect -@final -class Value: +_T = typing.TypeVar("_T") + +@typing.final +class Value(typing.Generic[_T]): """ A Python object plus the serializer used to emit it as SQL. @@ -18,12 +20,19 @@ class Value: serializer; the data type is not stored. """ + @typing.overload + def __new__( + cls, + object: _T, + /, + ) -> typing.Self: ... + @typing.overload def __new__( cls, - object: Any, - serializer: Serializer | DataType | None = None, + object: _T, + serializer: Serializer[_T] | DataType[_T] | None = None, /, - ) -> Self: + ) -> typing.Self: """ Creates a SQL value. @@ -40,7 +49,7 @@ class Value: """ @property - def object(self) -> Any: + def object(self) -> _T: """ Returns the wrapped Python object. @@ -50,7 +59,7 @@ class Value: """ @property - def serializer(self) -> Serializer: + def serializer(self) -> Serializer[_T]: """ Returns the serializer used for raw SQL literals. diff --git a/rapidquery/data_type.pyi b/rapidquery/data_type.pyi index e974997..04d2984 100644 --- a/rapidquery/data_type.pyi +++ b/rapidquery/data_type.pyi @@ -1,9 +1,15 @@ +import datetime +import decimal +import enum +import typing +import uuid from collections.abc import Callable -from typing import Any, Self, final from rapidquery.dialect import SqlDialect -class Serializer: +_T = typing.TypeVar("_T") + +class Serializer(typing.Generic[_T]): """ Converts a Python object into a dialect-specific SQL literal string. @@ -18,7 +24,7 @@ class Serializer: identity. """ - def serialize(self, dialect: type[SqlDialect] | SqlDialect, object: Any) -> str: + def serialize(self, dialect: type[SqlDialect] | SqlDialect, object: _T) -> str: """ Renders `object` as a SQL literal for `dialect`. @@ -39,8 +45,8 @@ class Serializer: def __hash__(self) -> int: ... def __repr__(self) -> str: ... -@final -class IntegerSerializer(Serializer): +@typing.final +class IntegerSerializer(Serializer[int]): """ Serializes Python integers as bare SQL integer literals. @@ -51,10 +57,10 @@ class IntegerSerializer(Serializer): TypeError: If the value is not an `int`. """ - def __new__(cls) -> Self: ... + def __new__(cls) -> typing.Self: ... -@final -class FloatSerializer(Serializer): +@typing.final +class FloatSerializer(Serializer[int | float | decimal.Decimal]): """ Serializes numeric values as bare SQL numeric literals. @@ -66,10 +72,10 @@ class FloatSerializer(Serializer): ValueError: If the value is a non-finite float (`nan`, `inf`, `-inf`). """ - def __new__(cls) -> Self: ... + def __new__(cls) -> typing.Self: ... -@final -class BooleanSerializer(Serializer): +@typing.final +class BooleanSerializer(Serializer[bool]): """ Serializes Python booleans as dialect boolean literals. @@ -80,10 +86,10 @@ class BooleanSerializer(Serializer): TypeError: If the value is not a `bool`. """ - def __new__(cls) -> Self: ... + def __new__(cls) -> typing.Self: ... -@final -class StringSerializer(Serializer): +@typing.final +class StringSerializer(Serializer[str | enum.Enum]): """ Serializes text as a quoted, escaped SQL string literal. @@ -94,10 +100,10 @@ class StringSerializer(Serializer): TypeError: If the value is not a `str` or a string-valued enum member. """ - def __new__(cls) -> Self: ... + def __new__(cls) -> typing.Self: ... -@final -class UuidSerializer(Serializer): +@typing.final +class UuidSerializer(Serializer[uuid.UUID | str]): """ Serializes UUIDs as quoted SQL string literals. @@ -107,10 +113,10 @@ class UuidSerializer(Serializer): TypeError: If the value is not a `uuid.UUID` or `str`. """ - def __new__(cls) -> Self: ... + def __new__(cls) -> typing.Self: ... -@final -class BytesSerializer(Serializer): +@typing.final +class BytesSerializer(Serializer[bytes]): """ Serializes binary data as a dialect blob/bytea literal. @@ -120,10 +126,10 @@ class BytesSerializer(Serializer): TypeError: If the value is not `bytes`. """ - def __new__(cls) -> Self: ... + def __new__(cls) -> typing.Self: ... -@final -class BitSerializer(Serializer): +@typing.final +class BitSerializer(Serializer[str]): """ Serializes bit strings as dialect bit-string literals. @@ -134,10 +140,10 @@ class BitSerializer(Serializer): ValueError: If the string is empty or contains characters other than `'0'` and `'1'`. """ - def __new__(cls) -> Self: ... + def __new__(cls) -> typing.Self: ... -@final -class JSONSerializer(Serializer): +@typing.final +class JSONSerializer(Serializer[typing.Any]): """ Serializes a JSON-encodable object as a quoted SQL string literal. @@ -151,10 +157,10 @@ class JSONSerializer(Serializer): TypeError: If the value cannot be JSON-encoded. """ - def __new__(cls) -> Self: ... + def __new__(cls) -> typing.Self: ... -@final -class DateSerializer(Serializer): +@typing.final +class DateSerializer(Serializer[datetime.date]): """ Serializes dates as quoted `YYYY-MM-DD` literals. @@ -164,10 +170,10 @@ class DateSerializer(Serializer): TypeError: If the value is not a `datetime.date`. """ - def __new__(cls) -> Self: ... + def __new__(cls) -> typing.Self: ... -@final -class TimeSerializer(Serializer): +@typing.final +class TimeSerializer(Serializer[datetime.time]): """ Serializes times as quoted `HH:MM:SS[.ffffff]` literals. @@ -177,10 +183,10 @@ class TimeSerializer(Serializer): TypeError: If the value is not a `datetime.time`. """ - def __new__(cls) -> Self: ... + def __new__(cls) -> typing.Self: ... -@final -class DatetimeSerializer(Serializer): +@typing.final +class DatetimeSerializer(Serializer[datetime.datetime]): """ Serializes timestamps as quoted SQL timestamp literals. @@ -193,10 +199,10 @@ class DatetimeSerializer(Serializer): OverflowError: If an epoch value is out of range for a timestamp. """ - def __new__(cls) -> Self: ... + def __new__(cls) -> typing.Self: ... -@final -class InetSerializer(Serializer): +@typing.final +class InetSerializer(Serializer[typing.Any]): """ Serializes IP addresses and networks as quoted SQL literals. @@ -208,10 +214,10 @@ class InetSerializer(Serializer): ValueError: If the value is not a valid IPv4/IPv6 address or network. """ - def __new__(cls) -> Self: ... + def __new__(cls) -> typing.Self: ... -@final -class MacAddressSerializer(Serializer): +@typing.final +class MacAddressSerializer(Serializer[str]): """ Serializes MAC addresses as quoted SQL literals. @@ -221,10 +227,10 @@ class MacAddressSerializer(Serializer): ValueError: If the value is not a valid MAC address. """ - def __new__(cls) -> Self: ... + def __new__(cls) -> typing.Self: ... -@final -class UnspecifiedSerializer(Serializer): +@typing.final +class UnspecifiedSerializer(Serializer[typing.Any]): """ Inserts `str(obj)` into SQL with no quoting or type check. @@ -232,10 +238,10 @@ class UnspecifiedSerializer(Serializer): is not a safe SQL value; prefer an explicit serializer. """ - def __new__(cls) -> Self: ... + def __new__(cls) -> typing.Self: ... -@final -class ArraySerializer(Serializer): +@typing.final +class ArraySerializer(Serializer[list[_T] | tuple[_T]]): """ Serializes sequences as `ARRAY[elem, ...]` literals. @@ -244,7 +250,7 @@ class ArraySerializer(Serializer): `ArraySerializer`. """ - def __new__(cls, element: Serializer) -> Self: + def __new__(cls, element: Serializer[_T]) -> typing.Self: """ Creates an array serializer. @@ -254,7 +260,7 @@ class ArraySerializer(Serializer): """ @property - def element(self) -> Serializer: + def element(self) -> Serializer[_T]: """ Returns the serializer used for each array element. @@ -262,8 +268,8 @@ class ArraySerializer(Serializer): The `Serializer` passed to the constructor. """ -@final -class CustomSerializer(Serializer): +@typing.final +class CustomSerializer(Serializer[_T]): """ Serializes values by calling a user-supplied Python function. @@ -272,7 +278,7 @@ class CustomSerializer(Serializer): caller's responsibility to produce a safe fragment. """ - def __new__(cls, function: Callable[[str, Any], str]) -> Self: + def __new__(cls, function: Callable[[str, _T], str]) -> typing.Self: """ Creates a serializer backed by a Python callable. @@ -283,7 +289,7 @@ class CustomSerializer(Serializer): """ @property - def function(self) -> Callable[[str, Any], str]: + def function(self) -> Callable[[str, _T], str]: """ Returns the callable passed to the constructor. @@ -291,8 +297,8 @@ class CustomSerializer(Serializer): The function given as `function` to `__new__`. """ -@final -class DataType: +@typing.final +class DataType(typing.Generic[_T]): """ Represents a SQL data type. @@ -309,7 +315,9 @@ class DataType: name; unrecognized names get `UnspecifiedSerializer`. """ - def __new__(cls, name: str, serializer: Serializer | None = None, /) -> Self: + def __new__( + cls, name: str, serializer: Serializer[_T] | None = None, / + ) -> typing.Self: """ Creates a new SQL data type. @@ -337,7 +345,7 @@ class DataType: """ @property - def serializer(self) -> Serializer: + def serializer(self) -> Serializer[_T]: """ Returns the serializer used to render Python values as SQL literals. @@ -345,7 +353,7 @@ class DataType: The `Serializer` associated with this data type. """ - def serialize(self, dialect: type[SqlDialect] | SqlDialect, object: Any) -> str: + def serialize(self, dialect: type[SqlDialect] | SqlDialect, object: _T) -> str: """ Renders `object` as a SQL literal for `dialect`. diff --git a/src/data_type/mod.rs b/src/data_type/mod.rs index 1aec77d..b68bd6c 100644 --- a/src/data_type/mod.rs +++ b/src/data_type/mod.rs @@ -41,7 +41,8 @@ use crate::internal::alias; from_py_object, module = "rapidquery._core.data_type", eq, - hash + hash, + generic )] #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct PyDataType { diff --git a/src/data_type/serializer/mod.rs b/src/data_type/serializer/mod.rs index 41b132f..be4b7b4 100644 --- a/src/data_type/serializer/mod.rs +++ b/src/data_type/serializer/mod.rs @@ -43,7 +43,7 @@ where pub fn serializer_repr(serializer: &SerializerEnum) -> String { match serializer { SerializerEnum::Array(element) => { - format!("ArraySerializer({}())", class_name(element)) + format!("ArraySerializer({})", serializer_repr(element)) } other => { let name = class_name(other); @@ -70,7 +70,8 @@ pub fn serializer_repr(serializer: &SerializerEnum) -> String { "Serializer", subclass, from_py_object, - module = "rapidquery._core.data_type" + module = "rapidquery._core.data_type", + generic )] #[derive(Debug, Clone)] pub struct PySerializer(pub SerializerEnum); From 9b1d25701808281c95b586addbcfe2c53e22670f Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Fri, 11 Sep 2026 13:52:34 +0330 Subject: [PATCH 29/87] Make Value generic --- src/value.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/value.rs b/src/value.rs index c05e66c..56eef59 100644 --- a/src/value.rs +++ b/src/value.rs @@ -25,7 +25,7 @@ use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_sql}; /// Passing an existing `Value` keeps its object (and its serializer, unless /// a new one is given). A `DataType` as `serializer` contributes only its /// serializer; the data type is not stored. -#[rapidquery_macros::pyclass("Value", skip_from_py_object)] +#[rapidquery_macros::pyclass("Value", skip_from_py_object, generic)] #[derive(Debug, Clone)] pub struct PyValue { pub object: alias::PyObject, From 5183f8412d99c1de4fc937e9488c53da1c2b07bc Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Sat, 12 Sep 2026 16:36:44 +0330 Subject: [PATCH 30/87] Implement Expr (not optimized, not stable) --- AGENTS.md | 7 +- Cargo.lock | 17 + Cargo.toml | 5 + rapidquery/__init__.py | 14 +- rapidquery/_ident.py | 11 - rapidquery/_ident.pyi | 130 ------ rapidquery/_value.py | 9 - rapidquery/_value.pyi | 92 ----- rapidquery/expr.py | 41 ++ rapidquery/expr.pyi | 218 ++++++++++ src/expr/enum_.rs | 868 ++++++++++++++++++++++++++++++++++++++++ src/expr/func.rs | 195 +++++++++ src/{ => expr}/ident.rs | 217 ++++++---- src/expr/mod.rs | 205 ++++++++++ src/expr/nodes.rs | 580 +++++++++++++++++++++++++++ src/expr/tests.rs | 224 +++++++++++ src/{ => expr}/value.rs | 128 +++--- src/lib.rs | 8 +- src/sql.rs | 16 +- src/typeref.rs | 39 +- tests/test_expr.py | 148 +++++++ 21 files changed, 2755 insertions(+), 417 deletions(-) delete mode 100644 rapidquery/_ident.py delete mode 100644 rapidquery/_ident.pyi delete mode 100644 rapidquery/_value.py delete mode 100644 rapidquery/_value.pyi create mode 100644 rapidquery/expr.py create mode 100644 rapidquery/expr.pyi create mode 100644 src/expr/enum_.rs create mode 100644 src/expr/func.rs rename src/{ => expr}/ident.rs (85%) create mode 100644 src/expr/mod.rs create mode 100644 src/expr/nodes.rs create mode 100644 src/expr/tests.rs rename src/{ => expr}/value.rs (68%) create mode 100644 tests/test_expr.py diff --git a/AGENTS.md b/AGENTS.md index 55dbd7d..8e9cfef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,19 +37,18 @@ Keep these `refactor/v1` divergences from the PR (do not revert them): ``` src/dialect/ SqlDialect enum + engines + tests; py.rs class tree src/data_type/ DataType, interned names, Serializer enum; py.rs class tree -src/ident.rs Ident, IdentPart, tests + Unquote/Asterisk pyclasses -src/value.rs Value (#[pyclass] on the same type) +src/expr/ ExprEnum + PyExpr ABC; ident.rs / value.rs / func / nodes src/sql.rs BufferedSql, ParameterizedSql, WriteSql src/internal/ aliases, JSON dumps helper, string utils, submodule helper src/typeref.rs cached PyTypeObject pointers -rapidquery/ Python package: _ident.py, _value.py, data_type.py, dialect.py +rapidquery/ Python package: expr.py, data_type.py, dialect.py rapidquery-macros/ Proc-macros tests/ Python tests (ident, value, dialect, data_type, serializers) backup/, backup-py/ gitignored v0 snapshots — read-only target/, .venv/ Generated — do not commit or edit ``` -Rust unit tests live next to the types (`data_type/tests.rs`, `dialect/tests.rs`, `ident.rs`, `internal/utils/strings.rs`). +Rust unit tests live next to the types (`data_type/tests.rs`, `dialect/tests.rs`, `expr/ident.rs`, `expr/tests.rs`, `internal/utils/strings.rs`). Reference the PR without checking it out as the working tree: `git show origin/migrate:`. Do not copy files wholesale if this branch already renamed or cleaned them. diff --git a/Cargo.lock b/Cargo.lock index 3911425..ec28c8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -366,7 +366,9 @@ dependencies = [ "parking_lot", "pyo3", "rapidquery-macros", + "sea-query", "smallvec", + "sqlparser", ] [[package]] @@ -399,6 +401,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sea-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "546040c653a705e60ec65ecd3191a809603734bebbc225775916dea9ae409b31" + [[package]] name = "shlex" version = "2.0.1" @@ -417,6 +425,15 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +[[package]] +name = "sqlparser" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c6d1b651dc4edf07eead2a0c6c78016ce971bc2c10da5266861b13f25e7cec" +dependencies = [ + "log", +] + [[package]] name = "syn" version = "2.0.117" diff --git a/Cargo.toml b/Cargo.toml index 7e75091..98688a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,11 @@ mac_address = { version = "1.1", default-features = false } once_cell = { version = "1.21.4", default-features = false } smallvec = { version = "1.16.0", default-features = false } +# ONLY for inspiring the source +sqlparser = { version = "0.62.0", default-features = false } +# ONLY for inspiring the source +sea-query = { version = "1.0.2", default-features = false } + [features] default = ["extension-module"] extension-module = ["pyo3/extension-module"] diff --git a/rapidquery/__init__.py b/rapidquery/__init__.py index 69246f7..ad0a99d 100644 --- a/rapidquery/__init__.py +++ b/rapidquery/__init__.py @@ -2,16 +2,22 @@ from . import data_type as data_type from . import dialect as dialect -from ._ident import Asterisk as Asterisk -from ._ident import Ident as Ident -from ._ident import Unquote as Unquote -from ._value import Value as Value +from . import expr as expr +from .expr import Asterisk as Asterisk +from .expr import Expr as Expr +from .expr import Func as Func +from .expr import Ident as Ident +from .expr import Unquote as Unquote +from .expr import Value as Value __all__ = [ "Asterisk", + "Expr", + "Func", "Ident", "Unquote", "Value", "data_type", "dialect", + "expr", ] diff --git a/rapidquery/_ident.py b/rapidquery/_ident.py deleted file mode 100644 index 98f8b1b..0000000 --- a/rapidquery/_ident.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -SQL identifiers. -""" - -from ._core import Asterisk, Ident, Unquote - -__all__ = [ - "Asterisk", - "Ident", - "Unquote", -] diff --git a/rapidquery/_ident.pyi b/rapidquery/_ident.pyi deleted file mode 100644 index 697d098..0000000 --- a/rapidquery/_ident.pyi +++ /dev/null @@ -1,130 +0,0 @@ -from collections.abc import Iterator, Sequence -from typing import Self, final, overload - -from rapidquery.dialect import SqlDialect - -@final -class Unquote: - """ - Marker for an unquoted identifier part. - - The body is emitted without dialect delimiters. It must be a simple - ASCII name (`[A-Za-z_][A-Za-z0-9_]*`). This is **not** a raw-SQL - fragment. - """ - - def __new__(cls, name: str, /) -> Self: - """ - Creates an unquoted identifier part. - - Args: - name: The identifier body, unquoted. - - Raises: - ValueError: If `name` is empty or not a simple ASCII identifier - (letters, digits, underscore; first character not a digit). - """ - - @property - def name(self) -> str: - """ - Returns the unquoted identifier body. - - Returns: - The string passed to the constructor. - """ - - def __eq__(self, other: object) -> bool: ... - def __hash__(self) -> int: ... - def __repr__(self) -> str: ... - -@final -class Asterisk: - """ - SQL wildcard `*`. - - Pass the class (`Asterisk`) or an instance (`Asterisk()`) as an - identifier part. It must be the last part of an `Ident` (`t.*`). - `Ident("*")` is a quoted column named star, not this wildcard. - """ - - def __new__(cls) -> Self: ... - def __eq__(self, other: object) -> bool: ... - def __hash__(self) -> int: ... - def __repr__(self) -> str: ... - -@final -class Ident: - """ - Represents a SQL identifier (`schema.table.column`). - - An identifier is a sequence of parts. Each constructor argument is one - part: a quoted name (`str`), an `Unquote` name, an `Asterisk` wildcard, - or a nested `Ident` whose parts are flattened. - """ - - def __new__(cls, *parts: str | Unquote | Ident | Asterisk | type[Asterisk]) -> Self: - """ - Creates a SQL identifier. - - Args: - parts: One argument per part. A `str` is always quoted, including - `"*"` (a column named star). Pass `Asterisk` for the SQL - wildcard. Dotted names are `Ident("schema", "table")`, not - `Ident("schema.table")`. - - **Note**: Sequences are not accepted. `Ident(["table", "id"])` - is a `TypeError`; write `Ident("table", "id")`. - - Raises: - TypeError: If an argument is not `str`, `Unquote`, `Asterisk`, or - `Ident`. - ValueError: If there are no parts, a part is empty, `Asterisk` is - not last, an `Unquote` body is not a simple ASCII name, or - there are more than 10 parts. - """ - - def __len__(self) -> int: ... - @overload - def __getitem__(self, index: int) -> str | Unquote | Asterisk: ... - @overload - def __getitem__(self, index: slice) -> Ident: ... - def __getitem__(self, index: int | slice) -> str | Unquote | Asterisk | Ident: - """ - Returns a part by index, or a sub-identifier by slice. - - Args: - index: An `int` (negative indices count from the end) or a - `slice`. - - Returns: - A `str`, `Unquote`, or `Asterisk` for an integer index. An - `Ident` for a slice. - - Raises: - IndexError: If the integer index is out of range. - ValueError: If the slice selects no parts. - """ - - def __iter__(self) -> Iterator[str | Unquote | Asterisk]: ... - def to_list(self) -> Sequence[str | Unquote | Asterisk]: - """ - Returns the parts as a list (`str`, `Unquote`, or `Asterisk`). - """ - - def to_sql(self, dialect: type[SqlDialect] | SqlDialect) -> str: - """ - Renders this identifier as SQL for `dialect`. - - Args: - dialect: A dialect class (`PostgreSQL`) or instance - (`PostgreSQL()`). - - Returns: - The dotted identifier with each part quoted or emitted according - to its kind. - """ - - def __eq__(self, other: object) -> bool: ... - def __hash__(self) -> int: ... - def __repr__(self) -> str: ... diff --git a/rapidquery/_value.py b/rapidquery/_value.py deleted file mode 100644 index c669c93..0000000 --- a/rapidquery/_value.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -SQL values. -""" - -from ._core import Value - -__all__ = [ - "Value", -] diff --git a/rapidquery/_value.pyi b/rapidquery/_value.pyi deleted file mode 100644 index 5e7b066..0000000 --- a/rapidquery/_value.pyi +++ /dev/null @@ -1,92 +0,0 @@ -import typing - -from rapidquery.data_type import DataType, Serializer -from rapidquery.dialect import SqlDialect - -_T = typing.TypeVar("_T") - -@typing.final -class Value(typing.Generic[_T]): - """ - A Python object plus the serializer used to emit it as SQL. - - `Value` does not convert the object until SQL is built. Parameterized - statements bind the object as a placeholder. Raw SQL runs the serializer - (`None` becomes `NULL`). - - If `serializer` is omitted, one is inferred from the object's type. - Passing an existing `Value` keeps its object (and its serializer, unless - a new one is given). A `DataType` as `serializer` contributes only its - serializer; the data type is not stored. - """ - - @typing.overload - def __new__( - cls, - object: _T, - /, - ) -> typing.Self: ... - @typing.overload - def __new__( - cls, - object: _T, - serializer: Serializer[_T] | DataType[_T] | None = None, - /, - ) -> typing.Self: - """ - Creates a SQL value. - - Args: - object: The Python object to bind or serialize. If this is already - a `Value`, its object is reused. - serializer: How to render `object` as a SQL literal. A `DataType` - is accepted and only its serializer is kept. If omitted, - a serializer is inferred from `object` (or kept, when - `object` is already a `Value`). - - Raises: - TypeError: If `serializer` is not a `Serializer` or `DataType`. - """ - - @property - def object(self) -> _T: - """ - Returns the wrapped Python object. - - Returns: - The object passed to the constructor (or taken from an existing - `Value`). - """ - - @property - def serializer(self) -> Serializer[_T]: - """ - Returns the serializer used for raw SQL literals. - - Returns: - The `Serializer` stored on this value. Not used when the value - is bound as a parameter. - """ - - def to_sql(self, dialect: type[SqlDialect] | SqlDialect) -> str: - """ - Renders this value as a SQL literal for `dialect`. - - Raw path: `None` becomes `NULL`; otherwise the serializer writes a - literal. This is not the parameterized form. - - Args: - dialect: A dialect class (`PostgreSQL`) or instance - (`PostgreSQL()`). - - Returns: - A SQL fragment suitable for embedding in a statement. - - Raises: - TypeError: If `object` is not an accepted type for the serializer. - ValueError: If `object` is the right type but cannot be represented. - """ - - def __eq__(self, other: object) -> bool: ... - def __hash__(self) -> int: ... - def __repr__(self) -> str: ... diff --git a/rapidquery/expr.py b/rapidquery/expr.py new file mode 100644 index 0000000..4ba0970 --- /dev/null +++ b/rapidquery/expr.py @@ -0,0 +1,41 @@ +""" +SQL expressions. +""" + +from ._core.expr import Asterisk as Asterisk +from ._core.expr import Between as Between +from ._core.expr import Binary as Binary +from ._core.expr import Case as Case +from ._core.expr import Cast as Cast +from ._core.expr import Custom as Custom +from ._core.expr import Expr as Expr +from ._core.expr import Func as Func +from ._core.expr import Ident as Ident +from ._core.expr import InList as InList +from ._core.expr import Is as Is +from ._core.expr import IsDistinctFrom as IsDistinctFrom +from ._core.expr import Like as Like +from ._core.expr import TupleExpr as TupleExpr +from ._core.expr import Unary as Unary +from ._core.expr import Unquote as Unquote +from ._core.expr import Value as Value + +__all__ = [ + "Asterisk", + "Between", + "Binary", + "Case", + "Cast", + "Custom", + "Expr", + "Func", + "Ident", + "InList", + "Is", + "IsDistinctFrom", + "Like", + "TupleExpr", + "Unary", + "Unquote", + "Value", +] diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi new file mode 100644 index 0000000..4d6fb33 --- /dev/null +++ b/rapidquery/expr.pyi @@ -0,0 +1,218 @@ +import typing +from collections.abc import Iterable, Iterator, Sequence +from typing import Self, final, overload + +from rapidquery.data_type import DataType, Serializer +from rapidquery.dialect import SqlDialect + +class Expr: + """ + Abstract SQL expression. + + Concrete nodes are frozen subclasses (``Ident``, ``Value``, ``Func``, + ``Binary``, …). This base class cannot be instantiated. + + Future methods (``to_sql``, ``eq``, ``like``, ``between``, ``in_``, + ``is_null``, ``cast``, ``case``, ``custom``, arithmetic/logic) will live + here so every subclass inherits them. Prefer named methods over v0-style + dunders that return ``Expr``. + """ + + def __eq__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... + +@final +class Unquote: + def __new__(cls, name: str, /) -> Self: ... + @property + def name(self) -> str: ... + def __eq__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... + +@final +class Asterisk: + def __new__(cls) -> Self: ... + def __eq__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... + +@final +class Ident(Expr): + def __new__( + cls, *parts: str | Unquote | Ident | Asterisk | type[Asterisk] + ) -> Self: ... + def __len__(self) -> int: ... + @overload + def __getitem__(self, index: int) -> str | Unquote | Asterisk: ... + @overload + def __getitem__(self, index: slice) -> Ident: ... + def __iter__(self) -> Iterator[str | Unquote | Asterisk]: ... + def to_list(self) -> Sequence[str | Unquote | Asterisk]: ... + def to_sql(self, dialect: type[SqlDialect] | SqlDialect) -> str: ... + +_T = typing.TypeVar("_T") + +@final +class Value(Expr, typing.Generic[_T]): + @overload + def __new__(cls, object: _T, /) -> typing.Self: ... + @overload + def __new__( + cls, + object: _T, + serializer: Serializer[_T] | DataType[_T] | None = None, + /, + ) -> typing.Self: ... + @property + def object(self) -> _T: ... + @property + def serializer(self) -> Serializer[_T]: ... + def to_sql(self, dialect: type[SqlDialect] | SqlDialect) -> str: ... + +@final +class Func(Expr): + def __new__( + cls, + name: str | Ident | Unquote, + *args: object, + distinct: bool | None = None, + filter: object | None = None, + parentheses: bool = True, + ) -> Self: ... + @property + def name(self) -> Ident: ... + @property + def args(self) -> tuple[Expr, ...] | None: ... + @property + def filter(self) -> Expr | None: ... + +@final +class TupleExpr(Expr): + def __new__(cls, *items: object) -> Self: ... + @property + def items(self) -> tuple[Expr, ...]: ... + +@final +class Unary(Expr): + def __new__(cls, op: str, expr: object, /) -> Self: ... + @property + def op(self) -> str: ... + @property + def expr(self) -> Expr: ... + +@final +class Binary(Expr): + def __new__(cls, left: object, op: str, right: object, /) -> Self: ... + @property + def left(self) -> Expr: ... + @property + def op(self) -> str: ... + @property + def right(self) -> Expr: ... + +@final +class Is(Expr): + def __new__(cls, expr: object, kind: str, *, negated: bool = False) -> Self: ... + @property + def expr(self) -> Expr: ... + @property + def kind(self) -> str: ... + @property + def negated(self) -> bool: ... + +@final +class IsDistinctFrom(Expr): + def __new__(cls, left: object, right: object, *, negated: bool = False) -> Self: ... + @property + def left(self) -> Expr: ... + @property + def right(self) -> Expr: ... + @property + def negated(self) -> bool: ... + +@final +class InList(Expr): + def __new__( + cls, expr: object, list: Iterable[object], *, negated: bool = False + ) -> Self: ... + @property + def expr(self) -> Expr: ... + @property + def list(self) -> tuple[Expr, ...]: ... + @property + def negated(self) -> bool: ... + +@final +class Between(Expr): + def __new__( + cls, + expr: object, + low: object, + high: object, + *, + negated: bool = False, + ) -> Self: ... + @property + def expr(self) -> Expr: ... + @property + def low(self) -> Expr: ... + @property + def high(self) -> Expr: ... + @property + def negated(self) -> bool: ... + +@final +class Like(Expr): + def __new__( + cls, + expr: object, + pattern: object, + *, + kind: str = "LIKE", + negated: bool = False, + escape: str | None = None, + ) -> Self: ... + @property + def expr(self) -> Expr: ... + @property + def pattern(self) -> Expr: ... + @property + def kind(self) -> str: ... + @property + def negated(self) -> bool: ... + @property + def escape(self) -> str | None: ... + +@final +class Cast(Expr): + def __new__(cls, expr: object, data_type: DataType, /) -> Self: ... + @property + def expr(self) -> Expr: ... + @property + def data_type(self) -> DataType: ... + +@final +class Case(Expr): + def __new__( + cls, + when_then: Iterable[tuple[object, object]], + *, + operand: object | None = None, + else_: object | None = None, + ) -> Self: ... + @property + def operand(self) -> Expr | None: ... + @property + def when_then(self) -> tuple[tuple[Expr, Expr], ...]: ... + @property + def else_(self) -> Expr | None: ... + +@final +class Custom(Expr): + def __new__(cls, sql: str, *args: object) -> Self: ... + @property + def sql(self) -> str: ... + @property + def args(self) -> tuple[Expr, ...]: ... diff --git a/src/expr/enum_.rs b/src/expr/enum_.rs new file mode 100644 index 0000000..49540c6 --- /dev/null +++ b/src/expr/enum_.rs @@ -0,0 +1,868 @@ +//! Engine expression tree. +//! +//! Python classes are empty views over [`ExprEnum`] (Serializer-style). +//! Query-shaped variants are omitted until `Query` exists. + +use std::fmt::Write as _; +use std::hash::{Hash, Hasher}; + +use arcstr::ArcStr; +use pyo3::types::PyAnyMethods; + +use crate::data_type::{PyDataType, serializer_repr}; +use crate::dialect::SqlDialect; +use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; + +use super::func::Func; +use super::ident::Ident; +use super::value::Value; + +/// How duplicate values are treated inside function argument lists. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DuplicateTreatment { + /// Consider only unique values. + Distinct, + /// Retain all duplicate values (the default). + All, +} + +impl DuplicateTreatment { + pub(crate) fn as_sql(self) -> &'static str { + match self { + Self::Distinct => "DISTINCT", + Self::All => "ALL", + } + } +} + +/// Unary operator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum UnaryOp { + Not, + Plus, + Minus, + BitNot, +} + +impl UnaryOp { + pub fn parse(s: &str) -> Option { + match s { + "NOT" | "not" | "Not" => Some(Self::Not), + "+" => Some(Self::Plus), + "-" => Some(Self::Minus), + "~" => Some(Self::BitNot), + _ => None, + } + } + + pub fn as_sql(self) -> &'static str { + match self { + Self::Not => "NOT", + Self::Plus => "+", + Self::Minus => "-", + Self::BitNot => "~", + } + } +} + +/// Binary operator. Unknown spellings become [`BinaryOp::Custom`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum BinaryOp { + Add, + Sub, + Mul, + Div, + Mod, + Eq, + NotEq, + Lt, + LtEq, + Gt, + GtEq, + And, + Or, + Xor, + BitAnd, + BitOr, + BitXor, + LShift, + RShift, + StringConcat, + Custom(ArcStr), +} + +impl BinaryOp { + pub fn parse(s: &str) -> Self { + match s { + "+" => Self::Add, + "-" => Self::Sub, + "*" => Self::Mul, + "/" => Self::Div, + "%" => Self::Mod, + "=" => Self::Eq, + "<>" | "!=" => Self::NotEq, + "<" => Self::Lt, + "<=" => Self::LtEq, + ">" => Self::Gt, + ">=" => Self::GtEq, + "AND" | "and" | "And" => Self::And, + "OR" | "or" | "Or" => Self::Or, + "XOR" | "xor" | "Xor" => Self::Xor, + "&" => Self::BitAnd, + "|" => Self::BitOr, + "^" => Self::BitXor, + "<<" => Self::LShift, + ">>" => Self::RShift, + "||" => Self::StringConcat, + other => Self::Custom(ArcStr::from(other)), + } + } + + pub fn as_sql(&self) -> &str { + match self { + Self::Add => "+", + Self::Sub => "-", + Self::Mul => "*", + Self::Div => "/", + Self::Mod => "%", + Self::Eq => "=", + Self::NotEq => "<>", + Self::Lt => "<", + Self::LtEq => "<=", + Self::Gt => ">", + Self::GtEq => ">=", + Self::And => "AND", + Self::Or => "OR", + Self::Xor => "XOR", + Self::BitAnd => "&", + Self::BitOr => "|", + Self::BitXor => "^", + Self::LShift => "<<", + Self::RShift => ">>", + Self::StringConcat => "||", + Self::Custom(s) => s.as_str(), + } + } +} + +/// Right-hand side of `IS [NOT] …`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum IsKind { + Null, + True, + False, + Unknown, +} + +impl IsKind { + pub fn parse(s: &str) -> Option { + if s.eq_ignore_ascii_case("NULL") { + Some(Self::Null) + } else if s.eq_ignore_ascii_case("TRUE") { + Some(Self::True) + } else if s.eq_ignore_ascii_case("FALSE") { + Some(Self::False) + } else if s.eq_ignore_ascii_case("UNKNOWN") { + Some(Self::Unknown) + } else { + None + } + } + + pub fn as_sql(self) -> &'static str { + match self { + Self::Null => "NULL", + Self::True => "TRUE", + Self::False => "FALSE", + Self::Unknown => "UNKNOWN", + } + } +} + +/// Pattern-matching operator family. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum LikeKind { + Like, + ILike, + SimilarTo, + RLike, +} + +impl LikeKind { + pub fn parse(s: &str) -> Option { + if s.eq_ignore_ascii_case("LIKE") { + Some(Self::Like) + } else if s.eq_ignore_ascii_case("ILIKE") { + Some(Self::ILike) + } else if s.eq_ignore_ascii_case("SIMILAR TO") { + Some(Self::SimilarTo) + } else if s.eq_ignore_ascii_case("RLIKE") { + Some(Self::RLike) + } else { + None + } + } + + pub fn as_sql(self) -> &'static str { + match self { + Self::Like => "LIKE", + Self::ILike => "ILIKE", + Self::SimilarTo => "SIMILAR TO", + Self::RLike => "RLIKE", + } + } +} + +/// SQL expression tree. +/// +/// TODO: `Exists`, `InSubquery`, `Subquery`, and `Quantified` (`ANY` / +/// `ALL` / `SOME`) once `Query` exists. +#[derive(Debug, Clone, PartialEq)] +pub enum ExprEnum { + Ident(Ident), + Value(Value), + Tuple(Vec), + Unary { + op: UnaryOp, + expr: Box, + }, + Binary { + left: Box, + op: BinaryOp, + right: Box, + }, + Function(Func), + Is { + expr: Box, + negated: bool, + kind: IsKind, + }, + IsDistinctFrom { + left: Box, + right: Box, + negated: bool, + }, + InList { + expr: Box, + list: Vec, + negated: bool, + }, + Between { + expr: Box, + negated: bool, + low: Box, + high: Box, + }, + Like { + kind: LikeKind, + negated: bool, + expr: Box, + pattern: Box, + escape: Option, + }, + Cast { + expr: Box, + data_type: PyDataType, + }, + Case { + operand: Option>, + when_then: Vec<(ExprEnum, ExprEnum)>, + else_result: Option>, + }, + Custom { + sql: String, + args: Vec, + }, +} + +impl ExprEnum { + /// Lower is tighter binding. + fn precedence(&self) -> u8 { + match self { + Self::Ident(_) + | Self::Value(_) + | Self::Tuple(_) + | Self::Function(_) + | Self::Cast { .. } + | Self::Case { .. } + | Self::Custom { .. } => 0, + Self::Unary { .. } => 1, + Self::Binary { op, .. } => match op { + BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => 2, + BinaryOp::Add | BinaryOp::Sub => 3, + BinaryOp::BitAnd + | BinaryOp::BitOr + | BinaryOp::BitXor + | BinaryOp::LShift + | BinaryOp::RShift + | BinaryOp::StringConcat => 4, + BinaryOp::Eq + | BinaryOp::NotEq + | BinaryOp::Lt + | BinaryOp::LtEq + | BinaryOp::Gt + | BinaryOp::GtEq + | BinaryOp::Custom(_) => 5, + BinaryOp::And => 6, + BinaryOp::Xor => 7, + BinaryOp::Or => 8, + }, + Self::Is { .. } + | Self::IsDistinctFrom { .. } + | Self::InList { .. } + | Self::Between { .. } + | Self::Like { .. } => 5, + } + } + + fn write_operand( + &self, + dialect: &SqlDialect, + out: &mut W, + parent_prec: u8, + paren_if_equal: bool, + ) -> PrepareResult<()> { + let child_prec = self.precedence(); + let paren = child_prec > parent_prec || (paren_if_equal && child_prec == parent_prec); + if paren { + out.push_char('('); + } + self.write_sql(dialect, out)?; + if paren { + out.push_char(')'); + } + Ok(()) + } + + pub(crate) fn py_eq( + &self, + py: pyo3::Python<'_>, + other: &Self, + ) -> pyo3::PyResult { + match (self, other) { + (Self::Value(a), Self::Value(b)) => { + if a.serializer != b.serializer { + return Ok(false); + } + a.object.bind(py).eq(b.object.bind(py)) + } + (a, b) => Ok(a == b), + } + } + + pub(crate) fn py_hash(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + std::mem::discriminant(self).hash(&mut hasher); + match self { + Self::Ident(ident) => ident.hash(&mut hasher), + Self::Value(value) => { + value.object.bind(py).hash()?.hash(&mut hasher); + value.serializer.hash(&mut hasher); + } + Self::Tuple(items) => { + items.len().hash(&mut hasher); + for item in items { + item.py_hash(py)?.hash(&mut hasher); + } + } + Self::Unary { op, expr } => { + op.hash(&mut hasher); + expr.py_hash(py)?.hash(&mut hasher); + } + Self::Binary { left, op, right } => { + left.py_hash(py)?.hash(&mut hasher); + op.hash(&mut hasher); + right.py_hash(py)?.hash(&mut hasher); + } + Self::Function(func) => { + func.name.hash(&mut hasher); + // args/filter hashed via recursive walk in python_repr-level identity + match &func.args { + super::func::FuncArguments::None => 0u8.hash(&mut hasher), + super::func::FuncArguments::List { + duplicate_treatment, + args, + } => { + 1u8.hash(&mut hasher); + duplicate_treatment.hash(&mut hasher); + args.len().hash(&mut hasher); + for arg in args { + arg.py_hash(py)?.hash(&mut hasher); + } + } + } + match &func.filter { + Some(expr) => { + 1u8.hash(&mut hasher); + expr.py_hash(py)?.hash(&mut hasher); + } + None => 0u8.hash(&mut hasher), + } + } + Self::Is { + expr, + negated, + kind, + } => { + expr.py_hash(py)?.hash(&mut hasher); + negated.hash(&mut hasher); + kind.hash(&mut hasher); + } + Self::IsDistinctFrom { + left, + right, + negated, + } => { + left.py_hash(py)?.hash(&mut hasher); + right.py_hash(py)?.hash(&mut hasher); + negated.hash(&mut hasher); + } + Self::InList { + expr, + list, + negated, + } => { + expr.py_hash(py)?.hash(&mut hasher); + negated.hash(&mut hasher); + list.len().hash(&mut hasher); + for item in list { + item.py_hash(py)?.hash(&mut hasher); + } + } + Self::Between { + expr, + negated, + low, + high, + } => { + expr.py_hash(py)?.hash(&mut hasher); + negated.hash(&mut hasher); + low.py_hash(py)?.hash(&mut hasher); + high.py_hash(py)?.hash(&mut hasher); + } + Self::Like { + kind, + negated, + expr, + pattern, + escape, + } => { + kind.hash(&mut hasher); + negated.hash(&mut hasher); + expr.py_hash(py)?.hash(&mut hasher); + pattern.py_hash(py)?.hash(&mut hasher); + escape.hash(&mut hasher); + } + Self::Cast { expr, data_type } => { + expr.py_hash(py)?.hash(&mut hasher); + data_type.hash(&mut hasher); + } + Self::Case { + operand, + when_then, + else_result, + } => { + match operand { + Some(expr) => { + 1u8.hash(&mut hasher); + expr.py_hash(py)?.hash(&mut hasher); + } + None => 0u8.hash(&mut hasher), + } + when_then.len().hash(&mut hasher); + for (when, then) in when_then { + when.py_hash(py)?.hash(&mut hasher); + then.py_hash(py)?.hash(&mut hasher); + } + match else_result { + Some(expr) => { + 1u8.hash(&mut hasher); + expr.py_hash(py)?.hash(&mut hasher); + } + None => 0u8.hash(&mut hasher), + } + } + Self::Custom { sql, args } => { + sql.hash(&mut hasher); + args.len().hash(&mut hasher); + for arg in args { + arg.py_hash(py)?.hash(&mut hasher); + } + } + } + Ok(hasher.finish()) + } + + pub(crate) fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + match self { + Self::Ident(ident) => Ok(ident.python_repr()), + Self::Value(value) => value.python_repr(py), + Self::Tuple(items) => { + let mut buf = String::from("TupleExpr("); + join_reprs(py, &mut buf, items)?; + buf.push(')'); + Ok(buf) + } + Self::Unary { op, expr } => Ok(format!( + "Unary({:?}, {})", + op.as_sql(), + expr.python_repr(py)? + )), + Self::Binary { left, op, right } => Ok(format!( + "Binary({}, {:?}, {})", + left.python_repr(py)?, + op.as_sql(), + right.python_repr(py)? + )), + Self::Function(func) => func.python_repr(py), + Self::Is { + expr, + negated, + kind, + } => { + let mut buf = format!("Is({}, {:?}", expr.python_repr(py)?, kind.as_sql()); + if *negated { + buf.push_str(", negated=True"); + } + buf.push(')'); + Ok(buf) + } + Self::IsDistinctFrom { + left, + right, + negated, + } => { + let mut buf = format!( + "IsDistinctFrom({}, {}", + left.python_repr(py)?, + right.python_repr(py)? + ); + if *negated { + buf.push_str(", negated=True"); + } + buf.push(')'); + Ok(buf) + } + Self::InList { + expr, + list, + negated, + } => { + let mut buf = format!("InList({}, (", expr.python_repr(py)?); + join_reprs(py, &mut buf, list)?; + buf.push(')'); + if *negated { + buf.push_str(", negated=True"); + } + buf.push(')'); + Ok(buf) + } + Self::Between { + expr, + negated, + low, + high, + } => { + let mut buf = format!( + "Between({}, {}, {}", + expr.python_repr(py)?, + low.python_repr(py)?, + high.python_repr(py)? + ); + if *negated { + buf.push_str(", negated=True"); + } + buf.push(')'); + Ok(buf) + } + Self::Like { + kind, + negated, + expr, + pattern, + escape, + } => { + let mut buf = format!( + "Like({}, {}", + expr.python_repr(py)?, + pattern.python_repr(py)? + ); + if *kind != LikeKind::Like { + write!(buf, ", kind={:?}", kind.as_sql()).unwrap(); + } + if *negated { + buf.push_str(", negated=True"); + } + if let Some(c) = escape { + write!(buf, ", escape={c:?}").unwrap(); + } + buf.push(')'); + Ok(buf) + } + Self::Cast { expr, data_type } => Ok(format!( + "Cast({}, DataType({:?}, {}))", + expr.python_repr(py)?, + data_type.name.as_str(), + serializer_repr(&data_type.serializer) + )), + Self::Case { + operand, + when_then, + else_result, + } => { + let mut buf = String::from("Case(["); + for (i, (when, then)) in when_then.iter().enumerate() { + if i > 0 { + buf.push_str(", "); + } + write!( + buf, + "({}, {})", + when.python_repr(py)?, + then.python_repr(py)? + ) + .unwrap(); + } + buf.push(']'); + if let Some(operand) = operand { + write!(buf, ", operand={}", operand.python_repr(py)?).unwrap(); + } + if let Some(else_result) = else_result { + write!(buf, ", else_={}", else_result.python_repr(py)?).unwrap(); + } + buf.push(')'); + Ok(buf) + } + Self::Custom { sql, args } => { + let mut buf = format!("Custom({sql:?}"); + for arg in args { + buf.push_str(", "); + buf.push_str(&arg.python_repr(py)?); + } + buf.push(')'); + Ok(buf) + } + } + } +} + +fn join_reprs( + py: pyo3::Python<'_>, + buf: &mut String, + items: &[ExprEnum], +) -> pyo3::PyResult<()> { + for (i, item) in items.iter().enumerate() { + if i > 0 { + buf.push_str(", "); + } + buf.push_str(&item.python_repr(py)?); + } + Ok(()) +} + +impl WriteSql for ExprEnum { + fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { + match self { + Self::Ident(ident) => ident.write_sql(dialect, out), + Self::Value(value) => value.write_sql(dialect, out), + Self::Tuple(items) => { + out.push_char('('); + write_separated(dialect, out, items, ", ")?; + out.push_char(')'); + Ok(()) + } + Self::Unary { op, expr } => { + match op { + UnaryOp::Not => { + out.push_str("NOT "); + } + UnaryOp::Plus | UnaryOp::Minus | UnaryOp::BitNot => { + out.push_str(op.as_sql()); + } + } + expr.write_operand(dialect, out, self.precedence(), true) + } + Self::Binary { left, op, right } => { + let prec = self.precedence(); + left.write_operand(dialect, out, prec, false)?; + out.push_char(' '); + out.push_str(op.as_sql()); + out.push_char(' '); + right.write_operand(dialect, out, prec, true) + } + Self::Function(func) => func.write_sql(dialect, out), + Self::Is { + expr, + negated, + kind, + } => { + expr.write_operand(dialect, out, self.precedence(), false)?; + if *negated { + out.push_str(" IS NOT "); + } else { + out.push_str(" IS "); + } + out.push_str(kind.as_sql()); + Ok(()) + } + Self::IsDistinctFrom { + left, + right, + negated, + } => { + let prec = self.precedence(); + left.write_operand(dialect, out, prec, false)?; + if *negated { + out.push_str(" IS NOT DISTINCT FROM "); + } else { + out.push_str(" IS DISTINCT FROM "); + } + right.write_operand(dialect, out, prec, true) + } + Self::InList { + expr, + list, + negated, + } => { + expr.write_operand(dialect, out, self.precedence(), false)?; + if *negated { + out.push_str(" NOT IN ("); + } else { + out.push_str(" IN ("); + } + write_separated(dialect, out, list, ", ")?; + out.push_char(')'); + Ok(()) + } + Self::Between { + expr, + negated, + low, + high, + } => { + let prec = self.precedence(); + expr.write_operand(dialect, out, prec, false)?; + if *negated { + out.push_str(" NOT BETWEEN "); + } else { + out.push_str(" BETWEEN "); + } + low.write_operand(dialect, out, prec, true)?; + out.push_str(" AND "); + high.write_operand(dialect, out, prec, true) + } + Self::Like { + kind, + negated, + expr, + pattern, + escape, + } => { + let prec = self.precedence(); + expr.write_operand(dialect, out, prec, false)?; + out.push_char(' '); + if *negated { + out.push_str("NOT "); + } + out.push_str(kind.as_sql()); + out.push_char(' '); + pattern.write_operand(dialect, out, prec, true)?; + if let Some(c) = escape { + out.push_str(" ESCAPE "); + let raw = c.encode_utf8(&mut [0; 4]).to_string(); + dialect + .write_string_literal(out, &raw) + .map_err(|_| crate::sql::PrepareError::Write)?; + } + Ok(()) + } + Self::Cast { expr, data_type } => { + out.push_str("CAST("); + expr.write_sql(dialect, out)?; + out.push_str(" AS "); + data_type.write_sql(dialect, out)?; + out.push_char(')'); + Ok(()) + } + Self::Case { + operand, + when_then, + else_result, + } => { + out.push_str("CASE"); + if let Some(operand) = operand { + out.push_char(' '); + operand.write_sql(dialect, out)?; + } + for (when, then) in when_then { + out.push_str(" WHEN "); + when.write_sql(dialect, out)?; + out.push_str(" THEN "); + then.write_sql(dialect, out)?; + } + if let Some(else_result) = else_result { + out.push_str(" ELSE "); + else_result.write_sql(dialect, out)?; + } + out.push_str(" END"); + Ok(()) + } + Self::Custom { sql, args } => write_custom(sql, args, dialect, out), + } + } +} + +fn write_custom( + sql: &str, + args: &[ExprEnum], + dialect: &SqlDialect, + out: &mut W, +) -> PrepareResult<()> { + let bytes = sql.as_bytes(); + let mut i = 0; + let mut start = 0; + let mut used = 0usize; + while i < bytes.len() { + if bytes[i] == b'{' { + if i + 1 < bytes.len() && bytes[i + 1] == b'{' { + out.push_str(&sql[start..i]); + out.push_char('{'); + i += 2; + start = i; + continue; + } + if i + 1 < bytes.len() && bytes[i + 1] == b'}' { + out.push_str(&sql[start..i]); + if used >= args.len() { + return Err(crate::sql::PrepareError::CustomPlaceholders { + expected: used + 1, + got: args.len(), + }); + } + args[used].write_sql(dialect, out)?; + used += 1; + i += 2; + start = i; + continue; + } + } else if bytes[i] == b'}' + && i + 1 < bytes.len() + && bytes[i + 1] == b'}' + { + out.push_str(&sql[start..i]); + out.push_char('}'); + i += 2; + start = i; + continue; + } + i += 1; + } + out.push_str(&sql[start..]); + if used != args.len() { + return Err(crate::sql::PrepareError::CustomPlaceholders { + expected: used, + got: args.len(), + }); + } + Ok(()) +} diff --git a/src/expr/func.rs b/src/expr/func.rs new file mode 100644 index 0000000..add687c --- /dev/null +++ b/src/expr/func.rs @@ -0,0 +1,195 @@ +use pyo3::types::{PyAnyMethods, PyTupleMethods}; + +use super::ident::{Ident, IdentExtractor}; +use crate::internal::alias; +use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; + +use super::enum_::{DuplicateTreatment, ExprEnum}; +use super::{ExprExtractor, PyExpr, subclass}; + +#[derive(Clone, Debug, PartialEq)] +pub enum FuncArguments { + /// Used for special functions like `CURRENT_TIMESTAMP` that are invoked + /// without parentheses. + None, + /// A normal function argument list, including `DISTINCT` / `ALL`. + List { + duplicate_treatment: Option, + args: Vec, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Func { + pub name: Ident, + pub args: FuncArguments, + pub filter: Option>, +} + +impl Func { + pub(crate) fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut buf = format!("Func({})", self.name.python_repr()); + match &self.args { + FuncArguments::None => buf.push_str(", parentheses=False"), + FuncArguments::List { + duplicate_treatment, + args, + } => { + for arg in args { + buf.push_str(", "); + buf.push_str(&arg.python_repr(py)?); + } + if let Some(dt) = duplicate_treatment { + match dt { + DuplicateTreatment::Distinct => buf.push_str(", distinct=True"), + DuplicateTreatment::All => buf.push_str(", distinct=False"), + } + } + } + } + if let Some(filter) = &self.filter { + buf.push_str(", filter="); + buf.push_str(&filter.python_repr(py)?); + } + buf.push(')'); + Ok(buf) + } +} + +impl WriteSql for Func { + fn write_sql( + &self, + dialect: &crate::dialect::SqlDialect, + out: &mut W, + ) -> PrepareResult<()> { + self.name.write_sql(dialect, out)?; + match &self.args { + FuncArguments::None => {} + FuncArguments::List { + duplicate_treatment, + args, + } => { + out.push_char('('); + if let Some(dt) = duplicate_treatment { + out.push_str(dt.as_sql()); + if !args.is_empty() { + out.push_char(' '); + } + } + write_separated(dialect, out, args, ", ")?; + out.push_char(')'); + } + } + if let Some(filter) = &self.filter { + out.push_str(" FILTER (WHERE "); + filter.write_sql(dialect, out)?; + out.push_char(')'); + } + Ok(()) + } +} + +/// SQL function call. +/// +/// `name` is an identifier (`SUM`, `schema.fn`). `parentheses=False` emits +/// `CURRENT_TIMESTAMP` with no argument list. `distinct=True` inserts +/// `DISTINCT` inside the parentheses. +/// +/// Args: +/// name: Function name (`str`, `Ident`, `Unquote`). +/// args: Positional argument expressions. A bare `str` is a column. +/// distinct: `True` → `DISTINCT`, `False` → `ALL`, omitted → neither. +/// filter: Optional `FILTER (WHERE …)` predicate. +/// parentheses: `False` omits `()`. +#[rapidquery_macros::pyclass( + "Func", + skip_from_py_object, + extends = PyExpr, + module = "rapidquery._core.expr" +)] +#[derive(Debug, Default)] +pub struct PyFunc; + +#[inline] +fn func_inner<'a>(slf: &'a pyo3::PyRef<'a, PyFunc>) -> &'a Func { + match &slf.as_super().0 { + ExprEnum::Function(func) => func, + _ => unreachable!("Func instance always holds ExprEnum::Function"), + } +} + +#[pyo3::pymethods] +impl PyFunc { + #[new] + #[pyo3(signature = (name, *args, distinct = None, filter = None, parentheses = true))] + fn __new__( + name: IdentExtractor, + args: alias::ArgsType<'_>, + distinct: Option, + filter: Option, + parentheses: bool, + ) -> pyo3::PyResult> { + let func_args = if !parentheses { + if !args.is_empty() || distinct.is_some() { + return rapidquery_macros::pyerr!( + Err(PyValueError), + "parentheses=False cannot be combined with arguments or distinct" + ); + } + FuncArguments::None + } else { + let mut extracted = Vec::with_capacity(args.len()); + for item in args.iter() { + extracted.push(item.extract::()?.0); + } + FuncArguments::List { + duplicate_treatment: match distinct { + Some(true) => Some(DuplicateTreatment::Distinct), + Some(false) => Some(DuplicateTreatment::All), + None => None, + }, + args: extracted, + } + }; + + Ok(subclass::(ExprEnum::Function(Func { + name: name.0, + args: func_args, + filter: filter.map(|ExprExtractor(expr)| Box::new(expr)), + }))) + } + + #[getter] + fn get_name( + slf: pyo3::PyRef<'_, Self>, + ) -> pyo3::PyResult> { + PyExpr(ExprEnum::Ident(func_inner(&slf).name.clone())).into_subclasses(slf.py()) + } + + #[getter] + fn get_args( + slf: pyo3::PyRef<'_, Self>, + ) -> pyo3::PyResult> { + let py = slf.py(); + match &func_inner(&slf).args { + FuncArguments::None => Ok(py.None().into_bound(py).into_any()), + FuncArguments::List { args, .. } => { + let mut items = Vec::with_capacity(args.len()); + for arg in args { + items.push(PyExpr(arg.clone()).into_subclasses(py)?); + } + Ok(pyo3::types::PyTuple::new(py, items)?.into_any()) + } + } + } + + #[getter] + fn get_filter( + slf: pyo3::PyRef<'_, Self>, + ) -> pyo3::PyResult>> { + match &func_inner(&slf).filter { + Some(expr) => Ok(Some(PyExpr((**expr).clone()).into_subclasses(slf.py())?)), + None => Ok(None), + } + } +} diff --git a/src/ident.rs b/src/expr/ident.rs similarity index 85% rename from src/ident.rs rename to src/expr/ident.rs index a4dd700..e7ca157 100644 --- a/src/ident.rs +++ b/src/expr/ident.rs @@ -89,22 +89,8 @@ impl WriteSql for IdentPart { /// /// Never empty. At most [`Ident::MAX_PARTS`] parts. An [`IdentPart::Asterisk`] /// may appear only as the last part. -/// -/// Args: -/// parts: One argument per part. A `str` is always quoted, including -/// `"*"` (a column named star). Pass `Asterisk` for the SQL -/// wildcard. Dotted names are `Ident("schema", "table")`, not -/// `Ident("schema.table")`. -/// -/// Raises: -/// TypeError: If an argument is not `str`, `Unquote`, `Asterisk`, or -/// `Ident`. -/// ValueError: If there are no parts, a part is empty, `Asterisk` is -/// not last, an `Unquote` body is not a simple ASCII name, or -/// there are more than 10 parts. -#[rapidquery_macros::pyclass("Ident", skip_from_py_object, eq, hash)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct PyIdent(SmallVec<[IdentPart; 4]>); +pub struct Ident(SmallVec<[IdentPart; 4]>); /// Why building an [`Ident`] failed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -147,7 +133,7 @@ fn is_safe_unquoted(s: &str) -> bool { bytes.all(|c| c.is_ascii_alphanumeric() || c == b'_') } -impl PyIdent { +impl Ident { /// Maximum number of dotted parts. pub const MAX_PARTS: usize = 10; @@ -175,6 +161,25 @@ impl PyIdent { self.0 } + pub(crate) fn python_repr(&self) -> String { + use std::fmt::Write; + + let mut result = String::from("Ident("); + let parts = self.parts(); + for (index, item) in parts.iter().enumerate() { + match item { + IdentPart::Quote(x) => write!(result, "{x:?}").unwrap(), + IdentPart::Unquote(x) => write!(result, "Unquote({x:?})").unwrap(), + IdentPart::Asterisk => result.push_str("Asterisk"), + } + if index + 1 < parts.len() { + result.push_str(", "); + } + } + result.push(')'); + result + } + /// One already-validated part. Prefer this over [`from_parts`] for a /// single `str` / `Unquote` / `Asterisk`. #[inline] @@ -214,7 +219,7 @@ impl PyIdent { } } -impl std::str::FromStr for PyIdent { +impl std::str::FromStr for Ident { type Err = ParseIdentError; #[cfg_attr(feature = "nightly", optimize(speed))] @@ -246,7 +251,7 @@ impl std::str::FromStr for PyIdent { } } -impl WriteSql for PyIdent { +impl WriteSql for Ident { #[inline] fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { write_separated(dialect, out, &self.0, ".") @@ -306,7 +311,7 @@ unsafe fn is_asterisk(obj: *mut pyo3::ffi::PyObject) -> bool { } enum Extracted { - Ident(PyIdent), + Ident(Ident), Part(IdentPart), } @@ -326,8 +331,13 @@ fn extract_arg( } if crate::typeref::check_exact(ptr, crate::typeref::IDENT_TYPE) { - let ident = obj.cast_unchecked::(); - return Ok(Extracted::Ident(ident.get().clone())); + let expr = obj.cast_unchecked::(); + match &expr.get().0 { + super::ExprEnum::Ident(ident) => { + return Ok(Extracted::Ident(ident.clone())); + } + _ => unreachable!("Ident instance always holds ExprEnum::Ident"), + } } if crate::typeref::check_exact(ptr, crate::typeref::UNQUOTE_TYPE) { @@ -352,7 +362,7 @@ fn extract_arg( /// /// Accepts `Ident`, `str` (one quoted part, including `"*"`), `Unquote`, /// `Asterisk`, and `type[Asterisk]`. Sequences are rejected. -pub struct IdentExtractor(pub PyIdent); +pub struct IdentExtractor(pub Ident); impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for IdentExtractor { type Error = pyo3::PyErr; @@ -363,32 +373,65 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for IdentExtractor { ) -> Result { match extract_arg(obj)? { Extracted::Ident(ident) => Ok(Self(ident)), - Extracted::Part(part) => Ok(Self(PyIdent::from_one(part))), + Extracted::Part(part) => Ok(Self(Ident::from_one(part))), } } } -impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyIdent { - type Error = pyo3::PyErr; +/// Represents a SQL identifier (`schema.table.column`). +/// +/// An identifier is a sequence of parts. Each constructor argument is one +/// part: a quoted name (`str`), an `Unquote` name, an `Asterisk` wildcard, +/// or a nested `Ident` whose parts are flattened. +/// +/// Never empty. At most 10 parts. An asterisk may appear only as the last +/// part. +/// +/// Args: +/// parts: One argument per part. A `str` is always quoted, including +/// `"*"` (a column named star). Pass `Asterisk` for the SQL +/// wildcard. Dotted names are `Ident("schema", "table")`, not +/// `Ident("schema.table")`. +/// +/// Raises: +/// TypeError: If an argument is not `str`, `Unquote`, `Asterisk`, or +/// `Ident`. +/// ValueError: If there are no parts, a part is empty, `Asterisk` is +/// not last, an `Unquote` body is not a simple ASCII name, or +/// there are more than 10 parts. +#[rapidquery_macros::pyclass( + "Ident", + skip_from_py_object, + extends = super::PyExpr, + module = "rapidquery._core.expr" +)] +#[derive(Debug, Default)] +pub struct PyIdent; - #[inline] - fn extract( - obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, - ) -> Result { - IdentExtractor::extract(obj).map(|IdentExtractor(inner)| inner) +#[inline] +fn ident_inner<'a>(slf: &'a pyo3::PyRef<'a, PyIdent>) -> &'a Ident { + match &slf.as_super().0 { + super::ExprEnum::Ident(ident) => ident, + _ => unreachable!("Ident instance always holds ExprEnum::Ident"), } } +#[inline] +fn wrap_ident(ident: Ident) -> pyo3::PyClassInitializer { + pyo3::PyClassInitializer::from(super::PyExpr(super::ExprEnum::Ident(ident))) + .add_subclass(PyIdent) +} + #[pyo3::pymethods] impl PyIdent { #[new] #[pyo3(signature = (*parts))] - fn __new__(parts: alias::ArgsType) -> pyo3::PyResult { - match parts.len() { - 0 => Err(parse_err(ParseIdentError::Empty)), + fn __new__(parts: alias::ArgsType) -> pyo3::PyResult> { + let ident = match parts.len() { + 0 => return Err(parse_err(ParseIdentError::Empty)), 1 => match extract_arg(parts.get_item(0)?.as_borrowed())? { - Extracted::Ident(ident) => Ok(ident), - Extracted::Part(part) => Ok(PyIdent::from_one(part)), + Extracted::Ident(ident) => ident, + Extracted::Part(part) => Ident::from_one(part), }, _ => { let mut acc = SmallVec::<[IdentPart; 4]>::new(); @@ -398,13 +441,14 @@ impl PyIdent { Extracted::Part(part) => acc.push(part), } } - PyIdent::from_parts(acc).map_err(parse_err) + Ident::from_parts(acc).map_err(parse_err)? } - } + }; + Ok(wrap_ident(ident)) } - fn __len__(&self) -> usize { - self.0.len() + fn __len__(slf: pyo3::PyRef<'_, Self>) -> usize { + ident_inner(&slf).len() } /// Returns a part by index, or a sub-identifier by slice. @@ -421,11 +465,11 @@ impl PyIdent { /// IndexError: If the integer index is out of range. /// ValueError: If the slice selects no parts. fn __getitem__<'py>( - &self, + slf: pyo3::PyRef<'py, Self>, py: pyo3::Python<'py>, index: utils::IndexOrSlice, ) -> pyo3::PyResult> { - let parts = self.parts(); + let parts = ident_inner(&slf).parts(); let len = parts.len() as isize; match index { @@ -456,17 +500,17 @@ impl PyIdent { } } - let inner = PyIdent::from_parts(selected).map_err(parse_err)?; - Ok(pyo3::Bound::new(py, inner)?.into_any()) + let inner = Ident::from_parts(selected).map_err(parse_err)?; + super::PyExpr(super::ExprEnum::Ident(inner)).into_subclasses(py) } } } fn __iter__<'py>( - &self, + slf: pyo3::PyRef<'py, Self>, py: pyo3::Python<'py>, ) -> pyo3::PyResult> { - let items = self + let items = ident_inner(&slf) .parts() .iter() .map(|part| ident_part_to_pyobject(py, part)) @@ -476,8 +520,12 @@ impl PyIdent { } /// Returns the parts as a list (`str`, `Unquote`, or `Asterisk`). - fn to_list(&self, py: pyo3::Python<'_>) -> pyo3::PyResult> { - self.parts() + fn to_list( + slf: pyo3::PyRef<'_, Self>, + py: pyo3::Python<'_>, + ) -> pyo3::PyResult> { + ident_inner(&slf) + .parts() .iter() .map(|part| ident_part_to_pyobject(py, part).map(|bound| bound.unbind())) .collect() @@ -493,32 +541,13 @@ impl PyIdent { /// The dotted identifier with each part quoted or emitted according /// to its kind. fn to_sql( - &self, + slf: pyo3::PyRef<'_, Self>, py: pyo3::Python<'_>, dialect: crate::dialect::py::PySqlDialect, ) -> pyo3::PyResult { - write_sql(py, self, &dialect.0) + write_sql(py, ident_inner(&slf), &dialect.0) .map_err(|e| rapidquery_macros::pyerr!(PyRuntimeError, "{}", e)) } - - fn __repr__(&self) -> String { - use std::fmt::Write; - - let mut result = String::from("Ident("); - let parts = self.parts(); - for (index, item) in parts.iter().enumerate() { - match item { - IdentPart::Quote(x) => write!(result, "{x:?}").unwrap(), - IdentPart::Unquote(x) => write!(result, "Unquote({x:?})").unwrap(), - IdentPart::Asterisk => result.push_str("Asterisk"), - } - if index + 1 < parts.len() { - result.push_str(", "); - } - } - result.push(')'); - result - } } /// Marker for an unquoted identifier part. @@ -533,7 +562,13 @@ impl PyIdent { /// Raises: /// ValueError: If `name` is empty or not a simple ASCII identifier /// (letters, digits, underscore; first character not a digit). -#[rapidquery_macros::pyclass("Unquote", skip_from_py_object, eq, hash)] +#[rapidquery_macros::pyclass( + "Unquote", + skip_from_py_object, + eq, + hash, + module = "rapidquery._core.expr" +)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct PyUnquote(pub arcstr::ArcStr); @@ -565,7 +600,13 @@ impl PyUnquote { /// Pass the class (`Asterisk`) or an instance (`Asterisk()`) as an /// identifier part. It must be the last part of an `Ident` (`t.*`). /// `Ident("*")` is a quoted column named star, not this wildcard. -#[rapidquery_macros::pyclass("Asterisk", skip_from_py_object, eq, hash)] +#[rapidquery_macros::pyclass( + "Asterisk", + skip_from_py_object, + eq, + hash, + module = "rapidquery._core.expr" +)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct PyAsterisk; @@ -587,8 +628,8 @@ mod tests { use super::*; - fn ident_from(parts: impl IntoIterator) -> PyIdent { - PyIdent::from_parts(parts).unwrap() + fn ident_from(parts: impl IntoIterator) -> Ident { + Ident::from_parts(parts).unwrap() } fn quote(s: &str) -> IdentPart { @@ -597,7 +638,7 @@ mod tests { #[test] fn from_str_splits_on_dots() { - let ident = PyIdent::from_str("public.users.id").unwrap(); + let ident = Ident::from_str("public.users.id").unwrap(); assert_eq!( ident.parts(), &[quote("public"), quote("users"), quote("id"),] @@ -606,63 +647,63 @@ mod tests { #[test] fn from_str_bare_star_is_asterisk() { - let ident = PyIdent::from_str("*").unwrap(); + let ident = Ident::from_str("*").unwrap(); assert_eq!(ident.parts(), &[IdentPart::Asterisk]); } #[test] fn from_str_table_star_keeps_asterisk_last() { - let ident = PyIdent::from_str("t.*").unwrap(); + let ident = Ident::from_str("t.*").unwrap(); assert_eq!(ident.parts(), &[quote("t"), IdentPart::Asterisk]); } #[test] fn from_str_mid_star_is_demoted_to_quoted() { - let ident = PyIdent::from_str("*.id").unwrap(); + let ident = Ident::from_str("*.id").unwrap(); assert_eq!(ident.parts(), &[quote("*"), quote("id")]); } #[test] fn from_str_rejects_empty_and_empty_chunks() { - assert_eq!(PyIdent::from_str("").unwrap_err(), ParseIdentError::Empty); + assert_eq!(Ident::from_str("").unwrap_err(), ParseIdentError::Empty); assert_eq!( - PyIdent::from_str("a.").unwrap_err(), + Ident::from_str("a.").unwrap_err(), ParseIdentError::EmptyChunk ); assert_eq!( - PyIdent::from_str(".a").unwrap_err(), + Ident::from_str(".a").unwrap_err(), ParseIdentError::EmptyChunk ); assert_eq!( - PyIdent::from_str("a..b").unwrap_err(), + Ident::from_str("a..b").unwrap_err(), ParseIdentError::EmptyChunk ); } #[test] fn from_str_rejects_too_many_parts() { - let ten = (0..PyIdent::MAX_PARTS) + let ten = (0..Ident::MAX_PARTS) .map(|i| format!("p{i}")) .collect::>() .join("."); - assert!(PyIdent::from_str(&ten).is_ok()); + assert!(Ident::from_str(&ten).is_ok()); let eleven = format!("{ten}.extra"); assert_eq!( - PyIdent::from_str(&eleven).unwrap_err(), + Ident::from_str(&eleven).unwrap_err(), ParseIdentError::TooManyParts ); } #[test] fn from_parts_rejects_empty() { - assert_eq!(PyIdent::from_parts([]).unwrap_err(), ParseIdentError::Empty); + assert_eq!(Ident::from_parts([]).unwrap_err(), ParseIdentError::Empty); } #[test] fn from_parts_rejects_empty_quoted_part() { assert_eq!( - PyIdent::from_parts([IdentPart::Quote(ArcStr::from(""))]).unwrap_err(), + Ident::from_parts([IdentPart::Quote(ArcStr::from(""))]).unwrap_err(), ParseIdentError::EmptyChunk ); } @@ -670,7 +711,7 @@ mod tests { #[test] fn from_parts_rejects_asterisk_not_last() { assert_eq!( - PyIdent::from_parts([IdentPart::Asterisk, quote("id")]).unwrap_err(), + Ident::from_parts([IdentPart::Asterisk, quote("id")]).unwrap_err(), ParseIdentError::AsteriskNotLast ); } @@ -719,7 +760,7 @@ mod tests { #[test] fn from_one_is_single_part() { - let ident = PyIdent::from_one(quote("id")); + let ident = Ident::from_one(quote("id")); assert_eq!(ident.len(), 1); assert_eq!(ident.parts(), &[quote("id")]); } @@ -733,7 +774,7 @@ mod tests { #[test] fn prepare_uses_dialect_delimiters() { - fn render(ident: &PyIdent, dialect: SqlDialect) -> String { + fn render(ident: &Ident, dialect: SqlDialect) -> String { let mut buf = String::new(); for (i, part) in ident.parts().iter().enumerate() { if i > 0 { diff --git a/src/expr/mod.rs b/src/expr/mod.rs new file mode 100644 index 0000000..e18ab5b --- /dev/null +++ b/src/expr/mod.rs @@ -0,0 +1,205 @@ +//! SQL expressions: engine enum plus a Serializer-style Python class tree. +//! +//! [`PyExpr`] holds [`ExprEnum`]. Concrete nodes (`Ident`, `Binary`, …) are +//! empty frozen subclasses. Shared methods live on the base. +//! +//! Suggested future methods (not implemented): `to_sql` / `prepare`; `eq` / +//! `ne` / `lt` / `le` / `gt` / `ge`; `add` / `sub` / `mul` / `div` / `mod`; +//! `and_` / `or_` / `xor` / `not_`; `bit_and` / `bit_or`; `like` / `ilike` / +//! `between` / `in_` / `is_null` / `is_distinct_from` / `cast`; `case`; +//! `custom`. Prefer named methods over v0 dunders that return `Expr`. + +mod enum_; +mod func; +mod ident; +mod nodes; +mod value; + +#[cfg(test)] +mod tests; + +pub use enum_::{BinaryOp, DuplicateTreatment, ExprEnum, IsKind, LikeKind, UnaryOp}; +pub use func::{Func, FuncArguments, PyFunc}; +pub use ident::{Ident, IdentExtractor, IdentPart, ParseIdentError, PyAsterisk, PyIdent, PyUnquote}; +pub use nodes::{ + PyBetween, PyBinary, PyCase, PyCast, PyCustom, PyInList, PyIs, PyIsDistinctFrom, PyLike, + PyTupleExpr, PyUnary, +}; +pub use value::{PyValue, Value}; + +use pyo3::types::PyTupleMethods; + +use crate::internal::alias; +use crate::internal::utils; + +/// Abstract SQL expression. +/// +/// Concrete nodes are frozen subclasses (`Ident`, `Value`, `Func`, +/// `Binary`, …). This base class cannot be instantiated. +/// +/// Future methods (`to_sql`, `eq`, `like`, `between`, `in_`, `is_null`, +/// `cast`, `case`, `custom`, arithmetic/logic) will live here so every +/// subclass inherits them. Prefer named methods over v0-style dunders that +/// return `Expr`. +#[rapidquery_macros::pyclass( + "Expr", + subclass, + skip_from_py_object, + module = "rapidquery._core.expr" +)] +#[derive(Debug, Clone)] +pub struct PyExpr(pub ExprEnum); + +fn wrap<'py, T>( + py: pyo3::Python<'py>, + base: PyExpr, +) -> pyo3::PyResult> +where + T: pyo3::PyClass + Default, +{ + pyo3::Bound::new( + py, + pyo3::PyClassInitializer::from(base).add_subclass(T::default()), + ) + .map(|x| x.into_any()) +} + +pub(crate) fn subclass>( + inner: ExprEnum, +) -> pyo3::PyClassInitializer { + pyo3::PyClassInitializer::from(PyExpr(inner)).add_subclass(T::default()) +} + +impl PyExpr { + /// Wrap `self` as the matching Python subclass. + pub fn into_subclasses<'py>( + self, + py: pyo3::Python<'py>, + ) -> pyo3::PyResult> { + match &self.0 { + ExprEnum::Ident(_) => wrap::(py, self), + ExprEnum::Value(_) => wrap::(py, self), + ExprEnum::Tuple(_) => wrap::(py, self), + ExprEnum::Unary { .. } => wrap::(py, self), + ExprEnum::Binary { .. } => wrap::(py, self), + ExprEnum::Function(_) => wrap::(py, self), + ExprEnum::Is { .. } => wrap::(py, self), + ExprEnum::IsDistinctFrom { .. } => wrap::(py, self), + ExprEnum::InList { .. } => wrap::(py, self), + ExprEnum::Between { .. } => wrap::(py, self), + ExprEnum::Like { .. } => wrap::(py, self), + ExprEnum::Cast { .. } => wrap::(py, self), + ExprEnum::Case { .. } => wrap::(py, self), + ExprEnum::Custom { .. } => wrap::(py, self), + } + } +} + +#[pyo3::pymethods] +impl PyExpr { + fn __repr__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + self.0.python_repr(py) + } + + fn __eq__(&self, py: pyo3::Python<'_>, other: &pyo3::Bound<'_, Self>) -> pyo3::PyResult { + self.0.py_eq(py, &other.get().0) + } + + fn __hash__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + self.0.py_hash(py) + } +} + +/// Ident-priority extractor: a bare `str` becomes [`ExprEnum::Ident`]. +pub struct ExprExtractor(pub ExprEnum); + +/// Literal-priority extractor: a bare `str` becomes [`ExprEnum::Value`]. +pub struct LiteralExprExtractor(pub ExprEnum); + +fn extract_expr( + obj: pyo3::prelude::Borrowed<'_, '_, pyo3::prelude::PyAny>, + str_as_ident: bool, +) -> pyo3::PyResult { + let ptr = obj.as_ptr(); + unsafe { + if crate::typeref::check(ptr, crate::typeref::EXPR_TYPE) { + let expr = obj.cast_unchecked::(); + return Ok(expr.get().0.clone()); + } + + if crate::typeref::check_exact(ptr, crate::typeref::UNQUOTE_TYPE) { + let unquote = obj.cast_unchecked::(); + let part = IdentPart::unquoted(unquote.get().0.as_str()).map_err(|err| { + rapidquery_macros::pyerr!(PyValueError, "{}", err) + })?; + return Ok(ExprEnum::Ident(Ident::from_one(part))); + } + + if is_asterisk(ptr) { + return Ok(ExprEnum::Ident(Ident::from_one(IdentPart::Asterisk))); + } + + if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { + let s = utils::py_str(obj.py(), ptr)?; + if str_as_ident { + let part = IdentPart::quoted(s).map_err(|err| { + rapidquery_macros::pyerr!(PyValueError, "{}", err) + })?; + return Ok(ExprEnum::Ident(Ident::from_one(part))); + } + return Ok(ExprEnum::Value(Value::infer_bound(obj.as_any().clone()))); + } + + if pyo3::ffi::PyTuple_CheckExact(ptr) == 1 { + let tuple = obj.cast_unchecked::(); + let mut items = Vec::with_capacity(tuple.len()); + for item in tuple.iter() { + items.push(extract_expr(item.as_borrowed(), str_as_ident)?); + } + return Ok(ExprEnum::Tuple(items)); + } + } + + Ok(ExprEnum::Value(Value::infer_bound(obj.as_any().clone()))) +} + +/// `true` when `obj` is `Asterisk` or `type[Asterisk]`. +unsafe fn is_asterisk(obj: *mut pyo3::ffi::PyObject) -> bool { + let type_ptr = if pyo3::ffi::PyType_CheckExact(obj) == 1 { + obj.cast::() + } else { + pyo3::ffi::Py_TYPE(obj) + }; + type_ptr == crate::typeref::ASTERISK_TYPE +} + +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for ExprExtractor { + type Error = pyo3::PyErr; + + #[inline] + fn extract( + obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, + ) -> Result { + extract_expr(obj, true).map(Self) + } +} + +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for LiteralExprExtractor { + type Error = pyo3::PyErr; + + #[inline] + fn extract( + obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, + ) -> Result { + extract_expr(obj, false).map(Self) + } +} + +#[pyo3::pymodule(name = "expr", gil_used = false)] +pub mod expr_mod { + #[pymodule_export] + use super::{ + PyAsterisk, PyBetween, PyBinary, PyCase, PyCast, PyCustom, PyExpr, PyFunc, PyIdent, + PyInList, PyIs, PyIsDistinctFrom, PyLike, PyTupleExpr, PyUnary, PyUnquote, PyValue, + }; +} diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs new file mode 100644 index 0000000..fc3a98b --- /dev/null +++ b/src/expr/nodes.rs @@ -0,0 +1,580 @@ +use pyo3::types::{PyAnyMethods, PyTupleMethods}; + +use crate::data_type::PyDataType; +use crate::internal::alias; + +use super::enum_::{BinaryOp, ExprEnum, IsKind, LikeKind, UnaryOp}; +use super::{ExprExtractor, LiteralExprExtractor, PyExpr, subclass}; + +#[inline] +fn child<'py>( + py: pyo3::Python<'py>, + expr: &ExprEnum, +) -> pyo3::PyResult> { + PyExpr(expr.clone()).into_subclasses(py) +} + +macro_rules! empty_node { + ($(#[$meta:meta])* $name:ident => $pyname:literal) => { + $(#[$meta])* + #[rapidquery_macros::pyclass( + $pyname, + skip_from_py_object, + extends = PyExpr, + module = "rapidquery._core.expr" + )] + #[derive(Debug, Default)] + pub struct $name; + }; +} + +empty_node! { + /// Parenthesized row / tuple expression `(a, b, …)`. + PyTupleExpr => "TupleExpr" +} +empty_node! { + /// Unary expression (`NOT x`, `-x`, `+x`, `~x`). + PyUnary => "Unary" +} +empty_node! { + /// Binary expression (`a + b`, `a AND b`, custom operators). + PyBinary => "Binary" +} +empty_node! { + /// `expr IS [NOT] {NULL|TRUE|FALSE|UNKNOWN}`. + PyIs => "Is" +} +empty_node! { + /// `a IS [NOT] DISTINCT FROM b`. + PyIsDistinctFrom => "IsDistinctFrom" +} +empty_node! { + /// `expr [NOT] IN (… )`. + PyInList => "InList" +} +empty_node! { + /// `expr [NOT] BETWEEN low AND high`. + PyBetween => "Between" +} +empty_node! { + /// `expr [NOT] LIKE / ILIKE / SIMILAR TO / RLIKE pattern`. + PyLike => "Like" +} +empty_node! { + /// `CAST(expr AS type)`. + PyCast => "Cast" +} +empty_node! { + /// `CASE [operand] WHEN … THEN … [ELSE …] END`. + PyCase => "Case" +} +empty_node! { + /// Escape-hatch SQL template. `{}` slots are filled by argument + /// expressions. `{{` / `}}` emit literal braces. + PyCustom => "Custom" +} + +#[pyo3::pymethods] +impl PyTupleExpr { + #[new] + #[pyo3(signature = (*items))] + fn __new__(items: alias::ArgsType<'_>) -> pyo3::PyResult> { + let mut exprs = Vec::with_capacity(items.len()); + for item in items.iter() { + exprs.push(item.extract::()?.0); + } + Ok(subclass::(ExprEnum::Tuple(exprs))) + } + + #[getter] + fn get_items(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let py = slf.py(); + let ExprEnum::Tuple(items) = &slf.as_super().0 else { + unreachable!("TupleExpr instance always holds ExprEnum::Tuple"); + }; + let mut out = Vec::with_capacity(items.len()); + for item in items { + out.push(child(py, item)?); + } + Ok(pyo3::types::PyTuple::new(py, out)?.into_any()) + } +} + +#[pyo3::pymethods] +impl PyUnary { + #[new] + #[pyo3(signature = (op, expr, /))] + fn __new__( + op: &str, + expr: ExprExtractor, + ) -> pyo3::PyResult> { + let Some(op) = UnaryOp::parse(op) else { + return rapidquery_macros::pyerr!( + Err(PyValueError), + "unknown unary operator {op:?}" + ); + }; + Ok(subclass::(ExprEnum::Unary { + op, + expr: Box::new(expr.0), + })) + } + + #[getter] + fn get_op(slf: pyo3::PyRef<'_, Self>) -> &'static str { + let ExprEnum::Unary { op, .. } = &slf.as_super().0 else { + unreachable!("Unary instance always holds ExprEnum::Unary"); + }; + op.as_sql() + } + + #[getter] + fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let ExprEnum::Unary { expr, .. } = &slf.as_super().0 else { + unreachable!("Unary instance always holds ExprEnum::Unary"); + }; + child(slf.py(), expr) + } +} + +#[pyo3::pymethods] +impl PyBinary { + #[new] + #[pyo3(signature = (left, op, right, /))] + fn __new__( + left: ExprExtractor, + op: &str, + right: ExprExtractor, + ) -> pyo3::PyClassInitializer { + subclass::(ExprEnum::Binary { + left: Box::new(left.0), + op: BinaryOp::parse(op), + right: Box::new(right.0), + }) + } + + #[getter] + fn get_left(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let ExprEnum::Binary { left, .. } = &slf.as_super().0 else { + unreachable!("Binary instance always holds ExprEnum::Binary"); + }; + child(slf.py(), left) + } + + #[getter] + fn get_op(slf: pyo3::PyRef<'_, Self>) -> String { + let ExprEnum::Binary { op, .. } = &slf.as_super().0 else { + unreachable!("Binary instance always holds ExprEnum::Binary"); + }; + op.as_sql().to_owned() + } + + #[getter] + fn get_right(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let ExprEnum::Binary { right, .. } = &slf.as_super().0 else { + unreachable!("Binary instance always holds ExprEnum::Binary"); + }; + child(slf.py(), right) + } +} + +#[pyo3::pymethods] +impl PyIs { + #[new] + #[pyo3(signature = (expr, kind, *, negated = false))] + fn __new__( + expr: ExprExtractor, + kind: &str, + negated: bool, + ) -> pyo3::PyResult> { + let Some(kind) = IsKind::parse(kind) else { + return rapidquery_macros::pyerr!( + Err(PyValueError), + "unknown IS kind {kind:?}; expected NULL, TRUE, FALSE, or UNKNOWN" + ); + }; + Ok(subclass::(ExprEnum::Is { + expr: Box::new(expr.0), + negated, + kind, + })) + } + + #[getter] + fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let ExprEnum::Is { expr, .. } = &slf.as_super().0 else { + unreachable!("Is instance always holds ExprEnum::Is"); + }; + child(slf.py(), expr) + } + + #[getter] + fn get_kind(slf: pyo3::PyRef<'_, Self>) -> &'static str { + let ExprEnum::Is { kind, .. } = &slf.as_super().0 else { + unreachable!("Is instance always holds ExprEnum::Is"); + }; + kind.as_sql() + } + + #[getter] + fn get_negated(slf: pyo3::PyRef<'_, Self>) -> bool { + let ExprEnum::Is { negated, .. } = &slf.as_super().0 else { + unreachable!("Is instance always holds ExprEnum::Is"); + }; + *negated + } +} + +#[pyo3::pymethods] +impl PyIsDistinctFrom { + #[new] + #[pyo3(signature = (left, right, *, negated = false))] + fn __new__( + left: ExprExtractor, + right: ExprExtractor, + negated: bool, + ) -> pyo3::PyClassInitializer { + subclass::(ExprEnum::IsDistinctFrom { + left: Box::new(left.0), + right: Box::new(right.0), + negated, + }) + } + + #[getter] + fn get_left(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let ExprEnum::IsDistinctFrom { left, .. } = &slf.as_super().0 else { + unreachable!(); + }; + child(slf.py(), left) + } + + #[getter] + fn get_right(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let ExprEnum::IsDistinctFrom { right, .. } = &slf.as_super().0 else { + unreachable!(); + }; + child(slf.py(), right) + } + + #[getter] + fn get_negated(slf: pyo3::PyRef<'_, Self>) -> bool { + let ExprEnum::IsDistinctFrom { negated, .. } = &slf.as_super().0 else { + unreachable!(); + }; + *negated + } +} + +#[pyo3::pymethods] +impl PyInList { + #[new] + #[pyo3(signature = (expr, list, *, negated = false))] + fn __new__( + expr: ExprExtractor, + list: alias::BoundObject<'_>, + negated: bool, + ) -> pyo3::PyResult> { + let mut items = Vec::new(); + for item in list.try_iter()? { + items.push(item?.extract::()?.0); + } + Ok(subclass::(ExprEnum::InList { + expr: Box::new(expr.0), + list: items, + negated, + })) + } + + #[getter] + fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let ExprEnum::InList { expr, .. } = &slf.as_super().0 else { + unreachable!(); + }; + child(slf.py(), expr) + } + + #[getter] + fn get_list(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let py = slf.py(); + let ExprEnum::InList { list, .. } = &slf.as_super().0 else { + unreachable!(); + }; + let mut out = Vec::with_capacity(list.len()); + for item in list { + out.push(child(py, item)?); + } + Ok(pyo3::types::PyTuple::new(py, out)?.into_any()) + } + + #[getter] + fn get_negated(slf: pyo3::PyRef<'_, Self>) -> bool { + let ExprEnum::InList { negated, .. } = &slf.as_super().0 else { + unreachable!(); + }; + *negated + } +} + +#[pyo3::pymethods] +impl PyBetween { + #[new] + #[pyo3(signature = (expr, low, high, *, negated = false))] + fn __new__( + expr: ExprExtractor, + low: ExprExtractor, + high: ExprExtractor, + negated: bool, + ) -> pyo3::PyClassInitializer { + subclass::(ExprEnum::Between { + expr: Box::new(expr.0), + negated, + low: Box::new(low.0), + high: Box::new(high.0), + }) + } + + #[getter] + fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let ExprEnum::Between { expr, .. } = &slf.as_super().0 else { + unreachable!(); + }; + child(slf.py(), expr) + } + + #[getter] + fn get_low(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let ExprEnum::Between { low, .. } = &slf.as_super().0 else { + unreachable!(); + }; + child(slf.py(), low) + } + + #[getter] + fn get_high(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let ExprEnum::Between { high, .. } = &slf.as_super().0 else { + unreachable!(); + }; + child(slf.py(), high) + } + + #[getter] + fn get_negated(slf: pyo3::PyRef<'_, Self>) -> bool { + let ExprEnum::Between { negated, .. } = &slf.as_super().0 else { + unreachable!(); + }; + *negated + } +} + +#[pyo3::pymethods] +impl PyLike { + #[new] + #[pyo3(signature = (expr, pattern, *, kind = "LIKE", negated = false, escape = None))] + fn __new__( + expr: ExprExtractor, + pattern: LiteralExprExtractor, + kind: &str, + negated: bool, + escape: Option, + ) -> pyo3::PyResult> { + let Some(kind) = LikeKind::parse(kind) else { + return rapidquery_macros::pyerr!( + Err(PyValueError), + "unknown LIKE kind {kind:?}; expected LIKE, ILIKE, SIMILAR TO, or RLIKE" + ); + }; + Ok(subclass::(ExprEnum::Like { + kind, + negated, + expr: Box::new(expr.0), + pattern: Box::new(pattern.0), + escape, + })) + } + + #[getter] + fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let ExprEnum::Like { expr, .. } = &slf.as_super().0 else { + unreachable!(); + }; + child(slf.py(), expr) + } + + #[getter] + fn get_pattern(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let ExprEnum::Like { pattern, .. } = &slf.as_super().0 else { + unreachable!(); + }; + child(slf.py(), pattern) + } + + #[getter] + fn get_kind(slf: pyo3::PyRef<'_, Self>) -> &'static str { + let ExprEnum::Like { kind, .. } = &slf.as_super().0 else { + unreachable!(); + }; + kind.as_sql() + } + + #[getter] + fn get_negated(slf: pyo3::PyRef<'_, Self>) -> bool { + let ExprEnum::Like { negated, .. } = &slf.as_super().0 else { + unreachable!(); + }; + *negated + } + + #[getter] + fn get_escape(slf: pyo3::PyRef<'_, Self>) -> Option { + let ExprEnum::Like { escape, .. } = &slf.as_super().0 else { + unreachable!(); + }; + *escape + } +} + +#[pyo3::pymethods] +impl PyCast { + #[new] + #[pyo3(signature = (expr, data_type, /))] + fn __new__(expr: ExprExtractor, data_type: PyDataType) -> pyo3::PyClassInitializer { + subclass::(ExprEnum::Cast { + expr: Box::new(expr.0), + data_type, + }) + } + + #[getter] + fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let ExprEnum::Cast { expr, .. } = &slf.as_super().0 else { + unreachable!(); + }; + child(slf.py(), expr) + } + + #[getter] + fn get_data_type(slf: pyo3::PyRef<'_, Self>) -> PyDataType { + let ExprEnum::Cast { data_type, .. } = &slf.as_super().0 else { + unreachable!(); + }; + data_type.clone() + } +} + +#[pyo3::pymethods] +impl PyCase { + #[new] + #[pyo3(signature = (when_then, *, operand = None, else_ = None))] + fn __new__( + when_then: alias::BoundObject<'_>, + operand: Option, + else_: Option, + ) -> pyo3::PyResult> { + let mut pairs = Vec::new(); + for item in when_then.try_iter()? { + let item = item?; + let pair = item.cast::().map_err(|_| { + rapidquery_macros::pyerr!( + PyTypeError, + "Case when_then items must be (when, then) pairs" + ) + })?; + if pair.len() != 2 { + return rapidquery_macros::pyerr!( + Err(PyValueError), + "Case when_then items must be (when, then) pairs" + ); + } + let when = pair.get_item(0)?.extract::()?.0; + let then = pair.get_item(1)?.extract::()?.0; + pairs.push((when, then)); + } + Ok(subclass::(ExprEnum::Case { + operand: operand.map(|ExprExtractor(e)| Box::new(e)), + when_then: pairs, + else_result: else_.map(|ExprExtractor(e)| Box::new(e)), + })) + } + + #[getter] + fn get_operand( + slf: pyo3::PyRef<'_, Self>, + ) -> pyo3::PyResult>> { + let ExprEnum::Case { operand, .. } = &slf.as_super().0 else { + unreachable!(); + }; + match operand { + Some(expr) => Ok(Some(child(slf.py(), expr)?)), + None => Ok(None), + } + } + + #[getter] + fn get_when_then(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let py = slf.py(); + let ExprEnum::Case { when_then, .. } = &slf.as_super().0 else { + unreachable!(); + }; + let mut out = Vec::with_capacity(when_then.len()); + for (when, then) in when_then { + let pair = pyo3::types::PyTuple::new( + py, + [child(py, when)?, child(py, then)?], + )?; + out.push(pair); + } + Ok(pyo3::types::PyTuple::new(py, out)?.into_any()) + } + + #[getter] + fn get_else_(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult>> { + let ExprEnum::Case { else_result, .. } = &slf.as_super().0 else { + unreachable!(); + }; + match else_result { + Some(expr) => Ok(Some(child(slf.py(), expr)?)), + None => Ok(None), + } + } +} + +#[pyo3::pymethods] +impl PyCustom { + #[new] + #[pyo3(signature = (sql, *args))] + fn __new__( + sql: String, + args: alias::ArgsType<'_>, + ) -> pyo3::PyResult> { + let mut extracted = Vec::with_capacity(args.len()); + for item in args.iter() { + extracted.push(item.extract::()?.0); + } + Ok(subclass::(ExprEnum::Custom { + sql, + args: extracted, + })) + } + + #[getter] + fn get_sql(slf: pyo3::PyRef<'_, Self>) -> String { + let ExprEnum::Custom { sql, .. } = &slf.as_super().0 else { + unreachable!(); + }; + sql.clone() + } + + #[getter] + fn get_args(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let py = slf.py(); + let ExprEnum::Custom { args, .. } = &slf.as_super().0 else { + unreachable!(); + }; + let mut out = Vec::with_capacity(args.len()); + for arg in args { + out.push(child(py, arg)?); + } + Ok(pyo3::types::PyTuple::new(py, out)?.into_any()) + } +} diff --git a/src/expr/tests.rs b/src/expr/tests.rs new file mode 100644 index 0000000..c058b7d --- /dev/null +++ b/src/expr/tests.rs @@ -0,0 +1,224 @@ +use crate::data_type::PyDataType; +use crate::dialect::SqlDialect; +use super::ident::{Ident, IdentPart}; +use crate::sql::{PrepareError, SqlWriter, WriteSql}; + +use super::enum_::{BinaryOp, DuplicateTreatment, ExprEnum, IsKind, LikeKind, UnaryOp}; +use super::func::{Func, FuncArguments}; + +struct TestWriter(String); + +impl SqlWriter for TestWriter { + fn push_str(&mut self, s: &str) -> &mut Self { + self.0.push_str(s); + self + } + + fn push_char(&mut self, c: char) -> &mut Self { + self.0.push(c); + self + } + + fn push_value( + &mut self, + _dialect: &SqlDialect, + _serializer: &crate::data_type::SerializerEnum, + _object: &crate::internal::alias::PyObject, + ) -> crate::sql::PrepareResult<()> { + panic!("test writer does not support values"); + } +} + +impl std::fmt::Write for TestWriter { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + self.0.push_str(s); + Ok(()) + } +} + +fn quote(s: &str) -> IdentPart { + IdentPart::quoted(s).unwrap() +} + +fn ident(name: &str) -> ExprEnum { + ExprEnum::Ident(Ident::from_one(quote(name))) +} + +fn render(expr: &ExprEnum, dialect: SqlDialect) -> String { + let mut out = TestWriter(String::new()); + expr.write_sql(&dialect, &mut out).unwrap(); + out.0 +} + +fn pg(expr: &ExprEnum) -> String { + render(expr, SqlDialect::PostgreSql) +} + +#[test] +fn ident_uses_dialect_quotes() { + let expr = ident("id"); + assert_eq!(pg(&expr), "\"id\""); + assert_eq!(render(&expr, SqlDialect::MySql), "`id`"); +} + +#[test] +fn binary_add_and_and() { + let add = ExprEnum::Binary { + left: Box::new(ident("a")), + op: BinaryOp::Add, + right: Box::new(ident("b")), + }; + assert_eq!(pg(&add), "\"a\" + \"b\""); + + let and = ExprEnum::Binary { + left: Box::new(ident("a")), + op: BinaryOp::And, + right: Box::new(ident("b")), + }; + assert_eq!(pg(&and), "\"a\" AND \"b\""); +} + +#[test] +fn or_parenthesizes_weaker_and_child_on_right_of_and() { + let or = ExprEnum::Binary { + left: Box::new(ident("a")), + op: BinaryOp::Or, + right: Box::new(ident("b")), + }; + let and = ExprEnum::Binary { + left: Box::new(or), + op: BinaryOp::And, + right: Box::new(ident("c")), + }; + assert_eq!(pg(&and), "(\"a\" OR \"b\") AND \"c\""); +} + +#[test] +fn unary_not_and_minus() { + let not = ExprEnum::Unary { + op: UnaryOp::Not, + expr: Box::new(ident("x")), + }; + assert_eq!(pg(¬), "NOT \"x\""); + + let minus = ExprEnum::Unary { + op: UnaryOp::Minus, + expr: Box::new(ident("x")), + }; + assert_eq!(pg(&minus), "-\"x\""); +} + +#[test] +fn function_distinct_and_filter_and_bare() { + let sum = ExprEnum::Function(Func { + name: Ident::from_one(quote("SUM")), + args: FuncArguments::List { + duplicate_treatment: Some(DuplicateTreatment::Distinct), + args: vec![ident("amount")], + }, + filter: Some(Box::new(ExprEnum::Binary { + left: Box::new(ident("amount")), + op: BinaryOp::Gt, + right: Box::new(ident("zero")), + })), + }); + assert_eq!( + pg(&sum), + "\"SUM\"(DISTINCT \"amount\") FILTER (WHERE \"amount\" > \"zero\")" + ); + + let now = ExprEnum::Function(Func { + name: Ident::from_one(quote("CURRENT_TIMESTAMP")), + args: FuncArguments::None, + filter: None, + }); + assert_eq!(pg(&now), "\"CURRENT_TIMESTAMP\""); +} + +#[test] +fn is_in_between_like_cast_case_tuple() { + let is_null = ExprEnum::Is { + expr: Box::new(ident("x")), + negated: true, + kind: IsKind::Null, + }; + assert_eq!(pg(&is_null), "\"x\" IS NOT NULL"); + + let in_list = ExprEnum::InList { + expr: Box::new(ident("x")), + list: vec![ident("a"), ident("b")], + negated: false, + }; + assert_eq!(pg(&in_list), "\"x\" IN (\"a\", \"b\")"); + + let between = ExprEnum::Between { + expr: Box::new(ident("x")), + negated: false, + low: Box::new(ident("a")), + high: Box::new(ident("b")), + }; + assert_eq!(pg(&between), "\"x\" BETWEEN \"a\" AND \"b\""); + + let like = ExprEnum::Like { + kind: LikeKind::ILike, + negated: false, + expr: Box::new(ident("name")), + pattern: Box::new(ident("pat")), + escape: None, + }; + assert_eq!(pg(&like), "\"name\" ILIKE \"pat\""); + + let cast = ExprEnum::Cast { + expr: Box::new(ident("x")), + data_type: PyDataType::from_name("INT"), + }; + assert_eq!(pg(&cast), "CAST(\"x\" AS INT)"); + + let case = ExprEnum::Case { + operand: Some(Box::new(ident("x"))), + when_then: vec![(ident("a"), ident("b"))], + else_result: Some(Box::new(ident("c"))), + }; + assert_eq!(pg(&case), "CASE \"x\" WHEN \"a\" THEN \"b\" ELSE \"c\" END"); + + let tuple = ExprEnum::Tuple(vec![ident("a"), ident("b")]); + assert_eq!(pg(&tuple), "(\"a\", \"b\")"); +} + +#[test] +fn custom_placeholders_and_braces() { + let expr = ExprEnum::Custom { + sql: "json_agg(DISTINCT {})".to_string(), + args: vec![ident("col")], + }; + assert_eq!(pg(&expr), "json_agg(DISTINCT \"col\")"); + + let expr = ExprEnum::Custom { + sql: "a {{ b }} c".to_string(), + args: vec![], + }; + assert_eq!(pg(&expr), "a { b } c"); + + let expr = ExprEnum::Custom { + sql: "{} {}".to_string(), + args: vec![ident("a")], + }; + let err = { + let mut out = TestWriter(String::new()); + expr.write_sql(&SqlDialect::PostgreSql, &mut out) + .unwrap_err() + }; + assert!(matches!( + err, + PrepareError::CustomPlaceholders { + expected: 2, + got: 1 + } + )); +} + +#[test] +fn binary_op_parse_unknown_is_custom() { + assert_eq!(BinaryOp::parse("->"), BinaryOp::Custom(arcstr::ArcStr::from("->"))); + assert_eq!(BinaryOp::parse("!=").as_sql(), "<>"); +} diff --git a/src/value.rs b/src/expr/value.rs similarity index 68% rename from src/value.rs rename to src/expr/value.rs index 56eef59..dc63629 100644 --- a/src/value.rs +++ b/src/expr/value.rs @@ -1,7 +1,5 @@ //! A Python object plus the [`Serializer`] used to emit it as SQL. -use std::hash::{Hash, Hasher}; - use pyo3::types::{PyAnyMethods, PyStringMethods}; use crate::data_type::{PySerializer, SerializerEnum, SerializerExtractor, serializer_repr}; @@ -16,23 +14,13 @@ use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_sql}; /// Python `None` becomes `NULL`. /// /// Clone requires the GIL (`Py::clone`). -/// -/// `Value` does not convert the object until SQL is built. Parameterized -/// statements bind the object as a placeholder. Raw SQL runs the serializer -/// (`None` becomes `NULL`). -/// -/// If `serializer` is omitted, one is inferred from the object's type. -/// Passing an existing `Value` keeps its object (and its serializer, unless -/// a new one is given). A `DataType` as `serializer` contributes only its -/// serializer; the data type is not stored. -#[rapidquery_macros::pyclass("Value", skip_from_py_object, generic)] #[derive(Debug, Clone)] -pub struct PyValue { +pub struct Value { pub object: alias::PyObject, pub serializer: SerializerEnum, } -impl PyValue { +impl Value { #[inline] pub fn new(object: alias::PyObject, serializer: SerializerEnum) -> Self { Self { object, serializer } @@ -43,32 +31,62 @@ impl PyValue { let serializer = SerializerEnum::infer_bound(&bound); Self::new(bound.unbind(), serializer) } + + pub(crate) fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let obj_repr = self.object.bind(py).repr()?; + Ok(format!( + "Value({}, {})", + obj_repr.to_cow()?, + serializer_repr(&self.serializer) + )) + } } -impl WriteSql for PyValue { +impl WriteSql for Value { fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { out.push_value(dialect, &self.serializer, &self.object) } } -impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyValue { - type Error = pyo3::PyErr; - - #[inline] - fn extract( - obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, - ) -> Result { - unsafe { - if crate::typeref::check_exact(obj.as_ptr(), crate::typeref::VALUE_TYPE) { - let value = obj.cast_unchecked::(); - return Ok(value.get().clone()); - } - } +impl PartialEq for Value { + fn eq(&self, other: &Self) -> bool { + self.serializer == other.serializer && self.object.as_ptr() == other.object.as_ptr() + } +} - Ok(PyValue::infer_bound(obj.as_any().clone())) +/// A Python object plus the [`Serializer`] used to emit it as SQL. +/// +/// `Value` does not convert the object until SQL is built. Parameterized +/// statements bind the object as a placeholder. Raw SQL runs the serializer +/// (`None` becomes `NULL`). +/// +/// If `serializer` is omitted, one is inferred from the object's type. +/// Passing an existing `Value` keeps its object (and its serializer, unless +/// a new one is given). A `DataType` as `serializer` contributes only its +/// serializer; the data type is not stored. +#[rapidquery_macros::pyclass( + "Value", + skip_from_py_object, + extends = super::PyExpr, + module = "rapidquery._core.expr", + generic +)] +#[derive(Debug, Default)] +pub struct PyValue; + +#[inline] +fn value_inner<'a>(slf: &'a pyo3::PyRef<'a, PyValue>) -> &'a Value { + match &slf.as_super().0 { + super::ExprEnum::Value(value) => value, + _ => unreachable!("Value instance always holds ExprEnum::Value"), } } +#[inline] +fn wrap_value(value: Value) -> pyo3::PyClassInitializer { + pyo3::PyClassInitializer::from(super::PyExpr(super::ExprEnum::Value(value))).add_subclass(PyValue) +} + #[pyo3::pymethods] impl PyValue { /// Creates a SQL value. @@ -91,21 +109,23 @@ impl PyValue { ) -> pyo3::PyClassInitializer { // handle Value(Value(...), new_serializer) if unsafe { crate::typeref::check_exact(object.as_ptr(), crate::typeref::VALUE_TYPE) } { - let value = unsafe { object.cast_unchecked::() }; - let inner = value.get().clone(); + let expr = unsafe { object.cast_unchecked::() }; + let super::ExprEnum::Value(inner) = expr.get().0.clone() else { + unreachable!("Value instance always holds ExprEnum::Value"); + }; - let slf = match serializer { - Some(SerializerExtractor(s)) => PyValue::new(inner.object, s), + let value = match serializer { + Some(SerializerExtractor(s)) => Value::new(inner.object, s), None => inner, }; - return pyo3::PyClassInitializer::from(slf); + return wrap_value(value); } - let slf = match serializer { - Some(SerializerExtractor(s)) => PyValue::new(object.unbind(), s), - None => PyValue::infer_bound(object), + let value = match serializer { + Some(SerializerExtractor(s)) => Value::new(object.unbind(), s), + None => Value::infer_bound(object), }; - pyo3::PyClassInitializer::from(slf) + wrap_value(value) } /// Returns the wrapped Python object. @@ -115,7 +135,7 @@ impl PyValue { /// `Value`). #[getter] fn get_object(slf: pyo3::PyRef<'_, Self>) -> alias::PyObject { - slf.object.clone_ref(slf.py()) + value_inner(&slf).object.clone_ref(slf.py()) } /// Returns the serializer used for raw SQL literals. @@ -125,7 +145,7 @@ impl PyValue { /// is bound as a parameter. #[getter] fn get_serializer(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - PySerializer(slf.serializer.clone()).into_subclasses(slf.py()) + PySerializer(value_inner(&slf).serializer.clone()).into_subclasses(slf.py()) } /// Renders this value as a SQL literal for `dialect`. @@ -144,34 +164,10 @@ impl PyValue { /// TypeError: If `object` is not an accepted type for the serializer. /// ValueError: If `object` is the right type but cannot be represented. fn to_sql( - &self, + slf: pyo3::PyRef<'_, Self>, py: pyo3::Python<'_>, dialect: crate::dialect::py::PySqlDialect, ) -> pyo3::PyResult { - write_sql(py, self, &dialect.0).map_err(Into::into) - } - - fn __eq__(&self, py: pyo3::Python<'_>, other: &pyo3::Bound<'_, Self>) -> pyo3::PyResult { - if self.serializer != other.get().serializer { - return Ok(false); - } - - self.object.bind(py).eq(other.get().object.bind(py)) - } - - fn __hash__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - self.object.bind(py).hash()?.hash(&mut hasher); - self.serializer.hash(&mut hasher); - Ok(hasher.finish()) - } - - fn __repr__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let obj_repr = self.object.bind(py).repr()?; - Ok(format!( - "Value({}, {})", - obj_repr.to_cow()?, - serializer_repr(&self.serializer) - )) + write_sql(py, value_inner(&slf), &dialect.0).map_err(Into::into) } } diff --git a/src/lib.rs b/src/lib.rs index a6eb052..8b07812 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,11 +3,10 @@ pub mod data_type; pub mod dialect; -pub mod ident; +pub mod expr; pub mod internal; pub mod sql; pub mod typeref; -pub mod value; /// RapidQuery native extension. #[pyo3::pymodule(gil_used = false)] @@ -19,10 +18,7 @@ mod _core { use super::dialect::dialect_mod; #[pymodule_export] - use super::ident::{PyIdent, PyAsterisk, PyUnquote}; - - #[pymodule_export] - use super::value::PyValue; + use super::expr::expr_mod; #[pymodule_init] #[cold] diff --git a/src/sql.rs b/src/sql.rs index 2231839..c87c6ec 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -19,6 +19,8 @@ pub enum PrepareError { Write, /// An error that originated in Python (e.g. from `.extract()`). Python(pyo3::PyErr), + /// A `Custom` template had a different number of `{}` slots than args. + CustomPlaceholders { expected: usize, got: usize }, } impl std::fmt::Display for PrepareError { @@ -26,6 +28,10 @@ impl std::fmt::Display for PrepareError { match self { Self::Write => f.write_str("failed to write SQL"), Self::Python(err) => write!(f, "{err}"), + Self::CustomPlaceholders { expected, got } => write!( + f, + "custom expression has {expected} placeholders but {got} arguments" + ), } } } @@ -33,7 +39,7 @@ impl std::fmt::Display for PrepareError { impl std::error::Error for PrepareError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { - Self::Write => None, + Self::Write | Self::CustomPlaceholders { .. } => None, Self::Python(err) => Some(err), } } @@ -58,6 +64,14 @@ impl From for pyo3::PyErr { PrepareError::Write => { rapidquery_macros::pyerr!(PyRuntimeError, "failed to write SQL") } + PrepareError::CustomPlaceholders { expected, got } => { + rapidquery_macros::pyerr!( + PyValueError, + "custom expression has {} placeholders but {} arguments", + expected, + got + ) + } } } } diff --git a/src/typeref.rs b/src/typeref.rs index 6c3d474..c712eb9 100644 --- a/src/typeref.rs +++ b/src/typeref.rs @@ -45,6 +45,21 @@ pub(crate) static mut ASTERISK_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::nu // Value type pub(crate) static mut VALUE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +// Expr types +pub(crate) static mut EXPR_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut FUNC_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut TUPLE_EXPR_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut UNARY_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut BINARY_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut IS_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut IS_DISTINCT_FROM_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut IN_LIST_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut BETWEEN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut LIKE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut CAST_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut CASE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut CUSTOM_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); + // Python standard-library types (serializers) pub(crate) static mut STD_DECIMAL_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); pub(crate) static mut STD_UUID_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); @@ -130,12 +145,24 @@ fn _initialize_typeref(py: pyo3::Python) { crate::data_type::PyUnspecifiedSerializer => UNSPECIFIED_SERIALIZER_TYPE, crate::data_type::PyArraySerializer => ARRAY_SERIALIZER_TYPE, crate::data_type::PyCustomSerializer => CUSTOM_SERIALIZER_TYPE, - // ident - crate::ident::PyIdent => IDENT_TYPE, - crate::ident::PyUnquote => UNQUOTE_TYPE, - crate::ident::PyAsterisk => ASTERISK_TYPE, - // value - crate::value::PyValue => VALUE_TYPE, + // expr (base before subclasses that extend it) + crate::expr::PyExpr => EXPR_TYPE, + crate::expr::PyFunc => FUNC_TYPE, + crate::expr::PyTupleExpr => TUPLE_EXPR_TYPE, + crate::expr::PyUnary => UNARY_TYPE, + crate::expr::PyBinary => BINARY_TYPE, + crate::expr::PyIs => IS_TYPE, + crate::expr::PyIsDistinctFrom => IS_DISTINCT_FROM_TYPE, + crate::expr::PyInList => IN_LIST_TYPE, + crate::expr::PyBetween => BETWEEN_TYPE, + crate::expr::PyLike => LIKE_TYPE, + crate::expr::PyCast => CAST_TYPE, + crate::expr::PyCase => CASE_TYPE, + crate::expr::PyCustom => CUSTOM_TYPE, + crate::expr::PyIdent => IDENT_TYPE, + crate::expr::PyUnquote => UNQUOTE_TYPE, + crate::expr::PyAsterisk => ASTERISK_TYPE, + crate::expr::PyValue => VALUE_TYPE, ); STD_DECIMAL_TYPE = look_up_type_object(c"decimal", c"Decimal"); diff --git a/tests/test_expr.py b/tests/test_expr.py new file mode 100644 index 0000000..2d3601c --- /dev/null +++ b/tests/test_expr.py @@ -0,0 +1,148 @@ +import pytest + +from rapidquery import Asterisk, Expr, Func, Ident, Unquote, Value +from rapidquery.data_type import DataType +from rapidquery.expr import ( + Between, + Binary, + Case, + Cast, + Custom, + InList, + Is, + IsDistinctFrom, + Like, + TupleExpr, + Unary, +) + + +class TestExprHierarchy: + def test_ident_and_value_are_expr(self): + assert isinstance(Ident("id"), Expr) + assert isinstance(Value(1), Expr) + assert isinstance(Func("SUM", "amount"), Expr) + + def test_ident_value_live_in_expr_module(self): + assert Ident.__module__ == "rapidquery._core.expr" + assert Value.__module__ == "rapidquery._core.expr" + assert Unquote.__module__ == "rapidquery._core.expr" + assert Asterisk.__module__ == "rapidquery._core.expr" + assert Func.__module__ == "rapidquery._core.expr" + + def test_base_expr_cannot_be_instantiated(self): + with pytest.raises(TypeError): + Expr() + + def test_ident_repr_unchanged(self): + assert repr(Ident("schema", "table")) == 'Ident("schema", "table")' + + def test_value_repr_unchanged(self): + assert repr(Value(42)).startswith("Value(42,") + + +class TestBinaryUnary: + def test_str_is_ident(self): + expr = Binary("a", "+", "b") + assert isinstance(expr.left, Ident) + assert expr.left[0] == "a" + assert expr.op == "+" + assert isinstance(expr.right, Ident) + assert repr(expr) == 'Binary(Ident("a"), "+", Ident("b"))' + + def test_value_for_string_literal(self): + expr = Binary("id", "=", Value("x")) + assert isinstance(expr.left, Ident) + assert isinstance(expr.right, Value) + assert expr.right.object == "x" + + def test_custom_operator(self): + expr = Binary("data", "->", Unquote("key")) + assert expr.op == "->" + + def test_unary_not(self): + expr = Unary("NOT", "x") + assert expr.op == "NOT" + assert isinstance(expr.expr, Ident) + + def test_unknown_unary_raises(self): + with pytest.raises(ValueError, match="unknown unary"): + Unary("@", "x") + + +class TestFunc: + def test_args_are_idents(self): + fn = Func("SUM", "amount") + assert isinstance(fn.name, Ident) + assert fn.args is not None + assert isinstance(fn.args[0], Ident) + assert fn.filter is None + + def test_bare_function(self): + fn = Func("CURRENT_TIMESTAMP", parentheses=False) + assert fn.args is None + assert "parentheses=False" in repr(fn) + + def test_distinct(self): + fn = Func("COUNT", "id", distinct=True) + assert "distinct=True" in repr(fn) + + +class TestNodes: + def test_tuple_expr(self): + expr = TupleExpr("a", "b") + assert len(expr.items) == 2 + assert isinstance(expr.items[0], Ident) + + def test_is_null(self): + expr = Is("x", "NULL", negated=True) + assert expr.kind == "NULL" + assert expr.negated is True + + def test_in_list(self): + expr = InList("x", ["a", "b"]) + assert len(expr.list) == 2 + assert isinstance(expr.list[0], Ident) + + def test_between(self): + expr = Between("x", "a", "b") + assert isinstance(expr.low, Ident) + assert isinstance(expr.high, Ident) + + def test_like_pattern_is_literal(self): + expr = Like("name", "%a%") + assert isinstance(expr.expr, Ident) + assert isinstance(expr.pattern, Value) + assert expr.pattern.object == "%a%" + assert expr.kind == "LIKE" + + def test_cast(self): + expr = Cast("x", DataType("int")) + assert isinstance(expr.expr, Ident) + assert expr.data_type.name == "INT" + + def test_case(self): + expr = Case([("a", "b")], else_="c") + assert expr.operand is None + assert len(expr.when_then) == 1 + assert isinstance(expr.when_then[0][0], Ident) + assert isinstance(expr.else_, Ident) + + def test_custom(self): + expr = Custom("json_agg(DISTINCT {})", "col") + assert expr.sql == "json_agg(DISTINCT {})" + assert isinstance(expr.args[0], Ident) + + def test_is_distinct_from(self): + expr = IsDistinctFrom("a", "b", negated=True) + assert expr.negated is True + + +class TestEquality: + def test_ident_equality_still_works(self): + assert Ident("t", "id") == Ident("t", "id") + assert hash(Ident("id")) != hash(Ident(Unquote("id"))) + + def test_binary_equality(self): + assert Binary("a", "+", "b") == Binary("a", "+", "b") + assert Binary("a", "+", "b") != Binary("a", "-", "b") From affe18d7b79b880efa9b7e8b8327e7ad226f77fd Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Tue, 15 Sep 2026 16:01:22 +0330 Subject: [PATCH 31/87] Optimize and refactor expressions (not tested) --- rapidquery/__init__.py | 4 +- rapidquery/dialect.py | 4 +- rapidquery/expr.py | 58 +- rapidquery/expr.pyi | 24 +- src/data_type/serializer/mod.rs | 1 + src/data_type/serializer/serialize.rs | 14 +- src/expr/base.rs | 83 +++ src/expr/enum_.rs | 868 -------------------------- src/expr/enum_/func.rs | 109 ++++ src/expr/enum_/ident.rs | 254 ++++++++ src/expr/enum_/mod.rs | 638 +++++++++++++++++++ src/expr/enum_/operator.rs | 184 ++++++ src/expr/enum_/value.rs | 55 ++ src/expr/extractors.rs | 186 ++++++ src/expr/func.rs | 194 ++---- src/expr/ident.rs | 719 ++------------------- src/expr/mod.rs | 217 +------ src/expr/nodes.rs | 592 +++++++++--------- src/expr/tests.rs | 224 ------- src/expr/value.rs | 137 ++-- src/internal/modules/json.rs | 4 +- src/internal/utils/mod.rs | 8 +- src/lib.rs | 1 - src/typeref.rs | 35 +- tests/test_expr.py | 30 +- 25 files changed, 2030 insertions(+), 2613 deletions(-) create mode 100644 src/expr/base.rs delete mode 100644 src/expr/enum_.rs create mode 100644 src/expr/enum_/func.rs create mode 100644 src/expr/enum_/ident.rs create mode 100644 src/expr/enum_/mod.rs create mode 100644 src/expr/enum_/operator.rs create mode 100644 src/expr/enum_/value.rs create mode 100644 src/expr/extractors.rs delete mode 100644 src/expr/tests.rs diff --git a/rapidquery/__init__.py b/rapidquery/__init__.py index ad0a99d..574bf5f 100644 --- a/rapidquery/__init__.py +++ b/rapidquery/__init__.py @@ -5,7 +5,7 @@ from . import expr as expr from .expr import Asterisk as Asterisk from .expr import Expr as Expr -from .expr import Func as Func +from .expr import Function as Function from .expr import Ident as Ident from .expr import Unquote as Unquote from .expr import Value as Value @@ -13,7 +13,7 @@ __all__ = [ "Asterisk", "Expr", - "Func", + "Function", "Ident", "Unquote", "Value", diff --git a/rapidquery/dialect.py b/rapidquery/dialect.py index 7b9cbf7..481a94a 100644 --- a/rapidquery/dialect.py +++ b/rapidquery/dialect.py @@ -20,18 +20,18 @@ ) __all__ = [ + "MSSQL", "BigQuery", "Databricks", "DuckDB", "Hive", - "MSSQL", "MySql", "Oracle", "PostgreSQL", "Redshift", + "SQLite", "Snowflake", "Spark", "SqlDialect", - "SQLite", "Teradata", ] diff --git a/rapidquery/expr.py b/rapidquery/expr.py index 4ba0970..070c415 100644 --- a/rapidquery/expr.py +++ b/rapidquery/expr.py @@ -2,40 +2,42 @@ SQL expressions. """ -from ._core.expr import Asterisk as Asterisk -from ._core.expr import Between as Between -from ._core.expr import Binary as Binary -from ._core.expr import Case as Case -from ._core.expr import Cast as Cast -from ._core.expr import Custom as Custom -from ._core.expr import Expr as Expr -from ._core.expr import Func as Func -from ._core.expr import Ident as Ident -from ._core.expr import InList as InList -from ._core.expr import Is as Is -from ._core.expr import IsDistinctFrom as IsDistinctFrom -from ._core.expr import Like as Like -from ._core.expr import TupleExpr as TupleExpr -from ._core.expr import Unary as Unary -from ._core.expr import Unquote as Unquote -from ._core.expr import Value as Value +from ._core.expr import ( + Asterisk, + BetweenExpr, + BinaryExpr, + CaseExpr, + CastExpr, + CustomExpr, + Expr, + Function, + Ident, + InListExpr, + IsDistinctFromExpr, + IsExpr, + LikeExpr, + TupleExpr, + UnaryExpr, + Unquote, + Value, +) __all__ = [ "Asterisk", - "Between", - "Binary", - "Case", - "Cast", - "Custom", + "BetweenExpr", + "BinaryExpr", + "CaseExpr", + "CastExpr", + "CustomExpr", "Expr", - "Func", + "Function", "Ident", - "InList", - "Is", - "IsDistinctFrom", - "Like", + "InListExpr", + "IsDistinctFromExpr", + "IsExpr", + "LikeExpr", "TupleExpr", - "Unary", + "UnaryExpr", "Unquote", "Value", ] diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index 4d6fb33..5e1a316 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -48,7 +48,6 @@ class Ident(Expr): def __getitem__(self, index: int) -> str | Unquote | Asterisk: ... @overload def __getitem__(self, index: slice) -> Ident: ... - def __iter__(self) -> Iterator[str | Unquote | Asterisk]: ... def to_list(self) -> Sequence[str | Unquote | Asterisk]: ... def to_sql(self, dialect: type[SqlDialect] | SqlDialect) -> str: ... @@ -72,14 +71,13 @@ class Value(Expr, typing.Generic[_T]): def to_sql(self, dialect: type[SqlDialect] | SqlDialect) -> str: ... @final -class Func(Expr): +class Function(Expr): def __new__( cls, name: str | Ident | Unquote, *args: object, distinct: bool | None = None, filter: object | None = None, - parentheses: bool = True, ) -> Self: ... @property def name(self) -> Ident: ... @@ -95,7 +93,7 @@ class TupleExpr(Expr): def items(self) -> tuple[Expr, ...]: ... @final -class Unary(Expr): +class UnaryExpr(Expr): def __new__(cls, op: str, expr: object, /) -> Self: ... @property def op(self) -> str: ... @@ -103,7 +101,7 @@ class Unary(Expr): def expr(self) -> Expr: ... @final -class Binary(Expr): +class BinaryExpr(Expr): def __new__(cls, left: object, op: str, right: object, /) -> Self: ... @property def left(self) -> Expr: ... @@ -113,7 +111,7 @@ class Binary(Expr): def right(self) -> Expr: ... @final -class Is(Expr): +class IsExpr(Expr): def __new__(cls, expr: object, kind: str, *, negated: bool = False) -> Self: ... @property def expr(self) -> Expr: ... @@ -123,7 +121,7 @@ class Is(Expr): def negated(self) -> bool: ... @final -class IsDistinctFrom(Expr): +class IsDistinctFromExpr(Expr): def __new__(cls, left: object, right: object, *, negated: bool = False) -> Self: ... @property def left(self) -> Expr: ... @@ -133,7 +131,7 @@ class IsDistinctFrom(Expr): def negated(self) -> bool: ... @final -class InList(Expr): +class InListExpr(Expr): def __new__( cls, expr: object, list: Iterable[object], *, negated: bool = False ) -> Self: ... @@ -145,7 +143,7 @@ class InList(Expr): def negated(self) -> bool: ... @final -class Between(Expr): +class BetweenExpr(Expr): def __new__( cls, expr: object, @@ -164,7 +162,7 @@ class Between(Expr): def negated(self) -> bool: ... @final -class Like(Expr): +class LikeExpr(Expr): def __new__( cls, expr: object, @@ -186,7 +184,7 @@ class Like(Expr): def escape(self) -> str | None: ... @final -class Cast(Expr): +class CastExpr(Expr): def __new__(cls, expr: object, data_type: DataType, /) -> Self: ... @property def expr(self) -> Expr: ... @@ -194,7 +192,7 @@ class Cast(Expr): def data_type(self) -> DataType: ... @final -class Case(Expr): +class CaseExpr(Expr): def __new__( cls, when_then: Iterable[tuple[object, object]], @@ -210,7 +208,7 @@ class Case(Expr): def else_(self) -> Expr | None: ... @final -class Custom(Expr): +class CustomExpr(Expr): def __new__(cls, sql: str, *args: object) -> Self: ... @property def sql(self) -> str: ... diff --git a/src/data_type/serializer/mod.rs b/src/data_type/serializer/mod.rs index be4b7b4..5b24b21 100644 --- a/src/data_type/serializer/mod.rs +++ b/src/data_type/serializer/mod.rs @@ -26,6 +26,7 @@ fn class_name(serializer: &SerializerEnum) -> &'static str { } } +#[inline] fn wrap<'py, T>( py: pyo3::Python<'py>, base: PySerializer, diff --git a/src/data_type/serializer/serialize.rs b/src/data_type/serializer/serialize.rs index 88e9c29..41a63ca 100644 --- a/src/data_type/serializer/serialize.rs +++ b/src/data_type/serializer/serialize.rs @@ -11,7 +11,7 @@ use pyo3::types::{PyAnyMethods, PyTzInfoAccess}; use crate::dialect::SqlDialect; use crate::internal::alias; -use crate::internal::utils::{get_type_name, py_str}; +use crate::internal::utils::{borrow_py_str_buffer, get_type_name}; impl super::serializer_enum::SerializerEnum { pub fn write_sql( @@ -60,7 +60,7 @@ unsafe fn py_to_string( if s.is_null() { return Err(pyo3::PyErr::fetch(py)); } - let out = py_str(py, s).map(str::to_owned); + let out = borrow_py_str_buffer(py, s).map(str::to_owned); pyo3::ffi::Py_DECREF(s); out } @@ -177,7 +177,7 @@ unsafe fn write_str( out: &mut String, ) -> pyo3::PyResult<()> { if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { - write_quoted(dialect, out, py_str(py, ptr)?); + write_quoted(dialect, out, borrow_py_str_buffer(py, ptr)?); return Ok(()); } @@ -189,7 +189,7 @@ unsafe fn write_str( } let is_str = pyo3::ffi::PyUnicode_CheckExact(value) == 1; let result = if is_str { - write_quoted(dialect, out, py_str(py, value)?); + write_quoted(dialect, out, borrow_py_str_buffer(py, value)?); Ok(()) } else { rapidquery_macros::pyerr!( @@ -223,7 +223,7 @@ unsafe fn write_uuid( write_quoted(dialect, out, &text); Ok(()) } else if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { - write_quoted(dialect, out, py_str(py, ptr)?); + write_quoted(dialect, out, borrow_py_str_buffer(py, ptr)?); Ok(()) } else { rapidquery_macros::pyerr!( @@ -265,7 +265,7 @@ unsafe fn write_bit( out: &mut String, ) -> pyo3::PyResult<()> { if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { - let raw = py_str(py, ptr)?; + let raw = borrow_py_str_buffer(py, ptr)?; if raw.is_empty() || !raw.bytes().all(|b| b == b'0' || b == b'1') { return rapidquery_macros::pyerr!( Err(PyValueError), @@ -531,6 +531,6 @@ unsafe fn write_custom( get_type_name(py, output.as_ptr()) ); } - out.push_str(py_str(py, output.as_ptr())?); + out.push_str(borrow_py_str_buffer(py, output.as_ptr())?); Ok(()) } diff --git a/src/expr/base.rs b/src/expr/base.rs new file mode 100644 index 0000000..900a916 --- /dev/null +++ b/src/expr/base.rs @@ -0,0 +1,83 @@ +//! SQL expressions. +//! +//! [`PyExpr`] holds [`ExprEnum`]. Concrete nodes (`Ident`, `Binary`, …) are +//! empty frozen subclasses. Shared methods live on the base. +//! +// TODO: `to_sql` / `prepare`; `eq` / `ne` / `lt` / `le` / `gt` / `ge`; `add` / `sub` / `mul` / `div` / `mod`; +// `and_` / `or_` / `xor` / `not_`; `bit_and` / `bit_or`; `like` / `ilike` / +// `between` / `in_` / `is_null` / `is_distinct_from` / `cast`; `case`; +// `custom`. Prefer named methods over v0 dunders that return `Expr`. + +use crate::internal::alias; + +use super::enum_::ExprEnum; + +/// Abstract SQL expression. +/// +/// Concrete nodes are frozen subclasses (`Ident`, `Value`, `Func`, +/// `Binary`, …). This base class cannot be instantiated. +#[rapidquery_macros::pyclass( + "Expr", + subclass, + skip_from_py_object, + module = "rapidquery._core.expr" +)] +#[derive(Debug, Clone)] +pub struct PyExpr(pub ExprEnum); + +#[inline(always)] +fn wrap<'py, T>(py: pyo3::Python<'py>, base: PyExpr) -> pyo3::PyResult> +where + T: pyo3::PyClass + Default, +{ + pyo3::Bound::new( + py, + pyo3::PyClassInitializer::from(base).add_subclass(T::default()), + ) + .map(|x| x.into_any()) +} + +/// Wrap `expr` as the matching Python subclass. +/// +/// Shorthand for `PyExpr(expr).into_subclasses(py)` +#[inline] +pub(super) fn into_subclasses<'py>( + py: pyo3::Python<'py>, + expr: ExprEnum, +) -> pyo3::PyResult> { + PyExpr(expr).into_subclasses(py) +} + +impl PyExpr { + /// Wrap `self` as the matching Python subclass. + #[inline] + pub fn into_subclasses<'py>( + self, + py: pyo3::Python<'py>, + ) -> pyo3::PyResult> { + match &self.0 { + ExprEnum::Ident(_) => wrap::(py, self), + ExprEnum::Value(_) => wrap::(py, self), + ExprEnum::Tuple(_) => wrap::(py, self), + ExprEnum::Unary(_) => wrap::(py, self), + ExprEnum::Binary(_) => wrap::(py, self), + ExprEnum::Function(_) => wrap::(py, self), + ExprEnum::Is(_) => wrap::(py, self), + ExprEnum::IsDistinctFrom(_) => wrap::(py, self), + ExprEnum::InList(_) => wrap::(py, self), + ExprEnum::Between(_) => wrap::(py, self), + ExprEnum::Like(_) => wrap::(py, self), + // ExprEnum::Cast(_) => wrap::(py, self), + // ExprEnum::Case(_) => wrap::(py, self), + // ExprEnum::Custom(_) => wrap::(py, self), + _ => todo!(), + } + } +} + +#[pyo3::pymethods] +impl PyExpr { + fn __repr__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + self.0.python_repr(py) + } +} diff --git a/src/expr/enum_.rs b/src/expr/enum_.rs deleted file mode 100644 index 49540c6..0000000 --- a/src/expr/enum_.rs +++ /dev/null @@ -1,868 +0,0 @@ -//! Engine expression tree. -//! -//! Python classes are empty views over [`ExprEnum`] (Serializer-style). -//! Query-shaped variants are omitted until `Query` exists. - -use std::fmt::Write as _; -use std::hash::{Hash, Hasher}; - -use arcstr::ArcStr; -use pyo3::types::PyAnyMethods; - -use crate::data_type::{PyDataType, serializer_repr}; -use crate::dialect::SqlDialect; -use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; - -use super::func::Func; -use super::ident::Ident; -use super::value::Value; - -/// How duplicate values are treated inside function argument lists. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum DuplicateTreatment { - /// Consider only unique values. - Distinct, - /// Retain all duplicate values (the default). - All, -} - -impl DuplicateTreatment { - pub(crate) fn as_sql(self) -> &'static str { - match self { - Self::Distinct => "DISTINCT", - Self::All => "ALL", - } - } -} - -/// Unary operator. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum UnaryOp { - Not, - Plus, - Minus, - BitNot, -} - -impl UnaryOp { - pub fn parse(s: &str) -> Option { - match s { - "NOT" | "not" | "Not" => Some(Self::Not), - "+" => Some(Self::Plus), - "-" => Some(Self::Minus), - "~" => Some(Self::BitNot), - _ => None, - } - } - - pub fn as_sql(self) -> &'static str { - match self { - Self::Not => "NOT", - Self::Plus => "+", - Self::Minus => "-", - Self::BitNot => "~", - } - } -} - -/// Binary operator. Unknown spellings become [`BinaryOp::Custom`]. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum BinaryOp { - Add, - Sub, - Mul, - Div, - Mod, - Eq, - NotEq, - Lt, - LtEq, - Gt, - GtEq, - And, - Or, - Xor, - BitAnd, - BitOr, - BitXor, - LShift, - RShift, - StringConcat, - Custom(ArcStr), -} - -impl BinaryOp { - pub fn parse(s: &str) -> Self { - match s { - "+" => Self::Add, - "-" => Self::Sub, - "*" => Self::Mul, - "/" => Self::Div, - "%" => Self::Mod, - "=" => Self::Eq, - "<>" | "!=" => Self::NotEq, - "<" => Self::Lt, - "<=" => Self::LtEq, - ">" => Self::Gt, - ">=" => Self::GtEq, - "AND" | "and" | "And" => Self::And, - "OR" | "or" | "Or" => Self::Or, - "XOR" | "xor" | "Xor" => Self::Xor, - "&" => Self::BitAnd, - "|" => Self::BitOr, - "^" => Self::BitXor, - "<<" => Self::LShift, - ">>" => Self::RShift, - "||" => Self::StringConcat, - other => Self::Custom(ArcStr::from(other)), - } - } - - pub fn as_sql(&self) -> &str { - match self { - Self::Add => "+", - Self::Sub => "-", - Self::Mul => "*", - Self::Div => "/", - Self::Mod => "%", - Self::Eq => "=", - Self::NotEq => "<>", - Self::Lt => "<", - Self::LtEq => "<=", - Self::Gt => ">", - Self::GtEq => ">=", - Self::And => "AND", - Self::Or => "OR", - Self::Xor => "XOR", - Self::BitAnd => "&", - Self::BitOr => "|", - Self::BitXor => "^", - Self::LShift => "<<", - Self::RShift => ">>", - Self::StringConcat => "||", - Self::Custom(s) => s.as_str(), - } - } -} - -/// Right-hand side of `IS [NOT] …`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum IsKind { - Null, - True, - False, - Unknown, -} - -impl IsKind { - pub fn parse(s: &str) -> Option { - if s.eq_ignore_ascii_case("NULL") { - Some(Self::Null) - } else if s.eq_ignore_ascii_case("TRUE") { - Some(Self::True) - } else if s.eq_ignore_ascii_case("FALSE") { - Some(Self::False) - } else if s.eq_ignore_ascii_case("UNKNOWN") { - Some(Self::Unknown) - } else { - None - } - } - - pub fn as_sql(self) -> &'static str { - match self { - Self::Null => "NULL", - Self::True => "TRUE", - Self::False => "FALSE", - Self::Unknown => "UNKNOWN", - } - } -} - -/// Pattern-matching operator family. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum LikeKind { - Like, - ILike, - SimilarTo, - RLike, -} - -impl LikeKind { - pub fn parse(s: &str) -> Option { - if s.eq_ignore_ascii_case("LIKE") { - Some(Self::Like) - } else if s.eq_ignore_ascii_case("ILIKE") { - Some(Self::ILike) - } else if s.eq_ignore_ascii_case("SIMILAR TO") { - Some(Self::SimilarTo) - } else if s.eq_ignore_ascii_case("RLIKE") { - Some(Self::RLike) - } else { - None - } - } - - pub fn as_sql(self) -> &'static str { - match self { - Self::Like => "LIKE", - Self::ILike => "ILIKE", - Self::SimilarTo => "SIMILAR TO", - Self::RLike => "RLIKE", - } - } -} - -/// SQL expression tree. -/// -/// TODO: `Exists`, `InSubquery`, `Subquery`, and `Quantified` (`ANY` / -/// `ALL` / `SOME`) once `Query` exists. -#[derive(Debug, Clone, PartialEq)] -pub enum ExprEnum { - Ident(Ident), - Value(Value), - Tuple(Vec), - Unary { - op: UnaryOp, - expr: Box, - }, - Binary { - left: Box, - op: BinaryOp, - right: Box, - }, - Function(Func), - Is { - expr: Box, - negated: bool, - kind: IsKind, - }, - IsDistinctFrom { - left: Box, - right: Box, - negated: bool, - }, - InList { - expr: Box, - list: Vec, - negated: bool, - }, - Between { - expr: Box, - negated: bool, - low: Box, - high: Box, - }, - Like { - kind: LikeKind, - negated: bool, - expr: Box, - pattern: Box, - escape: Option, - }, - Cast { - expr: Box, - data_type: PyDataType, - }, - Case { - operand: Option>, - when_then: Vec<(ExprEnum, ExprEnum)>, - else_result: Option>, - }, - Custom { - sql: String, - args: Vec, - }, -} - -impl ExprEnum { - /// Lower is tighter binding. - fn precedence(&self) -> u8 { - match self { - Self::Ident(_) - | Self::Value(_) - | Self::Tuple(_) - | Self::Function(_) - | Self::Cast { .. } - | Self::Case { .. } - | Self::Custom { .. } => 0, - Self::Unary { .. } => 1, - Self::Binary { op, .. } => match op { - BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => 2, - BinaryOp::Add | BinaryOp::Sub => 3, - BinaryOp::BitAnd - | BinaryOp::BitOr - | BinaryOp::BitXor - | BinaryOp::LShift - | BinaryOp::RShift - | BinaryOp::StringConcat => 4, - BinaryOp::Eq - | BinaryOp::NotEq - | BinaryOp::Lt - | BinaryOp::LtEq - | BinaryOp::Gt - | BinaryOp::GtEq - | BinaryOp::Custom(_) => 5, - BinaryOp::And => 6, - BinaryOp::Xor => 7, - BinaryOp::Or => 8, - }, - Self::Is { .. } - | Self::IsDistinctFrom { .. } - | Self::InList { .. } - | Self::Between { .. } - | Self::Like { .. } => 5, - } - } - - fn write_operand( - &self, - dialect: &SqlDialect, - out: &mut W, - parent_prec: u8, - paren_if_equal: bool, - ) -> PrepareResult<()> { - let child_prec = self.precedence(); - let paren = child_prec > parent_prec || (paren_if_equal && child_prec == parent_prec); - if paren { - out.push_char('('); - } - self.write_sql(dialect, out)?; - if paren { - out.push_char(')'); - } - Ok(()) - } - - pub(crate) fn py_eq( - &self, - py: pyo3::Python<'_>, - other: &Self, - ) -> pyo3::PyResult { - match (self, other) { - (Self::Value(a), Self::Value(b)) => { - if a.serializer != b.serializer { - return Ok(false); - } - a.object.bind(py).eq(b.object.bind(py)) - } - (a, b) => Ok(a == b), - } - } - - pub(crate) fn py_hash(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - std::mem::discriminant(self).hash(&mut hasher); - match self { - Self::Ident(ident) => ident.hash(&mut hasher), - Self::Value(value) => { - value.object.bind(py).hash()?.hash(&mut hasher); - value.serializer.hash(&mut hasher); - } - Self::Tuple(items) => { - items.len().hash(&mut hasher); - for item in items { - item.py_hash(py)?.hash(&mut hasher); - } - } - Self::Unary { op, expr } => { - op.hash(&mut hasher); - expr.py_hash(py)?.hash(&mut hasher); - } - Self::Binary { left, op, right } => { - left.py_hash(py)?.hash(&mut hasher); - op.hash(&mut hasher); - right.py_hash(py)?.hash(&mut hasher); - } - Self::Function(func) => { - func.name.hash(&mut hasher); - // args/filter hashed via recursive walk in python_repr-level identity - match &func.args { - super::func::FuncArguments::None => 0u8.hash(&mut hasher), - super::func::FuncArguments::List { - duplicate_treatment, - args, - } => { - 1u8.hash(&mut hasher); - duplicate_treatment.hash(&mut hasher); - args.len().hash(&mut hasher); - for arg in args { - arg.py_hash(py)?.hash(&mut hasher); - } - } - } - match &func.filter { - Some(expr) => { - 1u8.hash(&mut hasher); - expr.py_hash(py)?.hash(&mut hasher); - } - None => 0u8.hash(&mut hasher), - } - } - Self::Is { - expr, - negated, - kind, - } => { - expr.py_hash(py)?.hash(&mut hasher); - negated.hash(&mut hasher); - kind.hash(&mut hasher); - } - Self::IsDistinctFrom { - left, - right, - negated, - } => { - left.py_hash(py)?.hash(&mut hasher); - right.py_hash(py)?.hash(&mut hasher); - negated.hash(&mut hasher); - } - Self::InList { - expr, - list, - negated, - } => { - expr.py_hash(py)?.hash(&mut hasher); - negated.hash(&mut hasher); - list.len().hash(&mut hasher); - for item in list { - item.py_hash(py)?.hash(&mut hasher); - } - } - Self::Between { - expr, - negated, - low, - high, - } => { - expr.py_hash(py)?.hash(&mut hasher); - negated.hash(&mut hasher); - low.py_hash(py)?.hash(&mut hasher); - high.py_hash(py)?.hash(&mut hasher); - } - Self::Like { - kind, - negated, - expr, - pattern, - escape, - } => { - kind.hash(&mut hasher); - negated.hash(&mut hasher); - expr.py_hash(py)?.hash(&mut hasher); - pattern.py_hash(py)?.hash(&mut hasher); - escape.hash(&mut hasher); - } - Self::Cast { expr, data_type } => { - expr.py_hash(py)?.hash(&mut hasher); - data_type.hash(&mut hasher); - } - Self::Case { - operand, - when_then, - else_result, - } => { - match operand { - Some(expr) => { - 1u8.hash(&mut hasher); - expr.py_hash(py)?.hash(&mut hasher); - } - None => 0u8.hash(&mut hasher), - } - when_then.len().hash(&mut hasher); - for (when, then) in when_then { - when.py_hash(py)?.hash(&mut hasher); - then.py_hash(py)?.hash(&mut hasher); - } - match else_result { - Some(expr) => { - 1u8.hash(&mut hasher); - expr.py_hash(py)?.hash(&mut hasher); - } - None => 0u8.hash(&mut hasher), - } - } - Self::Custom { sql, args } => { - sql.hash(&mut hasher); - args.len().hash(&mut hasher); - for arg in args { - arg.py_hash(py)?.hash(&mut hasher); - } - } - } - Ok(hasher.finish()) - } - - pub(crate) fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - match self { - Self::Ident(ident) => Ok(ident.python_repr()), - Self::Value(value) => value.python_repr(py), - Self::Tuple(items) => { - let mut buf = String::from("TupleExpr("); - join_reprs(py, &mut buf, items)?; - buf.push(')'); - Ok(buf) - } - Self::Unary { op, expr } => Ok(format!( - "Unary({:?}, {})", - op.as_sql(), - expr.python_repr(py)? - )), - Self::Binary { left, op, right } => Ok(format!( - "Binary({}, {:?}, {})", - left.python_repr(py)?, - op.as_sql(), - right.python_repr(py)? - )), - Self::Function(func) => func.python_repr(py), - Self::Is { - expr, - negated, - kind, - } => { - let mut buf = format!("Is({}, {:?}", expr.python_repr(py)?, kind.as_sql()); - if *negated { - buf.push_str(", negated=True"); - } - buf.push(')'); - Ok(buf) - } - Self::IsDistinctFrom { - left, - right, - negated, - } => { - let mut buf = format!( - "IsDistinctFrom({}, {}", - left.python_repr(py)?, - right.python_repr(py)? - ); - if *negated { - buf.push_str(", negated=True"); - } - buf.push(')'); - Ok(buf) - } - Self::InList { - expr, - list, - negated, - } => { - let mut buf = format!("InList({}, (", expr.python_repr(py)?); - join_reprs(py, &mut buf, list)?; - buf.push(')'); - if *negated { - buf.push_str(", negated=True"); - } - buf.push(')'); - Ok(buf) - } - Self::Between { - expr, - negated, - low, - high, - } => { - let mut buf = format!( - "Between({}, {}, {}", - expr.python_repr(py)?, - low.python_repr(py)?, - high.python_repr(py)? - ); - if *negated { - buf.push_str(", negated=True"); - } - buf.push(')'); - Ok(buf) - } - Self::Like { - kind, - negated, - expr, - pattern, - escape, - } => { - let mut buf = format!( - "Like({}, {}", - expr.python_repr(py)?, - pattern.python_repr(py)? - ); - if *kind != LikeKind::Like { - write!(buf, ", kind={:?}", kind.as_sql()).unwrap(); - } - if *negated { - buf.push_str(", negated=True"); - } - if let Some(c) = escape { - write!(buf, ", escape={c:?}").unwrap(); - } - buf.push(')'); - Ok(buf) - } - Self::Cast { expr, data_type } => Ok(format!( - "Cast({}, DataType({:?}, {}))", - expr.python_repr(py)?, - data_type.name.as_str(), - serializer_repr(&data_type.serializer) - )), - Self::Case { - operand, - when_then, - else_result, - } => { - let mut buf = String::from("Case(["); - for (i, (when, then)) in when_then.iter().enumerate() { - if i > 0 { - buf.push_str(", "); - } - write!( - buf, - "({}, {})", - when.python_repr(py)?, - then.python_repr(py)? - ) - .unwrap(); - } - buf.push(']'); - if let Some(operand) = operand { - write!(buf, ", operand={}", operand.python_repr(py)?).unwrap(); - } - if let Some(else_result) = else_result { - write!(buf, ", else_={}", else_result.python_repr(py)?).unwrap(); - } - buf.push(')'); - Ok(buf) - } - Self::Custom { sql, args } => { - let mut buf = format!("Custom({sql:?}"); - for arg in args { - buf.push_str(", "); - buf.push_str(&arg.python_repr(py)?); - } - buf.push(')'); - Ok(buf) - } - } - } -} - -fn join_reprs( - py: pyo3::Python<'_>, - buf: &mut String, - items: &[ExprEnum], -) -> pyo3::PyResult<()> { - for (i, item) in items.iter().enumerate() { - if i > 0 { - buf.push_str(", "); - } - buf.push_str(&item.python_repr(py)?); - } - Ok(()) -} - -impl WriteSql for ExprEnum { - fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { - match self { - Self::Ident(ident) => ident.write_sql(dialect, out), - Self::Value(value) => value.write_sql(dialect, out), - Self::Tuple(items) => { - out.push_char('('); - write_separated(dialect, out, items, ", ")?; - out.push_char(')'); - Ok(()) - } - Self::Unary { op, expr } => { - match op { - UnaryOp::Not => { - out.push_str("NOT "); - } - UnaryOp::Plus | UnaryOp::Minus | UnaryOp::BitNot => { - out.push_str(op.as_sql()); - } - } - expr.write_operand(dialect, out, self.precedence(), true) - } - Self::Binary { left, op, right } => { - let prec = self.precedence(); - left.write_operand(dialect, out, prec, false)?; - out.push_char(' '); - out.push_str(op.as_sql()); - out.push_char(' '); - right.write_operand(dialect, out, prec, true) - } - Self::Function(func) => func.write_sql(dialect, out), - Self::Is { - expr, - negated, - kind, - } => { - expr.write_operand(dialect, out, self.precedence(), false)?; - if *negated { - out.push_str(" IS NOT "); - } else { - out.push_str(" IS "); - } - out.push_str(kind.as_sql()); - Ok(()) - } - Self::IsDistinctFrom { - left, - right, - negated, - } => { - let prec = self.precedence(); - left.write_operand(dialect, out, prec, false)?; - if *negated { - out.push_str(" IS NOT DISTINCT FROM "); - } else { - out.push_str(" IS DISTINCT FROM "); - } - right.write_operand(dialect, out, prec, true) - } - Self::InList { - expr, - list, - negated, - } => { - expr.write_operand(dialect, out, self.precedence(), false)?; - if *negated { - out.push_str(" NOT IN ("); - } else { - out.push_str(" IN ("); - } - write_separated(dialect, out, list, ", ")?; - out.push_char(')'); - Ok(()) - } - Self::Between { - expr, - negated, - low, - high, - } => { - let prec = self.precedence(); - expr.write_operand(dialect, out, prec, false)?; - if *negated { - out.push_str(" NOT BETWEEN "); - } else { - out.push_str(" BETWEEN "); - } - low.write_operand(dialect, out, prec, true)?; - out.push_str(" AND "); - high.write_operand(dialect, out, prec, true) - } - Self::Like { - kind, - negated, - expr, - pattern, - escape, - } => { - let prec = self.precedence(); - expr.write_operand(dialect, out, prec, false)?; - out.push_char(' '); - if *negated { - out.push_str("NOT "); - } - out.push_str(kind.as_sql()); - out.push_char(' '); - pattern.write_operand(dialect, out, prec, true)?; - if let Some(c) = escape { - out.push_str(" ESCAPE "); - let raw = c.encode_utf8(&mut [0; 4]).to_string(); - dialect - .write_string_literal(out, &raw) - .map_err(|_| crate::sql::PrepareError::Write)?; - } - Ok(()) - } - Self::Cast { expr, data_type } => { - out.push_str("CAST("); - expr.write_sql(dialect, out)?; - out.push_str(" AS "); - data_type.write_sql(dialect, out)?; - out.push_char(')'); - Ok(()) - } - Self::Case { - operand, - when_then, - else_result, - } => { - out.push_str("CASE"); - if let Some(operand) = operand { - out.push_char(' '); - operand.write_sql(dialect, out)?; - } - for (when, then) in when_then { - out.push_str(" WHEN "); - when.write_sql(dialect, out)?; - out.push_str(" THEN "); - then.write_sql(dialect, out)?; - } - if let Some(else_result) = else_result { - out.push_str(" ELSE "); - else_result.write_sql(dialect, out)?; - } - out.push_str(" END"); - Ok(()) - } - Self::Custom { sql, args } => write_custom(sql, args, dialect, out), - } - } -} - -fn write_custom( - sql: &str, - args: &[ExprEnum], - dialect: &SqlDialect, - out: &mut W, -) -> PrepareResult<()> { - let bytes = sql.as_bytes(); - let mut i = 0; - let mut start = 0; - let mut used = 0usize; - while i < bytes.len() { - if bytes[i] == b'{' { - if i + 1 < bytes.len() && bytes[i + 1] == b'{' { - out.push_str(&sql[start..i]); - out.push_char('{'); - i += 2; - start = i; - continue; - } - if i + 1 < bytes.len() && bytes[i + 1] == b'}' { - out.push_str(&sql[start..i]); - if used >= args.len() { - return Err(crate::sql::PrepareError::CustomPlaceholders { - expected: used + 1, - got: args.len(), - }); - } - args[used].write_sql(dialect, out)?; - used += 1; - i += 2; - start = i; - continue; - } - } else if bytes[i] == b'}' - && i + 1 < bytes.len() - && bytes[i + 1] == b'}' - { - out.push_str(&sql[start..i]); - out.push_char('}'); - i += 2; - start = i; - continue; - } - i += 1; - } - out.push_str(&sql[start..]); - if used != args.len() { - return Err(crate::sql::PrepareError::CustomPlaceholders { - expected: used, - got: args.len(), - }); - } - Ok(()) -} diff --git a/src/expr/enum_/func.rs b/src/expr/enum_/func.rs new file mode 100644 index 0000000..cdea5cc --- /dev/null +++ b/src/expr/enum_/func.rs @@ -0,0 +1,109 @@ +use super::ident::Ident; +use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; + +use super::ExprEnum; + +/// How duplicate values are treated inside function argument lists. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DuplicateTreatment { + /// Consider only unique values. + Distinct, + /// Retain all duplicate values (the default). + All, +} + +impl DuplicateTreatment { + pub fn as_sql(self) -> &'static str { + match self { + Self::Distinct => "DISTINCT", + Self::All => "ALL", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum FunctionArguments { + /// Used for special functions like `CURRENT_TIMESTAMP` that are invoked + /// without parentheses. + None, + /// A normal function argument list, including `DISTINCT` / `ALL`. + List { + duplicate_treatment: Option, + args: Vec, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Function { + /// The function name + pub name: Ident, + /// The arguments to the function, including any options specified within the + /// delimiting parentheses. + pub args: FunctionArguments, + /// e.g. `x > 5` in `COUNT(x) FILTER (WHERE x > 5)` + pub filter: Option>, + // TODO: null_treatment, over, within_group +} + +impl Function { + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut buf = format!("Function({}", self.name.python_repr()); + match &self.args { + FunctionArguments::None => (), + FunctionArguments::List { + duplicate_treatment, + args, + } => { + for arg in args { + buf.push_str(", "); + buf.push_str(&arg.python_repr(py)?); + } + if let Some(dt) = duplicate_treatment { + match dt { + DuplicateTreatment::Distinct => buf.push_str(", distinct=True"), + DuplicateTreatment::All => buf.push_str(", distinct=False"), + } + } + } + } + if let Some(filter) = &self.filter { + buf.push_str(", filter="); + buf.push_str(&filter.python_repr(py)?); + } + buf.push(')'); + Ok(buf) + } +} + +impl WriteSql for Function { + fn write_sql( + &self, + dialect: &crate::dialect::SqlDialect, + out: &mut W, + ) -> PrepareResult<()> { + self.name.write_sql(dialect, out)?; + match &self.args { + FunctionArguments::None => {} + FunctionArguments::List { + duplicate_treatment, + args, + } => { + out.push_char('('); + if let Some(dt) = duplicate_treatment { + out.push_str(dt.as_sql()); + if !args.is_empty() { + out.push_char(' '); + } + } + write_separated(dialect, out, args, ", ")?; + out.push_char(')'); + } + } + if let Some(filter) = &self.filter { + out.push_str(" FILTER (WHERE "); + filter.write_sql(dialect, out)?; + out.push_char(')'); + } + Ok(()) + } +} diff --git a/src/expr/enum_/ident.rs b/src/expr/enum_/ident.rs new file mode 100644 index 0000000..18be7b6 --- /dev/null +++ b/src/expr/enum_/ident.rs @@ -0,0 +1,254 @@ +//! SQL identifiers. + +use crate::dialect::SqlDialect; +use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; + +/// One segment of a dotted identifier. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum IdentPart { + /// Identifier that should be quoted and escaped for the dialect. + Quote(arcstr::ArcStr), + /// Identifier emitted without delimiters. Must be a simple ASCII name. + Unquote(arcstr::ArcStr), + /// SQL wildcard `*`. + Asterisk, +} + +impl IdentPart { + /// Quoted part whose body is `s`. + /// + /// `"*"` is a quoted identifier named star, not [`IdentPart::Asterisk`]. + pub fn quoted(s: impl AsRef) -> Result { + let s = s.as_ref(); + if s.is_empty() { + return Err(ParseIdentError::EmptyChunk); + } + Ok(Self::Quote(arcstr::ArcStr::from(s))) + } + + /// Unquoted part whose body is `s`. + /// + /// Rejects anything that is not `[A-Za-z_][A-Za-z0-9_]*`. + pub fn unquoted(s: impl AsRef) -> Result { + let s = s.as_ref(); + if s.is_empty() { + return Err(ParseIdentError::EmptyChunk); + } + if !is_safe_unquoted(s) { + return Err(ParseIdentError::InvalidUnquoted); + } + Ok(Self::Unquote(arcstr::ArcStr::from(s))) + } +} + +impl> From for IdentPart { + /// Parser helper used by [`Ident::from_str`]. + /// + /// A literal `"*"` becomes [`IdentPart::Asterisk`]. Everything else is + /// [`IdentPart::Quote`] with no emptiness check — [`Ident::from_str`] + /// rejects empty chunks first. Python `str` extract does **not** use + /// this impl (it always goes through [`IdentPart::quoted`]). + fn from(value: S) -> Self { + let s = value.as_ref(); + if s == "*" { + IdentPart::Asterisk + } else { + IdentPart::Quote(arcstr::ArcStr::from(s)) + } + } +} + +impl WriteSql for IdentPart { + #[inline] + fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { + match self { + Self::Quote(name) => { + dialect.quote_identifier(out, name.as_str())?; + } + Self::Unquote(name) => { + out.push_str(name.as_str()); + } + Self::Asterisk => { + out.push_char('*'); + } + } + Ok(()) + } +} + +/// Represents a SQL identifier (`schema.table.column`). +/// +/// An identifier is a sequence of parts. Each constructor argument is one +/// part: a quoted name (`str`), an `Unquote` name, an `Asterisk` wildcard, +/// or a nested `Ident` whose parts are flattened. +/// +/// Never empty. At most [`Ident::MAX_PARTS`] parts. An [`IdentPart::Asterisk`] +/// may appear only as the last part. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Ident(smallvec::SmallVec<[IdentPart; 4]>); + +/// Why building an [`Ident`] failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParseIdentError { + /// No parts (empty string, empty constructor). + Empty, + /// A quoted or unquoted part was the empty string (`"a..b"`, `""` as a part). + EmptyChunk, + /// More than [`Ident::MAX_PARTS`] parts. + TooManyParts, + /// `*` appeared before the last part (`*.id`). + AsteriskNotLast, + /// Unquoted body was not a simple ASCII identifier. + InvalidUnquoted, +} + +impl std::fmt::Display for ParseIdentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Empty => "identifier is empty", + Self::EmptyChunk => "identifier has an empty part", + Self::TooManyParts => "identifier has too many parts", + Self::AsteriskNotLast => "asterisk must be the last identifier part", + Self::InvalidUnquoted => "unquoted identifier is not a simple ASCII name", + }) + } +} + +impl std::error::Error for ParseIdentError {} + +/// `[A-Za-z_][A-Za-z0-9_]*`. Empty string is not safe. +fn is_safe_unquoted(s: &str) -> bool { + let mut bytes = s.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + if !first.is_ascii_alphabetic() && first != b'_' { + return false; + } + bytes.all(|c| c.is_ascii_alphanumeric() || c == b'_') +} + +impl Ident { + /// Maximum number of dotted parts. + pub const MAX_PARTS: usize = 10; + + /// Borrowed parts in order. + #[inline] + pub fn parts(&self) -> &[IdentPart] { + &self.0 + } + + /// Number of parts. Always ≄ 1 for a successfully built `Ident`. + #[inline] + pub fn len(&self) -> usize { + self.0.len() + } + + /// `true` when there are no parts. Unreachable for a validated `Ident`. + #[inline] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Consume and yield the parts. + #[inline] + pub fn into_parts(self) -> smallvec::SmallVec<[IdentPart; 4]> { + self.0 + } + + pub fn python_repr(&self) -> String { + use std::fmt::Write; + + let mut result = String::from("Ident("); + let parts = self.parts(); + for (index, item) in parts.iter().enumerate() { + match item { + IdentPart::Quote(x) => write!(result, "{x:?}").unwrap(), + IdentPart::Unquote(x) => write!(result, "Unquote({x:?})").unwrap(), + IdentPart::Asterisk => result.push_str("Asterisk"), + } + if index + 1 < parts.len() { + result.push_str(", "); + } + } + result.push(')'); + result + } + + /// One already-validated part. Prefer this over [`from_parts`] for a + /// single `str` / `Unquote` / `Asterisk`. + #[inline] + pub fn from_one(part: IdentPart) -> Self { + Self(smallvec::smallvec![part]) + } + + /// Build from parts. Composition only: empty ident, empty chunk, + /// [`MAX_PARTS`](Self::MAX_PARTS), asterisk-not-last. + /// + /// Unquoted charset is enforced by [`IdentPart::unquoted`], not here. + #[cfg_attr(feature = "nightly", optimize(speed))] + pub fn from_parts(iter: impl IntoIterator) -> Result { + let mut parts = smallvec::SmallVec::<[IdentPart; 4]>::new(); + + for part in iter { + if let Some(IdentPart::Asterisk) = parts.last() { + return Err(ParseIdentError::AsteriskNotLast); + } + if parts.len() >= Self::MAX_PARTS { + return Err(ParseIdentError::TooManyParts); + } + match &part { + IdentPart::Quote(name) | IdentPart::Unquote(name) if name.is_empty() => { + return Err(ParseIdentError::EmptyChunk); + } + IdentPart::Quote(_) | IdentPart::Unquote(_) | IdentPart::Asterisk => {} + } + parts.push(part); + } + + if parts.is_empty() { + return Err(ParseIdentError::Empty); + } + + Ok(Self(parts)) + } +} + +impl std::str::FromStr for Ident { + type Err = ParseIdentError; + + #[cfg_attr(feature = "nightly", optimize(speed))] + fn from_str(s: &str) -> Result { + if s.is_empty() { + return Err(ParseIdentError::Empty); + } + + let mut parts = smallvec::SmallVec::<[IdentPart; 4]>::new(); + + for chunk in s.splitn(Self::MAX_PARTS + 1, '.') { + if chunk.is_empty() { + return Err(ParseIdentError::EmptyChunk); + } + if parts.len() >= Self::MAX_PARTS { + return Err(ParseIdentError::TooManyParts); + } + // A bare `*` only makes sense as the final part (e.g. `table.*`). + // If another part follows, demote the previous `*` to a quoted star. + if let Some(last) = parts.last_mut() + && matches!(last, IdentPart::Asterisk) + { + *last = IdentPart::Quote(arcstr::ArcStr::from("*")); + } + parts.push(chunk.into()); + } + + Ok(Self(parts)) + } +} + +impl WriteSql for Ident { + #[inline] + fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { + write_separated(dialect, out, &self.0, ".") + } +} diff --git a/src/expr/enum_/mod.rs b/src/expr/enum_/mod.rs new file mode 100644 index 0000000..2ddb913 --- /dev/null +++ b/src/expr/enum_/mod.rs @@ -0,0 +1,638 @@ +mod func; +mod ident; +mod operator; +mod value; + +pub use func::*; +pub use ident::*; +pub use operator::*; +pub use value::*; + +use crate::dialect::SqlDialect; +use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; +use std::fmt::Write; + +#[derive(Clone, Debug, PartialEq)] +pub struct Unary { + pub op: UnaryOp, + pub expr: Box, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Binary { + pub left: Box, + pub op: BinaryOp, + pub right: Box, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Is { + pub expr: Box, + pub negated: bool, + pub kind: IsKind, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct IsDistinctFrom { + pub left: Box, + pub right: Box, + pub negated: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct InList { + pub expr: Box, + pub list: Vec, + pub negated: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Between { + pub expr: Box, + pub negated: bool, + pub low: Box, + pub high: Box, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Like { + pub kind: LikeKind, + pub negated: bool, + pub expr: Box, + pub pattern: Box, + pub escape: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Cast { + pub expr: Box, + pub data_type: crate::data_type::DataTypeName, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Case { + pub operand: Option>, + pub when: Vec<(ExprEnum, ExprEnum)>, + pub else_result: Option>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Custom { + pub sql: arcstr::ArcStr, + pub args: Vec, +} + +/// SQL expression tree. +/// +/// TODO: `Exists`, `InSubquery`, `Subquery`, and `Quantified` (`ANY` / +/// `ALL` / `SOME`) once `Query` exists. +#[derive(Clone, Debug, PartialEq)] +pub enum ExprEnum { + /// Represents a SQL identifier + Ident(Ident), + /// A Python object plus the Serializer used to emit it as SQL. + Value(Value), + /// Represents a SQL tuple + Tuple(Vec), + /// Unary operation + Unary(Unary), + /// Binary operation + Binary(Binary), + /// Function + Function(Function), + /// IS [NOT] + Is(Is), + /// IS [NOT] DISTINCT FROM + IsDistinctFrom(IsDistinctFrom), + /// [NOT] IN + InList(InList), + /// [NOT] BETWEEN AND + Between(Between), + Like(Like), + Cast(Cast), + Case(Case), + Custom(Custom), +} + +fn join_reprs(py: pyo3::Python<'_>, buf: &mut String, items: &[ExprEnum]) -> pyo3::PyResult<()> { + for (i, item) in items.iter().enumerate() { + if i > 0 { + buf.push_str(", "); + } + buf.push_str(&item.python_repr(py)?); + } + Ok(()) +} + +impl From for ExprEnum { + #[inline] + fn from(value: Ident) -> Self { + Self::Ident(value) + } +} + +impl From for ExprEnum { + #[inline] + fn from(value: Value) -> Self { + Self::Value(value) + } +} + +impl From for ExprEnum { + #[inline] + fn from(value: Unary) -> Self { + Self::Unary(value) + } +} + +impl From for ExprEnum { + #[inline] + fn from(value: Binary) -> Self { + Self::Binary(value) + } +} + +impl From for ExprEnum { + #[inline] + fn from(value: Function) -> Self { + Self::Function(value) + } +} + +impl From for ExprEnum { + #[inline] + fn from(value: Is) -> Self { + Self::Is(value) + } +} + +impl From for ExprEnum { + #[inline] + fn from(value: IsDistinctFrom) -> Self { + Self::IsDistinctFrom(value) + } +} + +impl From for ExprEnum { + #[inline] + fn from(value: InList) -> Self { + Self::InList(value) + } +} + +impl From for ExprEnum { + #[inline] + fn from(value: Between) -> Self { + Self::Between(value) + } +} + +impl From for ExprEnum { + #[inline] + fn from(value: Like) -> Self { + Self::Like(value) + } +} + +impl From for ExprEnum { + #[inline] + fn from(value: Cast) -> Self { + Self::Cast(value) + } +} + +impl From for ExprEnum { + #[inline] + fn from(value: Case) -> Self { + Self::Case(value) + } +} + +impl From for ExprEnum { + #[inline] + fn from(value: Custom) -> Self { + Self::Custom(value) + } +} + +impl ExprEnum { + /// Lower is tighter binding. + #[inline] + fn precedence(&self) -> u8 { + match self { + Self::Ident(_) + | Self::Value(_) + | Self::Tuple(_) + | Self::Function(_) + | Self::Cast(_) + | Self::Case(_) + | Self::Custom(_) => 0, + Self::Unary(_) => 1, + Self::Binary(Binary { op, .. }) => match op { + BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => 2, + BinaryOp::Add | BinaryOp::Sub => 3, + BinaryOp::BitAnd + | BinaryOp::BitOr + | BinaryOp::BitXor + | BinaryOp::LShift + | BinaryOp::RShift + | BinaryOp::StringConcat => 4, + BinaryOp::Eq + | BinaryOp::NotEq + | BinaryOp::Lt + | BinaryOp::LtEq + | BinaryOp::Gt + | BinaryOp::GtEq + | BinaryOp::Custom(_) => 5, + BinaryOp::And => 6, + BinaryOp::Xor => 7, + BinaryOp::Or => 8, + }, + Self::Is(_) + | Self::IsDistinctFrom(_) + | Self::InList(_) + | Self::Between(_) + | Self::Like(_) => 5, + } + } + + #[inline] + fn write_operand( + &self, + dialect: &SqlDialect, + out: &mut W, + parent_prec: u8, + paren_if_equal: bool, + ) -> PrepareResult<()> { + let child_prec = self.precedence(); + let paren = child_prec > parent_prec || (paren_if_equal && child_prec == parent_prec); + if paren { + out.push_char('('); + } + self.write_sql(dialect, out)?; + if paren { + out.push_char(')'); + } + Ok(()) + } + + #[inline] + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + match self { + Self::Ident(ident) => Ok(ident.python_repr()), + Self::Value(value) => value.python_repr(py), + Self::Tuple(items) => { + let mut buf = String::from("TupleExpr("); + join_reprs(py, &mut buf, items)?; + buf.push(')'); + Ok(buf) + } + Self::Unary(Unary { op, expr }) => Ok(format!( + "UnaryExpr({:?}, {})", + op.as_sql(), + expr.python_repr(py)? + )), + Self::Binary(Binary { left, op, right }) => Ok(format!( + "BinaryExpr({}, {:?}, {})", + left.python_repr(py)?, + op.as_sql(), + right.python_repr(py)? + )), + Self::Function(func) => func.python_repr(py), + Self::Is(Is { + expr, + negated, + kind, + }) => { + let mut buf = format!("IsExpr({}, {:?}", expr.python_repr(py)?, kind.as_sql()); + if *negated { + buf.push_str(", negated=True"); + } + buf.push(')'); + Ok(buf) + } + Self::IsDistinctFrom(IsDistinctFrom { + left, + right, + negated, + }) => { + let mut buf = format!( + "IsDistinctFromExpr({}, {}", + left.python_repr(py)?, + right.python_repr(py)? + ); + if *negated { + buf.push_str(", negated=True"); + } + buf.push(')'); + Ok(buf) + } + Self::InList(InList { + expr, + list, + negated, + }) => { + let mut buf = format!("InListExpr({}, (", expr.python_repr(py)?); + join_reprs(py, &mut buf, list)?; + buf.push(')'); + if *negated { + buf.push_str(", negated=True"); + } + buf.push(')'); + Ok(buf) + } + Self::Between(Between { + expr, + negated, + low, + high, + }) => { + let mut buf = format!( + "BetweenExpr({}, {}, {}", + expr.python_repr(py)?, + low.python_repr(py)?, + high.python_repr(py)? + ); + if *negated { + buf.push_str(", negated=True"); + } + buf.push(')'); + Ok(buf) + } + Self::Like(Like { + kind, + negated, + expr, + pattern, + escape, + }) => { + let mut buf = format!( + "LikeExpr({}, {}", + expr.python_repr(py)?, + pattern.python_repr(py)? + ); + if *kind != LikeKind::Like { + write!(buf, ", kind={:?}", kind.as_sql()).unwrap(); + } + if *negated { + buf.push_str(", negated=True"); + } + if let Some(c) = escape { + write!(buf, ", escape={c:?}").unwrap(); + } + buf.push(')'); + Ok(buf) + } + Self::Cast(Cast { expr, data_type }) => Ok(format!( + "CastExpr({}, {:?})", + expr.python_repr(py)?, + data_type.as_str(), + )), + Self::Case(Case { + operand, + when: when_then, + else_result, + }) => { + let mut buf = String::from("CaseExpr(["); + for (i, (when, then)) in when_then.iter().enumerate() { + if i > 0 { + buf.push_str(", "); + } + write!( + buf, + "({}, {})", + when.python_repr(py)?, + then.python_repr(py)? + ) + .unwrap(); + } + buf.push(']'); + if let Some(operand) = operand { + write!(buf, ", operand={}", operand.python_repr(py)?).unwrap(); + } + if let Some(else_result) = else_result { + write!(buf, ", else_={}", else_result.python_repr(py)?).unwrap(); + } + buf.push(')'); + Ok(buf) + } + Self::Custom(Custom { sql, args }) => { + let mut buf = format!("CustomExpr({sql:?}"); + for arg in args { + buf.push_str(", "); + buf.push_str(&arg.python_repr(py)?); + } + buf.push(')'); + Ok(buf) + } + } + } +} + +impl WriteSql for ExprEnum { + #[cfg_attr(feature = "nightly", optimize(speed))] + fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { + match self { + Self::Ident(ident) => ident.write_sql(dialect, out), + Self::Value(value) => value.write_sql(dialect, out), + Self::Tuple(items) => { + out.push_char('('); + write_separated(dialect, out, items, ", ")?; + out.push_char(')'); + Ok(()) + } + Self::Unary(Unary { op, expr }) => { + match op { + UnaryOp::Not => { + out.push_str("NOT "); + } + UnaryOp::Plus | UnaryOp::Minus | UnaryOp::BitNot => { + out.push_str(op.as_sql()); + } + } + expr.write_operand(dialect, out, self.precedence(), true) + } + Self::Binary(Binary { left, op, right }) => { + let prec = self.precedence(); + left.write_operand(dialect, out, prec, false)?; + out.push_char(' '); + out.push_str(op.as_sql()); + out.push_char(' '); + right.write_operand(dialect, out, prec, true) + } + Self::Function(func) => func.write_sql(dialect, out), + Self::Is(Is { + expr, + negated, + kind, + }) => { + expr.write_operand(dialect, out, self.precedence(), false)?; + if *negated { + out.push_str(" IS NOT "); + } else { + out.push_str(" IS "); + } + out.push_str(kind.as_sql()); + Ok(()) + } + Self::IsDistinctFrom(IsDistinctFrom { + left, + right, + negated, + }) => { + let prec = self.precedence(); + left.write_operand(dialect, out, prec, false)?; + if *negated { + out.push_str(" IS NOT DISTINCT FROM "); + } else { + out.push_str(" IS DISTINCT FROM "); + } + right.write_operand(dialect, out, prec, true) + } + Self::InList(InList { + expr, + list, + negated, + }) => { + expr.write_operand(dialect, out, self.precedence(), false)?; + if *negated { + out.push_str(" NOT IN ("); + } else { + out.push_str(" IN ("); + } + write_separated(dialect, out, list, ", ")?; + out.push_char(')'); + Ok(()) + } + Self::Between(Between { + expr, + negated, + low, + high, + }) => { + let prec = self.precedence(); + expr.write_operand(dialect, out, prec, false)?; + if *negated { + out.push_str(" NOT BETWEEN "); + } else { + out.push_str(" BETWEEN "); + } + low.write_operand(dialect, out, prec, true)?; + out.push_str(" AND "); + high.write_operand(dialect, out, prec, true) + } + Self::Like(Like { + kind, + negated, + expr, + pattern, + escape, + }) => { + let prec = self.precedence(); + expr.write_operand(dialect, out, prec, false)?; + out.push_char(' '); + if *negated { + out.push_str("NOT "); + } + out.push_str(kind.as_sql()); + out.push_char(' '); + pattern.write_operand(dialect, out, prec, true)?; + if let Some(c) = escape { + out.push_str(" ESCAPE "); + let raw = c.encode_utf8(&mut [0; 4]).to_string(); + dialect + .write_string_literal(out, &raw) + .map_err(|_| crate::sql::PrepareError::Write)?; + } + Ok(()) + } + Self::Cast(Cast { expr, data_type }) => { + out.push_str("CAST("); + expr.write_sql(dialect, out)?; + out.push_str(" AS "); + data_type.write_sql(dialect, out)?; + out.push_char(')'); + Ok(()) + } + Self::Case(Case { + operand, + when: when_then, + else_result, + }) => { + out.push_str("CASE"); + if let Some(operand) = operand { + out.push_char(' '); + operand.write_sql(dialect, out)?; + } + for (when, then) in when_then { + out.push_str(" WHEN "); + when.write_sql(dialect, out)?; + out.push_str(" THEN "); + then.write_sql(dialect, out)?; + } + if let Some(else_result) = else_result { + out.push_str(" ELSE "); + else_result.write_sql(dialect, out)?; + } + out.push_str(" END"); + Ok(()) + } + Self::Custom(Custom { sql, args }) => write_custom(sql, args, dialect, out), + } + } +} + +#[cfg_attr(feature = "nightly", optimize(speed))] +fn write_custom( + sql: &str, + args: &[ExprEnum], + dialect: &SqlDialect, + out: &mut W, +) -> PrepareResult<()> { + let bytes = sql.as_bytes(); + let mut i = 0; + let mut start = 0; + let mut used = 0usize; + + while i < bytes.len() { + if bytes[i] == b'{' { + if i + 1 < bytes.len() && bytes[i + 1] == b'{' { + out.push_str(&sql[start..i]); + out.push_char('{'); + i += 2; + start = i; + continue; + } + if i + 1 < bytes.len() && bytes[i + 1] == b'}' { + out.push_str(&sql[start..i]); + if used >= args.len() { + return Err(crate::sql::PrepareError::CustomPlaceholders { + expected: used + 1, + got: args.len(), + }); + } + args[used].write_sql(dialect, out)?; + used += 1; + i += 2; + start = i; + continue; + } + } else if bytes[i] == b'}' && i + 1 < bytes.len() && bytes[i + 1] == b'}' { + out.push_str(&sql[start..i]); + out.push_char('}'); + i += 2; + start = i; + continue; + } + i += 1; + } + out.push_str(&sql[start..]); + + if used != args.len() { + return Err(crate::sql::PrepareError::CustomPlaceholders { + expected: used, + got: args.len(), + }); + } + Ok(()) +} diff --git a/src/expr/enum_/operator.rs b/src/expr/enum_/operator.rs new file mode 100644 index 0000000..2cd205f --- /dev/null +++ b/src/expr/enum_/operator.rs @@ -0,0 +1,184 @@ +/// Unary operator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum UnaryOp { + Not, + Plus, + Minus, + BitNot, +} + +impl UnaryOp { + #[inline] + pub fn parse(s: &str) -> Option { + match s { + "NOT" | "not" | "Not" => Some(Self::Not), + "+" => Some(Self::Plus), + "-" => Some(Self::Minus), + "~" => Some(Self::BitNot), + _ => None, + } + } + + #[inline] + pub fn as_sql(self) -> &'static str { + match self { + Self::Not => "NOT", + Self::Plus => "+", + Self::Minus => "-", + Self::BitNot => "~", + } + } +} + +/// Binary operator. Unknown spellings become [`BinaryOp::Custom`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum BinaryOp { + Add, + Sub, + Mul, + Div, + Mod, + Eq, + NotEq, + Lt, + LtEq, + Gt, + GtEq, + And, + Or, + Xor, + BitAnd, + BitOr, + BitXor, + LShift, + RShift, + StringConcat, + Custom(arcstr::ArcStr), +} + +impl BinaryOp { + #[inline] + pub fn parse(s: &str) -> Self { + match s { + "+" => Self::Add, + "-" => Self::Sub, + "*" => Self::Mul, + "/" => Self::Div, + "%" => Self::Mod, + "=" => Self::Eq, + "<>" | "!=" => Self::NotEq, + "<" => Self::Lt, + "<=" => Self::LtEq, + ">" => Self::Gt, + ">=" => Self::GtEq, + "AND" | "and" | "And" => Self::And, + "OR" | "or" | "Or" => Self::Or, + "XOR" | "xor" | "Xor" => Self::Xor, + "&" => Self::BitAnd, + "|" => Self::BitOr, + "^" => Self::BitXor, + "<<" => Self::LShift, + ">>" => Self::RShift, + "||" => Self::StringConcat, + other => Self::Custom(arcstr::ArcStr::from(other)), + } + } + + #[inline] + pub fn as_sql(&self) -> &str { + match self { + Self::Add => "+", + Self::Sub => "-", + Self::Mul => "*", + Self::Div => "/", + Self::Mod => "%", + Self::Eq => "=", + Self::NotEq => "<>", + Self::Lt => "<", + Self::LtEq => "<=", + Self::Gt => ">", + Self::GtEq => ">=", + Self::And => "AND", + Self::Or => "OR", + Self::Xor => "XOR", + Self::BitAnd => "&", + Self::BitOr => "|", + Self::BitXor => "^", + Self::LShift => "<<", + Self::RShift => ">>", + Self::StringConcat => "||", + Self::Custom(s) => s.as_str(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum IsKind { + Null, + True, + False, + Unknown, +} + +impl IsKind { + #[inline] + pub fn parse(s: &str) -> Option { + if s.eq_ignore_ascii_case("NULL") { + Some(Self::Null) + } else if s.eq_ignore_ascii_case("TRUE") { + Some(Self::True) + } else if s.eq_ignore_ascii_case("FALSE") { + Some(Self::False) + } else if s.eq_ignore_ascii_case("UNKNOWN") { + Some(Self::Unknown) + } else { + None + } + } + + #[inline] + pub fn as_sql(self) -> &'static str { + match self { + Self::Null => "NULL", + Self::True => "TRUE", + Self::False => "FALSE", + Self::Unknown => "UNKNOWN", + } + } +} + +/// Pattern-matching operator family. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum LikeKind { + Like, + ILike, + SimilarTo, + RLike, +} + +impl LikeKind { + #[inline] + pub fn parse(s: &str) -> Option { + if s.eq_ignore_ascii_case("LIKE") { + Some(Self::Like) + } else if s.eq_ignore_ascii_case("ILIKE") { + Some(Self::ILike) + } else if s.eq_ignore_ascii_case("SIMILAR TO") { + Some(Self::SimilarTo) + } else if s.eq_ignore_ascii_case("RLIKE") { + Some(Self::RLike) + } else { + None + } + } + + #[inline] + pub fn as_sql(self) -> &'static str { + match self { + Self::Like => "LIKE", + Self::ILike => "ILIKE", + Self::SimilarTo => "SIMILAR TO", + Self::RLike => "RLIKE", + } + } +} diff --git a/src/expr/enum_/value.rs b/src/expr/enum_/value.rs new file mode 100644 index 0000000..afe5eb8 --- /dev/null +++ b/src/expr/enum_/value.rs @@ -0,0 +1,55 @@ +//! A Python object plus the [`Serializer`] used to emit it as SQL. + +use pyo3::types::{PyAnyMethods, PyStringMethods}; + +use crate::data_type::{SerializerEnum, serializer_repr}; +use crate::dialect::SqlDialect; +use crate::internal::alias; +use crate::sql::{PrepareResult, SqlWriter, WriteSql}; + +/// A Python object plus the [`Serializer`] used to emit it as SQL. +/// +/// Parameterized SQL binds [`Self::object`] as a placeholder and ignores the +/// serializer. Raw SQL (`BufferedSql`) calls [`Serializer::write_sql`]; +/// Python `None` becomes `NULL`. +/// +/// Clone requires the GIL (`Py::clone`). +#[derive(Debug, Clone)] +pub struct Value { + pub object: alias::PyObject, + pub serializer: SerializerEnum, +} + +impl Value { + #[inline] + pub fn new(object: alias::PyObject, serializer: SerializerEnum) -> Self { + Self { object, serializer } + } + + #[inline] + pub fn infer_bound(bound: alias::BoundObject) -> Self { + let serializer = SerializerEnum::infer_bound(&bound); + Self::new(bound.unbind(), serializer) + } + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let obj_repr = self.object.bind(py).repr()?; + Ok(format!( + "Value({}, {})", + obj_repr.to_cow()?, + serializer_repr(&self.serializer) + )) + } +} + +impl WriteSql for Value { + fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { + out.push_value(dialect, &self.serializer, &self.object) + } +} + +impl PartialEq for Value { + fn eq(&self, other: &Self) -> bool { + self.serializer == other.serializer && self.object.as_ptr() == other.object.as_ptr() + } +} diff --git a/src/expr/extractors.rs b/src/expr/extractors.rs new file mode 100644 index 0000000..61f20ad --- /dev/null +++ b/src/expr/extractors.rs @@ -0,0 +1,186 @@ +use pyo3::types::PyTupleMethods; + +use super::enum_::{ExprEnum, Ident, IdentPart}; +use crate::internal::utils; + +/// `true` when `obj` is `Asterisk` or `type[Asterisk]`. +#[inline] +unsafe fn is_asterisk(obj: *mut pyo3::ffi::PyObject) -> bool { + let type_ptr = if pyo3::ffi::PyType_CheckExact(obj) == 1 { + obj.cast::() + } else { + pyo3::ffi::Py_TYPE(obj) + }; + type_ptr == crate::typeref::ASTERISK_TYPE +} + +/// Ident-priority extractor: a bare `str` becomes [`ExprEnum::Ident`]. +pub struct ExprExtractor(pub ExprEnum); + +/// Literal-priority extractor: a bare `str` becomes [`ExprEnum::Value`]. +pub struct LiteralExprExtractor(pub ExprEnum); + +/// Extracts a native [`Ident`](crate::Ident) from a Python object. +/// +/// Accepts `Ident`, `str` (one quoted part, including `"*"`), `Unquote`, +/// `Asterisk`, and `type[Asterisk]`. Sequences are rejected. +pub struct IdentExtractor(pub Ident); + +/// Accepts `PyExpr` only when it is a condition, not a value or ident. +pub struct ConditionExtractor(pub ExprEnum); + +fn extract_expr( + obj: pyo3::prelude::Borrowed<'_, '_, pyo3::prelude::PyAny>, + str_as_ident: bool, +) -> pyo3::PyResult { + let ptr = obj.as_ptr(); + unsafe { + if crate::typeref::check(ptr, crate::typeref::EXPR_TYPE) { + let expr = obj.cast_unchecked::(); + return Ok(expr.get().0.clone()); + } + + if crate::typeref::check_exact(ptr, crate::typeref::UNQUOTE_TYPE) { + let unquote = obj.cast_unchecked::(); + let part = IdentPart::unquoted(unquote.get().0.as_str()) + .map_err(|err| rapidquery_macros::pyerr!(PyValueError, "{}", err))?; + + return Ok(ExprEnum::Ident(Ident::from_one(part))); + } + + if is_asterisk(ptr) { + return Ok(ExprEnum::Ident(Ident::from_one(IdentPart::Asterisk))); + } + + if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { + let s = utils::borrow_py_str_buffer(obj.py(), ptr)?; + + // a bare `str` becomes [`ExprEnum::Ident`] if `str_as_ident` + if str_as_ident { + let part = IdentPart::quoted(s) + .map_err(|err| rapidquery_macros::pyerr!(PyValueError, "{}", err))?; + + return Ok(ExprEnum::Ident(Ident::from_one(part))); + } + + return Ok(ExprEnum::Value(super::enum_::Value::infer_bound( + obj.as_any().clone(), + ))); + } + + if pyo3::ffi::PyTuple_CheckExact(ptr) == 1 { + let tuple = obj.cast_unchecked::(); + let mut items = Vec::with_capacity(tuple.len()); + + for item in tuple.iter() { + items.push(extract_expr(item.as_borrowed(), str_as_ident)?); + } + + return Ok(ExprEnum::Tuple(items)); + } + } + + Ok(ExprEnum::Value(super::enum_::Value::infer_bound( + obj.as_any().clone(), + ))) +} + +pub(super) enum ExtractedIdentArgument { + Ident(Ident), + Part(IdentPart), +} + +/// Hot-path extract: `str` first, then Ident / Unquote / Asterisk. +#[inline] +pub(super) fn extract_ident_argument( + obj: pyo3::prelude::Borrowed<'_, '_, pyo3::prelude::PyAny>, +) -> pyo3::PyResult { + let ptr = obj.as_ptr(); + unsafe { + // SAFETY: `ptr` is a live borrowed `PyObject`. Type pointers are + // interned in `initialize_typeref` before any extractor runs. + if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { + let s = utils::borrow_py_str_buffer(obj.py(), ptr)?; + let part = IdentPart::quoted(s) + .map_err(|e| rapidquery_macros::pyerr!(PyValueError, "{}", e))?; + + return Ok(ExtractedIdentArgument::Part(part)); + } + + if crate::typeref::check_exact(ptr, crate::typeref::IDENT_TYPE) { + let expr = obj.cast_unchecked::(); + + match &expr.get().0 { + ExprEnum::Ident(ident) => { + return Ok(ExtractedIdentArgument::Ident(ident.clone())); + } + _ => unreachable!("Ident instance always holds ExprEnum::Ident"), + } + } + + if crate::typeref::check_exact(ptr, crate::typeref::UNQUOTE_TYPE) { + let unquote = obj.cast_unchecked::(); + let part = IdentPart::unquoted(unquote.get().0.as_str()) + .map_err(|e| rapidquery_macros::pyerr!(PyValueError, "{}", e))?; + + return Ok(ExtractedIdentArgument::Part(part)); + } + + if is_asterisk(ptr) { + return Ok(ExtractedIdentArgument::Part(IdentPart::Asterisk)); + } + } + + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected str, Ident, Unquote or Asterisk, got {}", + unsafe { crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) } + ) +} + +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for ExprExtractor { + type Error = pyo3::PyErr; + + #[inline] + fn extract( + obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, + ) -> Result { + extract_expr(obj, true).map(Self) + } +} + +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for LiteralExprExtractor { + type Error = pyo3::PyErr; + + #[inline] + fn extract( + obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, + ) -> Result { + extract_expr(obj, false).map(Self) + } +} + +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for IdentExtractor { + type Error = pyo3::PyErr; + + #[inline] + fn extract( + obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, + ) -> Result { + match extract_ident_argument(obj)? { + ExtractedIdentArgument::Ident(ident) => Ok(Self(ident)), + ExtractedIdentArgument::Part(part) => Ok(Self(Ident::from_one(part))), + } + } +} + +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for ConditionExtractor { + type Error = pyo3::PyErr; + + #[inline] + fn extract( + obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, + ) -> Result { + todo!() + } +} diff --git a/src/expr/func.rs b/src/expr/func.rs index add687c..832948b 100644 --- a/src/expr/func.rs +++ b/src/expr/func.rs @@ -1,93 +1,10 @@ use pyo3::types::{PyAnyMethods, PyTupleMethods}; -use super::ident::{Ident, IdentExtractor}; -use crate::internal::alias; -use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; - -use super::enum_::{DuplicateTreatment, ExprEnum}; -use super::{ExprExtractor, PyExpr, subclass}; - -#[derive(Clone, Debug, PartialEq)] -pub enum FuncArguments { - /// Used for special functions like `CURRENT_TIMESTAMP` that are invoked - /// without parentheses. - None, - /// A normal function argument list, including `DISTINCT` / `ALL`. - List { - duplicate_treatment: Option, - args: Vec, - }, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct Func { - pub name: Ident, - pub args: FuncArguments, - pub filter: Option>, -} +use crate::internal::{alias, utils}; -impl Func { - pub(crate) fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = format!("Func({})", self.name.python_repr()); - match &self.args { - FuncArguments::None => buf.push_str(", parentheses=False"), - FuncArguments::List { - duplicate_treatment, - args, - } => { - for arg in args { - buf.push_str(", "); - buf.push_str(&arg.python_repr(py)?); - } - if let Some(dt) = duplicate_treatment { - match dt { - DuplicateTreatment::Distinct => buf.push_str(", distinct=True"), - DuplicateTreatment::All => buf.push_str(", distinct=False"), - } - } - } - } - if let Some(filter) = &self.filter { - buf.push_str(", filter="); - buf.push_str(&filter.python_repr(py)?); - } - buf.push(')'); - Ok(buf) - } -} - -impl WriteSql for Func { - fn write_sql( - &self, - dialect: &crate::dialect::SqlDialect, - out: &mut W, - ) -> PrepareResult<()> { - self.name.write_sql(dialect, out)?; - match &self.args { - FuncArguments::None => {} - FuncArguments::List { - duplicate_treatment, - args, - } => { - out.push_char('('); - if let Some(dt) = duplicate_treatment { - out.push_str(dt.as_sql()); - if !args.is_empty() { - out.push_char(' '); - } - } - write_separated(dialect, out, args, ", ")?; - out.push_char(')'); - } - } - if let Some(filter) = &self.filter { - out.push_str(" FILTER (WHERE "); - filter.write_sql(dialect, out)?; - out.push_char(')'); - } - Ok(()) - } -} +use super::base::PyExpr; +use super::enum_::{DuplicateTreatment, FunctionArguments}; +use super::extractors::{ExprExtractor, IdentExtractor}; /// SQL function call. /// @@ -100,49 +17,50 @@ impl WriteSql for Func { /// args: Positional argument expressions. A bare `str` is a column. /// distinct: `True` → `DISTINCT`, `False` → `ALL`, omitted → neither. /// filter: Optional `FILTER (WHERE …)` predicate. -/// parentheses: `False` omits `()`. #[rapidquery_macros::pyclass( - "Func", + "Function", skip_from_py_object, - extends = PyExpr, + extends = super::base::PyExpr, module = "rapidquery._core.expr" )] #[derive(Debug, Default)] -pub struct PyFunc; +pub struct PyFunction; + +impl PyFunction { + #[inline(always)] + fn inner<'a>(slf: &'a pyo3::PyRef<'a, PyFunction>) -> &'a super::enum_::Function { + debug_assert!( + matches!(&slf.as_super().0, super::enum_::ExprEnum::Function(..)), + "PyFunction instance always holds ExprEnum::Function" + ); -#[inline] -fn func_inner<'a>(slf: &'a pyo3::PyRef<'a, PyFunc>) -> &'a Func { - match &slf.as_super().0 { - ExprEnum::Function(func) => func, - _ => unreachable!("Func instance always holds ExprEnum::Function"), + match &slf.as_super().0 { + super::enum_::ExprEnum::Function(v) => v, + _ => unsafe { std::hint::unreachable_unchecked() }, + } } } #[pyo3::pymethods] -impl PyFunc { +impl PyFunction { #[new] - #[pyo3(signature = (name, *args, distinct = None, filter = None, parentheses = true))] + #[pyo3(signature = (name, *args, distinct = None, filter = None))] fn __new__( name: IdentExtractor, args: alias::ArgsType<'_>, distinct: Option, filter: Option, - parentheses: bool, ) -> pyo3::PyResult> { - let func_args = if !parentheses { - if !args.is_empty() || distinct.is_some() { - return rapidquery_macros::pyerr!( - Err(PyValueError), - "parentheses=False cannot be combined with arguments or distinct" - ); - } - FuncArguments::None + let func_args = if args.is_empty() { + FunctionArguments::None } else { let mut extracted = Vec::with_capacity(args.len()); + for item in args.iter() { extracted.push(item.extract::()?.0); } - FuncArguments::List { + + FunctionArguments::List { duplicate_treatment: match distinct { Some(true) => Some(DuplicateTreatment::Distinct), Some(false) => Some(DuplicateTreatment::All), @@ -152,44 +70,54 @@ impl PyFunc { } }; - Ok(subclass::(ExprEnum::Function(Func { + let inner = super::enum_::Function { name: name.0, args: func_args, filter: filter.map(|ExprExtractor(expr)| Box::new(expr)), - }))) + }; + Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) } - #[getter] - fn get_name( - slf: pyo3::PyRef<'_, Self>, - ) -> pyo3::PyResult> { - PyExpr(ExprEnum::Ident(func_inner(&slf).name.clone())).into_subclasses(slf.py()) + fn get_name(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let inner = Self::inner(&slf); + PyExpr(super::enum_::ExprEnum::Ident(inner.name.clone())).into_subclasses(slf.py()) } - #[getter] - fn get_args( - slf: pyo3::PyRef<'_, Self>, - ) -> pyo3::PyResult> { + fn get_args(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { let py = slf.py(); - match &func_inner(&slf).args { - FuncArguments::None => Ok(py.None().into_bound(py).into_any()), - FuncArguments::List { args, .. } => { - let mut items = Vec::with_capacity(args.len()); - for arg in args { - items.push(PyExpr(arg.clone()).into_subclasses(py)?); + let inner = Self::inner(&slf); + + match &inner.args { + FunctionArguments::None => Ok(utils::new_pynone(py)), + FunctionArguments::List { args, .. } => { + // instead of creating a Vec and then creating a PyTuple, which is `T(n) = 2n` + // we can use unsafe tuple to create PyTuple directly, which is `T(n) = n` + let tuple = unsafe { pyo3::ffi::PyTuple_New(args.len() as isize) }; + + for (ix, arg) in args.iter().enumerate() { + match PyExpr(arg.clone()).into_subclasses(py) { + Ok(bound) => unsafe { + pyo3::ffi::PyTuple_SetItem(tuple, ix as isize, bound.into_ptr()); + }, + Err(err) => unsafe { + pyo3::ffi::Py_DECREF(tuple); + return Err(err); + }, + } } - Ok(pyo3::types::PyTuple::new(py, items)?.into_any()) + + Ok(unsafe { pyo3::Bound::from_owned_ptr(py, tuple) }) } } } - #[getter] - fn get_filter( - slf: pyo3::PyRef<'_, Self>, - ) -> pyo3::PyResult>> { - match &func_inner(&slf).filter { - Some(expr) => Ok(Some(PyExpr((**expr).clone()).into_subclasses(slf.py())?)), - None => Ok(None), + fn get_filter(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let py = slf.py(); + let inner = Self::inner(&slf); + + match &inner.filter { + Some(e) => super::base::into_subclasses(py, (**e).clone()), + None => Ok(utils::new_pynone(py)), } } } diff --git a/src/expr/ident.rs b/src/expr/ident.rs index e7ca157..6671482 100644 --- a/src/expr/ident.rs +++ b/src/expr/ident.rs @@ -1,281 +1,8 @@ -//! SQL identifiers. - -use std::fmt; - -use arcstr::ArcStr; -use smallvec::{SmallVec, smallvec}; - -use crate::dialect::SqlDialect; -use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; - -/// One segment of a dotted identifier. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum IdentPart { - /// Identifier that should be quoted and escaped for the dialect. - Quote(ArcStr), - /// Identifier emitted without delimiters. Must be a simple ASCII name. - Unquote(ArcStr), - /// SQL wildcard `*`. - Asterisk, -} - -impl IdentPart { - /// Quoted part whose body is `s`. - /// - /// `"*"` is a quoted identifier named star, not [`IdentPart::Asterisk`]. - pub fn quoted(s: impl AsRef) -> Result { - let s = s.as_ref(); - if s.is_empty() { - return Err(ParseIdentError::EmptyChunk); - } - Ok(Self::Quote(ArcStr::from(s))) - } - - /// Unquoted part whose body is `s`. - /// - /// Rejects anything that is not `[A-Za-z_][A-Za-z0-9_]*`. - pub fn unquoted(s: impl AsRef) -> Result { - let s = s.as_ref(); - if s.is_empty() { - return Err(ParseIdentError::EmptyChunk); - } - if !is_safe_unquoted(s) { - return Err(ParseIdentError::InvalidUnquoted); - } - Ok(Self::Unquote(ArcStr::from(s))) - } -} - -impl> From for IdentPart { - /// Parser helper used by [`Ident::from_str`]. - /// - /// A literal `"*"` becomes [`IdentPart::Asterisk`]. Everything else is - /// [`IdentPart::Quote`] with no emptiness check — [`Ident::from_str`] - /// rejects empty chunks first. Python `str` extract does **not** use - /// this impl (it always goes through [`IdentPart::quoted`]). - fn from(value: S) -> Self { - let s = value.as_ref(); - if s == "*" { - IdentPart::Asterisk - } else { - IdentPart::Quote(ArcStr::from(s)) - } - } -} - -impl WriteSql for IdentPart { - #[inline] - fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { - match self { - Self::Quote(name) => { - dialect.quote_identifier(out, name.as_str())?; - } - Self::Unquote(name) => { - out.push_str(name.as_str()); - } - Self::Asterisk => { - out.push_char('*'); - } - } - Ok(()) - } -} - -/// Represents a SQL identifier (`schema.table.column`). -/// -/// An identifier is a sequence of parts. Each constructor argument is one -/// part: a quoted name (`str`), an `Unquote` name, an `Asterisk` wildcard, -/// or a nested `Ident` whose parts are flattened. -/// -/// Never empty. At most [`Ident::MAX_PARTS`] parts. An [`IdentPart::Asterisk`] -/// may appear only as the last part. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Ident(SmallVec<[IdentPart; 4]>); - -/// Why building an [`Ident`] failed. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ParseIdentError { - /// No parts (empty string, empty constructor). - Empty, - /// A quoted or unquoted part was the empty string (`"a..b"`, `""` as a part). - EmptyChunk, - /// More than [`Ident::MAX_PARTS`] parts. - TooManyParts, - /// `*` appeared before the last part (`*.id`). - AsteriskNotLast, - /// Unquoted body was not a simple ASCII identifier. - InvalidUnquoted, -} - -impl fmt::Display for ParseIdentError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(match self { - Self::Empty => "identifier is empty", - Self::EmptyChunk => "identifier has an empty part", - Self::TooManyParts => "identifier has too many parts", - Self::AsteriskNotLast => "asterisk must be the last identifier part", - Self::InvalidUnquoted => "unquoted identifier is not a simple ASCII name", - }) - } -} - -impl std::error::Error for ParseIdentError {} - -/// `[A-Za-z_][A-Za-z0-9_]*`. Empty string is not safe. -fn is_safe_unquoted(s: &str) -> bool { - let mut bytes = s.bytes(); - let Some(first) = bytes.next() else { - return false; - }; - if !first.is_ascii_alphabetic() && first != b'_' { - return false; - } - bytes.all(|c| c.is_ascii_alphanumeric() || c == b'_') -} - -impl Ident { - /// Maximum number of dotted parts. - pub const MAX_PARTS: usize = 10; - - /// Borrowed parts in order. - #[inline] - pub fn parts(&self) -> &[IdentPart] { - &self.0 - } - - /// Number of parts. Always ≄ 1 for a successfully built `Ident`. - #[inline] - pub fn len(&self) -> usize { - self.0.len() - } - - /// `true` when there are no parts. Unreachable for a validated `Ident`. - #[inline] - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } - - /// Consume and yield the parts. - #[inline] - pub fn into_parts(self) -> SmallVec<[IdentPart; 4]> { - self.0 - } - - pub(crate) fn python_repr(&self) -> String { - use std::fmt::Write; - - let mut result = String::from("Ident("); - let parts = self.parts(); - for (index, item) in parts.iter().enumerate() { - match item { - IdentPart::Quote(x) => write!(result, "{x:?}").unwrap(), - IdentPart::Unquote(x) => write!(result, "Unquote({x:?})").unwrap(), - IdentPart::Asterisk => result.push_str("Asterisk"), - } - if index + 1 < parts.len() { - result.push_str(", "); - } - } - result.push(')'); - result - } - - /// One already-validated part. Prefer this over [`from_parts`] for a - /// single `str` / `Unquote` / `Asterisk`. - #[inline] - pub fn from_one(part: IdentPart) -> Self { - Self(smallvec![part]) - } - - /// Build from parts. Composition only: empty ident, empty chunk, - /// [`MAX_PARTS`](Self::MAX_PARTS), asterisk-not-last. - /// - /// Unquoted charset is enforced by [`IdentPart::unquoted`], not here. - #[cfg_attr(feature = "nightly", optimize(speed))] - pub fn from_parts(iter: impl IntoIterator) -> Result { - let mut parts = SmallVec::<[IdentPart; 4]>::new(); - - for part in iter { - if let Some(IdentPart::Asterisk) = parts.last() { - return Err(ParseIdentError::AsteriskNotLast); - } - if parts.len() >= Self::MAX_PARTS { - return Err(ParseIdentError::TooManyParts); - } - match &part { - IdentPart::Quote(name) | IdentPart::Unquote(name) if name.is_empty() => { - return Err(ParseIdentError::EmptyChunk); - } - IdentPart::Quote(_) | IdentPart::Unquote(_) | IdentPart::Asterisk => {} - } - parts.push(part); - } - - if parts.is_empty() { - return Err(ParseIdentError::Empty); - } - - Ok(Self(parts)) - } -} - -impl std::str::FromStr for Ident { - type Err = ParseIdentError; - - #[cfg_attr(feature = "nightly", optimize(speed))] - fn from_str(s: &str) -> Result { - if s.is_empty() { - return Err(ParseIdentError::Empty); - } - - let mut parts = SmallVec::<[IdentPart; 4]>::new(); - - for chunk in s.splitn(Self::MAX_PARTS + 1, '.') { - if chunk.is_empty() { - return Err(ParseIdentError::EmptyChunk); - } - if parts.len() >= Self::MAX_PARTS { - return Err(ParseIdentError::TooManyParts); - } - // A bare `*` only makes sense as the final part (e.g. `table.*`). - // If another part follows, demote the previous `*` to a quoted star. - if let Some(last) = parts.last_mut() - && matches!(last, IdentPart::Asterisk) - { - *last = IdentPart::Quote(ArcStr::from("*")); - } - parts.push(chunk.into()); - } - - Ok(Self(parts)) - } -} - -impl WriteSql for Ident { - #[inline] - fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { - write_separated(dialect, out, &self.0, ".") - } -} - -// --------------------------------------------------------------------------- -// Python surface (`Ident`, `Unquote`, `Asterisk`) -// --------------------------------------------------------------------------- -// -// A Python `str` is always a quoted part, including `"*"`. The wildcard is -// `Asterisk`. Dotted names are `Ident("schema", "table")`; the native -// [`FromStr`](std::str::FromStr) parser is not used on the Python constructor. - -use pyo3::types::{PyAnyMethods, PySliceMethods, PyTupleMethods}; +use pyo3::types::PyTupleMethods; +use super::enum_::{Ident, IdentPart, ParseIdentError}; +use super::extractors::{ExtractedIdentArgument, extract_ident_argument}; use crate::internal::alias; -use crate::internal::utils; -use crate::sql::write_sql; - -#[inline] -fn parse_err(err: ParseIdentError) -> pyo3::PyErr { - rapidquery_macros::pyerr!(PyValueError, "{}", err) -} /// Convert a native part into the matching Python object (`str`, `Unquote`, /// or `Asterisk`). @@ -293,91 +20,6 @@ fn ident_part_to_pyobject<'py>( } } -/// `true` when `obj` is `Asterisk` or `type[Asterisk]`. -/// -/// # Safety -/// -/// `obj` must be a valid, non-null `PyObject*`. `ASTERISK_TYPE` must have -/// been interned by [`crate::typeref::initialize_typeref`]. The GIL must -/// be held. -#[inline] -unsafe fn is_asterisk(obj: *mut pyo3::ffi::PyObject) -> bool { - let type_ptr = if pyo3::ffi::PyType_CheckExact(obj) == 1 { - obj.cast::() - } else { - pyo3::ffi::Py_TYPE(obj) - }; - type_ptr == crate::typeref::ASTERISK_TYPE -} - -enum Extracted { - Ident(Ident), - Part(IdentPart), -} - -/// Hot-path extract: `str` first, then Ident / Unquote / Asterisk. -#[inline] -fn extract_arg( - obj: pyo3::prelude::Borrowed<'_, '_, pyo3::prelude::PyAny>, -) -> pyo3::PyResult { - let ptr = obj.as_ptr(); - unsafe { - // SAFETY: `ptr` is a live borrowed `PyObject`. Type pointers are - // interned in `initialize_typeref` before any extractor runs. - if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { - let s = utils::py_str(obj.py(), ptr)?; - let part = IdentPart::quoted(s).map_err(parse_err)?; - return Ok(Extracted::Part(part)); - } - - if crate::typeref::check_exact(ptr, crate::typeref::IDENT_TYPE) { - let expr = obj.cast_unchecked::(); - match &expr.get().0 { - super::ExprEnum::Ident(ident) => { - return Ok(Extracted::Ident(ident.clone())); - } - _ => unreachable!("Ident instance always holds ExprEnum::Ident"), - } - } - - if crate::typeref::check_exact(ptr, crate::typeref::UNQUOTE_TYPE) { - let unquote = obj.cast_unchecked::(); - let part = IdentPart::unquoted(unquote.get().0.as_str()).map_err(parse_err)?; - return Ok(Extracted::Part(part)); - } - - if is_asterisk(ptr) { - return Ok(Extracted::Part(IdentPart::Asterisk)); - } - } - - rapidquery_macros::pyerr!( - Err(PyTypeError), - "expected str, Ident, Unquote or Asterisk, got {}", - unsafe { crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) } - ) -} - -/// Extracts a native [`Ident`](crate::Ident) from a Python object. -/// -/// Accepts `Ident`, `str` (one quoted part, including `"*"`), `Unquote`, -/// `Asterisk`, and `type[Asterisk]`. Sequences are rejected. -pub struct IdentExtractor(pub Ident); - -impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for IdentExtractor { - type Error = pyo3::PyErr; - - #[inline] - fn extract( - obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, - ) -> Result { - match extract_arg(obj)? { - Extracted::Ident(ident) => Ok(Self(ident)), - Extracted::Part(part) => Ok(Self(Ident::from_one(part))), - } - } -} - /// Represents a SQL identifier (`schema.table.column`). /// /// An identifier is a sequence of parts. Each constructor argument is one @@ -402,24 +44,25 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for IdentExtractor { #[rapidquery_macros::pyclass( "Ident", skip_from_py_object, - extends = super::PyExpr, + extends = super::base::PyExpr, module = "rapidquery._core.expr" )] #[derive(Debug, Default)] pub struct PyIdent; -#[inline] -fn ident_inner<'a>(slf: &'a pyo3::PyRef<'a, PyIdent>) -> &'a Ident { - match &slf.as_super().0 { - super::ExprEnum::Ident(ident) => ident, - _ => unreachable!("Ident instance always holds ExprEnum::Ident"), - } -} +impl PyIdent { + #[inline(always)] + fn inner<'a>(slf: &'a pyo3::PyRef<'a, PyIdent>) -> &'a Ident { + debug_assert!( + matches!(&slf.as_super().0, super::enum_::ExprEnum::Ident(..)), + "PyIdent instance always holds ExprEnum::Ident" + ); -#[inline] -fn wrap_ident(ident: Ident) -> pyo3::PyClassInitializer { - pyo3::PyClassInitializer::from(super::PyExpr(super::ExprEnum::Ident(ident))) - .add_subclass(PyIdent) + match &slf.as_super().0 { + super::enum_::ExprEnum::Ident(ident) => ident, + _ => unsafe { std::hint::unreachable_unchecked() }, + } + } } #[pyo3::pymethods] @@ -428,95 +71,35 @@ impl PyIdent { #[pyo3(signature = (*parts))] fn __new__(parts: alias::ArgsType) -> pyo3::PyResult> { let ident = match parts.len() { - 0 => return Err(parse_err(ParseIdentError::Empty)), - 1 => match extract_arg(parts.get_item(0)?.as_borrowed())? { - Extracted::Ident(ident) => ident, - Extracted::Part(part) => Ident::from_one(part), + 0 => return rapidquery_macros::pyerr!(Err(PyValueError), "{}", ParseIdentError::Empty), + 1 => match extract_ident_argument(parts.get_item(0)?.as_borrowed())? { + ExtractedIdentArgument::Ident(ident) => ident, + ExtractedIdentArgument::Part(part) => Ident::from_one(part), }, _ => { - let mut acc = SmallVec::<[IdentPart; 4]>::new(); + let mut acc = smallvec::SmallVec::<[IdentPart; 4]>::new(); for item in parts.iter() { - match extract_arg(item.as_borrowed())? { - Extracted::Ident(ident) => acc.extend(ident.into_parts()), - Extracted::Part(part) => acc.push(part), + match extract_ident_argument(item.as_borrowed())? { + ExtractedIdentArgument::Ident(ident) => acc.extend(ident.into_parts()), + ExtractedIdentArgument::Part(part) => acc.push(part), } } - Ident::from_parts(acc).map_err(parse_err)? - } - }; - Ok(wrap_ident(ident)) - } - - fn __len__(slf: pyo3::PyRef<'_, Self>) -> usize { - ident_inner(&slf).len() - } - /// Returns a part by index, or a sub-identifier by slice. - /// - /// Args: - /// index: An `int` (negative indices count from the end) or a - /// `slice`. - /// - /// Returns: - /// A `str`, `Unquote`, or `Asterisk` for an integer index. An - /// `Ident` for a slice. - /// - /// Raises: - /// IndexError: If the integer index is out of range. - /// ValueError: If the slice selects no parts. - fn __getitem__<'py>( - slf: pyo3::PyRef<'py, Self>, - py: pyo3::Python<'py>, - index: utils::IndexOrSlice, - ) -> pyo3::PyResult> { - let parts = ident_inner(&slf).parts(); - let len = parts.len() as isize; - - match index { - utils::IndexOrSlice::Index(i) => { - let idx = if i < 0 { i + len } else { i }; - if idx < 0 || idx >= len { - return rapidquery_macros::pyerr!( - Err(PyIndexError), - "Ident index out of range" - ); - } - ident_part_to_pyobject(py, &parts[idx as usize]) + Ident::from_parts(acc) + .map_err(|e| rapidquery_macros::pyerr!(PyValueError, "{}", e))? } - utils::IndexOrSlice::Slice(slice) => { - let indices = slice.bind(py).indices(len)?; - let mut selected = Vec::with_capacity(indices.slicelength); - - let mut i = indices.start; - if indices.step > 0 { - while i < indices.stop { - selected.push(parts[i as usize].clone()); - i += indices.step; - } - } else { - while i > indices.stop { - selected.push(parts[i as usize].clone()); - i += indices.step; - } - } + }; - let inner = Ident::from_parts(selected).map_err(parse_err)?; - super::PyExpr(super::ExprEnum::Ident(inner)).into_subclasses(py) - } - } + Ok( + pyo3::PyClassInitializer::from(super::base::PyExpr(super::enum_::ExprEnum::Ident( + ident, + ))) + .add_subclass(PyIdent), + ) } - fn __iter__<'py>( - slf: pyo3::PyRef<'py, Self>, - py: pyo3::Python<'py>, - ) -> pyo3::PyResult> { - let items = ident_inner(&slf) - .parts() - .iter() - .map(|part| ident_part_to_pyobject(py, part)) - .collect::>>()?; - let list = pyo3::types::PyList::new(py, items)?; - list.try_iter() + fn __len__(slf: pyo3::PyRef<'_, Self>) -> usize { + Self::inner(&slf).len() } /// Returns the parts as a list (`str`, `Unquote`, or `Asterisk`). @@ -524,30 +107,12 @@ impl PyIdent { slf: pyo3::PyRef<'_, Self>, py: pyo3::Python<'_>, ) -> pyo3::PyResult> { - ident_inner(&slf) + Self::inner(&slf) .parts() .iter() .map(|part| ident_part_to_pyobject(py, part).map(|bound| bound.unbind())) .collect() } - - /// Renders this identifier as SQL for `dialect`. - /// - /// Args: - /// dialect: A dialect class (`PostgreSQL`) or instance - /// (`PostgreSQL()`). - /// - /// Returns: - /// The dotted identifier with each part quoted or emitted according - /// to its kind. - fn to_sql( - slf: pyo3::PyRef<'_, Self>, - py: pyo3::Python<'_>, - dialect: crate::dialect::py::PySqlDialect, - ) -> pyo3::PyResult { - write_sql(py, ident_inner(&slf), &dialect.0) - .map_err(|e| rapidquery_macros::pyerr!(PyRuntimeError, "{}", e)) - } } /// Marker for an unquoted identifier part. @@ -577,7 +142,9 @@ impl PyUnquote { #[new] #[pyo3(signature = (name, /))] fn __new__(name: &str) -> pyo3::PyResult { - IdentPart::unquoted(name).map_err(parse_err)?; + // validate the `name` + IdentPart::unquoted(name).map_err(|e| rapidquery_macros::pyerr!(PyValueError, "{}", e))?; + Ok(Self(arcstr::ArcStr::from(name))) } @@ -621,211 +188,3 @@ impl PyAsterisk { "Asterisk()" } } - -#[cfg(test)] -mod tests { - use std::str::FromStr; - - use super::*; - - fn ident_from(parts: impl IntoIterator) -> Ident { - Ident::from_parts(parts).unwrap() - } - - fn quote(s: &str) -> IdentPart { - IdentPart::quoted(s).unwrap() - } - - #[test] - fn from_str_splits_on_dots() { - let ident = Ident::from_str("public.users.id").unwrap(); - assert_eq!( - ident.parts(), - &[quote("public"), quote("users"), quote("id"),] - ); - } - - #[test] - fn from_str_bare_star_is_asterisk() { - let ident = Ident::from_str("*").unwrap(); - assert_eq!(ident.parts(), &[IdentPart::Asterisk]); - } - - #[test] - fn from_str_table_star_keeps_asterisk_last() { - let ident = Ident::from_str("t.*").unwrap(); - assert_eq!(ident.parts(), &[quote("t"), IdentPart::Asterisk]); - } - - #[test] - fn from_str_mid_star_is_demoted_to_quoted() { - let ident = Ident::from_str("*.id").unwrap(); - assert_eq!(ident.parts(), &[quote("*"), quote("id")]); - } - - #[test] - fn from_str_rejects_empty_and_empty_chunks() { - assert_eq!(Ident::from_str("").unwrap_err(), ParseIdentError::Empty); - assert_eq!( - Ident::from_str("a.").unwrap_err(), - ParseIdentError::EmptyChunk - ); - assert_eq!( - Ident::from_str(".a").unwrap_err(), - ParseIdentError::EmptyChunk - ); - assert_eq!( - Ident::from_str("a..b").unwrap_err(), - ParseIdentError::EmptyChunk - ); - } - - #[test] - fn from_str_rejects_too_many_parts() { - let ten = (0..Ident::MAX_PARTS) - .map(|i| format!("p{i}")) - .collect::>() - .join("."); - assert!(Ident::from_str(&ten).is_ok()); - - let eleven = format!("{ten}.extra"); - assert_eq!( - Ident::from_str(&eleven).unwrap_err(), - ParseIdentError::TooManyParts - ); - } - - #[test] - fn from_parts_rejects_empty() { - assert_eq!(Ident::from_parts([]).unwrap_err(), ParseIdentError::Empty); - } - - #[test] - fn from_parts_rejects_empty_quoted_part() { - assert_eq!( - Ident::from_parts([IdentPart::Quote(ArcStr::from(""))]).unwrap_err(), - ParseIdentError::EmptyChunk - ); - } - - #[test] - fn from_parts_rejects_asterisk_not_last() { - assert_eq!( - Ident::from_parts([IdentPart::Asterisk, quote("id")]).unwrap_err(), - ParseIdentError::AsteriskNotLast - ); - } - - #[test] - fn unquoted_rejects_invalid() { - for bad in ["", "*", "a;b", "a.b", "1id", "a-b", "a b", "cafĆ©"] { - let err = IdentPart::unquoted(bad).unwrap_err(); - assert!( - err == ParseIdentError::InvalidUnquoted || err == ParseIdentError::EmptyChunk, - "{bad:?} -> {err:?}" - ); - } - } - - #[test] - fn unquoted_accepts_simple_ascii_names() { - for ok in ["Users", "id", "_x", "a1", "created_at"] { - let part = IdentPart::unquoted(ok).unwrap(); - match part { - IdentPart::Unquote(n) => assert_eq!(n.as_str(), ok), - _ => panic!("expected Unquote"), - } - } - } - - #[test] - fn quoted_star_is_not_asterisk() { - let part = quote("*"); - assert_ne!(part, IdentPart::Asterisk); - match part { - IdentPart::Quote(n) => assert_eq!(n.as_str(), "*"), - _ => panic!("expected Quote"), - } - } - - #[test] - fn quote_unquote_asterisk_are_not_equal() { - let q = quote("id"); - let u = IdentPart::unquoted("id").unwrap(); - assert_ne!(q, u); - assert_ne!(q, IdentPart::Asterisk); - assert_ne!(u, IdentPart::Asterisk); - assert_ne!(ident_from([q.clone()]), ident_from([u.clone()])); - } - - #[test] - fn from_one_is_single_part() { - let ident = Ident::from_one(quote("id")); - assert_eq!(ident.len(), 1); - assert_eq!(ident.parts(), &[quote("id")]); - } - - #[test] - fn parse_ident_error_display_is_not_todo() { - let msg = ParseIdentError::Empty.to_string(); - assert!(!msg.is_empty()); - assert!(!msg.contains("todo")); - } - - #[test] - fn prepare_uses_dialect_delimiters() { - fn render(ident: &Ident, dialect: SqlDialect) -> String { - let mut buf = String::new(); - for (i, part) in ident.parts().iter().enumerate() { - if i > 0 { - buf.push('.'); - } - match part { - IdentPart::Quote(n) => { - dialect.quote_identifier(&mut buf, n.as_str()).unwrap(); - } - IdentPart::Unquote(n) => buf.push_str(n.as_str()), - IdentPart::Asterisk => buf.push('*'), - } - } - buf - } - - let ident = ident_from([quote("id")]); - assert_eq!(render(&ident, SqlDialect::PostgreSql), "\"id\""); - assert_eq!(render(&ident, SqlDialect::MySql), "`id`"); - assert_eq!(render(&ident, SqlDialect::MsSql), "[id]"); - assert_eq!(render(&ident, SqlDialect::Teradata), "'id'"); - - let ident = ident_from([quote("a\"b")]); - assert_eq!(render(&ident, SqlDialect::PostgreSql), "\"a\"\"b\""); - - let ident = ident_from([quote("a`b")]); - assert_eq!(render(&ident, SqlDialect::MySql), "`a``b`"); - - let ident = ident_from([quote("a]b")]); - assert_eq!(render(&ident, SqlDialect::MsSql), "[a]]b]"); - - let ident = ident_from([quote("a'b")]); - assert_eq!(render(&ident, SqlDialect::Teradata), "'a''b'"); - - let ident = ident_from([quote("a; DROP TABLE t--")]); - assert_eq!( - render(&ident, SqlDialect::PostgreSql), - "\"a; DROP TABLE t--\"" - ); - - let ident = ident_from([ - quote("public"), - IdentPart::unquoted("Users").unwrap(), - quote("id"), - ]); - assert_eq!( - render(&ident, SqlDialect::PostgreSql), - "\"public\".Users.\"id\"" - ); - - let ident = ident_from([quote("t"), IdentPart::Asterisk]); - assert_eq!(render(&ident, SqlDialect::MySql), "`t`.*"); - } -} diff --git a/src/expr/mod.rs b/src/expr/mod.rs index e18ab5b..2d067da 100644 --- a/src/expr/mod.rs +++ b/src/expr/mod.rs @@ -1,205 +1,28 @@ -//! SQL expressions: engine enum plus a Serializer-style Python class tree. -//! -//! [`PyExpr`] holds [`ExprEnum`]. Concrete nodes (`Ident`, `Binary`, …) are -//! empty frozen subclasses. Shared methods live on the base. -//! -//! Suggested future methods (not implemented): `to_sql` / `prepare`; `eq` / -//! `ne` / `lt` / `le` / `gt` / `ge`; `add` / `sub` / `mul` / `div` / `mod`; -//! `and_` / `or_` / `xor` / `not_`; `bit_and` / `bit_or`; `like` / `ilike` / -//! `between` / `in_` / `is_null` / `is_distinct_from` / `cast`; `case`; -//! `custom`. Prefer named methods over v0 dunders that return `Expr`. +pub mod base; +pub mod enum_; +pub mod extractors; +pub mod func; +pub mod ident; +pub mod nodes; +pub mod value; -mod enum_; -mod func; -mod ident; -mod nodes; -mod value; - -#[cfg(test)] -mod tests; - -pub use enum_::{BinaryOp, DuplicateTreatment, ExprEnum, IsKind, LikeKind, UnaryOp}; -pub use func::{Func, FuncArguments, PyFunc}; -pub use ident::{Ident, IdentExtractor, IdentPart, ParseIdentError, PyAsterisk, PyIdent, PyUnquote}; -pub use nodes::{ - PyBetween, PyBinary, PyCase, PyCast, PyCustom, PyInList, PyIs, PyIsDistinctFrom, PyLike, - PyTupleExpr, PyUnary, -}; -pub use value::{PyValue, Value}; - -use pyo3::types::PyTupleMethods; - -use crate::internal::alias; -use crate::internal::utils; - -/// Abstract SQL expression. -/// -/// Concrete nodes are frozen subclasses (`Ident`, `Value`, `Func`, -/// `Binary`, …). This base class cannot be instantiated. -/// -/// Future methods (`to_sql`, `eq`, `like`, `between`, `in_`, `is_null`, -/// `cast`, `case`, `custom`, arithmetic/logic) will live here so every -/// subclass inherits them. Prefer named methods over v0-style dunders that -/// return `Expr`. -#[rapidquery_macros::pyclass( - "Expr", - subclass, - skip_from_py_object, - module = "rapidquery._core.expr" -)] -#[derive(Debug, Clone)] -pub struct PyExpr(pub ExprEnum); - -fn wrap<'py, T>( - py: pyo3::Python<'py>, - base: PyExpr, -) -> pyo3::PyResult> -where - T: pyo3::PyClass + Default, -{ - pyo3::Bound::new( - py, - pyo3::PyClassInitializer::from(base).add_subclass(T::default()), - ) - .map(|x| x.into_any()) -} - -pub(crate) fn subclass>( - inner: ExprEnum, -) -> pyo3::PyClassInitializer { - pyo3::PyClassInitializer::from(PyExpr(inner)).add_subclass(T::default()) -} - -impl PyExpr { - /// Wrap `self` as the matching Python subclass. - pub fn into_subclasses<'py>( - self, - py: pyo3::Python<'py>, - ) -> pyo3::PyResult> { - match &self.0 { - ExprEnum::Ident(_) => wrap::(py, self), - ExprEnum::Value(_) => wrap::(py, self), - ExprEnum::Tuple(_) => wrap::(py, self), - ExprEnum::Unary { .. } => wrap::(py, self), - ExprEnum::Binary { .. } => wrap::(py, self), - ExprEnum::Function(_) => wrap::(py, self), - ExprEnum::Is { .. } => wrap::(py, self), - ExprEnum::IsDistinctFrom { .. } => wrap::(py, self), - ExprEnum::InList { .. } => wrap::(py, self), - ExprEnum::Between { .. } => wrap::(py, self), - ExprEnum::Like { .. } => wrap::(py, self), - ExprEnum::Cast { .. } => wrap::(py, self), - ExprEnum::Case { .. } => wrap::(py, self), - ExprEnum::Custom { .. } => wrap::(py, self), - } - } -} - -#[pyo3::pymethods] -impl PyExpr { - fn __repr__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - self.0.python_repr(py) - } - - fn __eq__(&self, py: pyo3::Python<'_>, other: &pyo3::Bound<'_, Self>) -> pyo3::PyResult { - self.0.py_eq(py, &other.get().0) - } - - fn __hash__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - self.0.py_hash(py) - } -} - -/// Ident-priority extractor: a bare `str` becomes [`ExprEnum::Ident`]. -pub struct ExprExtractor(pub ExprEnum); - -/// Literal-priority extractor: a bare `str` becomes [`ExprEnum::Value`]. -pub struct LiteralExprExtractor(pub ExprEnum); - -fn extract_expr( - obj: pyo3::prelude::Borrowed<'_, '_, pyo3::prelude::PyAny>, - str_as_ident: bool, -) -> pyo3::PyResult { - let ptr = obj.as_ptr(); - unsafe { - if crate::typeref::check(ptr, crate::typeref::EXPR_TYPE) { - let expr = obj.cast_unchecked::(); - return Ok(expr.get().0.clone()); - } - - if crate::typeref::check_exact(ptr, crate::typeref::UNQUOTE_TYPE) { - let unquote = obj.cast_unchecked::(); - let part = IdentPart::unquoted(unquote.get().0.as_str()).map_err(|err| { - rapidquery_macros::pyerr!(PyValueError, "{}", err) - })?; - return Ok(ExprEnum::Ident(Ident::from_one(part))); - } - - if is_asterisk(ptr) { - return Ok(ExprEnum::Ident(Ident::from_one(IdentPart::Asterisk))); - } - - if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { - let s = utils::py_str(obj.py(), ptr)?; - if str_as_ident { - let part = IdentPart::quoted(s).map_err(|err| { - rapidquery_macros::pyerr!(PyValueError, "{}", err) - })?; - return Ok(ExprEnum::Ident(Ident::from_one(part))); - } - return Ok(ExprEnum::Value(Value::infer_bound(obj.as_any().clone()))); - } - - if pyo3::ffi::PyTuple_CheckExact(ptr) == 1 { - let tuple = obj.cast_unchecked::(); - let mut items = Vec::with_capacity(tuple.len()); - for item in tuple.iter() { - items.push(extract_expr(item.as_borrowed(), str_as_ident)?); - } - return Ok(ExprEnum::Tuple(items)); - } - } - - Ok(ExprEnum::Value(Value::infer_bound(obj.as_any().clone()))) -} - -/// `true` when `obj` is `Asterisk` or `type[Asterisk]`. -unsafe fn is_asterisk(obj: *mut pyo3::ffi::PyObject) -> bool { - let type_ptr = if pyo3::ffi::PyType_CheckExact(obj) == 1 { - obj.cast::() - } else { - pyo3::ffi::Py_TYPE(obj) - }; - type_ptr == crate::typeref::ASTERISK_TYPE -} - -impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for ExprExtractor { - type Error = pyo3::PyErr; +#[pyo3::pymodule(name = "expr", gil_used = false)] +pub mod expr_mod { + #[pymodule_export] + use super::base::PyExpr; - #[inline] - fn extract( - obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, - ) -> Result { - extract_expr(obj, true).map(Self) - } -} + #[pymodule_export] + use super::func::PyFunction; -impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for LiteralExprExtractor { - type Error = pyo3::PyErr; + #[pymodule_export] + use super::ident::{PyAsterisk, PyIdent, PyUnquote}; - #[inline] - fn extract( - obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, - ) -> Result { - extract_expr(obj, false).map(Self) - } -} + #[pymodule_export] + use super::value::PyValue; -#[pyo3::pymodule(name = "expr", gil_used = false)] -pub mod expr_mod { #[pymodule_export] - use super::{ - PyAsterisk, PyBetween, PyBinary, PyCase, PyCast, PyCustom, PyExpr, PyFunc, PyIdent, - PyInList, PyIs, PyIsDistinctFrom, PyLike, PyTupleExpr, PyUnary, PyUnquote, PyValue, + use super::nodes::{ + PyBetweenExpr, PyBinaryExpr, PyCaseExpr, PyCastExpr, PyCustomExpr, PyInListExpr, + PyIsDistinctFromExpr, PyIsExpr, PyLikeExpr, PyTupleExpr, PyUnaryExpr, }; } diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index fc3a98b..4476ec3 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -1,77 +1,82 @@ use pyo3::types::{PyAnyMethods, PyTupleMethods}; -use crate::data_type::PyDataType; -use crate::internal::alias; - -use super::enum_::{BinaryOp, ExprEnum, IsKind, LikeKind, UnaryOp}; -use super::{ExprExtractor, LiteralExprExtractor, PyExpr, subclass}; - -#[inline] -fn child<'py>( - py: pyo3::Python<'py>, - expr: &ExprEnum, -) -> pyo3::PyResult> { - PyExpr(expr.clone()).into_subclasses(py) -} +use super::base::PyExpr; +use super::enum_::ExprEnum; +use super::extractors::{ConditionExtractor, ExprExtractor, LiteralExprExtractor}; +use crate::data_type::DataTypeName; +use crate::internal::{alias, utils}; macro_rules! empty_node { - ($(#[$meta:meta])* $name:ident => $pyname:literal) => { - $(#[$meta])* - #[rapidquery_macros::pyclass( - $pyname, - skip_from_py_object, - extends = PyExpr, - module = "rapidquery._core.expr" - )] - #[derive(Debug, Default)] - pub struct $name; + ( + $( + $(#[$meta:meta])* + $name:ident($variant:ident($inner:ty)) => $pyname:literal, + )+ + ) => { + $( + $(#[$meta])* + #[rapidquery_macros::pyclass( + $pyname, + skip_from_py_object, + extends = PyExpr, + module = "rapidquery._core.expr" + )] + #[derive(Debug, Default)] + pub struct $name; + + impl $name { + #[inline] + fn inner<'a>(slf: &'a pyo3::PyRef<'a, $name>) -> &'a $inner { + debug_assert!( + matches!(&slf.as_super().0, ExprEnum::$variant(..)), + concat!(stringify!($name), " instance always holds ExprEnum::", stringify!($variant)) + + ); + + match &slf.as_super().0 { + ExprEnum::$variant(v) => v, + _ => unsafe { std::hint::unreachable_unchecked() }, + } + } + } + )+ }; } empty_node! { /// Parenthesized row / tuple expression `(a, b, …)`. - PyTupleExpr => "TupleExpr" -} -empty_node! { + PyTupleExpr(Tuple([ExprEnum])) => "TupleExpr", + /// Unary expression (`NOT x`, `-x`, `+x`, `~x`). - PyUnary => "Unary" -} -empty_node! { + PyUnaryExpr(Unary(super::enum_::Unary)) => "UnaryExpr", + /// Binary expression (`a + b`, `a AND b`, custom operators). - PyBinary => "Binary" -} -empty_node! { + PyBinaryExpr(Binary(super::enum_::Binary)) => "BinaryExpr", + /// `expr IS [NOT] {NULL|TRUE|FALSE|UNKNOWN}`. - PyIs => "Is" -} -empty_node! { + PyIsExpr(Is(super::enum_::Is)) => "IsExpr", + /// `a IS [NOT] DISTINCT FROM b`. - PyIsDistinctFrom => "IsDistinctFrom" -} -empty_node! { - /// `expr [NOT] IN (… )`. - PyInList => "InList" -} -empty_node! { - /// `expr [NOT] BETWEEN low AND high`. - PyBetween => "Between" -} -empty_node! { + PyIsDistinctFromExpr(IsDistinctFrom(super::enum_::IsDistinctFrom)) => "IsDistinctFromExpr", + + // /// `expr [NOT] IN (… )`. + PyInListExpr(InList(super::enum_::InList)) => "InListExpr", + + // /// `expr [NOT] BETWEEN low AND high`. + PyBetweenExpr(Between(super::enum_::Between)) => "BetweenExpr", + /// `expr [NOT] LIKE / ILIKE / SIMILAR TO / RLIKE pattern`. - PyLike => "Like" -} -empty_node! { + PyLikeExpr(Like(super::enum_::Like)) => "LikeExpr", + /// `CAST(expr AS type)`. - PyCast => "Cast" -} -empty_node! { + PyCastExpr(Cast(super::enum_::Cast)) => "CastExpr", + /// `CASE [operand] WHEN … THEN … [ELSE …] END`. - PyCase => "Case" -} -empty_node! { + PyCaseExpr(Case(super::enum_::Case)) => "CaseExpr", + /// Escape-hatch SQL template. `{}` slots are filled by argument /// expressions. `{{` / `}}` emit literal braces. - PyCustom => "Custom" + PyCustomExpr(Custom(super::enum_::Custom)) => "CustomExpr", } #[pyo3::pymethods] @@ -79,197 +84,175 @@ impl PyTupleExpr { #[new] #[pyo3(signature = (*items))] fn __new__(items: alias::ArgsType<'_>) -> pyo3::PyResult> { + use pyo3::types::PyTupleMethods; + let mut exprs = Vec::with_capacity(items.len()); for item in items.iter() { exprs.push(item.extract::()?.0); } - Ok(subclass::(ExprEnum::Tuple(exprs))) + + Ok(pyo3::PyClassInitializer::from(PyExpr(ExprEnum::Tuple(exprs))).add_subclass(Self)) } - #[getter] - fn get_items(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + fn get_items( + slf: pyo3::PyRef<'_, Self>, + ) -> pyo3::PyResult> { let py = slf.py(); - let ExprEnum::Tuple(items) = &slf.as_super().0 else { - unreachable!("TupleExpr instance always holds ExprEnum::Tuple"); - }; + let items = Self::inner(&slf); let mut out = Vec::with_capacity(items.len()); + for item in items { - out.push(child(py, item)?); + out.push(super::base::into_subclasses(py, item.clone())?); } - Ok(pyo3::types::PyTuple::new(py, out)?.into_any()) + pyo3::types::PyTuple::new(py, out) } } #[pyo3::pymethods] -impl PyUnary { +impl PyUnaryExpr { #[new] - #[pyo3(signature = (op, expr, /))] + #[pyo3(signature = (operator, expr, /))] fn __new__( - op: &str, + operator: &str, expr: ExprExtractor, ) -> pyo3::PyResult> { - let Some(op) = UnaryOp::parse(op) else { + let Some(op) = super::enum_::UnaryOp::parse(operator) else { return rapidquery_macros::pyerr!( Err(PyValueError), - "unknown unary operator {op:?}" + "unknown unary operator {:?}", + operator ); }; - Ok(subclass::(ExprEnum::Unary { + + let inner = super::enum_::Unary { op, expr: Box::new(expr.0), - })) + }; + Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) } - #[getter] - fn get_op(slf: pyo3::PyRef<'_, Self>) -> &'static str { - let ExprEnum::Unary { op, .. } = &slf.as_super().0 else { - unreachable!("Unary instance always holds ExprEnum::Unary"); - }; - op.as_sql() + fn get_operator(slf: pyo3::PyRef<'_, Self>) -> &'static str { + let inner = Self::inner(&slf); + inner.op.as_sql() } - #[getter] fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - let ExprEnum::Unary { expr, .. } = &slf.as_super().0 else { - unreachable!("Unary instance always holds ExprEnum::Unary"); - }; - child(slf.py(), expr) + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), (*inner.expr).clone()) } } #[pyo3::pymethods] -impl PyBinary { +impl PyBinaryExpr { #[new] - #[pyo3(signature = (left, op, right, /))] + #[pyo3(signature = (left, operator, right, /))] fn __new__( left: ExprExtractor, - op: &str, + operator: &str, right: ExprExtractor, ) -> pyo3::PyClassInitializer { - subclass::(ExprEnum::Binary { + let inner = super::enum_::Binary { left: Box::new(left.0), - op: BinaryOp::parse(op), + op: super::enum_::BinaryOp::parse(operator), right: Box::new(right.0), - }) + }; + pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self) } - #[getter] fn get_left(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - let ExprEnum::Binary { left, .. } = &slf.as_super().0 else { - unreachable!("Binary instance always holds ExprEnum::Binary"); - }; - child(slf.py(), left) + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), (*inner.left).clone()) } - #[getter] - fn get_op(slf: pyo3::PyRef<'_, Self>) -> String { - let ExprEnum::Binary { op, .. } = &slf.as_super().0 else { - unreachable!("Binary instance always holds ExprEnum::Binary"); - }; - op.as_sql().to_owned() + fn get_operator(slf: pyo3::PyRef<'_, Self>) -> String { + let inner = Self::inner(&slf); + inner.op.as_sql().to_owned() } - #[getter] fn get_right(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - let ExprEnum::Binary { right, .. } = &slf.as_super().0 else { - unreachable!("Binary instance always holds ExprEnum::Binary"); - }; - child(slf.py(), right) + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), (*inner.right).clone()) } } #[pyo3::pymethods] -impl PyIs { +impl PyIsExpr { #[new] - #[pyo3(signature = (expr, kind, *, negated = false))] + #[pyo3(signature = (expr, kind, /, *, negated = false))] fn __new__( expr: ExprExtractor, kind: &str, negated: bool, ) -> pyo3::PyResult> { - let Some(kind) = IsKind::parse(kind) else { + let Some(kind) = super::enum_::IsKind::parse(kind) else { return rapidquery_macros::pyerr!( Err(PyValueError), - "unknown IS kind {kind:?}; expected NULL, TRUE, FALSE, or UNKNOWN" + "unknown IS kind {:?}; expected NULL, TRUE, FALSE, or UNKNOWN", + kind ); }; - Ok(subclass::(ExprEnum::Is { + + let inner = super::enum_::Is { expr: Box::new(expr.0), negated, kind, - })) + }; + Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) } - #[getter] fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - let ExprEnum::Is { expr, .. } = &slf.as_super().0 else { - unreachable!("Is instance always holds ExprEnum::Is"); - }; - child(slf.py(), expr) + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), (*inner.expr).clone()) } - #[getter] fn get_kind(slf: pyo3::PyRef<'_, Self>) -> &'static str { - let ExprEnum::Is { kind, .. } = &slf.as_super().0 else { - unreachable!("Is instance always holds ExprEnum::Is"); - }; - kind.as_sql() + let inner = Self::inner(&slf); + inner.kind.as_sql() } - #[getter] fn get_negated(slf: pyo3::PyRef<'_, Self>) -> bool { - let ExprEnum::Is { negated, .. } = &slf.as_super().0 else { - unreachable!("Is instance always holds ExprEnum::Is"); - }; - *negated + let inner = Self::inner(&slf); + inner.negated } } #[pyo3::pymethods] -impl PyIsDistinctFrom { +impl PyIsDistinctFromExpr { #[new] - #[pyo3(signature = (left, right, *, negated = false))] + #[pyo3(signature = (left, right, /, *, negated = false))] fn __new__( left: ExprExtractor, right: ExprExtractor, negated: bool, ) -> pyo3::PyClassInitializer { - subclass::(ExprEnum::IsDistinctFrom { + let inner = super::enum_::IsDistinctFrom { left: Box::new(left.0), right: Box::new(right.0), negated, - }) + }; + pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self) } - #[getter] fn get_left(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - let ExprEnum::IsDistinctFrom { left, .. } = &slf.as_super().0 else { - unreachable!(); - }; - child(slf.py(), left) + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), (*inner.left).clone()) } - #[getter] fn get_right(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - let ExprEnum::IsDistinctFrom { right, .. } = &slf.as_super().0 else { - unreachable!(); - }; - child(slf.py(), right) + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), (*inner.right).clone()) } - #[getter] fn get_negated(slf: pyo3::PyRef<'_, Self>) -> bool { - let ExprEnum::IsDistinctFrom { negated, .. } = &slf.as_super().0 else { - unreachable!(); - }; - *negated + let inner = Self::inner(&slf); + inner.negated } } #[pyo3::pymethods] -impl PyInList { +impl PyInListExpr { #[new] - #[pyo3(signature = (expr, list, *, negated = false))] + #[pyo3(signature = (expr, list, /, *, negated = false))] fn __new__( expr: ExprExtractor, list: alias::BoundObject<'_>, @@ -279,98 +262,93 @@ impl PyInList { for item in list.try_iter()? { items.push(item?.extract::()?.0); } - Ok(subclass::(ExprEnum::InList { + + let inner = super::enum_::InList { expr: Box::new(expr.0), list: items, negated, - })) + }; + Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) } - #[getter] fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - let ExprEnum::InList { expr, .. } = &slf.as_super().0 else { - unreachable!(); - }; - child(slf.py(), expr) + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), (*inner.expr).clone()) } - #[getter] - fn get_list(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + fn get_list(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { let py = slf.py(); - let ExprEnum::InList { list, .. } = &slf.as_super().0 else { - unreachable!(); - }; - let mut out = Vec::with_capacity(list.len()); - for item in list { - out.push(child(py, item)?); + let inner = Self::inner(&slf); + + // instead of creating a Vec and then creating a PyTuple, which is `T(n) = 2n` + // we can use unsafe tuple to create PyTuple directly, which is `T(n) = n` + let tuple = unsafe { pyo3::ffi::PyTuple_New(inner.list.len() as isize) }; + + for (ix, arg) in inner.list.iter().enumerate() { + match PyExpr(arg.clone()).into_subclasses(py) { + Ok(bound) => unsafe { + pyo3::ffi::PyTuple_SetItem(tuple, ix as isize, bound.into_ptr()); + }, + Err(err) => unsafe { + pyo3::ffi::Py_DECREF(tuple); + return Err(err); + }, + } } - Ok(pyo3::types::PyTuple::new(py, out)?.into_any()) + + Ok(unsafe { pyo3::Bound::from_owned_ptr(py, tuple) }) } - #[getter] fn get_negated(slf: pyo3::PyRef<'_, Self>) -> bool { - let ExprEnum::InList { negated, .. } = &slf.as_super().0 else { - unreachable!(); - }; - *negated + let inner = Self::inner(&slf); + inner.negated } } #[pyo3::pymethods] -impl PyBetween { +impl PyBetweenExpr { #[new] - #[pyo3(signature = (expr, low, high, *, negated = false))] + #[pyo3(signature = (expr, low, high, /, *, negated = false))] fn __new__( expr: ExprExtractor, low: ExprExtractor, high: ExprExtractor, negated: bool, ) -> pyo3::PyClassInitializer { - subclass::(ExprEnum::Between { + let inner = super::enum_::Between { expr: Box::new(expr.0), negated, low: Box::new(low.0), high: Box::new(high.0), - }) + }; + pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self) } - #[getter] fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - let ExprEnum::Between { expr, .. } = &slf.as_super().0 else { - unreachable!(); - }; - child(slf.py(), expr) + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), (*inner.expr).clone()) } - #[getter] fn get_low(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - let ExprEnum::Between { low, .. } = &slf.as_super().0 else { - unreachable!(); - }; - child(slf.py(), low) + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), (*inner.low).clone()) } - #[getter] fn get_high(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - let ExprEnum::Between { high, .. } = &slf.as_super().0 else { - unreachable!(); - }; - child(slf.py(), high) + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), (*inner.high).clone()) } - #[getter] fn get_negated(slf: pyo3::PyRef<'_, Self>) -> bool { - let ExprEnum::Between { negated, .. } = &slf.as_super().0 else { - unreachable!(); - }; - *negated + let inner = Self::inner(&slf); + inner.negated } } #[pyo3::pymethods] -impl PyLike { +impl PyLikeExpr { #[new] - #[pyo3(signature = (expr, pattern, *, kind = "LIKE", negated = false, escape = None))] + #[pyo3(signature = (expr, pattern, /, *, kind = "LIKE", negated = false, escape = None))] fn __new__( expr: ExprExtractor, pattern: LiteralExprExtractor, @@ -378,169 +356,151 @@ impl PyLike { negated: bool, escape: Option, ) -> pyo3::PyResult> { - let Some(kind) = LikeKind::parse(kind) else { + let Some(kind) = super::enum_::LikeKind::parse(kind) else { return rapidquery_macros::pyerr!( Err(PyValueError), "unknown LIKE kind {kind:?}; expected LIKE, ILIKE, SIMILAR TO, or RLIKE" ); }; - Ok(subclass::(ExprEnum::Like { + let inner = super::enum_::Like { kind, negated, expr: Box::new(expr.0), pattern: Box::new(pattern.0), escape, - })) + }; + Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) } - #[getter] fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - let ExprEnum::Like { expr, .. } = &slf.as_super().0 else { - unreachable!(); - }; - child(slf.py(), expr) + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), (*inner.expr).clone()) } - #[getter] fn get_pattern(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - let ExprEnum::Like { pattern, .. } = &slf.as_super().0 else { - unreachable!(); - }; - child(slf.py(), pattern) + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), (*inner.pattern).clone()) } - #[getter] fn get_kind(slf: pyo3::PyRef<'_, Self>) -> &'static str { - let ExprEnum::Like { kind, .. } = &slf.as_super().0 else { - unreachable!(); - }; - kind.as_sql() + let inner = Self::inner(&slf); + inner.kind.as_sql() } - #[getter] fn get_negated(slf: pyo3::PyRef<'_, Self>) -> bool { - let ExprEnum::Like { negated, .. } = &slf.as_super().0 else { - unreachable!(); - }; - *negated + let inner = Self::inner(&slf); + inner.negated } - #[getter] fn get_escape(slf: pyo3::PyRef<'_, Self>) -> Option { - let ExprEnum::Like { escape, .. } = &slf.as_super().0 else { - unreachable!(); - }; - *escape + let inner = Self::inner(&slf); + inner.escape } } #[pyo3::pymethods] -impl PyCast { +impl PyCastExpr { #[new] - #[pyo3(signature = (expr, data_type, /))] - fn __new__(expr: ExprExtractor, data_type: PyDataType) -> pyo3::PyClassInitializer { - subclass::(ExprEnum::Cast { + #[pyo3(signature = (expr, to, /))] + fn __new__(expr: ExprExtractor, to: String) -> pyo3::PyClassInitializer { + let data_type_name = DataTypeName::from(to); + let inner = super::enum_::Cast { expr: Box::new(expr.0), - data_type, - }) + data_type: data_type_name, + }; + pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self) } - #[getter] fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - let ExprEnum::Cast { expr, .. } = &slf.as_super().0 else { - unreachable!(); - }; - child(slf.py(), expr) + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), (*inner.expr).clone()) } - #[getter] - fn get_data_type(slf: pyo3::PyRef<'_, Self>) -> PyDataType { - let ExprEnum::Cast { data_type, .. } = &slf.as_super().0 else { - unreachable!(); - }; - data_type.clone() + fn get_data_type(slf: pyo3::PyRef<'_, Self>) -> String { + let inner = Self::inner(&slf); + inner.data_type.to_string() } } - #[pyo3::pymethods] -impl PyCase { +impl PyCaseExpr { #[new] - #[pyo3(signature = (when_then, *, operand = None, else_ = None))] - fn __new__( - when_then: alias::BoundObject<'_>, - operand: Option, - else_: Option, - ) -> pyo3::PyResult> { - let mut pairs = Vec::new(); - for item in when_then.try_iter()? { - let item = item?; - let pair = item.cast::().map_err(|_| { - rapidquery_macros::pyerr!( - PyTypeError, - "Case when_then items must be (when, then) pairs" - ) - })?; - if pair.len() != 2 { - return rapidquery_macros::pyerr!( - Err(PyValueError), - "Case when_then items must be (when, then) pairs" - ); - } - let when = pair.get_item(0)?.extract::()?.0; - let then = pair.get_item(1)?.extract::()?.0; - pairs.push((when, then)); - } - Ok(subclass::(ExprEnum::Case { + #[pyo3(signature = (operand = None, /))] + fn __new__(operand: Option) -> pyo3::PyResult> { + let inner = super::enum_::Case { operand: operand.map(|ExprExtractor(e)| Box::new(e)), - when_then: pairs, - else_result: else_.map(|ExprExtractor(e)| Box::new(e)), - })) + when: vec![], + else_result: None, + }; + Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) } - #[getter] - fn get_operand( + fn when( slf: pyo3::PyRef<'_, Self>, - ) -> pyo3::PyResult>> { - let ExprEnum::Case { operand, .. } = &slf.as_super().0 else { - unreachable!(); - }; - match operand { - Some(expr) => Ok(Some(child(slf.py(), expr)?)), - None => Ok(None), + when: ConditionExtractor, + then: ExprExtractor, + ) -> pyo3::PyResult { + let mut inner = Self::inner(&slf).clone(); + inner.when.push((when.0, then.0)); + + pyo3::Py::new( + slf.py(), + pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self), + ) + .map(pyo3::Py::into_any) + } + + fn else_(slf: pyo3::PyRef<'_, Self>, result: ExprExtractor) -> pyo3::PyResult { + let mut inner = Self::inner(&slf).clone(); + inner.else_result = Some(Box::new(result.0)); + + pyo3::Py::new( + slf.py(), + pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self), + ) + .map(pyo3::Py::into_any) + } + + fn get_operand(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let inner = Self::inner(&slf); + + match inner.operand.as_ref() { + Some(expr) => { + let bound = super::base::into_subclasses(slf.py(), (**expr).clone())?; + Ok(bound) + } + None => Ok(slf.py().None().into_any().into_bound(slf.py())), } } - #[getter] - fn get_when_then(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + fn get_when_list(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { let py = slf.py(); - let ExprEnum::Case { when_then, .. } = &slf.as_super().0 else { - unreachable!(); - }; - let mut out = Vec::with_capacity(when_then.len()); - for (when, then) in when_then { + let inner = Self::inner(&slf); + + let mut out = Vec::with_capacity(inner.when.len()); + for (when, then) in inner.when.iter() { let pair = pyo3::types::PyTuple::new( py, - [child(py, when)?, child(py, then)?], + [ + super::base::into_subclasses(py, when.clone())?, + super::base::into_subclasses(py, then.clone())?, + ], )?; out.push(pair); } Ok(pyo3::types::PyTuple::new(py, out)?.into_any()) } - #[getter] - fn get_else_(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult>> { - let ExprEnum::Case { else_result, .. } = &slf.as_super().0 else { - unreachable!(); - }; - match else_result { - Some(expr) => Ok(Some(child(slf.py(), expr)?)), - None => Ok(None), + fn get_else(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let inner = Self::inner(&slf); + match inner.else_result.as_ref() { + Some(expr) => super::base::into_subclasses(slf.py(), (**expr).clone()), + None => Ok(utils::new_pynone(slf.py())), } } } #[pyo3::pymethods] -impl PyCustom { +impl PyCustomExpr { #[new] #[pyo3(signature = (sql, *args))] fn __new__( @@ -548,33 +508,43 @@ impl PyCustom { args: alias::ArgsType<'_>, ) -> pyo3::PyResult> { let mut extracted = Vec::with_capacity(args.len()); + for item in args.iter() { extracted.push(item.extract::()?.0); } - Ok(subclass::(ExprEnum::Custom { - sql, + + let inner = super::enum_::Custom { + sql: sql.into(), args: extracted, - })) + }; + Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) } - #[getter] fn get_sql(slf: pyo3::PyRef<'_, Self>) -> String { - let ExprEnum::Custom { sql, .. } = &slf.as_super().0 else { - unreachable!(); - }; - sql.clone() + let inner = Self::inner(&slf); + inner.sql.to_string() } - #[getter] fn get_args(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { let py = slf.py(); - let ExprEnum::Custom { args, .. } = &slf.as_super().0 else { - unreachable!(); - }; - let mut out = Vec::with_capacity(args.len()); - for arg in args { - out.push(child(py, arg)?); + let inner = Self::inner(&slf); + + // instead of creating a Vec and then creating a PyTuple, which is `T(n) = 2n` + // we can use unsafe tuple to create PyTuple directly, which is `T(n) = n` + let tuple = unsafe { pyo3::ffi::PyTuple_New(inner.args.len() as isize) }; + + for (ix, arg) in inner.args.iter().enumerate() { + match PyExpr(arg.clone()).into_subclasses(py) { + Ok(bound) => unsafe { + pyo3::ffi::PyTuple_SetItem(tuple, ix as isize, bound.into_ptr()); + }, + Err(err) => unsafe { + pyo3::ffi::Py_DECREF(tuple); + return Err(err); + }, + } } - Ok(pyo3::types::PyTuple::new(py, out)?.into_any()) + + Ok(unsafe { pyo3::Bound::from_owned_ptr(py, tuple) }) } } diff --git a/src/expr/tests.rs b/src/expr/tests.rs deleted file mode 100644 index c058b7d..0000000 --- a/src/expr/tests.rs +++ /dev/null @@ -1,224 +0,0 @@ -use crate::data_type::PyDataType; -use crate::dialect::SqlDialect; -use super::ident::{Ident, IdentPart}; -use crate::sql::{PrepareError, SqlWriter, WriteSql}; - -use super::enum_::{BinaryOp, DuplicateTreatment, ExprEnum, IsKind, LikeKind, UnaryOp}; -use super::func::{Func, FuncArguments}; - -struct TestWriter(String); - -impl SqlWriter for TestWriter { - fn push_str(&mut self, s: &str) -> &mut Self { - self.0.push_str(s); - self - } - - fn push_char(&mut self, c: char) -> &mut Self { - self.0.push(c); - self - } - - fn push_value( - &mut self, - _dialect: &SqlDialect, - _serializer: &crate::data_type::SerializerEnum, - _object: &crate::internal::alias::PyObject, - ) -> crate::sql::PrepareResult<()> { - panic!("test writer does not support values"); - } -} - -impl std::fmt::Write for TestWriter { - fn write_str(&mut self, s: &str) -> std::fmt::Result { - self.0.push_str(s); - Ok(()) - } -} - -fn quote(s: &str) -> IdentPart { - IdentPart::quoted(s).unwrap() -} - -fn ident(name: &str) -> ExprEnum { - ExprEnum::Ident(Ident::from_one(quote(name))) -} - -fn render(expr: &ExprEnum, dialect: SqlDialect) -> String { - let mut out = TestWriter(String::new()); - expr.write_sql(&dialect, &mut out).unwrap(); - out.0 -} - -fn pg(expr: &ExprEnum) -> String { - render(expr, SqlDialect::PostgreSql) -} - -#[test] -fn ident_uses_dialect_quotes() { - let expr = ident("id"); - assert_eq!(pg(&expr), "\"id\""); - assert_eq!(render(&expr, SqlDialect::MySql), "`id`"); -} - -#[test] -fn binary_add_and_and() { - let add = ExprEnum::Binary { - left: Box::new(ident("a")), - op: BinaryOp::Add, - right: Box::new(ident("b")), - }; - assert_eq!(pg(&add), "\"a\" + \"b\""); - - let and = ExprEnum::Binary { - left: Box::new(ident("a")), - op: BinaryOp::And, - right: Box::new(ident("b")), - }; - assert_eq!(pg(&and), "\"a\" AND \"b\""); -} - -#[test] -fn or_parenthesizes_weaker_and_child_on_right_of_and() { - let or = ExprEnum::Binary { - left: Box::new(ident("a")), - op: BinaryOp::Or, - right: Box::new(ident("b")), - }; - let and = ExprEnum::Binary { - left: Box::new(or), - op: BinaryOp::And, - right: Box::new(ident("c")), - }; - assert_eq!(pg(&and), "(\"a\" OR \"b\") AND \"c\""); -} - -#[test] -fn unary_not_and_minus() { - let not = ExprEnum::Unary { - op: UnaryOp::Not, - expr: Box::new(ident("x")), - }; - assert_eq!(pg(¬), "NOT \"x\""); - - let minus = ExprEnum::Unary { - op: UnaryOp::Minus, - expr: Box::new(ident("x")), - }; - assert_eq!(pg(&minus), "-\"x\""); -} - -#[test] -fn function_distinct_and_filter_and_bare() { - let sum = ExprEnum::Function(Func { - name: Ident::from_one(quote("SUM")), - args: FuncArguments::List { - duplicate_treatment: Some(DuplicateTreatment::Distinct), - args: vec![ident("amount")], - }, - filter: Some(Box::new(ExprEnum::Binary { - left: Box::new(ident("amount")), - op: BinaryOp::Gt, - right: Box::new(ident("zero")), - })), - }); - assert_eq!( - pg(&sum), - "\"SUM\"(DISTINCT \"amount\") FILTER (WHERE \"amount\" > \"zero\")" - ); - - let now = ExprEnum::Function(Func { - name: Ident::from_one(quote("CURRENT_TIMESTAMP")), - args: FuncArguments::None, - filter: None, - }); - assert_eq!(pg(&now), "\"CURRENT_TIMESTAMP\""); -} - -#[test] -fn is_in_between_like_cast_case_tuple() { - let is_null = ExprEnum::Is { - expr: Box::new(ident("x")), - negated: true, - kind: IsKind::Null, - }; - assert_eq!(pg(&is_null), "\"x\" IS NOT NULL"); - - let in_list = ExprEnum::InList { - expr: Box::new(ident("x")), - list: vec![ident("a"), ident("b")], - negated: false, - }; - assert_eq!(pg(&in_list), "\"x\" IN (\"a\", \"b\")"); - - let between = ExprEnum::Between { - expr: Box::new(ident("x")), - negated: false, - low: Box::new(ident("a")), - high: Box::new(ident("b")), - }; - assert_eq!(pg(&between), "\"x\" BETWEEN \"a\" AND \"b\""); - - let like = ExprEnum::Like { - kind: LikeKind::ILike, - negated: false, - expr: Box::new(ident("name")), - pattern: Box::new(ident("pat")), - escape: None, - }; - assert_eq!(pg(&like), "\"name\" ILIKE \"pat\""); - - let cast = ExprEnum::Cast { - expr: Box::new(ident("x")), - data_type: PyDataType::from_name("INT"), - }; - assert_eq!(pg(&cast), "CAST(\"x\" AS INT)"); - - let case = ExprEnum::Case { - operand: Some(Box::new(ident("x"))), - when_then: vec![(ident("a"), ident("b"))], - else_result: Some(Box::new(ident("c"))), - }; - assert_eq!(pg(&case), "CASE \"x\" WHEN \"a\" THEN \"b\" ELSE \"c\" END"); - - let tuple = ExprEnum::Tuple(vec![ident("a"), ident("b")]); - assert_eq!(pg(&tuple), "(\"a\", \"b\")"); -} - -#[test] -fn custom_placeholders_and_braces() { - let expr = ExprEnum::Custom { - sql: "json_agg(DISTINCT {})".to_string(), - args: vec![ident("col")], - }; - assert_eq!(pg(&expr), "json_agg(DISTINCT \"col\")"); - - let expr = ExprEnum::Custom { - sql: "a {{ b }} c".to_string(), - args: vec![], - }; - assert_eq!(pg(&expr), "a { b } c"); - - let expr = ExprEnum::Custom { - sql: "{} {}".to_string(), - args: vec![ident("a")], - }; - let err = { - let mut out = TestWriter(String::new()); - expr.write_sql(&SqlDialect::PostgreSql, &mut out) - .unwrap_err() - }; - assert!(matches!( - err, - PrepareError::CustomPlaceholders { - expected: 2, - got: 1 - } - )); -} - -#[test] -fn binary_op_parse_unknown_is_custom() { - assert_eq!(BinaryOp::parse("->"), BinaryOp::Custom(arcstr::ArcStr::from("->"))); - assert_eq!(BinaryOp::parse("!=").as_sql(), "<>"); -} diff --git a/src/expr/value.rs b/src/expr/value.rs index dc63629..8d5af12 100644 --- a/src/expr/value.rs +++ b/src/expr/value.rs @@ -1,58 +1,9 @@ -//! A Python object plus the [`Serializer`] used to emit it as SQL. +use crate::{ + data_type::{PySerializer, SerializerExtractor}, + internal::alias, +}; -use pyo3::types::{PyAnyMethods, PyStringMethods}; - -use crate::data_type::{PySerializer, SerializerEnum, SerializerExtractor, serializer_repr}; -use crate::dialect::SqlDialect; -use crate::internal::alias; -use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_sql}; - -/// A Python object plus the [`Serializer`] used to emit it as SQL. -/// -/// Parameterized SQL binds [`Self::object`] as a placeholder and ignores the -/// serializer. Raw SQL (`BufferedSql`) calls [`Serializer::write_sql`]; -/// Python `None` becomes `NULL`. -/// -/// Clone requires the GIL (`Py::clone`). -#[derive(Debug, Clone)] -pub struct Value { - pub object: alias::PyObject, - pub serializer: SerializerEnum, -} - -impl Value { - #[inline] - pub fn new(object: alias::PyObject, serializer: SerializerEnum) -> Self { - Self { object, serializer } - } - - #[inline] - pub fn infer_bound(bound: alias::BoundObject) -> Self { - let serializer = SerializerEnum::infer_bound(&bound); - Self::new(bound.unbind(), serializer) - } - - pub(crate) fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let obj_repr = self.object.bind(py).repr()?; - Ok(format!( - "Value({}, {})", - obj_repr.to_cow()?, - serializer_repr(&self.serializer) - )) - } -} - -impl WriteSql for Value { - fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { - out.push_value(dialect, &self.serializer, &self.object) - } -} - -impl PartialEq for Value { - fn eq(&self, other: &Self) -> bool { - self.serializer == other.serializer && self.object.as_ptr() == other.object.as_ptr() - } -} +use super::enum_::Value; /// A Python object plus the [`Serializer`] used to emit it as SQL. /// @@ -67,26 +18,28 @@ impl PartialEq for Value { #[rapidquery_macros::pyclass( "Value", skip_from_py_object, - extends = super::PyExpr, + extends = super::base::PyExpr, module = "rapidquery._core.expr", generic )] #[derive(Debug, Default)] pub struct PyValue; -#[inline] -fn value_inner<'a>(slf: &'a pyo3::PyRef<'a, PyValue>) -> &'a Value { - match &slf.as_super().0 { - super::ExprEnum::Value(value) => value, - _ => unreachable!("Value instance always holds ExprEnum::Value"), +impl PyValue { + #[inline(always)] + fn inner<'a>(slf: &'a pyo3::PyRef<'a, PyValue>) -> &'a Value { + debug_assert!( + matches!(&slf.as_super().0, super::enum_::ExprEnum::Value(..)), + "PyValue instance always holds ExprEnum::Value" + ); + + match &slf.as_super().0 { + super::enum_::ExprEnum::Value(v) => v, + _ => unsafe { std::hint::unreachable_unchecked() }, + } } } -#[inline] -fn wrap_value(value: Value) -> pyo3::PyClassInitializer { - pyo3::PyClassInitializer::from(super::PyExpr(super::ExprEnum::Value(value))).add_subclass(PyValue) -} - #[pyo3::pymethods] impl PyValue { /// Creates a SQL value. @@ -109,33 +62,43 @@ impl PyValue { ) -> pyo3::PyClassInitializer { // handle Value(Value(...), new_serializer) if unsafe { crate::typeref::check_exact(object.as_ptr(), crate::typeref::VALUE_TYPE) } { - let expr = unsafe { object.cast_unchecked::() }; - let super::ExprEnum::Value(inner) = expr.get().0.clone() else { - unreachable!("Value instance always holds ExprEnum::Value"); + let expr = unsafe { object.cast_unchecked::() }; + let expr_inner = expr.get().0.clone(); + + debug_assert!( + matches!(expr_inner, super::enum_::ExprEnum::Value(..)), + "PyValue instance always holds ExprEnum::Value" + ); + + let inner = match expr_inner { + super::enum_::ExprEnum::Value(v) => v, + _ => unsafe { std::hint::unreachable_unchecked() }, }; let value = match serializer { Some(SerializerExtractor(s)) => Value::new(inner.object, s), None => inner, }; - return wrap_value(value); + + let new_expr = super::base::PyExpr(super::enum_::ExprEnum::Value(value)); + return pyo3::PyClassInitializer::from(new_expr).add_subclass(PyValue); } let value = match serializer { Some(SerializerExtractor(s)) => Value::new(object.unbind(), s), None => Value::infer_bound(object), }; - wrap_value(value) + + let expr = super::base::PyExpr(super::enum_::ExprEnum::Value(value)); + return pyo3::PyClassInitializer::from(expr).add_subclass(PyValue); } /// Returns the wrapped Python object. /// /// Returns: - /// The object passed to the constructor (or taken from an existing - /// `Value`). - #[getter] + /// The object passed to the constructor (or taken from an existing `Value`). fn get_object(slf: pyo3::PyRef<'_, Self>) -> alias::PyObject { - value_inner(&slf).object.clone_ref(slf.py()) + Self::inner(&slf).object.clone_ref(slf.py()) } /// Returns the serializer used for raw SQL literals. @@ -143,31 +106,7 @@ impl PyValue { /// Returns: /// The `Serializer` stored on this value. Not used when the value /// is bound as a parameter. - #[getter] fn get_serializer(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - PySerializer(value_inner(&slf).serializer.clone()).into_subclasses(slf.py()) - } - - /// Renders this value as a SQL literal for `dialect`. - /// - /// Raw path: `None` becomes `NULL`; otherwise the serializer writes a - /// literal. This is not the parameterized form. - /// - /// Args: - /// dialect: A dialect class (`PostgreSQL`) or instance - /// (`PostgreSQL()`). - /// - /// Returns: - /// A SQL fragment suitable for embedding in a statement. - /// - /// Raises: - /// TypeError: If `object` is not an accepted type for the serializer. - /// ValueError: If `object` is the right type but cannot be represented. - fn to_sql( - slf: pyo3::PyRef<'_, Self>, - py: pyo3::Python<'_>, - dialect: crate::dialect::py::PySqlDialect, - ) -> pyo3::PyResult { - write_sql(py, value_inner(&slf), &dialect.0).map_err(Into::into) + PySerializer(Self::inner(&slf).serializer.clone()).into_subclasses(slf.py()) } } diff --git a/src/internal/modules/json.rs b/src/internal/modules/json.rs index 495548b..7e58b87 100644 --- a/src/internal/modules/json.rs +++ b/src/internal/modules/json.rs @@ -6,7 +6,7 @@ use once_cell::sync::OnceCell; use pyo3::types::{PyAnyMethods, PyModule, PyTuple}; -use crate::internal::utils::py_str; +use crate::internal::utils::borrow_py_str_buffer; #[cfg(all(feature = "only-stdlib-json", feature = "only-orjson"))] compile_error!( @@ -92,7 +92,7 @@ unsafe fn visit_json_text( } if pyo3::ffi::PyUnicode_CheckExact(result) == 1 { - return f(py_str(py, result)?); + return f(borrow_py_str_buffer(py, result)?); } rapidquery_macros::pyerr!( diff --git a/src/internal/utils/mod.rs b/src/internal/utils/mod.rs index 6043123..93ab92a 100644 --- a/src/internal/utils/mod.rs +++ b/src/internal/utils/mod.rs @@ -12,7 +12,7 @@ pub use submodule_ext::*; /// returned slice is valid until that object is mutated or the GIL is /// released. The GIL must be held (`py`). #[inline] -pub unsafe fn py_str<'a>( +pub unsafe fn borrow_py_str_buffer<'a>( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, ) -> pyo3::PyResult<&'a str> { @@ -55,6 +55,12 @@ pub unsafe fn get_type_name<'a>(py: pyo3::Python<'a>, obj: *mut pyo3::ffi::PyObj } } +/// Gets the Python builtin value `None` as [`pyo3::Bound<'_, pyo3::PyAny>`]. +#[inline] +pub fn new_pynone<'py>(py: pyo3::Python<'py>) -> pyo3::Bound<'py, pyo3::PyAny> { + unsafe { pyo3::Bound::from_borrowed_ptr(py, pyo3::ffi::Py_None()) } +} + /// Helper enum to accept either an `isize` index or a `slice` in `__getitem__`. #[derive(Debug, pyo3::FromPyObject)] pub enum IndexOrSlice { diff --git a/src/lib.rs b/src/lib.rs index 8b07812..825ee9d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,7 +25,6 @@ mod _core { fn init(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> { use crate::internal::utils::PyModuleSubmoduleExt; - // Cache PyTypeObject* before any Python code can extract a dialect. crate::typeref::initialize_typeref(m.py()); m.init_submodules() } diff --git a/src/typeref.rs b/src/typeref.rs index c712eb9..8f570fc 100644 --- a/src/typeref.rs +++ b/src/typeref.rs @@ -47,18 +47,6 @@ pub(crate) static mut VALUE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_ // Expr types pub(crate) static mut EXPR_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut FUNC_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut TUPLE_EXPR_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut UNARY_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut BINARY_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut IS_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut IS_DISTINCT_FROM_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut IN_LIST_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut BETWEEN_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut LIKE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut CAST_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut CASE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut CUSTOM_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); // Python standard-library types (serializers) pub(crate) static mut STD_DECIMAL_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); @@ -145,24 +133,11 @@ fn _initialize_typeref(py: pyo3::Python) { crate::data_type::PyUnspecifiedSerializer => UNSPECIFIED_SERIALIZER_TYPE, crate::data_type::PyArraySerializer => ARRAY_SERIALIZER_TYPE, crate::data_type::PyCustomSerializer => CUSTOM_SERIALIZER_TYPE, - // expr (base before subclasses that extend it) - crate::expr::PyExpr => EXPR_TYPE, - crate::expr::PyFunc => FUNC_TYPE, - crate::expr::PyTupleExpr => TUPLE_EXPR_TYPE, - crate::expr::PyUnary => UNARY_TYPE, - crate::expr::PyBinary => BINARY_TYPE, - crate::expr::PyIs => IS_TYPE, - crate::expr::PyIsDistinctFrom => IS_DISTINCT_FROM_TYPE, - crate::expr::PyInList => IN_LIST_TYPE, - crate::expr::PyBetween => BETWEEN_TYPE, - crate::expr::PyLike => LIKE_TYPE, - crate::expr::PyCast => CAST_TYPE, - crate::expr::PyCase => CASE_TYPE, - crate::expr::PyCustom => CUSTOM_TYPE, - crate::expr::PyIdent => IDENT_TYPE, - crate::expr::PyUnquote => UNQUOTE_TYPE, - crate::expr::PyAsterisk => ASTERISK_TYPE, - crate::expr::PyValue => VALUE_TYPE, + crate::expr::base::PyExpr => EXPR_TYPE, + crate::expr::ident::PyIdent => IDENT_TYPE, + crate::expr::ident::PyUnquote => UNQUOTE_TYPE, + crate::expr::ident::PyAsterisk => ASTERISK_TYPE, + crate::expr::value::PyValue => VALUE_TYPE, ); STD_DECIMAL_TYPE = look_up_type_object(c"decimal", c"Decimal"); diff --git a/tests/test_expr.py b/tests/test_expr.py index 2d3601c..8434e40 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -1,10 +1,10 @@ import pytest -from rapidquery import Asterisk, Expr, Func, Ident, Unquote, Value +from rapidquery import Asterisk, Expr, Function, Ident, Unquote, Value from rapidquery.data_type import DataType from rapidquery.expr import ( Between, - Binary, + BinaryExpr, Case, Cast, Custom, @@ -13,7 +13,7 @@ IsDistinctFrom, Like, TupleExpr, - Unary, + UnaryExpr, ) @@ -21,14 +21,14 @@ class TestExprHierarchy: def test_ident_and_value_are_expr(self): assert isinstance(Ident("id"), Expr) assert isinstance(Value(1), Expr) - assert isinstance(Func("SUM", "amount"), Expr) + assert isinstance(Function("SUM", "amount"), Expr) def test_ident_value_live_in_expr_module(self): assert Ident.__module__ == "rapidquery._core.expr" assert Value.__module__ == "rapidquery._core.expr" assert Unquote.__module__ == "rapidquery._core.expr" assert Asterisk.__module__ == "rapidquery._core.expr" - assert Func.__module__ == "rapidquery._core.expr" + assert Function.__module__ == "rapidquery._core.expr" def test_base_expr_cannot_be_instantiated(self): with pytest.raises(TypeError): @@ -43,7 +43,7 @@ def test_value_repr_unchanged(self): class TestBinaryUnary: def test_str_is_ident(self): - expr = Binary("a", "+", "b") + expr = BinaryExpr("a", "+", "b") assert isinstance(expr.left, Ident) assert expr.left[0] == "a" assert expr.op == "+" @@ -51,40 +51,40 @@ def test_str_is_ident(self): assert repr(expr) == 'Binary(Ident("a"), "+", Ident("b"))' def test_value_for_string_literal(self): - expr = Binary("id", "=", Value("x")) + expr = BinaryExpr("id", "=", Value("x")) assert isinstance(expr.left, Ident) assert isinstance(expr.right, Value) assert expr.right.object == "x" def test_custom_operator(self): - expr = Binary("data", "->", Unquote("key")) + expr = BinaryExpr("data", "->", Unquote("key")) assert expr.op == "->" def test_unary_not(self): - expr = Unary("NOT", "x") + expr = UnaryExpr("NOT", "x") assert expr.op == "NOT" assert isinstance(expr.expr, Ident) def test_unknown_unary_raises(self): with pytest.raises(ValueError, match="unknown unary"): - Unary("@", "x") + UnaryExpr("@", "x") class TestFunc: def test_args_are_idents(self): - fn = Func("SUM", "amount") + fn = Function("SUM", "amount") assert isinstance(fn.name, Ident) assert fn.args is not None assert isinstance(fn.args[0], Ident) assert fn.filter is None def test_bare_function(self): - fn = Func("CURRENT_TIMESTAMP", parentheses=False) + fn = Function("CURRENT_TIMESTAMP", parentheses=False) assert fn.args is None assert "parentheses=False" in repr(fn) def test_distinct(self): - fn = Func("COUNT", "id", distinct=True) + fn = Function("COUNT", "id", distinct=True) assert "distinct=True" in repr(fn) @@ -144,5 +144,5 @@ def test_ident_equality_still_works(self): assert hash(Ident("id")) != hash(Ident(Unquote("id"))) def test_binary_equality(self): - assert Binary("a", "+", "b") == Binary("a", "+", "b") - assert Binary("a", "+", "b") != Binary("a", "-", "b") + assert BinaryExpr("a", "+", "b") == BinaryExpr("a", "+", "b") + assert BinaryExpr("a", "+", "b") != BinaryExpr("a", "-", "b") From 7ac9b68433acf00645966743c04eb86f4808c1ea Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Wed, 16 Sep 2026 11:26:14 +0330 Subject: [PATCH 32/87] Fix stub-files --- rapidquery/data_type.pyi | 2 - rapidquery/dialect.pyi | 1 - rapidquery/expr.pyi | 799 +++++++++++++++++++++++++++++++++------ src/expr/base.rs | 7 +- src/expr/enum_/mod.rs | 7 + src/expr/extractors.rs | 18 +- tests/test_expr.py | 149 ++++---- 7 files changed, 784 insertions(+), 199 deletions(-) diff --git a/rapidquery/data_type.pyi b/rapidquery/data_type.pyi index 04d2984..6772e37 100644 --- a/rapidquery/data_type.pyi +++ b/rapidquery/data_type.pyi @@ -43,7 +43,6 @@ class Serializer(typing.Generic[_T]): def __eq__(self, other: object) -> bool: ... def __hash__(self) -> int: ... - def __repr__(self) -> str: ... @typing.final class IntegerSerializer(Serializer[int]): @@ -369,4 +368,3 @@ class DataType(typing.Generic[_T]): def __eq__(self, other: object) -> bool: ... def __hash__(self) -> int: ... - def __repr__(self) -> str: ... diff --git a/rapidquery/dialect.pyi b/rapidquery/dialect.pyi index 7929a4c..687684f 100644 --- a/rapidquery/dialect.pyi +++ b/rapidquery/dialect.pyi @@ -5,7 +5,6 @@ class SqlDialect: def name(self) -> str: ... def __eq__(self, other: object) -> bool: ... def __hash__(self) -> int: ... - def __repr__(self) -> str: ... @final class BigQuery(SqlDialect): diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index 5e1a316..982144e 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -1,60 +1,146 @@ import typing -from collections.abc import Iterable, Iterator, Sequence +from collections.abc import Iterable, Sequence from typing import Self, final, overload from rapidquery.data_type import DataType, Serializer -from rapidquery.dialect import SqlDialect class Expr: """ Abstract SQL expression. - Concrete nodes are frozen subclasses (``Ident``, ``Value``, ``Func``, - ``Binary``, …). This base class cannot be instantiated. - - Future methods (``to_sql``, ``eq``, ``like``, ``between``, ``in_``, - ``is_null``, ``cast``, ``case``, ``custom``, arithmetic/logic) will live - here so every subclass inherits them. Prefer named methods over v0-style - dunders that return ``Expr``. + Concrete nodes are frozen subclasses (``Ident``, ``Value``, + ``Function``, ``BinaryExpr``, …). This base class cannot be + instantiated. """ - def __eq__(self, other: object) -> bool: ... - def __hash__(self) -> int: ... - def __repr__(self) -> str: ... - @final class Unquote: - def __new__(cls, name: str, /) -> Self: ... + """ + Marker for an unquoted identifier part. + + The body is emitted without dialect delimiters. It must be a simple + ASCII name (``[A-Za-z_][A-Za-z0-9_]*``). This is not a raw-SQL + fragment. + """ + + def __new__(cls, name: str, /) -> Self: + """ + Create an unquoted identifier part. + + Args: + name: The identifier body, unquoted. + + Returns: + A new ``Unquote``. + + Raises: + ValueError: If ``name`` is empty or not a simple ASCII + identifier (letters, digits, underscore; first character + not a digit). + """ + @property - def name(self) -> str: ... + def name(self) -> str: + """The unquoted identifier body. + + Returns: + The string passed to the constructor. + """ + def __eq__(self, other: object) -> bool: ... def __hash__(self) -> int: ... - def __repr__(self) -> str: ... @final class Asterisk: - def __new__(cls) -> Self: ... + """ + SQL wildcard ``*``. + + Pass the class (``Asterisk``) or an instance (``Asterisk()``) as an + identifier part. It must be the last part of an ``Ident`` (``t.*``). + ``Ident("*")`` is a quoted column named star, not this wildcard. + """ + + def __new__(cls) -> Self: + """ + Create a wildcard part. + + Returns: + A new ``Asterisk``. + """ + def __eq__(self, other: object) -> bool: ... def __hash__(self) -> int: ... - def __repr__(self) -> str: ... @final class Ident(Expr): - def __new__( - cls, *parts: str | Unquote | Ident | Asterisk | type[Asterisk] - ) -> Self: ... - def __len__(self) -> int: ... - @overload - def __getitem__(self, index: int) -> str | Unquote | Asterisk: ... - @overload - def __getitem__(self, index: slice) -> Ident: ... - def to_list(self) -> Sequence[str | Unquote | Asterisk]: ... - def to_sql(self, dialect: type[SqlDialect] | SqlDialect) -> str: ... + """ + SQL identifier (``schema.table.column``). + + An identifier is a sequence of parts. Each constructor argument is + one part: a quoted name (``str``), an ``Unquote`` name, an + ``Asterisk`` wildcard, or a nested ``Ident`` whose parts are + flattened. + + Never empty. At most 10 parts. An asterisk may appear only as the + last part. + """ + + def __new__(cls, *parts: str | Unquote | Ident | Asterisk | type[Asterisk]) -> Self: + """ + Create a SQL identifier. + + Args: + *parts: One argument per part. A ``str`` is always quoted, + including ``"*"`` (a column named star). Pass + ``Asterisk`` for the SQL wildcard. Dotted names are + ``Ident("schema", "table")``, not + ``Ident("schema.table")``. Sequences are not accepted. + + Returns: + A new ``Ident``. + + Raises: + TypeError: If an argument is not ``str``, ``Unquote``, + ``Asterisk``, or ``Ident``. + ValueError: If there are no parts, a part is empty, + ``Asterisk`` is not last, an ``Unquote`` body is not a + simple ASCII name, or there are more than 10 parts. + """ + + def __len__(self) -> int: + """ + Return the number of identifier parts. + + Returns: + Part count; always at least 1. + """ + + def to_list(self) -> Sequence[str | Unquote | Asterisk]: + """ + Return the parts as a list. + + Returns: + Each part as a ``str``, ``Unquote``, or ``Asterisk``. + """ _T = typing.TypeVar("_T") @final class Value(Expr, typing.Generic[_T]): + """ + A Python object plus the serializer used to emit it as SQL. + + ``Value`` does not convert the object until SQL is built. + Parameterized statements bind the object as a placeholder. Raw SQL + runs the serializer (``None`` becomes ``NULL``). + + If ``serializer`` is omitted, one is inferred from the object's + type. Passing an existing ``Value`` keeps its object (and its + serializer, unless a new one is given). A ``DataType`` as + ``serializer`` contributes only its serializer; the data type is + not stored. + """ + @overload def __new__(cls, object: _T, /) -> typing.Self: ... @overload @@ -63,154 +149,623 @@ class Value(Expr, typing.Generic[_T]): object: _T, serializer: Serializer[_T] | DataType[_T] | None = None, /, - ) -> typing.Self: ... - @property - def object(self) -> _T: ... - @property - def serializer(self) -> Serializer[_T]: ... - def to_sql(self, dialect: type[SqlDialect] | SqlDialect) -> str: ... + ) -> typing.Self: + """ + Create a SQL value. + + Args: + object: The Python object to bind or serialize. If this is + already a ``Value``, its object is reused. + serializer: How to render ``object`` as a SQL literal. A + ``DataType`` is accepted and only its serializer is + kept. If omitted, a serializer is inferred from + ``object`` (or kept, when ``object`` is already a + ``Value``). + + Returns: + A new ``Value``. + + Raises: + TypeError: If ``serializer`` is not a ``Serializer`` or + ``DataType``. + """ + + def get_object(self) -> _T: + """ + Return the wrapped Python object. + + Returns: + The object passed to the constructor (or taken from an + existing ``Value``). + """ + + def get_serializer(self) -> Serializer[_T]: + """ + Return the serializer used for raw SQL literals. + + Returns: + The ``Serializer`` stored on this value. Not used when the + value is bound as a parameter. + """ @final class Function(Expr): + """ + SQL function call. + + ``name`` is an identifier (``SUM``, ``schema.fn``). Passing no + positional arguments emits a bare call such as + ``CURRENT_TIMESTAMP`` (no parentheses). ``distinct=True`` inserts + ``DISTINCT`` inside the parentheses. + """ + def __new__( cls, name: str | Ident | Unquote, *args: object, distinct: bool | None = None, - filter: object | None = None, - ) -> Self: ... - @property - def name(self) -> Ident: ... - @property - def args(self) -> tuple[Expr, ...] | None: ... - @property - def filter(self) -> Expr | None: ... + filter: Expr | None = None, + ) -> Self: + """ + Create a function call. + + Args: + name: Function name (``str``, ``Ident``, or ``Unquote``). + *args: Positional argument expressions. A bare ``str`` is a + column. An empty argument list means no parentheses + (``CURRENT_TIMESTAMP``). + distinct: ``True`` inserts ``DISTINCT``, ``False`` inserts + ``ALL``, omitted inserts neither. + filter: Optional ``FILTER (WHERE …)`` predicate. + + Returns: + A new ``Function``. + + Raises: + TypeError: If ``name`` is not an identifier. + ValueError: If ``name`` is an empty or invalid identifier. + """ + + def get_name(self) -> Ident: + """ + Return the function name. + + Returns: + The function name as an ``Ident``. + """ + + def get_args(self) -> tuple[Expr, ...] | None: + """ + Return the positional arguments. + + Returns: + A tuple of argument expressions, or ``None`` when the call + has no parentheses. + """ + + def get_filter(self) -> Expr | None: + """ + Return the ``FILTER (WHERE …)`` predicate, if any. + + Returns: + The filter expression, or ``None``. + """ @final class TupleExpr(Expr): - def __new__(cls, *items: object) -> Self: ... - @property - def items(self) -> tuple[Expr, ...]: ... + """Parenthesized row / tuple expression ``(a, b, …)``.""" + + def __new__(cls, *items: object) -> Self: + """ + Create a tuple expression. + + Args: + *items: Elements of the tuple. A bare ``str`` is a column. + + Returns: + A new ``TupleExpr``. + """ + + def get_items(self) -> tuple[Expr, ...]: + """ + Return the tuple elements. + + Returns: + The elements as a tuple of expressions. + """ @final class UnaryExpr(Expr): - def __new__(cls, op: str, expr: object, /) -> Self: ... - @property - def op(self) -> str: ... - @property - def expr(self) -> Expr: ... + """Unary expression (``NOT x``, ``-x``, ``+x``, ``~x``).""" + + def __new__(cls, operator: str, expr: object, /) -> Self: + """ + Create a unary expression. + + Args: + operator: One of ``NOT``, ``+``, ``-``, or ``~``. + expr: The operand. A bare ``str`` is a column. + + Returns: + A new ``UnaryExpr``. + + Raises: + ValueError: If ``operator`` is not a known unary operator. + """ + + def get_operator(self) -> str: + """ + Return the unary operator spelling. + + Returns: + ``NOT``, ``+``, ``-``, or ``~``. + """ + + def get_expr(self) -> Expr: + """ + Return the operand. + + Returns: + The operand expression. + """ @final class BinaryExpr(Expr): - def __new__(cls, left: object, op: str, right: object, /) -> Self: ... - @property - def left(self) -> Expr: ... - @property - def op(self) -> str: ... - @property - def right(self) -> Expr: ... + """ + Binary expression (``a + b``, ``a AND b``, custom operators). + + A bare ``str`` on either side is a column. Pass ``Value(...)`` for a + string literal. Unknown operator spellings are stored as a custom + operator (for example ``->``). + """ + + def __new__(cls, left: object, operator: str, right: object, /) -> Self: + """ + Create a binary expression. + + Args: + left: Left operand. A bare ``str`` is a column. + operator: SQL operator spelling (``+``, ``AND``, ``=``, + ``->``, …). ``!=`` is stored as ``<>``. + right: Right operand. A bare ``str`` is a column. + + Returns: + A new ``BinaryExpr``. + """ + + def get_left(self) -> Expr: + """ + Return the left operand. + + Returns: + The left-hand expression. + """ + + def get_operator(self) -> str: + """ + Return the operator spelling. + + Returns: + The SQL operator (``+``, ``AND``, ``<>``, or the custom + string). + """ + + def get_right(self) -> Expr: + """ + Return the right operand. + + Returns: + The right-hand expression. + """ @final class IsExpr(Expr): - def __new__(cls, expr: object, kind: str, *, negated: bool = False) -> Self: ... - @property - def expr(self) -> Expr: ... - @property - def kind(self) -> str: ... - @property - def negated(self) -> bool: ... + """``expr IS [NOT] {NULL | TRUE | FALSE | UNKNOWN}``.""" + + def __new__(cls, expr: object, kind: str, /, *, negated: bool = False) -> Self: + """ + Create an ``IS`` predicate. + + Args: + expr: The tested expression. A bare ``str`` is a column. + kind: ``NULL``, ``TRUE``, ``FALSE``, or ``UNKNOWN``. + negated: If ``True``, emit ``IS NOT``. + + Returns: + A new ``IsExpr``. + + Raises: + ValueError: If ``kind`` is not one of the accepted spellings. + """ + + def get_expr(self) -> Expr: + """ + Return the tested expression. + + Returns: + The left-hand expression. + """ + + def get_kind(self) -> str: + """ + Return the ``IS`` kind. + + Returns: + ``NULL``, ``TRUE``, ``FALSE``, or ``UNKNOWN``. + """ + + def get_negated(self) -> bool: + """ + Return whether this is ``IS NOT``. + + Returns: + ``True`` if the predicate is negated. + """ @final class IsDistinctFromExpr(Expr): - def __new__(cls, left: object, right: object, *, negated: bool = False) -> Self: ... - @property - def left(self) -> Expr: ... - @property - def right(self) -> Expr: ... - @property - def negated(self) -> bool: ... + """``a IS [NOT] DISTINCT FROM b``.""" + + def __new__(cls, left: object, right: object, /, *, negated: bool = False) -> Self: + """ + Create an ``IS DISTINCT FROM`` predicate. + + Args: + left: Left operand. A bare ``str`` is a column. + right: Right operand. A bare ``str`` is a column. + negated: If ``True``, emit ``IS NOT DISTINCT FROM``. + + Returns: + A new ``IsDistinctFromExpr``. + """ + + def get_left(self) -> Expr: + """ + Return the left operand. + + Returns: + The left-hand expression. + """ + + def get_right(self) -> Expr: + """ + Return the right operand. + + Returns: + The right-hand expression. + """ + + def get_negated(self) -> bool: + """ + Return whether this is ``IS NOT DISTINCT FROM``. + + Returns: + ``True`` if the predicate is negated. + """ @final class InListExpr(Expr): + """``expr [NOT] IN (…)``.""" + def __new__( - cls, expr: object, list: Iterable[object], *, negated: bool = False - ) -> Self: ... - @property - def expr(self) -> Expr: ... - @property - def list(self) -> tuple[Expr, ...]: ... - @property - def negated(self) -> bool: ... + cls, expr: object, list: Iterable[object], /, *, negated: bool = False + ) -> Self: + """ + Create an ``IN`` list predicate. + + Args: + expr: The tested expression. A bare ``str`` is a column. + list: Iterable of list elements. Each bare ``str`` is a + column. + negated: If ``True``, emit ``NOT IN``. + + Returns: + A new ``InListExpr``. + """ + + def get_expr(self) -> Expr: + """ + Return the tested expression. + + Returns: + The left-hand expression. + """ + + def get_list(self) -> tuple[Expr, ...]: + """ + Return the ``IN`` list. + + Returns: + The list elements as a tuple of expressions. + """ + + def get_negated(self) -> bool: + """ + Return whether this is ``NOT IN``. + + Returns: + ``True`` if the predicate is negated. + """ @final class BetweenExpr(Expr): + """``expr [NOT] BETWEEN low AND high``.""" + def __new__( cls, expr: object, low: object, high: object, + /, *, negated: bool = False, - ) -> Self: ... - @property - def expr(self) -> Expr: ... - @property - def low(self) -> Expr: ... - @property - def high(self) -> Expr: ... - @property - def negated(self) -> bool: ... + ) -> Self: + """ + Create a ``BETWEEN`` predicate. + + Args: + expr: The tested expression. A bare ``str`` is a column. + low: Lower bound. A bare ``str`` is a column. + high: Upper bound. A bare ``str`` is a column. + negated: If ``True``, emit ``NOT BETWEEN``. + + Returns: + A new ``BetweenExpr``. + """ + + def get_expr(self) -> Expr: + """ + Return the tested expression. + + Returns: + The left-hand expression. + """ + + def get_low(self) -> Expr: + """ + Return the lower bound. + + Returns: + The low expression. + """ + + def get_high(self) -> Expr: + """ + Return the upper bound. + + Returns: + The high expression. + """ + + def get_negated(self) -> bool: + """ + Return whether this is ``NOT BETWEEN``. + + Returns: + ``True`` if the predicate is negated. + """ @final class LikeExpr(Expr): + """``expr [NOT] LIKE / ILIKE / SIMILAR TO / RLIKE pattern``. + + The pattern uses literal-priority extraction: a bare ``str`` is a + string value, not a column. + """ + def __new__( cls, expr: object, pattern: object, + /, *, kind: str = "LIKE", negated: bool = False, escape: str | None = None, - ) -> Self: ... - @property - def expr(self) -> Expr: ... - @property - def pattern(self) -> Expr: ... - @property - def kind(self) -> str: ... - @property - def negated(self) -> bool: ... - @property - def escape(self) -> str | None: ... + ) -> Self: + """ + Create a ``LIKE``-family predicate. + + Args: + expr: The tested expression. A bare ``str`` is a column. + pattern: The pattern. A bare ``str`` is a string literal. + kind: ``LIKE``, ``ILIKE``, ``SIMILAR TO``, or ``RLIKE``. + negated: If ``True``, emit ``NOT LIKE`` (or the matching + negated form). + escape: Optional single-character ``ESCAPE`` clause. + + Returns: + A new ``LikeExpr``. + + Raises: + ValueError: If ``kind`` is not a known ``LIKE`` spelling. + """ + + def get_expr(self) -> Expr: + """ + Return the tested expression. + + Returns: + The left-hand expression. + """ + + def get_pattern(self) -> Expr: + """ + Return the pattern expression. + + Returns: + Usually a ``Value`` when constructed from a string. + """ + + def get_kind(self) -> str: + """ + Return the operator kind. + + Returns: + ``LIKE``, ``ILIKE``, ``SIMILAR TO``, or ``RLIKE``. + """ + + def get_negated(self) -> bool: + """ + Return whether this is a negated match. + + Returns: + ``True`` if the predicate is negated. + """ + + def get_escape(self) -> str | None: + """ + Return the ``ESCAPE`` character, if any. + + Returns: + A single character, or ``None``. + """ @final class CastExpr(Expr): - def __new__(cls, expr: object, data_type: DataType, /) -> Self: ... - @property - def expr(self) -> Expr: ... - @property - def data_type(self) -> DataType: ... + """``CAST(expr AS type)``.""" + + def __new__(cls, expr: object, to: str, /) -> Self: + """ + Create a ``CAST`` expression. + + Args: + expr: The expression to cast. A bare ``str`` is a column. + to: SQL type name (``INT``, ``VARCHAR(255)``, …). Known + names are canonicalized the same way as ``DataType``. + + Returns: + A new ``CastExpr``. + """ + + def get_expr(self) -> Expr: + """ + Return the expression being cast. + + Returns: + The inner expression. + """ + + def get_data_type(self) -> str: + """ + Return the target SQL type name. + + Returns: + The canonical type spelling. + """ @final class CaseExpr(Expr): - def __new__( - cls, - when_then: Iterable[tuple[object, object]], - *, - operand: object | None = None, - else_: object | None = None, - ) -> Self: ... - @property - def operand(self) -> Expr | None: ... - @property - def when_then(self) -> tuple[tuple[Expr, Expr], ...]: ... - @property - def else_(self) -> Expr | None: ... + """ + ``CASE [operand] WHEN … THEN … [ELSE …] END``. + + Build searched or simple ``CASE`` by chaining ``when`` and + ``else_``. Each call returns a new frozen instance. + + The ``when`` argument must already be a condition expression + (``BinaryExpr``, ``IsExpr``, …), not an ``Ident`` or ``Value``. + """ + + def __new__(cls, operand: object | None = None, /) -> Self: + """ + Create an empty ``CASE`` expression. + + Args: + operand: Optional simple-``CASE`` operand. A bare ``str`` + is a column. Omit for a searched ``CASE``. + + Returns: + A new ``CaseExpr`` with no ``WHEN`` clauses. + """ + + def when(self, when: Expr, then: object) -> Self: + """ + Append a ``WHEN … THEN …`` clause. + + Args: + when: A condition expression (not ``Ident`` or ``Value``). + then: Result expression. A bare ``str`` is a column. + + Returns: + A new ``CaseExpr`` with the clause appended. + + Raises: + TypeError: If ``when`` is not a condition expression. + """ + + def else_(self, result: object) -> Self: + """ + Set the ``ELSE`` result. + + Args: + result: The else expression. A bare ``str`` is a column. + + Returns: + A new ``CaseExpr`` with ``ELSE`` set. + """ + + def get_operand(self) -> Expr | None: + """ + Return the simple-``CASE`` operand, if any. + + Returns: + The operand expression, or ``None`` for a searched + ``CASE``. + """ + + def get_when_list(self) -> tuple[tuple[Expr, Expr], ...]: + """ + Return the ``WHEN`` / ``THEN`` pairs. + + Returns: + A tuple of ``(when, then)`` expression pairs. + """ + + def get_else(self) -> Expr | None: + """ + Return the ``ELSE`` expression, if any. + + Returns: + The else expression, or ``None``. + """ @final class CustomExpr(Expr): - def __new__(cls, sql: str, *args: object) -> Self: ... - @property - def sql(self) -> str: ... - @property - def args(self) -> tuple[Expr, ...]: ... + """ + Escape-hatch SQL template. + + ``{}`` slots are filled by rendering the corresponding argument + expression. ``{{`` / ``}}`` emit literal braces. Empty ``args`` + means a raw fragment. Argument count must match placeholder count + when SQL is written. + """ + + def __new__(cls, sql: str, *args: object) -> Self: + """ + Create a custom SQL fragment. + + Args: + sql: Template string. Each ``{}`` is replaced by one + argument. ``{{`` and ``}}`` emit ``{`` and ``}``. + *args: Expressions interpolated at ``{}`` slots. A bare + ``str`` is a column. + + Returns: + A new ``CustomExpr``. + """ + + def get_sql(self) -> str: + """ + Return the template string. + + Returns: + The ``sql`` passed to the constructor. + """ + + def get_args(self) -> tuple[Expr, ...]: + """ + Return the interpolated arguments. + + Returns: + The argument expressions in slot order. + """ diff --git a/src/expr/base.rs b/src/expr/base.rs index 900a916..6076f68 100644 --- a/src/expr/base.rs +++ b/src/expr/base.rs @@ -67,10 +67,9 @@ impl PyExpr { ExprEnum::InList(_) => wrap::(py, self), ExprEnum::Between(_) => wrap::(py, self), ExprEnum::Like(_) => wrap::(py, self), - // ExprEnum::Cast(_) => wrap::(py, self), - // ExprEnum::Case(_) => wrap::(py, self), - // ExprEnum::Custom(_) => wrap::(py, self), - _ => todo!(), + ExprEnum::Cast(_) => wrap::(py, self), + ExprEnum::Case(_) => wrap::(py, self), + ExprEnum::Custom(_) => wrap::(py, self), } } } diff --git a/src/expr/enum_/mod.rs b/src/expr/enum_/mod.rs index 2ddb913..0b8cef4 100644 --- a/src/expr/enum_/mod.rs +++ b/src/expr/enum_/mod.rs @@ -216,6 +216,13 @@ impl From for ExprEnum { } impl ExprEnum { + /// `true` when this node can appear as a SQL condition (`WHERE`, + /// `WHEN`, `FILTER`). Identifiers and bound values are not conditions. + #[inline] + pub(crate) fn is_condition(&self) -> bool { + !matches!(self, Self::Ident(_) | Self::Value(_)) + } + /// Lower is tighter binding. #[inline] fn precedence(&self) -> u8 { diff --git a/src/expr/extractors.rs b/src/expr/extractors.rs index 61f20ad..b6d3f95 100644 --- a/src/expr/extractors.rs +++ b/src/expr/extractors.rs @@ -181,6 +181,22 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for ConditionExtractor { fn extract( obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, ) -> Result { - todo!() + let ptr = obj.as_ptr(); + unsafe { + if crate::typeref::check(ptr, crate::typeref::EXPR_TYPE) { + let expr = obj.cast_unchecked::(); + let inner = &expr.get().0; + + if inner.is_condition() { + return Ok(Self(inner.clone())); + } + } + } + + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected a condition expression, got {}", + unsafe { crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) } + ) } } diff --git a/tests/test_expr.py b/tests/test_expr.py index 8434e40..a71d6a9 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -1,17 +1,16 @@ import pytest from rapidquery import Asterisk, Expr, Function, Ident, Unquote, Value -from rapidquery.data_type import DataType from rapidquery.expr import ( - Between, + BetweenExpr, BinaryExpr, - Case, - Cast, - Custom, - InList, - Is, - IsDistinctFrom, - Like, + CaseExpr, + CastExpr, + CustomExpr, + InListExpr, + IsDistinctFromExpr, + IsExpr, + LikeExpr, TupleExpr, UnaryExpr, ) @@ -44,44 +43,43 @@ def test_value_repr_unchanged(self): class TestBinaryUnary: def test_str_is_ident(self): expr = BinaryExpr("a", "+", "b") - assert isinstance(expr.left, Ident) - assert expr.left[0] == "a" - assert expr.op == "+" - assert isinstance(expr.right, Ident) - assert repr(expr) == 'Binary(Ident("a"), "+", Ident("b"))' + assert isinstance(expr.get_left(), Ident) + assert expr.get_left().to_list()[0] == "a" + assert expr.get_operator() == "+" + assert isinstance(expr.get_right(), Ident) + assert repr(expr) == 'BinaryExpr(Ident("a"), "+", Ident("b"))' def test_value_for_string_literal(self): expr = BinaryExpr("id", "=", Value("x")) - assert isinstance(expr.left, Ident) - assert isinstance(expr.right, Value) - assert expr.right.object == "x" + assert isinstance(expr.get_left(), Ident) + assert isinstance(expr.get_right(), Value) + assert expr.get_right().get_object() == "x" def test_custom_operator(self): expr = BinaryExpr("data", "->", Unquote("key")) - assert expr.op == "->" + assert expr.get_operator() == "->" def test_unary_not(self): expr = UnaryExpr("NOT", "x") - assert expr.op == "NOT" - assert isinstance(expr.expr, Ident) + assert expr.get_operator() == "NOT" + assert isinstance(expr.get_expr(), Ident) def test_unknown_unary_raises(self): with pytest.raises(ValueError, match="unknown unary"): UnaryExpr("@", "x") -class TestFunc: +class TestFunction: def test_args_are_idents(self): fn = Function("SUM", "amount") - assert isinstance(fn.name, Ident) - assert fn.args is not None - assert isinstance(fn.args[0], Ident) - assert fn.filter is None + assert isinstance(fn.get_name(), Ident) + assert fn.get_args() is not None + assert isinstance(fn.get_args()[0], Ident) + assert fn.get_filter() is None def test_bare_function(self): - fn = Function("CURRENT_TIMESTAMP", parentheses=False) - assert fn.args is None - assert "parentheses=False" in repr(fn) + fn = Function("CURRENT_TIMESTAMP") + assert fn.get_args() is None def test_distinct(self): fn = Function("COUNT", "id", distinct=True) @@ -91,58 +89,71 @@ def test_distinct(self): class TestNodes: def test_tuple_expr(self): expr = TupleExpr("a", "b") - assert len(expr.items) == 2 - assert isinstance(expr.items[0], Ident) + items = expr.get_items() + assert len(items) == 2 + assert isinstance(items[0], Ident) def test_is_null(self): - expr = Is("x", "NULL", negated=True) - assert expr.kind == "NULL" - assert expr.negated is True + expr = IsExpr("x", "NULL", negated=True) + assert expr.get_kind() == "NULL" + assert expr.get_negated() is True def test_in_list(self): - expr = InList("x", ["a", "b"]) - assert len(expr.list) == 2 - assert isinstance(expr.list[0], Ident) + expr = InListExpr("x", ["a", "b"]) + assert len(expr.get_list()) == 2 + assert isinstance(expr.get_list()[0], Ident) def test_between(self): - expr = Between("x", "a", "b") - assert isinstance(expr.low, Ident) - assert isinstance(expr.high, Ident) + expr = BetweenExpr("x", "a", "b") + assert isinstance(expr.get_low(), Ident) + assert isinstance(expr.get_high(), Ident) def test_like_pattern_is_literal(self): - expr = Like("name", "%a%") - assert isinstance(expr.expr, Ident) - assert isinstance(expr.pattern, Value) - assert expr.pattern.object == "%a%" - assert expr.kind == "LIKE" + expr = LikeExpr("name", "%a%") + assert isinstance(expr.get_expr(), Ident) + assert isinstance(expr.get_pattern(), Value) + assert expr.get_pattern().get_object() == "%a%" + assert expr.get_kind() == "LIKE" def test_cast(self): - expr = Cast("x", DataType("int")) - assert isinstance(expr.expr, Ident) - assert expr.data_type.name == "INT" - - def test_case(self): - expr = Case([("a", "b")], else_="c") - assert expr.operand is None - assert len(expr.when_then) == 1 - assert isinstance(expr.when_then[0][0], Ident) - assert isinstance(expr.else_, Ident) + expr = CastExpr("x", "int") + assert isinstance(expr.get_expr(), Ident) + assert expr.get_data_type() == "INT" def test_custom(self): - expr = Custom("json_agg(DISTINCT {})", "col") - assert expr.sql == "json_agg(DISTINCT {})" - assert isinstance(expr.args[0], Ident) + expr = CustomExpr("json_agg(DISTINCT {})", "col") + assert expr.get_sql() == "json_agg(DISTINCT {})" + assert isinstance(expr.get_args()[0], Ident) def test_is_distinct_from(self): - expr = IsDistinctFrom("a", "b", negated=True) - assert expr.negated is True - - -class TestEquality: - def test_ident_equality_still_works(self): - assert Ident("t", "id") == Ident("t", "id") - assert hash(Ident("id")) != hash(Ident(Unquote("id"))) - - def test_binary_equality(self): - assert BinaryExpr("a", "+", "b") == BinaryExpr("a", "+", "b") - assert BinaryExpr("a", "+", "b") != BinaryExpr("a", "-", "b") + expr = IsDistinctFromExpr("a", "b", negated=True) + assert expr.get_negated() is True + + +class TestConditionExtractor: + def test_case_when_accepts_binary_condition(self): + cond = BinaryExpr("x", ">", "y") + expr = CaseExpr().when(cond, "yes").else_("no") + when_list = expr.get_when_list() + assert len(when_list) == 1 + assert isinstance(when_list[0][0], BinaryExpr) + assert isinstance(when_list[0][1], Ident) + assert isinstance(expr.get_else(), Ident) + + def test_case_when_accepts_is_expr(self): + expr = CaseExpr().when(IsExpr("x", "NULL"), "missing") + assert isinstance(expr.get_when_list()[0][0], IsExpr) + + def test_case_when_rejects_ident(self): + with pytest.raises(TypeError, match="condition"): + CaseExpr().when(Ident("x"), "yes") + + def test_case_when_rejects_value(self): + with pytest.raises(TypeError, match="condition"): + CaseExpr().when(Value(1), "yes") + + def test_case_when_rejects_bare_str_and_int(self): + with pytest.raises(TypeError, match="condition"): + CaseExpr().when("x", "yes") + with pytest.raises(TypeError, match="condition"): + CaseExpr().when(1, "yes") From ed013fdf648366f85d0bb48bf6897fad7a2b70d0 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Wed, 16 Sep 2026 17:49:58 +0330 Subject: [PATCH 33/87] Rewrite tests for data_type and serializers --- .gitignore | 1 - Cargo.lock | 7 + Cargo.toml | 1 + rapidquery/data_type.pyi | 24 +- rapidquery/dialect.py | 2 + src/data_type/mod.rs | 6 + src/data_type/serializer/mod.rs | 19 +- src/data_type/serializer/serialize.rs | 15 +- src/data_type/serializer/serializer_enum.rs | 12 + src/dialect/bigquery.rs | 1 + src/dialect/databricks.rs | 1 + src/dialect/hive.rs | 1 + src/dialect/mod.rs | 27 + src/dialect/mssql.rs | 1 + src/dialect/oracle.rs | 1 + src/dialect/py.rs | 2 - src/dialect/redshift.rs | 1 + src/dialect/snowflake.rs | 1 + src/dialect/spark.rs | 1 + src/dialect/sqlite.rs | 1 + src/dialect/tests.rs | 44 + src/lib.rs | 17 + tests/conftest.py | 17 +- tests/test_array_serializer.py | 102 -- tests/test_custom_serializer.py | 88 -- tests/test_data_type.py | 132 +- tests/test_dialect.py | 105 -- tests/test_expr.py | 159 --- tests/test_ident.py | 198 --- tests/test_serializers.py | 1413 ++++++++++++------- tests/test_value.py | 107 -- 31 files changed, 1134 insertions(+), 1373 deletions(-) delete mode 100644 tests/test_array_serializer.py delete mode 100644 tests/test_custom_serializer.py delete mode 100644 tests/test_dialect.py delete mode 100644 tests/test_expr.py delete mode 100644 tests/test_ident.py delete mode 100644 tests/test_value.py diff --git a/.gitignore b/.gitignore index d669600..965592f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,3 @@ backup/ backup-py/ /.vscode /*_cache -/a.py diff --git a/Cargo.lock b/Cargo.lock index ec28c8d..fb492bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -369,6 +369,7 @@ dependencies = [ "sea-query", "smallvec", "sqlparser", + "uuid", ] [[package]] @@ -457,6 +458,12 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" + [[package]] name = "wasm-bindgen" version = "0.2.127" diff --git a/Cargo.toml b/Cargo.toml index 98688a3..2274b26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ smallvec = { version = "1.16.0", default-features = false } sqlparser = { version = "0.62.0", default-features = false } # ONLY for inspiring the source sea-query = { version = "1.0.2", default-features = false } +uuid = { version = "1.26.1", default-features = false } [features] default = ["extension-module"] diff --git a/rapidquery/data_type.pyi b/rapidquery/data_type.pyi index 6772e37..5fe4761 100644 --- a/rapidquery/data_type.pyi +++ b/rapidquery/data_type.pyi @@ -39,6 +39,8 @@ class Serializer(typing.Generic[_T]): TypeError: If `object` is not an accepted type for this serializer. ValueError: If `object` is the right type but cannot be represented (for example a non-finite float). + NotImplementedError: If `dialect` has no native literal for this + serializer (bit strings, `ARRAY[…]`, inet, macaddr). """ def __eq__(self, other: object) -> bool: ... @@ -110,6 +112,7 @@ class UuidSerializer(Serializer[uuid.UUID | str]): Raises: TypeError: If the value is not a `uuid.UUID` or `str`. + ValueError: If the value is `str`, but it is not a UUID. """ def __new__(cls) -> typing.Self: ... @@ -133,10 +136,12 @@ class BitSerializer(Serializer[str]): Serializes bit strings as dialect bit-string literals. Accepts a `str` consisting only of `'0'` and `'1'` characters. + Supported on PostgreSQL, DuckDB, MySQL, and Teradata. Raises: TypeError: If the value is not a `str`. ValueError: If the string is empty or contains characters other than `'0'` and `'1'`. + NotImplementedError: If the dialect has no native bit-string literal. """ def __new__(cls) -> typing.Self: ... @@ -149,7 +154,7 @@ class JSONSerializer(Serializer[typing.Any]): Accepts any object that the JSON encoder can dump. Uses `orjson` when it is installed, otherwise the stdlib `json` module. The JSON text is then wrapped with the dialect's string quoting. With `orjson`, datetime, UUID, - dataclass, and non-str dict keys are encoded natively; stdlib `json` + enum.StrEnum, and dataclass are encoded natively; stdlib `json` rejects those. Raises: @@ -207,10 +212,11 @@ class InetSerializer(Serializer[typing.Any]): Accepts `str` or `ipaddress` objects (`IPv4Address`, `IPv6Address`, `IPv4Network`, `IPv6Network`, and related types whose `str()` is a valid - address or CIDR). + address or CIDR). Supported on PostgreSQL and DuckDB. Raises: ValueError: If the value is not a valid IPv4/IPv6 address or network. + NotImplementedError: If the dialect has no native INET type. """ def __new__(cls) -> typing.Self: ... @@ -221,9 +227,11 @@ class MacAddressSerializer(Serializer[str]): Serializes MAC addresses as quoted SQL literals. Accepts a `str` using `:` or `-` as the separator. + Supported on PostgreSQL. Raises: ValueError: If the value is not a valid MAC address. + NotImplementedError: If the dialect has no native MACADDR type. """ def __new__(cls) -> typing.Self: ... @@ -246,7 +254,11 @@ class ArraySerializer(Serializer[list[_T] | tuple[_T]]): Each element is serialized independently with `element`. Accepts `list` or `tuple`. Nested arrays are expressed by wrapping another - `ArraySerializer`. + `ArraySerializer`. Supported on PostgreSQL, DuckDB, and BigQuery. + + Raises: + TypeError: If the value is not a `list` or `tuple`. + NotImplementedError: If the dialect has no `ARRAY[…]` constructor. """ def __new__(cls, element: Serializer[_T]) -> typing.Self: @@ -364,6 +376,12 @@ class DataType(typing.Generic[_T]): Returns: A SQL fragment produced by this type's serializer. + + Raises: + TypeError: If `object` is not an accepted type for this serializer. + ValueError: If `object` is the right type but cannot be represented. + NotImplementedError: If `dialect` has no native literal for this + type's serializer (bit strings, `ARRAY[…]`, inet, macaddr). """ def __eq__(self, other: object) -> bool: ... diff --git a/rapidquery/dialect.py b/rapidquery/dialect.py index 481a94a..90ca2ba 100644 --- a/rapidquery/dialect.py +++ b/rapidquery/dialect.py @@ -2,6 +2,8 @@ SQL dialect markers. """ +# TODO: rename PostgreSQL to PostgreSql, SQLite to Sqlite, MSSQL to MsSql, DuckDB to DuckDb + from ._core.dialect import ( MSSQL, BigQuery, diff --git a/src/data_type/mod.rs b/src/data_type/mod.rs index b68bd6c..4a8aa3d 100644 --- a/src/data_type/mod.rs +++ b/src/data_type/mod.rs @@ -131,6 +131,12 @@ impl PyDataType { /// /// Returns: /// A SQL fragment produced by this type's serializer. + /// + /// Raises: + /// TypeError: If `object` is not an accepted type for this serializer. + /// ValueError: If `object` is the right type but cannot be represented. + /// NotImplementedError: If `dialect` has no native literal for this + /// type's serializer (bit strings, `ARRAY[…]`, inet, macaddr). fn serialize( &self, dialect: crate::dialect::py::PySqlDialect, diff --git a/src/data_type/serializer/mod.rs b/src/data_type/serializer/mod.rs index 5b24b21..e70d61f 100644 --- a/src/data_type/serializer/mod.rs +++ b/src/data_type/serializer/mod.rs @@ -5,7 +5,7 @@ pub use serializer_enum::SerializerEnum; use crate::internal::alias; -fn class_name(serializer: &SerializerEnum) -> &'static str { +pub(super) fn class_name(serializer: &SerializerEnum) -> &'static str { match serializer { SerializerEnum::Integer => "IntegerSerializer", SerializerEnum::Float => "FloatSerializer", @@ -119,6 +119,8 @@ impl PySerializer { /// TypeError: If `object` is not an accepted type for this serializer. /// ValueError: If `object` is the right type but cannot be represented /// (for example a non-finite float). + /// NotImplementedError: If `dialect` has no native literal for this + /// serializer (bit strings, `ARRAY[…]`, inet, macaddr). fn serialize( &self, dialect: crate::dialect::py::PySqlDialect, @@ -226,17 +228,19 @@ impl_unit_serializers! { /// Serializes bit strings as dialect bit-string literals. /// Accepts a `str` consisting only of `'0'` and `'1'` characters. + /// Supported on PostgreSQL, DuckDB, MySQL, and Teradata. /// /// Raises: /// TypeError: If the value is not a `str`. /// ValueError: If the string is empty or contains characters other than `'0'` and `'1'`. + /// NotImplementedError: If the dialect has no native bit-string literal. Bit => PyBitSerializer => "BitSerializer", /// Serializes a JSON-encodable object as a quoted SQL string literal. /// Accepts any object that the JSON encoder can dump. Uses `orjson` when it /// is installed, otherwise the stdlib `json` module. The JSON text is then /// wrapped with the dialect's string quoting. With `orjson`, datetime, - /// UUID, dataclass, and non-str dict keys are encoded natively; stdlib + /// UUID, enum.StrEnum, and dataclass are encoded natively; stdlib /// `json` rejects those. /// /// Raises: @@ -270,17 +274,20 @@ impl_unit_serializers! { /// Serializes IP addresses and networks as quoted SQL literals. /// Accepts `str` or `ipaddress` objects (`IPv4Address`, `IPv6Address`, /// `IPv4Network`, `IPv6Network`, and related types whose `str()` is a valid - /// address or CIDR). + /// address or CIDR). Supported on PostgreSQL and DuckDB. /// /// Raises: /// ValueError: If the value is not a valid IPv4/IPv6 address or network. + /// NotImplementedError: If the dialect has no native INET type. Inet => PyInetSerializer => "InetSerializer", /// Serializes MAC addresses as quoted SQL literals. /// Accepts a `str` using `:` or `-` as the separator. + /// Supported on PostgreSQL. /// /// Raises: /// ValueError: If the value is not a valid MAC address. + /// NotImplementedError: If the dialect has no native MACADDR type. MacAddress => PyMacAddressSerializer => "MacAddressSerializer", /// Inserts `str(obj)` into SQL with no quoting or type check. @@ -293,7 +300,11 @@ impl_unit_serializers! { /// /// Each element is serialized independently with `element`. Accepts `list` /// or `tuple`. Nested arrays are expressed by wrapping another -/// `ArraySerializer`. +/// `ArraySerializer`. Supported on PostgreSQL, DuckDB, and BigQuery. +/// +/// Raises: +/// TypeError: If the value is not a `list` or `tuple`. +/// NotImplementedError: If the dialect has no `ARRAY[…]` constructor. #[rapidquery_macros::pyclass( "ArraySerializer", extends = PySerializer, diff --git a/src/data_type/serializer/serialize.rs b/src/data_type/serializer/serialize.rs index 41a63ca..b5a319b 100644 --- a/src/data_type/serializer/serialize.rs +++ b/src/data_type/serializer/serialize.rs @@ -20,6 +20,15 @@ impl super::serializer_enum::SerializerEnum { object: &alias::BoundObject<'_>, out: &mut String, ) -> pyo3::PyResult<()> { + if !self.is_supported_by(dialect) { + return rapidquery_macros::pyerr!( + Err(PyNotImplementedError), + "{} does not support {}", + dialect.name(), + super::class_name(self) + ); + } + let py = object.py(); let ptr = object.as_ptr(); unsafe { @@ -223,7 +232,11 @@ unsafe fn write_uuid( write_quoted(dialect, out, &text); Ok(()) } else if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { - write_quoted(dialect, out, borrow_py_str_buffer(py, ptr)?); + let borrowed = borrow_py_str_buffer(py, ptr)?; + let parsed = uuid::Uuid::try_parse(borrowed) + .map_err(|e| rapidquery_macros::pyerr!(PyValueError, "invalid uuid str: {}", e))?; + + write_quoted(dialect, out, &parsed.hyphenated().to_string()); Ok(()) } else { rapidquery_macros::pyerr!( diff --git a/src/data_type/serializer/serializer_enum.rs b/src/data_type/serializer/serializer_enum.rs index 0dd9687..b582253 100644 --- a/src/data_type/serializer/serializer_enum.rs +++ b/src/data_type/serializer/serializer_enum.rs @@ -97,6 +97,18 @@ impl SerializerEnum { } } + /// Whether `dialect` can emit a native literal for this serializer. + #[inline] + pub fn is_supported_by(&self, dialect: &crate::dialect::SqlDialect) -> bool { + match self { + Self::Bit => dialect.supports_bit(), + Self::Array(_) => dialect.supports_array(), + Self::Inet => dialect.supports_inet(), + Self::MacAddress => dialect.supports_mac_address(), + _ => true, + } + } + pub fn infer_bound(bound: &alias::BoundObject) -> Self { unsafe { let object_ptr = bound.as_ptr(); diff --git a/src/dialect/bigquery.rs b/src/dialect/bigquery.rs index 605d65a..cdaf128 100644 --- a/src/dialect/bigquery.rs +++ b/src/dialect/bigquery.rs @@ -59,6 +59,7 @@ pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> writer.write_char('\'') } +// Unsupported: Serializer::write_sql raises NotImplementedError via supports_bit. pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { super::debug_assert_bits(bits); write!(writer, "CAST(0b{bits} AS INT64)") diff --git a/src/dialect/databricks.rs b/src/dialect/databricks.rs index d6a9021..daa5a7c 100644 --- a/src/dialect/databricks.rs +++ b/src/dialect/databricks.rs @@ -58,6 +58,7 @@ pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> super::write_x_hex_literal(writer, bytes) } +// Unsupported: Serializer::write_sql raises NotImplementedError via supports_bit. pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { write!(writer, "CAST({}L AS BINARY)", super::bits_as_i64(bits)) } diff --git a/src/dialect/hive.rs b/src/dialect/hive.rs index 13f8f49..4f004a1 100644 --- a/src/dialect/hive.rs +++ b/src/dialect/hive.rs @@ -58,6 +58,7 @@ pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> writer.write_str("')") } +// Unsupported: Serializer::write_sql raises NotImplementedError via supports_bit. pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { write!(writer, "CAST({} AS BIGINT)", super::bits_as_i64(bits)) } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index a29f088..ba97b0e 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -309,6 +309,33 @@ impl SqlDialect { dispatch!(self, const PLACEHOLDER_SYNTAX) } + /// Native bit-string literals (`B'…'`, `b'…'`, `'…'B`). + #[inline] + pub fn supports_bit(self) -> bool { + matches!( + self, + Self::PostgreSql | Self::DuckDb | Self::MySql | Self::Teradata + ) + } + + /// `ARRAY[…]` constructor syntax. + #[inline] + pub fn supports_array(self) -> bool { + matches!(self, Self::PostgreSql | Self::DuckDb | Self::BigQuery) + } + + /// Native `INET` / `CIDR` types. + #[inline] + pub fn supports_inet(self) -> bool { + matches!(self, Self::PostgreSql | Self::DuckDb) + } + + /// Native `MACADDR` type. + #[inline] + pub fn supports_mac_address(self) -> bool { + matches!(self, Self::PostgreSql) + } + /// Write a boolean literal (`TRUE`/`FALSE` or `1`/`0`). #[inline] pub fn format_boolean(&self, writer: &mut W, value: bool) -> fmt::Result { diff --git a/src/dialect/mssql.rs b/src/dialect/mssql.rs index b4d86d2..e11d5cd 100644 --- a/src/dialect/mssql.rs +++ b/src/dialect/mssql.rs @@ -45,6 +45,7 @@ pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> super::bytes_to_hex(writer, bytes) } +// Unsupported: Serializer::write_sql raises NotImplementedError via supports_bit. pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { write!(writer, "CAST({} AS BIGINT)", super::bits_as_i64(bits)) } diff --git a/src/dialect/oracle.rs b/src/dialect/oracle.rs index 5cea7fb..ea53a36 100644 --- a/src/dialect/oracle.rs +++ b/src/dialect/oracle.rs @@ -44,6 +44,7 @@ pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> writer.write_str("')") } +// Unsupported: Serializer::write_sql raises NotImplementedError via supports_bit. pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { write!(writer, "{}", super::bits_as_i64(bits)) } diff --git a/src/dialect/py.rs b/src/dialect/py.rs index d1fb945..50520e9 100644 --- a/src/dialect/py.rs +++ b/src/dialect/py.rs @@ -20,8 +20,6 @@ use crate::internal::utils::get_type_name; use super::SqlDialect; -// `subclass` is so the 13 built-in dialects can `extends` this type from -// Rust. Python subclasses are not accepted by the extractor below. /// Abstract SQL dialect. /// /// Concrete dialects are frozen subclasses (`PostgreSQL`, `DuckDB`, ...). diff --git a/src/dialect/redshift.rs b/src/dialect/redshift.rs index fb237a6..59f13ff 100644 --- a/src/dialect/redshift.rs +++ b/src/dialect/redshift.rs @@ -46,6 +46,7 @@ pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> writer.write_str("', 'hex')") } +// Unsupported: Serializer::write_sql raises NotImplementedError via supports_bit. pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { write!(writer, "CAST({} AS BIGINT)", super::bits_as_i64(bits)) } diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index 311e875..6fdf968 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -57,6 +57,7 @@ pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> super::write_x_hex_literal(writer, bytes) } +// Unsupported: Serializer::write_sql raises NotImplementedError via supports_bit. pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { write!( writer, diff --git a/src/dialect/spark.rs b/src/dialect/spark.rs index d34edcb..c2430d6 100644 --- a/src/dialect/spark.rs +++ b/src/dialect/spark.rs @@ -57,6 +57,7 @@ pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> super::write_x_hex_literal(writer, bytes) } +// Unsupported: Serializer::write_sql raises NotImplementedError via supports_bit. pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { write!(writer, "CAST({} AS BIGINT)", super::bits_as_i64(bits)) } diff --git a/src/dialect/sqlite.rs b/src/dialect/sqlite.rs index 3677707..b246107 100644 --- a/src/dialect/sqlite.rs +++ b/src/dialect/sqlite.rs @@ -43,6 +43,7 @@ pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> super::write_x_hex_literal(writer, bytes) } +// Unsupported: Serializer::write_sql raises NotImplementedError via supports_bit. pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { write!(writer, "{}", super::bits_as_i64(bits)) } diff --git a/src/dialect/tests.rs b/src/dialect/tests.rs index 7ab71e9..3dc60eb 100644 --- a/src/dialect/tests.rs +++ b/src/dialect/tests.rs @@ -877,3 +877,47 @@ fn placeholder_sigil_matches_syntax() { assert_eq!(PlaceholderSyntax::Colon.sigil(), ':'); assert_eq!(PlaceholderSyntax::DollarNamed.sigil(), '$'); } + +#[test] +fn serializer_support_matrix() { + const BIT: &[SqlDialect] = &[ + SqlDialect::DuckDb, + SqlDialect::MySql, + SqlDialect::PostgreSql, + SqlDialect::Teradata, + ]; + const ARRAY: &[SqlDialect] = &[ + SqlDialect::BigQuery, + SqlDialect::DuckDb, + SqlDialect::PostgreSql, + ]; + const INET: &[SqlDialect] = &[SqlDialect::DuckDb, SqlDialect::PostgreSql]; + const MAC: &[SqlDialect] = &[SqlDialect::PostgreSql]; + + for dialect in ALL_DIALECTS { + assert_eq!( + dialect.supports_bit(), + BIT.contains(dialect), + "{} supports_bit", + dialect.name() + ); + assert_eq!( + dialect.supports_array(), + ARRAY.contains(dialect), + "{} supports_array", + dialect.name() + ); + assert_eq!( + dialect.supports_inet(), + INET.contains(dialect), + "{} supports_inet", + dialect.name() + ); + assert_eq!( + dialect.supports_mac_address(), + MAC.contains(dialect), + "{} supports_mac_address", + dialect.name() + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 825ee9d..971f6ff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,8 +24,25 @@ mod _core { #[cold] fn init(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> { use crate::internal::utils::PyModuleSubmoduleExt; + use pyo3::types::PyModuleMethods; crate::typeref::initialize_typeref(m.py()); + + m.add( + "_feature_only_stdlib_json", + cfg_select! { + feature = "only-stdlib-json" => true, + not(feature = "only-stdlib-json") => false, + }, + )?; + m.add( + "_feature_only_orjson", + cfg_select! { + feature = "only-orjson" => true, + not(feature = "only-orjson") => false, + }, + )?; + m.init_submodules() } } diff --git a/tests/conftest.py b/tests/conftest.py index 9fc11c8..1cd65c0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,3 @@ -import pytest - from rapidquery.dialect import ( MSSQL, BigQuery, @@ -17,7 +15,7 @@ Teradata, ) -ALL_DIALECTS = [ +ALL_DIALECTS: list[type[SqlDialect]] = [ BigQuery, Databricks, DuckDB, @@ -32,16 +30,3 @@ SQLite, Teradata, ] -ALL_DIALECT_NAMES = [cls.__name__ for cls in ALL_DIALECTS] - - -@pytest.fixture(params=ALL_DIALECTS, ids=ALL_DIALECT_NAMES) -def dialect_cls(request: pytest.FixtureRequest) -> type[SqlDialect]: - """Every concrete SqlDialect subclass.""" - return request.param - - -@pytest.fixture(params=ALL_DIALECTS, ids=ALL_DIALECT_NAMES) -def dialect_instance(request: pytest.FixtureRequest) -> SqlDialect: - """An instance of every concrete SqlDialect subclass.""" - return request.param() diff --git a/tests/test_array_serializer.py b/tests/test_array_serializer.py deleted file mode 100644 index 9a3c75d..0000000 --- a/tests/test_array_serializer.py +++ /dev/null @@ -1,102 +0,0 @@ -import pytest - -from rapidquery.data_type import ( - ArraySerializer, - IntegerSerializer, - Serializer, - StringSerializer, -) - - -class TestArraySerializerConstruction: - def test_is_serializer_subclass(self): - assert issubclass(ArraySerializer, Serializer) - - def test_constructs_with_element_serializer(self): - # Should not raise. - ArraySerializer(IntegerSerializer()) - - def test_element_property_returns_the_given_serializer_type(self): - arr = ArraySerializer(IntegerSerializer()) - assert isinstance(arr.element, IntegerSerializer) - - def test_element_property_reflects_string_serializer(self): - arr = ArraySerializer(StringSerializer()) - assert isinstance(arr.element, StringSerializer) - - def test_rejects_non_serializer_element(self): - with pytest.raises(TypeError): - ArraySerializer("not-a-serializer") # type: ignore - - def test_nested_array_serializer(self): - """ArraySerializer should be composable (array of arrays).""" - inner = ArraySerializer(IntegerSerializer()) - outer = ArraySerializer(inner) - assert isinstance(outer.element, ArraySerializer) - assert isinstance(outer.element.element, IntegerSerializer) - - -class TestArraySerializerIntendedBehaviour: - def test_serializes_each_int_element_independently(self, dialect_cls): - arr = ArraySerializer(IntegerSerializer()) - result = arr.serialize(dialect_cls, [1, 2, 3]) - assert result == "ARRAY[1, 2, 3]" - - def test_serializes_tuple_same_as_list(self, dialect_cls): - arr = ArraySerializer(IntegerSerializer()) - list_result = arr.serialize(dialect_cls, [1, 2, 3]) - tuple_result = arr.serialize(dialect_cls, (1, 2, 3)) - assert list_result == tuple_result == "ARRAY[1, 2, 3]" - - def test_serializes_string_elements_with_correct_individual_quoting( - self, dialect_cls - ): - arr = ArraySerializer(StringSerializer()) - result = arr.serialize(dialect_cls, ["a", "b"]) - # Each element must be independently quoted, not the whole list. - assert result.count("a") == 1 - assert result.count("b") == 1 - - def test_empty_array(self, dialect_cls): - arr = ArraySerializer(IntegerSerializer()) - result = arr.serialize(dialect_cls, []) - assert result == "ARRAY[]" - - def test_single_element_array(self, dialect_cls): - arr = ArraySerializer(IntegerSerializer()) - result = arr.serialize(dialect_cls, [42]) - assert result == "ARRAY[42]" - - def test_element_type_error_propagates_with_correct_element_identified( - self, dialect_cls - ): - """If a single element is the wrong type for the element serializer, - the error should reflect that one bad element, not the container. - """ - arr = ArraySerializer(IntegerSerializer()) - with pytest.raises(TypeError): - arr.serialize(dialect_cls, [1, "not an int", 3]) - - def test_wraps_result_in_array_literal_prefix(self, dialect_cls): - arr = ArraySerializer(IntegerSerializer()) - result = arr.serialize(dialect_cls, [1]) - assert result.startswith("ARRAY[") and result.endswith("]") - - -class TestArraySerializerContainerValidation: - """These parts of the contract are independent of the per-element bug: - input-type validation happens before the (buggy) per-element loop, so - rejecting a bad *container* type still works correctly today. - """ - - @pytest.mark.parametrize("value", ["not a list", 123, None, {"a": 1}]) - def test_rejects_non_list_tuple_container(self, value, dialect_cls): - arr = ArraySerializer(IntegerSerializer()) - with pytest.raises(TypeError): - arr.serialize(dialect_cls, value) - - def test_rejects_set_as_container(self, dialect_cls): - """Only list/tuple are documented as accepted containers, not set.""" - arr = ArraySerializer(IntegerSerializer()) - with pytest.raises(TypeError): - arr.serialize(dialect_cls, {1, 2, 3}) diff --git a/tests/test_custom_serializer.py b/tests/test_custom_serializer.py deleted file mode 100644 index ba4e629..0000000 --- a/tests/test_custom_serializer.py +++ /dev/null @@ -1,88 +0,0 @@ -import pytest - -from rapidquery.data_type import CustomSerializer, Serializer - - -class TestCustomSerializerConstruction: - def test_is_serializer_subclass(self): - assert issubclass(CustomSerializer, Serializer) - - def test_constructs_with_callable(self): - CustomSerializer(lambda dialect, obj: str(obj)) - - def test_function_getter_returns_the_same_callable(self): - def my_func(dialect, obj): - return str(obj) - - serializer = CustomSerializer(my_func) - assert serializer.function is my_func - - @pytest.mark.parametrize("value", [None, 123, "not callable", [1, 2, 3]]) - def test_rejects_non_callable(self, value): - with pytest.raises(TypeError): - CustomSerializer(value) - - def test_accepts_any_callable_object_not_just_functions(self): - class Callable: - def __call__(self, dialect, obj): - return "called" - - # Should not raise. - CustomSerializer(Callable()) - - -class TestCustomSerializerInvocation: - def test_calls_function_with_object_and_dialect_name(self, dialect_cls): - captured = {} - - def capture(dialect_name, obj): - captured["obj"] = obj - captured["dialect_name"] = dialect_name - return "ok" - - CustomSerializer(capture).serialize(dialect_cls, 42) - - assert captured["obj"] == 42 - assert captured["dialect_name"] == dialect_cls().name - - def test_returns_the_function_result(self, dialect_cls): - serializer = CustomSerializer(lambda dialect, obj: f"CUSTOM({obj})") - result = serializer.serialize(dialect_cls, "value") - assert result == "CUSTOM(value)" - - def test_dialect_name_differs_per_dialect(self): - from rapidquery.dialect import MySql, PostgreSQL - - seen_names = [] - - def capture(dialect_name, obj): - seen_names.append(dialect_name) - return "ok" - - serializer = CustomSerializer(capture) - serializer.serialize(PostgreSQL, "x") - serializer.serialize(MySql, "x") - - assert seen_names[0] != seen_names[1] - - def test_exception_inside_function_propagates(self, dialect_cls): - def failing(dialect, obj): - raise ValueError("boom") - - with pytest.raises(ValueError, match="boom"): - CustomSerializer(failing).serialize(dialect_cls, "x") - - def test_non_string_return_value_is_rejected(self, dialect_cls): - serializer = CustomSerializer(lambda dialect, obj: 12345) # type: ignore - with pytest.raises(TypeError): - serializer.serialize(dialect_cls, "x") - - def test_none_return_value_is_rejected(self, dialect_cls): - serializer = CustomSerializer(lambda dialect, obj: None) # type: ignore - with pytest.raises(TypeError): - serializer.serialize(dialect_cls, "x") - - def test_can_be_used_to_implement_arbitrary_formatting(self, dialect_cls): - serializer = CustomSerializer(lambda dialect, obj: f"'{str(obj).upper()}'") - result = serializer.serialize(dialect_cls, "hello") - assert result == "'HELLO'" diff --git a/tests/test_data_type.py b/tests/test_data_type.py index 86e0114..65d86ad 100644 --- a/tests/test_data_type.py +++ b/tests/test_data_type.py @@ -12,7 +12,6 @@ IntegerSerializer, JSONSerializer, MacAddressSerializer, - Serializer, StringSerializer, TimeSerializer, UnspecifiedSerializer, @@ -21,29 +20,6 @@ from rapidquery.dialect import PostgreSQL -class TestDataTypeConstruction: - def test_constructs_with_name_only(self): - dt = DataType("integer") - assert dt.name == "INTEGER" or dt.name.lower() == "integer" - - def test_constructs_with_explicit_serializer(self): - dt = DataType("my_custom_type", StringSerializer()) - assert isinstance(dt.serializer, StringSerializer) - - def test_name_property_returns_str(self): - dt = DataType("varchar") - assert isinstance(dt.name, str) - - def test_serializer_property_returns_serializer_instance(self): - dt = DataType("integer") - assert isinstance(dt.serializer, Serializer) - - def test_explicit_serializer_overrides_inference(self): - dt = DataType("integer", StringSerializer()) - assert isinstance(dt.serializer, StringSerializer) - assert not isinstance(dt.serializer, IntegerSerializer) - - class TestDataTypeNameNormalization: @pytest.mark.parametrize( "input_name,canonical", @@ -83,21 +59,38 @@ def test_parameterized_name_is_not_alias_matched(self): class TestDataTypeSerializerInference: - """`DataType(name).serializer` with no explicit serializer should infer - the correct serializer type from the SQL type name's group. - """ - @pytest.mark.parametrize( "name", [ "tinyint", + "tinyint unsigned", + "utinyint", + "int2", + "int2 unsigned", "smallint", + "smallint unsigned", + "usmallint", "mediumint", + "mediumint unsigned", "int", - "integer", - "bigint", + "int4", "int8", + "int64", + "integer", + "unsigned integer", + "integer unsigned", + "int unsigned", + "int4 unsigned", "hugeint", + "uhugeint", + "bigint", + "bigint unsigned", + "unsigned bigint", + "unsigned bitint", + "ubigint", + "int8 unsigned", + "signed", + "signed integer", "unsigned", ], ) @@ -107,13 +100,21 @@ def test_integer_family_infers_integer_serializer(self, name): @pytest.mark.parametrize( "name", [ - "float", - "double", - "decimal", "numeric", + "decimal", + "bignumeric", + "bigdecimal", + "dec", + "float", + "float4", + "float8", + "float64", "real", + "real unsigned", + "double", + "double unsigned", "double precision", - "bignumeric", + "double precision unsigned", ], ) def test_float_family_infers_float_serializer(self, name): @@ -121,7 +122,24 @@ def test_float_family_infers_float_serializer(self, name): @pytest.mark.parametrize( "name", - ["char", "varchar", "text", "clob", "nvarchar", "string", "longtext"], + [ + "character", + "char", + "character varying", + "characater varying", + "char varying", + "varchar", + "nvarchar", + "character large object", + "char large object", + "clob", + "regclass", + "text", + "tinytext", + "mediumtext", + "longtext", + "string", + ], ) def test_string_family_infers_string_serializer(self, name): assert isinstance(DataType(name).serializer, StringSerializer) @@ -129,7 +147,19 @@ def test_string_family_infers_string_serializer(self, name): def test_uuid_infers_uuid_serializer(self): assert isinstance(DataType("uuid").serializer, UuidSerializer) - @pytest.mark.parametrize("name", ["binary", "varbinary", "blob", "bytes", "bytea"]) + @pytest.mark.parametrize( + "name", + [ + "binary", + "varbinary", + "blob", + "tinyblob", + "mediumblob", + "longblob", + "bytes", + "bytea", + ], + ) def test_bytes_family_infers_bytes_serializer(self, name): assert isinstance(DataType(name).serializer, BytesSerializer) @@ -137,16 +167,25 @@ def test_bytes_family_infers_bytes_serializer(self, name): def test_bit_family_infers_bit_serializer(self, name): assert isinstance(DataType(name).serializer, BitSerializer) - @pytest.mark.parametrize("name", ["date"]) - def test_date_infers_date_serializer(self, name): - assert isinstance(DataType(name).serializer, DateSerializer) + def test_date_infers_date_serializer(self): + assert isinstance(DataType("date").serializer, DateSerializer) - @pytest.mark.parametrize("name", ["time", "time with timezone"]) + @pytest.mark.parametrize( + "name", ["time", "time without timezone", "time with timezone"] + ) def test_time_family_infers_time_serializer(self, name): assert isinstance(DataType(name).serializer, TimeSerializer) @pytest.mark.parametrize( - "name", ["datetime", "timestamp", "timestamp with timezone", "timestampntz"] + "name", + [ + "datetime", + "timestamp", + "timestamp without timezone", + "timestamp with timezone", + "timestamptz", + "timestampntz", + ], ) def test_datetime_family_infers_datetime_serializer(self, name): assert isinstance(DataType(name).serializer, DatetimeSerializer) @@ -170,16 +209,11 @@ def test_unrecognized_name_infers_unspecified_serializer(self): assert isinstance(DataType("not_a_real_type").serializer, UnspecifiedSerializer) def test_parameterized_name_infers_unspecified_serializer(self): - """Parameterized names skip the alias table entirely -> CUSTOM group - -> UnspecifiedSerializer, even if the base name (e.g. 'varchar') - would otherwise be recognized.""" assert isinstance(DataType("varchar(255)").serializer, UnspecifiedSerializer) - def test_boolean_infers_boolean_serializer(self): - assert isinstance(DataType("boolean").serializer, BooleanSerializer) - - def test_bool_alias_infers_boolean_serializer(self): - assert isinstance(DataType("bool").serializer, BooleanSerializer) + @pytest.mark.parametrize("name", ["boolean", "bool"]) + def test_boolean_family_infers_boolean_serializer(self, name): + assert isinstance(DataType(name).serializer, BooleanSerializer) def test_boolean_serializer_correctly_rejects_non_bool_int(self): dt = DataType("boolean") diff --git a/tests/test_dialect.py b/tests/test_dialect.py deleted file mode 100644 index a91cf73..0000000 --- a/tests/test_dialect.py +++ /dev/null @@ -1,105 +0,0 @@ -import pytest - -from rapidquery.dialect import SqlDialect - -from .conftest import ALL_DIALECT_NAMES, ALL_DIALECTS - - -class TestDialectIdentity: - def test_name_matches_class_name(self, dialect_cls: type[SqlDialect]): - instance = dialect_cls() - assert instance.name == dialect_cls.__name__ - - def test_name_is_a_string(self, dialect_cls: type[SqlDialect]): - assert isinstance(dialect_cls().name, str) - - def test_name_is_stable_across_instances(self, dialect_cls: type[SqlDialect]): - assert dialect_cls().name == dialect_cls().name - - def test_all_expected_dialects_are_exposed(self): - import rapidquery.dialect as dialect_module - - for expected_name in ALL_DIALECT_NAMES: - assert hasattr(dialect_module, expected_name), ( - f"expected dialect module to expose {expected_name!r}" - ) - - def test_different_dialects_have_different_names(self): - names = {cls().name for cls in ALL_DIALECTS} - assert len(names) == len(ALL_DIALECTS) - - -class TestDialectSubclassing: - def test_is_subclass_of_sql_dialect(self, dialect_cls: type[SqlDialect]): - assert issubclass(dialect_cls, SqlDialect) - - def test_instance_is_instance_of_sql_dialect(self, dialect_instance: SqlDialect): - assert isinstance(dialect_instance, SqlDialect) - - def test_dialect_classes_are_all_distinct(self): - assert len(set(ALL_DIALECTS)) == len(ALL_DIALECTS) - - -class TestDialectFinality: - def test_cannot_be_subclassed_further(self, dialect_cls: type[SqlDialect]): - with pytest.raises(TypeError): - _ = type(f"Sub{dialect_cls.__name__}", (dialect_cls,), {}) - - def test_base_sql_dialect_can_be_subclassed(self): - class _CustomDialect(SqlDialect): - pass - - assert issubclass(_CustomDialect, SqlDialect) - - -class TestDialectConstruction: - def test_constructs_with_no_arguments(self, dialect_cls: type[SqlDialect]): - _ = dialect_cls() - - def test_rejects_unexpected_arguments(self, dialect_cls: type[SqlDialect]): - with pytest.raises(TypeError): - _ = dialect_cls("unexpected") - - def test_name_is_read_only(self, dialect_instance: SqlDialect): - with pytest.raises(AttributeError): - dialect_instance.name = "Something" - - def test_base_cannot_be_instantiated(self): - with pytest.raises(TypeError): - _ = SqlDialect() - - -class TestDialectMarkers: - def test_instances_of_same_dialect_are_equal(self, dialect_cls: type[SqlDialect]): - assert dialect_cls() == dialect_cls() - - def test_instances_of_different_dialects_are_not_equal(self): - from rapidquery.dialect import MySql, PostgreSQL - - assert PostgreSQL() != MySql() - - def test_instance_is_not_equal_to_its_class(self, dialect_cls: type[SqlDialect]): - assert dialect_cls() != dialect_cls - assert dialect_cls != dialect_cls() - - def test_not_equal_to_unrelated_values(self, dialect_instance: SqlDialect): - assert dialect_instance != object() - assert dialect_instance != dialect_instance.name - assert dialect_instance != 1 - - def test_equal_instances_share_a_hash(self, dialect_cls: type[SqlDialect]): - assert hash(dialect_cls()) == hash(dialect_cls()) - - def test_same_dialect_collapses_in_a_set(self, dialect_cls: type[SqlDialect]): - assert len({dialect_cls(), dialect_cls()}) == 1 - - def test_different_dialects_are_distinct_set_keys(self): - from rapidquery.dialect import MySql, PostgreSQL - - assert len({PostgreSQL(), MySql()}) == 2 - - def test_repr_is_call_form(self, dialect_cls: type[SqlDialect]): - assert repr(dialect_cls()) == f"{dialect_cls.__name__}()" - - def test_repr_matches_name(self, dialect_instance: SqlDialect): - assert repr(dialect_instance) == f"{dialect_instance.name}()" diff --git a/tests/test_expr.py b/tests/test_expr.py deleted file mode 100644 index a71d6a9..0000000 --- a/tests/test_expr.py +++ /dev/null @@ -1,159 +0,0 @@ -import pytest - -from rapidquery import Asterisk, Expr, Function, Ident, Unquote, Value -from rapidquery.expr import ( - BetweenExpr, - BinaryExpr, - CaseExpr, - CastExpr, - CustomExpr, - InListExpr, - IsDistinctFromExpr, - IsExpr, - LikeExpr, - TupleExpr, - UnaryExpr, -) - - -class TestExprHierarchy: - def test_ident_and_value_are_expr(self): - assert isinstance(Ident("id"), Expr) - assert isinstance(Value(1), Expr) - assert isinstance(Function("SUM", "amount"), Expr) - - def test_ident_value_live_in_expr_module(self): - assert Ident.__module__ == "rapidquery._core.expr" - assert Value.__module__ == "rapidquery._core.expr" - assert Unquote.__module__ == "rapidquery._core.expr" - assert Asterisk.__module__ == "rapidquery._core.expr" - assert Function.__module__ == "rapidquery._core.expr" - - def test_base_expr_cannot_be_instantiated(self): - with pytest.raises(TypeError): - Expr() - - def test_ident_repr_unchanged(self): - assert repr(Ident("schema", "table")) == 'Ident("schema", "table")' - - def test_value_repr_unchanged(self): - assert repr(Value(42)).startswith("Value(42,") - - -class TestBinaryUnary: - def test_str_is_ident(self): - expr = BinaryExpr("a", "+", "b") - assert isinstance(expr.get_left(), Ident) - assert expr.get_left().to_list()[0] == "a" - assert expr.get_operator() == "+" - assert isinstance(expr.get_right(), Ident) - assert repr(expr) == 'BinaryExpr(Ident("a"), "+", Ident("b"))' - - def test_value_for_string_literal(self): - expr = BinaryExpr("id", "=", Value("x")) - assert isinstance(expr.get_left(), Ident) - assert isinstance(expr.get_right(), Value) - assert expr.get_right().get_object() == "x" - - def test_custom_operator(self): - expr = BinaryExpr("data", "->", Unquote("key")) - assert expr.get_operator() == "->" - - def test_unary_not(self): - expr = UnaryExpr("NOT", "x") - assert expr.get_operator() == "NOT" - assert isinstance(expr.get_expr(), Ident) - - def test_unknown_unary_raises(self): - with pytest.raises(ValueError, match="unknown unary"): - UnaryExpr("@", "x") - - -class TestFunction: - def test_args_are_idents(self): - fn = Function("SUM", "amount") - assert isinstance(fn.get_name(), Ident) - assert fn.get_args() is not None - assert isinstance(fn.get_args()[0], Ident) - assert fn.get_filter() is None - - def test_bare_function(self): - fn = Function("CURRENT_TIMESTAMP") - assert fn.get_args() is None - - def test_distinct(self): - fn = Function("COUNT", "id", distinct=True) - assert "distinct=True" in repr(fn) - - -class TestNodes: - def test_tuple_expr(self): - expr = TupleExpr("a", "b") - items = expr.get_items() - assert len(items) == 2 - assert isinstance(items[0], Ident) - - def test_is_null(self): - expr = IsExpr("x", "NULL", negated=True) - assert expr.get_kind() == "NULL" - assert expr.get_negated() is True - - def test_in_list(self): - expr = InListExpr("x", ["a", "b"]) - assert len(expr.get_list()) == 2 - assert isinstance(expr.get_list()[0], Ident) - - def test_between(self): - expr = BetweenExpr("x", "a", "b") - assert isinstance(expr.get_low(), Ident) - assert isinstance(expr.get_high(), Ident) - - def test_like_pattern_is_literal(self): - expr = LikeExpr("name", "%a%") - assert isinstance(expr.get_expr(), Ident) - assert isinstance(expr.get_pattern(), Value) - assert expr.get_pattern().get_object() == "%a%" - assert expr.get_kind() == "LIKE" - - def test_cast(self): - expr = CastExpr("x", "int") - assert isinstance(expr.get_expr(), Ident) - assert expr.get_data_type() == "INT" - - def test_custom(self): - expr = CustomExpr("json_agg(DISTINCT {})", "col") - assert expr.get_sql() == "json_agg(DISTINCT {})" - assert isinstance(expr.get_args()[0], Ident) - - def test_is_distinct_from(self): - expr = IsDistinctFromExpr("a", "b", negated=True) - assert expr.get_negated() is True - - -class TestConditionExtractor: - def test_case_when_accepts_binary_condition(self): - cond = BinaryExpr("x", ">", "y") - expr = CaseExpr().when(cond, "yes").else_("no") - when_list = expr.get_when_list() - assert len(when_list) == 1 - assert isinstance(when_list[0][0], BinaryExpr) - assert isinstance(when_list[0][1], Ident) - assert isinstance(expr.get_else(), Ident) - - def test_case_when_accepts_is_expr(self): - expr = CaseExpr().when(IsExpr("x", "NULL"), "missing") - assert isinstance(expr.get_when_list()[0][0], IsExpr) - - def test_case_when_rejects_ident(self): - with pytest.raises(TypeError, match="condition"): - CaseExpr().when(Ident("x"), "yes") - - def test_case_when_rejects_value(self): - with pytest.raises(TypeError, match="condition"): - CaseExpr().when(Value(1), "yes") - - def test_case_when_rejects_bare_str_and_int(self): - with pytest.raises(TypeError, match="condition"): - CaseExpr().when("x", "yes") - with pytest.raises(TypeError, match="condition"): - CaseExpr().when(1, "yes") diff --git a/tests/test_ident.py b/tests/test_ident.py deleted file mode 100644 index 0fdae23..0000000 --- a/tests/test_ident.py +++ /dev/null @@ -1,198 +0,0 @@ -import pytest - -from rapidquery import Asterisk, Ident, Unquote -from rapidquery.dialect import MSSQL, MySql, PostgreSQL, SqlDialect, Teradata - -from .conftest import ALL_DIALECTS - - -class TestIdentConstruction: - def test_varargs_parts(self): - ident = Ident("table", "id") - assert len(ident) == 2 - assert ident[0] == "table" - assert ident[1] == "id" - - def test_single_str_is_one_quoted_part_even_with_dots(self): - ident = Ident("schema.table") - assert len(ident) == 1 - assert ident[0] == "schema.table" - assert ident.to_sql(PostgreSQL) == '"schema.table"' - - def test_rejects_no_arguments(self): - with pytest.raises(ValueError, match="empty"): - _ = Ident() - - def test_rejects_empty_string_part(self): - with pytest.raises(ValueError, match="empty"): - _ = Ident("") - - def test_rejects_non_ident_argument(self): - with pytest.raises(TypeError): - _ = Ident(1) - - def test_flattens_nested_ident(self): - ident = Ident(Ident("t"), "id") - assert list(ident) == ["t", "id"] - assert ident.to_sql(PostgreSQL) == '"t"."id"' - - def test_too_many_parts(self): - with pytest.raises(ValueError, match="too many"): - _ = Ident(*[f"p{i}" for i in range(11)]) - - def test_ten_parts_are_allowed(self): - ident = Ident(*[f"p{i}" for i in range(10)]) - assert len(ident) == 10 - - -class TestQuotedStarVersusAsterisk: - def test_str_star_is_quoted_column(self): - ident = Ident("*") - assert len(ident) == 1 - assert ident[0] == "*" - assert ident.to_sql(PostgreSQL) == '"*"' - assert ident.to_sql(MySql) == "`*`" - - def test_asterisk_class_is_wildcard(self): - ident = Ident(Asterisk) - assert ident[0] == Asterisk() - assert ident.to_sql(PostgreSQL) == "*" - - def test_asterisk_instance_is_wildcard(self): - ident = Ident(Asterisk()) - assert ident.to_sql(MySql) == "*" - - def test_qualified_wildcard(self): - ident = Ident("t", Asterisk) - assert ident.to_sql(PostgreSQL) == '"t".*' - assert ident.to_sql(MySql) == "`t`.*" - - def test_asterisk_not_last_is_rejected(self): - with pytest.raises(ValueError, match="asterisk"): - _ = Ident(Asterisk, "id") - - -class TestUnquote: - def test_simple_name(self): - part = Unquote("Users") - assert part.name == "Users" - assert Ident(part).to_sql(PostgreSQL) == "Users" - - def test_mixed_with_quoted_parts(self): - ident = Ident("public", Unquote("Users"), "id") - assert ident.to_sql(PostgreSQL) == '"public".Users."id"' - - def test_equals_by_name(self): - assert Unquote("id") == Unquote("id") - assert Unquote("id") != Unquote("ID") - assert Unquote("id") != "id" - - def test_rejects_injection_and_non_ascii(self): - for bad in [ - "", - "*", - "a;b", - "a.b", - "1id", - "a-b", - "a b", - "cafĆ©", - "a; DROP TABLE t--", - ]: - with pytest.raises(ValueError): - _ = Unquote(bad) - - def test_accepts_underscore_and_digits(self): - assert Unquote("_id").name == "_id" - assert Unquote("created_at").name == "created_at" - assert Unquote("a1").name == "a1" - - -class TestIndexingAndIteration: - def test_len_getitem_iter_to_list(self): - ident = Ident("a", "b", "c") - assert len(ident) == 3 - assert ident[0] == "a" - assert ident[-1] == "c" - assert list(ident) == ["a", "b", "c"] - assert ident.to_list() == ["a", "b", "c"] - - def test_index_error(self): - ident = Ident("a") - with pytest.raises(IndexError): - _ = ident[1] - with pytest.raises(IndexError): - _ = ident[-2] - - def test_slice_returns_ident(self): - ident = Ident("a", "b", "c") - sliced = ident[1:] - assert isinstance(sliced, Ident) - assert list(sliced) == ["b", "c"] - assert sliced.to_sql(PostgreSQL) == '"b"."c"' - - def test_empty_slice_is_value_error(self): - ident = Ident("a", "b") - with pytest.raises(ValueError, match="empty"): - _ = ident[1:1] - - def test_unquote_and_asterisk_parts_round_trip(self): - ident = Ident("t", Unquote("Users"), Asterisk) - assert ident[0] == "t" - assert ident[1] == Unquote("Users") - assert ident[2] == Asterisk() - assert list(ident)[1] == Unquote("Users") - - -class TestEqualityHashReprFrozen: - def test_equal_when_parts_match(self): - assert Ident("t", "id") == Ident("t", "id") - assert Ident("t", "id") != Ident("t", "name") - - def test_quoted_is_not_unquoted(self): - assert Ident("id") != Ident(Unquote("id")) - assert hash(Ident("id")) != hash(Ident(Unquote("id"))) - - def test_asterisk_instances_are_equal(self): - assert Asterisk() == Asterisk() - assert Ident(Asterisk) == Ident(Asterisk()) - - def test_usable_as_dict_key(self): - mapping = {Ident("t", "id"): 1, Unquote("id"): 2, Asterisk(): 3} - assert mapping[Ident("t", "id")] == 1 - assert mapping[Unquote("id")] == 2 - assert mapping[Asterisk()] == 3 - - def test_repr(self): - assert repr(Ident("schema", "table")) == 'Ident("schema", "table")' - assert repr(Ident("t", Asterisk)) == 'Ident("t", Asterisk)' - assert repr(Ident(Unquote("Users"))) == 'Ident(Unquote("Users"))' - assert repr(Unquote("Users")) == 'Unquote("Users")' - assert repr(Asterisk()) == "Asterisk()" - - -class TestRender: - def test_class_and_instance_dialect_agree(self): - ident = Ident("id") - assert ident.to_sql(PostgreSQL) == ident.to_sql(PostgreSQL()) - assert ident.to_sql(MySql) == ident.to_sql(MySql()) - - @pytest.mark.parametrize("dialect_cls", ALL_DIALECTS) - def test_render_succeeds_on_every_dialect(self, dialect_cls: type[SqlDialect]): - ident = Ident("users", "id") - sql = ident.to_sql(dialect_cls) - assert "users" in sql - assert "id" in sql - assert "." in sql - - def test_delimiter_escaping(self): - assert Ident('a"b').to_sql(PostgreSQL) == '"a""b"' - assert Ident("a`b").to_sql(MySql) == "`a``b`" - assert Ident("a]b").to_sql(MSSQL) == "[a]]b]" - assert Ident("a'b").to_sql(Teradata) == "'a''b'" - - def test_quoted_injection_stays_inside_delimiters(self): - payload = "a; DROP TABLE t--" - sql = Ident(payload).to_sql(PostgreSQL) - assert sql == f'"{payload}"' - assert sql.startswith('"') and sql.endswith('"') diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 9f0f71b..884ed38 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -1,9 +1,13 @@ import datetime +import enum import ipaddress +import typing import uuid +from dataclasses import dataclass from decimal import Decimal import pytest +from rapidquery._core import _feature_only_stdlib_json from rapidquery.data_type import ( ArraySerializer, @@ -24,616 +28,949 @@ UnspecifiedSerializer, UuidSerializer, ) -from rapidquery.dialect import PostgreSQL, SqlDialect - -# --------------------------------------------------------------------------- -# IntegerSerializer -# --------------------------------------------------------------------------- +from rapidquery.dialect import ( + MSSQL, + BigQuery, + Databricks, + DuckDB, + Hive, + MySql, + Oracle, + PostgreSQL, + Redshift, + Snowflake, + Spark, + SqlDialect, + SQLite, + Teradata, +) +from .conftest import ALL_DIALECTS + +# Quoted-literal wrap groups (payloads with no quotes/backslashes). +_PG = (PostgreSQL,) +_MSSQL = (MSSQL,) +_PLAIN_QUOTE = ( + BigQuery, + Databricks, + DuckDB, + Hive, + MySql, + Oracle, + Redshift, + Snowflake, + Spark, + SQLite, + Teradata, +) -class TestIntegerSerializer: - def test_is_serializer_subclass(self): - assert issubclass(IntegerSerializer, Serializer) +# String-escape families — same partition as src/dialect/tests.rs. +_BACKSLASH_ESCAPING = ( + BigQuery, + Databricks, + Hive, + MySql, + PostgreSQL, + Snowflake, + Spark, +) +_QUOTE_ONLY = ( + DuckDB, + MSSQL, + Oracle, + Redshift, + SQLite, + Teradata, +) +_NUL_STRIPPING = (MSSQL, Oracle, Redshift, SQLite, Teradata) +_NUL_X00 = (BigQuery, PostgreSQL) +_NUL_BACKSLASH_ZERO = (Databricks, Hive, MySql, Snowflake, Spark) +_DOUBLE_QUOTE_ESCAPING = (Databricks, MySql, Spark) +_HEX_CTRL = (BigQuery, PostgreSQL, Snowflake) +_UNICODE_CTRL = (Databricks, Hive, Spark) + +_BIT_OK = (DuckDB, MySql, PostgreSQL, Teradata) +_BIT_NO = tuple(d for d in ALL_DIALECTS if d not in _BIT_OK) +_ARRAY_OK = (BigQuery, DuckDB, PostgreSQL) +_ARRAY_NO = tuple(d for d in ALL_DIALECTS if d not in _ARRAY_OK) +_INET_NO = tuple(d for d in ALL_DIALECTS if d not in (DuckDB, PostgreSQL)) +_MAC_NO = tuple(d for d in ALL_DIALECTS if d is not PostgreSQL) + +try: + import orjson # ruff: ignore[F401] + + USING_ORJSON = not _feature_only_stdlib_json +except ImportError: + USING_ORJSON = False + + +class ValidStrEnum(enum.StrEnum): + HelloWorld = "Hello World" + Sticker = "šŸ˜€" + WithQuote = "'OK'" + Hex = "\x0d" + + +class InvalidStrEnum(enum.Enum): + Variant = 1 + + +@dataclass(frozen=True, slots=True) +class ExampleDataclass: + x: int + + +def _custom_ok(dialect_name: str, obj: typing.Any) -> str: + return f"CUSTOM:{dialect_name}:{obj}" + + +def _custom_not_str(dialect_name: str, obj: typing.Any) -> int: + return 12345 + + +def _quoted(dialect: type[SqlDialect] | SqlDialect, body: str) -> str: + name = dialect if isinstance(dialect, type) else type(dialect) + if name is PostgreSQL: + return f"E'{body}'" + if name is MSSQL: + return f"N'{body}'" + return f"'{body}'" + + +def _bytes_literal(dialect: type[SqlDialect] | SqlDialect, data: bytes) -> str: + hx = data.hex().upper() + name = dialect if isinstance(dialect, type) else type(dialect) + if name is BigQuery: + return "B'" + "".join(f"\\x{b:02X}" for b in data) + "'" + if name is Hive: + return f"UNHEX('{hx}')" + if name is MSSQL: + return f"0x{hx}" + if name is Oracle: + return f"HEXTORAW('{hx}')" + if name is PostgreSQL: + return f"'\\x{hx}'::bytea" + if name is Redshift: + return f"TO_VARBYTE('{hx}', 'hex')" + if name is Teradata: + return f"'{hx}'XBV" + return f"X'{hx}'" + + +@dataclass(frozen=True, slots=True) +class SerializeTestCase: + serializer: Serializer + dialects: typing.Iterable[type[SqlDialect] | SqlDialect] + input: typing.Any + output: str | type[Exception] + + +SERIALIZE_TEST_CASES: list[SerializeTestCase] = [ + # --- Integer --- + SerializeTestCase(IntegerSerializer(), ALL_DIALECTS, 1, "1"), + SerializeTestCase( + IntegerSerializer(), + ALL_DIALECTS, + 18_446_744_073_709_551_615, + "18446744073709551615", + ), + SerializeTestCase( + IntegerSerializer(), + ALL_DIALECTS, + -9_223_372_036_854_775_808, + "-9223372036854775808", + ), + SerializeTestCase(IntegerSerializer(), ALL_DIALECTS, 2.5, TypeError), + SerializeTestCase(IntegerSerializer(), ALL_DIALECTS, True, TypeError), + SerializeTestCase(IntegerSerializer(), ALL_DIALECTS, "invalid", TypeError), + # --- Float --- + SerializeTestCase(FloatSerializer(), ALL_DIALECTS, 1.1, "1.1"), + SerializeTestCase(FloatSerializer(), ALL_DIALECTS, 1, "1"), + SerializeTestCase(FloatSerializer(), ALL_DIALECTS, 1.0, "1.0"), + SerializeTestCase(FloatSerializer(), ALL_DIALECTS, Decimal("1.3435"), "1.3435"), + SerializeTestCase(FloatSerializer(), ALL_DIALECTS, "invalid", TypeError), + # --- Boolean --- + SerializeTestCase( + BooleanSerializer(), + ( + BigQuery, + Databricks, + DuckDB, + Hive, + PostgreSQL, + Redshift, + Snowflake, + Spark, + Teradata, + ), + True, + "TRUE", + ), + SerializeTestCase( + BooleanSerializer(), + (MSSQL, MySql, Oracle, SQLite), + True, + "1", + ), + SerializeTestCase( + BooleanSerializer(), + ( + BigQuery, + Databricks, + DuckDB, + Hive, + PostgreSQL, + Redshift, + Snowflake, + Spark, + Teradata, + ), + False, + "FALSE", + ), + SerializeTestCase( + BooleanSerializer(), + (MSSQL, MySql, Oracle, SQLite), + False, + "0", + ), + SerializeTestCase(BooleanSerializer(), ALL_DIALECTS, 1, TypeError), + SerializeTestCase(BooleanSerializer(), ALL_DIALECTS, object(), TypeError), + SerializeTestCase(BooleanSerializer(), ALL_DIALECTS, 0, TypeError), + # --- String | PostgreSQL --- + SerializeTestCase( + StringSerializer(), [PostgreSQL], "Hello World", "E'Hello World'" + ), + SerializeTestCase(StringSerializer(), [PostgreSQL], "šŸ˜€", "E'šŸ˜€'"), + SerializeTestCase(StringSerializer(), [PostgreSQL], "'OK'", "E'\\'OK\\''"), + SerializeTestCase(StringSerializer(), [PostgreSQL], "\x0d", "E'\\r'"), + # --- String | MSSQL --- + SerializeTestCase(StringSerializer(), [MSSQL], "Hello World", "N'Hello World'"), + SerializeTestCase(StringSerializer(), [MSSQL], "šŸ˜€", "N'šŸ˜€'"), + SerializeTestCase(StringSerializer(), [MSSQL], "'OK'", "N'''OK'''"), + SerializeTestCase(StringSerializer(), [MSSQL], "\x0d", "N'\r'"), + # --- String | DuckDB - Oracle - Redshift - SQLite - Teradata --- + SerializeTestCase( + StringSerializer(), + [DuckDB, Oracle, Redshift, SQLite, Teradata], + "Hello World", + "'Hello World'", + ), + SerializeTestCase( + StringSerializer(), [DuckDB, Oracle, Redshift, SQLite, Teradata], "šŸ˜€", "'šŸ˜€'" + ), + SerializeTestCase( + StringSerializer(), + [DuckDB, Oracle, Redshift, SQLite, Teradata], + "'OK'", + "'''OK'''", + ), + SerializeTestCase( + StringSerializer(), [DuckDB, Oracle, Redshift, SQLite, Teradata], "\x0d", "'\r'" + ), + # --- String --- + SerializeTestCase( + StringSerializer(), + ( + BigQuery, + Databricks, + Hive, + MySql, + Snowflake, + Spark, + ), + "Hello World", + "'Hello World'", + ), + SerializeTestCase( + StringSerializer(), + ( + BigQuery, + Databricks, + Hive, + MySql, + Snowflake, + Spark, + ), + "šŸ˜€", + "'šŸ˜€'", + ), + SerializeTestCase( + StringSerializer(), + ( + BigQuery, + Databricks, + Hive, + MySql, + Snowflake, + Spark, + ), + "'OK'", + "'\\'OK\\''", + ), + SerializeTestCase( + StringSerializer(), + ( + BigQuery, + Databricks, + Hive, + MySql, + Snowflake, + Spark, + ), + "\x0d", + "'\\r'", + ), + SerializeTestCase( + StringSerializer(), + ( + BigQuery, + Databricks, + Hive, + MySql, + Snowflake, + Spark, + ), + ValidStrEnum.HelloWorld, + "'Hello World'", + ), + SerializeTestCase( + StringSerializer(), + ( + BigQuery, + Databricks, + Hive, + MySql, + Snowflake, + Spark, + ), + ValidStrEnum.Sticker, + "'šŸ˜€'", + ), + SerializeTestCase( + StringSerializer(), + ( + BigQuery, + Databricks, + Hive, + MySql, + Snowflake, + Spark, + ), + ValidStrEnum.WithQuote, + "'\\'OK\\''", + ), + SerializeTestCase( + StringSerializer(), + ( + BigQuery, + Databricks, + Hive, + MySql, + Snowflake, + Spark, + ), + ValidStrEnum.Hex, + "'\\r'", + ), + SerializeTestCase( + StringSerializer(), + ALL_DIALECTS, + InvalidStrEnum.Variant, + TypeError, + ), + SerializeTestCase( + StringSerializer(), + ALL_DIALECTS, + 1, + TypeError, + ), + # --- Uuid | PostgreSQL --- + SerializeTestCase( + UuidSerializer(), + [PostgreSQL], + "103596b1-db75-4ae0-907a-a4a9bc4dab85", + "E'103596b1-db75-4ae0-907a-a4a9bc4dab85'", + ), + SerializeTestCase( + UuidSerializer(), + [PostgreSQL], + uuid.UUID("103596b1-db75-4ae0-907a-a4a9bc4dab85"), + "E'103596b1-db75-4ae0-907a-a4a9bc4dab85'", + ), + SerializeTestCase( + UuidSerializer(), + [PostgreSQL], + "103596-db75-4ae0-907a-a4a9bc4d85", + ValueError, + ), + SerializeTestCase( + UuidSerializer(), + [PostgreSQL], + 6428739, + TypeError, + ), + # --- Uuid | MSSQL --- + SerializeTestCase( + UuidSerializer(), + [MSSQL], + "103596b1-db75-4ae0-907a-a4a9bc4dab85", + "N'103596b1-db75-4ae0-907a-a4a9bc4dab85'", + ), + SerializeTestCase( + UuidSerializer(), + [MSSQL], + uuid.UUID("103596b1-db75-4ae0-907a-a4a9bc4dab85"), + "N'103596b1-db75-4ae0-907a-a4a9bc4dab85'", + ), + # --- Uuid --- + SerializeTestCase( + UuidSerializer(), + [i for i in ALL_DIALECTS if i not in (MSSQL, PostgreSQL)], + "103596b1-db75-4ae0-907a-a4a9bc4dab85", + "'103596b1-db75-4ae0-907a-a4a9bc4dab85'", + ), + SerializeTestCase( + UuidSerializer(), + [i for i in ALL_DIALECTS if i not in (MSSQL, PostgreSQL)], + uuid.UUID("103596b1-db75-4ae0-907a-a4a9bc4dab85"), + "'103596b1-db75-4ae0-907a-a4a9bc4dab85'", + ), + # --- Bytes | BigQuery --- + SerializeTestCase( + BytesSerializer(), [BigQuery], b"simple", "B'\\x73\\x69\\x6D\\x70\\x6C\\x65'" + ), + # --- Bytes | X'...' format --- + SerializeTestCase( + BytesSerializer(), + [Databricks, DuckDB, MySql, Snowflake, Spark, SQLite], + b"simple", + "X'73696D706C65'", + ), + # --- Bytes | Hive --- + SerializeTestCase(BytesSerializer(), [Hive], b"simple", "UNHEX('73696D706C65')"), + # --- Bytes | MSSQL --- + SerializeTestCase(BytesSerializer(), [MSSQL], b"simple", "0x73696D706C65"), + # --- Bytes | Oracle --- + SerializeTestCase( + BytesSerializer(), [Oracle], b"simple", "HEXTORAW('73696D706C65')" + ), + # --- Bytes | PostgreSQL --- + SerializeTestCase( + BytesSerializer(), [PostgreSQL], b"simple", "'\\x73696D706C65'::bytea" + ), + # --- Bytes | Redshift --- + SerializeTestCase( + BytesSerializer(), [Redshift], b"simple", "TO_VARBYTE('73696D706C65', 'hex')" + ), + # --- Bytes | Teradata --- + SerializeTestCase(BytesSerializer(), [Teradata], b"simple", "'73696D706C65'XBV"), + # --- Bit | MySql --- + SerializeTestCase(BitSerializer(), [MySql], "10", "b'10'"), + # --- Bit | Teradata --- + SerializeTestCase(BitSerializer(), [Teradata], "10", "'10'B"), + # --- Bit --- + SerializeTestCase(BitSerializer(), [DuckDB, PostgreSQL], "10", "B'10'"), + SerializeTestCase(BitSerializer(), _BIT_NO, "10", NotImplementedError), + SerializeTestCase(BitSerializer(), _BIT_NO, 123, NotImplementedError), + # --- JSON | PostgreSQL --- + SerializeTestCase(JSONSerializer(), [PostgreSQL], 10, "E'10'"), + SerializeTestCase(JSONSerializer(), [PostgreSQL], "10", """E'"10"'"""), + SerializeTestCase( + JSONSerializer(), [PostgreSQL], [1, "Ali", 1.0, {}], """E'[1,"Ali",1.0,{}]'""" + ), + SerializeTestCase( + JSONSerializer(), + [PostgreSQL], + datetime.datetime(2024, 10, 1, tzinfo=datetime.timezone.utc), + """E'"2024-10-01T00:00:00+00:00"'""" if USING_ORJSON else TypeError, + ), + SerializeTestCase( + JSONSerializer(), + [PostgreSQL], + uuid.UUID("103596b1-db75-4ae0-907a-a4a9bc4dab85"), + """E'"103596b1-db75-4ae0-907a-a4a9bc4dab85"'""" if USING_ORJSON else TypeError, + ), + SerializeTestCase( + JSONSerializer(), + [PostgreSQL], + ExampleDataclass(x=1), + """E'{"x":1}'""" if USING_ORJSON else TypeError, + ), + SerializeTestCase( + JSONSerializer(), + [PostgreSQL], + ValidStrEnum.HelloWorld, + """E'"Hello World"'""" if USING_ORJSON else TypeError, + ), + # --- Date --- + SerializeTestCase( + DateSerializer(), _PG, datetime.date(2024, 1, 15), "E'2024-01-15'" + ), + SerializeTestCase( + DateSerializer(), _MSSQL, datetime.date(2024, 1, 15), "N'2024-01-15'" + ), + SerializeTestCase( + DateSerializer(), _PLAIN_QUOTE, datetime.date(2024, 1, 15), "'2024-01-15'" + ), + SerializeTestCase( + DateSerializer(), + ALL_DIALECTS, + datetime.datetime(2024, 1, 15, 10, 0), # ruff: ignore[DTZ001] + TypeError, + ), + SerializeTestCase(DateSerializer(), ALL_DIALECTS, "2024-01-15", TypeError), + # --- Time --- + SerializeTestCase(TimeSerializer(), _PG, datetime.time(13, 45, 30), "E'13:45:30'"), + SerializeTestCase( + TimeSerializer(), _MSSQL, datetime.time(13, 45, 30), "N'13:45:30'" + ), + SerializeTestCase( + TimeSerializer(), _PLAIN_QUOTE, datetime.time(13, 45, 30), "'13:45:30'" + ), + SerializeTestCase( + TimeSerializer(), + _PG, + datetime.time(1, 2, 3, 456789), + "E'01:02:03.456789'", + ), + SerializeTestCase( + TimeSerializer(), + _MSSQL, + datetime.time(1, 2, 3, 456789), + "N'01:02:03.456789'", + ), + SerializeTestCase( + TimeSerializer(), + _PLAIN_QUOTE, + datetime.time(1, 2, 3, 456789), + "'01:02:03.456789'", + ), + SerializeTestCase( + TimeSerializer(), ALL_DIALECTS, datetime.date(2024, 1, 1), TypeError + ), + # --- Datetime --- + SerializeTestCase( + DatetimeSerializer(), + _PG, + datetime.datetime(2024, 1, 15, 10, 30, 0), # ruff: ignore[DTZ001] + "E'2024-01-15 10:30:00'", + ), + SerializeTestCase( + DatetimeSerializer(), + _MSSQL, + datetime.datetime(2024, 1, 15, 10, 30, 0), # ruff: ignore[DTZ001] + "N'2024-01-15 10:30:00'", + ), + SerializeTestCase( + DatetimeSerializer(), + _PLAIN_QUOTE, + datetime.datetime(2024, 1, 15, 10, 30, 0), # ruff: ignore[DTZ001] + "'2024-01-15 10:30:00'", + ), + SerializeTestCase( + DatetimeSerializer(), + _PG, + datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc), + "E'2024-01-15 10:30:00+00:00'", + ), + SerializeTestCase( + DatetimeSerializer(), + _MSSQL, + datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc), + "N'2024-01-15 10:30:00+00:00'", + ), + SerializeTestCase( + DatetimeSerializer(), + _PLAIN_QUOTE, + datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc), + "'2024-01-15 10:30:00+00:00'", + ), + SerializeTestCase( + DatetimeSerializer(), + _PG, + datetime.datetime( + 2024, + 1, + 15, + 10, + 30, + 0, + tzinfo=datetime.timezone(datetime.timedelta(hours=-5)), + ), + "E'2024-01-15 10:30:00-05:00'", + ), + SerializeTestCase( + DatetimeSerializer(), + _MSSQL, + datetime.datetime( + 2024, + 1, + 15, + 10, + 30, + 0, + tzinfo=datetime.timezone(datetime.timedelta(hours=-5)), + ), + "N'2024-01-15 10:30:00-05:00'", + ), + SerializeTestCase( + DatetimeSerializer(), + _PLAIN_QUOTE, + datetime.datetime( + 2024, + 1, + 15, + 10, + 30, + 0, + tzinfo=datetime.timezone(datetime.timedelta(hours=-5)), + ), + "'2024-01-15 10:30:00-05:00'", + ), + SerializeTestCase(DatetimeSerializer(), _PG, 0, "E'1970-01-01 00:00:00+00:00'"), + SerializeTestCase(DatetimeSerializer(), _MSSQL, 0, "N'1970-01-01 00:00:00+00:00'"), + SerializeTestCase( + DatetimeSerializer(), _PLAIN_QUOTE, 0, "'1970-01-01 00:00:00+00:00'" + ), + SerializeTestCase( + DatetimeSerializer(), _PG, 0.5, "E'1970-01-01 00:00:00.500+00:00'" + ), + SerializeTestCase( + DatetimeSerializer(), _MSSQL, 0.5, "N'1970-01-01 00:00:00.500+00:00'" + ), + SerializeTestCase( + DatetimeSerializer(), _PLAIN_QUOTE, 0.5, "'1970-01-01 00:00:00.500+00:00'" + ), + SerializeTestCase( + DatetimeSerializer(), ALL_DIALECTS, datetime.date(2024, 1, 15), TypeError + ), + SerializeTestCase( + DatetimeSerializer(), ALL_DIALECTS, "2024-01-15T10:30:00", TypeError + ), + SerializeTestCase(DatetimeSerializer(), ALL_DIALECTS, 10**20, OverflowError), + # --- Inet --- + SerializeTestCase(InetSerializer(), _PG, "192.168.1.1", "E'192.168.1.1/32'"), + SerializeTestCase(InetSerializer(), [DuckDB], "192.168.1.1", "'192.168.1.1/32'"), + SerializeTestCase( + InetSerializer(), + _PG, + ipaddress.ip_address("192.168.1.1"), + "E'192.168.1.1/32'", + ), + SerializeTestCase( + InetSerializer(), + [DuckDB], + ipaddress.ip_address("192.168.1.1"), + "'192.168.1.1/32'", + ), + SerializeTestCase(InetSerializer(), _PG, "192.168.1.0/24", "E'192.168.1.0/24'"), + SerializeTestCase(InetSerializer(), [DuckDB], "192.168.1.0/24", "'192.168.1.0/24'"), + SerializeTestCase(InetSerializer(), _PG, "::1", "E'::1/128'"), + SerializeTestCase(InetSerializer(), [DuckDB], "::1", "'::1/128'"), + SerializeTestCase(InetSerializer(), _PG, "2001:db8::/32", "E'2001:db8::/32'"), + SerializeTestCase(InetSerializer(), [DuckDB], "2001:db8::/32", "'2001:db8::/32'"), + SerializeTestCase(InetSerializer(), [PostgreSQL, DuckDB], "not-an-ip", ValueError), + SerializeTestCase(InetSerializer(), _INET_NO, "192.168.1.1", NotImplementedError), + # --- MacAddress --- + SerializeTestCase( + MacAddressSerializer(), _PG, "01:23:45:67:89:ab", "E'01:23:45:67:89:AB'" + ), + SerializeTestCase( + MacAddressSerializer(), _PG, "01-23-45-67-89-AB", "E'01:23:45:67:89:AB'" + ), + SerializeTestCase(MacAddressSerializer(), [PostgreSQL], "not-a-mac", ValueError), + SerializeTestCase( + MacAddressSerializer(), _MAC_NO, "01:23:45:67:89:ab", NotImplementedError + ), + # --- Unspecified --- + SerializeTestCase(UnspecifiedSerializer(), ALL_DIALECTS, 123, "123"), + SerializeTestCase(UnspecifiedSerializer(), ALL_DIALECTS, "hello", "hello"), + SerializeTestCase(UnspecifiedSerializer(), ALL_DIALECTS, None, "None"), + # --- Array --- + SerializeTestCase( + ArraySerializer(IntegerSerializer()), _ARRAY_OK, [1, 2, 3], "ARRAY[1, 2, 3]" + ), + SerializeTestCase( + ArraySerializer(IntegerSerializer()), _ARRAY_OK, (1, 2, 3), "ARRAY[1, 2, 3]" + ), + SerializeTestCase(ArraySerializer(IntegerSerializer()), _ARRAY_OK, [], "ARRAY[]"), + SerializeTestCase( + ArraySerializer(ArraySerializer(IntegerSerializer())), + _ARRAY_OK, + [[1, 2], [3]], + "ARRAY[ARRAY[1, 2], ARRAY[3]]", + ), + SerializeTestCase( + ArraySerializer(IntegerSerializer()), _ARRAY_OK, "not a list", TypeError + ), + SerializeTestCase( + ArraySerializer(IntegerSerializer()), _ARRAY_OK, [1, "x"], TypeError + ), + SerializeTestCase( + ArraySerializer(IntegerSerializer()), + _ARRAY_NO, + [1, 2, 3], + NotImplementedError, + ), + # --- Custom --- + SerializeTestCase( + CustomSerializer(_custom_ok), [PostgreSQL], 1, "CUSTOM:PostgreSQL:1" + ), + SerializeTestCase( + CustomSerializer(_custom_not_str), # pyright: ignore + ALL_DIALECTS, + "x", + TypeError, + ), +] + + +@pytest.mark.parametrize("testcase", SERIALIZE_TEST_CASES) +def test_serialize(testcase: SerializeTestCase): + for dialect in testcase.dialects: + try: + output = testcase.serializer.serialize(dialect, testcase.input) + except Exception as error: + if isinstance(testcase.output, str): + raise - @pytest.mark.parametrize("value", [0, 1, -1, 42, -42, 2**63, -(2**63), 10**30]) - def test_serializes_ints_as_bare_decimal_literal( - self, value, dialect_cls: type[SqlDialect] - ): - result = IntegerSerializer().serialize(dialect_cls, value) - assert result == str(value) + assert type(error) is testcase.output - def test_accepts_dialect_instance_as_well_as_class(self): - cls_result = IntegerSerializer().serialize(PostgreSQL, 5) - instance_result = IntegerSerializer().serialize(PostgreSQL(), 5) - assert cls_result == instance_result == "5" + else: + assert output == testcase.output, ( + f"{testcase.serializer!r}({dialect!r}, {testcase.input!r}) == {output!r} != {testcase.output!r}" + ) - def test_arbitrary_precision_is_preserved(self, dialect_cls: type[SqlDialect]): - big = 10**40 - assert IntegerSerializer().serialize(dialect_cls, big) == str(big) - def test_rejects_bool_despite_bool_being_a_python_int_subclass( - self, dialect_cls: type[SqlDialect] +def test_serialize_cases_cover_all_serializers(): + assert {type(c.serializer) for c in SERIALIZE_TEST_CASES} == { + ArraySerializer, + BitSerializer, + BooleanSerializer, + BytesSerializer, + CustomSerializer, + DateSerializer, + DatetimeSerializer, + FloatSerializer, + InetSerializer, + IntegerSerializer, + JSONSerializer, + MacAddressSerializer, + StringSerializer, + TimeSerializer, + UnspecifiedSerializer, + UuidSerializer, + } + + +def test_unsupported_serializer_error_message(): + with pytest.raises( + NotImplementedError, match="SQLite does not support BitSerializer" ): - """bool is technically an int in Python, but must not be accepted here.""" - with pytest.raises(TypeError): - IntegerSerializer().serialize(dialect_cls, True) - - @pytest.mark.parametrize("value", [1.5, "5", None, [1], Decimal("5")]) - def test_rejects_non_int_types(self, value, dialect_cls: type[SqlDialect]): - with pytest.raises(TypeError): - IntegerSerializer().serialize(dialect_cls, value) - - -# --------------------------------------------------------------------------- -# FloatSerializer -# --------------------------------------------------------------------------- - - -class TestFloatSerializer: - def test_is_serializer_subclass(self): - assert issubclass(FloatSerializer, Serializer) - - def test_serializes_float_as_numeric_literal(self, dialect_cls: type[SqlDialect]): - result = FloatSerializer().serialize(dialect_cls, 3.14) - assert result == "3.14" - # Must round-trip back to the same float (no precision loss). - assert float(result) == 3.14 - - def test_serializes_int_via_its_exact_text_form( - self, dialect_cls: type[SqlDialect] + BitSerializer().serialize(SQLite, "10") + with pytest.raises( + NotImplementedError, match="MySql does not support ArraySerializer" ): - assert FloatSerializer().serialize(dialect_cls, 42) == "42" - - def test_serializes_decimal_via_its_exact_text_form( - self, dialect_cls: type[SqlDialect] + ArraySerializer(IntegerSerializer()).serialize(MySql, [1, 2]) + with pytest.raises( + NotImplementedError, match="SQLite does not support InetSerializer" ): - result = FloatSerializer().serialize(dialect_cls, Decimal("1.23456789012345")) - assert result == "1.23456789012345" - - @pytest.mark.parametrize("bad", ["abc", "", " ", "1.2.3-oops", "3.14"]) - def test_rejects_non_numeric_strings(self, bad, dialect_cls: type[SqlDialect]): - with pytest.raises(TypeError): - FloatSerializer().serialize(dialect_cls, bad) - - @pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")]) - def test_rejects_non_finite_floats(self, value, dialect_cls: type[SqlDialect]): - with pytest.raises(ValueError): - FloatSerializer().serialize(dialect_cls, value) - - def test_float_round_trips_without_ambiguous_int_form( - self, dialect_cls: type[SqlDialect] + InetSerializer().serialize(SQLite, "192.168.1.1") + with pytest.raises( + NotImplementedError, match="DuckDB does not support MacAddressSerializer" ): - """1.0 must serialize distinguishably from the integer 1 (per doc comment).""" - result = FloatSerializer().serialize(dialect_cls, 1.0) - assert result != "1" - - @pytest.mark.parametrize("value", [None, [1.0], b"1.0"]) - def test_rejects_unsupported_types(self, value, dialect_cls: type[SqlDialect]): - with pytest.raises(TypeError): - FloatSerializer().serialize(dialect_cls, value) - - -# --------------------------------------------------------------------------- -# BooleanSerializer -# --------------------------------------------------------------------------- - - -class TestBooleanSerializer: - def test_is_serializer_subclass(self): - assert issubclass(BooleanSerializer, Serializer) - - def test_serializes_true(self, dialect_cls: type[SqlDialect]): - result = BooleanSerializer().serialize(dialect_cls, True) - assert isinstance(result, str) - assert result != "" - - def test_serializes_false(self, dialect_cls: type[SqlDialect]): - result = BooleanSerializer().serialize(dialect_cls, False) - assert isinstance(result, str) - assert result != "" - - def test_true_and_false_serialize_differently(self, dialect_cls: type[SqlDialect]): - s = BooleanSerializer() - assert s.serialize(dialect_cls, True) != s.serialize(dialect_cls, False) - - @pytest.mark.parametrize("value", [1, 0, "true", "false", None, 1.0]) - def test_rejects_non_bool_types(self, value, dialect_cls: type[SqlDialect]): - """Only actual Python bool is accepted, not truthy/falsy look-alikes.""" - with pytest.raises(TypeError): - BooleanSerializer().serialize(dialect_cls, value) - - -# --------------------------------------------------------------------------- -# StringSerializer -# --------------------------------------------------------------------------- - - -class TestStringSerializer: - def test_is_serializer_subclass(self): - assert issubclass(StringSerializer, Serializer) - - def test_serializes_str_as_quoted_literal(self, dialect_cls: type[SqlDialect]): - result = StringSerializer().serialize(dialect_cls, "hello") - assert isinstance(result, str) - assert "hello" in result - # A string literal must be visually distinguishable/quoted, not bare. - assert result != "hello" - - def test_empty_string_still_quoted(self, dialect_cls: type[SqlDialect]): - result = StringSerializer().serialize(dialect_cls, "") - assert result != "" - - def test_single_quote_is_escaped(self, dialect_cls: type[SqlDialect]): - """A literal single quote inside the value must not break out of the - surrounding quotes (classic SQL-injection-shaped correctness bug).""" - result = StringSerializer().serialize(dialect_cls, "O'Brien") - # The escaped form must contain a doubled/escaped quote, not a bare - # unescaped one that would prematurely close the string literal. - assert "O'Brien" not in result or "O''Brien" in result or "O\\'Brien" in result - - def test_serializes_str_enum_member_via_its_value( - self, dialect_cls: type[SqlDialect] + MacAddressSerializer().serialize(DuckDB, "01:23:45:67:89:ab") + with pytest.raises( + NotImplementedError, match="BigQuery does not support BitSerializer" ): - import enum - - class Color(str, enum.Enum): - RED = "red" - - result = StringSerializer().serialize(dialect_cls, Color.RED) - assert "red" in result - - @pytest.mark.parametrize("value", [123, None, [1, 2], b"bytes"]) - def test_rejects_non_str_types(self, value, dialect_cls: type[SqlDialect]): - with pytest.raises(TypeError): - StringSerializer().serialize(dialect_cls, value) + ArraySerializer(BitSerializer()).serialize(BigQuery, ["10"]) # --------------------------------------------------------------------------- -# UuidSerializer +# String serialization escapes / edge cases # --------------------------------------------------------------------------- -class TestUuidSerializer: - def test_is_serializer_subclass(self): - assert issubclass(UuidSerializer, Serializer) +@pytest.mark.parametrize("dialect", ALL_DIALECTS) +def test_string_empty_is_quoted(dialect: type[SqlDialect]): + assert StringSerializer().serialize(dialect, "") == _quoted(dialect, "") - def test_serializes_uuid_object(self, dialect_cls: type[SqlDialect]): - value = uuid.uuid4() - result = UuidSerializer().serialize(dialect_cls, value) - assert str(value) in result - def test_serializes_uuid_string(self, dialect_cls: type[SqlDialect]): - value = str(uuid.uuid4()) - result = UuidSerializer().serialize(dialect_cls, value) - assert value in result +@pytest.mark.parametrize("dialect", _QUOTE_ONLY) +def test_string_quote_only_doubles_single_quote(dialect: type[SqlDialect]): + s = StringSerializer() + assert s.serialize(dialect, "O'Brien") == _quoted(dialect, "O''Brien") + assert s.serialize(dialect, "''''") == _quoted(dialect, "''''''''") - def test_result_is_quoted(self, dialect_cls: type[SqlDialect]): - value = uuid.uuid4() - result = UuidSerializer().serialize(dialect_cls, value) - assert result != str(value) - @pytest.mark.parametrize("value", [123, None, [1, 2], b"bytes"]) - def test_rejects_unsupported_types(self, value, dialect_cls: type[SqlDialect]): - with pytest.raises(TypeError): - UuidSerializer().serialize(dialect_cls, value) +@pytest.mark.parametrize("dialect", _BACKSLASH_ESCAPING) +def test_string_backslash_family_escapes_single_quote(dialect: type[SqlDialect]): + s = StringSerializer() + assert s.serialize(dialect, "O'Brien") == _quoted(dialect, "O\\'Brien") + assert s.serialize(dialect, "''''") == _quoted(dialect, "\\'\\'\\'\\'") -# --------------------------------------------------------------------------- -# BytesSerializer -# --------------------------------------------------------------------------- - - -class TestBytesSerializer: - def test_is_serializer_subclass(self): - assert issubclass(BytesSerializer, Serializer) - - def test_serializes_bytes(self, dialect_cls: type[SqlDialect]): - result = BytesSerializer().serialize(dialect_cls, b"\x00\x01\xff") - assert isinstance(result, str) +@pytest.mark.parametrize("dialect", _QUOTE_ONLY) +def test_string_quote_only_passes_backslash_through(dialect: type[SqlDialect]): + s = StringSerializer() + assert s.serialize(dialect, "\\") == _quoted(dialect, "\\") + assert s.serialize(dialect, "\\'") == _quoted(dialect, "\\''") - def test_empty_bytes(self, dialect_cls: type[SqlDialect]): - result = BytesSerializer().serialize(dialect_cls, b"") - assert isinstance(result, str) - def test_different_bytes_serialize_differently(self, dialect_cls: type[SqlDialect]): - s = BytesSerializer() - assert s.serialize(dialect_cls, b"abc") != s.serialize(dialect_cls, b"xyz") +@pytest.mark.parametrize("dialect", _BACKSLASH_ESCAPING) +def test_string_backslash_family_doubles_backslash(dialect: type[SqlDialect]): + s = StringSerializer() + assert s.serialize(dialect, "\\") == _quoted(dialect, "\\\\") + assert s.serialize(dialect, "\\'") == _quoted(dialect, "\\\\\\'") - @pytest.mark.parametrize("value", ["not bytes", 123, None, bytearray(b"abc")]) - def test_rejects_non_exact_bytes_types(self, value, dialect_cls: type[SqlDialect]): - """Only `bytes`, not `str`/`bytearray`/other buffer-like types.""" - with pytest.raises(TypeError): - BytesSerializer().serialize(dialect_cls, value) - - -# --------------------------------------------------------------------------- -# BitSerializer -# --------------------------------------------------------------------------- +@pytest.mark.parametrize("dialect", _NUL_STRIPPING) +def test_string_nul_is_stripped(dialect: type[SqlDialect]): + assert StringSerializer().serialize(dialect, "a\0b") == _quoted(dialect, "ab") -class TestBitSerializer: - def test_is_serializer_subclass(self): - assert issubclass(BitSerializer, Serializer) - @pytest.mark.parametrize("value", ["0", "1", "1010", "000", "111111"]) - def test_serializes_valid_bit_strings(self, value, dialect_cls: type[SqlDialect]): - result = BitSerializer().serialize(dialect_cls, value) - assert isinstance(result, str) +@pytest.mark.parametrize("dialect", _NUL_X00) +def test_string_nul_is_hex_escaped(dialect: type[SqlDialect]): + assert StringSerializer().serialize(dialect, "a\0b") == _quoted(dialect, "a\\x00b") - as_int = int(value, base=2) - assert value in result or str(as_int) in result +@pytest.mark.parametrize("dialect", _NUL_BACKSLASH_ZERO) +def test_string_nul_is_backslash_zero(dialect: type[SqlDialect]): + assert StringSerializer().serialize(dialect, "a\0b") == _quoted(dialect, "a\\0b") - def test_rejects_empty_string(self, dialect_cls: type[SqlDialect]): - with pytest.raises(ValueError): - BitSerializer().serialize(dialect_cls, "") - @pytest.mark.parametrize("bad", ["102", "abc", "1 0", "2", "-1"]) - def test_rejects_non_binary_content(self, bad, dialect_cls: type[SqlDialect]): - with pytest.raises(ValueError): - BitSerializer().serialize(dialect_cls, bad) +def test_string_nul_duckdb_passthrough(): + assert StringSerializer().serialize(DuckDB, "a\0b") == "'a\0b'" - @pytest.mark.parametrize("value", [10, None, [1, 0]]) - def test_rejects_non_str_types(self, value, dialect_cls: type[SqlDialect]): - with pytest.raises(TypeError): - BitSerializer().serialize(dialect_cls, value) - -# --------------------------------------------------------------------------- -# JSONSerializer -# --------------------------------------------------------------------------- - - -class TestJSONSerializer: - def test_is_serializer_subclass(self): - assert issubclass(JSONSerializer, Serializer) - - @pytest.mark.parametrize( - "value", - [ - {"a": 1, "b": [1, 2, 3]}, - [1, 2, 3], - "plain string", - 123, - 123.45, - True, - None, - ], +@pytest.mark.parametrize("dialect", _BACKSLASH_ESCAPING) +def test_string_backslash_family_escapes_newline_and_cr(dialect: type[SqlDialect]): + assert StringSerializer().serialize(dialect, "a\nb\rc") == _quoted( + dialect, "a\\nb\\rc" ) - def test_serializes_json_encodable_values( - self, value, dialect_cls: type[SqlDialect] - ): - result = JSONSerializer().serialize(dialect_cls, value) - assert isinstance(result, str) - assert result != "" - - def test_result_is_quoted_as_a_string_literal(self, dialect_cls: type[SqlDialect]): - result = JSONSerializer().serialize(dialect_cls, {"a": 1}) - # The JSON text itself must be wrapped as a SQL string literal. - assert result != '{"a": 1}' - - def test_dict_contents_are_present_in_output(self, dialect_cls: type[SqlDialect]): - result = JSONSerializer().serialize(dialect_cls, {"key": "value"}) - assert "key" in result and "value" in result - - def test_rejects_non_json_encodable_values(self, dialect_cls: type[SqlDialect]): - class NotEncodable: - pass - - with pytest.raises(TypeError): - JSONSerializer().serialize(dialect_cls, NotEncodable()) - - def test_compact_separators(self, dialect_cls: type[SqlDialect]): - result = JSONSerializer().serialize(dialect_cls, {"a": 1}) - assert ": " not in result - result = JSONSerializer().serialize(dialect_cls, [1, 2]) - assert ", " not in result - - def test_nan(self, dialect_cls: type[SqlDialect]): - # orjson emits JSON `null` for NaN; stdlib `json` with allow_nan=False - # raises ValueError. - try: - import orjson # noqa: F401 - except ImportError: - with pytest.raises(ValueError): - JSONSerializer().serialize(dialect_cls, float("nan")) - else: - result = JSONSerializer().serialize(dialect_cls, float("nan")) - assert "null" in result - - def test_orjson_encodes_datetime(self, dialect_cls: type[SqlDialect]): - pytest.importorskip("orjson") - result = JSONSerializer().serialize( - dialect_cls, {"t": datetime.datetime(2024, 1, 2, 3, 4, 5)} - ) - assert "2024-01-02" in result - - -# --------------------------------------------------------------------------- -# DateSerializer -# --------------------------------------------------------------------------- - - -class TestDateSerializer: - def test_is_serializer_subclass(self): - assert issubclass(DateSerializer, Serializer) - - def test_serializes_date_as_iso_literal(self, dialect_cls: type[SqlDialect]): - result = DateSerializer().serialize(dialect_cls, datetime.date(2024, 1, 15)) - assert "2024-01-15" in result - - def test_result_is_quoted(self, dialect_cls: type[SqlDialect]): - result = DateSerializer().serialize(dialect_cls, datetime.date(2024, 1, 15)) - assert result != "2024-01-15" - - def test_rejects_datetime_as_not_exactly_date(self, dialect_cls: type[SqlDialect]): - """A `datetime.datetime` is technically a `date` subclass in Python, - but the serializer should require an exact `date` (see e.g. the - exact-type checks used throughout the other serializers).""" - with pytest.raises(TypeError): - DateSerializer().serialize( - dialect_cls, datetime.datetime(2024, 1, 15, 10, 0) - ) - - @pytest.mark.parametrize("value", ["2024-01-15", None, 20240115]) - def test_rejects_non_date_types(self, value, dialect_cls: type[SqlDialect]): - with pytest.raises(TypeError): - DateSerializer().serialize(dialect_cls, value) - - -# --------------------------------------------------------------------------- -# TimeSerializer -# --------------------------------------------------------------------------- - - -class TestTimeSerializer: - def test_is_serializer_subclass(self): - assert issubclass(TimeSerializer, Serializer) - - def test_serializes_time_as_hms_literal(self, dialect_cls: type[SqlDialect]): - result = TimeSerializer().serialize(dialect_cls, datetime.time(13, 45, 30)) - assert "13:45:30" in result - - def test_serializes_time_with_microseconds(self, dialect_cls: type[SqlDialect]): - result = TimeSerializer().serialize(dialect_cls, datetime.time(1, 2, 3, 456789)) - assert "01:02:03" in result - - def test_result_is_quoted(self, dialect_cls: type[SqlDialect]): - result = TimeSerializer().serialize(dialect_cls, datetime.time(13, 45, 30)) - assert result != "13:45:30" - - @pytest.mark.parametrize("value", ["13:45:30", None, datetime.date(2024, 1, 1)]) - def test_rejects_non_time_types(self, value, dialect_cls: type[SqlDialect]): - with pytest.raises(TypeError): - TimeSerializer().serialize(dialect_cls, value) - - -# --------------------------------------------------------------------------- -# DatetimeSerializer -# --------------------------------------------------------------------------- - - -class TestDatetimeSerializer: - def test_is_serializer_subclass(self): - assert issubclass(DatetimeSerializer, Serializer) - - def test_serializes_naive_datetime(self, dialect_cls: type[SqlDialect]): - result = DatetimeSerializer().serialize( - dialect_cls, datetime.datetime(2024, 1, 15, 10, 30, 0) - ) - assert "2024-01-15" in result and "10:30:00" in result - def test_serializes_aware_datetime(self, dialect_cls: type[SqlDialect]): - value = datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc) - result = DatetimeSerializer().serialize(dialect_cls, value) - assert "2024-01-15" in result and "10:30:00" in result - def test_accepts_int_as_utc_epoch(self, dialect_cls: type[SqlDialect]): - result = DatetimeSerializer().serialize(dialect_cls, 0) - assert "1970-01-01" in result - - def test_accepts_float_as_utc_epoch(self, dialect_cls: type[SqlDialect]): - result = DatetimeSerializer().serialize(dialect_cls, 0.0) - assert "1970-01-01" in result - - def test_result_is_quoted(self, dialect_cls: type[SqlDialect]): - result = DatetimeSerializer().serialize( - dialect_cls, datetime.datetime(2024, 1, 15, 10, 30, 0) - ) - assert result != "2024-01-15 10:30:00" +@pytest.mark.parametrize("dialect", _QUOTE_ONLY) +def test_string_quote_only_passes_newline_cr_tab_through(dialect: type[SqlDialect]): + assert StringSerializer().serialize(dialect, "a\nb\rc\td") == _quoted( + dialect, "a\nb\rc\td" + ) - @pytest.mark.parametrize("value", ["2024-01-15T10:30:00", None, [2024, 1, 15]]) - def test_rejects_unsupported_types(self, value, dialect_cls: type[SqlDialect]): - with pytest.raises(TypeError): - DatetimeSerializer().serialize(dialect_cls, value) +@pytest.mark.parametrize( + "dialect", + [d for d in _BACKSLASH_ESCAPING if d is not MySql], +) +def test_string_backslash_family_escapes_tab(dialect: type[SqlDialect]): + assert StringSerializer().serialize(dialect, "a\tb") == _quoted(dialect, "a\\tb") -# --------------------------------------------------------------------------- -# InetSerializer -# --------------------------------------------------------------------------- +def test_string_mysql_passes_tab_through(): + assert StringSerializer().serialize(MySql, "a\tb") == "'a\tb'" -class TestInetSerializer: - def test_is_serializer_subclass(self): - assert issubclass(InetSerializer, Serializer) - @pytest.mark.parametrize( - "value", - [ - ipaddress.ip_address("192.168.1.1"), - "192.168.1.1", - "192.168.1.0/24", - "::1", - "2001:db8::/32", - ], +@pytest.mark.parametrize("dialect", _HEX_CTRL) +def test_string_other_controls_hex_escape(dialect: type[SqlDialect]): + assert StringSerializer().serialize(dialect, "\x01\x1a\x7f") == _quoted( + dialect, "\\x01\\x1A\\x7F" ) - def test_serializes_valid_ip_networks(self, value, dialect_cls: type[SqlDialect]): - result = InetSerializer().serialize(dialect_cls, value) - assert isinstance(result, str) - assert result != "" - - def test_result_is_quoted(self, dialect_cls: type[SqlDialect]): - result = InetSerializer().serialize(dialect_cls, "192.168.1.1") - assert result != "192.168.1.1" - @pytest.mark.parametrize("bad", ["not-an-ip", "999.999.999.999", ""]) - def test_rejects_invalid_ip_strings(self, bad, dialect_cls: type[SqlDialect]): - with pytest.raises(ValueError): - InetSerializer().serialize(dialect_cls, bad) - @pytest.mark.parametrize("value", [None, 12345]) - def test_rejects_non_str_types(self, value, dialect_cls: type[SqlDialect]): - with pytest.raises(ValueError): - InetSerializer().serialize(dialect_cls, value) +@pytest.mark.parametrize("dialect", _UNICODE_CTRL) +def test_string_other_controls_unicode_escape(dialect: type[SqlDialect]): + assert StringSerializer().serialize(dialect, "\x01\x1a\x7f") == _quoted( + dialect, "\\u0001\\u001A\\u007F" + ) -# --------------------------------------------------------------------------- -# MacAddressSerializer -# --------------------------------------------------------------------------- +def test_string_mysql_ctrl_z_and_raw_other_controls(): + assert StringSerializer().serialize(MySql, "\x01\x1a\x7f") == "'\x01\\Z\x7f'" -class TestMacAddressSerializer: - def test_is_serializer_subclass(self): - assert issubclass(MacAddressSerializer, Serializer) +@pytest.mark.parametrize("dialect", _QUOTE_ONLY) +def test_string_quote_only_passes_other_controls_through(dialect: type[SqlDialect]): + assert StringSerializer().serialize(dialect, "\x01\x1a\x7f") == _quoted( + dialect, "\x01\x1a\x7f" + ) - @pytest.mark.parametrize("value", ["01:23:45:67:89:ab", "01-23-45-67-89-AB"]) - def test_serializes_valid_mac_addresses(self, value, dialect_cls: type[SqlDialect]): - result = MacAddressSerializer().serialize(dialect_cls, value) - assert isinstance(result, str) - assert result != "" - def test_result_is_quoted(self, dialect_cls: type[SqlDialect]): - result = MacAddressSerializer().serialize(dialect_cls, "01:23:45:67:89:ab") - assert result != "01:23:45:67:89:ab" +@pytest.mark.parametrize("dialect", _DOUBLE_QUOTE_ESCAPING) +def test_string_double_quote_is_escaped(dialect: type[SqlDialect]): + assert StringSerializer().serialize(dialect, 'say "hi"') == _quoted( + dialect, 'say \\"hi\\"' + ) - @pytest.mark.parametrize("bad", ["not-a-mac", "gg:hh:ii:jj:kk:ll", ""]) - def test_rejects_invalid_mac_strings(self, bad, dialect_cls: type[SqlDialect]): - with pytest.raises(ValueError): - MacAddressSerializer().serialize(dialect_cls, bad) - @pytest.mark.parametrize("value", [None, 12345]) - def test_rejects_non_str_types(self, value, dialect_cls: type[SqlDialect]): - with pytest.raises(ValueError): - MacAddressSerializer().serialize(dialect_cls, value) +@pytest.mark.parametrize( + "dialect", + [d for d in ALL_DIALECTS if d not in _DOUBLE_QUOTE_ESCAPING], +) +def test_string_double_quote_is_literal(dialect: type[SqlDialect]): + assert StringSerializer().serialize(dialect, 'say "hi"') == _quoted( + dialect, 'say "hi"' + ) -# --------------------------------------------------------------------------- -# UnspecifiedSerializer -# --------------------------------------------------------------------------- - +@pytest.mark.parametrize("dialect", _QUOTE_ONLY) +def test_string_injection_quote_only(dialect: type[SqlDialect]): + out = StringSerializer().serialize(dialect, "' OR 1=1 --") + assert out == _quoted(dialect, "'' OR 1=1 --") + assert " OR 1=1 --" in out -class TestUnspecifiedSerializer: - """The fallback serializer: `str(obj)`, no type validation, no quoting. - This is intentionally permissive per its doc comment ("useful when user - doesn't specify the serializer, and also we couldn't detect the type - from datatype"). - """ +@pytest.mark.parametrize("dialect", _BACKSLASH_ESCAPING) +def test_string_injection_backslash_family(dialect: type[SqlDialect]): + out = StringSerializer().serialize(dialect, "' OR 1=1 --") + assert out == _quoted(dialect, "\\' OR 1=1 --") - def test_is_serializer_subclass(self): - assert issubclass(UnspecifiedSerializer, Serializer) - @pytest.mark.parametrize( - "value,expected", - [ - (123, "123"), - ("hello", "hello"), - (3.14, "3.14"), - (None, "None"), - (True, "True"), - ], +@pytest.mark.parametrize("dialect", ALL_DIALECTS) +def test_string_unicode_passthrough(dialect: type[SqlDialect]): + assert StringSerializer().serialize(dialect, "ę—„ęœ¬čŖžšŸ˜€") == _quoted( + dialect, "ę—„ęœ¬čŖžšŸ˜€" ) - def test_falls_back_to_str_of_the_object( - self, value, expected, dialect_cls: type[SqlDialect] - ): - result = UnspecifiedSerializer().serialize(dialect_cls, value) - assert result == expected - def test_accepts_any_object_without_raising(self, dialect_cls: type[SqlDialect]): - class Anything: - def __str__(self): - return "custom-repr" - result = UnspecifiedSerializer().serialize(dialect_cls, Anything()) - assert result == "custom-repr" +@pytest.mark.parametrize("value", [123, b"bytes", None]) +@pytest.mark.parametrize("dialect", ALL_DIALECTS) +def test_string_rejects_non_str(dialect: type[SqlDialect], value: typing.Any): + with pytest.raises(TypeError): + StringSerializer().serialize(dialect, value) # --------------------------------------------------------------------------- -# Cross-cutting: dialect argument acceptance +# Bytes serialization escapes / edge cases # --------------------------------------------------------------------------- -class TestSerializeAcceptsClassOrInstance: - """Per data_type.pyi: `serialize(dialect: Type[SqlDialect] | SqlDialect, ...)`.""" - - def test_integer_serializer_accepts_class(self): - assert IntegerSerializer().serialize(PostgreSQL, 1) == "1" - - def test_integer_serializer_accepts_instance(self): - assert IntegerSerializer().serialize(PostgreSQL(), 1) == "1" - - def test_class_and_instance_give_identical_results( - self, dialect_cls: type[SqlDialect] - ): - s = StringSerializer() - assert s.serialize(dialect_cls, "x") == s.serialize(dialect_cls(), "x") - - def test_rejects_non_dialect_argument(self): - with pytest.raises(TypeError): - IntegerSerializer().serialize( - "not-a-dialect", # type: ignore - 1, - ) - - def test_rejects_none_as_dialect(self): - with pytest.raises(TypeError): - IntegerSerializer().serialize( - None, # type: ignore - 1, - ) - - -class TestSerializerMarkers: - def test_unit_instances_compare_equal(self): - assert IntegerSerializer() == IntegerSerializer() - assert IntegerSerializer() != FloatSerializer() - - def test_unit_instances_are_hashable(self): - assert hash(IntegerSerializer()) == hash(IntegerSerializer()) - assert len({IntegerSerializer(), IntegerSerializer(), FloatSerializer()}) == 2 - - def test_unit_repr_is_constructor_call(self): - assert repr(IntegerSerializer()) == "IntegerSerializer()" - assert repr(JSONSerializer()) == "JSONSerializer()" +@pytest.mark.parametrize("dialect", ALL_DIALECTS) +@pytest.mark.parametrize( + "data", + [b"", b"\x00", b"\xff", b"\x00\x01\xff"], +) +def test_bytes_hex_wrappers(dialect: type[SqlDialect], data: bytes): + assert BytesSerializer().serialize(dialect, data) == _bytes_literal(dialect, data) - def test_array_equals_by_element_tree(self): - a = ArraySerializer(IntegerSerializer()) - b = ArraySerializer(IntegerSerializer()) - c = ArraySerializer(StringSerializer()) - assert a == b - assert a != c - assert repr(a) == "ArraySerializer(IntegerSerializer())" - def test_custom_equals_by_function_identity(self): - def fn(dialect, obj): - return "x" +@pytest.mark.parametrize("dialect", ALL_DIALECTS) +def test_bytes_all_256_values_uppercase_hex(dialect: type[SqlDialect]): + data = bytes(range(256)) + out = BytesSerializer().serialize(dialect, data) + assert out == _bytes_literal(dialect, data) + if dialect is BigQuery: + expected_run = "".join(f"\\x{b:02X}" for b in data) + else: + expected_run = "".join(f"{b:02X}" for b in data) - assert CustomSerializer(fn) == CustomSerializer(fn) - assert CustomSerializer(fn) != CustomSerializer(lambda d, o: "x") + assert expected_run in out -class TestDatetimeOffsetPreserved: - def test_aware_datetime_keeps_offset(self, dialect_cls: type[SqlDialect]): - value = datetime.datetime( - 2024, - 1, - 15, - 10, - 30, - 0, - tzinfo=datetime.timezone(datetime.timedelta(hours=5)), - ) - result = DatetimeSerializer().serialize(dialect_cls, value) - assert "10:30:00" in result - assert "+05:00" in result - assert "05:30:00" not in result +@pytest.mark.parametrize( + "value", + [bytearray(b"abc"), memoryview(b"abc"), "abc", 1, None], +) +@pytest.mark.parametrize("dialect", ALL_DIALECTS) +def test_bytes_rejects_non_exact_bytes(dialect: type[SqlDialect], value: typing.Any): + with pytest.raises(TypeError): + BytesSerializer().serialize(dialect, value) diff --git a/tests/test_value.py b/tests/test_value.py deleted file mode 100644 index 531def9..0000000 --- a/tests/test_value.py +++ /dev/null @@ -1,107 +0,0 @@ -import pytest - -from rapidquery import Value -from rapidquery.data_type import ( - DataType, - IntegerSerializer, - JSONSerializer, - StringSerializer, -) -from rapidquery.dialect import PostgreSQL - - -class TestValueConstruction: - def test_infers_integer_serializer(self): - value = Value(42) - assert value.object == 42 - assert isinstance(value.serializer, IntegerSerializer) - - def test_infers_string_serializer(self): - value = Value("hi") - assert value.object == "hi" - assert isinstance(value.serializer, StringSerializer) - - def test_infers_boolean_for_bool_and_none(self): - assert type(Value(True).serializer).__name__ == "BooleanSerializer" - assert type(Value(None).serializer).__name__ == "BooleanSerializer" - - def test_infers_json_for_list_and_dict(self): - assert isinstance(Value([1, 2]).serializer, JSONSerializer) - assert isinstance(Value({"a": 1}).serializer, JSONSerializer) - - def test_infers_bytes_serializer(self): - value = Value(b"abc") - assert value.object == b"abc" - assert type(value.serializer).__name__ == "BytesSerializer" - - def test_explicit_serializer_overrides_inference(self): - value = Value(1, StringSerializer()) - assert isinstance(value.serializer, StringSerializer) - - def test_wraps_existing_value_and_keeps_serializer(self): - inner = Value(1, StringSerializer()) - outer = Value(inner) - assert outer.object == 1 - assert isinstance(outer.serializer, StringSerializer) - - def test_wraps_existing_value_with_new_serializer(self): - inner = Value(1) - outer = Value(inner, StringSerializer()) - assert outer.object == 1 - assert isinstance(outer.serializer, StringSerializer) - - def test_data_type_supplies_serializer_only(self): - value = Value(1, DataType("int")) - assert isinstance(value.serializer, IntegerSerializer) - assert not hasattr(value, "data_type") - - def test_data_type_serializer_override(self): - value = Value("x", DataType("integer", StringSerializer())) - assert isinstance(value.serializer, StringSerializer) - - def test_rejects_non_serializer_second_arg(self): - with pytest.raises(TypeError, match="Serializer or DataType"): - _ = Value(1, "integer") - - -class TestValueToSql: - def test_integer_literal(self): - assert Value(42).to_sql(PostgreSQL) == "42" - - def test_string_literal_matches_string_serializer(self): - assert Value("hi").to_sql(PostgreSQL) == StringSerializer().serialize( - PostgreSQL, "hi" - ) - - def test_none_is_null(self): - assert Value(None).to_sql(PostgreSQL) == "NULL" - - def test_accepts_dialect_class_or_instance(self): - value = Value(1) - assert value.to_sql(PostgreSQL) == value.to_sql(PostgreSQL()) - - def test_propagates_serializer_type_error(self): - with pytest.raises(TypeError): - Value("not an int", IntegerSerializer()).to_sql(PostgreSQL) - - -class TestValueEquality: - def test_equal_when_object_and_serializer_match(self): - assert Value(1) == Value(1) - assert hash(Value(1)) == hash(Value(1)) - - def test_unequal_when_serializer_differs(self): - assert Value(1) != Value(1, StringSerializer()) - - def test_unequal_when_object_differs(self): - assert Value(1) != Value(2) - - def test_not_equal_to_bare_object(self): - assert Value(1) != 1 - - def test_unhashable_object_raises(self): - with pytest.raises(TypeError): - hash(Value({"a": 1})) - - def test_repr_includes_object_and_serializer(self): - assert repr(Value(42)) == "Value(42, IntegerSerializer())" From fd3d20ec86f2ce7b5889de0392cb11c67374f83f Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Wed, 16 Sep 2026 18:21:56 +0330 Subject: [PATCH 34/87] Update Function --- Cargo.lock | 1 + Cargo.toml | 2 +- rapidquery/__init__.py | 4 ---- rapidquery/expr.pyi | 8 ++++---- src/expr/base.rs | 19 +++++++++++++++++++ src/expr/func.rs | 29 ++++++++++------------------- tests/conftest.py | 17 ++++++++++++++++- 7 files changed, 51 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fb492bc..62a2664 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -293,6 +293,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b" dependencies = [ "chrono", + "hashbrown", "libc", "once_cell", "portable-atomic", diff --git a/Cargo.toml b/Cargo.toml index 2274b26..2e00ec7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ opt-level = 3 [dependencies] rapidquery-macros = { path = "rapidquery-macros" } -pyo3 = { version = "0.29", default-features = false, features = [ "macros", "chrono", "py-clone" ] } +pyo3 = { version = "0.29", default-features = false, features = [ "macros", "chrono", "py-clone", "hashbrown" ] } parking_lot = { version = "0.12", default-features = false } hashbrown = { version = "0.17.1", default-features = false, features = ["allocator-api2", "default-hasher", "inline-more"] } arcstr = { version = "1.2" } diff --git a/rapidquery/__init__.py b/rapidquery/__init__.py index 574bf5f..70d5474 100644 --- a/rapidquery/__init__.py +++ b/rapidquery/__init__.py @@ -4,16 +4,12 @@ from . import dialect as dialect from . import expr as expr from .expr import Asterisk as Asterisk -from .expr import Expr as Expr -from .expr import Function as Function from .expr import Ident as Ident from .expr import Unquote as Unquote from .expr import Value as Value __all__ = [ "Asterisk", - "Expr", - "Function", "Ident", "Unquote", "Value", diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index 982144e..540105e 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -202,7 +202,8 @@ class Function(Expr): def __new__( cls, name: str | Ident | Unquote, - *args: object, + args: typing.Iterable[object] | None, + *, distinct: bool | None = None, filter: Expr | None = None, ) -> Self: @@ -211,9 +212,8 @@ class Function(Expr): Args: name: Function name (``str``, ``Ident``, or ``Unquote``). - *args: Positional argument expressions. A bare ``str`` is a - column. An empty argument list means no parentheses - (``CURRENT_TIMESTAMP``). + args: Positional argument expressions. A bare ``str`` is a + column. `None` means no parentheses (``CURRENT_TIMESTAMP``). distinct: ``True`` inserts ``DISTINCT``, ``False`` inserts ``ALL``, omitted inserts neither. filter: Optional ``FILTER (WHERE …)`` predicate. diff --git a/src/expr/base.rs b/src/expr/base.rs index 6076f68..de40a4a 100644 --- a/src/expr/base.rs +++ b/src/expr/base.rs @@ -9,6 +9,7 @@ // `custom`. Prefer named methods over v0 dunders that return `Expr`. use crate::internal::alias; +use crate::sql; use super::enum_::ExprEnum; @@ -76,6 +77,24 @@ impl PyExpr { #[pyo3::pymethods] impl PyExpr { + /// Render this expression as a parameterized statement. + fn prepare( + slf: pyo3::PyRef<'_, Self>, + py: pyo3::Python<'_>, + dialect: crate::dialect::py::PySqlDialect, + ) -> pyo3::PyResult<(String, sql::PrepareParams)> { + sql::prepare(py, &slf.0, &dialect.0).map_err(Into::into) + } + + /// Render this expression as raw SQL (values inlined as literals). + fn to_sql( + slf: pyo3::PyRef<'_, Self>, + py: pyo3::Python<'_>, + dialect: crate::dialect::py::PySqlDialect, + ) -> pyo3::PyResult { + sql::write_sql(py, &slf.0, &dialect.0).map_err(Into::into) + } + fn __repr__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { self.0.python_repr(py) } diff --git a/src/expr/func.rs b/src/expr/func.rs index 832948b..0b819c0 100644 --- a/src/expr/func.rs +++ b/src/expr/func.rs @@ -1,10 +1,8 @@ -use pyo3::types::{PyAnyMethods, PyTupleMethods}; - use crate::internal::{alias, utils}; use super::base::PyExpr; use super::enum_::{DuplicateTreatment, FunctionArguments}; -use super::extractors::{ExprExtractor, IdentExtractor}; +use super::extractors::{ConditionExtractor, ExprExtractor, IdentExtractor}; /// SQL function call. /// @@ -44,36 +42,29 @@ impl PyFunction { #[pyo3::pymethods] impl PyFunction { #[new] - #[pyo3(signature = (name, *args, distinct = None, filter = None))] + #[pyo3(signature = (name, args = None, *, distinct = None, filter = None))] fn __new__( name: IdentExtractor, - args: alias::ArgsType<'_>, + args: Option>, distinct: Option, - filter: Option, + filter: Option, ) -> pyo3::PyResult> { - let func_args = if args.is_empty() { - FunctionArguments::None - } else { - let mut extracted = Vec::with_capacity(args.len()); - - for item in args.iter() { - extracted.push(item.extract::()?.0); - } - - FunctionArguments::List { + let func_args = match args { + None => FunctionArguments::None, + Some(x) => FunctionArguments::List { duplicate_treatment: match distinct { Some(true) => Some(DuplicateTreatment::Distinct), Some(false) => Some(DuplicateTreatment::All), None => None, }, - args: extracted, - } + args: x.into_iter().map(|ExprExtractor(x)| x).collect(), + }, }; let inner = super::enum_::Function { name: name.0, args: func_args, - filter: filter.map(|ExprExtractor(expr)| Box::new(expr)), + filter: filter.map(|ConditionExtractor(expr)| Box::new(expr)), }; Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) } diff --git a/tests/conftest.py b/tests/conftest.py index 1cd65c0..9fc11c8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +import pytest + from rapidquery.dialect import ( MSSQL, BigQuery, @@ -15,7 +17,7 @@ Teradata, ) -ALL_DIALECTS: list[type[SqlDialect]] = [ +ALL_DIALECTS = [ BigQuery, Databricks, DuckDB, @@ -30,3 +32,16 @@ SQLite, Teradata, ] +ALL_DIALECT_NAMES = [cls.__name__ for cls in ALL_DIALECTS] + + +@pytest.fixture(params=ALL_DIALECTS, ids=ALL_DIALECT_NAMES) +def dialect_cls(request: pytest.FixtureRequest) -> type[SqlDialect]: + """Every concrete SqlDialect subclass.""" + return request.param + + +@pytest.fixture(params=ALL_DIALECTS, ids=ALL_DIALECT_NAMES) +def dialect_instance(request: pytest.FixtureRequest) -> SqlDialect: + """An instance of every concrete SqlDialect subclass.""" + return request.param() From 6c97491b5e77af4db23500cf34f61365744a31ba Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Thu, 17 Sep 2026 16:36:56 +0330 Subject: [PATCH 35/87] Fix some bugs, write tests for ident/value/function --- rapidquery/data_type.pyi | 2 +- rapidquery/expr.pyi | 24 ++- src/data_type/serializer/serialize.rs | 25 +-- src/data_type/serializer/serializer_enum.rs | 6 +- src/expr/enum_/func.rs | 3 +- src/expr/extractors.rs | 22 ++- src/expr/func.rs | 16 ++ src/expr/ident.rs | 43 ++++++ src/expr/value.rs | 29 ++++ src/internal/utils/mod.rs | 7 - src/typeref.rs | 4 +- tests/conftest.py | 9 ++ tests/test_expr.py | 83 ++++++++++ tests/test_ident.py | 161 ++++++++++++++++++++ tests/test_serializers.py | 10 +- tests/test_value.py | 153 +++++++++++++++++++ 16 files changed, 545 insertions(+), 52 deletions(-) create mode 100644 tests/test_expr.py create mode 100644 tests/test_ident.py create mode 100644 tests/test_value.py diff --git a/rapidquery/data_type.pyi b/rapidquery/data_type.pyi index 5fe4761..fe10ab2 100644 --- a/rapidquery/data_type.pyi +++ b/rapidquery/data_type.pyi @@ -94,7 +94,7 @@ class StringSerializer(Serializer[str | enum.Enum]): """ Serializes text as a quoted, escaped SQL string literal. - Accepts `str`, or an `enum.Enum` member whose `.value` is a `str`. The + Accepts `str`, or an `enum.StrEnum`. The dialect supplies quoting and escaping. Raises: diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index 540105e..5c69a63 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -3,6 +3,7 @@ from collections.abc import Iterable, Sequence from typing import Self, final, overload from rapidquery.data_type import DataType, Serializer +from rapidquery.dialect import SqlDialect class Expr: """ @@ -13,6 +14,18 @@ class Expr: instantiated. """ + def to_sql(self, dialect: type[SqlDialect] | SqlDialect) -> str: + """ + Render this expression as raw SQL (values inlined as literals). + """ + + def prepare( + self, dialect: type[SqlDialect] | SqlDialect + ) -> tuple[str, dict[str, typing.Any]]: + """ + Render this expression as a parameterized statement. + """ + @final class Unquote: """ @@ -115,6 +128,10 @@ class Ident(Expr): Part count; always at least 1. """ + def __getitem__(self, index: int) -> str | Unquote | Asterisk: ... + def __hash__(self) -> int: ... + def __eq__(self, value: object, /) -> bool: ... + def __ne__(self, value: object, /) -> bool: ... def to_list(self) -> Sequence[str | Unquote | Asterisk]: """ Return the parts as a list. @@ -188,6 +205,10 @@ class Value(Expr, typing.Generic[_T]): value is bound as a parameter. """ + def __hash__(self) -> int: ... + def __eq__(self, value: object, /) -> bool: ... + def __ne__(self, value: object, /) -> bool: ... + @final class Function(Expr): """ @@ -202,7 +223,7 @@ class Function(Expr): def __new__( cls, name: str | Ident | Unquote, - args: typing.Iterable[object] | None, + args: typing.Iterable[object] | None = None, *, distinct: bool | None = None, filter: Expr | None = None, @@ -234,6 +255,7 @@ class Function(Expr): The function name as an ``Ident``. """ + def get_distinct(self) -> bool | None: ... def get_args(self) -> tuple[Expr, ...] | None: """ Return the positional arguments. diff --git a/src/data_type/serializer/serialize.rs b/src/data_type/serializer/serialize.rs index b5a319b..1665e81 100644 --- a/src/data_type/serializer/serialize.rs +++ b/src/data_type/serializer/serialize.rs @@ -185,35 +185,14 @@ unsafe fn write_str( dialect: &SqlDialect, out: &mut String, ) -> pyo3::PyResult<()> { - if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { + if pyo3::ffi::PyUnicode_Check(ptr) == 1 { write_quoted(dialect, out, borrow_py_str_buffer(py, ptr)?); return Ok(()); } - // type(type(obj)) is EnumMeta — the instance is an enum member. - if (*pyo3::ffi::Py_TYPE(ptr)).ob_base.ob_base.ob_type == crate::typeref::STD_ENUM_TYPE { - let value = pyo3::ffi::PyObject_GetAttrString(ptr, c"value".as_ptr()); - if value.is_null() { - return Err(pyo3::PyErr::fetch(py)); - } - let is_str = pyo3::ffi::PyUnicode_CheckExact(value) == 1; - let result = if is_str { - write_quoted(dialect, out, borrow_py_str_buffer(py, value)?); - Ok(()) - } else { - rapidquery_macros::pyerr!( - Err(PyTypeError), - "expected str/Enum for string serialization, got {}", - get_type_name(py, ptr) - ) - }; - pyo3::ffi::Py_DECREF(value); - return result; - } - rapidquery_macros::pyerr!( Err(PyTypeError), - "expected str/Enum for string serialization, got {}", + "expected str for string serialization, got {}", get_type_name(py, ptr) ) } diff --git a/src/data_type/serializer/serializer_enum.rs b/src/data_type/serializer/serializer_enum.rs index b582253..f16e70c 100644 --- a/src/data_type/serializer/serializer_enum.rs +++ b/src/data_type/serializer/serializer_enum.rs @@ -136,10 +136,8 @@ impl SerializerEnum { return Self::Integer; } - // str/Enum -> String - if pyo3::ffi::PyUnicode_CheckExact(object_ptr) == 1 - || object_type_ptr == crate::typeref::STD_ENUM_TYPE - { + // str/enum.StrEnum -> String + if pyo3::ffi::PyUnicode_Check(object_ptr) == 1 { return Self::String; } diff --git a/src/expr/enum_/func.rs b/src/expr/enum_/func.rs index cdea5cc..1da8a55 100644 --- a/src/expr/enum_/func.rs +++ b/src/expr/enum_/func.rs @@ -89,9 +89,10 @@ impl WriteSql for Function { args, } => { out.push_char('('); + if let Some(dt) = duplicate_treatment { - out.push_str(dt.as_sql()); if !args.is_empty() { + out.push_str(dt.as_sql()); out.push_char(' '); } } diff --git a/src/expr/extractors.rs b/src/expr/extractors.rs index b6d3f95..d8fb2f4 100644 --- a/src/expr/extractors.rs +++ b/src/expr/extractors.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use pyo3::types::PyTupleMethods; use super::enum_::{ExprEnum, Ident, IdentPart}; @@ -57,14 +59,15 @@ fn extract_expr( // a bare `str` becomes [`ExprEnum::Ident`] if `str_as_ident` if str_as_ident { - let part = IdentPart::quoted(s) + let inner = Ident::from_str(s) .map_err(|err| rapidquery_macros::pyerr!(PyValueError, "{}", err))?; - return Ok(ExprEnum::Ident(Ident::from_one(part))); + return Ok(ExprEnum::Ident(inner)); } - return Ok(ExprEnum::Value(super::enum_::Value::infer_bound( - obj.as_any().clone(), + return Ok(ExprEnum::Value(super::enum_::Value::new( + obj.as_any().clone().unbind(), + crate::data_type::SerializerEnum::String, ))); } @@ -167,6 +170,17 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for IdentExtractor { fn extract( obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, ) -> Result { + unsafe { + // SAFETY: `ptr` is a live borrowed `PyObject`. + if pyo3::ffi::PyUnicode_CheckExact(obj.as_ptr()) == 1 { + let s = utils::borrow_py_str_buffer(obj.py(), obj.as_ptr())?; + let inner = Ident::from_str(s) + .map_err(|e| rapidquery_macros::pyerr!(PyValueError, "{}", e))?; + + return Ok(Self(inner)); + } + } + match extract_ident_argument(obj)? { ExtractedIdentArgument::Ident(ident) => Ok(Self(ident)), ExtractedIdentArgument::Part(part) => Ok(Self(Ident::from_one(part))), diff --git a/src/expr/func.rs b/src/expr/func.rs index 0b819c0..07d791c 100644 --- a/src/expr/func.rs +++ b/src/expr/func.rs @@ -74,6 +74,22 @@ impl PyFunction { PyExpr(super::enum_::ExprEnum::Ident(inner.name.clone())).into_subclasses(slf.py()) } + fn get_distinct(slf: pyo3::PyRef<'_, Self>) -> Option { + let inner = Self::inner(&slf); + + match inner.args { + FunctionArguments::None => None, + FunctionArguments::List { + duplicate_treatment: None, + .. + } => None, + FunctionArguments::List { + duplicate_treatment: Some(x), + .. + } => Some(matches!(x, DuplicateTreatment::Distinct)), + } + } + fn get_args(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { let py = slf.py(); let inner = Self::inner(&slf); diff --git a/src/expr/ident.rs b/src/expr/ident.rs index 6671482..6c4ce7b 100644 --- a/src/expr/ident.rs +++ b/src/expr/ident.rs @@ -102,6 +102,24 @@ impl PyIdent { Self::inner(&slf).len() } + fn __getitem__<'py>( + slf: pyo3::PyRef<'py, Self>, + index: isize, + ) -> pyo3::PyResult> { + let inner = Self::inner(&slf); + + let x = if index < 0 { + (inner.len() as isize) + index + } else { + index + }; + + match inner.parts().get(x as usize) { + Some(part) => ident_part_to_pyobject(slf.py(), part), + None => rapidquery_macros::pyerr!(Err(PyIndexError), "index out of range"), + } + } + /// Returns the parts as a list (`str`, `Unquote`, or `Asterisk`). fn to_list( slf: pyo3::PyRef<'_, Self>, @@ -113,6 +131,31 @@ impl PyIdent { .map(|part| ident_part_to_pyobject(py, part).map(|bound| bound.unbind())) .collect() } + + fn __hash__(slf: pyo3::PyRef<'_, Self>) -> u64 { + use std::hash::{DefaultHasher, Hash, Hasher}; + + let mut state = DefaultHasher::default(); + let inner = Self::inner(&slf); + inner.hash(&mut state); + state.finish() + } + + fn __eq__(slf: pyo3::PyRef<'_, Self>, other: pyo3::PyRef<'_, Self>) -> bool { + if slf.as_ptr() == other.as_ptr() { + return true; + } + + Self::inner(&slf).eq(Self::inner(&other)) + } + + fn __ne__(slf: pyo3::PyRef<'_, Self>, other: pyo3::PyRef<'_, Self>) -> bool { + if slf.as_ptr() == other.as_ptr() { + return true; + } + + Self::inner(&slf).ne(Self::inner(&other)) + } } /// Marker for an unquoted identifier part. diff --git a/src/expr/value.rs b/src/expr/value.rs index 8d5af12..870371d 100644 --- a/src/expr/value.rs +++ b/src/expr/value.rs @@ -109,4 +109,33 @@ impl PyValue { fn get_serializer(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { PySerializer(Self::inner(&slf).serializer.clone()).into_subclasses(slf.py()) } + + fn __hash__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { + use pyo3::types::PyAnyMethods; + use std::hash::{DefaultHasher, Hash, Hasher}; + + let inner = Self::inner(&slf); + let hash_object = inner.object.bind(slf.py()).hash()?; + + let mut state = DefaultHasher::default(); + state.write_isize(hash_object); + inner.serializer.hash(&mut state); + Ok(state.finish()) + } + + fn __eq__(slf: pyo3::PyRef<'_, Self>, other: pyo3::PyRef<'_, Self>) -> bool { + if slf.as_ptr() == other.as_ptr() { + return true; + } + + Self::inner(&slf).eq(Self::inner(&other)) + } + + fn __ne__(slf: pyo3::PyRef<'_, Self>, other: pyo3::PyRef<'_, Self>) -> bool { + if slf.as_ptr() == other.as_ptr() { + return true; + } + + Self::inner(&slf).ne(Self::inner(&other)) + } } diff --git a/src/internal/utils/mod.rs b/src/internal/utils/mod.rs index 93ab92a..ed63642 100644 --- a/src/internal/utils/mod.rs +++ b/src/internal/utils/mod.rs @@ -60,10 +60,3 @@ pub unsafe fn get_type_name<'a>(py: pyo3::Python<'a>, obj: *mut pyo3::ffi::PyObj pub fn new_pynone<'py>(py: pyo3::Python<'py>) -> pyo3::Bound<'py, pyo3::PyAny> { unsafe { pyo3::Bound::from_borrowed_ptr(py, pyo3::ffi::Py_None()) } } - -/// Helper enum to accept either an `isize` index or a `slice` in `__getitem__`. -#[derive(Debug, pyo3::FromPyObject)] -pub enum IndexOrSlice { - Index(isize), - Slice(pyo3::Py), -} diff --git a/src/typeref.rs b/src/typeref.rs index 8f570fc..6ca54dc 100644 --- a/src/typeref.rs +++ b/src/typeref.rs @@ -54,7 +54,7 @@ pub(crate) static mut STD_UUID_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::nu pub(crate) static mut STD_DATETIME_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); pub(crate) static mut STD_DATE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); pub(crate) static mut STD_TIME_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut STD_ENUM_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +// pub(crate) static mut STD_ENUM_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); unsafe fn get_type_object_for( py: pyo3::Python, @@ -142,7 +142,7 @@ fn _initialize_typeref(py: pyo3::Python) { STD_DECIMAL_TYPE = look_up_type_object(c"decimal", c"Decimal"); STD_UUID_TYPE = look_up_type_object(c"uuid", c"UUID"); - STD_ENUM_TYPE = look_up_type_object(c"enum", c"EnumMeta"); + // STD_ENUM_TYPE = look_up_type_object(c"enum", c"EnumMeta"); pyo3::ffi::PyDateTime_IMPORT(); let datetime_capsule = pyo3::ffi::PyCapsule_Import(c"datetime.datetime_CAPI".as_ptr(), 1) diff --git a/tests/conftest.py b/tests/conftest.py index 9fc11c8..5b9c321 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import pytest +from rapidquery._core import _feature_only_stdlib_json from rapidquery.dialect import ( MSSQL, @@ -17,6 +18,14 @@ Teradata, ) +try: + import orjson # ruff: ignore[F401] + + USING_ORJSON = not _feature_only_stdlib_json +except ImportError: + USING_ORJSON = False + + ALL_DIALECTS = [ BigQuery, Databricks, diff --git a/tests/test_expr.py b/tests/test_expr.py new file mode 100644 index 0000000..26d285c --- /dev/null +++ b/tests/test_expr.py @@ -0,0 +1,83 @@ +from rapidquery.dialect import PostgreSQL +from rapidquery.expr import Asterisk, BinaryExpr, Function, Ident, Unquote, Value + + +class TestFunction: + def test_new(self): + fn = Function("now") + assert fn.get_name() == Ident("now") + assert fn.get_distinct() is None + assert fn.get_args() is None + assert fn.get_filter() is None + + fn = Function("example", filter=BinaryExpr(1, ">", 2)) + assert fn.get_name() == Ident("example") + assert fn.get_distinct() is None + assert fn.get_args() is None + assert isinstance(fn.get_filter(), BinaryExpr) + + fn = Function("count", ["id"], distinct=False, filter=BinaryExpr(1, ">", 2)) + assert fn.get_name() == Ident("count") + assert fn.get_distinct() is False + assert fn.get_args() == (Ident("id"),) + assert isinstance(fn.get_filter(), BinaryExpr) + + fn = Function(Unquote("NOW")) + assert fn.get_name() == Ident(Unquote("NOW")) + + fn = Function("schema.fn") + assert fn.get_name() == Ident("schema", "fn") + + def test_to_sql(self): + fn = Function("current_timestamp") + assert fn.to_sql(PostgreSQL) == '"current_timestamp"' + + fn = Function(Unquote("CURRENT_TIMESTAMP")) + assert fn.to_sql(PostgreSQL) == "CURRENT_TIMESTAMP" + + fn = Function(Unquote("NOW"), []) + assert fn.to_sql(PostgreSQL) == "NOW()" + + fn = Function(Unquote("NOW")) + assert fn.to_sql(PostgreSQL) == "NOW" + + fn = Function( + "count", [Asterisk], filter=BinaryExpr("status", "=", Value("completed")) + ) + assert ( + fn.to_sql(PostgreSQL) + == '"count"(*) FILTER (WHERE "status" = E\'completed\')' + ) + + fn = Function( + "count", + ["id"], + distinct=False, + filter=BinaryExpr("status", "=", Value("completed")), + ) + assert ( + fn.to_sql(PostgreSQL) + == '"count"(ALL "id") FILTER (WHERE "status" = E\'completed\')' + ) + + fn = Function( + "count", + ["id"], + distinct=True, + filter=BinaryExpr("status", "=", Value("completed")), + ) + assert ( + fn.to_sql(PostgreSQL) + == '"count"(DISTINCT "id") FILTER (WHERE "status" = E\'completed\')' + ) + + fn = Function( + "now", + [], + distinct=True, + ) + assert fn.to_sql(PostgreSQL) == '"now"()' + + def test_edge_cases(self): + # TODO + pass diff --git a/tests/test_ident.py b/tests/test_ident.py new file mode 100644 index 0000000..7ee67c8 --- /dev/null +++ b/tests/test_ident.py @@ -0,0 +1,161 @@ +import pytest + +from rapidquery import Asterisk, Ident, Unquote +from rapidquery.dialect import MSSQL, MySql, PostgreSQL, SqlDialect, Teradata + + +class TestIdentConstruction: + def test_varargs_parts(self): + ident = Ident("table", "id") + assert len(ident) == 2 + assert ident[0] == "table" + assert ident[1] == "id" + + def test_single_str_is_one_quoted_part_even_with_dots(self): + ident = Ident("schema.table") + + assert len(ident) == 1 + assert ident[0] == "schema.table" + assert ident.to_sql(PostgreSQL) == '"schema.table"' + + def test_rejects_no_arguments(self): + with pytest.raises(ValueError, match="empty"): + _ = Ident() + + def test_rejects_empty_string_part(self): + with pytest.raises(ValueError, match="empty"): + _ = Ident("") + + def test_rejects_non_ident_argument(self): + with pytest.raises(TypeError): + _ = Ident(1) # pyright: ignore + + def test_flattens_nested_ident(self): + ident = Ident(Ident("t"), "id") + + assert ident.to_list() == ["t", "id"] + assert ident.to_sql(PostgreSQL) == '"t"."id"' + + def test_too_many_parts(self): + with pytest.raises(ValueError, match="too many"): + _ = Ident(*[f"p{i}" for i in range(11)]) + + def test_ten_parts_are_allowed(self): + ident = Ident(*[f"p{i}" for i in range(10)]) + assert len(ident) == 10 + + +class TestQuotedStarVersusAsterisk: + def test_str_star_is_quoted_column(self): + ident = Ident("*") + assert len(ident) == 1 + assert ident[0] == "*" + assert ident.to_sql(PostgreSQL) == '"*"' + assert ident.to_sql(MySql) == "`*`" + + def test_asterisk_class_is_wildcard(self): + ident = Ident(Asterisk) + assert ident[0] == Asterisk() + assert ident.to_sql(PostgreSQL) == "*" + + def test_asterisk_instance_is_wildcard(self): + ident = Ident(Asterisk()) + assert ident.to_sql(MySql) == "*" + + def test_qualified_wildcard(self): + ident = Ident("t", Asterisk) + assert ident.to_sql(PostgreSQL) == '"t".*' + assert ident.to_sql(MySql) == "`t`.*" + + def test_asterisk_not_last_is_rejected(self): + with pytest.raises(ValueError, match="asterisk"): + _ = Ident(Asterisk, "id") + + +class TestUnquote: + def test_simple_name(self): + part = Unquote("Users") + assert part.name == "Users" + assert Ident(part).to_sql(PostgreSQL) == "Users" + + def test_mixed_with_quoted_parts(self): + ident = Ident("public", Unquote("Users"), "id") + assert ident.to_sql(PostgreSQL) == '"public".Users."id"' + + def test_equals_by_name(self): + assert Unquote("id") == Unquote("id") + assert Unquote("id") != Unquote("ID") + assert Unquote("id") != "id" + + def test_rejects_injection_and_non_ascii(self): + for bad in [ + "", + "*", + "a;b", + "a.b", + "1id", + "a-b", + "a b", + "cafĆ©", + "a; DROP TABLE t--", + ]: + with pytest.raises(ValueError): + _ = Unquote(bad) + + def test_accepts_underscore_and_digits(self): + assert Unquote("_id").name == "_id" + assert Unquote("created_at").name == "created_at" + assert Unquote("a1").name == "a1" + + +class TestEqualityHashReprFrozen: + def test_equal_when_parts_match(self): + assert Ident("t", "id") == Ident("t", "id") + assert Ident("t", "id") != Ident("t", "name") + + def test_quoted_is_not_unquoted(self): + assert Ident("id") != Ident(Unquote("id")) + assert hash(Ident("id")) != hash(Ident(Unquote("id"))) + + def test_asterisk_instances_are_equal(self): + assert Asterisk() == Asterisk() + assert Ident(Asterisk) == Ident(Asterisk()) + + def test_usable_as_dict_key(self): + mapping = {Ident("t", "id"): 1, Unquote("id"): 2, Asterisk(): 3} + assert mapping[Ident("t", "id")] == 1 + assert mapping[Unquote("id")] == 2 + assert mapping[Asterisk()] == 3 + + def test_repr(self): + assert repr(Ident("schema", "table")) == 'Ident("schema", "table")' + assert repr(Ident("t", Asterisk)) == 'Ident("t", Asterisk)' + assert repr(Ident(Unquote("Users"))) == 'Ident(Unquote("Users"))' + assert repr(Unquote("Users")) == 'Unquote("Users")' + assert repr(Asterisk()) == "Asterisk()" + + +class TestRender: + def test_class_and_instance_dialect_agree(self): + ident = Ident("id") + assert ident.to_sql(PostgreSQL) == ident.to_sql(PostgreSQL()) + assert ident.to_sql(MySql) == ident.to_sql(MySql()) + + def test_render_succeeds_on_every_dialect(self, dialect_cls: type[SqlDialect]): + ident = Ident("users", "id") + sql = ident.to_sql(dialect_cls) + assert "users" in sql + assert "id" in sql + assert "." in sql + + def test_delimiter_escaping(self): + assert Ident('a"b').to_sql(PostgreSQL) == '"a""b"' + assert Ident("a`b").to_sql(MySql) == "`a``b`" + assert Ident("a]b").to_sql(MSSQL) == "[a]]b]" + assert Ident("a'b").to_sql(Teradata) == "'a''b'" + + def test_quoted_injection_stays_inside_delimiters(self): + payload = "a; DROP TABLE t--" + sql = Ident(payload).to_sql(PostgreSQL) + assert sql == f'"{payload}"' + assert sql.startswith('"') and sql.endswith('"') diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 884ed38..c74a58c 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -7,7 +7,6 @@ from decimal import Decimal import pytest -from rapidquery._core import _feature_only_stdlib_json from rapidquery.data_type import ( ArraySerializer, @@ -45,7 +44,7 @@ Teradata, ) -from .conftest import ALL_DIALECTS +from .conftest import ALL_DIALECTS, USING_ORJSON # Quoted-literal wrap groups (payloads with no quotes/backslashes). _PG = (PostgreSQL,) @@ -96,13 +95,6 @@ _INET_NO = tuple(d for d in ALL_DIALECTS if d not in (DuckDB, PostgreSQL)) _MAC_NO = tuple(d for d in ALL_DIALECTS if d is not PostgreSQL) -try: - import orjson # ruff: ignore[F401] - - USING_ORJSON = not _feature_only_stdlib_json -except ImportError: - USING_ORJSON = False - class ValidStrEnum(enum.StrEnum): HelloWorld = "Hello World" diff --git a/tests/test_value.py b/tests/test_value.py new file mode 100644 index 0000000..ea5495c --- /dev/null +++ b/tests/test_value.py @@ -0,0 +1,153 @@ +import datetime +import decimal +import enum +import uuid + +import pytest + +from rapidquery.data_type import ( + ArraySerializer, + BitSerializer, + BooleanSerializer, + BytesSerializer, + DataType, + DateSerializer, + DatetimeSerializer, + FloatSerializer, + InetSerializer, + IntegerSerializer, + JSONSerializer, + MacAddressSerializer, + Serializer, + StringSerializer, + TimeSerializer, + UnspecifiedSerializer, + UuidSerializer, +) +from rapidquery.dialect import PostgreSQL +from rapidquery.expr import Expr, Value +from tests.conftest import USING_ORJSON + + +class ValidEnum(enum.StrEnum): + TEST = "test" + + +class InvalidEnum(enum.Enum): + TEST = 1 + + +INFER_TEST_CASES: dict[type[Serializer], list[object]] = { + IntegerSerializer: [1, 18_446_744_073_709_551_615, -1], + FloatSerializer: [1.0, decimal.Decimal("1.0"), decimal.Decimal(1)], + BooleanSerializer: [True, False], + StringSerializer: ["string", ValidEnum.TEST], + UuidSerializer: [uuid.uuid4()], + BytesSerializer: [b"bytes"], + BitSerializer: [], + JSONSerializer: [ + list(), # ruff: ignore + dict(), # ruff: ignore + {"OK": 1}, + ], + DateSerializer: [datetime.datetime.now(tz=datetime.UTC).date()], + TimeSerializer: [datetime.datetime.now(tz=datetime.UTC).time()], + DatetimeSerializer: [datetime.datetime.now(tz=datetime.UTC)], + InetSerializer: [], + MacAddressSerializer: [], + UnspecifiedSerializer: [datetime.timezone.utc, InvalidEnum.TEST], + ArraySerializer: [], +} + +if USING_ORJSON: + INFER_TEST_CASES[JSONSerializer].append({"OK": datetime.datetime.now()}) # ruff: ignore + INFER_TEST_CASES[JSONSerializer].append({"OK": uuid.uuid4()}) + + +class TestValueConstruction: + def test_new_overload_1(self): + val = Value(1) + assert isinstance(val, Expr) + assert val.get_object() == 1 + assert isinstance(val.get_serializer(), IntegerSerializer) + + def test_new_overload_2(self): + val = Value(1, InetSerializer()) + assert isinstance(val, Expr) + assert val.get_object() == 1 + assert isinstance(val.get_serializer(), InetSerializer) + + @pytest.mark.parametrize( + "serializer_cls, value", + [ + pytest.param(cls, value, id=f"{cls.__name__}-{value!r}") + for cls, values in INFER_TEST_CASES.items() + for value in values + ], + ) + def test_infers(self, serializer_cls: type[Serializer], value: object): + val = Value(value) + assert val.get_object() == value + assert isinstance(val.get_serializer(), serializer_cls) + + def test_wrap_inner(self): + inner = Value(object(), UnspecifiedSerializer()) + outer = Value(inner) + + assert inner.get_object() is outer.get_object() + assert inner.get_serializer() == outer.get_serializer() + + def test_wrap_trick_to_change_serializer(self): + inner = Value(object(), InetSerializer()) + outer = Value(inner, UnspecifiedSerializer()) + + assert inner.get_object() is outer.get_object() + assert isinstance(inner.get_serializer(), InetSerializer) + assert isinstance(outer.get_serializer(), UnspecifiedSerializer) + + def test_data_type_serializer_override(self): + value = Value("x", DataType("integer", StringSerializer())) + assert isinstance(value.get_serializer(), StringSerializer) + + +class TestValueToSql: + def test_integer_literal(self): + assert Value(42).to_sql(PostgreSQL) == "42" + + def test_string_literal_matches_string_serializer(self): + assert Value("hi").to_sql(PostgreSQL) == StringSerializer().serialize( + PostgreSQL, "hi" + ) + + def test_none_is_null(self): + assert Value(None).to_sql(PostgreSQL) == "NULL" + + def test_accepts_dialect_class_or_instance(self): + value = Value(1) + assert value.to_sql(PostgreSQL) == value.to_sql(PostgreSQL()) + + def test_propagates_serializer_type_error(self): + with pytest.raises(TypeError): + Value("not an int", IntegerSerializer()).to_sql(PostgreSQL) + + +class TestValueEquality: + def test_equal_when_object_and_serializer_match(self): + assert Value(1) == Value(1) + assert hash(Value(1)) == hash(Value(1)) + + def test_unequal_when_serializer_differs(self): + assert Value(1) != Value(1, StringSerializer()) + + def test_unequal_when_object_differs(self): + assert Value(1) != Value(2) + + def test_not_equal_to_bare_object(self): + assert Value(1) != 1 + + def test_unhashable_object_raises(self): + with pytest.raises(TypeError): + hash(Value({"a": 1})) + + def test_repr_includes_object_and_serializer(self): + assert repr(Value(42)) == "Value(42, IntegerSerializer())" From b6883077d0e64245f92efca09ff28b3c58009b7b Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Sun, 20 Sep 2026 21:31:37 +0330 Subject: [PATCH 36/87] Complete expr tests and use literal-priority extraction Bare strings in expression constructors are values, not columns. Cover Function through CustomExpr (constructors, SQL, prepare, errors). --- rapidquery/expr.pyi | 105 +++++--- src/expr/enum_/mod.rs | 2 + src/expr/extractors.rs | 14 - src/expr/func.rs | 6 +- src/expr/nodes.rs | 45 ++-- src/lib.rs | 15 ++ tests/test_expr.py | 564 ++++++++++++++++++++++++++++++++++++++++- 7 files changed, 666 insertions(+), 85 deletions(-) diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index 5c69a63..9fb2ea3 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -233,8 +233,7 @@ class Function(Expr): Args: name: Function name (``str``, ``Ident``, or ``Unquote``). - args: Positional argument expressions. A bare ``str`` is a - column. `None` means no parentheses (``CURRENT_TIMESTAMP``). + args: Positional argument expressions. `None` means no parentheses (``CURRENT_TIMESTAMP``). distinct: ``True`` inserts ``DISTINCT``, ``False`` inserts ``ALL``, omitted inserts neither. filter: Optional ``FILTER (WHERE …)`` predicate. @@ -282,7 +281,7 @@ class TupleExpr(Expr): Create a tuple expression. Args: - *items: Elements of the tuple. A bare ``str`` is a column. + *items: Elements of the tuple. Returns: A new ``TupleExpr``. @@ -300,13 +299,15 @@ class TupleExpr(Expr): class UnaryExpr(Expr): """Unary expression (``NOT x``, ``-x``, ``+x``, ``~x``).""" - def __new__(cls, operator: str, expr: object, /) -> Self: + def __new__( + cls, operator: typing.Literal["NOT", "+", "-", "~"] | str, expr: object, / + ) -> Self: """ Create a unary expression. Args: operator: One of ``NOT``, ``+``, ``-``, or ``~``. - expr: The operand. A bare ``str`` is a column. + expr: The operand. Returns: A new ``UnaryExpr``. @@ -315,7 +316,7 @@ class UnaryExpr(Expr): ValueError: If ``operator`` is not a known unary operator. """ - def get_operator(self) -> str: + def get_operator(self) -> typing.Literal["NOT", "+", "-", "~"]: """ Return the unary operator spelling. @@ -336,20 +337,48 @@ class BinaryExpr(Expr): """ Binary expression (``a + b``, ``a AND b``, custom operators). - A bare ``str`` on either side is a column. Pass ``Value(...)`` for a - string literal. Unknown operator spellings are stored as a custom + Unknown operator spellings are stored as a custom operator (for example ``->``). """ - def __new__(cls, left: object, operator: str, right: object, /) -> Self: + def __new__( + cls, + left: object, + operator: typing.Literal[ + "+", + "-", + "*", + "/", + "%", + "=", + "<>", + "!=", + "<", + "<=", + ">=", + ">", + "AND", + "OR", + "XOR", + "&", + "|", + "^", + "<<", + ">>", + "||", + ] + | str, + right: object, + /, + ) -> Self: """ Create a binary expression. Args: - left: Left operand. A bare ``str`` is a column. + left: Left operand. operator: SQL operator spelling (``+``, ``AND``, ``=``, ``->``, …). ``!=`` is stored as ``<>``. - right: Right operand. A bare ``str`` is a column. + right: Right operand. Returns: A new ``BinaryExpr``. @@ -384,12 +413,19 @@ class BinaryExpr(Expr): class IsExpr(Expr): """``expr IS [NOT] {NULL | TRUE | FALSE | UNKNOWN}``.""" - def __new__(cls, expr: object, kind: str, /, *, negated: bool = False) -> Self: + def __new__( + cls, + expr: object, + kind: typing.Literal["NULL", "TRUE", "FALSE", "UNKNOWN"] | str, + /, + *, + negated: bool = False, + ) -> Self: """ Create an ``IS`` predicate. Args: - expr: The tested expression. A bare ``str`` is a column. + expr: The tested expression. kind: ``NULL``, ``TRUE``, ``FALSE``, or ``UNKNOWN``. negated: If ``True``, emit ``IS NOT``. @@ -408,7 +444,7 @@ class IsExpr(Expr): The left-hand expression. """ - def get_kind(self) -> str: + def get_kind(self) -> typing.Literal["NULL", "TRUE", "FALSE", "UNKNOWN"]: """ Return the ``IS`` kind. @@ -433,8 +469,8 @@ class IsDistinctFromExpr(Expr): Create an ``IS DISTINCT FROM`` predicate. Args: - left: Left operand. A bare ``str`` is a column. - right: Right operand. A bare ``str`` is a column. + left: Left operand. + right: Right operand. negated: If ``True``, emit ``IS NOT DISTINCT FROM``. Returns: @@ -476,9 +512,8 @@ class InListExpr(Expr): Create an ``IN`` list predicate. Args: - expr: The tested expression. A bare ``str`` is a column. - list: Iterable of list elements. Each bare ``str`` is a - column. + expr: The tested expression. + list: Iterable of list elements. negated: If ``True``, emit ``NOT IN``. Returns: @@ -526,9 +561,9 @@ class BetweenExpr(Expr): Create a ``BETWEEN`` predicate. Args: - expr: The tested expression. A bare ``str`` is a column. - low: Lower bound. A bare ``str`` is a column. - high: Upper bound. A bare ``str`` is a column. + expr: The tested expression. + low: Lower bound. + high: Upper bound. negated: If ``True``, emit ``NOT BETWEEN``. Returns: @@ -569,11 +604,7 @@ class BetweenExpr(Expr): @final class LikeExpr(Expr): - """``expr [NOT] LIKE / ILIKE / SIMILAR TO / RLIKE pattern``. - - The pattern uses literal-priority extraction: a bare ``str`` is a - string value, not a column. - """ + """``expr [NOT] LIKE / ILIKE / SIMILAR TO / RLIKE pattern``.""" def __new__( cls, @@ -581,7 +612,7 @@ class LikeExpr(Expr): pattern: object, /, *, - kind: str = "LIKE", + kind: typing.Literal["LIKE", "ILIKE", "SIMILAR TO", "RLIKE"] | str = "LIKE", negated: bool = False, escape: str | None = None, ) -> Self: @@ -589,8 +620,8 @@ class LikeExpr(Expr): Create a ``LIKE``-family predicate. Args: - expr: The tested expression. A bare ``str`` is a column. - pattern: The pattern. A bare ``str`` is a string literal. + expr: The tested expression. + pattern: The pattern. kind: ``LIKE``, ``ILIKE``, ``SIMILAR TO``, or ``RLIKE``. negated: If ``True``, emit ``NOT LIKE`` (or the matching negated form). @@ -619,7 +650,7 @@ class LikeExpr(Expr): Usually a ``Value`` when constructed from a string. """ - def get_kind(self) -> str: + def get_kind(self) -> typing.Literal["LIKE", "ILIKE", "SIMILAR TO", "RLIKE"]: """ Return the operator kind. @@ -652,7 +683,7 @@ class CastExpr(Expr): Create a ``CAST`` expression. Args: - expr: The expression to cast. A bare ``str`` is a column. + expr: The expression to cast. to: SQL type name (``INT``, ``VARCHAR(255)``, …). Known names are canonicalized the same way as ``DataType``. @@ -693,8 +724,7 @@ class CaseExpr(Expr): Create an empty ``CASE`` expression. Args: - operand: Optional simple-``CASE`` operand. A bare ``str`` - is a column. Omit for a searched ``CASE``. + operand: Optional simple-``CASE``. Omit for a searched ``CASE``. Returns: A new ``CaseExpr`` with no ``WHEN`` clauses. @@ -706,7 +736,7 @@ class CaseExpr(Expr): Args: when: A condition expression (not ``Ident`` or ``Value``). - then: Result expression. A bare ``str`` is a column. + then: Result expression. Returns: A new ``CaseExpr`` with the clause appended. @@ -720,7 +750,7 @@ class CaseExpr(Expr): Set the ``ELSE`` result. Args: - result: The else expression. A bare ``str`` is a column. + result: The else expression. Returns: A new ``CaseExpr`` with ``ELSE`` set. @@ -769,8 +799,7 @@ class CustomExpr(Expr): Args: sql: Template string. Each ``{}`` is replaced by one argument. ``{{`` and ``}}`` emit ``{`` and ``}``. - *args: Expressions interpolated at ``{}`` slots. A bare - ``str`` is a column. + *args: Expressions interpolated at ``{}`` slots. Returns: A new ``CustomExpr``. diff --git a/src/expr/enum_/mod.rs b/src/expr/enum_/mod.rs index 0b8cef4..c2ba914 100644 --- a/src/expr/enum_/mod.rs +++ b/src/expr/enum_/mod.rs @@ -59,6 +59,8 @@ pub struct Like { pub kind: LikeKind, pub negated: bool, pub expr: Box, + + // TODO: may it's better to keep `Arcstr | list of Arcstr` pub pattern: Box, pub escape: Option, } diff --git a/src/expr/extractors.rs b/src/expr/extractors.rs index d8fb2f4..5e70e50 100644 --- a/src/expr/extractors.rs +++ b/src/expr/extractors.rs @@ -16,9 +16,6 @@ unsafe fn is_asterisk(obj: *mut pyo3::ffi::PyObject) -> bool { type_ptr == crate::typeref::ASTERISK_TYPE } -/// Ident-priority extractor: a bare `str` becomes [`ExprEnum::Ident`]. -pub struct ExprExtractor(pub ExprEnum); - /// Literal-priority extractor: a bare `str` becomes [`ExprEnum::Value`]. pub struct LiteralExprExtractor(pub ExprEnum); @@ -141,17 +138,6 @@ pub(super) fn extract_ident_argument( ) } -impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for ExprExtractor { - type Error = pyo3::PyErr; - - #[inline] - fn extract( - obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, - ) -> Result { - extract_expr(obj, true).map(Self) - } -} - impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for LiteralExprExtractor { type Error = pyo3::PyErr; diff --git a/src/expr/func.rs b/src/expr/func.rs index 07d791c..261719c 100644 --- a/src/expr/func.rs +++ b/src/expr/func.rs @@ -2,7 +2,7 @@ use crate::internal::{alias, utils}; use super::base::PyExpr; use super::enum_::{DuplicateTreatment, FunctionArguments}; -use super::extractors::{ConditionExtractor, ExprExtractor, IdentExtractor}; +use super::extractors::{ConditionExtractor, IdentExtractor, LiteralExprExtractor}; /// SQL function call. /// @@ -45,7 +45,7 @@ impl PyFunction { #[pyo3(signature = (name, args = None, *, distinct = None, filter = None))] fn __new__( name: IdentExtractor, - args: Option>, + args: Option>, distinct: Option, filter: Option, ) -> pyo3::PyResult> { @@ -57,7 +57,7 @@ impl PyFunction { Some(false) => Some(DuplicateTreatment::All), None => None, }, - args: x.into_iter().map(|ExprExtractor(x)| x).collect(), + args: x.into_iter().map(|LiteralExprExtractor(x)| x).collect(), }, }; diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index 4476ec3..33257a5 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -2,7 +2,7 @@ use pyo3::types::{PyAnyMethods, PyTupleMethods}; use super::base::PyExpr; use super::enum_::ExprEnum; -use super::extractors::{ConditionExtractor, ExprExtractor, LiteralExprExtractor}; +use super::extractors::{ConditionExtractor, LiteralExprExtractor}; use crate::data_type::DataTypeName; use crate::internal::{alias, utils}; @@ -88,7 +88,7 @@ impl PyTupleExpr { let mut exprs = Vec::with_capacity(items.len()); for item in items.iter() { - exprs.push(item.extract::()?.0); + exprs.push(item.extract::()?.0); } Ok(pyo3::PyClassInitializer::from(PyExpr(ExprEnum::Tuple(exprs))).add_subclass(Self)) @@ -114,7 +114,7 @@ impl PyUnaryExpr { #[pyo3(signature = (operator, expr, /))] fn __new__( operator: &str, - expr: ExprExtractor, + expr: LiteralExprExtractor, ) -> pyo3::PyResult> { let Some(op) = super::enum_::UnaryOp::parse(operator) else { return rapidquery_macros::pyerr!( @@ -147,9 +147,9 @@ impl PyBinaryExpr { #[new] #[pyo3(signature = (left, operator, right, /))] fn __new__( - left: ExprExtractor, + left: LiteralExprExtractor, operator: &str, - right: ExprExtractor, + right: LiteralExprExtractor, ) -> pyo3::PyClassInitializer { let inner = super::enum_::Binary { left: Box::new(left.0), @@ -180,7 +180,7 @@ impl PyIsExpr { #[new] #[pyo3(signature = (expr, kind, /, *, negated = false))] fn __new__( - expr: ExprExtractor, + expr: LiteralExprExtractor, kind: &str, negated: bool, ) -> pyo3::PyResult> { @@ -221,8 +221,8 @@ impl PyIsDistinctFromExpr { #[new] #[pyo3(signature = (left, right, /, *, negated = false))] fn __new__( - left: ExprExtractor, - right: ExprExtractor, + left: LiteralExprExtractor, + right: LiteralExprExtractor, negated: bool, ) -> pyo3::PyClassInitializer { let inner = super::enum_::IsDistinctFrom { @@ -254,13 +254,13 @@ impl PyInListExpr { #[new] #[pyo3(signature = (expr, list, /, *, negated = false))] fn __new__( - expr: ExprExtractor, + expr: LiteralExprExtractor, list: alias::BoundObject<'_>, negated: bool, ) -> pyo3::PyResult> { let mut items = Vec::new(); for item in list.try_iter()? { - items.push(item?.extract::()?.0); + items.push(item?.extract::()?.0); } let inner = super::enum_::InList { @@ -310,9 +310,9 @@ impl PyBetweenExpr { #[new] #[pyo3(signature = (expr, low, high, /, *, negated = false))] fn __new__( - expr: ExprExtractor, - low: ExprExtractor, - high: ExprExtractor, + expr: LiteralExprExtractor, + low: LiteralExprExtractor, + high: LiteralExprExtractor, negated: bool, ) -> pyo3::PyClassInitializer { let inner = super::enum_::Between { @@ -350,7 +350,7 @@ impl PyLikeExpr { #[new] #[pyo3(signature = (expr, pattern, /, *, kind = "LIKE", negated = false, escape = None))] fn __new__( - expr: ExprExtractor, + expr: LiteralExprExtractor, pattern: LiteralExprExtractor, kind: &str, negated: bool, @@ -402,7 +402,7 @@ impl PyLikeExpr { impl PyCastExpr { #[new] #[pyo3(signature = (expr, to, /))] - fn __new__(expr: ExprExtractor, to: String) -> pyo3::PyClassInitializer { + fn __new__(expr: LiteralExprExtractor, to: String) -> pyo3::PyClassInitializer { let data_type_name = DataTypeName::from(to); let inner = super::enum_::Cast { expr: Box::new(expr.0), @@ -425,9 +425,11 @@ impl PyCastExpr { impl PyCaseExpr { #[new] #[pyo3(signature = (operand = None, /))] - fn __new__(operand: Option) -> pyo3::PyResult> { + fn __new__( + operand: Option, + ) -> pyo3::PyResult> { let inner = super::enum_::Case { - operand: operand.map(|ExprExtractor(e)| Box::new(e)), + operand: operand.map(|LiteralExprExtractor(e)| Box::new(e)), when: vec![], else_result: None, }; @@ -437,7 +439,7 @@ impl PyCaseExpr { fn when( slf: pyo3::PyRef<'_, Self>, when: ConditionExtractor, - then: ExprExtractor, + then: LiteralExprExtractor, ) -> pyo3::PyResult { let mut inner = Self::inner(&slf).clone(); inner.when.push((when.0, then.0)); @@ -449,7 +451,10 @@ impl PyCaseExpr { .map(pyo3::Py::into_any) } - fn else_(slf: pyo3::PyRef<'_, Self>, result: ExprExtractor) -> pyo3::PyResult { + fn else_( + slf: pyo3::PyRef<'_, Self>, + result: LiteralExprExtractor, + ) -> pyo3::PyResult { let mut inner = Self::inner(&slf).clone(); inner.else_result = Some(Box::new(result.0)); @@ -510,7 +515,7 @@ impl PyCustomExpr { let mut extracted = Vec::with_capacity(args.len()); for item in args.iter() { - extracted.push(item.extract::()?.0); + extracted.push(item.extract::()?.0); } let inner = super::enum_::Custom { diff --git a/src/lib.rs b/src/lib.rs index 971f6ff..6e0ef06 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,21 @@ pub mod internal; pub mod sql; pub mod typeref; +// Tasks until being stable (until this part) +// - Support `any` in LikeExpr +// - Pattern in LikeExpr should not parameterized +// - Support `null_treatment` in Function +// - Support `AtDashAt`, `DoubleAt`, `Hash`, `PGAbs`, `PGCubeRoot`, `PGPostfixFactorial`, +// `PGPrefixFactorial`, `PGSquareRoot`, `QuestionDash`, and `QuestionPipe` in UnaryExpr +// - Support IsNormalizedExpr +// - Support `TryCast`, `SafeCast`, and `DoubleColon` kinds in CastExpr +// - Support `array` and `format` in CastExpr +// - Support TrimExpr +// - Support CollateExpr +// - Raise error when something is not supported by a dialect +// - Update docstrings +// - Test and coverage everything and edge cases + /// RapidQuery native extension. #[pyo3::pymodule(gil_used = false)] mod _core { diff --git a/tests/test_expr.py b/tests/test_expr.py index 26d285c..ac749dc 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -1,5 +1,62 @@ -from rapidquery.dialect import PostgreSQL -from rapidquery.expr import Asterisk, BinaryExpr, Function, Ident, Unquote, Value +import pytest + +from rapidquery.dialect import PostgreSQL, SqlDialect +from rapidquery.expr import ( + Asterisk, + BetweenExpr, + BinaryExpr, + CaseExpr, + CastExpr, + CustomExpr, + Expr, + Function, + Ident, + InListExpr, + IsDistinctFromExpr, + IsExpr, + LikeExpr, + TupleExpr, + UnaryExpr, + Unquote, + Value, +) + + +class TestExpr: + def test_cannot_instantiate_base(self): + with pytest.raises(TypeError): + Expr() # pyright: ignore + + def test_nodes_are_expr_subclasses(self): + nodes = [ + Ident("id"), + Value(1), + Function("now"), + TupleExpr(1), + UnaryExpr("-", 1), + BinaryExpr(1, "+", 2), + IsExpr(Ident("id"), "NULL"), + IsDistinctFromExpr(1, 2), + InListExpr(Ident("id"), [1]), + BetweenExpr(Ident("id"), 0, 1), + LikeExpr(Ident("name"), "%a%"), + CastExpr(Ident("id"), "int"), + CaseExpr(), + CustomExpr("1"), + ] + for node in nodes: + assert isinstance(node, Expr) + + def test_to_sql_and_prepare_accept_class_or_instance(self): + expr = BinaryExpr(Ident("id"), ">", 10) + assert expr.to_sql(PostgreSQL) == expr.to_sql(PostgreSQL()) + assert expr.prepare(PostgreSQL)[0] == expr.prepare(PostgreSQL())[0] + + def test_render_succeeds_on_every_dialect(self, dialect_cls: type[SqlDialect]): + expr = BinaryExpr(Ident("id"), ">", 10) + sql = expr.to_sql(dialect_cls) + assert "id" in sql + assert "10" in sql class TestFunction: @@ -16,7 +73,9 @@ def test_new(self): assert fn.get_args() is None assert isinstance(fn.get_filter(), BinaryExpr) - fn = Function("count", ["id"], distinct=False, filter=BinaryExpr(1, ">", 2)) + fn = Function( + "count", [Ident("id")], distinct=False, filter=BinaryExpr(1, ">", 2) + ) assert fn.get_name() == Ident("count") assert fn.get_distinct() is False assert fn.get_args() == (Ident("id"),) @@ -42,7 +101,7 @@ def test_to_sql(self): assert fn.to_sql(PostgreSQL) == "NOW" fn = Function( - "count", [Asterisk], filter=BinaryExpr("status", "=", Value("completed")) + "count", [Asterisk], filter=BinaryExpr(Ident("status"), "=", "completed") ) assert ( fn.to_sql(PostgreSQL) @@ -51,9 +110,9 @@ def test_to_sql(self): fn = Function( "count", - ["id"], + [Ident("id")], distinct=False, - filter=BinaryExpr("status", "=", Value("completed")), + filter=BinaryExpr(Ident("status"), "=", "completed"), ) assert ( fn.to_sql(PostgreSQL) @@ -62,9 +121,9 @@ def test_to_sql(self): fn = Function( "count", - ["id"], + [Ident("id")], distinct=True, - filter=BinaryExpr("status", "=", Value("completed")), + filter=BinaryExpr(Ident("status"), "=", "completed"), ) assert ( fn.to_sql(PostgreSQL) @@ -79,5 +138,490 @@ def test_to_sql(self): assert fn.to_sql(PostgreSQL) == '"now"()' def test_edge_cases(self): - # TODO - pass + with pytest.raises(TypeError): + Function(1) # pyright: ignore + + with pytest.raises(ValueError, match="empty"): + Function("") + + with pytest.raises(TypeError, match="condition"): + Function("now", filter=Ident("x")) # pyright: ignore + + with pytest.raises(TypeError, match="condition"): + Function("now", filter=1) # pyright: ignore + + fn = Function("now", distinct=True) + assert fn.get_distinct() is None + assert fn.get_args() is None + assert fn.to_sql(PostgreSQL) == '"now"' + + fn = Function("f", ["id"]) + assert fn.get_args() == (Value("id"),) + assert fn.to_sql(PostgreSQL) == '"f"(E\'id\')' + + fn = Function(Ident("schema.fn"), []) + assert fn.get_name() == Ident("schema.fn") + assert fn.to_sql(PostgreSQL) == '"schema.fn"()' + + fn = Function( + "count", + [1], + filter=BinaryExpr(Ident("x"), ">", 2), + ) + sql, params = fn.prepare(PostgreSQL) + assert sql == '"count"(:param_0) FILTER (WHERE "x" > :param_1)' + assert params == {"param_0": 1, "param_1": 2} + + +class TestTupleExpr: + def test_new(self): + tpl = TupleExpr(1, 2, "A") + assert tpl.get_items() == (Value(1), Value(2), Value("A")) + + tpl = TupleExpr(1, 2, Ident("A")) + assert tpl.get_items() == (Value(1), Value(2), Ident("A")) + + empty = TupleExpr() + assert empty.get_items() == () + + nested = TupleExpr((1, 2), Ident("A")) + assert isinstance(nested.get_items()[0], TupleExpr) + assert nested.get_items()[0].get_items() == (Value(1), Value(2)) + assert nested.get_items()[1] == Ident("A") + + def test_to_sql(self): + tpl = TupleExpr(1, 2, "A") + assert tpl.to_sql(PostgreSQL) == "(1, 2, E'A')" + + sql, params = tpl.prepare(PostgreSQL) + assert sql == "(:param_0, :param_1, :param_2)" + assert params == {"param_0": 1, "param_1": 2, "param_2": "A"} + + assert TupleExpr().to_sql(PostgreSQL) == "()" + assert TupleExpr(1).to_sql(PostgreSQL) == "(1)" + assert TupleExpr((1, 2), Ident("A")).to_sql(PostgreSQL) == '((1, 2), "A")' + + +class TestUnaryExpr: + def test_new(self): + un = UnaryExpr("NOT", 1) + assert un.get_expr() == Value(1) + assert un.get_operator() == "NOT" + + un = UnaryExpr("Not", 1) + assert un.get_operator() == "NOT" + + un = UnaryExpr("-", 1) + assert un.get_operator() == "-" + + un = UnaryExpr("+", Ident("id")) + assert un.get_operator() == "+" + assert un.get_expr() == Ident("id") + + un = UnaryExpr("~", 1) + assert un.get_operator() == "~" + + with pytest.raises(ValueError, match="unknown unary operator"): + UnaryExpr("!", 1) + + def test_to_sql(self): + un = UnaryExpr("-", Function("now", [])) + assert un.to_sql(PostgreSQL) == '-"now"()' + + assert UnaryExpr("NOT", 1).to_sql(PostgreSQL) == "NOT 1" + assert UnaryExpr("NOT", Ident("id")).to_sql(PostgreSQL) == 'NOT "id"' + assert UnaryExpr("+", 1).to_sql(PostgreSQL) == "+1" + assert UnaryExpr("~", Ident("id")).to_sql(PostgreSQL) == '~"id"' + assert UnaryExpr("-", UnaryExpr("-", 1)).to_sql(PostgreSQL) == "-(-1)" + assert UnaryExpr("NOT", UnaryExpr("NOT", 1)).to_sql(PostgreSQL) == "NOT (NOT 1)" + assert UnaryExpr("-", BinaryExpr(1, "+", 2)).to_sql(PostgreSQL) == "-(1 + 2)" + + sql, params = UnaryExpr("-", 1).prepare(PostgreSQL) + assert sql == "-:param_0" + assert params == {"param_0": 1} + + +class TestBinaryExpr: + def test_new(self): + bin = BinaryExpr(Ident("id"), ">", 10) + assert bin.get_left() == Ident("id") + assert bin.get_operator() == ">" + assert bin.get_right() == Value(10) + + bin = BinaryExpr("str", "-custom-", 10) + assert bin.get_left() == Value("str") + assert bin.get_operator() == "-custom-" + assert bin.get_right() == Value(10) + + bin = BinaryExpr(1, "!=", 2) + assert bin.get_operator() == "<>" + + bin = BinaryExpr(1, "and", 2) + assert bin.get_operator() == "AND" + bin = BinaryExpr(1, "Or", 2) + assert bin.get_operator() == "OR" + bin = BinaryExpr(1, "xor", 2) + assert bin.get_operator() == "XOR" + + def test_to_sql(self): + bin = BinaryExpr(Ident("id"), ">", 10) + assert bin.to_sql(PostgreSQL) == '"id" > 10' + + bin = BinaryExpr("str", "-custom-", 10) + assert bin.to_sql(PostgreSQL) == "E'str' -custom- 10" + + bin = BinaryExpr(Ident("id"), ">", BinaryExpr(456, "&", 10)) + assert bin.to_sql(PostgreSQL) == '"id" > 456 & 10' + + bin = BinaryExpr(Ident("id"), ">", BinaryExpr(456, "CusTom", 10)) + assert bin.to_sql(PostgreSQL) == '"id" > (456 CusTom 10)' + + bin = BinaryExpr(Ident("id"), ">", BinaryExpr(456, "<", 10)) + assert bin.to_sql(PostgreSQL) == '"id" > (456 < 10)' + + assert BinaryExpr(1, "!=", 2).to_sql(PostgreSQL) == "1 <> 2" + assert BinaryExpr(1, "AND", 2).to_sql(PostgreSQL) == "1 AND 2" + assert ( + BinaryExpr(BinaryExpr(1, "+", 2), "AND", 3).to_sql(PostgreSQL) == "1 + 2 AND 3" + ) + assert ( + BinaryExpr(1, "+", BinaryExpr(2, "AND", 3)).to_sql(PostgreSQL) + == "1 + (2 AND 3)" + ) + + sql, params = BinaryExpr(Ident("id"), ">", 10).prepare(PostgreSQL) + assert sql == '"id" > :param_0' + assert params == {"param_0": 10} + + def test_repr(self): + assert ( + repr(BinaryExpr(Ident("id"), ">", 10)) + == 'BinaryExpr(Ident("id"), ">", Value(10, IntegerSerializer()))' + ) + + +class TestIsExpr: + def test_new(self): + is_ = IsExpr(Ident("id"), "null") + assert is_.get_expr() == Ident("id") + assert is_.get_kind() == "NULL" + assert is_.get_negated() is False + + is_ = IsExpr(None, "UNKNOWN", negated=True) + assert is_.get_expr() == Value(None) + assert is_.get_kind() == "UNKNOWN" + assert is_.get_negated() is True + + with pytest.raises(ValueError, match="unknown IS kind"): + IsExpr(Ident("id"), "MAYBE") + + def test_to_sql(self): + is_ = IsExpr(Ident("id"), "NUll") + assert is_.to_sql(PostgreSQL) == '"id" IS NULL' + + is_ = IsExpr(Ident("id"), "FalSE") + assert is_.to_sql(PostgreSQL) == '"id" IS FALSE' + + is_ = IsExpr(Ident("id"), "tRuE") + assert is_.to_sql(PostgreSQL) == '"id" IS TRUE' + + is_ = IsExpr(Ident("id"), "unknown", negated=True) + assert is_.to_sql(PostgreSQL) == '"id" IS NOT UNKNOWN' + + sql, params = IsExpr(Ident("id"), "NULL").prepare(PostgreSQL) + assert sql == '"id" IS NULL' + assert params == {} + + sql, params = IsExpr(1, "TRUE").prepare(PostgreSQL) + assert sql == ":param_0 IS TRUE" + assert params == {"param_0": 1} + + def test_repr(self): + assert repr(IsExpr(Ident("id"), "NULL")) == 'IsExpr(Ident("id"), "NULL")' + assert ( + repr(IsExpr(Ident("id"), "NULL", negated=True)) + == 'IsExpr(Ident("id"), "NULL", negated=True)' + ) + + +class TestIsDistinctFromExpr: + def test_new(self): + idf = IsDistinctFromExpr(2, 3) + assert idf.get_left() == Value(2) + assert idf.get_right() == Value(3) + assert idf.get_negated() is False + + idf = IsDistinctFromExpr("A", "B") + assert idf.get_left() == Value("A") + assert idf.get_right() == Value("B") + assert idf.get_negated() is False + + idf = IsDistinctFromExpr(Ident("id"), 10, negated=True) + assert idf.get_left() == Ident("id") + assert idf.get_right() == Value(10) + assert idf.get_negated() is True + + idf = IsDistinctFromExpr(Ident("a"), Ident("b")) + assert idf.get_left() == Ident("a") + assert idf.get_right() == Ident("b") + + def test_to_sql(self): + idf = IsDistinctFromExpr(2, 3) + assert idf.to_sql(PostgreSQL) == "2 IS DISTINCT FROM 3" + + idf = IsDistinctFromExpr(Ident("id"), 10, negated=True) + assert idf.to_sql(PostgreSQL) == '"id" IS NOT DISTINCT FROM 10' + + sql, params = idf.prepare(PostgreSQL) + assert sql == '"id" IS NOT DISTINCT FROM :param_0' + assert params == {"param_0": 10} + + assert ( + IsDistinctFromExpr(Ident("a"), Ident("b")).to_sql(PostgreSQL) + == '"a" IS DISTINCT FROM "b"' + ) + + +class TestInListExpr: + def test_new(self): + lst = InListExpr(Ident("id"), [1, 2, 3]) + assert lst.get_expr() == Ident("id") + assert lst.get_list() == (Value(1), Value(2), Value(3)) + assert lst.get_negated() is False + + lst = InListExpr("A", [1, Ident("id"), 3], negated=True) + assert lst.get_expr() == Value("A") + assert lst.get_list() == (Value(1), Ident("id"), Value(3)) + assert lst.get_negated() is True + + lst = InListExpr(Ident("id"), []) + assert lst.get_list() == () + + with pytest.raises(TypeError): + InListExpr(Ident("id"), 1) # pyright: ignore + + def test_to_sql(self): + lst = InListExpr(Ident("id"), [1, 2, 3]) + assert lst.to_sql(PostgreSQL) == '"id" IN (1, 2, 3)' + + sql, params = lst.prepare(PostgreSQL) + assert sql == '"id" IN (:param_0, :param_1, :param_2)' + assert params == {"param_0": 1, "param_1": 2, "param_2": 3} + + assert InListExpr(Ident("id"), [], negated=True).to_sql(PostgreSQL) == ( + '"id" NOT IN ()' + ) + assert InListExpr(Ident("id"), [1], negated=True).to_sql(PostgreSQL) == ( + '"id" NOT IN (1)' + ) + + +class TestBetweenExpr: + def test_new(self): + bw = BetweenExpr(Ident("id"), 0, 10) + assert bw.get_expr() == Ident("id") + assert bw.get_low() == Value(0) + assert bw.get_high() == Value(10) + assert bw.get_negated() is False + + bw = BetweenExpr( + Ident("id"), BinaryExpr(Ident("old"), "|", 0), 10, negated=True + ) + assert bw.get_expr() == Ident("id") + assert isinstance(bw.get_low(), BinaryExpr) + assert bw.get_high() == Value(10) + assert bw.get_negated() is True + + def test_to_sql(self): + bw = BetweenExpr( + Ident("id"), BinaryExpr(Ident("old"), "|", 0), 10, negated=True + ) + assert bw.to_sql(PostgreSQL) == '"id" NOT BETWEEN "old" | 0 AND 10' + + bw = BetweenExpr(Ident("id"), 1, 10) + assert bw.to_sql(PostgreSQL) == '"id" BETWEEN 1 AND 10' + + bw = BetweenExpr(Ident("id"), BinaryExpr(1, "AND", 2), 10) + assert bw.to_sql(PostgreSQL) == '"id" BETWEEN (1 AND 2) AND 10' + + sql, params = BetweenExpr(Ident("id"), 1, 10).prepare(PostgreSQL) + assert sql == '"id" BETWEEN :param_0 AND :param_1' + assert params == {"param_0": 1, "param_1": 10} + + +class TestLikeExpr: + def test_new(self): + like = LikeExpr(Ident("name"), "%a%") + assert like.get_expr() == Ident("name") + assert like.get_pattern() == Value("%a%") + assert like.get_kind() == "LIKE" + assert like.get_negated() is False + assert like.get_escape() is None + + like = LikeExpr( + Ident("name"), + Ident("pattern"), + kind="ilike", + negated=True, + escape="!", + ) + assert like.get_expr() == Ident("name") + assert like.get_pattern() == Ident("pattern") + assert like.get_kind() == "ILIKE" + assert like.get_negated() is True + assert like.get_escape() == "!" + + assert LikeExpr(Ident("n"), "p", kind="SIMILAR TO").get_kind() == "SIMILAR TO" + assert LikeExpr(Ident("n"), "p", kind="rlike").get_kind() == "RLIKE" + + with pytest.raises(ValueError, match="unknown LIKE kind"): + LikeExpr(Ident("n"), "p", kind="MATCH") + + with pytest.raises((TypeError, ValueError)): + LikeExpr(Ident("n"), "p", escape="ab") # pyright: ignore + + def test_to_sql(self): + like = LikeExpr(Ident("name"), "%a%") + assert like.to_sql(PostgreSQL) == '"name" LIKE E\'%a%\'' + + assert ( + LikeExpr(Ident("name"), "%a%", kind="ILIKE").to_sql(PostgreSQL) + == '"name" ILIKE E\'%a%\'' + ) + assert ( + LikeExpr(Ident("name"), "%a%", kind="SIMILAR TO").to_sql(PostgreSQL) + == '"name" SIMILAR TO E\'%a%\'' + ) + assert ( + LikeExpr(Ident("name"), "%a%", kind="RLIKE").to_sql(PostgreSQL) + == '"name" RLIKE E\'%a%\'' + ) + assert ( + LikeExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSQL) + == '"name" NOT LIKE E\'%a%\'' + ) + assert ( + LikeExpr(Ident("name"), "%a%", escape="!").to_sql(PostgreSQL) + == '"name" LIKE E\'%a%\' ESCAPE E\'!\'' + ) + + sql, params = LikeExpr(Ident("name"), "%a%").prepare(PostgreSQL) + assert sql == '"name" LIKE :param_0' + assert params == {"param_0": "%a%"} + + +class TestCastExpr: + def test_new(self): + cast = CastExpr(Ident("id"), "int") + assert cast.get_expr() == Ident("id") + assert cast.get_data_type() == "INT" + + cast = CastExpr(1, "varchar(255)") + assert cast.get_expr() == Value(1) + assert cast.get_data_type() == "varchar(255)" + + cast = CastExpr(Ident("x"), "some_totally_unknown_type") + assert cast.get_data_type() == "some_totally_unknown_type" + + def test_to_sql(self): + assert CastExpr(Ident("id"), "int").to_sql(PostgreSQL) == 'CAST("id" AS INT)' + assert CastExpr(1, "varchar(255)").to_sql(PostgreSQL) == ( + "CAST(1 AS varchar(255))" + ) + + sql, params = CastExpr(1, "INT").prepare(PostgreSQL) + assert sql == "CAST(:param_0 AS INT)" + assert params == {"param_0": 1} + + +class TestCaseExpr: + def test_new(self): + empty = CaseExpr() + assert empty.get_operand() is None + assert empty.get_when_list() == () + assert empty.get_else() is None + + cond = BinaryExpr(Ident("id"), ">", 0) + searched = empty.when(cond, "yes") + assert searched is not empty + assert empty.get_when_list() == () + when_list = searched.get_when_list() + assert len(when_list) == 1 + assert isinstance(when_list[0][0], BinaryExpr) + assert when_list[0][1] == Value("yes") + assert searched.get_else() is None + + with_else = searched.else_("no") + assert with_else is not searched + assert searched.get_else() is None + assert with_else.get_else() == Value("no") + + simple = CaseExpr(Ident("status")) + assert simple.get_operand() == Ident("status") + + with pytest.raises(TypeError, match="condition"): + CaseExpr().when(Ident("x"), 1) # pyright: ignore + + with pytest.raises(TypeError, match="condition"): + CaseExpr().when(Value(1), 1) # pyright: ignore + + with pytest.raises(TypeError, match="condition"): + CaseExpr().when(1, 2) # pyright: ignore + + def test_to_sql(self): + assert CaseExpr().to_sql(PostgreSQL) == "CASE END" + + searched = ( + CaseExpr() + .when(BinaryExpr(Ident("id"), ">", 0), "yes") + .else_("no") + ) + assert ( + searched.to_sql(PostgreSQL) + == 'CASE WHEN "id" > 0 THEN E\'yes\' ELSE E\'no\' END' + ) + + simple = ( + CaseExpr(Ident("status")) + .when(BinaryExpr(Ident("status"), "=", "open"), 1) + .else_(0) + ) + assert ( + simple.to_sql(PostgreSQL) + == 'CASE "status" WHEN "status" = E\'open\' THEN 1 ELSE 0 END' + ) + + sql, params = searched.prepare(PostgreSQL) + assert sql == 'CASE WHEN "id" > :param_0 THEN :param_1 ELSE :param_2 END' + assert params == {"param_0": 0, "param_1": "yes", "param_2": "no"} + + +class TestCustomExpr: + def test_new(self): + custom = CustomExpr("1 = 1") + assert custom.get_sql() == "1 = 1" + assert custom.get_args() == () + + custom = CustomExpr("{} = {}", Ident("a"), 1) + assert custom.get_sql() == "{} = {}" + assert custom.get_args() == (Ident("a"), Value(1)) + + def test_to_sql(self): + assert CustomExpr("1 = 1").to_sql(PostgreSQL) == "1 = 1" + assert CustomExpr("{} = {}", Ident("a"), 1).to_sql(PostgreSQL) == '"a" = 1' + assert CustomExpr("{{x}}").to_sql(PostgreSQL) == "{x}" + assert CustomExpr("a {{}} b").to_sql(PostgreSQL) == "a {} b" + assert CustomExpr("fn({})", Ident("id")).to_sql(PostgreSQL) == 'fn("id")' + + with pytest.raises(ValueError, match="placeholders"): + CustomExpr("a {} {}", 1).to_sql(PostgreSQL) + + with pytest.raises(ValueError, match="placeholders"): + CustomExpr("a {}", 1, 2).to_sql(PostgreSQL) + + sql, params = CustomExpr("x = {}", 1).prepare(PostgreSQL) + assert sql == "x = :param_0" + assert params == {"param_0": 1} + + with pytest.raises(ValueError, match="placeholders"): + CustomExpr("{} {}", 1).prepare(PostgreSQL) From 3ee9f70157b81d4f557628ccef2cdc7a1a84af7a Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Sun, 20 Sep 2026 22:06:52 +0330 Subject: [PATCH 37/87] Refactor PyFunction --- rapidquery/expr.pyi | 83 ++++++++++++++++++++------ src/dialect/mod.rs | 8 +++ src/expr/enum_/func.rs | 71 ++++++++++++++++++++--- src/expr/func.rs | 99 ++++++++++++++++++++++--------- src/lib.rs | 1 - tests/test_expr.py | 128 +++++++++++++++++++++++++++++------------ 6 files changed, 298 insertions(+), 92 deletions(-) diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index 9fb2ea3..e751c51 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -214,29 +214,18 @@ class Function(Expr): """ SQL function call. - ``name`` is an identifier (``SUM``, ``schema.fn``). Passing no - positional arguments emits a bare call such as - ``CURRENT_TIMESTAMP`` (no parentheses). ``distinct=True`` inserts - ``DISTINCT`` inside the parentheses. + ``name`` is an identifier (``SUM``, ``schema.fn``). The constructor + emits a bare name such as ``CURRENT_TIMESTAMP`` (no parentheses). + Chain ``call``, ``filter``, ``ignore_nulls``, and ``respect_nulls``. + Each method returns a new frozen instance. """ - def __new__( - cls, - name: str | Ident | Unquote, - args: typing.Iterable[object] | None = None, - *, - distinct: bool | None = None, - filter: Expr | None = None, - ) -> Self: + def __new__(cls, name: str | Ident | Unquote, /) -> Self: """ - Create a function call. + Create a function name with no argument list. Args: name: Function name (``str``, ``Ident``, or ``Unquote``). - args: Positional argument expressions. `None` means no parentheses (``CURRENT_TIMESTAMP``). - distinct: ``True`` inserts ``DISTINCT``, ``False`` inserts - ``ALL``, omitted inserts neither. - filter: Optional ``FILTER (WHERE …)`` predicate. Returns: A new ``Function``. @@ -246,6 +235,56 @@ class Function(Expr): ValueError: If ``name`` is an empty or invalid identifier. """ + def call(self, *args: object, distinct: bool | None = None) -> Self: + """ + Attach an argument list (including empty parentheses). + + Args: + *args: Positional argument expressions. + distinct: ``True`` inserts ``DISTINCT``, ``False`` inserts + ``ALL``, omitted inserts neither. + + Returns: + A new ``Function`` with this argument list. + """ + + def filter(self, predicate: Expr, /) -> Self: + """ + Attach a ``FILTER (WHERE …)`` predicate. + + Args: + predicate: A condition expression (not ``Ident`` or ``Value``). + + Returns: + A new ``Function`` with the filter set. + + Raises: + TypeError: If ``predicate`` is not a condition expression. + """ + + def ignore_nulls(self) -> Self: + """ + Attach ``IGNORE NULLS``. + + BigQuery and DuckDB emit it inside the parentheses + (``LAG(x IGNORE NULLS)``). Other dialects emit it after the + call (``LAG(x) IGNORE NULLS``). + + Returns: + A new ``Function`` with this null treatment. + """ + + def respect_nulls(self) -> Self: + """ + Attach ``RESPECT NULLS``. + + Placement follows ``ignore_nulls`` (inside the parentheses on + BigQuery and DuckDB). + + Returns: + A new ``Function`` with this null treatment. + """ + def get_name(self) -> Ident: """ Return the function name. @@ -272,6 +311,16 @@ class Function(Expr): The filter expression, or ``None``. """ + def get_null_treatment( + self, + ) -> typing.Literal["IGNORE NULLS", "RESPECT NULLS"] | None: + """ + Return the postfix null-treatment clause, if any. + + Returns: + ``IGNORE NULLS``, ``RESPECT NULLS``, or ``None``. + """ + @final class TupleExpr(Expr): """Parenthesized row / tuple expression ``(a, b, …)``.""" diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index ba97b0e..29362d8 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -336,6 +336,14 @@ impl SqlDialect { matches!(self, Self::PostgreSql) } + /// `{IGNORE|RESPECT} NULLS` inside the argument list + /// (`FIRST_VALUE(x IGNORE NULLS)`), as documented for BigQuery and DuckDB. + /// Other dialects emit it after the closing parenthesis. + #[inline] + pub fn null_treatment_inside_parens(self) -> bool { + matches!(self, Self::BigQuery | Self::DuckDb) + } + /// Write a boolean literal (`TRUE`/`FALSE` or `1`/`0`). #[inline] pub fn format_boolean(&self, writer: &mut W, value: bool) -> fmt::Result { diff --git a/src/expr/enum_/func.rs b/src/expr/enum_/func.rs index 1da8a55..fd56099 100644 --- a/src/expr/enum_/func.rs +++ b/src/expr/enum_/func.rs @@ -21,6 +21,27 @@ impl DuplicateTreatment { } } +/// How `NULL`s are treated in some window/aggregate functions. +/// +/// Placement is dialect-specific: postfix `LAG(x) IGNORE NULLS`, or +/// inside the argument list on BigQuery/DuckDB (`LAG(x IGNORE NULLS)`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum NullTreatment { + /// `IGNORE NULLS` + IgnoreNulls, + /// `RESPECT NULLS` + RespectNulls, +} + +impl NullTreatment { + pub fn as_sql(self) -> &'static str { + match self { + Self::IgnoreNulls => "IGNORE NULLS", + Self::RespectNulls => "RESPECT NULLS", + } + } +} + #[derive(Clone, Debug, PartialEq)] pub enum FunctionArguments { /// Used for special functions like `CURRENT_TIMESTAMP` that are invoked @@ -42,35 +63,50 @@ pub struct Function { pub args: FunctionArguments, /// e.g. `x > 5` in `COUNT(x) FILTER (WHERE x > 5)` pub filter: Option>, - // TODO: null_treatment, over, within_group + /// `IGNORE NULLS` / `RESPECT NULLS`. Inside the parens on BigQuery + /// and DuckDB; after the call on other dialects. + pub null_treatment: Option, + // TODO: over, within_group } impl Function { pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = format!("Function({}", self.name.python_repr()); + let mut buf = format!("Function({})", self.name.python_repr()); match &self.args { FunctionArguments::None => (), FunctionArguments::List { duplicate_treatment, args, } => { - for arg in args { - buf.push_str(", "); + buf.push_str(".call("); + for (i, arg) in args.iter().enumerate() { + if i > 0 { + buf.push_str(", "); + } buf.push_str(&arg.python_repr(py)?); } if let Some(dt) = duplicate_treatment { + if !args.is_empty() { + buf.push_str(", "); + } match dt { - DuplicateTreatment::Distinct => buf.push_str(", distinct=True"), - DuplicateTreatment::All => buf.push_str(", distinct=False"), + DuplicateTreatment::Distinct => buf.push_str("distinct=True"), + DuplicateTreatment::All => buf.push_str("distinct=False"), } } + buf.push(')'); } } + match self.null_treatment { + Some(NullTreatment::IgnoreNulls) => buf.push_str(".ignore_nulls()"), + Some(NullTreatment::RespectNulls) => buf.push_str(".respect_nulls()"), + None => {} + } if let Some(filter) = &self.filter { - buf.push_str(", filter="); + buf.push_str(".filter("); buf.push_str(&filter.python_repr(py)?); + buf.push(')'); } - buf.push(')'); Ok(buf) } } @@ -82,8 +118,15 @@ impl WriteSql for Function { out: &mut W, ) -> PrepareResult<()> { self.name.write_sql(dialect, out)?; + let nt_inside = dialect.null_treatment_inside_parens(); match &self.args { - FunctionArguments::None => {} + FunctionArguments::None => { + // No parentheses to put the clause in; always postfix. + if let Some(nt) = self.null_treatment { + out.push_char(' '); + out.push_str(nt.as_sql()); + } + } FunctionArguments::List { duplicate_treatment, args, @@ -97,7 +140,17 @@ impl WriteSql for Function { } } write_separated(dialect, out, args, ", ")?; + if nt_inside && let Some(nt) = self.null_treatment { + if !args.is_empty() { + out.push_char(' '); + } + out.push_str(nt.as_sql()); + } out.push_char(')'); + if !nt_inside && let Some(nt) = self.null_treatment { + out.push_char(' '); + out.push_str(nt.as_sql()); + } } } if let Some(filter) = &self.filter { diff --git a/src/expr/func.rs b/src/expr/func.rs index 261719c..1820869 100644 --- a/src/expr/func.rs +++ b/src/expr/func.rs @@ -1,20 +1,21 @@ +use pyo3::types::{PyAnyMethods, PyTupleMethods}; + use crate::internal::{alias, utils}; use super::base::PyExpr; -use super::enum_::{DuplicateTreatment, FunctionArguments}; +use super::enum_::{DuplicateTreatment, FunctionArguments, NullTreatment}; use super::extractors::{ConditionExtractor, IdentExtractor, LiteralExprExtractor}; /// SQL function call. /// -/// `name` is an identifier (`SUM`, `schema.fn`). `parentheses=False` emits -/// `CURRENT_TIMESTAMP` with no argument list. `distinct=True` inserts -/// `DISTINCT` inside the parentheses. +/// `name` is an identifier (`SUM`, `schema.fn`). The constructor emits a +/// bare name such as `CURRENT_TIMESTAMP` (no parentheses). `.call(...)` +/// adds an argument list; `.filter(...)` adds `FILTER (WHERE …)`; +/// `.ignore_nulls()` / `.respect_nulls()` add a postfix null-treatment +/// clause. Each method returns a new frozen `Function`. /// /// Args: /// name: Function name (`str`, `Ident`, `Unquote`). -/// args: Positional argument expressions. A bare `str` is a column. -/// distinct: `True` → `DISTINCT`, `False` → `ALL`, omitted → neither. -/// filter: Optional `FILTER (WHERE …)` predicate. #[rapidquery_macros::pyclass( "Function", skip_from_py_object, @@ -37,38 +38,76 @@ impl PyFunction { _ => unsafe { std::hint::unreachable_unchecked() }, } } + + fn wrap( + py: pyo3::Python<'_>, + inner: super::enum_::Function, + ) -> pyo3::PyResult { + pyo3::Py::new( + py, + pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self), + ) + .map(pyo3::Py::into_any) + } } #[pyo3::pymethods] impl PyFunction { #[new] - #[pyo3(signature = (name, args = None, *, distinct = None, filter = None))] - fn __new__( - name: IdentExtractor, - args: Option>, - distinct: Option, - filter: Option, - ) -> pyo3::PyResult> { - let func_args = match args { - None => FunctionArguments::None, - Some(x) => FunctionArguments::List { - duplicate_treatment: match distinct { - Some(true) => Some(DuplicateTreatment::Distinct), - Some(false) => Some(DuplicateTreatment::All), - None => None, - }, - args: x.into_iter().map(|LiteralExprExtractor(x)| x).collect(), - }, - }; - + #[pyo3(signature = (name, /))] + fn __new__(name: IdentExtractor) -> pyo3::PyResult> { let inner = super::enum_::Function { name: name.0, - args: func_args, - filter: filter.map(|ConditionExtractor(expr)| Box::new(expr)), + args: FunctionArguments::None, + filter: None, + null_treatment: None, }; Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) } + #[pyo3(signature = (*args, distinct = None))] + fn call( + slf: pyo3::PyRef<'_, Self>, + args: alias::ArgsType<'_>, + distinct: Option, + ) -> pyo3::PyResult { + let mut inner = Self::inner(&slf).clone(); + let mut extracted = Vec::with_capacity(args.len()); + for item in args.iter() { + extracted.push(item.extract::()?.0); + } + inner.args = FunctionArguments::List { + duplicate_treatment: match distinct { + Some(true) => Some(DuplicateTreatment::Distinct), + Some(false) => Some(DuplicateTreatment::All), + None => None, + }, + args: extracted, + }; + Self::wrap(slf.py(), inner) + } + + fn filter( + slf: pyo3::PyRef<'_, Self>, + predicate: ConditionExtractor, + ) -> pyo3::PyResult { + let mut inner = Self::inner(&slf).clone(); + inner.filter = Some(Box::new(predicate.0)); + Self::wrap(slf.py(), inner) + } + + fn ignore_nulls(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { + let mut inner = Self::inner(&slf).clone(); + inner.null_treatment = Some(NullTreatment::IgnoreNulls); + Self::wrap(slf.py(), inner) + } + + fn respect_nulls(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { + let mut inner = Self::inner(&slf).clone(); + inner.null_treatment = Some(NullTreatment::RespectNulls); + Self::wrap(slf.py(), inner) + } + fn get_name(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { let inner = Self::inner(&slf); PyExpr(super::enum_::ExprEnum::Ident(inner.name.clone())).into_subclasses(slf.py()) @@ -127,4 +166,8 @@ impl PyFunction { None => Ok(utils::new_pynone(py)), } } + + fn get_null_treatment(slf: pyo3::PyRef<'_, Self>) -> Option<&'static str> { + Self::inner(&slf).null_treatment.map(NullTreatment::as_sql) + } } diff --git a/src/lib.rs b/src/lib.rs index 6e0ef06..f7efb97 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,6 @@ pub mod typeref; // Tasks until being stable (until this part) // - Support `any` in LikeExpr // - Pattern in LikeExpr should not parameterized -// - Support `null_treatment` in Function // - Support `AtDashAt`, `DoubleAt`, `Hash`, `PGAbs`, `PGCubeRoot`, `PGPostfixFactorial`, // `PGPrefixFactorial`, `PGSquareRoot`, `QuestionDash`, and `QuestionPipe` in UnaryExpr // - Support IsNormalizedExpr diff --git a/tests/test_expr.py b/tests/test_expr.py index ac749dc..9994ec8 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -1,6 +1,6 @@ import pytest -from rapidquery.dialect import PostgreSQL, SqlDialect +from rapidquery.dialect import BigQuery, DuckDB, PostgreSQL, SqlDialect from rapidquery.expr import ( Asterisk, BetweenExpr, @@ -66,15 +66,18 @@ def test_new(self): assert fn.get_distinct() is None assert fn.get_args() is None assert fn.get_filter() is None + assert fn.get_null_treatment() is None - fn = Function("example", filter=BinaryExpr(1, ">", 2)) + fn = Function("example").filter(BinaryExpr(1, ">", 2)) assert fn.get_name() == Ident("example") assert fn.get_distinct() is None assert fn.get_args() is None assert isinstance(fn.get_filter(), BinaryExpr) - fn = Function( - "count", [Ident("id")], distinct=False, filter=BinaryExpr(1, ">", 2) + fn = ( + Function("count") + .call(Ident("id"), distinct=False) + .filter(BinaryExpr(1, ">", 2)) ) assert fn.get_name() == Ident("count") assert fn.get_distinct() is False @@ -87,6 +90,12 @@ def test_new(self): fn = Function("schema.fn") assert fn.get_name() == Ident("schema", "fn") + bare = Function("now") + called = bare.call() + assert called is not bare + assert called.get_args() == () + assert bare.get_args() is None + def test_to_sql(self): fn = Function("current_timestamp") assert fn.to_sql(PostgreSQL) == '"current_timestamp"' @@ -94,49 +103,81 @@ def test_to_sql(self): fn = Function(Unquote("CURRENT_TIMESTAMP")) assert fn.to_sql(PostgreSQL) == "CURRENT_TIMESTAMP" - fn = Function(Unquote("NOW"), []) + fn = Function(Unquote("NOW")).call() assert fn.to_sql(PostgreSQL) == "NOW()" fn = Function(Unquote("NOW")) assert fn.to_sql(PostgreSQL) == "NOW" - fn = Function( - "count", [Asterisk], filter=BinaryExpr(Ident("status"), "=", "completed") + fn = Function("count").call(Asterisk).filter( + BinaryExpr(Ident("status"), "=", "completed") ) assert ( fn.to_sql(PostgreSQL) == '"count"(*) FILTER (WHERE "status" = E\'completed\')' ) - fn = Function( - "count", - [Ident("id")], - distinct=False, - filter=BinaryExpr(Ident("status"), "=", "completed"), + fn = ( + Function("count") + .call(Ident("id"), distinct=False) + .filter(BinaryExpr(Ident("status"), "=", "completed")) ) assert ( fn.to_sql(PostgreSQL) == '"count"(ALL "id") FILTER (WHERE "status" = E\'completed\')' ) - fn = Function( - "count", - [Ident("id")], - distinct=True, - filter=BinaryExpr(Ident("status"), "=", "completed"), + fn = ( + Function("count") + .call(Ident("id"), distinct=True) + .filter(BinaryExpr(Ident("status"), "=", "completed")) ) assert ( fn.to_sql(PostgreSQL) == '"count"(DISTINCT "id") FILTER (WHERE "status" = E\'completed\')' ) - fn = Function( - "now", - [], - distinct=True, - ) + fn = Function("now").call(distinct=True) assert fn.to_sql(PostgreSQL) == '"now"()' + fn = Function("custom").call(1, 2, Ident("id"), distinct=True) + assert fn.to_sql(PostgreSQL) == '"custom"(DISTINCT 1, 2, "id")' + + fn = Function("lag").call(Ident("x")).ignore_nulls() + assert fn.to_sql(PostgreSQL) == '"lag"("x") IGNORE NULLS' + assert fn.to_sql(BigQuery) == "`lag`(`x` IGNORE NULLS)" + assert fn.to_sql(DuckDB) == '"lag"("x" IGNORE NULLS)' + assert fn.get_null_treatment() == "IGNORE NULLS" + + fn = Function("lag").call(Ident("x")).respect_nulls() + assert fn.to_sql(PostgreSQL) == '"lag"("x") RESPECT NULLS' + assert fn.to_sql(BigQuery) == "`lag`(`x` RESPECT NULLS)" + assert fn.to_sql(DuckDB) == '"lag"("x" RESPECT NULLS)' + assert fn.get_null_treatment() == "RESPECT NULLS" + + fn = Function("lag").call().ignore_nulls() + assert fn.to_sql(PostgreSQL) == '"lag"() IGNORE NULLS' + assert fn.to_sql(BigQuery) == "`lag`(IGNORE NULLS)" + + fn = Function("lag").ignore_nulls() + assert fn.to_sql(PostgreSQL) == '"lag" IGNORE NULLS' + assert fn.to_sql(BigQuery) == "`lag` IGNORE NULLS" + + fn = ( + Function("count") + .call(Ident("id")) + .ignore_nulls() + .filter(BinaryExpr(Ident("status"), "=", "completed")) + ) + assert ( + fn.to_sql(PostgreSQL) + == '"count"("id") IGNORE NULLS FILTER (WHERE "status" = E\'completed\')' + ) + assert ( + fn.to_sql(BigQuery) + == "`count`(`id` IGNORE NULLS) FILTER (WHERE `status` = 'completed')" + ) + def test_edge_cases(self): with pytest.raises(TypeError): Function(1) # pyright: ignore @@ -145,33 +186,46 @@ def test_edge_cases(self): Function("") with pytest.raises(TypeError, match="condition"): - Function("now", filter=Ident("x")) # pyright: ignore + Function("now").filter(Ident("x")) # pyright: ignore with pytest.raises(TypeError, match="condition"): - Function("now", filter=1) # pyright: ignore + Function("now").filter(1) # pyright: ignore - fn = Function("now", distinct=True) - assert fn.get_distinct() is None - assert fn.get_args() is None - assert fn.to_sql(PostgreSQL) == '"now"' - - fn = Function("f", ["id"]) + fn = Function("f").call("id") assert fn.get_args() == (Value("id"),) assert fn.to_sql(PostgreSQL) == '"f"(E\'id\')' - fn = Function(Ident("schema.fn"), []) + fn = Function(Ident("schema.fn")).call() assert fn.get_name() == Ident("schema.fn") assert fn.to_sql(PostgreSQL) == '"schema.fn"()' - fn = Function( - "count", - [1], - filter=BinaryExpr(Ident("x"), ">", 2), - ) + first = Function("f").call(1) + second = first.call(2, 3) + assert first.get_args() == (Value(1),) + assert second.get_args() == (Value(2), Value(3)) + + filtered = Function("f").filter(BinaryExpr(1, ">", 0)) + replaced = filtered.filter(BinaryExpr(2, ">", 0)) + assert filtered.to_sql(PostgreSQL) == '"f" FILTER (WHERE 1 > 0)' + assert replaced.to_sql(PostgreSQL) == '"f" FILTER (WHERE 2 > 0)' + + ignored = Function("lag").call(Ident("x")).ignore_nulls() + respected = ignored.respect_nulls() + assert ignored.get_null_treatment() == "IGNORE NULLS" + assert respected.get_null_treatment() == "RESPECT NULLS" + assert ignored.to_sql(PostgreSQL) == '"lag"("x") IGNORE NULLS' + assert respected.to_sql(PostgreSQL) == '"lag"("x") RESPECT NULLS' + + fn = Function("count").call(1).filter(BinaryExpr(Ident("x"), ">", 2)) sql, params = fn.prepare(PostgreSQL) assert sql == '"count"(:param_0) FILTER (WHERE "x" > :param_1)' assert params == {"param_0": 1, "param_1": 2} + assert ( + repr(Function("lag").call(Ident("x")).ignore_nulls()) + == 'Function(Ident("lag")).call(Ident("x")).ignore_nulls()' + ) + class TestTupleExpr: def test_new(self): @@ -225,7 +279,7 @@ def test_new(self): UnaryExpr("!", 1) def test_to_sql(self): - un = UnaryExpr("-", Function("now", [])) + un = UnaryExpr("-", Function("now").call()) assert un.to_sql(PostgreSQL) == '-"now"()' assert UnaryExpr("NOT", 1).to_sql(PostgreSQL) == "NOT 1" From 1d15a004395a00479b8a2bea70b6b65c153ffafc Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 21 Sep 2026 12:10:33 +0330 Subject: [PATCH 38/87] Split SimilarTo/RLike from Like/ILike, support ANY for LIKE/ILIKE --- rapidquery/expr.py | 4 + rapidquery/expr.pyi | 132 +++++++++++++++++++-- src/dialect/mod.rs | 21 ++++ src/expr/base.rs | 2 + src/expr/enum_/func.rs | 10 +- src/expr/enum_/mod.rs | 228 +++++++++++++++++++++++++++++++++---- src/expr/enum_/operator.rs | 36 ------ src/expr/extractors.rs | 44 ++++++- src/expr/mod.rs | 3 +- src/expr/nodes.rs | 128 ++++++++++++++++++--- src/expr/value.rs | 2 +- src/lib.rs | 10 +- tests/conftest.py | 2 +- tests/test_expr.py | 216 ++++++++++++++++++++++++++++++----- tests/test_serializers.py | 8 +- 15 files changed, 718 insertions(+), 128 deletions(-) diff --git a/rapidquery/expr.py b/rapidquery/expr.py index 070c415..c8d32c8 100644 --- a/rapidquery/expr.py +++ b/rapidquery/expr.py @@ -16,6 +16,8 @@ IsDistinctFromExpr, IsExpr, LikeExpr, + RLikeExpr, + SimilarToExpr, TupleExpr, UnaryExpr, Unquote, @@ -36,6 +38,8 @@ "IsDistinctFromExpr", "IsExpr", "LikeExpr", + "RLikeExpr", + "SimilarToExpr", "TupleExpr", "UnaryExpr", "Unquote", diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index e751c51..29f8cc8 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -653,7 +653,7 @@ class BetweenExpr(Expr): @final class LikeExpr(Expr): - """``expr [NOT] LIKE / ILIKE / SIMILAR TO / RLIKE pattern``.""" + """``expr [NOT] LIKE / ILIKE pattern`` or ``LIKE ANY`` from a sequence.""" def __new__( cls, @@ -661,26 +661,88 @@ class LikeExpr(Expr): pattern: object, /, *, - kind: typing.Literal["LIKE", "ILIKE", "SIMILAR TO", "RLIKE"] | str = "LIKE", negated: bool = False, + case_sensitive: bool = True, escape: str | None = None, ) -> Self: """ Create a ``LIKE``-family predicate. + A ``list`` or ``tuple`` of patterns emits ``LIKE ANY`` (or an + equivalent ``OR`` expansion on dialects without native ANY). + A single expression (including a ``str``) is scalar ``LIKE``. + Args: expr: The tested expression. - pattern: The pattern. - kind: ``LIKE``, ``ILIKE``, ``SIMILAR TO``, or ``RLIKE``. - negated: If ``True``, emit ``NOT LIKE`` (or the matching - negated form). + pattern: One pattern, or a sequence of patterns. + negated: If ``True``, emit ``NOT`` / match-none. + case_sensitive: If ``False``, emit ``ILIKE`` instead of ``LIKE``. escape: Optional single-character ``ESCAPE`` clause. Returns: A new ``LikeExpr``. Raises: - ValueError: If ``kind`` is not a known ``LIKE`` spelling. + ValueError: If ``pattern`` is an empty sequence. + """ + + def get_expr(self) -> Expr: + """ + Return the tested expression. + + Returns: + The left-hand expression. + """ + + def get_pattern(self) -> Expr | tuple[Expr, ...]: + """ + Return the pattern or the ``LIKE ANY`` patterns. + + Returns: + One expression, or a tuple when constructed from a sequence. + """ + + def get_negated(self) -> bool: + """ + Return whether this is a negated match. + + Returns: + ``True`` if the predicate is negated. + """ + + def get_case_sensitive(self) -> bool: ... + def get_escape(self) -> str | None: + """ + Return the ``ESCAPE`` character, if any. + + Returns: + A single character, or ``None``. + """ + +@final +class SimilarToExpr(Expr): + """``expr [NOT] SIMILAR TO pattern``.""" + + def __new__( + cls, + expr: object, + pattern: object, + /, + *, + negated: bool = False, + escape: str | None = None, + ) -> Self: + """ + Create a ``SIMILAR TO`` predicate. + + Args: + expr: The tested expression. + pattern: The pattern. + negated: If ``True``, emit ``NOT``. + escape: Optional single-character ``ESCAPE`` clause. + + Returns: + A new ``SimilarToExpr``. """ def get_expr(self) -> Expr: @@ -699,12 +761,62 @@ class LikeExpr(Expr): Usually a ``Value`` when constructed from a string. """ - def get_kind(self) -> typing.Literal["LIKE", "ILIKE", "SIMILAR TO", "RLIKE"]: + def get_negated(self) -> bool: + """ + Return whether this is a negated match. + + Returns: + ``True`` if the predicate is negated. + """ + + def get_escape(self) -> str | None: + """ + Return the ``ESCAPE`` character, if any. + + Returns: + A single character, or ``None``. + """ + +@final +class RLikeExpr(Expr): + """``expr [NOT] RLIKE pattern``.""" + + def __new__( + cls, + expr: object, + pattern: object, + /, + *, + negated: bool = False, + escape: str | None = None, + ) -> Self: + """ + Create a ``RLIKE`` predicate. + + Args: + expr: The tested expression. + pattern: The pattern. + negated: If ``True``, emit ``NOT``. + escape: Optional single-character ``ESCAPE`` clause. + + Returns: + A new ``RLikeExpr``. """ - Return the operator kind. + + def get_expr(self) -> Expr: + """ + Return the tested expression. + + Returns: + The left-hand expression. + """ + + def get_pattern(self) -> Expr: + """ + Return the pattern expression. Returns: - ``LIKE``, ``ILIKE``, ``SIMILAR TO``, or ``RLIKE``. + Usually a ``Value`` when constructed from a string. """ def get_negated(self) -> bool: diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 29362d8..613fab9 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -44,6 +44,17 @@ mod tests; pub use py::dialect_mod; +/// How `LIKE ANY` / `ILIKE ANY` is rendered. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum LikeAnySyntax { + /// PostgreSQL: `LIKE ANY (ARRAY[p, …])`. + ArrayConstructor, + /// Snowflake / Spark / Databricks: `LIKE ANY (p, …)`. + PatternList, + /// No native ANY: `(x LIKE p1 OR x LIKE p2)`. + OrExpansion, +} + /// How a boolean literal is spelled. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum BoolFormat { @@ -344,6 +355,16 @@ impl SqlDialect { matches!(self, Self::BigQuery | Self::DuckDb) } + /// How `LIKE ANY` / `ILIKE ANY` is spelled. + #[inline] + pub fn like_any_syntax(self) -> LikeAnySyntax { + match self { + Self::PostgreSql => LikeAnySyntax::ArrayConstructor, + Self::Snowflake | Self::Spark | Self::Databricks => LikeAnySyntax::PatternList, + _ => LikeAnySyntax::OrExpansion, + } + } + /// Write a boolean literal (`TRUE`/`FALSE` or `1`/`0`). #[inline] pub fn format_boolean(&self, writer: &mut W, value: bool) -> fmt::Result { diff --git a/src/expr/base.rs b/src/expr/base.rs index de40a4a..0967b8a 100644 --- a/src/expr/base.rs +++ b/src/expr/base.rs @@ -68,6 +68,8 @@ impl PyExpr { ExprEnum::InList(_) => wrap::(py, self), ExprEnum::Between(_) => wrap::(py, self), ExprEnum::Like(_) => wrap::(py, self), + ExprEnum::SimilarTo(_) => wrap::(py, self), + ExprEnum::RLike(_) => wrap::(py, self), ExprEnum::Cast(_) => wrap::(py, self), ExprEnum::Case(_) => wrap::(py, self), ExprEnum::Custom(_) => wrap::(py, self), diff --git a/src/expr/enum_/func.rs b/src/expr/enum_/func.rs index fd56099..8279f6d 100644 --- a/src/expr/enum_/func.rs +++ b/src/expr/enum_/func.rs @@ -133,11 +133,11 @@ impl WriteSql for Function { } => { out.push_char('('); - if let Some(dt) = duplicate_treatment { - if !args.is_empty() { - out.push_str(dt.as_sql()); - out.push_char(' '); - } + if let Some(dt) = duplicate_treatment + && !args.is_empty() + { + out.push_str(dt.as_sql()); + out.push_char(' '); } write_separated(dialect, out, args, ", ")?; if nt_inside && let Some(nt) = self.null_treatment { diff --git a/src/expr/enum_/mod.rs b/src/expr/enum_/mod.rs index c2ba914..19ed33c 100644 --- a/src/expr/enum_/mod.rs +++ b/src/expr/enum_/mod.rs @@ -8,7 +8,7 @@ pub use ident::*; pub use operator::*; pub use value::*; -use crate::dialect::SqlDialect; +use crate::dialect::{LikeAnySyntax, SqlDialect}; use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; use std::fmt::Write; @@ -54,13 +54,36 @@ pub struct Between { pub high: Box, } +/// Pattern side of [`Like`]: one expression, or a non-empty ANY list. +#[derive(Clone, Debug, PartialEq)] +pub enum LikePattern { + /// `col LIKE p` + One(Box), + /// `col LIKE ANY …` — never empty. + Any(Vec), +} + #[derive(Clone, Debug, PartialEq)] pub struct Like { - pub kind: LikeKind, + pub case_sensitive: bool, pub negated: bool, pub expr: Box, + pub pattern: LikePattern, + pub escape: Option, +} - // TODO: may it's better to keep `Arcstr | list of Arcstr` +#[derive(Clone, Debug, PartialEq)] +pub struct SimilarTo { + pub negated: bool, + pub expr: Box, + pub pattern: Box, + pub escape: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RLike { + pub negated: bool, + pub expr: Box, pub pattern: Box, pub escape: Option, } @@ -111,6 +134,8 @@ pub enum ExprEnum { /// [NOT] BETWEEN AND Between(Between), Like(Like), + SimilarTo(SimilarTo), + RLike(RLike), Cast(Cast), Case(Case), Custom(Custom), @@ -196,6 +221,20 @@ impl From for ExprEnum { } } +impl From for ExprEnum { + #[inline] + fn from(value: SimilarTo) -> Self { + Self::SimilarTo(value) + } +} + +impl From for ExprEnum { + #[inline] + fn from(value: RLike) -> Self { + Self::RLike(value) + } +} + impl From for ExprEnum { #[inline] fn from(value: Cast) -> Self { @@ -261,7 +300,9 @@ impl ExprEnum { | Self::IsDistinctFrom(_) | Self::InList(_) | Self::Between(_) - | Self::Like(_) => 5, + | Self::Like(_) + | Self::SimilarTo(_) + | Self::RLike(_) => 5, } } @@ -369,20 +410,64 @@ impl ExprEnum { Ok(buf) } Self::Like(Like { - kind, + case_sensitive, + negated, + expr, + pattern, + escape, + }) => { + let mut buf = format!("LikeExpr({}, ", expr.python_repr(py)?); + match pattern { + LikePattern::One(p) => buf.push_str(&p.python_repr(py)?), + LikePattern::Any(ps) => { + buf.push('['); + join_reprs(py, &mut buf, ps)?; + buf.push(']'); + } + } + if !*case_sensitive { + buf.push_str(", case_sensitive=False"); + } + if *negated { + buf.push_str(", negated=True"); + } + if let Some(c) = escape { + write!(buf, ", escape={c:?}").unwrap(); + } + buf.push(')'); + Ok(buf) + } + Self::SimilarTo(SimilarTo { negated, expr, pattern, escape, }) => { let mut buf = format!( - "LikeExpr({}, {}", + "SimilarToExpr({}, {}", expr.python_repr(py)?, pattern.python_repr(py)? ); - if *kind != LikeKind::Like { - write!(buf, ", kind={:?}", kind.as_sql()).unwrap(); + if *negated { + buf.push_str(", negated=True"); } + if let Some(c) = escape { + write!(buf, ", escape={c:?}").unwrap(); + } + buf.push(')'); + Ok(buf) + } + Self::RLike(RLike { + negated, + expr, + pattern, + escape, + }) => { + let mut buf = format!( + "RLikeExpr({}, {}", + expr.python_repr(py)?, + pattern.python_repr(py)? + ); if *negated { buf.push_str(", negated=True"); } @@ -438,6 +523,97 @@ impl ExprEnum { } } +fn write_like_op(out: &mut W, case_sensitive: bool, negated: bool) { + if negated { + out.push_str("NOT "); + } + if case_sensitive { + out.push_str("LIKE"); + } else { + out.push_str("ILIKE"); + } +} + +fn write_escape( + dialect: &SqlDialect, + out: &mut W, + escape: Option, +) -> PrepareResult<()> { + let Some(c) = escape else { + return Ok(()); + }; + out.push_str(" ESCAPE "); + let mut buf = [0u8; 4]; + dialect + .write_string_literal(out, c.encode_utf8(&mut buf)) + .map_err(|_| crate::sql::PrepareError::Write)?; + Ok(()) +} + +fn write_like( + dialect: &SqlDialect, + out: &mut W, + like: &Like, + prec: u8, +) -> PrepareResult<()> { + match &like.pattern { + LikePattern::One(pattern) => { + like.expr.write_operand(dialect, out, prec, false)?; + out.push_char(' '); + write_like_op(out, like.case_sensitive, like.negated); + out.push_char(' '); + pattern.write_operand(dialect, out, prec, true)?; + write_escape(dialect, out, like.escape) + } + LikePattern::Any(patterns) => match dialect.like_any_syntax() { + LikeAnySyntax::ArrayConstructor => { + // `NOT LIKE ANY` in Postgres is a quantified NOT LIKE + // (exists a non-match), not "matches none". + if like.negated { + out.push_str("NOT ("); + } + like.expr.write_operand(dialect, out, prec, false)?; + out.push_char(' '); + write_like_op(out, like.case_sensitive, false); + out.push_str(" ANY (ARRAY["); + write_separated(dialect, out, patterns, ", ")?; + out.push_str("])"); + write_escape(dialect, out, like.escape)?; + if like.negated { + out.push_char(')'); + } + Ok(()) + } + LikeAnySyntax::PatternList => { + like.expr.write_operand(dialect, out, prec, false)?; + out.push_char(' '); + write_like_op(out, like.case_sensitive, like.negated); + out.push_str(" ANY ("); + write_separated(dialect, out, patterns, ", ")?; + out.push_char(')'); + write_escape(dialect, out, like.escape) + } + LikeAnySyntax::OrExpansion => { + out.push_char('('); + let sep = if like.negated { " AND " } else { " OR " }; + for (i, pattern) in patterns.iter().enumerate() { + if i > 0 { + out.push_str(sep); + } + like.expr.write_operand(dialect, out, prec, false)?; + out.push_char(' '); + write_like_op(out, like.case_sensitive, like.negated); + out.push_char(' '); + pattern.write_operand(dialect, out, prec, true)?; + write_escape(dialect, out, like.escape)?; + } + out.push_char(')'); + Ok(()) + } + }, + } +} + impl WriteSql for ExprEnum { #[cfg_attr(feature = "nightly", optimize(speed))] fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { @@ -530,8 +706,8 @@ impl WriteSql for ExprEnum { out.push_str(" AND "); high.write_operand(dialect, out, prec, true) } - Self::Like(Like { - kind, + Self::Like(like) => write_like(dialect, out, like, self.precedence()), + Self::SimilarTo(SimilarTo { negated, expr, pattern, @@ -541,19 +717,31 @@ impl WriteSql for ExprEnum { expr.write_operand(dialect, out, prec, false)?; out.push_char(' '); if *negated { - out.push_str("NOT "); + out.push_str("NOT SIMILAR TO "); + } else { + out.push_str("SIMILAR TO "); } - out.push_str(kind.as_sql()); - out.push_char(' '); + pattern.write_operand(dialect, out, prec, true)?; - if let Some(c) = escape { - out.push_str(" ESCAPE "); - let raw = c.encode_utf8(&mut [0; 4]).to_string(); - dialect - .write_string_literal(out, &raw) - .map_err(|_| crate::sql::PrepareError::Write)?; + write_escape(dialect, out, *escape) + } + Self::RLike(RLike { + negated, + expr, + pattern, + escape, + }) => { + let prec = self.precedence(); + expr.write_operand(dialect, out, prec, false)?; + out.push_char(' '); + if *negated { + out.push_str("NOT RLIKE "); + } else { + out.push_str("RLIKE "); } - Ok(()) + + pattern.write_operand(dialect, out, prec, true)?; + write_escape(dialect, out, *escape) } Self::Cast(Cast { expr, data_type }) => { out.push_str("CAST("); diff --git a/src/expr/enum_/operator.rs b/src/expr/enum_/operator.rs index 2cd205f..dfa5b80 100644 --- a/src/expr/enum_/operator.rs +++ b/src/expr/enum_/operator.rs @@ -146,39 +146,3 @@ impl IsKind { } } } - -/// Pattern-matching operator family. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum LikeKind { - Like, - ILike, - SimilarTo, - RLike, -} - -impl LikeKind { - #[inline] - pub fn parse(s: &str) -> Option { - if s.eq_ignore_ascii_case("LIKE") { - Some(Self::Like) - } else if s.eq_ignore_ascii_case("ILIKE") { - Some(Self::ILike) - } else if s.eq_ignore_ascii_case("SIMILAR TO") { - Some(Self::SimilarTo) - } else if s.eq_ignore_ascii_case("RLIKE") { - Some(Self::RLike) - } else { - None - } - } - - #[inline] - pub fn as_sql(self) -> &'static str { - match self { - Self::Like => "LIKE", - Self::ILike => "ILIKE", - Self::SimilarTo => "SIMILAR TO", - Self::RLike => "RLIKE", - } - } -} diff --git a/src/expr/extractors.rs b/src/expr/extractors.rs index 5e70e50..474afb0 100644 --- a/src/expr/extractors.rs +++ b/src/expr/extractors.rs @@ -1,8 +1,8 @@ use std::str::FromStr; -use pyo3::types::PyTupleMethods; +use pyo3::types::{PyAnyMethods, PyListMethods, PyTupleMethods}; -use super::enum_::{ExprEnum, Ident, IdentPart}; +use super::enum_::{ExprEnum, Ident, IdentPart, LikePattern}; use crate::internal::utils; /// `true` when `obj` is `Asterisk` or `type[Asterisk]`. @@ -85,6 +85,46 @@ fn extract_expr( ))) } +/// Scalar `LIKE` pattern, or a list/tuple of patterns (`LIKE ANY`). +/// +/// A Python `str` is never a sequence. An already-built `TupleExpr` is a +/// single pattern; a raw `tuple`/`list` is ANY. +pub fn extract_like_pattern( + obj: pyo3::prelude::Borrowed<'_, '_, pyo3::prelude::PyAny>, +) -> pyo3::PyResult { + let ptr = obj.as_ptr(); + unsafe { + if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { + return Ok(LikePattern::One(Box::new(extract_expr(obj, false)?))); + } + if pyo3::ffi::PyList_CheckExact(ptr) == 1 { + let list = obj.cast_unchecked::(); + return extract_like_any(list.iter()); + } + if pyo3::ffi::PyTuple_CheckExact(ptr) == 1 { + let tuple = obj.cast_unchecked::(); + return extract_like_any(tuple.iter()); + } + } + Ok(LikePattern::One(Box::new(extract_expr(obj, false)?))) +} + +fn extract_like_any<'py>( + iter: impl IntoIterator>, +) -> pyo3::PyResult { + let mut items = Vec::new(); + for item in iter { + items.push(item.extract::()?.0); + } + if items.is_empty() { + return rapidquery_macros::pyerr!( + Err(PyValueError), + "LIKE ANY pattern list is empty" + ); + } + Ok(LikePattern::Any(items)) +} + pub(super) enum ExtractedIdentArgument { Ident(Ident), Part(IdentPart), diff --git a/src/expr/mod.rs b/src/expr/mod.rs index 2d067da..c5effc5 100644 --- a/src/expr/mod.rs +++ b/src/expr/mod.rs @@ -23,6 +23,7 @@ pub mod expr_mod { #[pymodule_export] use super::nodes::{ PyBetweenExpr, PyBinaryExpr, PyCaseExpr, PyCastExpr, PyCustomExpr, PyInListExpr, - PyIsDistinctFromExpr, PyIsExpr, PyLikeExpr, PyTupleExpr, PyUnaryExpr, + PyIsDistinctFromExpr, PyIsExpr, PyLikeExpr, PyRLikeExpr, PySimilarToExpr, PyTupleExpr, + PyUnaryExpr, }; } diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index 33257a5..b455136 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -2,7 +2,7 @@ use pyo3::types::{PyAnyMethods, PyTupleMethods}; use super::base::PyExpr; use super::enum_::ExprEnum; -use super::extractors::{ConditionExtractor, LiteralExprExtractor}; +use super::extractors::{ConditionExtractor, LiteralExprExtractor, extract_like_pattern}; use crate::data_type::DataTypeName; use crate::internal::{alias, utils}; @@ -65,9 +65,15 @@ empty_node! { // /// `expr [NOT] BETWEEN low AND high`. PyBetweenExpr(Between(super::enum_::Between)) => "BetweenExpr", - /// `expr [NOT] LIKE / ILIKE / SIMILAR TO / RLIKE pattern`. + /// `expr [NOT] LIKE / ILIKE [ANY] pattern`. PyLikeExpr(Like(super::enum_::Like)) => "LikeExpr", + /// `expr [NOT] SIMILAR TO pattern`. + PySimilarToExpr(SimilarTo(super::enum_::SimilarTo)) => "SimilarToExpr", + + /// `expr [NOT] RLIKE pattern`. + PyRLikeExpr(RLike(super::enum_::RLike)) => "RLikeExpr", + /// `CAST(expr AS type)`. PyCastExpr(Cast(super::enum_::Cast)) => "CastExpr", @@ -348,25 +354,19 @@ impl PyBetweenExpr { #[pyo3::pymethods] impl PyLikeExpr { #[new] - #[pyo3(signature = (expr, pattern, /, *, kind = "LIKE", negated = false, escape = None))] + #[pyo3(signature = (expr, pattern, /, *, negated = false, case_sensitive = true, escape = None))] fn __new__( expr: LiteralExprExtractor, - pattern: LiteralExprExtractor, - kind: &str, + pattern: alias::BoundObject<'_>, negated: bool, + case_sensitive: bool, escape: Option, ) -> pyo3::PyResult> { - let Some(kind) = super::enum_::LikeKind::parse(kind) else { - return rapidquery_macros::pyerr!( - Err(PyValueError), - "unknown LIKE kind {kind:?}; expected LIKE, ILIKE, SIMILAR TO, or RLIKE" - ); - }; let inner = super::enum_::Like { - kind, + case_sensitive, negated, expr: Box::new(expr.0), - pattern: Box::new(pattern.0), + pattern: extract_like_pattern(pattern.as_borrowed())?, escape, }; Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) @@ -377,14 +377,112 @@ impl PyLikeExpr { super::base::into_subclasses(slf.py(), (*inner.expr).clone()) } + fn get_pattern(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let py = slf.py(); + let inner = Self::inner(&slf); + match &inner.pattern { + super::enum_::LikePattern::One(p) => super::base::into_subclasses(py, (**p).clone()), + super::enum_::LikePattern::Any(ps) => { + let tuple = unsafe { pyo3::ffi::PyTuple_New(ps.len() as isize) }; + for (ix, arg) in ps.iter().enumerate() { + match PyExpr(arg.clone()).into_subclasses(py) { + Ok(bound) => unsafe { + pyo3::ffi::PyTuple_SetItem(tuple, ix as isize, bound.into_ptr()); + }, + Err(err) => unsafe { + pyo3::ffi::Py_DECREF(tuple); + return Err(err); + }, + } + } + Ok(unsafe { pyo3::Bound::from_owned_ptr(py, tuple) }) + } + } + } + + fn get_negated(slf: pyo3::PyRef<'_, Self>) -> bool { + let inner = Self::inner(&slf); + inner.negated + } + + fn get_case_sensitive(slf: pyo3::PyRef<'_, Self>) -> bool { + let inner = Self::inner(&slf); + inner.case_sensitive + } + + fn get_escape(slf: pyo3::PyRef<'_, Self>) -> Option { + let inner = Self::inner(&slf); + inner.escape + } +} + +#[pyo3::pymethods] +impl PySimilarToExpr { + #[new] + #[pyo3(signature = (expr, pattern, /, *, negated = false, escape = None))] + fn __new__( + expr: LiteralExprExtractor, + pattern: LiteralExprExtractor, + negated: bool, + escape: Option, + ) -> pyo3::PyClassInitializer { + let inner = super::enum_::SimilarTo { + negated, + expr: Box::new(expr.0), + pattern: Box::new(pattern.0), + escape, + }; + pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self) + } + + fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), (*inner.expr).clone()) + } + fn get_pattern(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { let inner = Self::inner(&slf); super::base::into_subclasses(slf.py(), (*inner.pattern).clone()) } - fn get_kind(slf: pyo3::PyRef<'_, Self>) -> &'static str { + fn get_negated(slf: pyo3::PyRef<'_, Self>) -> bool { let inner = Self::inner(&slf); - inner.kind.as_sql() + inner.negated + } + + fn get_escape(slf: pyo3::PyRef<'_, Self>) -> Option { + let inner = Self::inner(&slf); + inner.escape + } +} + +#[pyo3::pymethods] +impl PyRLikeExpr { + #[new] + #[pyo3(signature = (expr, pattern, /, *, negated = false, escape = None))] + fn __new__( + expr: LiteralExprExtractor, + pattern: LiteralExprExtractor, + negated: bool, + escape: Option, + ) -> pyo3::PyClassInitializer { + let inner = super::enum_::RLike { + negated, + expr: Box::new(expr.0), + pattern: Box::new(pattern.0), + escape, + }; + pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self) + } + + fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), (*inner.expr).clone()) + } + + fn get_pattern(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), (*inner.pattern).clone()) } fn get_negated(slf: pyo3::PyRef<'_, Self>) -> bool { diff --git a/src/expr/value.rs b/src/expr/value.rs index 870371d..e6457c2 100644 --- a/src/expr/value.rs +++ b/src/expr/value.rs @@ -90,7 +90,7 @@ impl PyValue { }; let expr = super::base::PyExpr(super::enum_::ExprEnum::Value(value)); - return pyo3::PyClassInitializer::from(expr).add_subclass(PyValue); + pyo3::PyClassInitializer::from(expr).add_subclass(PyValue) } /// Returns the wrapped Python object. diff --git a/src/lib.rs b/src/lib.rs index f7efb97..1fe9411 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,8 +9,6 @@ pub mod sql; pub mod typeref; // Tasks until being stable (until this part) -// - Support `any` in LikeExpr -// - Pattern in LikeExpr should not parameterized // - Support `AtDashAt`, `DoubleAt`, `Hash`, `PGAbs`, `PGCubeRoot`, `PGPostfixFactorial`, // `PGPrefixFactorial`, `PGSquareRoot`, `QuestionDash`, and `QuestionPipe` in UnaryExpr // - Support IsNormalizedExpr @@ -22,6 +20,14 @@ pub mod typeref; // - Update docstrings // - Test and coverage everything and edge cases +// Later tasks: +// - Complete `Expr` methods +// - Test `Expr` methods +// - Benchmark and compare with old version +// - Open PR +// - Rewrite dialects and add a new dialect: CustomDialect +// - Start writing query ASTs. + /// RapidQuery native extension. #[pyo3::pymodule(gil_used = false)] mod _core { diff --git a/tests/conftest.py b/tests/conftest.py index 5b9c321..b446193 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,7 +19,7 @@ ) try: - import orjson # ruff: ignore[F401] + import orjson USING_ORJSON = not _feature_only_stdlib_json except ImportError: diff --git a/tests/test_expr.py b/tests/test_expr.py index 9994ec8..b7c8580 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -1,6 +1,6 @@ import pytest -from rapidquery.dialect import BigQuery, DuckDB, PostgreSQL, SqlDialect +from rapidquery.dialect import BigQuery, DuckDB, PostgreSQL, SQLite, Snowflake, SqlDialect from rapidquery.expr import ( Asterisk, BetweenExpr, @@ -15,6 +15,8 @@ IsDistinctFromExpr, IsExpr, LikeExpr, + RLikeExpr, + SimilarToExpr, TupleExpr, UnaryExpr, Unquote, @@ -40,6 +42,8 @@ def test_nodes_are_expr_subclasses(self): InListExpr(Ident("id"), [1]), BetweenExpr(Ident("id"), 0, 1), LikeExpr(Ident("name"), "%a%"), + SimilarToExpr(Ident("name"), "%a%"), + RLikeExpr(Ident("name"), "%a%"), CastExpr(Ident("id"), "int"), CaseExpr(), CustomExpr("1"), @@ -109,8 +113,10 @@ def test_to_sql(self): fn = Function(Unquote("NOW")) assert fn.to_sql(PostgreSQL) == "NOW" - fn = Function("count").call(Asterisk).filter( - BinaryExpr(Ident("status"), "=", "completed") + fn = ( + Function("count") + .call(Asterisk) + .filter(BinaryExpr(Ident("status"), "=", "completed")) ) assert ( fn.to_sql(PostgreSQL) @@ -193,7 +199,7 @@ def test_edge_cases(self): fn = Function("f").call("id") assert fn.get_args() == (Value("id"),) - assert fn.to_sql(PostgreSQL) == '"f"(E\'id\')' + assert fn.to_sql(PostgreSQL) == "\"f\"(E'id')" fn = Function(Ident("schema.fn")).call() assert fn.get_name() == Ident("schema.fn") @@ -336,7 +342,8 @@ def test_to_sql(self): assert BinaryExpr(1, "!=", 2).to_sql(PostgreSQL) == "1 <> 2" assert BinaryExpr(1, "AND", 2).to_sql(PostgreSQL) == "1 AND 2" assert ( - BinaryExpr(BinaryExpr(1, "+", 2), "AND", 3).to_sql(PostgreSQL) == "1 + 2 AND 3" + BinaryExpr(BinaryExpr(1, "+", 2), "AND", 3).to_sql(PostgreSQL) + == "1 + 2 AND 3" ) assert ( BinaryExpr(1, "+", BinaryExpr(2, "AND", 3)).to_sql(PostgreSQL) @@ -508,61 +515,212 @@ def test_new(self): like = LikeExpr(Ident("name"), "%a%") assert like.get_expr() == Ident("name") assert like.get_pattern() == Value("%a%") - assert like.get_kind() == "LIKE" + assert like.get_case_sensitive() is True assert like.get_negated() is False assert like.get_escape() is None like = LikeExpr( Ident("name"), Ident("pattern"), - kind="ilike", + case_sensitive=False, negated=True, escape="!", ) assert like.get_expr() == Ident("name") assert like.get_pattern() == Ident("pattern") - assert like.get_kind() == "ILIKE" + assert like.get_case_sensitive() is False assert like.get_negated() is True assert like.get_escape() == "!" - assert LikeExpr(Ident("n"), "p", kind="SIMILAR TO").get_kind() == "SIMILAR TO" - assert LikeExpr(Ident("n"), "p", kind="rlike").get_kind() == "RLIKE" + any_like = LikeExpr(Ident("name"), ["%a%", "%b%"]) + assert any_like.get_pattern() == (Value("%a%"), Value("%b%")) - with pytest.raises(ValueError, match="unknown LIKE kind"): - LikeExpr(Ident("n"), "p", kind="MATCH") + one_any = LikeExpr(Ident("name"), ["%a%"]) + assert one_any.get_pattern() == (Value("%a%"),) + + tup = LikeExpr(Ident("name"), ("%a%", Ident("p"))) + assert tup.get_pattern() == (Value("%a%"), Ident("p")) + + with pytest.raises(ValueError, match="empty"): + LikeExpr(Ident("name"), []) with pytest.raises((TypeError, ValueError)): LikeExpr(Ident("n"), "p", escape="ab") # pyright: ignore def test_to_sql(self): like = LikeExpr(Ident("name"), "%a%") - assert like.to_sql(PostgreSQL) == '"name" LIKE E\'%a%\'' + assert like.to_sql(PostgreSQL) == "\"name\" LIKE E'%a%'" assert ( - LikeExpr(Ident("name"), "%a%", kind="ILIKE").to_sql(PostgreSQL) - == '"name" ILIKE E\'%a%\'' - ) - assert ( - LikeExpr(Ident("name"), "%a%", kind="SIMILAR TO").to_sql(PostgreSQL) - == '"name" SIMILAR TO E\'%a%\'' - ) - assert ( - LikeExpr(Ident("name"), "%a%", kind="RLIKE").to_sql(PostgreSQL) - == '"name" RLIKE E\'%a%\'' + LikeExpr(Ident("name"), "%a%", case_sensitive=False).to_sql(PostgreSQL) + == "\"name\" ILIKE E'%a%'" ) assert ( LikeExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSQL) - == '"name" NOT LIKE E\'%a%\'' + == "\"name\" NOT LIKE E'%a%'" ) assert ( LikeExpr(Ident("name"), "%a%", escape="!").to_sql(PostgreSQL) - == '"name" LIKE E\'%a%\' ESCAPE E\'!\'' + == "\"name\" LIKE E'%a%' ESCAPE E'!'" ) sql, params = LikeExpr(Ident("name"), "%a%").prepare(PostgreSQL) assert sql == '"name" LIKE :param_0' assert params == {"param_0": "%a%"} + any_like = LikeExpr(Ident("name"), ["%a%", "%b%"]) + assert ( + any_like.to_sql(PostgreSQL) + == "\"name\" LIKE ANY (ARRAY[E'%a%', E'%b%'])" + ) + assert ( + any_like.to_sql(Snowflake) + == "\"name\" LIKE ANY ('%a%', '%b%')" + ) + assert any_like.to_sql(SQLite) == ( + "(\"name\" LIKE '%a%' OR \"name\" LIKE '%b%')" + ) + + sql, params = any_like.prepare(PostgreSQL) + assert sql == '"name" LIKE ANY (ARRAY[:param_0, :param_1])' + assert params == {"param_0": "%a%", "param_1": "%b%"} + + negated = LikeExpr(Ident("name"), ["%a%", "%b%"], negated=True) + assert ( + negated.to_sql(PostgreSQL) + == "NOT (\"name\" LIKE ANY (ARRAY[E'%a%', E'%b%']))" + ) + assert ( + negated.to_sql(Snowflake) + == "\"name\" NOT LIKE ANY ('%a%', '%b%')" + ) + assert negated.to_sql(SQLite) == ( + "(\"name\" NOT LIKE '%a%' AND \"name\" NOT LIKE '%b%')" + ) + + ilike_any = LikeExpr( + Ident("name"), ["%a%", "%b%"], case_sensitive=False + ) + assert ( + ilike_any.to_sql(PostgreSQL) + == "\"name\" ILIKE ANY (ARRAY[E'%a%', E'%b%'])" + ) + + escaped = LikeExpr(Ident("name"), ["%a%", "%b%"], escape="!") + assert ( + escaped.to_sql(PostgreSQL) + == "\"name\" LIKE ANY (ARRAY[E'%a%', E'%b%']) ESCAPE E'!'" + ) + assert escaped.to_sql(SQLite) == ( + "(\"name\" LIKE '%a%' ESCAPE '!' OR \"name\" LIKE '%b%' ESCAPE '!')" + ) + + assert ( + repr(LikeExpr(Ident("name"), ["%a%", "%b%"], negated=True)) + == "LikeExpr(Ident(\"name\"), [Value('%a%', StringSerializer()), " + "Value('%b%', StringSerializer())], negated=True)" + ) + + +class TestSimilarToExpr: + def test_new(self): + sim = SimilarToExpr(Ident("name"), "%a%") + assert sim.get_expr() == Ident("name") + assert sim.get_pattern() == Value("%a%") + assert sim.get_negated() is False + assert sim.get_escape() is None + + sim = SimilarToExpr( + Ident("name"), + Ident("pattern"), + negated=True, + escape="!", + ) + assert sim.get_expr() == Ident("name") + assert sim.get_pattern() == Ident("pattern") + assert sim.get_negated() is True + assert sim.get_escape() == "!" + + with pytest.raises((TypeError, ValueError)): + SimilarToExpr(Ident("n"), "p", escape="ab") # pyright: ignore + + def test_to_sql(self): + sim = SimilarToExpr(Ident("name"), "%a%") + assert sim.to_sql(PostgreSQL) == "\"name\" SIMILAR TO E'%a%'" + + assert ( + SimilarToExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSQL) + == "\"name\" NOT SIMILAR TO E'%a%'" + ) + assert ( + SimilarToExpr(Ident("name"), "%a%", escape="!").to_sql(PostgreSQL) + == "\"name\" SIMILAR TO E'%a%' ESCAPE E'!'" + ) + + sql, params = SimilarToExpr(Ident("name"), "%a%").prepare(PostgreSQL) + assert sql == '"name" SIMILAR TO :param_0' + assert params == {"param_0": "%a%"} + + assert ( + repr(SimilarToExpr(Ident("name"), "%a%")) + == "SimilarToExpr(Ident(\"name\"), Value('%a%', StringSerializer()))" + ) + assert ( + repr(SimilarToExpr(Ident("name"), "%a%", negated=True, escape="!")) + == "SimilarToExpr(Ident(\"name\"), Value('%a%', StringSerializer()), " + "negated=True, escape='!')" + ) + + +class TestRLikeExpr: + def test_new(self): + rlike = RLikeExpr(Ident("name"), "%a%") + assert rlike.get_expr() == Ident("name") + assert rlike.get_pattern() == Value("%a%") + assert rlike.get_negated() is False + assert rlike.get_escape() is None + + rlike = RLikeExpr( + Ident("name"), + Ident("pattern"), + negated=True, + escape="!", + ) + assert rlike.get_expr() == Ident("name") + assert rlike.get_pattern() == Ident("pattern") + assert rlike.get_negated() is True + assert rlike.get_escape() == "!" + + with pytest.raises((TypeError, ValueError)): + RLikeExpr(Ident("n"), "p", escape="ab") # pyright: ignore + + def test_to_sql(self): + rlike = RLikeExpr(Ident("name"), "%a%") + assert rlike.to_sql(PostgreSQL) == "\"name\" RLIKE E'%a%'" + + assert ( + RLikeExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSQL) + == "\"name\" NOT RLIKE E'%a%'" + ) + assert ( + RLikeExpr(Ident("name"), "%a%", escape="!").to_sql(PostgreSQL) + == "\"name\" RLIKE E'%a%' ESCAPE E'!'" + ) + + sql, params = RLikeExpr(Ident("name"), "%a%").prepare(PostgreSQL) + assert sql == '"name" RLIKE :param_0' + assert params == {"param_0": "%a%"} + + assert ( + repr(RLikeExpr(Ident("name"), "%a%")) + == "RLikeExpr(Ident(\"name\"), Value('%a%', StringSerializer()))" + ) + assert ( + repr(RLikeExpr(Ident("name"), "%a%", negated=True, escape="!")) + == "RLikeExpr(Ident(\"name\"), Value('%a%', StringSerializer()), " + "negated=True, escape='!')" + ) + class TestCastExpr: def test_new(self): @@ -625,14 +783,10 @@ def test_new(self): def test_to_sql(self): assert CaseExpr().to_sql(PostgreSQL) == "CASE END" - searched = ( - CaseExpr() - .when(BinaryExpr(Ident("id"), ">", 0), "yes") - .else_("no") - ) + searched = CaseExpr().when(BinaryExpr(Ident("id"), ">", 0), "yes").else_("no") assert ( searched.to_sql(PostgreSQL) - == 'CASE WHEN "id" > 0 THEN E\'yes\' ELSE E\'no\' END' + == "CASE WHEN \"id\" > 0 THEN E'yes' ELSE E'no' END" ) simple = ( diff --git a/tests/test_serializers.py b/tests/test_serializers.py index c74a58c..e734271 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -508,7 +508,7 @@ class SerializeTestCase: SerializeTestCase( DateSerializer(), ALL_DIALECTS, - datetime.datetime(2024, 1, 15, 10, 0), # ruff: ignore[DTZ001] + datetime.datetime(2024, 1, 15, 10, 0), TypeError, ), SerializeTestCase(DateSerializer(), ALL_DIALECTS, "2024-01-15", TypeError), @@ -545,19 +545,19 @@ class SerializeTestCase: SerializeTestCase( DatetimeSerializer(), _PG, - datetime.datetime(2024, 1, 15, 10, 30, 0), # ruff: ignore[DTZ001] + datetime.datetime(2024, 1, 15, 10, 30, 0), "E'2024-01-15 10:30:00'", ), SerializeTestCase( DatetimeSerializer(), _MSSQL, - datetime.datetime(2024, 1, 15, 10, 30, 0), # ruff: ignore[DTZ001] + datetime.datetime(2024, 1, 15, 10, 30, 0), "N'2024-01-15 10:30:00'", ), SerializeTestCase( DatetimeSerializer(), _PLAIN_QUOTE, - datetime.datetime(2024, 1, 15, 10, 30, 0), # ruff: ignore[DTZ001] + datetime.datetime(2024, 1, 15, 10, 30, 0), "'2024-01-15 10:30:00'", ), SerializeTestCase( From a8403e7503e19a2c4a1632afdadc1dfa35adac5c Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 21 Sep 2026 12:41:23 +0330 Subject: [PATCH 39/87] Support `AtDashAt`, `DoubleAt`, `Hash`, `PGAbs`, `PGCubeRoot`, `PGPostfixFactorial`, `PGPrefixFactorial`, `PGSquareRoot`, `QuestionDash`, and `QuestionPipe` in UnaryExpr Implement new Expr children: `IsNormalizedExpr` --- rapidquery/expr.py | 2 + rapidquery/expr.pyi | 102 +++++++++++++++++++++++++++++++++++-- src/expr/base.rs | 1 + src/expr/enum_/mod.rs | 87 +++++++++++++++++++++++++++---- src/expr/enum_/operator.rs | 76 +++++++++++++++++++++++++++ src/expr/mod.rs | 4 +- src/expr/nodes.rs | 52 ++++++++++++++++++- src/lib.rs | 5 +- tests/test_expr.py | 94 +++++++++++++++++++++++++++++++++- 9 files changed, 400 insertions(+), 23 deletions(-) diff --git a/rapidquery/expr.py b/rapidquery/expr.py index c8d32c8..3e206fa 100644 --- a/rapidquery/expr.py +++ b/rapidquery/expr.py @@ -15,6 +15,7 @@ InListExpr, IsDistinctFromExpr, IsExpr, + IsNormalizedExpr, LikeExpr, RLikeExpr, SimilarToExpr, @@ -37,6 +38,7 @@ "InListExpr", "IsDistinctFromExpr", "IsExpr", + "IsNormalizedExpr", "LikeExpr", "RLikeExpr", "SimilarToExpr", diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index 29f8cc8..c38a5e2 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -346,16 +346,38 @@ class TupleExpr(Expr): @final class UnaryExpr(Expr): - """Unary expression (``NOT x``, ``-x``, ``+x``, ``~x``).""" + """Unary expression (``NOT x``, ``-x``, ``|/x``, ``x!``, …).""" def __new__( - cls, operator: typing.Literal["NOT", "+", "-", "~"] | str, expr: object, / + cls, + operator: typing.Literal[ + "NOT", + "+", + "-", + "~", + "@-@", + "@@", + "#", + "@", + "||/", + "!", + "!!", + "|/", + "?-", + "?|", + ] + | str, + expr: object, + /, ) -> Self: """ Create a unary expression. Args: - operator: One of ``NOT``, ``+``, ``-``, or ``~``. + operator: A known unary spelling (``NOT``, ``+``, ``-``, + ``~``, ``|/``, ``||/``, ``@``, ``!!``, postfix ``!``, + ``#``, ``@-@``, ``@@``, ``?-``, ``?|``). ``!`` is + postfix factorial, not Hive prefix NOT. expr: The operand. Returns: @@ -365,12 +387,29 @@ class UnaryExpr(Expr): ValueError: If ``operator`` is not a known unary operator. """ - def get_operator(self) -> typing.Literal["NOT", "+", "-", "~"]: + def get_operator( + self, + ) -> typing.Literal[ + "NOT", + "+", + "-", + "~", + "@-@", + "@@", + "#", + "@", + "||/", + "!", + "!!", + "|/", + "?-", + "?|", + ]: """ Return the unary operator spelling. Returns: - ``NOT``, ``+``, ``-``, or ``~``. + The canonical SQL token (``NOT``, ``|/``, ``!``, …). """ def get_expr(self) -> Expr: @@ -509,6 +548,59 @@ class IsExpr(Expr): ``True`` if the predicate is negated. """ +@final +class IsNormalizedExpr(Expr): + """``expr IS [NOT] [form] NORMALIZED``.""" + + def __new__( + cls, + expr: object, + form: typing.Literal["NFC", "NFD", "NFKC", "NFKD"] | str | None = None, + /, + *, + negated: bool = False, + ) -> Self: + """ + Create an ``IS [form] NORMALIZED`` predicate. + + Args: + expr: The tested expression. + form: Optional ``NFC``, ``NFD``, ``NFKC``, or ``NFKD``. + Omitted form emits ``IS NORMALIZED`` (PostgreSQL + defaults to NFC). + negated: If ``True``, emit ``IS NOT``. + + Returns: + A new ``IsNormalizedExpr``. + + Raises: + ValueError: If ``form`` is not one of the accepted spellings. + """ + + def get_expr(self) -> Expr: + """ + Return the tested expression. + + Returns: + The left-hand expression. + """ + + def get_form(self) -> typing.Literal["NFC", "NFD", "NFKC", "NFKD"] | None: + """ + Return the normalization form, if any. + + Returns: + ``NFC``, ``NFD``, ``NFKC``, ``NFKD``, or ``None``. + """ + + def get_negated(self) -> bool: + """ + Return whether this is ``IS NOT … NORMALIZED``. + + Returns: + ``True`` if the predicate is negated. + """ + @final class IsDistinctFromExpr(Expr): """``a IS [NOT] DISTINCT FROM b``.""" diff --git a/src/expr/base.rs b/src/expr/base.rs index 0967b8a..f489a61 100644 --- a/src/expr/base.rs +++ b/src/expr/base.rs @@ -64,6 +64,7 @@ impl PyExpr { ExprEnum::Binary(_) => wrap::(py, self), ExprEnum::Function(_) => wrap::(py, self), ExprEnum::Is(_) => wrap::(py, self), + ExprEnum::IsNormalized(_) => wrap::(py, self), ExprEnum::IsDistinctFrom(_) => wrap::(py, self), ExprEnum::InList(_) => wrap::(py, self), ExprEnum::Between(_) => wrap::(py, self), diff --git a/src/expr/enum_/mod.rs b/src/expr/enum_/mod.rs index 19ed33c..b9d2637 100644 --- a/src/expr/enum_/mod.rs +++ b/src/expr/enum_/mod.rs @@ -32,6 +32,14 @@ pub struct Is { pub kind: IsKind, } +/// `expr IS [NOT] [form] NORMALIZED`. +#[derive(Clone, Debug, PartialEq)] +pub struct IsNormalized { + pub expr: Box, + pub negated: bool, + pub form: Option, +} + #[derive(Clone, Debug, PartialEq)] pub struct IsDistinctFrom { pub left: Box, @@ -127,6 +135,8 @@ pub enum ExprEnum { Function(Function), /// IS [NOT] Is(Is), + /// IS [NOT] [form] NORMALIZED + IsNormalized(IsNormalized), /// IS [NOT] DISTINCT FROM IsDistinctFrom(IsDistinctFrom), /// [NOT] IN @@ -193,6 +203,13 @@ impl From for ExprEnum { } } +impl From for ExprEnum { + #[inline] + fn from(value: IsNormalized) -> Self { + Self::IsNormalized(value) + } +} + impl From for ExprEnum { #[inline] fn from(value: IsDistinctFrom) -> Self { @@ -297,6 +314,7 @@ impl ExprEnum { BinaryOp::Or => 8, }, Self::Is(_) + | Self::IsNormalized(_) | Self::IsDistinctFrom(_) | Self::InList(_) | Self::Between(_) @@ -361,6 +379,21 @@ impl ExprEnum { buf.push(')'); Ok(buf) } + Self::IsNormalized(IsNormalized { + expr, + negated, + form, + }) => { + let mut buf = format!("IsNormalizedExpr({}", expr.python_repr(py)?); + if let Some(form) = form { + write!(buf, ", {:?}", form.as_sql()).unwrap(); + } + if *negated { + buf.push_str(", negated=True"); + } + buf.push(')'); + Ok(buf) + } Self::IsDistinctFrom(IsDistinctFrom { left, right, @@ -626,17 +659,33 @@ impl WriteSql for ExprEnum { out.push_char(')'); Ok(()) } - Self::Unary(Unary { op, expr }) => { - match op { - UnaryOp::Not => { - out.push_str("NOT "); - } - UnaryOp::Plus | UnaryOp::Minus | UnaryOp::BitNot => { - out.push_str(op.as_sql()); - } + Self::Unary(Unary { op, expr }) => match op { + UnaryOp::PGPostfixFactorial => { + expr.write_operand(dialect, out, self.precedence(), true)?; + out.push_char('!'); + Ok(()) } - expr.write_operand(dialect, out, self.precedence(), true) - } + UnaryOp::Not + | UnaryOp::Hash + | UnaryOp::AtDashAt + | UnaryOp::DoubleAt + | UnaryOp::QuestionDash + | UnaryOp::QuestionPipe => { + out.push_str(op.as_sql()); + out.push_char(' '); + expr.write_operand(dialect, out, self.precedence(), true) + } + UnaryOp::Plus + | UnaryOp::Minus + | UnaryOp::BitNot + | UnaryOp::PGAbs + | UnaryOp::PGSquareRoot + | UnaryOp::PGCubeRoot + | UnaryOp::PGPrefixFactorial => { + out.push_str(op.as_sql()); + expr.write_operand(dialect, out, self.precedence(), true) + } + }, Self::Binary(Binary { left, op, right }) => { let prec = self.precedence(); left.write_operand(dialect, out, prec, false)?; @@ -660,6 +709,24 @@ impl WriteSql for ExprEnum { out.push_str(kind.as_sql()); Ok(()) } + Self::IsNormalized(IsNormalized { + expr, + negated, + form, + }) => { + expr.write_operand(dialect, out, self.precedence(), false)?; + if *negated { + out.push_str(" IS NOT "); + } else { + out.push_str(" IS "); + } + if let Some(form) = form { + out.push_str(form.as_sql()); + out.push_char(' '); + } + out.push_str("NORMALIZED"); + Ok(()) + } Self::IsDistinctFrom(IsDistinctFrom { left, right, diff --git a/src/expr/enum_/operator.rs b/src/expr/enum_/operator.rs index dfa5b80..36843fa 100644 --- a/src/expr/enum_/operator.rs +++ b/src/expr/enum_/operator.rs @@ -5,6 +5,26 @@ pub enum UnaryOp { Plus, Minus, BitNot, + /// `@-@` — PostgreSQL/Redshift geometric length. + AtDashAt, + /// `@@` — PostgreSQL/Redshift geometric center. + DoubleAt, + /// `#` — PostgreSQL/Redshift geometric npoints. + Hash, + /// `@` — PostgreSQL absolute value. + PGAbs, + /// `||/` — PostgreSQL cube root. + PGCubeRoot, + /// `!` postfix — PostgreSQL factorial (`9!`). + PGPostfixFactorial, + /// `!!` — PostgreSQL prefix factorial. + PGPrefixFactorial, + /// `|/` — PostgreSQL square root. + PGSquareRoot, + /// `?-` — PostgreSQL/Redshift geometric is-horizontal. + QuestionDash, + /// `?|` — PostgreSQL/Redshift geometric is-vertical. + QuestionPipe, } impl UnaryOp { @@ -15,6 +35,16 @@ impl UnaryOp { "+" => Some(Self::Plus), "-" => Some(Self::Minus), "~" => Some(Self::BitNot), + "@-@" => Some(Self::AtDashAt), + "@@" => Some(Self::DoubleAt), + "#" => Some(Self::Hash), + "@" => Some(Self::PGAbs), + "||/" => Some(Self::PGCubeRoot), + "!" => Some(Self::PGPostfixFactorial), + "!!" => Some(Self::PGPrefixFactorial), + "|/" => Some(Self::PGSquareRoot), + "?-" => Some(Self::QuestionDash), + "?|" => Some(Self::QuestionPipe), _ => None, } } @@ -26,6 +56,16 @@ impl UnaryOp { Self::Plus => "+", Self::Minus => "-", Self::BitNot => "~", + Self::AtDashAt => "@-@", + Self::DoubleAt => "@@", + Self::Hash => "#", + Self::PGAbs => "@", + Self::PGCubeRoot => "||/", + Self::PGPostfixFactorial => "!", + Self::PGPrefixFactorial => "!!", + Self::PGSquareRoot => "|/", + Self::QuestionDash => "?-", + Self::QuestionPipe => "?|", } } } @@ -146,3 +186,39 @@ impl IsKind { } } } + +/// Unicode normalization form for [`super::IsNormalized`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum NormalizationForm { + Nfc, + Nfd, + Nfkc, + Nfkd, +} + +impl NormalizationForm { + #[inline] + pub fn parse(s: &str) -> Option { + if s.eq_ignore_ascii_case("NFC") { + Some(Self::Nfc) + } else if s.eq_ignore_ascii_case("NFD") { + Some(Self::Nfd) + } else if s.eq_ignore_ascii_case("NFKC") { + Some(Self::Nfkc) + } else if s.eq_ignore_ascii_case("NFKD") { + Some(Self::Nfkd) + } else { + None + } + } + + #[inline] + pub fn as_sql(self) -> &'static str { + match self { + Self::Nfc => "NFC", + Self::Nfd => "NFD", + Self::Nfkc => "NFKC", + Self::Nfkd => "NFKD", + } + } +} diff --git a/src/expr/mod.rs b/src/expr/mod.rs index c5effc5..7f5f7ec 100644 --- a/src/expr/mod.rs +++ b/src/expr/mod.rs @@ -23,7 +23,7 @@ pub mod expr_mod { #[pymodule_export] use super::nodes::{ PyBetweenExpr, PyBinaryExpr, PyCaseExpr, PyCastExpr, PyCustomExpr, PyInListExpr, - PyIsDistinctFromExpr, PyIsExpr, PyLikeExpr, PyRLikeExpr, PySimilarToExpr, PyTupleExpr, - PyUnaryExpr, + PyIsDistinctFromExpr, PyIsExpr, PyIsNormalizedExpr, PyLikeExpr, PyRLikeExpr, + PySimilarToExpr, PyTupleExpr, PyUnaryExpr, }; } diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index b455136..e9b3d5e 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -47,7 +47,7 @@ empty_node! { /// Parenthesized row / tuple expression `(a, b, …)`. PyTupleExpr(Tuple([ExprEnum])) => "TupleExpr", - /// Unary expression (`NOT x`, `-x`, `+x`, `~x`). + /// Unary expression (`NOT x`, `-x`, `|/x`, `x!`, …). PyUnaryExpr(Unary(super::enum_::Unary)) => "UnaryExpr", /// Binary expression (`a + b`, `a AND b`, custom operators). @@ -56,6 +56,9 @@ empty_node! { /// `expr IS [NOT] {NULL|TRUE|FALSE|UNKNOWN}`. PyIsExpr(Is(super::enum_::Is)) => "IsExpr", + /// `expr IS [NOT] [form] NORMALIZED`. + PyIsNormalizedExpr(IsNormalized(super::enum_::IsNormalized)) => "IsNormalizedExpr", + /// `a IS [NOT] DISTINCT FROM b`. PyIsDistinctFromExpr(IsDistinctFrom(super::enum_::IsDistinctFrom)) => "IsDistinctFromExpr", @@ -222,6 +225,53 @@ impl PyIsExpr { } } +#[pyo3::pymethods] +impl PyIsNormalizedExpr { + #[new] + #[pyo3(signature = (expr, form = None, /, *, negated = false))] + fn __new__( + expr: LiteralExprExtractor, + form: Option<&str>, + negated: bool, + ) -> pyo3::PyResult> { + let form = match form { + None => None, + Some(s) => { + let Some(parsed) = super::enum_::NormalizationForm::parse(s) else { + return rapidquery_macros::pyerr!( + Err(PyValueError), + "unknown normalization form {:?}; expected NFC, NFD, NFKC, or NFKD", + s + ); + }; + Some(parsed) + } + }; + + let inner = super::enum_::IsNormalized { + expr: Box::new(expr.0), + negated, + form, + }; + Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) + } + + fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), (*inner.expr).clone()) + } + + fn get_form(slf: pyo3::PyRef<'_, Self>) -> Option<&'static str> { + let inner = Self::inner(&slf); + inner.form.map(super::enum_::NormalizationForm::as_sql) + } + + fn get_negated(slf: pyo3::PyRef<'_, Self>) -> bool { + let inner = Self::inner(&slf); + inner.negated + } +} + #[pyo3::pymethods] impl PyIsDistinctFromExpr { #[new] diff --git a/src/lib.rs b/src/lib.rs index 1fe9411..acda5d0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,10 +9,7 @@ pub mod sql; pub mod typeref; // Tasks until being stable (until this part) -// - Support `AtDashAt`, `DoubleAt`, `Hash`, `PGAbs`, `PGCubeRoot`, `PGPostfixFactorial`, -// `PGPrefixFactorial`, `PGSquareRoot`, `QuestionDash`, and `QuestionPipe` in UnaryExpr -// - Support IsNormalizedExpr -// - Support `TryCast`, `SafeCast`, and `DoubleColon` kinds in CastExpr +// - Support `TryCast`, `SafeCast` kinds in CastExpr // - Support `array` and `format` in CastExpr // - Support TrimExpr // - Support CollateExpr diff --git a/tests/test_expr.py b/tests/test_expr.py index b7c8580..d6cc04c 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -14,6 +14,7 @@ InListExpr, IsDistinctFromExpr, IsExpr, + IsNormalizedExpr, LikeExpr, RLikeExpr, SimilarToExpr, @@ -38,6 +39,7 @@ def test_nodes_are_expr_subclasses(self): UnaryExpr("-", 1), BinaryExpr(1, "+", 2), IsExpr(Ident("id"), "NULL"), + IsNormalizedExpr(Ident("s")), IsDistinctFromExpr(1, 2), InListExpr(Ident("id"), [1]), BetweenExpr(Ident("id"), 0, 1), @@ -281,8 +283,22 @@ def test_new(self): un = UnaryExpr("~", 1) assert un.get_operator() == "~" + for token in ( + "@-@", + "@@", + "#", + "@", + "||/", + "!", + "!!", + "|/", + "?-", + "?|", + ): + assert UnaryExpr(token, 1).get_operator() == token + with pytest.raises(ValueError, match="unknown unary operator"): - UnaryExpr("!", 1) + UnaryExpr("bang", 1) def test_to_sql(self): un = UnaryExpr("-", Function("now").call()) @@ -300,6 +316,25 @@ def test_to_sql(self): assert sql == "-:param_0" assert params == {"param_0": 1} + assert UnaryExpr("|/", 9).to_sql(PostgreSQL) == "|/9" + assert UnaryExpr("||/", 27).to_sql(PostgreSQL) == "||/27" + assert UnaryExpr("!!", 1).to_sql(PostgreSQL) == "!!1" + assert UnaryExpr("@", 1).to_sql(PostgreSQL) == "@1" + assert UnaryExpr("!", 9).to_sql(PostgreSQL) == "9!" + assert UnaryExpr("#", Ident("path")).to_sql(PostgreSQL) == '# "path"' + assert UnaryExpr("@-@", Ident("path")).to_sql(PostgreSQL) == '@-@ "path"' + assert UnaryExpr("@@", Ident("path")).to_sql(PostgreSQL) == '@@ "path"' + assert UnaryExpr("?-", Ident("path")).to_sql(PostgreSQL) == '?- "path"' + assert UnaryExpr("?|", Ident("path")).to_sql(PostgreSQL) == '?| "path"' + + assert UnaryExpr("!", UnaryExpr("-", 1)).to_sql(PostgreSQL) == "(-1)!" + assert UnaryExpr("-", UnaryExpr("!", 1)).to_sql(PostgreSQL) == "-(1!)" + assert UnaryExpr("!", UnaryExpr("!", 1)).to_sql(PostgreSQL) == "(1!)!" + + sql, params = UnaryExpr("|/", 9).prepare(PostgreSQL) + assert sql == "|/:param_0" + assert params == {"param_0": 9} + class TestBinaryExpr: def test_new(self): @@ -405,6 +440,63 @@ def test_repr(self): ) +class TestIsNormalizedExpr: + def test_new(self): + norm = IsNormalizedExpr(Ident("s")) + assert norm.get_expr() == Ident("s") + assert norm.get_form() is None + assert norm.get_negated() is False + + norm = IsNormalizedExpr("cafĆ©", "nfc") + assert norm.get_expr() == Value("cafĆ©") + assert norm.get_form() == "NFC" + assert norm.get_negated() is False + + norm = IsNormalizedExpr(Ident("s"), "NFKD", negated=True) + assert norm.get_expr() == Ident("s") + assert norm.get_form() == "NFKD" + assert norm.get_negated() is True + + with pytest.raises(ValueError, match="unknown normalization form"): + IsNormalizedExpr(Ident("s"), "NFKC2") + + def test_to_sql(self): + assert IsNormalizedExpr(Ident("s")).to_sql(PostgreSQL) == ( + '"s" IS NORMALIZED' + ) + assert IsNormalizedExpr(Ident("s"), negated=True).to_sql(PostgreSQL) == ( + '"s" IS NOT NORMALIZED' + ) + assert IsNormalizedExpr(Ident("s"), "nFd").to_sql(PostgreSQL) == ( + '"s" IS NFD NORMALIZED' + ) + assert IsNormalizedExpr(Ident("s"), "NFKC", negated=True).to_sql( + PostgreSQL + ) == '"s" IS NOT NFKC NORMALIZED' + + sql, params = IsNormalizedExpr(Ident("s"), "NFC").prepare(PostgreSQL) + assert sql == '"s" IS NFC NORMALIZED' + assert params == {} + + sql, params = IsNormalizedExpr("cafĆ©", "NFC").prepare(PostgreSQL) + assert sql == ":param_0 IS NFC NORMALIZED" + assert params == {"param_0": "cafĆ©"} + + assert repr(IsNormalizedExpr(Ident("s"))) == 'IsNormalizedExpr(Ident("s"))' + assert ( + repr(IsNormalizedExpr(Ident("s"), "NFC")) + == 'IsNormalizedExpr(Ident("s"), "NFC")' + ) + assert ( + repr(IsNormalizedExpr(Ident("s"), negated=True)) + == 'IsNormalizedExpr(Ident("s"), negated=True)' + ) + assert ( + repr(IsNormalizedExpr(Ident("s"), "NFC", negated=True)) + == 'IsNormalizedExpr(Ident("s"), "NFC", negated=True)' + ) + + class TestIsDistinctFromExpr: def test_new(self): idf = IsDistinctFromExpr(2, 3) From 41fb3b33f686317024838df91b8435f0d0b0e250 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 21 Sep 2026 15:19:17 +0330 Subject: [PATCH 40/87] Support TRY_CAST and SAFE_CAST --- rapidquery/expr.pyi | 15 +++++++-- src/data_type/mod.rs | 49 ++++++++++++++++++++++----- src/expr/enum_/mod.rs | 21 +++++++++--- src/expr/enum_/operator.rs | 32 ++++++++++++++++++ src/expr/nodes.rs | 29 +++++++++++----- src/lib.rs | 1 - tests/test_expr.py | 68 +++++++++++++++++++++++--------------- 7 files changed, 163 insertions(+), 52 deletions(-) diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index c38a5e2..33259a0 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -931,13 +931,20 @@ class RLikeExpr(Expr): class CastExpr(Expr): """``CAST(expr AS type)``.""" - def __new__(cls, expr: object, to: str, /) -> Self: + def __new__( + cls, + expr: object, + data_type_name: str | DataType, + /, + *, + kind: typing.Literal["CAST", "TRY_CAST", "SAFE_CAST"] | str = "CAST", + ) -> Self: """ Create a ``CAST`` expression. Args: expr: The expression to cast. - to: SQL type name (``INT``, ``VARCHAR(255)``, …). Known + data_type_name: SQL type name (``INT``, ``VARCHAR(255)``, …). Known names are canonicalized the same way as ``DataType``. Returns: @@ -952,7 +959,7 @@ class CastExpr(Expr): The inner expression. """ - def get_data_type(self) -> str: + def get_data_type_name(self) -> str: """ Return the target SQL type name. @@ -960,6 +967,8 @@ class CastExpr(Expr): The canonical type spelling. """ + def get_kind(self) -> typing.Literal["CAST", "TRY_CAST", "SAFE_CAST"]: ... + @final class CaseExpr(Expr): """ diff --git a/src/data_type/mod.rs b/src/data_type/mod.rs index 4a8aa3d..9dd41aa 100644 --- a/src/data_type/mod.rs +++ b/src/data_type/mod.rs @@ -1,10 +1,10 @@ -// //! SQL data types: interned names, serializers, and the composed [`DataType`]. -// //! -// //! A [`DataTypeName`] is a canonical spelling plus a [`DataTypeGroup`]. The -// //! group is the inference key for which [`Serializer`] [`DataType::from_name`] -// //! constructs when none is passed. Groups are serializer-oriented: `NUMERIC` -// //! and `DECIMAL` sit in [`DataTypeGroup::Float`] because they share a -// //! serializer with IEEE floats. +//! SQL data types: interned names, serializers, and the composed [`DataType`]. +//! +//! A [`DataTypeName`] is a canonical spelling plus a [`DataTypeGroup`]. The +//! group is the inference key for which [`Serializer`] [`DataType::from_name`] +//! constructs when none is passed. Groups are serializer-oriented: `NUMERIC` +//! and `DECIMAL` sit in [`DataTypeGroup::Float`] because they share a +//! serializer with IEEE floats. mod name; mod serializer; @@ -15,7 +15,7 @@ mod tests; pub use name::{DataTypeGroup, DataTypeName}; pub use serializer::*; -use crate::internal::alias; +use crate::internal::{alias, utils}; /// Represents a SQL data type. /// @@ -156,6 +156,39 @@ impl PyDataType { } } +/// An extractor for DataTypeName, which accepts `str` and `DataType` +pub struct DataTypeNameExtractor(pub DataTypeName); + +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for DataTypeNameExtractor { + type Error = pyo3::PyErr; + + fn extract( + obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, + ) -> Result { + let ptr = obj.as_ptr(); + + unsafe { + // SAFETY: `ptr` is a live borrowed `PyObject`. Type pointers are + // interned in `initialize_typeref` before any extractor runs. + if crate::typeref::check(ptr, crate::typeref::DATATYPE_TYPE) { + let dt = obj.cast_unchecked::(); + return Ok(Self(dt.get().name.clone())); + } + + if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { + let borrowed = utils::borrow_py_str_buffer(obj.py(), ptr)?; + return Ok(Self(DataTypeName::from(borrowed))); + } + } + + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected Serializer or DataType, got {}", + unsafe { crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) } + ) + } +} + #[pyo3::pymodule(name = "data_type", gil_used = false)] pub mod data_type_mod { #[pymodule_export] diff --git a/src/expr/enum_/mod.rs b/src/expr/enum_/mod.rs index b9d2637..c5988b7 100644 --- a/src/expr/enum_/mod.rs +++ b/src/expr/enum_/mod.rs @@ -98,8 +98,9 @@ pub struct RLike { #[derive(Clone, Debug, PartialEq)] pub struct Cast { + pub kind: CastKind, pub expr: Box, - pub data_type: crate::data_type::DataTypeName, + pub data_type_name: crate::data_type::DataTypeName, } #[derive(Clone, Debug, PartialEq)] @@ -510,10 +511,15 @@ impl ExprEnum { buf.push(')'); Ok(buf) } - Self::Cast(Cast { expr, data_type }) => Ok(format!( - "CastExpr({}, {:?})", + Self::Cast(Cast { + kind, + expr, + data_type_name: data_type, + }) => Ok(format!( + "CastExpr({}, {:?}, kind={:?})", expr.python_repr(py)?, data_type.as_str(), + kind.as_sql() )), Self::Case(Case { operand, @@ -810,8 +816,13 @@ impl WriteSql for ExprEnum { pattern.write_operand(dialect, out, prec, true)?; write_escape(dialect, out, *escape) } - Self::Cast(Cast { expr, data_type }) => { - out.push_str("CAST("); + Self::Cast(Cast { + kind, + expr, + data_type_name: data_type, + }) => { + out.push_str(kind.as_sql()); + out.push_char('('); expr.write_sql(dialect, out)?; out.push_str(" AS "); data_type.write_sql(dialect, out)?; diff --git a/src/expr/enum_/operator.rs b/src/expr/enum_/operator.rs index 36843fa..f82d91e 100644 --- a/src/expr/enum_/operator.rs +++ b/src/expr/enum_/operator.rs @@ -222,3 +222,35 @@ impl NormalizationForm { } } } + +/// Unary operator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CastKind { + Cast, + TryCast, + SafeCast, +} + +impl CastKind { + #[inline] + pub fn parse(s: &str) -> Option { + if s.eq_ignore_ascii_case("CAST") { + Some(Self::Cast) + } else if s.eq_ignore_ascii_case("TRY_CAST") { + Some(Self::TryCast) + } else if s.eq_ignore_ascii_case("SAFE_CAST") { + Some(Self::SafeCast) + } else { + None + } + } + + #[inline] + pub fn as_sql(self) -> &'static str { + match self { + Self::Cast => "CAST", + Self::TryCast => "TRY_CAST", + Self::SafeCast => "SAFE_CAST", + } + } +} diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index e9b3d5e..03d9d7a 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -3,7 +3,7 @@ use pyo3::types::{PyAnyMethods, PyTupleMethods}; use super::base::PyExpr; use super::enum_::ExprEnum; use super::extractors::{ConditionExtractor, LiteralExprExtractor, extract_like_pattern}; -use crate::data_type::DataTypeName; +use crate::data_type::DataTypeNameExtractor; use crate::internal::{alias, utils}; macro_rules! empty_node { @@ -549,14 +549,22 @@ impl PyRLikeExpr { #[pyo3::pymethods] impl PyCastExpr { #[new] - #[pyo3(signature = (expr, to, /))] - fn __new__(expr: LiteralExprExtractor, to: String) -> pyo3::PyClassInitializer { - let data_type_name = DataTypeName::from(to); + #[pyo3(signature = (expr, data_type_name, /, *, kind=String::from("CAST")))] + fn __new__( + expr: LiteralExprExtractor, + data_type_name: DataTypeNameExtractor, + kind: String, + ) -> pyo3::PyResult> { + let Some(kind) = super::enum_::CastKind::parse(&kind) else { + return rapidquery_macros::pyerr!(Err(PyValueError), "unknown cast kind {:?}", kind); + }; + let inner = super::enum_::Cast { + kind, expr: Box::new(expr.0), - data_type: data_type_name, + data_type_name: data_type_name.0, }; - pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self) + Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) } fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { @@ -564,9 +572,14 @@ impl PyCastExpr { super::base::into_subclasses(slf.py(), (*inner.expr).clone()) } - fn get_data_type(slf: pyo3::PyRef<'_, Self>) -> String { + fn get_data_type_name(slf: pyo3::PyRef<'_, Self>) -> String { + let inner = Self::inner(&slf); + inner.data_type_name.to_string() + } + + fn get_kind(slf: pyo3::PyRef<'_, Self>) -> &'static str { let inner = Self::inner(&slf); - inner.data_type.to_string() + inner.kind.as_sql() } } #[pyo3::pymethods] diff --git a/src/lib.rs b/src/lib.rs index acda5d0..249bae3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,6 @@ pub mod sql; pub mod typeref; // Tasks until being stable (until this part) -// - Support `TryCast`, `SafeCast` kinds in CastExpr // - Support `array` and `format` in CastExpr // - Support TrimExpr // - Support CollateExpr diff --git a/tests/test_expr.py b/tests/test_expr.py index d6cc04c..01add86 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -1,6 +1,14 @@ import pytest -from rapidquery.dialect import BigQuery, DuckDB, PostgreSQL, SQLite, Snowflake, SqlDialect +from rapidquery.data_type import DataType +from rapidquery.dialect import ( + BigQuery, + DuckDB, + PostgreSQL, + Snowflake, + SqlDialect, + SQLite, +) from rapidquery.expr import ( Asterisk, BetweenExpr, @@ -461,18 +469,17 @@ def test_new(self): IsNormalizedExpr(Ident("s"), "NFKC2") def test_to_sql(self): - assert IsNormalizedExpr(Ident("s")).to_sql(PostgreSQL) == ( - '"s" IS NORMALIZED' - ) + assert IsNormalizedExpr(Ident("s")).to_sql(PostgreSQL) == ('"s" IS NORMALIZED') assert IsNormalizedExpr(Ident("s"), negated=True).to_sql(PostgreSQL) == ( '"s" IS NOT NORMALIZED' ) assert IsNormalizedExpr(Ident("s"), "nFd").to_sql(PostgreSQL) == ( '"s" IS NFD NORMALIZED' ) - assert IsNormalizedExpr(Ident("s"), "NFKC", negated=True).to_sql( - PostgreSQL - ) == '"s" IS NOT NFKC NORMALIZED' + assert ( + IsNormalizedExpr(Ident("s"), "NFKC", negated=True).to_sql(PostgreSQL) + == '"s" IS NOT NFKC NORMALIZED' + ) sql, params = IsNormalizedExpr(Ident("s"), "NFC").prepare(PostgreSQL) assert sql == '"s" IS NFC NORMALIZED' @@ -662,13 +669,9 @@ def test_to_sql(self): any_like = LikeExpr(Ident("name"), ["%a%", "%b%"]) assert ( - any_like.to_sql(PostgreSQL) - == "\"name\" LIKE ANY (ARRAY[E'%a%', E'%b%'])" - ) - assert ( - any_like.to_sql(Snowflake) - == "\"name\" LIKE ANY ('%a%', '%b%')" + any_like.to_sql(PostgreSQL) == "\"name\" LIKE ANY (ARRAY[E'%a%', E'%b%'])" ) + assert any_like.to_sql(Snowflake) == "\"name\" LIKE ANY ('%a%', '%b%')" assert any_like.to_sql(SQLite) == ( "(\"name\" LIKE '%a%' OR \"name\" LIKE '%b%')" ) @@ -682,20 +685,14 @@ def test_to_sql(self): negated.to_sql(PostgreSQL) == "NOT (\"name\" LIKE ANY (ARRAY[E'%a%', E'%b%']))" ) - assert ( - negated.to_sql(Snowflake) - == "\"name\" NOT LIKE ANY ('%a%', '%b%')" - ) + assert negated.to_sql(Snowflake) == "\"name\" NOT LIKE ANY ('%a%', '%b%')" assert negated.to_sql(SQLite) == ( "(\"name\" NOT LIKE '%a%' AND \"name\" NOT LIKE '%b%')" ) - ilike_any = LikeExpr( - Ident("name"), ["%a%", "%b%"], case_sensitive=False - ) + ilike_any = LikeExpr(Ident("name"), ["%a%", "%b%"], case_sensitive=False) assert ( - ilike_any.to_sql(PostgreSQL) - == "\"name\" ILIKE ANY (ARRAY[E'%a%', E'%b%'])" + ilike_any.to_sql(PostgreSQL) == "\"name\" ILIKE ANY (ARRAY[E'%a%', E'%b%'])" ) escaped = LikeExpr(Ident("name"), ["%a%", "%b%"], escape="!") @@ -818,16 +815,33 @@ class TestCastExpr: def test_new(self): cast = CastExpr(Ident("id"), "int") assert cast.get_expr() == Ident("id") - assert cast.get_data_type() == "INT" + assert cast.get_data_type_name() == "INT" + assert cast.get_kind() == "CAST" - cast = CastExpr(1, "varchar(255)") + cast = CastExpr(1, "varchar(255)", kind="TRY_CAST") assert cast.get_expr() == Value(1) - assert cast.get_data_type() == "varchar(255)" + assert cast.get_data_type_name() == "varchar(255)" + assert cast.get_kind() == "TRY_CAST" + + cast = CastExpr(Ident("x"), "some_totally_unknown_type", kind="cAsT") + assert cast.get_data_type_name() == "some_totally_unknown_type" + assert cast.get_kind() == "CAST" - cast = CastExpr(Ident("x"), "some_totally_unknown_type") - assert cast.get_data_type() == "some_totally_unknown_type" + dt = DataType("integer") + cast = CastExpr(Ident("x"), dt, kind="SAFE_CAST") + assert cast.get_data_type_name() == dt.name + assert cast.get_kind() == "SAFE_CAST" def test_to_sql(self): + assert CastExpr(Ident("id"), "int").to_sql(PostgreSQL) == 'CAST("id" AS INT)' + assert ( + CastExpr(Ident("id"), "int", kind="SAFE_CAST").to_sql(PostgreSQL) + == 'SAFE_CAST("id" AS INT)' + ) + assert ( + CastExpr(Ident("id"), "int", kind="TRY_CAST").to_sql(PostgreSQL) + == 'TRY_CAST("id" AS INT)' + ) assert CastExpr(Ident("id"), "int").to_sql(PostgreSQL) == 'CAST("id" AS INT)' assert CastExpr(1, "varchar(255)").to_sql(PostgreSQL) == ( "CAST(1 AS varchar(255))" From a0e1a1f8e9b7a0d02250653cb50fa676e2bf27fa Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 21 Sep 2026 16:14:21 +0330 Subject: [PATCH 41/87] Support format and at_time_zone in CastExpr Support TrimExpr Support CollateExpr --- rapidquery/expr.py | 4 + rapidquery/expr.pyi | 132 +++++++++++++++++++++++++++++++- src/expr/base.rs | 2 + src/expr/enum_/mod.rs | 130 ++++++++++++++++++++++++++++++- src/expr/enum_/operator.rs | 32 ++++++++ src/expr/extractors.rs | 35 +++++++++ src/expr/mod.rs | 6 +- src/expr/nodes.rs | 146 ++++++++++++++++++++++++++++++++++- src/lib.rs | 7 +- tests/test_expr.py | 151 +++++++++++++++++++++++++++++++++++++ 10 files changed, 627 insertions(+), 18 deletions(-) diff --git a/rapidquery/expr.py b/rapidquery/expr.py index 3e206fa..a64d335 100644 --- a/rapidquery/expr.py +++ b/rapidquery/expr.py @@ -8,6 +8,7 @@ BinaryExpr, CaseExpr, CastExpr, + CollateExpr, CustomExpr, Expr, Function, @@ -19,6 +20,7 @@ LikeExpr, RLikeExpr, SimilarToExpr, + TrimExpr, TupleExpr, UnaryExpr, Unquote, @@ -31,6 +33,7 @@ "BinaryExpr", "CaseExpr", "CastExpr", + "CollateExpr", "CustomExpr", "Expr", "Function", @@ -42,6 +45,7 @@ "LikeExpr", "RLikeExpr", "SimilarToExpr", + "TrimExpr", "TupleExpr", "UnaryExpr", "Unquote", diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index 33259a0..48e4e7c 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -929,7 +929,7 @@ class RLikeExpr(Expr): @final class CastExpr(Expr): - """``CAST(expr AS type)``.""" + """``CAST(expr AS type [FORMAT format [AT TIME ZONE tz]])``.""" def __new__( cls, @@ -938,17 +938,29 @@ class CastExpr(Expr): /, *, kind: typing.Literal["CAST", "TRY_CAST", "SAFE_CAST"] | str = "CAST", + format: object | None = None, + at_time_zone: object | None = None, ) -> Self: """ Create a ``CAST`` expression. Args: expr: The expression to cast. - data_type_name: SQL type name (``INT``, ``VARCHAR(255)``, …). Known - names are canonicalized the same way as ``DataType``. + data_type_name: SQL type name (``INT``, ``VARCHAR(255)``, + ``INT ARRAY``, …). Known names are canonicalized the + same way as ``DataType``. Array types are part of the + name (``INT ARRAY``), not a Cast flag. + kind: ``CAST``, ``TRY_CAST``, or ``SAFE_CAST``. + format: Optional BigQuery ``FORMAT`` expression. + at_time_zone: Optional ``AT TIME ZONE`` expression. Requires + ``format``. Returns: A new ``CastExpr``. + + Raises: + ValueError: If ``kind`` is unknown, or ``at_time_zone`` is + set without ``format``. """ def get_expr(self) -> Expr: @@ -969,6 +981,120 @@ class CastExpr(Expr): def get_kind(self) -> typing.Literal["CAST", "TRY_CAST", "SAFE_CAST"]: ... + def get_format(self) -> Expr | None: + """ + Return the ``FORMAT`` expression, if any. + + Returns: + The format expression, or ``None``. + """ + + def get_at_time_zone(self) -> Expr | None: + """ + Return the ``AT TIME ZONE`` expression, if any. + + Returns: + The time zone expression, or ``None``. + """ + +@final +class TrimExpr(Expr): + """``TRIM([BOTH|LEADING|TRAILING] [what FROM] expr[, chars…])``.""" + + def __new__( + cls, + expr: object, + /, + *, + where: typing.Literal["BOTH", "LEADING", "TRAILING"] | str | None = None, + what: object | None = None, + characters: Sequence[object] | None = None, + ) -> Self: + """ + Create a ``TRIM`` expression. + + Args: + expr: The string to trim. + where: Optional ``BOTH``, ``LEADING``, or ``TRAILING``. + what: Characters to remove in ``TRIM(what FROM expr)`` form. + characters: Sequence of extra arguments for + ``TRIM(expr, c1, c2)``. A ``str`` is not a sequence + (use ``what``). + + Returns: + A new ``TrimExpr``. + + Raises: + ValueError: If ``where`` is unknown, ``characters`` is empty, + or both ``what`` and ``characters`` are set. + TypeError: If ``characters`` is not a list or tuple. + """ + + def get_expr(self) -> Expr: + """ + Return the trimmed expression. + + Returns: + The source expression. + """ + + def get_where(self) -> typing.Literal["BOTH", "LEADING", "TRAILING"] | None: + """ + Return the trim side, if any. + + Returns: + ``BOTH``, ``LEADING``, ``TRAILING``, or ``None``. + """ + + def get_what(self) -> Expr | None: + """ + Return the ``FROM`` characters expression, if any. + + Returns: + The ``what`` expression, or ``None``. + """ + + def get_characters(self) -> tuple[Expr, ...] | None: + """ + Return the comma-list characters, if any. + + Returns: + A tuple of expressions, or ``None``. + """ + +@final +class CollateExpr(Expr): + """``expr COLLATE collation``.""" + + def __new__(cls, expr: object, collation: str | Unquote | Ident, /) -> Self: + """ + Create a ``COLLATE`` expression. + + Args: + expr: The expression to collate. + collation: Collation name as an identifier (``str`` is + split on dots like ``Function`` names). + + Returns: + A new ``CollateExpr``. + """ + + def get_expr(self) -> Expr: + """ + Return the collated expression. + + Returns: + The inner expression. + """ + + def get_collation(self) -> Ident: + """ + Return the collation identifier. + + Returns: + The collation name. + """ + @final class CaseExpr(Expr): """ diff --git a/src/expr/base.rs b/src/expr/base.rs index f489a61..98c0ee4 100644 --- a/src/expr/base.rs +++ b/src/expr/base.rs @@ -72,6 +72,8 @@ impl PyExpr { ExprEnum::SimilarTo(_) => wrap::(py, self), ExprEnum::RLike(_) => wrap::(py, self), ExprEnum::Cast(_) => wrap::(py, self), + ExprEnum::Trim(_) => wrap::(py, self), + ExprEnum::Collate(_) => wrap::(py, self), ExprEnum::Case(_) => wrap::(py, self), ExprEnum::Custom(_) => wrap::(py, self), } diff --git a/src/expr/enum_/mod.rs b/src/expr/enum_/mod.rs index c5988b7..0ce3e10 100644 --- a/src/expr/enum_/mod.rs +++ b/src/expr/enum_/mod.rs @@ -101,6 +101,25 @@ pub struct Cast { pub kind: CastKind, pub expr: Box, pub data_type_name: crate::data_type::DataTypeName, + pub format: Option>, + pub at_time_zone: Option>, +} + +/// `TRIM([BOTH|LEADING|TRAILING] [ FROM] [, chars…])`. +#[derive(Clone, Debug, PartialEq)] +pub struct Trim { + pub expr: Box, + pub where_: Option, + pub what: Option>, + /// Comma-list form `TRIM(expr, c1, c2)`. Never empty when `Some`. + pub characters: Option>, +} + +/// `expr COLLATE collation`. +#[derive(Clone, Debug, PartialEq)] +pub struct Collate { + pub expr: Box, + pub collation: Ident, } #[derive(Clone, Debug, PartialEq)] @@ -148,6 +167,8 @@ pub enum ExprEnum { SimilarTo(SimilarTo), RLike(RLike), Cast(Cast), + Trim(Trim), + Collate(Collate), Case(Case), Custom(Custom), } @@ -260,6 +281,20 @@ impl From for ExprEnum { } } +impl From for ExprEnum { + #[inline] + fn from(value: Trim) -> Self { + Self::Trim(value) + } +} + +impl From for ExprEnum { + #[inline] + fn from(value: Collate) -> Self { + Self::Collate(value) + } +} + impl From for ExprEnum { #[inline] fn from(value: Case) -> Self { @@ -291,6 +326,8 @@ impl ExprEnum { | Self::Tuple(_) | Self::Function(_) | Self::Cast(_) + | Self::Trim(_) + | Self::Collate(_) | Self::Case(_) | Self::Custom(_) => 0, Self::Unary(_) => 1, @@ -515,11 +552,51 @@ impl ExprEnum { kind, expr, data_type_name: data_type, - }) => Ok(format!( - "CastExpr({}, {:?}, kind={:?})", + format, + at_time_zone, + }) => { + let mut buf = format!( + "CastExpr({}, {:?}", + expr.python_repr(py)?, + data_type.as_str() + ); + if *kind != CastKind::Cast { + write!(buf, ", kind={:?}", kind.as_sql()).unwrap(); + } + if let Some(format) = format { + write!(buf, ", format={}", format.python_repr(py)?).unwrap(); + } + if let Some(tz) = at_time_zone { + write!(buf, ", at_time_zone={}", tz.python_repr(py)?).unwrap(); + } + buf.push(')'); + Ok(buf) + } + Self::Trim(Trim { + expr, + where_, + what, + characters, + }) => { + let mut buf = format!("TrimExpr({}", expr.python_repr(py)?); + if let Some(where_) = where_ { + write!(buf, ", where={:?}", where_.as_sql()).unwrap(); + } + if let Some(what) = what { + write!(buf, ", what={}", what.python_repr(py)?).unwrap(); + } + if let Some(characters) = characters { + buf.push_str(", characters=["); + join_reprs(py, &mut buf, characters)?; + buf.push(']'); + } + buf.push(')'); + Ok(buf) + } + Self::Collate(Collate { expr, collation }) => Ok(format!( + "CollateExpr({}, {})", expr.python_repr(py)?, - data_type.as_str(), - kind.as_sql() + collation.python_repr() )), Self::Case(Case { operand, @@ -820,15 +897,60 @@ impl WriteSql for ExprEnum { kind, expr, data_type_name: data_type, + format, + at_time_zone, }) => { out.push_str(kind.as_sql()); out.push_char('('); expr.write_sql(dialect, out)?; out.push_str(" AS "); data_type.write_sql(dialect, out)?; + if let Some(format) = format { + out.push_str(" FORMAT "); + format.write_sql(dialect, out)?; + if let Some(tz) = at_time_zone { + out.push_str(" AT TIME ZONE "); + tz.write_sql(dialect, out)?; + } + } + out.push_char(')'); + Ok(()) + } + Self::Trim(Trim { + expr, + where_, + what, + characters, + }) => { + out.push_str("TRIM("); + if let Some(where_) = where_ { + out.push_str(where_.as_sql()); + out.push_char(' '); + } + if let Some(what) = what { + what.write_sql(dialect, out)?; + out.push_str(" FROM "); + expr.write_sql(dialect, out)?; + } else if where_.is_some() { + out.push_str("FROM "); + expr.write_sql(dialect, out)?; + } else { + expr.write_sql(dialect, out)?; + } + if let Some(characters) = characters { + for c in characters { + out.push_str(", "); + c.write_sql(dialect, out)?; + } + } out.push_char(')'); Ok(()) } + Self::Collate(Collate { expr, collation }) => { + expr.write_operand(dialect, out, self.precedence(), false)?; + out.push_str(" COLLATE "); + collation.write_sql(dialect, out) + } Self::Case(Case { operand, when: when_then, diff --git a/src/expr/enum_/operator.rs b/src/expr/enum_/operator.rs index f82d91e..90eaeab 100644 --- a/src/expr/enum_/operator.rs +++ b/src/expr/enum_/operator.rs @@ -254,3 +254,35 @@ impl CastKind { } } } + +/// `BOTH` / `LEADING` / `TRAILING` in [`super::Trim`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TrimWhere { + Both, + Leading, + Trailing, +} + +impl TrimWhere { + #[inline] + pub fn parse(s: &str) -> Option { + if s.eq_ignore_ascii_case("BOTH") { + Some(Self::Both) + } else if s.eq_ignore_ascii_case("LEADING") { + Some(Self::Leading) + } else if s.eq_ignore_ascii_case("TRAILING") { + Some(Self::Trailing) + } else { + None + } + } + + #[inline] + pub fn as_sql(self) -> &'static str { + match self { + Self::Both => "BOTH", + Self::Leading => "LEADING", + Self::Trailing => "TRAILING", + } + } +} diff --git a/src/expr/extractors.rs b/src/expr/extractors.rs index 474afb0..5503a7f 100644 --- a/src/expr/extractors.rs +++ b/src/expr/extractors.rs @@ -125,6 +125,41 @@ fn extract_like_any<'py>( Ok(LikePattern::Any(items)) } +/// Comma-list `TRIM(expr, c1, c2)` characters: exact `list`/`tuple`, never empty. +pub fn extract_trim_characters( + obj: pyo3::prelude::Borrowed<'_, '_, pyo3::prelude::PyAny>, +) -> pyo3::PyResult> { + let ptr = obj.as_ptr(); + unsafe { + if pyo3::ffi::PyList_CheckExact(ptr) == 1 { + let list = obj.cast_unchecked::(); + return collect_trim_characters(list.iter()); + } + if pyo3::ffi::PyTuple_CheckExact(ptr) == 1 { + let tuple = obj.cast_unchecked::(); + return collect_trim_characters(tuple.iter()); + } + } + rapidquery_macros::pyerr!( + Err(PyTypeError), + "TRIM characters must be a list or tuple, got {}", + unsafe { crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) } + ) +} + +fn collect_trim_characters<'py>( + iter: impl IntoIterator>, +) -> pyo3::PyResult> { + let mut items = Vec::new(); + for item in iter { + items.push(item.extract::()?.0); + } + if items.is_empty() { + return rapidquery_macros::pyerr!(Err(PyValueError), "TRIM character list is empty"); + } + Ok(items) +} + pub(super) enum ExtractedIdentArgument { Ident(Ident), Part(IdentPart), diff --git a/src/expr/mod.rs b/src/expr/mod.rs index 7f5f7ec..cba542d 100644 --- a/src/expr/mod.rs +++ b/src/expr/mod.rs @@ -22,8 +22,8 @@ pub mod expr_mod { #[pymodule_export] use super::nodes::{ - PyBetweenExpr, PyBinaryExpr, PyCaseExpr, PyCastExpr, PyCustomExpr, PyInListExpr, - PyIsDistinctFromExpr, PyIsExpr, PyIsNormalizedExpr, PyLikeExpr, PyRLikeExpr, - PySimilarToExpr, PyTupleExpr, PyUnaryExpr, + PyBetweenExpr, PyBinaryExpr, PyCaseExpr, PyCastExpr, PyCollateExpr, PyCustomExpr, + PyInListExpr, PyIsDistinctFromExpr, PyIsExpr, PyIsNormalizedExpr, PyLikeExpr, + PyRLikeExpr, PySimilarToExpr, PyTrimExpr, PyTupleExpr, PyUnaryExpr, }; } diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index 03d9d7a..fd22235 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -2,7 +2,10 @@ use pyo3::types::{PyAnyMethods, PyTupleMethods}; use super::base::PyExpr; use super::enum_::ExprEnum; -use super::extractors::{ConditionExtractor, LiteralExprExtractor, extract_like_pattern}; +use super::extractors::{ + ConditionExtractor, IdentExtractor, LiteralExprExtractor, extract_like_pattern, + extract_trim_characters, +}; use crate::data_type::DataTypeNameExtractor; use crate::internal::{alias, utils}; @@ -77,9 +80,15 @@ empty_node! { /// `expr [NOT] RLIKE pattern`. PyRLikeExpr(RLike(super::enum_::RLike)) => "RLikeExpr", - /// `CAST(expr AS type)`. + /// `CAST(expr AS type [FORMAT …])`. PyCastExpr(Cast(super::enum_::Cast)) => "CastExpr", + /// `TRIM([BOTH|LEADING|TRAILING] [what FROM] expr)`. + PyTrimExpr(Trim(super::enum_::Trim)) => "TrimExpr", + + /// `expr COLLATE collation`. + PyCollateExpr(Collate(super::enum_::Collate)) => "CollateExpr", + /// `CASE [operand] WHEN … THEN … [ELSE …] END`. PyCaseExpr(Case(super::enum_::Case)) => "CaseExpr", @@ -549,20 +558,30 @@ impl PyRLikeExpr { #[pyo3::pymethods] impl PyCastExpr { #[new] - #[pyo3(signature = (expr, data_type_name, /, *, kind=String::from("CAST")))] + #[pyo3(signature = (expr, data_type_name, /, *, kind=String::from("CAST"), format=None, at_time_zone=None))] fn __new__( expr: LiteralExprExtractor, data_type_name: DataTypeNameExtractor, kind: String, + format: Option, + at_time_zone: Option, ) -> pyo3::PyResult> { let Some(kind) = super::enum_::CastKind::parse(&kind) else { return rapidquery_macros::pyerr!(Err(PyValueError), "unknown cast kind {:?}", kind); }; + if format.is_none() && at_time_zone.is_some() { + return rapidquery_macros::pyerr!( + Err(PyValueError), + "AT TIME ZONE requires FORMAT" + ); + } let inner = super::enum_::Cast { kind, expr: Box::new(expr.0), data_type_name: data_type_name.0, + format: format.map(|LiteralExprExtractor(e)| Box::new(e)), + at_time_zone: at_time_zone.map(|LiteralExprExtractor(e)| Box::new(e)), }; Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) } @@ -581,6 +600,127 @@ impl PyCastExpr { let inner = Self::inner(&slf); inner.kind.as_sql() } + + fn get_format(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let inner = Self::inner(&slf); + match inner.format.as_ref() { + Some(expr) => super::base::into_subclasses(slf.py(), (**expr).clone()), + None => Ok(utils::new_pynone(slf.py())), + } + } + + fn get_at_time_zone(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let inner = Self::inner(&slf); + match inner.at_time_zone.as_ref() { + Some(expr) => super::base::into_subclasses(slf.py(), (**expr).clone()), + None => Ok(utils::new_pynone(slf.py())), + } + } +} + +#[pyo3::pymethods] +impl PyTrimExpr { + #[new] + #[pyo3(signature = (expr, /, *, r#where = None, what = None, characters = None))] + fn __new__( + expr: LiteralExprExtractor, + r#where: Option<&str>, + what: Option, + characters: Option>, + ) -> pyo3::PyResult> { + if what.is_some() && characters.is_some() { + return rapidquery_macros::pyerr!( + Err(PyValueError), + "TRIM what and characters cannot both be set" + ); + } + + let where_ = match r#where { + None => None, + Some(s) => { + let Some(parsed) = super::enum_::TrimWhere::parse(s) else { + return rapidquery_macros::pyerr!( + Err(PyValueError), + "unknown TRIM where {:?}; expected BOTH, LEADING, or TRAILING", + s + ); + }; + Some(parsed) + } + }; + + let characters = match characters { + None => None, + Some(obj) => Some(extract_trim_characters(obj.as_borrowed())?), + }; + + let inner = super::enum_::Trim { + expr: Box::new(expr.0), + where_, + what: what.map(|LiteralExprExtractor(e)| Box::new(e)), + characters, + }; + Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) + } + + fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), (*inner.expr).clone()) + } + + fn get_where(slf: pyo3::PyRef<'_, Self>) -> Option<&'static str> { + let inner = Self::inner(&slf); + inner.where_.map(super::enum_::TrimWhere::as_sql) + } + + fn get_what(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let inner = Self::inner(&slf); + match inner.what.as_ref() { + Some(expr) => super::base::into_subclasses(slf.py(), (**expr).clone()), + None => Ok(utils::new_pynone(slf.py())), + } + } + + fn get_characters(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let py = slf.py(); + let inner = Self::inner(&slf); + match inner.characters.as_ref() { + None => Ok(utils::new_pynone(py)), + Some(chars) => { + let mut out = Vec::with_capacity(chars.len()); + for c in chars { + out.push(super::base::into_subclasses(py, c.clone())?); + } + Ok(pyo3::types::PyTuple::new(py, out)?.into_any()) + } + } + } +} + +#[pyo3::pymethods] +impl PyCollateExpr { + #[new] + #[pyo3(signature = (expr, collation, /))] + fn __new__( + expr: LiteralExprExtractor, + collation: IdentExtractor, + ) -> pyo3::PyClassInitializer { + let inner = super::enum_::Collate { + expr: Box::new(expr.0), + collation: collation.0, + }; + pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self) + } + + fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), (*inner.expr).clone()) + } + + fn get_collation(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { + let inner = Self::inner(&slf); + super::base::into_subclasses(slf.py(), inner.collation.clone().into()) + } } #[pyo3::pymethods] impl PyCaseExpr { diff --git a/src/lib.rs b/src/lib.rs index 249bae3..7d2443d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,19 +9,16 @@ pub mod sql; pub mod typeref; // Tasks until being stable (until this part) -// - Support `array` and `format` in CastExpr -// - Support TrimExpr -// - Support CollateExpr +// - Rewrite dialects and add a new dialect: CustomDialect // - Raise error when something is not supported by a dialect -// - Update docstrings // - Test and coverage everything and edge cases +// - Update docstrings, clean code, update Rust docs, add fmt/lint/coding-style rules // Later tasks: // - Complete `Expr` methods // - Test `Expr` methods // - Benchmark and compare with old version // - Open PR -// - Rewrite dialects and add a new dialect: CustomDialect // - Start writing query ASTs. /// RapidQuery native extension. diff --git a/tests/test_expr.py b/tests/test_expr.py index 01add86..dd21239 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -15,6 +15,7 @@ BinaryExpr, CaseExpr, CastExpr, + CollateExpr, CustomExpr, Expr, Function, @@ -26,6 +27,7 @@ LikeExpr, RLikeExpr, SimilarToExpr, + TrimExpr, TupleExpr, UnaryExpr, Unquote, @@ -55,6 +57,8 @@ def test_nodes_are_expr_subclasses(self): SimilarToExpr(Ident("name"), "%a%"), RLikeExpr(Ident("name"), "%a%"), CastExpr(Ident("id"), "int"), + TrimExpr(Ident("s")), + CollateExpr(Ident("s"), "en_US"), CaseExpr(), CustomExpr("1"), ] @@ -832,6 +836,19 @@ def test_new(self): assert cast.get_data_type_name() == dt.name assert cast.get_kind() == "SAFE_CAST" + formatted = CastExpr(Ident("s"), "DATE", format="%Y-%m-%d") + assert formatted.get_format() == Value("%Y-%m-%d") + assert formatted.get_at_time_zone() is None + + tz = CastExpr( + Ident("s"), "TIMESTAMP", format="%Y-%m-%d", at_time_zone="UTC" + ) + assert tz.get_format() == Value("%Y-%m-%d") + assert tz.get_at_time_zone() == Value("UTC") + + with pytest.raises(ValueError, match="AT TIME ZONE requires FORMAT"): + CastExpr(Ident("s"), "TIMESTAMP", at_time_zone="UTC") + def test_to_sql(self): assert CastExpr(Ident("id"), "int").to_sql(PostgreSQL) == 'CAST("id" AS INT)' assert ( @@ -846,11 +863,145 @@ def test_to_sql(self): assert CastExpr(1, "varchar(255)").to_sql(PostgreSQL) == ( "CAST(1 AS varchar(255))" ) + assert CastExpr(Ident("id"), "INT ARRAY").to_sql(PostgreSQL) == ( + 'CAST("id" AS INT ARRAY)' + ) + assert ( + CastExpr(Ident("s"), "DATE", format="%Y-%m-%d").to_sql(PostgreSQL) + == "CAST(\"s\" AS DATE FORMAT E'%Y-%m-%d')" + ) + assert ( + CastExpr( + Ident("s"), + "TIMESTAMP", + kind="SAFE_CAST", + format="%Y-%m-%d", + at_time_zone="UTC", + ).to_sql(PostgreSQL) + == "SAFE_CAST(\"s\" AS TIMESTAMP FORMAT E'%Y-%m-%d' AT TIME ZONE E'UTC')" + ) sql, params = CastExpr(1, "INT").prepare(PostgreSQL) assert sql == "CAST(:param_0 AS INT)" assert params == {"param_0": 1} + sql, params = CastExpr("s", "DATE", format="%Y-%m-%d").prepare(PostgreSQL) + assert sql == "CAST(:param_0 AS DATE FORMAT :param_1)" + assert params == {"param_0": "s", "param_1": "%Y-%m-%d"} + + assert repr(CastExpr(Ident("id"), "int")) == 'CastExpr(Ident("id"), "INT")' + assert ( + repr(CastExpr(Ident("id"), "int", kind="TRY_CAST")) + == 'CastExpr(Ident("id"), "INT", kind="TRY_CAST")' + ) + assert ( + repr(CastExpr(Ident("s"), "DATE", format="%Y-%m-%d")) + == "CastExpr(Ident(\"s\"), \"DATE\", " + "format=Value('%Y-%m-%d', StringSerializer()))" + ) + + +class TestTrimExpr: + def test_new(self): + trim = TrimExpr(Ident("s")) + assert trim.get_expr() == Ident("s") + assert trim.get_where() is None + assert trim.get_what() is None + assert trim.get_characters() is None + + trim = TrimExpr(Ident("s"), where="leading", what="x") + assert trim.get_where() == "LEADING" + assert trim.get_what() == Value("x") + assert trim.get_characters() is None + + trim = TrimExpr(Ident("s"), characters=["a", Ident("b")]) + assert trim.get_characters() == (Value("a"), Ident("b")) + + with pytest.raises(ValueError, match="unknown TRIM where"): + TrimExpr(Ident("s"), where="MIDDLE") + with pytest.raises(ValueError, match="empty"): + TrimExpr(Ident("s"), characters=[]) + with pytest.raises(ValueError, match="cannot both be set"): + TrimExpr(Ident("s"), what="x", characters=["y"]) + with pytest.raises(TypeError, match="list or tuple"): + TrimExpr(Ident("s"), characters="xy") # pyright: ignore + + def test_to_sql(self): + assert TrimExpr(Ident("s")).to_sql(PostgreSQL) == 'TRIM("s")' + assert TrimExpr(Ident("s"), where="LEADING").to_sql(PostgreSQL) == ( + 'TRIM(LEADING FROM "s")' + ) + assert TrimExpr(Ident("s"), what="x").to_sql(PostgreSQL) == ( + "TRIM(E'x' FROM \"s\")" + ) + assert TrimExpr(Ident("s"), where="BOTH", what="x").to_sql(PostgreSQL) == ( + "TRIM(BOTH E'x' FROM \"s\")" + ) + assert TrimExpr(Ident("s"), characters=["a", "b"]).to_sql(PostgreSQL) == ( + "TRIM(\"s\", E'a', E'b')" + ) + + sql, params = TrimExpr(Ident("s"), what="x").prepare(PostgreSQL) + assert sql == 'TRIM(:param_0 FROM "s")' + assert params == {"param_0": "x"} + + sql, params = TrimExpr(Ident("s"), characters=["a", "b"]).prepare( + PostgreSQL + ) + assert sql == 'TRIM("s", :param_0, :param_1)' + assert params == {"param_0": "a", "param_1": "b"} + + assert repr(TrimExpr(Ident("s"))) == 'TrimExpr(Ident("s"))' + assert ( + repr(TrimExpr(Ident("s"), where="BOTH", what="x")) + == 'TrimExpr(Ident("s"), where="BOTH", ' + "what=Value('x', StringSerializer()))" + ) + assert ( + repr(TrimExpr(Ident("s"), characters=["a", "b"])) + == 'TrimExpr(Ident("s"), characters=[' + "Value('a', StringSerializer()), Value('b', StringSerializer())])" + ) + + +class TestCollateExpr: + def test_new(self): + col = CollateExpr(Ident("name"), "en_US") + assert col.get_expr() == Ident("name") + assert col.get_collation() == Ident("en_US") + + col = CollateExpr("cafĆ©", Ident("pg_catalog", "default")) + assert col.get_expr() == Value("cafĆ©") + assert col.get_collation() == Ident("pg_catalog", "default") + + col = CollateExpr(Ident("name"), Unquote("C")) + assert col.get_collation() == Ident(Unquote("C")) + + col = CollateExpr(Ident("name"), "pg_catalog.default") + assert col.get_collation() == Ident("pg_catalog", "default") + + def test_to_sql(self): + assert CollateExpr(Ident("name"), "en_US").to_sql(PostgreSQL) == ( + '"name" COLLATE "en_US"' + ) + assert CollateExpr(Ident("name"), Unquote("C")).to_sql(PostgreSQL) == ( + '"name" COLLATE C' + ) + assert CollateExpr(Ident("name"), Ident("pg_catalog", "default")).to_sql( + PostgreSQL + ) == '"name" COLLATE "pg_catalog"."default"' + assert CollateExpr(BinaryExpr(1, "+", 2), "en_US").to_sql(PostgreSQL) == ( + '(1 + 2) COLLATE "en_US"' + ) + assert BinaryExpr( + CollateExpr(Ident("a"), "c"), "+", 1 + ).to_sql(PostgreSQL) == '"a" COLLATE "c" + 1' + + assert ( + repr(CollateExpr(Ident("name"), "en_US")) + == 'CollateExpr(Ident("name"), Ident("en_US"))' + ) + class TestCaseExpr: def test_new(self): From 0825dd09ddcfc77b78f9eb02eab3ed8c8c4d0d8e Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Tue, 22 Sep 2026 13:40:18 +0330 Subject: [PATCH 42/87] Rewrite dialects and add a new dialect: GenericDialect --- README.md | 47 +- rapidquery/data_type.pyi | 6 +- rapidquery/dialect.py | 20 +- rapidquery/dialect.pyi | 70 +- rapidquery/expr.pyi | 7 +- src/data_type/mod.rs | 6 +- src/data_type/name.rs | 2 +- src/data_type/serializer/mod.rs | 4 +- src/data_type/serializer/serialize.rs | 67 +- src/data_type/serializer/serializer_enum.rs | 10 +- src/dialect/base.rs | 161 +++ src/dialect/bigquery.rs | 66 - src/dialect/builtin.rs | 281 +++++ src/dialect/convert.rs | 157 +++ src/dialect/databricks.rs | 64 - src/dialect/duckdb.rs | 47 - src/dialect/generic.rs | 142 +++ src/dialect/hive.rs | 64 - src/dialect/inner.rs | 649 ++++++++++ src/dialect/mod.rs | 504 +------- src/dialect/mssql.rs | 51 - src/dialect/mysql.rs | 61 - src/dialect/oracle.rs | 50 - src/dialect/postgresql.rs | 70 -- src/dialect/py.rs | 186 --- src/dialect/redshift.rs | 52 - src/dialect/snowflake.rs | 67 -- src/dialect/spark.rs | 63 - src/dialect/sqlite.rs | 49 - src/dialect/teradata.rs | 50 - src/dialect/tests.rs | 923 -------------- src/expr/base.rs | 8 +- src/expr/enum_/func.rs | 8 +- src/expr/enum_/ident.rs | 8 +- src/expr/enum_/mod.rs | 21 +- src/expr/enum_/value.rs | 4 +- src/expr/extractors.rs | 5 +- src/expr/mod.rs | 4 +- src/expr/nodes.rs | 5 +- src/lib.rs | 1 - src/sql.rs | 28 +- src/typeref.rs | 28 +- tests/conftest.py | 54 +- tests/test_data_type.py | 14 +- tests/test_dialect.py | 468 ++++++++ tests/test_expr.py | 340 +++--- tests/test_ident.py | 49 +- tests/test_serializers.py | 1190 +++++-------------- tests/test_value.py | 16 +- 49 files changed, 2630 insertions(+), 3617 deletions(-) create mode 100644 src/dialect/base.rs delete mode 100644 src/dialect/bigquery.rs create mode 100644 src/dialect/builtin.rs create mode 100644 src/dialect/convert.rs delete mode 100644 src/dialect/databricks.rs delete mode 100644 src/dialect/duckdb.rs create mode 100644 src/dialect/generic.rs delete mode 100644 src/dialect/hive.rs create mode 100644 src/dialect/inner.rs delete mode 100644 src/dialect/mssql.rs delete mode 100644 src/dialect/mysql.rs delete mode 100644 src/dialect/oracle.rs delete mode 100644 src/dialect/postgresql.rs delete mode 100644 src/dialect/py.rs delete mode 100644 src/dialect/redshift.rs delete mode 100644 src/dialect/snowflake.rs delete mode 100644 src/dialect/spark.rs delete mode 100644 src/dialect/sqlite.rs delete mode 100644 src/dialect/teradata.rs delete mode 100644 src/dialect/tests.rs create mode 100644 tests/test_dialect.py diff --git a/README.md b/README.md index 4f251f6..664a0a2 100644 --- a/README.md +++ b/README.md @@ -110,12 +110,12 @@ when building the final SQL statement. ```python import rapidquery as rp -rp.Expr(25) # -> 25 (literal value) -rp.Expr("Hello") # -> 'Hello' (literal value) -rp.Expr(rq.Value('World')) # -> 'World' (literal value) +rp.Expr(25) # -> 25 (literal value) +rp.Expr("Hello") # -> 'Hello' (literal value) +rp.Expr(rq.Value("World")) # -> 'World' (literal value) -rp.Expr.col("id") # -> "id" (column reference) -rp.Expr.col("users.name") # -> "users"."name" (column reference) +rp.Expr.col("id") # -> "id" (column reference) +rp.Expr.col("users.name") # -> "users"."name" (column reference) rp.Expr(rq.ColumnRef("name", table="users")) # -> "users"."name" (column reference) ``` @@ -123,21 +123,25 @@ rp.Expr(rq.ColumnRef("name", table="users")) # -> "users"."name" (column refere ```python rq.Expr.col("status") == "active" # -> "status" == 'active' -rq.Expr.col("age") > 16 # -> "age" > 16 +rq.Expr.col("age") > 16 # -> "age" > 16 # Note that `rq.all` is different from built-in `all` rq.all( rq.Expr.col("age") >= 18, - rq.Expr.col("subscription").is_null(), # same as rq.Expr.col("subscription").is_(Expr.null()) - rq.Expr.col("status").in_(["pending", "approved", "active"]) -) # -> "age" >= 18 AND "subscription" IS NULL AND "status" IN ('pending', 'approved', 'active') + rq.Expr.col( + "subscription" + ).is_null(), # same as rq.Expr.col("subscription").is_(Expr.null()) + rq.Expr.col("status").in_(["pending", "approved", "active"]), +) # -> "age" >= 18 AND "subscription" IS NULL AND "status" IN ('pending', 'approved', 'active') # Note that `rq.any` is different from built-in `any` rq.any( rq.Expr.col("is_admin").is_(True), - rq.Expr.col("is_moderator").is_not_null(), # same as rq.Expr.col("subscription").is_not(Expr.null()) - rq.Expr.col("price").between(10.00, 50.00) -) # -> "is_admin" IS TRUE OR "is_moderator" IS NOT NULL OR "price" BETWEEN 10.00 AND 50.00 + rq.Expr.col( + "is_moderator" + ).is_not_null(), # same as rq.Expr.col("subscription").is_not(Expr.null()) + rq.Expr.col("price").between(10.00, 50.00), +) # -> "is_admin" IS TRUE OR "is_moderator" IS NOT NULL OR "price" BETWEEN 10.00 AND 50.00 ``` **Best Practices** @@ -149,7 +153,7 @@ rq.any( rq.Expr.col("user_name") # → "user_name" # String literal (value) -rq.Expr("user_name") # → 'user_name' +rq.Expr("user_name") # → 'user_name' ``` - Use `rapidquery.all()` and `rapidquery.any()` for logical combinations: More readable than chaining `&` and `|` operators @@ -356,8 +360,12 @@ clause = ( ) ) -users_count_select = rq.SelectStatement(rq.Func.count(rq.Expr.asterisk())).from_table("users_count") -teams_count_select = rq.SelectStatement(rq.Func.count(rq.Expr.asterisk())).from_table("teams_count") +users_count_select = rq.SelectStatement(rq.Func.count(rq.Expr.asterisk())).from_table( + "users_count" +) +teams_count_select = rq.SelectStatement(rq.Func.count(rq.Expr.asterisk())).from_table( + "teams_count" +) query: WithQuery = clause.query( rq.SelectStatement( @@ -393,7 +401,7 @@ But for functions not provided by the library, you can define custom functions. Custom functions can be defined using the `Func` constructor: ```python -stmt = rq.SelectStatement(rq.Func("CUSTOM", 1, 'hello')) +stmt = rq.SelectStatement(rq.Func("CUSTOM", 1, "hello")) stmt.to_sql("postgres") # SELECT CUSTOM(1, 'hello') ``` @@ -562,9 +570,11 @@ class IdColumnProperty: # Can return ColumnRef or str return "characters.id" + class IdColumnClassVar: __column_ref__ = "characters.id" + stmt = rq.SelectStatement().columns(IdColumnProperty()) # SELECT "characters"."id" AS "id" @@ -636,10 +646,7 @@ stmt = rq.DeleteStatement(UsersClassVar) 4. Use `Table`: It's possible because `Table` has `__table_name__` property. ```python -users = Table( - "archive.users", - ... -) +users = Table("archive.users", ...) stmt = rq.DeleteStatement(users) # DELETE FROM "archive"."users" diff --git a/rapidquery/data_type.pyi b/rapidquery/data_type.pyi index fe10ab2..5b9222c 100644 --- a/rapidquery/data_type.pyi +++ b/rapidquery/data_type.pyi @@ -24,7 +24,7 @@ class Serializer(typing.Generic[_T]): identity. """ - def serialize(self, dialect: type[SqlDialect] | SqlDialect, object: _T) -> str: + def serialize(self, dialect: SqlDialect, object: _T) -> str: """ Renders `object` as a SQL literal for `dialect`. @@ -289,7 +289,7 @@ class CustomSerializer(Serializer[_T]): caller's responsibility to produce a safe fragment. """ - def __new__(cls, function: Callable[[str, _T], str]) -> typing.Self: + def __new__(cls, function: Callable[[SqlDialect, _T], str]) -> typing.Self: """ Creates a serializer backed by a Python callable. @@ -364,7 +364,7 @@ class DataType(typing.Generic[_T]): The `Serializer` associated with this data type. """ - def serialize(self, dialect: type[SqlDialect] | SqlDialect, object: _T) -> str: + def serialize(self, dialect: SqlDialect, object: _T) -> str: """ Renders `object` as a SQL literal for `dialect`. diff --git a/rapidquery/dialect.py b/rapidquery/dialect.py index 90ca2ba..1a9f3cb 100644 --- a/rapidquery/dialect.py +++ b/rapidquery/dialect.py @@ -2,38 +2,38 @@ SQL dialect markers. """ -# TODO: rename PostgreSQL to PostgreSql, SQLite to Sqlite, MSSQL to MsSql, DuckDB to DuckDb - from ._core.dialect import ( - MSSQL, BigQuery, Databricks, - DuckDB, + DuckDb, + GenericDialect, Hive, + MsSql, MySql, Oracle, - PostgreSQL, + PostgreSql, Redshift, Snowflake, Spark, SqlDialect, - SQLite, + Sqlite, Teradata, ) __all__ = [ - "MSSQL", "BigQuery", "Databricks", - "DuckDB", + "DuckDb", + "GenericDialect", "Hive", + "MsSql", "MySql", "Oracle", - "PostgreSQL", + "PostgreSql", "Redshift", - "SQLite", "Snowflake", "Spark", "SqlDialect", + "Sqlite", "Teradata", ] diff --git a/rapidquery/dialect.pyi b/rapidquery/dialect.pyi index 687684f..6a2958a 100644 --- a/rapidquery/dialect.pyi +++ b/rapidquery/dialect.pyi @@ -1,11 +1,71 @@ from typing import Self, final class SqlDialect: + """ + Abstract SQL dialect. + + Concrete dialects are frozen subclasses (`PostgreSql`, `DuckDb`, ...). + Pass an instance wherever a dialect is required. + This base class cannot be instantiated. + + Instances of the same dialect compare equal and hash together. + """ + + @property + def placeholder_sigil(self) -> str: ... + @property + def bit_format(self) -> str | None: ... + @property + def supports_array(self) -> bool: ... + @property + def supports_inet(self) -> bool: ... + @property + def supports_mac_address(self) -> bool: ... + @property + def null_treatment_syntax(self) -> str: ... @property - def name(self) -> str: ... + def like_any_syntax(self) -> str: ... + @property + def bool_format(self) -> str: ... + @property + def identifier_format(self) -> str: ... + @property + def identifier_escape(self) -> str: ... + @property + def string_format(self) -> str: ... + @property + def string_escape(self) -> str: ... + @property + def bytes_format(self) -> str: ... + def format_boolean(self, value: bool) -> str: ... + def escape_string(self, string: str) -> str: ... + def quote_identifier(self, ident: str) -> str: ... + def write_string_literal(self, raw: str) -> str: ... + def format_bits(self, bits: str) -> str: ... + def format_bytes(self, data: bytes) -> str: ... def __eq__(self, other: object) -> bool: ... def __hash__(self) -> int: ... +@final +class GenericDialect(SqlDialect): + def __new__( + cls, + *, + placeholder_sigil: str, + bit_format: str | None, + supports_array: bool, + supports_inet: bool, + supports_mac_address: bool, + null_treatment_syntax: str, + like_any_syntax: str, + bool_format: str, + identifier_format: str, + identifier_escape: str, + string_format: str, + string_escape: str, + bytes_format: str, + ) -> Self: ... + @final class BigQuery(SqlDialect): def __new__(cls) -> Self: ... @@ -15,7 +75,7 @@ class Databricks(SqlDialect): def __new__(cls) -> Self: ... @final -class DuckDB(SqlDialect): +class DuckDb(SqlDialect): def __new__(cls) -> Self: ... @final @@ -23,7 +83,7 @@ class Hive(SqlDialect): def __new__(cls) -> Self: ... @final -class MSSQL(SqlDialect): +class MsSql(SqlDialect): def __new__(cls) -> Self: ... @final @@ -35,7 +95,7 @@ class Oracle(SqlDialect): def __new__(cls) -> Self: ... @final -class PostgreSQL(SqlDialect): +class PostgreSql(SqlDialect): def __new__(cls) -> Self: ... @final @@ -51,7 +111,7 @@ class Spark(SqlDialect): def __new__(cls) -> Self: ... @final -class SQLite(SqlDialect): +class Sqlite(SqlDialect): def __new__(cls) -> Self: ... @final diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index 48e4e7c..8571675 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -14,14 +14,12 @@ class Expr: instantiated. """ - def to_sql(self, dialect: type[SqlDialect] | SqlDialect) -> str: + def to_sql(self, dialect: SqlDialect) -> str: """ Render this expression as raw SQL (values inlined as literals). """ - def prepare( - self, dialect: type[SqlDialect] | SqlDialect - ) -> tuple[str, dict[str, typing.Any]]: + def prepare(self, dialect: SqlDialect) -> tuple[str, dict[str, typing.Any]]: """ Render this expression as a parameterized statement. """ @@ -980,7 +978,6 @@ class CastExpr(Expr): """ def get_kind(self) -> typing.Literal["CAST", "TRY_CAST", "SAFE_CAST"]: ... - def get_format(self) -> Expr | None: """ Return the ``FORMAT`` expression, if any. diff --git a/src/data_type/mod.rs b/src/data_type/mod.rs index 9dd41aa..132b571 100644 --- a/src/data_type/mod.rs +++ b/src/data_type/mod.rs @@ -71,7 +71,7 @@ impl crate::sql::WriteSql for PyDataType { #[inline] fn write_sql( &self, - dialect: &crate::dialect::SqlDialect, + dialect: &crate::dialect::PySqlDialect, out: &mut W, ) -> crate::sql::PrepareResult<()> { self.name.write_sql(dialect, out) @@ -139,11 +139,11 @@ impl PyDataType { /// type's serializer (bit strings, `ARRAY[…]`, inet, macaddr). fn serialize( &self, - dialect: crate::dialect::py::PySqlDialect, + dialect: crate::dialect::PySqlDialect, object: alias::BoundObject<'_>, ) -> pyo3::PyResult { let mut buf = String::new(); - self.serializer.write_sql(&dialect.0, &object, &mut buf)?; + self.serializer.write_sql(&dialect, &object, &mut buf)?; Ok(buf) } diff --git a/src/data_type/name.rs b/src/data_type/name.rs index 710b3e9..cb906ec 100644 --- a/src/data_type/name.rs +++ b/src/data_type/name.rs @@ -263,7 +263,7 @@ impl std::fmt::Display for DataTypeName { impl WriteSql for DataTypeName { fn write_sql( &self, - _dialect: &crate::dialect::SqlDialect, + _dialect: &crate::dialect::PySqlDialect, out: &mut W, ) -> PrepareResult<()> { out.push_str(self.as_str()); diff --git a/src/data_type/serializer/mod.rs b/src/data_type/serializer/mod.rs index e70d61f..dda8e80 100644 --- a/src/data_type/serializer/mod.rs +++ b/src/data_type/serializer/mod.rs @@ -123,11 +123,11 @@ impl PySerializer { /// serializer (bit strings, `ARRAY[…]`, inet, macaddr). fn serialize( &self, - dialect: crate::dialect::py::PySqlDialect, + dialect: crate::dialect::PySqlDialect, object: alias::BoundObject<'_>, ) -> pyo3::PyResult { let mut buf = String::new(); - self.0.write_sql(&dialect.0, &object, &mut buf)?; + self.0.write_sql(&dialect, &object, &mut buf)?; Ok(buf) } diff --git a/src/data_type/serializer/serialize.rs b/src/data_type/serializer/serialize.rs index 1665e81..9e9b3e9 100644 --- a/src/data_type/serializer/serialize.rs +++ b/src/data_type/serializer/serialize.rs @@ -9,22 +9,22 @@ use std::str::FromStr; use pyo3::types::{PyAnyMethods, PyTzInfoAccess}; -use crate::dialect::SqlDialect; +use crate::dialect::PySqlDialect; +use crate::dialect::inner::Dialect; use crate::internal::alias; use crate::internal::utils::{borrow_py_str_buffer, get_type_name}; impl super::serializer_enum::SerializerEnum { pub fn write_sql( &self, - dialect: &SqlDialect, + dialect: &PySqlDialect, object: &alias::BoundObject<'_>, out: &mut String, ) -> pyo3::PyResult<()> { - if !self.is_supported_by(dialect) { + if !self.is_supported_by(&dialect.0) { return rapidquery_macros::pyerr!( Err(PyNotImplementedError), - "{} does not support {}", - dialect.name(), + "this dialect does not support {}", super::class_name(self) ); } @@ -35,17 +35,17 @@ impl super::serializer_enum::SerializerEnum { match self { Self::Integer => write_integer(py, ptr, out), Self::Float => write_float(py, ptr, out), - Self::Boolean => write_bool(py, ptr, dialect, out), - Self::String => write_str(py, ptr, dialect, out), - Self::Uuid => write_uuid(py, ptr, dialect, out), - Self::Bytes => write_bytes(py, ptr, dialect, out), - Self::Bit => write_bit(py, ptr, dialect, out), - Self::Json => write_json(py, ptr, dialect, out), - Self::Date => write_date(py, ptr, dialect, out), - Self::Time => write_time(py, ptr, dialect, out), - Self::Datetime => write_datetime(py, ptr, dialect, out), - Self::Inet => write_inet(py, ptr, dialect, out), - Self::MacAddress => write_mac_address(py, ptr, dialect, out), + Self::Boolean => write_bool(py, ptr, &dialect.0, out), + Self::String => write_str(py, ptr, &dialect.0, out), + Self::Uuid => write_uuid(py, ptr, &dialect.0, out), + Self::Bytes => write_bytes(py, ptr, &dialect.0, out), + Self::Bit => write_bit(py, ptr, &dialect.0, out), + Self::Json => write_json(py, ptr, &dialect.0, out), + Self::Date => write_date(py, ptr, &dialect.0, out), + Self::Time => write_time(py, ptr, &dialect.0, out), + Self::Datetime => write_datetime(py, ptr, &dialect.0, out), + Self::Inet => write_inet(py, ptr, &dialect.0, out), + Self::MacAddress => write_mac_address(py, ptr, &dialect.0, out), Self::Array(element) => write_array(py, ptr, dialect, element, out), Self::Unspecified => { let text = py_to_string(py, ptr)?; @@ -83,7 +83,7 @@ unsafe fn py_bytes<'a>(ptr: *mut pyo3::ffi::PyObject) -> &'a [u8] { } #[inline] -fn write_quoted(dialect: &SqlDialect, out: &mut String, raw: &str) { +fn write_quoted(dialect: &Dialect, out: &mut String, raw: &str) { let _ = dialect.write_string_literal(out, raw); } @@ -158,7 +158,7 @@ unsafe fn write_float( unsafe fn write_bool( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &SqlDialect, + dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { if pyo3::ffi::PyBool_Check(ptr) == 1 { @@ -182,7 +182,7 @@ unsafe fn write_bool( unsafe fn write_str( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &SqlDialect, + dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { if pyo3::ffi::PyUnicode_Check(ptr) == 1 { @@ -203,7 +203,7 @@ unsafe fn write_str( unsafe fn write_uuid( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &SqlDialect, + dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { if crate::typeref::check_exact(ptr, crate::typeref::STD_UUID_TYPE) { @@ -232,11 +232,11 @@ unsafe fn write_uuid( unsafe fn write_bytes( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &SqlDialect, + dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { if pyo3::ffi::PyBytes_CheckExact(ptr) == 1 { - let _ = dialect.encode_bytes(out, py_bytes(ptr)); + let _ = dialect.format_bytes(out, py_bytes(ptr)); Ok(()) } else { Err(rapidquery_macros::pyerr!( @@ -253,7 +253,7 @@ unsafe fn write_bytes( unsafe fn write_bit( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &SqlDialect, + dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { @@ -265,7 +265,7 @@ unsafe fn write_bit( raw ); } - let _ = dialect.encode_bits(out, raw); + let _ = dialect.format_bits(out, raw); Ok(()) } else { rapidquery_macros::pyerr!( @@ -284,7 +284,7 @@ unsafe fn write_bit( unsafe fn write_json( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &SqlDialect, + dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { crate::internal::modules::json_dumps( @@ -302,7 +302,7 @@ unsafe fn write_json( unsafe fn write_date( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &SqlDialect, + dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { if crate::typeref::check_exact(ptr, crate::typeref::STD_DATE_TYPE) { @@ -327,7 +327,7 @@ unsafe fn write_date( unsafe fn write_time( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &SqlDialect, + dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { if crate::typeref::check_exact(ptr, crate::typeref::STD_TIME_TYPE) { @@ -356,7 +356,7 @@ unsafe fn write_time( unsafe fn write_datetime( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &SqlDialect, + dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { use chrono::TimeZone; @@ -429,7 +429,7 @@ unsafe fn write_datetime( unsafe fn write_inet( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &SqlDialect, + dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { let text = py_to_string(py, ptr)?; @@ -446,7 +446,7 @@ unsafe fn write_inet( unsafe fn write_mac_address( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &SqlDialect, + dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { let text = py_to_string(py, ptr)?; @@ -464,7 +464,7 @@ unsafe fn write_mac_address( unsafe fn write_array( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &SqlDialect, + dialect: &PySqlDialect, element: &super::serializer_enum::SerializerEnum, out: &mut String, ) -> pyo3::PyResult<()> { @@ -509,13 +509,14 @@ unsafe fn write_array( unsafe fn write_custom( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &SqlDialect, + dialect: &PySqlDialect, function: &pyo3::Py, object: &alias::BoundObject<'_>, out: &mut String, ) -> pyo3::PyResult<()> { let _ = ptr; - let output = function.call1(py, (dialect.name(), object))?; + + let output = function.call1(py, (dialect.clone(), object))?; if pyo3::ffi::PyUnicode_CheckExact(output.as_ptr()) != 1 { return rapidquery_macros::pyerr!( Err(PyTypeError), diff --git a/src/data_type/serializer/serializer_enum.rs b/src/data_type/serializer/serializer_enum.rs index f16e70c..4865b15 100644 --- a/src/data_type/serializer/serializer_enum.rs +++ b/src/data_type/serializer/serializer_enum.rs @@ -99,12 +99,12 @@ impl SerializerEnum { /// Whether `dialect` can emit a native literal for this serializer. #[inline] - pub fn is_supported_by(&self, dialect: &crate::dialect::SqlDialect) -> bool { + pub fn is_supported_by(&self, dialect: &crate::dialect::inner::Dialect) -> bool { match self { - Self::Bit => dialect.supports_bit(), - Self::Array(_) => dialect.supports_array(), - Self::Inet => dialect.supports_inet(), - Self::MacAddress => dialect.supports_mac_address(), + Self::Bit => dialect.bit_format.is_some(), + Self::Array(_) => dialect.supports_array, + Self::Inet => dialect.supports_inet, + Self::MacAddress => dialect.supports_mac_address, _ => true, } } diff --git a/src/dialect/base.rs b/src/dialect/base.rs new file mode 100644 index 0000000..a94e8ca --- /dev/null +++ b/src/dialect/base.rs @@ -0,0 +1,161 @@ +use std::hash::{Hash, Hasher}; + +use crate::internal::utils; + +use super::convert; +use super::inner::Dialect; + +/// Abstract SQL dialect. +/// +/// Concrete dialects are frozen subclasses (`PostgreSql`, `DuckDb`, ...). +/// Pass an instance wherever a dialect is required. +/// This base class cannot be instantiated. +/// +/// Instances of the same dialect compare equal and hash together. +#[rapidquery_macros::pyclass( + "SqlDialect", + subclass, + skip_from_py_object, + module = "rapidquery._core.dialect" +)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PySqlDialect(pub Dialect); + +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PySqlDialect { + type Error = pyo3::PyErr; + + fn extract( + obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, + ) -> Result { + unsafe { + // SAFETY: `obj` is a borrowed `&Bound`, so `as_ptr()` is a + // live `PyObject*`. + if crate::typeref::check(obj.as_ptr(), crate::typeref::SQL_DIALECT_TYPE) { + let cast = obj.cast_unchecked::(); + return Ok(Self(cast.get().0)); + } + + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected a SqlDialect, got {}", + utils::get_type_name(obj.py(), obj.as_ptr()) + ) + } + } +} + +fn collect(write: impl FnOnce(&mut String) -> std::fmt::Result) -> String { + let mut buf = String::new(); + let _ = write(&mut buf); + buf +} + +#[pyo3::pymethods] +impl PySqlDialect { + #[getter] + fn get_placeholder_sigil(&self) -> char { + self.0.placeholder_sigil + } + + #[getter] + fn get_bit_format(&self) -> Option<&'static str> { + self.0.bit_format.map(convert::bit_format_name) + } + + #[getter] + fn get_supports_array(&self) -> bool { + self.0.supports_array + } + + #[getter] + fn get_supports_inet(&self) -> bool { + self.0.supports_inet + } + + #[getter] + fn get_supports_mac_address(&self) -> bool { + self.0.supports_mac_address + } + + #[getter] + fn get_null_treatment_syntax(&self) -> &'static str { + convert::null_treatment_syntax_name(self.0.null_treatment_syntax) + } + + #[getter] + fn get_like_any_syntax(&self) -> &'static str { + convert::like_any_syntax_name(self.0.like_any_syntax) + } + + #[getter] + fn get_bool_format(&self) -> &'static str { + convert::bool_format_name(self.0.bool_format) + } + + #[getter] + fn get_identifier_format(&self) -> &'static str { + convert::identifier_format_name(self.0.identifier_format) + } + + #[getter] + fn get_identifier_escape(&self) -> &'static str { + convert::identifier_escape_name(self.0.identifier_escape) + } + + #[getter] + fn get_string_format(&self) -> &'static str { + convert::string_format_name(self.0.string_format) + } + + #[getter] + fn get_string_escape(&self) -> &'static str { + convert::string_escape_name(self.0.string_escape) + } + + #[getter] + fn get_bytes_format(&self) -> &'static str { + convert::bytes_format_name(self.0.bytes_format) + } + + fn format_boolean(&self, value: bool) -> String { + collect(|buf| self.0.format_boolean(buf, value)) + } + + fn escape_string(&self, string: &str) -> String { + collect(|buf| self.0.escape_string(buf, string)) + } + + fn quote_identifier(&self, ident: &str) -> String { + collect(|buf| self.0.quote_identifier(buf, ident)) + } + + fn write_string_literal(&self, raw: &str) -> String { + collect(|buf| self.0.write_string_literal(buf, raw)) + } + + fn format_bits(&self, bits: &str) -> pyo3::PyResult { + if self.0.bit_format.is_none() { + return rapidquery_macros::pyerr!( + Err(PyValueError), + "this dialect does not support literal bit values" + ); + } + Ok(collect(|buf| self.0.format_bits(buf, bits))) + } + + fn format_bytes(&self, data: &[u8]) -> String { + collect(|buf| self.0.format_bytes(buf, data)) + } + + // Bound so only instances match; the class object is a type. + // PostgreSql() == PostgreSql is false; PostgreSql() == PostgreSql() is true. + fn __eq__(&self, other: &pyo3::Bound<'_, Self>) -> bool { + self.0 == other.get().0 + } + + fn __hash__(&self) -> u64 { + let mut hasher = std::hash::DefaultHasher::default(); + self.0.hash(&mut hasher); + hasher.finish() + } +} diff --git a/src/dialect/bigquery.rs b/src/dialect/bigquery.rs deleted file mode 100644 index cdaf128..0000000 --- a/src/dialect/bigquery.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! [Google BigQuery](https://cloud.google.com/bigquery/) Standard SQL. -//! -//! • Strings: single-quoted; backslash-escape sequences (`\n`, `\xNN`, …). -//! • Identifiers: backtick-quoted. The body uses the same escaping as a -//! string (BigQuery does not double backticks). -//! • Bytes: `B'\xNN…'` byte literal. -//! • Bits: no native bit-string literal; emit `CAST(0b AS INT64)`. -//! • Bool: `TRUE` / `FALSE`. -//! • Placeholders: `@name` (native). -//! -//! Ref: - -use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; - -pub(super) const NAME: &str = "BigQuery"; - -pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; - -pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::Backtick; - -pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::AtSign; - -#[cfg_attr(feature = "nightly", optimize(speed))] -pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { - for ch in string.chars() { - match ch { - '\\' => writer.write_str("\\\\")?, - '\'' => writer.write_str("\\'")?, - '\n' => writer.write_str("\\n")?, - '\r' => writer.write_str("\\r")?, - '\t' => writer.write_str("\\t")?, - '\0' => writer.write_str("\\x00")?, - c if (c as u32) < 32 || c as u32 == 127 => { - super::write_byte_hex_escape(writer, c as u8)?; - } - c => writer.write_char(c)?, - } - } - - Ok(()) -} - -pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { - super::wrap_single_quote(writer, string) -} - -pub(super) fn escape_identifier( - writer: &mut W, - ident: &str, -) -> std::fmt::Result { - escape_string(writer, ident) -} - -pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { - writer.write_str("B'")?; - for &b in bytes { - super::write_byte_hex_escape(writer, b)?; - } - writer.write_char('\'') -} - -// Unsupported: Serializer::write_sql raises NotImplementedError via supports_bit. -pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { - super::debug_assert_bits(bits); - write!(writer, "CAST(0b{bits} AS INT64)") -} diff --git a/src/dialect/builtin.rs b/src/dialect/builtin.rs new file mode 100644 index 0000000..2d4c4eb --- /dev/null +++ b/src/dialect/builtin.rs @@ -0,0 +1,281 @@ +use super::inner::*; + +macro_rules! impl_builtin_dialects { + ( + $( + $(#[$docs:meta])* + $name:ident = $pyname:literal { + $inner:expr + } + )+ + ) => { + $( + $(#[$docs])* + #[rapidquery_macros::pyclass($pyname, extends = super::base::PySqlDialect, module = "rapidquery._core.dialect")] + pub struct $name; + + impl $name { + pub const CONFIG: Dialect = $inner; + } + + #[pyo3::pymethods] + impl $name { + #[new] + #[inline] + fn __new__() -> pyo3::PyClassInitializer { + pyo3::PyClassInitializer::from(super::base::PySqlDialect(Self::CONFIG)) + .add_subclass(Self) + } + } + )+ + }; +} + +impl_builtin_dialects!( + /// BigQuery SQL dialect. + PyBigQuery = "BigQuery" { + Dialect { + placeholder_sigil: '@', + bit_format: None, + supports_array: true, + supports_inet: false, + supports_mac_address: false, + null_treatment_syntax: NullTreatmentSyntax::InsideParens, + like_any_syntax: LikeAnySyntax::OrExpansion, + bool_format: BoolFormat::Keyword, + identifier_format: IdentifierFormat::Backtick, + identifier_escape: IdentifierEscape::SameAsString, + string_format: StringFormat::SingleQuote, + string_escape: StringEscape::BackslashHex, + bytes_format: BytesFormat::PrefixBigBSlashX, + } + } + + /// Databricks SQL dialect. + PyDatabricks = "Databricks" { + Dialect { + placeholder_sigil: ':', + bit_format: None, + supports_array: false, + supports_inet: false, + supports_mac_address: false, + null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + like_any_syntax: LikeAnySyntax::PatternList, + bool_format: BoolFormat::Keyword, + identifier_format: IdentifierFormat::Backtick, + identifier_escape: IdentifierEscape::Delimiters, + string_format: StringFormat::SingleQuote, + string_escape: StringEscape::BackslashUnicodeQuote, + bytes_format: BytesFormat::PrefixBigX, + } + } + + /// DuckDB SQL dialect. + PyDuckDb = "DuckDb" { + Dialect { + placeholder_sigil: '$', + bit_format: Some(BitFormat::PrefixBigB), + supports_array: true, + supports_inet: true, + supports_mac_address: false, + null_treatment_syntax: NullTreatmentSyntax::InsideParens, + like_any_syntax: LikeAnySyntax::OrExpansion, + bool_format: BoolFormat::Keyword, + identifier_format: IdentifierFormat::DoubleQuote, + identifier_escape: IdentifierEscape::Delimiters, + string_format: StringFormat::SingleQuote, + string_escape: StringEscape::Quote, + bytes_format: BytesFormat::PrefixBigX, + } + } + + /// Hive SQL dialect. + PyHive = "Hive" { + Dialect { + placeholder_sigil: ':', + bit_format: None, + supports_array: false, + supports_inet: false, + supports_mac_address: false, + null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + like_any_syntax: LikeAnySyntax::OrExpansion, + bool_format: BoolFormat::Keyword, + identifier_format: IdentifierFormat::Backtick, + identifier_escape: IdentifierEscape::Delimiters, + string_format: StringFormat::SingleQuote, + string_escape: StringEscape::BackslashUnicode, + bytes_format: BytesFormat::Unhex, + } + } + + /// Microsoft SQL Server dialect. + PyMsSql = "MsSql" { + Dialect { + placeholder_sigil: '@', + bit_format: None, + supports_array: false, + supports_inet: false, + supports_mac_address: false, + null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + like_any_syntax: LikeAnySyntax::OrExpansion, + bool_format: BoolFormat::Int, + identifier_format: IdentifierFormat::SquareBracket, + identifier_escape: IdentifierEscape::Delimiters, + string_format: StringFormat::PrefixBigN, + string_escape: StringEscape::QuoteStripNul, + bytes_format: BytesFormat::PrefixZeroX, + } + } + + /// MySQL dialect. + PyMySql = "MySql" { + Dialect { + placeholder_sigil: ':', + bit_format: Some(BitFormat::PrefixSmallB), + supports_array: false, + supports_inet: false, + supports_mac_address: false, + null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + like_any_syntax: LikeAnySyntax::OrExpansion, + bool_format: BoolFormat::Int, + identifier_format: IdentifierFormat::Backtick, + identifier_escape: IdentifierEscape::Delimiters, + string_format: StringFormat::SingleQuote, + string_escape: StringEscape::BackslashMysql, + bytes_format: BytesFormat::PrefixBigX, + } + } + + /// Oracle SQL dialect. + PyOracle = "Oracle" { + Dialect { + placeholder_sigil: ':', + bit_format: None, + supports_array: false, + supports_inet: false, + supports_mac_address: false, + null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + like_any_syntax: LikeAnySyntax::OrExpansion, + bool_format: BoolFormat::Int, + identifier_format: IdentifierFormat::DoubleQuote, + identifier_escape: IdentifierEscape::Delimiters, + string_format: StringFormat::SingleQuote, + string_escape: StringEscape::QuoteStripNul, + bytes_format: BytesFormat::HexToRaw, + } + } + + /// PostgreSQL dialect. + PyPostgreSql = "PostgreSql" { + Dialect { + placeholder_sigil: ':', + bit_format: Some(BitFormat::PrefixBigB), + supports_array: true, + supports_inet: true, + supports_mac_address: true, + null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + like_any_syntax: LikeAnySyntax::ArrayConstructor, + bool_format: BoolFormat::Keyword, + identifier_format: IdentifierFormat::DoubleQuote, + identifier_escape: IdentifierEscape::Delimiters, + string_format: StringFormat::PrefixBigE, + string_escape: StringEscape::BackslashHex, + bytes_format: BytesFormat::SlashXBytea, + } + } + + /// Amazon Redshift dialect. + PyRedshift = "Redshift" { + Dialect { + placeholder_sigil: ':', + bit_format: None, + supports_array: false, + supports_inet: false, + supports_mac_address: false, + null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + like_any_syntax: LikeAnySyntax::OrExpansion, + bool_format: BoolFormat::Keyword, + identifier_format: IdentifierFormat::DoubleQuote, + identifier_escape: IdentifierEscape::Delimiters, + string_format: StringFormat::SingleQuote, + string_escape: StringEscape::QuoteStripNul, + bytes_format: BytesFormat::ToVarbyte, + } + } + + /// Snowflake SQL dialect. + PySnowflake = "Snowflake" { + Dialect { + placeholder_sigil: ':', + bit_format: None, + supports_array: false, + supports_inet: false, + supports_mac_address: false, + null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + like_any_syntax: LikeAnySyntax::PatternList, + bool_format: BoolFormat::Keyword, + identifier_format: IdentifierFormat::DoubleQuote, + identifier_escape: IdentifierEscape::Delimiters, + string_format: StringFormat::SingleQuote, + string_escape: StringEscape::BackslashHexZero, + bytes_format: BytesFormat::PrefixBigX, + } + } + + /// Apache Spark SQL dialect. + PySpark = "Spark" { + Dialect { + placeholder_sigil: ':', + bit_format: None, + supports_array: false, + supports_inet: false, + supports_mac_address: false, + null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + like_any_syntax: LikeAnySyntax::PatternList, + bool_format: BoolFormat::Keyword, + identifier_format: IdentifierFormat::Backtick, + identifier_escape: IdentifierEscape::Delimiters, + string_format: StringFormat::SingleQuote, + string_escape: StringEscape::BackslashUnicodeQuote, + bytes_format: BytesFormat::PrefixBigX, + } + } + + /// SQLite dialect. + PySqlite = "Sqlite" { + Dialect { + placeholder_sigil: ':', + bit_format: None, + supports_array: false, + supports_inet: false, + supports_mac_address: false, + null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + like_any_syntax: LikeAnySyntax::OrExpansion, + bool_format: BoolFormat::Int, + identifier_format: IdentifierFormat::DoubleQuote, + identifier_escape: IdentifierEscape::Delimiters, + string_format: StringFormat::SingleQuote, + string_escape: StringEscape::QuoteStripNul, + bytes_format: BytesFormat::PrefixBigX, + } + } + + /// Teradata SQL dialect. + PyTeradata = "Teradata" { + Dialect { + placeholder_sigil: ':', + bit_format: Some(BitFormat::PostfixBigB), + supports_array: false, + supports_inet: false, + supports_mac_address: false, + null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + like_any_syntax: LikeAnySyntax::OrExpansion, + bool_format: BoolFormat::Keyword, + identifier_format: IdentifierFormat::SingleQuote, + identifier_escape: IdentifierEscape::Delimiters, + string_format: StringFormat::SingleQuote, + string_escape: StringEscape::QuoteStripNul, + bytes_format: BytesFormat::PostfixXbv, + } + } +); diff --git a/src/dialect/convert.rs b/src/dialect/convert.rs new file mode 100644 index 0000000..ee05875 --- /dev/null +++ b/src/dialect/convert.rs @@ -0,0 +1,157 @@ +//! Python string names for [`super::inner`] format enums. +//! +//! Conversion lives here so the enums themselves stay data-only. + +use super::inner::{ + BitFormat, BoolFormat, BytesFormat, IdentifierEscape, IdentifierFormat, LikeAnySyntax, + NullTreatmentSyntax, StringEscape, StringFormat, +}; + +macro_rules! named { + ($ty:ty, $to_str:ident, $parse:ident { $($v:ident),+ $(,)? }) => { + #[inline] + pub(super) fn $to_str(v: $ty) -> &'static str { + match v { + $(<$ty>::$v => stringify!($v),)+ + } + } + + #[inline] + pub(super) fn $parse(s: &str) -> Option<$ty> { + $( + if s.eq_ignore_ascii_case(stringify!($v)) { + return Some(<$ty>::$v); + } + )+ + None + } + }; +} + +named!( + BitFormat, + bit_format_name, + parse_bit_format { + PrefixBigB, + PrefixSmallB, + PostfixBigB + } +); +named!( + NullTreatmentSyntax, + null_treatment_syntax_name, + parse_null_treatment_syntax { + InsideParens, + OutsideParens + } +); +named!( + LikeAnySyntax, + like_any_syntax_name, + parse_like_any_syntax { + ArrayConstructor, + PatternList, + OrExpansion + } +); +named!( + BoolFormat, + bool_format_name, + parse_bool_format { Keyword, Int } +); +named!( + IdentifierFormat, + identifier_format_name, + parse_identifier_format { + SingleQuote, + DoubleQuote, + Backtick, + SquareBracket + } +); +named!( + IdentifierEscape, + identifier_escape_name, + parse_identifier_escape { + Delimiters, + SameAsString + } +); +named!( + StringFormat, + string_format_name, + parse_string_format { + SingleQuote, + PrefixBigE, + PrefixBigN + } +); +named!( + StringEscape, + string_escape_name, + parse_string_escape { + Quote, + QuoteStripNul, + BackslashHex, + BackslashMysql, + BackslashUnicode, + BackslashHexZero, + BackslashUnicodeQuote + } +); +named!( + BytesFormat, + bytes_format_name, + parse_bytes_format { + PrefixBigX, + PrefixZeroX, + SlashXBytea, + PrefixBigBSlashX, + HexToRaw, + Unhex, + ToVarbyte, + PostfixXbv + } +); + +pub(super) const BIT_FORMATS: &str = "PrefixBigB, PrefixSmallB, PostfixBigB"; +pub(super) const NULL_TREATMENT_SYNTAXES: &str = "InsideParens, OutsideParens"; +pub(super) const LIKE_ANY_SYNTAXES: &str = "ArrayConstructor, PatternList, OrExpansion"; +pub(super) const BOOL_FORMATS: &str = "Keyword, Int"; +pub(super) const IDENTIFIER_FORMATS: &str = "SingleQuote, DoubleQuote, Backtick, SquareBracket"; +pub(super) const IDENTIFIER_ESCAPES: &str = "Delimiters, SameAsString"; +pub(super) const STRING_FORMATS: &str = "SingleQuote, PrefixBigE, PrefixBigN"; +pub(super) const STRING_ESCAPES: &str = "Quote, QuoteStripNul, BackslashHex, BackslashMysql, BackslashUnicode, BackslashHexZero, BackslashUnicodeQuote"; +pub(super) const BYTES_FORMATS: &str = "PrefixBigX, PrefixZeroX, SlashXBytea, PrefixBigBSlashX, HexToRaw, Unhex, ToVarbyte, PostfixXbv"; + +#[inline] +pub(super) fn parse_field( + field: &str, + value: &str, + parse: impl Fn(&str) -> Option, + expected: &str, +) -> pyo3::PyResult { + match parse(value) { + Some(v) => Ok(v), + None => rapidquery_macros::pyerr!( + Err(PyValueError), + "unknown {} {:?}; expected {}", + field, + value, + expected + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_is_case_insensitive_and_round_trips() { + assert_eq!(parse_bit_format("prefixbigb"), Some(BitFormat::PrefixBigB)); + assert_eq!(bit_format_name(BitFormat::PrefixBigB), "PrefixBigB"); + assert_eq!(parse_bool_format("INT"), Some(BoolFormat::Int)); + assert!(parse_bytes_format("nope").is_none()); + } +} diff --git a/src/dialect/databricks.rs b/src/dialect/databricks.rs deleted file mode 100644 index daa5a7c..0000000 --- a/src/dialect/databricks.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! [Databricks SQL](https://www.databricks.com/). -//! -//! • Strings: single-quoted; backslash-escape sequences (Java-style `\uNNNN` -//! for other control chars). -//! • Identifiers: backtick-quoted; backtick doubled. -//! • Bytes: `X'DEADBEEF'` hex literal (BINARY). -//! • Bits: no native bit literal; `CAST(L AS BINARY)`. -//! • Bool: `TRUE` / `FALSE`. -//! • Placeholders: `:name` (client-side; not a native Databricks feature). -//! -//! Ref: - -use crate::internal::utils::replace_to; - -use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; - -pub(super) const NAME: &str = "Databricks"; - -pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; - -pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::Backtick; - -pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; - -#[cfg_attr(feature = "nightly", optimize(speed))] -pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { - for ch in string.chars() { - match ch { - '\\' => writer.write_str("\\\\")?, - '\'' => writer.write_str("\\'")?, - '"' => writer.write_str("\\\"")?, - '\n' => writer.write_str("\\n")?, - '\r' => writer.write_str("\\r")?, - '\t' => writer.write_str("\\t")?, - '\0' => writer.write_str("\\0")?, - c if (c as u32) < 32 || c as u32 == 127 => { - super::write_unicode4_escape(writer, c as u32)?; - } - c => writer.write_char(c)?, - } - } - - Ok(()) -} - -pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { - super::wrap_single_quote(writer, string) -} - -pub(super) fn escape_identifier( - writer: &mut W, - ident: &str, -) -> std::fmt::Result { - replace_to(ident, '`', "``", writer) -} - -pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { - super::write_x_hex_literal(writer, bytes) -} - -// Unsupported: Serializer::write_sql raises NotImplementedError via supports_bit. -pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { - write!(writer, "CAST({}L AS BINARY)", super::bits_as_i64(bits)) -} diff --git a/src/dialect/duckdb.rs b/src/dialect/duckdb.rs deleted file mode 100644 index 96ecf1e..0000000 --- a/src/dialect/duckdb.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! [DuckDB](https://duckdb.org/). -//! -//! • Strings: single-quoted; `''` for an embedded quote. Backslash is **not** -//! special in default mode. NUL is passed through (VARCHAR is UTF-8). -//! • Identifiers: double-quote quoted; `"` doubled. -//! • Bytes: `X'DEADBEEF'` hex literal. -//! • Bits: `B'10110101'` bit-string literal. -//! • Bool: `TRUE` / `FALSE`. -//! • Placeholders: `$name` (native-ish; DuckDB also accepts `$1` / `?`). -//! -//! Ref: - -use crate::internal::utils::replace_to; - -use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; - -pub(super) const NAME: &str = "DuckDB"; - -pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; - -pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::DoubleQuote; - -pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::DollarNamed; - -pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { - super::escape_single_quote(writer, string) -} - -pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { - super::wrap_single_quote(writer, string) -} - -pub(super) fn escape_identifier( - writer: &mut W, - ident: &str, -) -> std::fmt::Result { - replace_to(ident, '"', "\"\"", writer) -} - -pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { - super::write_x_hex_literal(writer, bytes) -} - -pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { - super::debug_assert_bits(bits); - write!(writer, "B'{bits}'") -} diff --git a/src/dialect/generic.rs b/src/dialect/generic.rs new file mode 100644 index 0000000..58d1c8b --- /dev/null +++ b/src/dialect/generic.rs @@ -0,0 +1,142 @@ +use super::base::PySqlDialect; +use super::convert; +use super::inner::Dialect; + +/// Frozen SQL dialect whose quoting and literal rules are set at construction. +#[rapidquery_macros::pyclass( + "GenericDialect", + extends = PySqlDialect, + module = "rapidquery._core.dialect" +)] +pub struct PyGenericDialect; + +#[pyo3::pymethods] +impl PyGenericDialect { + #[new] + #[pyo3(signature = ( + *, + placeholder_sigil, + bit_format, + supports_array, + supports_inet, + supports_mac_address, + null_treatment_syntax, + like_any_syntax, + bool_format, + identifier_format, + identifier_escape, + string_format, + string_escape, + bytes_format, + ))] + #[allow(clippy::too_many_arguments)] + fn __new__( + placeholder_sigil: char, + bit_format: Option<&str>, + supports_array: bool, + supports_inet: bool, + supports_mac_address: bool, + null_treatment_syntax: &str, + like_any_syntax: &str, + bool_format: &str, + identifier_format: &str, + identifier_escape: &str, + string_format: &str, + string_escape: &str, + bytes_format: &str, + ) -> pyo3::PyResult> { + let bit_format = match bit_format { + None => None, + Some(s) => Some(convert::parse_field( + "bit_format", + s, + convert::parse_bit_format, + convert::BIT_FORMATS, + )?), + }; + let inner = Dialect { + placeholder_sigil, + bit_format, + supports_array, + supports_inet, + supports_mac_address, + null_treatment_syntax: convert::parse_field( + "null_treatment_syntax", + null_treatment_syntax, + convert::parse_null_treatment_syntax, + convert::NULL_TREATMENT_SYNTAXES, + )?, + like_any_syntax: convert::parse_field( + "like_any_syntax", + like_any_syntax, + convert::parse_like_any_syntax, + convert::LIKE_ANY_SYNTAXES, + )?, + bool_format: convert::parse_field( + "bool_format", + bool_format, + convert::parse_bool_format, + convert::BOOL_FORMATS, + )?, + identifier_format: convert::parse_field( + "identifier_format", + identifier_format, + convert::parse_identifier_format, + convert::IDENTIFIER_FORMATS, + )?, + identifier_escape: convert::parse_field( + "identifier_escape", + identifier_escape, + convert::parse_identifier_escape, + convert::IDENTIFIER_ESCAPES, + )?, + string_format: convert::parse_field( + "string_format", + string_format, + convert::parse_string_format, + convert::STRING_FORMATS, + )?, + string_escape: convert::parse_field( + "string_escape", + string_escape, + convert::parse_string_escape, + convert::STRING_ESCAPES, + )?, + bytes_format: convert::parse_field( + "bytes_format", + bytes_format, + convert::parse_bytes_format, + convert::BYTES_FORMATS, + )?, + }; + Ok(pyo3::PyClassInitializer::from(PySqlDialect(inner)).add_subclass(Self)) + } + + fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { + let d = &slf.as_super().0; + let bit = match d.bit_format { + None => String::from("None"), + Some(fmt) => format!("{:?}", convert::bit_format_name(fmt)), + }; + format!( + "GenericDialect(placeholder_sigil={:?}, bit_format={bit}, supports_array={}, supports_inet={}, supports_mac_address={}, null_treatment_syntax={:?}, like_any_syntax={:?}, bool_format={:?}, identifier_format={:?}, identifier_escape={:?}, string_format={:?}, string_escape={:?}, bytes_format={:?})", + d.placeholder_sigil, + py_bool(d.supports_array), + py_bool(d.supports_inet), + py_bool(d.supports_mac_address), + convert::null_treatment_syntax_name(d.null_treatment_syntax), + convert::like_any_syntax_name(d.like_any_syntax), + convert::bool_format_name(d.bool_format), + convert::identifier_format_name(d.identifier_format), + convert::identifier_escape_name(d.identifier_escape), + convert::string_format_name(d.string_format), + convert::string_escape_name(d.string_escape), + convert::bytes_format_name(d.bytes_format), + ) + } +} + +#[inline] +fn py_bool(v: bool) -> &'static str { + if v { "True" } else { "False" } +} diff --git a/src/dialect/hive.rs b/src/dialect/hive.rs deleted file mode 100644 index 4f004a1..0000000 --- a/src/dialect/hive.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! [Apache Hive](https://hive.apache.org/). -//! -//! • Strings: single-quoted; Java-style backslash escapes (`\uNNNN` for -//! other control chars). -//! • Identifiers: backtick-quoted; backtick doubled. -//! • Bytes: no native byte literal; `UNHEX('DEADBEEF')`. -//! • Bits: no native bit literal; `CAST( AS BIGINT)`. -//! • Bool: `TRUE` / `FALSE`. -//! • Placeholders: `:name` (not native; rewritten client-side). -//! -//! Ref: - -use crate::internal::utils::replace_to; - -use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; - -pub(super) const NAME: &str = "Hive"; - -pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; - -pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::Backtick; - -pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; - -#[cfg_attr(feature = "nightly", optimize(speed))] -pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { - for ch in string.chars() { - match ch { - '\\' => writer.write_str("\\\\")?, - '\'' => writer.write_str("\\'")?, - '\n' => writer.write_str("\\n")?, - '\r' => writer.write_str("\\r")?, - '\t' => writer.write_str("\\t")?, - '\0' => writer.write_str("\\0")?, - c if (c as u32) < 32 || c as u32 == 127 => { - super::write_unicode4_escape(writer, c as u32)?; - } - c => writer.write_char(c)?, - } - } - Ok(()) -} - -pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { - super::wrap_single_quote(writer, string) -} - -pub(super) fn escape_identifier( - writer: &mut W, - ident: &str, -) -> std::fmt::Result { - replace_to(ident, '`', "``", writer) -} - -pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { - writer.write_str("UNHEX('")?; - super::bytes_to_hex(writer, bytes)?; - writer.write_str("')") -} - -// Unsupported: Serializer::write_sql raises NotImplementedError via supports_bit. -pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { - write!(writer, "CAST({} AS BIGINT)", super::bits_as_i64(bits)) -} diff --git a/src/dialect/inner.rs b/src/dialect/inner.rs new file mode 100644 index 0000000..6f722ba --- /dev/null +++ b/src/dialect/inner.rs @@ -0,0 +1,649 @@ +use std::fmt::{self, Write}; + +use crate::internal::utils; + +/// The way dialect accepts literal bit values +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BitFormat { + /// e.g. `B'101'` + PrefixBigB, + /// e.g. `b'101'` + PrefixSmallB, + /// e.g. `'101'B` + PostfixBigB, +} + +impl BitFormat { + /// Write `bits` as a native bit-string literal. + #[inline] + fn write(self, writer: &mut W, bits: &str) -> fmt::Result { + match self { + Self::PrefixBigB => write!(writer, "B'{bits}'"), + Self::PrefixSmallB => write!(writer, "b'{bits}'"), + Self::PostfixBigB => write!(writer, "'{bits}'B"), + } + } +} + +/// The way dialect supports `{IGNORE|RESPECT} NULLS` expressions in `Function`s. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NullTreatmentSyntax { + /// e.g. `FIRST_VALUE(x IGNORE NULLS)` + InsideParens, + /// e.g. `FIRST_VALUE(x) IGNORE NULLS` + OutsideParens, +} + +/// How `LIKE ANY` / `ILIKE ANY` is rendered. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum LikeAnySyntax { + /// `LIKE ANY (ARRAY[p, …])`. + ArrayConstructor, + /// `LIKE ANY (p, …)`. + PatternList, + /// `(x LIKE p1 OR x LIKE p2)`. Useful when it isn't supported natively by dialect + OrExpansion, +} + +/// How a boolean literal is spelled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BoolFormat { + /// `TRUE` / `FALSE` + Keyword, + /// `1` / `0` + Int, +} + +/// Delimiters wrapped around a quoted identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum IdentifierFormat { + /// `'name'` + SingleQuote, + /// `"name"` + DoubleQuote, + /// `` `name` `` + Backtick, + /// `[name]` + SquareBracket, +} + +/// How to escape identifiers +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum IdentifierEscape { + /// Escape identifier delimiters. + /// + /// - `IdentifierFormat::SingleQuote`: Escape `'` + /// - `IdentifierFormat::DoubleQuote`: Escape `"` + /// - `IdentifierFormat::Backtick`: Escape `` ` `` + /// - `IdentifierFormat::SquareBracket`: Escape `]` + Delimiters, + /// Escape same as how we escape string. + SameAsString, +} + +/// Delimiters wrapped around a literal string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum StringFormat { + /// `'string'` + SingleQuote, + /// `E'string'` + PrefixBigE, + /// `N'string'` + PrefixBigN, +} + +/// How to escape literal strings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum StringEscape { + /// `'` → `''`. Other characters, including NUL, are unchanged. + Quote, + /// `'` → `''`. NUL is deleted. + QuoteStripNul, + /// `\\`, `\'`, `\n`, `\r`, `\t`; NUL and other C0/DEL as `\xNN`. + BackslashHex, + /// MySQL: also `\"` and `\Z` for 0x1A; NUL as `\0`; tab and other controls pass through. + BackslashMysql, + /// Hive: `\0` for NUL; other C0/DEL as `\uNNNN`. No `\"`. + BackslashUnicode, + /// Snowflake: `\0` for NUL; other C0/DEL as `\xNN`. + BackslashHexZero, + /// Spark/Databricks: [`Self::BackslashUnicode`] plus `\"`. + BackslashUnicodeQuote, +} + +impl StringEscape { + /// Write `string` with special characters neutralized, without wrapping it. + #[inline] + fn write(self, writer: &mut W, string: &str) -> fmt::Result { + match self { + Self::Quote => utils::replace_to(string, '\'', "''", writer), + Self::QuoteStripNul => { + const REPLACEMENTS: [(u8, &str); 2] = [(b'\'', "''"), (b'\0', "")]; + utils::replace_ascii_to(string, &REPLACEMENTS, writer) + } + Self::BackslashHex => write_backslash_hex(writer, string), + Self::BackslashMysql => write_backslash_mysql(writer, string), + Self::BackslashUnicode => write_backslash_unicode(writer, string, false), + Self::BackslashHexZero => write_backslash_hex_zero(writer, string), + Self::BackslashUnicodeQuote => write_backslash_unicode(writer, string, true), + } + } +} + +/// The way dialect accepts literal bytes values. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BytesFormat { + /// `X'DEADBEEF'` + PrefixBigX, + /// `0xDEADBEEF` + PrefixZeroX, + /// `'\xDEADBEEF'::bytea` + SlashXBytea, + /// `B'\xNN…'` (each byte as `\xNN`) + PrefixBigBSlashX, + /// `HEXTORAW('DEADBEEF')` + HexToRaw, + /// `UNHEX('DEADBEEF')` + Unhex, + /// `TO_VARBYTE('DEADBEEF', 'hex')` + ToVarbyte, + /// `'DEADBEEF'XBV` + PostfixXbv, +} + +impl BytesFormat { + /// Write `bytes` as a native blob/bytea/varbyte literal. + #[inline] + pub fn write(self, writer: &mut W, bytes: &[u8]) -> fmt::Result { + match self { + Self::PrefixBigX => { + writer.write_str("X'")?; + bytes_to_hex(writer, bytes)?; + writer.write_char('\'') + } + Self::PrefixZeroX => { + writer.write_str("0x")?; + bytes_to_hex(writer, bytes) + } + Self::SlashXBytea => { + writer.write_str("'\\x")?; + bytes_to_hex(writer, bytes)?; + writer.write_str("'::bytea") + } + Self::PrefixBigBSlashX => { + writer.write_str("B'")?; + for &b in bytes { + write_byte_hex_escape(writer, b)?; + } + writer.write_char('\'') + } + Self::HexToRaw => { + writer.write_str("HEXTORAW('")?; + bytes_to_hex(writer, bytes)?; + writer.write_str("')") + } + Self::Unhex => { + writer.write_str("UNHEX('")?; + bytes_to_hex(writer, bytes)?; + writer.write_str("')") + } + Self::ToVarbyte => { + writer.write_str("TO_VARBYTE('")?; + bytes_to_hex(writer, bytes)?; + writer.write_str("', 'hex')") + } + Self::PostfixXbv => { + writer.write_char('\'')?; + bytes_to_hex(writer, bytes)?; + writer.write_str("'XBV") + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Dialect { + /// Named-placeholder sigil used when building parameterized SQL. + /// + /// e.g. `'$'`, `'@'`, `':'` + pub placeholder_sigil: char, + /// How the dialect accepts literal bit values if supported. + /// + /// Set [`Option::None`] if literal bit isn't supported. + pub bit_format: Option, + /// Whether the dialect supports `ARRAY[...]` data type. + pub supports_array: bool, + /// Whether the dialect supports `INET`/`CIDR` data types. + pub supports_inet: bool, + /// Whether the dialect supports `MACADDR` data type. + pub supports_mac_address: bool, + /// How the dialect supports `{IGNORE|RESPECT} NULLS` expressions in `Function`s. + pub null_treatment_syntax: NullTreatmentSyntax, + /// How `LIKE ANY` / `ILIKE ANY` is rendered. + pub like_any_syntax: LikeAnySyntax, + /// How the dialect accepts literal boolean values. + pub bool_format: BoolFormat, + /// Specifies how delimiters should wrap around a quoted identifier. + pub identifier_format: IdentifierFormat, + /// Specifies how to escape identifiers + pub identifier_escape: IdentifierEscape, + /// Specifies how delimiters should wrap around a literal string value. + pub string_format: StringFormat, + /// Specifies how to escape literal string value. + pub string_escape: StringEscape, + /// How the dialect accepts literal bytes values. + pub bytes_format: BytesFormat, +} + +impl Dialect { + /// Write a boolean literal (`TRUE`/`FALSE` or `1`/`0`). + #[inline] + pub fn format_boolean(&self, writer: &mut W, value: bool) -> fmt::Result { + match (self.bool_format, value) { + (BoolFormat::Int, false) => write!(writer, "0"), + (BoolFormat::Int, true) => write!(writer, "1"), + (BoolFormat::Keyword, false) => write!(writer, "FALSE"), + (BoolFormat::Keyword, true) => write!(writer, "TRUE"), + } + } + + /// Write `string` with special characters neutralized, without wrapping it. + #[inline] + pub fn escape_string(&self, writer: &mut W, string: &str) -> fmt::Result { + self.string_escape.write(writer, string) + } + + /// Escape `ident` and wrap it in this dialect's identifier delimiters. + #[inline] + pub fn quote_identifier(&self, writer: &mut W, ident: &str) -> fmt::Result { + let (start, end) = match self.identifier_format { + IdentifierFormat::SingleQuote => ('\'', '\''), + IdentifierFormat::DoubleQuote => ('"', '"'), + IdentifierFormat::Backtick => ('`', '`'), + IdentifierFormat::SquareBracket => ('[', ']'), + }; + writer.write_char(start)?; + + match self.identifier_escape { + IdentifierEscape::Delimiters => match self.identifier_format { + IdentifierFormat::SingleQuote => { + utils::replace_to(ident, '\'', "''", writer)?; + } + IdentifierFormat::DoubleQuote => { + utils::replace_to(ident, '"', "\"\"", writer)?; + } + IdentifierFormat::Backtick => { + utils::replace_to(ident, '`', "``", writer)?; + } + IdentifierFormat::SquareBracket => { + utils::replace_to(ident, ']', "]]", writer)?; + } + }, + IdentifierEscape::SameAsString => self.escape_string(writer, ident)?, + } + + writer.write_char(end) + } + + /// Escape `raw` and wrap it as a complete string literal. + #[inline] + pub fn write_string_literal(&self, writer: &mut W, raw: &str) -> fmt::Result { + match self.string_format { + StringFormat::PrefixBigE => writer.write_str("E'")?, + StringFormat::PrefixBigN => writer.write_str("N'")?, + StringFormat::SingleQuote => writer.write_char('\'')?, + } + self.escape_string(writer, raw)?; + writer.write_char('\'') + } + + /// Write a bit-string (`'0'`/`'1'` characters) as a native literal. + /// + /// Native formats embed `bits` as-is. Callers must pass only `'0'` and `'1'`; + /// that is checked with `debug_assert!`. + /// + /// # Panics + /// + /// Panics when the dialect does not support literal bit values and + /// [`Self::bit_format`] is [`None`]. Callers should check + /// [`Option::is_some`] on [`Self::bit_format`] before calling this method. + #[inline] + pub fn format_bits(&self, writer: &mut W, bits: &str) -> fmt::Result { + debug_assert!( + bits.chars().all(|c| c == '0' || c == '1'), + "format_bits: invalid input: {bits}" + ); + match self.bit_format { + Some(format) => format.write(writer, bits), + None => panic!( + "format_bits: dialect does not support literal bit values (bit_format is None)" + ), + } + } + + /// Write `bytes` as a native blob/bytea/varbyte literal. + #[inline] + pub fn format_bytes(&self, writer: &mut W, bytes: &[u8]) -> fmt::Result { + self.bytes_format.write(writer, bytes) + } +} + +const HEX_DIGITS: &[u8; 16] = b"0123456789ABCDEF"; + +/// Write `byte` as two uppercase hex digits. +#[inline] +fn write_hex2(writer: &mut W, byte: u8) -> fmt::Result { + let buf = [ + HEX_DIGITS[(byte >> 4) as usize], + HEX_DIGITS[(byte & 0x0F) as usize], + ]; + // SAFETY: HEX_DIGITS is ASCII, so `buf` is valid UTF-8. + writer.write_str(unsafe { std::str::from_utf8_unchecked(&buf) }) +} + +/// Write `bytes` as a contiguous uppercase hex run (`DEADBEEF`). +#[inline] +fn bytes_to_hex(writer: &mut W, bytes: &[u8]) -> fmt::Result { + for &b in bytes { + write_hex2(writer, b)?; + } + Ok(()) +} + +/// Write `\xNN` for a single byte (uppercase hex). +#[inline] +fn write_byte_hex_escape(writer: &mut W, byte: u8) -> fmt::Result { + writer.write_str("\\x")?; + write_hex2(writer, byte) +} + +/// Write `\uNNNN` for a codepoint that fits in 16 bits. +#[inline] +fn write_unicode4_escape(writer: &mut W, code: u32) -> fmt::Result { + debug_assert!(code <= 0xFFFF); + writer.write_str("\\u")?; + write_hex2(writer, (code >> 8) as u8)?; + write_hex2(writer, code as u8) +} + +#[cfg_attr(feature = "nightly", optimize(speed))] +fn write_backslash_hex(writer: &mut W, string: &str) -> fmt::Result { + for ch in string.chars() { + match ch { + '\\' => writer.write_str("\\\\")?, + '\'' => writer.write_str("\\'")?, + '\n' => writer.write_str("\\n")?, + '\r' => writer.write_str("\\r")?, + '\t' => writer.write_str("\\t")?, + '\0' => writer.write_str("\\x00")?, + c if (c as u32) < 32 || c as u32 == 127 => { + write_byte_hex_escape(writer, c as u8)?; + } + c => writer.write_char(c)?, + } + } + Ok(()) +} + +#[cfg_attr(feature = "nightly", optimize(speed))] +fn write_backslash_mysql(writer: &mut W, string: &str) -> fmt::Result { + for ch in string.chars() { + match ch { + '\\' => writer.write_str("\\\\")?, + '\'' => writer.write_str("\\'")?, + '"' => writer.write_str("\\\"")?, + '\0' => writer.write_str("\\0")?, + '\n' => writer.write_str("\\n")?, + '\r' => writer.write_str("\\r")?, + '\x1a' => writer.write_str("\\Z")?, + c => writer.write_char(c)?, + } + } + Ok(()) +} + +#[cfg_attr(feature = "nightly", optimize(speed))] +fn write_backslash_unicode( + writer: &mut W, + string: &str, + escape_double_quote: bool, +) -> fmt::Result { + for ch in string.chars() { + match ch { + '\\' => writer.write_str("\\\\")?, + '\'' => writer.write_str("\\'")?, + '"' if escape_double_quote => writer.write_str("\\\"")?, + '\n' => writer.write_str("\\n")?, + '\r' => writer.write_str("\\r")?, + '\t' => writer.write_str("\\t")?, + '\0' => writer.write_str("\\0")?, + c if (c as u32) < 32 || c as u32 == 127 => { + write_unicode4_escape(writer, c as u32)?; + } + c => writer.write_char(c)?, + } + } + Ok(()) +} + +#[cfg_attr(feature = "nightly", optimize(speed))] +fn write_backslash_hex_zero(writer: &mut W, string: &str) -> fmt::Result { + for ch in string.chars() { + match ch { + '\\' => writer.write_str("\\\\")?, + '\'' => writer.write_str("\\'")?, + '\n' => writer.write_str("\\n")?, + '\r' => writer.write_str("\\r")?, + '\t' => writer.write_str("\\t")?, + '\0' => writer.write_str("\\0")?, + c if (c as u32) < 32 || c as u32 == 127 => { + write_byte_hex_escape(writer, c as u8)?; + } + c => writer.write_char(c)?, + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn collect(write: impl FnOnce(&mut String) -> fmt::Result) -> String { + let mut buf = String::new(); + write(&mut buf).unwrap(); + buf + } + + fn escape(kind: StringEscape, string: &str) -> String { + collect(|buf| kind.write(buf, string)) + } + + fn bytes(kind: BytesFormat, bytes: &[u8]) -> String { + collect(|buf| kind.write(buf, bytes)) + } + + fn base() -> Dialect { + Dialect { + placeholder_sigil: ':', + bit_format: None, + supports_array: false, + supports_inet: false, + supports_mac_address: false, + null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + like_any_syntax: LikeAnySyntax::OrExpansion, + bool_format: BoolFormat::Keyword, + identifier_format: IdentifierFormat::DoubleQuote, + identifier_escape: IdentifierEscape::Delimiters, + string_format: StringFormat::SingleQuote, + string_escape: StringEscape::Quote, + bytes_format: BytesFormat::PrefixBigX, + } + } + + #[test] + fn quote_vs_quote_strip_nul() { + for kind in [StringEscape::Quote, StringEscape::QuoteStripNul] { + assert_eq!(escape(kind, "O'Brien"), "O''Brien"); + assert_eq!(escape(kind, "'"), "''"); + } + assert_eq!(escape(StringEscape::Quote, "a\0b"), "a\0b"); + assert_eq!(escape(StringEscape::QuoteStripNul, "a\0b"), "ab"); + } + + #[test] + fn backslash_variants() { + let cases: [(StringEscape, &str, &str); 40] = [ + (StringEscape::BackslashHex, "'", "\\'"), + (StringEscape::BackslashHex, "\\", "\\\\"), + (StringEscape::BackslashHex, "\n", "\\n"), + (StringEscape::BackslashHex, "\t", "\\t"), + (StringEscape::BackslashHex, "\0", "\\x00"), + (StringEscape::BackslashHex, "\"", "\""), + (StringEscape::BackslashHex, "\x1a", "\\x1A"), + (StringEscape::BackslashHex, "\x01", "\\x01"), + (StringEscape::BackslashMysql, "'", "\\'"), + (StringEscape::BackslashMysql, "\\", "\\\\"), + (StringEscape::BackslashMysql, "\n", "\\n"), + (StringEscape::BackslashMysql, "\t", "\t"), + (StringEscape::BackslashMysql, "\0", "\\0"), + (StringEscape::BackslashMysql, "\"", "\\\""), + (StringEscape::BackslashMysql, "\x1a", "\\Z"), + (StringEscape::BackslashMysql, "\x01", "\x01"), + (StringEscape::BackslashUnicode, "'", "\\'"), + (StringEscape::BackslashUnicode, "\\", "\\\\"), + (StringEscape::BackslashUnicode, "\n", "\\n"), + (StringEscape::BackslashUnicode, "\t", "\\t"), + (StringEscape::BackslashUnicode, "\0", "\\0"), + (StringEscape::BackslashUnicode, "\"", "\""), + (StringEscape::BackslashUnicode, "\x1a", "\\u001A"), + (StringEscape::BackslashUnicode, "\x01", "\\u0001"), + (StringEscape::BackslashHexZero, "'", "\\'"), + (StringEscape::BackslashHexZero, "\\", "\\\\"), + (StringEscape::BackslashHexZero, "\n", "\\n"), + (StringEscape::BackslashHexZero, "\t", "\\t"), + (StringEscape::BackslashHexZero, "\0", "\\0"), + (StringEscape::BackslashHexZero, "\"", "\""), + (StringEscape::BackslashHexZero, "\x1a", "\\x1A"), + (StringEscape::BackslashHexZero, "\x01", "\\x01"), + (StringEscape::BackslashUnicodeQuote, "'", "\\'"), + (StringEscape::BackslashUnicodeQuote, "\\", "\\\\"), + (StringEscape::BackslashUnicodeQuote, "\n", "\\n"), + (StringEscape::BackslashUnicodeQuote, "\t", "\\t"), + (StringEscape::BackslashUnicodeQuote, "\0", "\\0"), + (StringEscape::BackslashUnicodeQuote, "\"", "\\\""), + (StringEscape::BackslashUnicodeQuote, "\x1a", "\\u001A"), + (StringEscape::BackslashUnicodeQuote, "\x01", "\\u0001"), + ]; + for (kind, input, expected) in cases { + assert_eq!( + escape(kind, input), + expected, + "{kind:?} escaped {input:?} incorrectly" + ); + } + } + + #[test] + fn bytes_format_wrappers() { + let empty: &[u8] = &[]; + let zero: &[u8] = &[0x00]; + let dead: &[u8] = &[0xDE, 0xAD]; + + assert_eq!(bytes(BytesFormat::PrefixBigX, empty), "X''"); + assert_eq!(bytes(BytesFormat::PrefixBigX, zero), "X'00'"); + assert_eq!(bytes(BytesFormat::PrefixBigX, dead), "X'DEAD'"); + + assert_eq!(bytes(BytesFormat::PrefixZeroX, empty), "0x"); + assert_eq!(bytes(BytesFormat::PrefixZeroX, zero), "0x00"); + assert_eq!(bytes(BytesFormat::PrefixZeroX, dead), "0xDEAD"); + + assert_eq!(bytes(BytesFormat::SlashXBytea, empty), "'\\x'::bytea"); + assert_eq!(bytes(BytesFormat::SlashXBytea, zero), "'\\x00'::bytea"); + assert_eq!(bytes(BytesFormat::SlashXBytea, dead), "'\\xDEAD'::bytea"); + + assert_eq!(bytes(BytesFormat::PrefixBigBSlashX, empty), "B''"); + assert_eq!(bytes(BytesFormat::PrefixBigBSlashX, zero), "B'\\x00'"); + assert_eq!(bytes(BytesFormat::PrefixBigBSlashX, dead), "B'\\xDE\\xAD'"); + + assert_eq!(bytes(BytesFormat::HexToRaw, empty), "HEXTORAW('')"); + assert_eq!(bytes(BytesFormat::HexToRaw, zero), "HEXTORAW('00')"); + assert_eq!(bytes(BytesFormat::HexToRaw, dead), "HEXTORAW('DEAD')"); + + assert_eq!(bytes(BytesFormat::Unhex, empty), "UNHEX('')"); + assert_eq!(bytes(BytesFormat::Unhex, zero), "UNHEX('00')"); + assert_eq!(bytes(BytesFormat::Unhex, dead), "UNHEX('DEAD')"); + + assert_eq!( + bytes(BytesFormat::ToVarbyte, empty), + "TO_VARBYTE('', 'hex')" + ); + assert_eq!( + bytes(BytesFormat::ToVarbyte, zero), + "TO_VARBYTE('00', 'hex')" + ); + assert_eq!( + bytes(BytesFormat::ToVarbyte, dead), + "TO_VARBYTE('DEAD', 'hex')" + ); + + assert_eq!(bytes(BytesFormat::PostfixXbv, empty), "''XBV"); + assert_eq!(bytes(BytesFormat::PostfixXbv, zero), "'00'XBV"); + assert_eq!(bytes(BytesFormat::PostfixXbv, dead), "'DEAD'XBV"); + } + + #[test] + fn bit_format_literals() { + let bits = "10110101"; + assert_eq!( + collect(|buf| BitFormat::PrefixBigB.write(buf, bits)), + "B'10110101'" + ); + assert_eq!( + collect(|buf| BitFormat::PrefixSmallB.write(buf, bits)), + "b'10110101'" + ); + assert_eq!( + collect(|buf| BitFormat::PostfixBigB.write(buf, bits)), + "'10110101'B" + ); + + let mut cfg = base(); + cfg.bit_format = Some(BitFormat::PrefixBigB); + assert_eq!(collect(|buf| cfg.format_bits(buf, bits)), "B'10110101'"); + assert_eq!( + collect(|buf| cfg.format_bytes(buf, &[0xDE, 0xAD])), + "X'DEAD'" + ); + } + + #[test] + #[should_panic(expected = "bit_format is None")] + fn format_bits_panics_when_unsupported() { + let cfg = base(); + let _ = cfg.format_bits(&mut String::new(), "1"); + } + + #[test] + fn same_as_string_identifier_uses_string_escape() { + let mut cfg = base(); + cfg.identifier_format = IdentifierFormat::Backtick; + cfg.identifier_escape = IdentifierEscape::SameAsString; + cfg.string_escape = StringEscape::BackslashHex; + assert_eq!( + collect(|buf| cfg.quote_identifier(buf, "foo'bar\\baz")), + "`foo\\'bar\\\\baz`" + ); + } + + #[test] + fn write_string_literal_prefix_big_e_backslash_hex() { + let mut cfg = base(); + cfg.string_format = StringFormat::PrefixBigE; + cfg.string_escape = StringEscape::BackslashHex; + assert_eq!( + collect(|buf| cfg.write_string_literal(buf, "hello'world\n")), + "E'hello\\'world\\n'" + ); + } +} diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 613fab9..3cfd4c0 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1,487 +1,21 @@ -//! SQL dialect definitions. -//! -//! A dialect encodes how one database engine quotes identifiers, escapes -//! string/byte/bit literals, formats booleans, and spells named placeholders. -//! -//! Dialects are selected at runtime through the [`SqlDialect`] enum. Each -//! variant is a marker; behavior lives in the matching private module. -//! -//! # Placeholders -//! -//! [`PlaceholderSyntax`] is the *named* form (`@param_N`, `:param_N`, -//! `$param_N`). Several engines have no native named placeholders (Hive, -//! MySQL, Postgres `$1`, SQLite `?`); those dialects still pick a named -//! style that common Python clients rewrite client-side. -//! -//! # Quoting contract -//! -//! `escape_*` writes the **body** only (special characters neutralized). -//! `quote_*` wraps an **already-escaped** body in the dialect's delimiters. -//! Call `escape` then `quote`, or use `quote_identifier` which does both -//! for identifiers. - -use std::fmt::{self, Write}; - -use crate::internal::utils::{replace_ascii_to, replace_to}; - -mod bigquery; -mod databricks; -mod duckdb; -mod hive; -mod mssql; -mod mysql; -mod oracle; -mod postgresql; -mod redshift; -mod snowflake; -mod spark; -mod sqlite; -mod teradata; -pub mod py; - -#[cfg(test)] -mod tests; - -pub use py::dialect_mod; - -/// How `LIKE ANY` / `ILIKE ANY` is rendered. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum LikeAnySyntax { - /// PostgreSQL: `LIKE ANY (ARRAY[p, …])`. - ArrayConstructor, - /// Snowflake / Spark / Databricks: `LIKE ANY (p, …)`. - PatternList, - /// No native ANY: `(x LIKE p1 OR x LIKE p2)`. - OrExpansion, -} - -/// How a boolean literal is spelled. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum BoolFormat { - /// `TRUE` / `FALSE` - Keyword, - /// `1` / `0` - Int, -} - -impl BoolFormat { - fn write(self, writer: &mut W, value: bool) -> fmt::Result { - match (self, value) { - (Self::Keyword, true) => writer.write_str("TRUE"), - (Self::Keyword, false) => writer.write_str("FALSE"), - (Self::Int, true) => writer.write_char('1'), - (Self::Int, false) => writer.write_char('0'), - } - } -} - -/// Delimiters wrapped around a quoted identifier. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum IdentifierQuote { - /// `'name'` (Teradata). - SingleQuote, - /// `"name"` (Postgres, SQLite, Oracle, …). - DoubleQuote, - /// `` `name` `` (MySQL, BigQuery, Spark, …). - Backtick, - /// `[name]` (SQL Server). - SquareBracket, -} - -impl IdentifierQuote { - /// Opening and closing delimiter characters. - #[inline] - pub const fn delimiters(self) -> (char, char) { - match self { - Self::SingleQuote => ('\'', '\''), - Self::DoubleQuote => ('"', '"'), - Self::Backtick => ('`', '`'), - Self::SquareBracket => ('[', ']'), - } - } -} - -/// Named-placeholder spelling used when building parameterized SQL. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum PlaceholderSyntax { - /// `@param_name` (SQL Server, BigQuery). - AtSign, - /// `:param_name` (Oracle, and many client-side rewrites). - Colon, - /// `$param_name` (DuckDB; not Postgres positional `$1`). - DollarNamed, -} - -impl PlaceholderSyntax { - /// Character written immediately before the parameter name. - #[inline] - pub const fn sigil(self) -> char { - match self { - Self::AtSign => '@', - Self::Colon => ':', - Self::DollarNamed => '$', - } - } -} - -// --------------------------------------------------------------------------- -// Shared write helpers (no heap allocation beyond the caller's `Write`). -// --------------------------------------------------------------------------- - -const HEX_DIGITS: &[u8; 16] = b"0123456789ABCDEF"; - -/// Write `byte` as two uppercase hex digits. -#[inline] -pub(super) fn write_hex2(writer: &mut W, byte: u8) -> fmt::Result { - let buf = [ - HEX_DIGITS[(byte >> 4) as usize], - HEX_DIGITS[(byte & 0x0F) as usize], - ]; - // SAFETY: HEX_DIGITS is ASCII, so `buf` is valid UTF-8. - writer.write_str(unsafe { std::str::from_utf8_unchecked(&buf) }) -} - -/// Write `bytes` as a contiguous uppercase hex run (`DEADBEEF`). -#[inline] -pub(super) fn bytes_to_hex(writer: &mut W, bytes: &[u8]) -> fmt::Result { - for &b in bytes { - write_hex2(writer, b)?; - } - Ok(()) -} - -/// Write `\xNN` for a single byte (uppercase hex). -#[inline] -pub(super) fn write_byte_hex_escape(writer: &mut W, byte: u8) -> fmt::Result { - writer.write_str("\\x")?; - write_hex2(writer, byte) -} - -/// Write `\uNNNN` for a codepoint that fits in 16 bits. -#[inline] -pub(super) fn write_unicode4_escape(writer: &mut W, code: u32) -> fmt::Result { - debug_assert!(code <= 0xFFFF); - writer.write_str("\\u")?; - write_hex2(writer, (code >> 8) as u8)?; - write_hex2(writer, code as u8) -} - -/// `X'DEADBEEF'` blob literal, used by MySQL, SQLite, Spark, Snowflake, … -#[inline] -pub(super) fn write_x_hex_literal(writer: &mut W, bytes: &[u8]) -> fmt::Result { - writer.write_str("X'")?; - bytes_to_hex(writer, bytes)?; - writer.write_char('\'') -} - -/// ANSI string-body escape: `'` → `''`. Does not strip NUL. -#[inline] -pub(super) fn escape_single_quote(writer: &mut W, string: &str) -> fmt::Result { - replace_to(string, '\'', "''", writer) -} - -/// ANSI string-body escape plus NUL stripping (`'` → `''`, `\0` deleted). -/// -/// Used by engines whose string types cannot hold NUL (T-SQL, Oracle, -/// Redshift, SQLite, Teradata). -#[inline] -pub(super) fn escape_quote_and_strip_nul(writer: &mut W, string: &str) -> fmt::Result { - const REPLACEMENTS: [(u8, &str); 2] = [(b'\'', "''"), (b'\0', "")]; - replace_ascii_to(string, &REPLACEMENTS, writer) -} - -/// Wrap an already-escaped body in single quotes (`'…'`). -#[inline] -pub(super) fn wrap_single_quote(writer: &mut W, string: &str) -> fmt::Result { - writer.write_char('\'')?; - writer.write_str(string)?; - writer.write_char('\'') -} - -/// Debug-only check that `bits` is a `'0'`/`'1'` string. -#[inline] -pub(super) fn debug_assert_bits(bits: &str) { - debug_assert!( - bits.chars().all(|c| c == '0' || c == '1'), - "encode_bits: invalid input: {bits}" - ); -} - -/// Parse `bits` as a base-2 `i64`. -/// -/// Empty strings and values that overflow `i64` (including bit strings -/// longer than 64) return `0`. That fallback is the current contract until -/// a later layer can surface a real error. Callers must pass only `'0'`/`'1'`. -#[inline] -pub(super) fn bits_as_i64(bits: &str) -> i64 { - debug_assert_bits(bits); - i64::from_str_radix(bits, 2).unwrap_or(0) -} - -/// Run `write` into a new `String`. `fmt::Write` for `String` is infallible. -#[inline] -fn to_string(write: impl FnOnce(&mut String) -> fmt::Result) -> String { - let mut buf = String::new(); - let _ = write(&mut buf); - buf -} - -/// Dispatches to the per-dialect free function of the same name, forwarding -/// any extra arguments. Keeps [`SqlDialect`]'s methods free of repetitive -/// match boilerplate as new dialects/methods are added. -/// -/// Use `dispatch!(self, fn METHOD, arg1, arg2, ...)` for functions and -/// `dispatch!(self, const NAME)` for associated consts (no call parens). -macro_rules! dispatch { - ($self:expr, fn $method:ident $(, $arg:expr)*) => { - match $self { - SqlDialect::BigQuery => bigquery::$method($($arg),*), - SqlDialect::Databricks => databricks::$method($($arg),*), - SqlDialect::DuckDb => duckdb::$method($($arg),*), - SqlDialect::Hive => hive::$method($($arg),*), - SqlDialect::MsSql => mssql::$method($($arg),*), - SqlDialect::MySql => mysql::$method($($arg),*), - SqlDialect::Oracle => oracle::$method($($arg),*), - SqlDialect::PostgreSql => postgresql::$method($($arg),*), - SqlDialect::Redshift => redshift::$method($($arg),*), - SqlDialect::Snowflake => snowflake::$method($($arg),*), - SqlDialect::Spark => spark::$method($($arg),*), - SqlDialect::Sqlite => sqlite::$method($($arg),*), - SqlDialect::Teradata => teradata::$method($($arg),*), - } - }; - ($self:expr, const $item:ident) => { - match $self { - SqlDialect::BigQuery => bigquery::$item, - SqlDialect::Databricks => databricks::$item, - SqlDialect::DuckDb => duckdb::$item, - SqlDialect::Hive => hive::$item, - SqlDialect::MsSql => mssql::$item, - SqlDialect::MySql => mysql::$item, - SqlDialect::Oracle => oracle::$item, - SqlDialect::PostgreSql => postgresql::$item, - SqlDialect::Redshift => redshift::$item, - SqlDialect::Snowflake => snowflake::$item, - SqlDialect::Spark => spark::$item, - SqlDialect::Sqlite => sqlite::$item, - SqlDialect::Teradata => teradata::$item, - } +mod base; +mod builtin; +mod convert; +mod generic; +pub mod inner; + +pub use base::PySqlDialect; +pub use builtin::{ + PyBigQuery, PyDatabricks, PyDuckDb, PyHive, PyMsSql, PyMySql, PyOracle, PyPostgreSql, + PyRedshift, PySnowflake, PySpark, PySqlite, PyTeradata, +}; +pub use generic::PyGenericDialect; + +#[pyo3::pymodule(name = "dialect", gil_used = false)] +pub mod dialect_mod { + #[pymodule_export] + use super::{ + PyBigQuery, PyDatabricks, PyDuckDb, PyGenericDialect, PyHive, PyMsSql, PyMySql, PyOracle, + PyPostgreSql, PyRedshift, PySnowflake, PySpark, PySqlDialect, PySqlite, PyTeradata, }; } - -/// The SQL dialects supported by this crate, selectable at runtime. -/// -/// Each variant carries no data; dialect-specific behavior lives in the -/// corresponding private module and is reached through the methods below. -/// -/// [`SqlDialect::name`] is the Python class name (`DuckDB`, `PostgreSQL`, -/// `SQLite`, …). Rust variant identifiers stay idiomatic (`DuckDb`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[non_exhaustive] -pub enum SqlDialect { - /// [Google BigQuery](https://cloud.google.com/bigquery/) - BigQuery, - /// [Databricks SQL](https://www.databricks.com/) - Databricks, - /// [DuckDB](https://duckdb.org/) - DuckDb, - /// [Apache Hive](https://hive.apache.org/) - Hive, - /// [Microsoft SQL Server](https://www.microsoft.com/en-us/sql-server/) - MsSql, - /// [MySQL](https://www.mysql.com/) - MySql, - /// [Oracle Database](https://docs.oracle.com/en/database/oracle/oracle-database/) - Oracle, - /// [PostgreSQL](https://www.postgresql.org/) - PostgreSql, - /// [Amazon Redshift](https://aws.amazon.com/redshift/) - Redshift, - /// [Snowflake](https://www.snowflake.com/) - Snowflake, - /// [Apache Spark SQL](https://spark.apache.org/docs/latest/sql-ref.html) - Spark, - /// [SQLite](https://www.sqlite.org/) - Sqlite, - /// [Teradata](https://docs.teradata.com/) - Teradata, -} - -impl SqlDialect { - /// Display name, identical to the Python class name (e.g. `"PostgreSQL"`, `"DuckDB"`). - #[inline] - pub fn name(&self) -> &'static str { - dispatch!(self, const NAME) - } - - /// Named-placeholder style for parameterized SQL. - #[inline] - pub fn placeholder_syntax(&self) -> PlaceholderSyntax { - dispatch!(self, const PLACEHOLDER_SYNTAX) - } - - /// Native bit-string literals (`B'…'`, `b'…'`, `'…'B`). - #[inline] - pub fn supports_bit(self) -> bool { - matches!( - self, - Self::PostgreSql | Self::DuckDb | Self::MySql | Self::Teradata - ) - } - - /// `ARRAY[…]` constructor syntax. - #[inline] - pub fn supports_array(self) -> bool { - matches!(self, Self::PostgreSql | Self::DuckDb | Self::BigQuery) - } - - /// Native `INET` / `CIDR` types. - #[inline] - pub fn supports_inet(self) -> bool { - matches!(self, Self::PostgreSql | Self::DuckDb) - } - - /// Native `MACADDR` type. - #[inline] - pub fn supports_mac_address(self) -> bool { - matches!(self, Self::PostgreSql) - } - - /// `{IGNORE|RESPECT} NULLS` inside the argument list - /// (`FIRST_VALUE(x IGNORE NULLS)`), as documented for BigQuery and DuckDB. - /// Other dialects emit it after the closing parenthesis. - #[inline] - pub fn null_treatment_inside_parens(self) -> bool { - matches!(self, Self::BigQuery | Self::DuckDb) - } - - /// How `LIKE ANY` / `ILIKE ANY` is spelled. - #[inline] - pub fn like_any_syntax(self) -> LikeAnySyntax { - match self { - Self::PostgreSql => LikeAnySyntax::ArrayConstructor, - Self::Snowflake | Self::Spark | Self::Databricks => LikeAnySyntax::PatternList, - _ => LikeAnySyntax::OrExpansion, - } - } - - /// Write a boolean literal (`TRUE`/`FALSE` or `1`/`0`). - #[inline] - pub fn format_boolean(&self, writer: &mut W, value: bool) -> fmt::Result { - dispatch!(self, const BOOL_FORMAT).write(writer, value) - } - - /// [`format_boolean`](Self::format_boolean) into an owned `String`. - #[inline] - pub fn format_boolean_as_string(&self, value: bool) -> String { - let mut buf = String::with_capacity(5); - let _ = self.format_boolean(&mut buf, value); - buf - } - - /// Write `ident` with delimiter characters doubled/escaped, **without** - /// wrapping it. Use [`quote_identifier`](Self::quote_identifier) to also - /// add delimiters. - #[inline] - pub fn escape_identifier(&self, writer: &mut W, ident: &str) -> fmt::Result { - dispatch!(self, fn escape_identifier, writer, ident) - } - - /// [`escape_identifier`](Self::escape_identifier) into an owned `String`. - #[inline] - pub fn escape_identifier_as_string(&self, ident: &str) -> String { - to_string(|buf| self.escape_identifier(buf, ident)) - } - - /// Escape `ident` and wrap it in this dialect's identifier delimiters. - #[inline] - pub fn quote_identifier(&self, writer: &mut W, ident: &str) -> fmt::Result { - let (start, end) = dispatch!(self, const IDENTIFIER_QUOTE).delimiters(); - writer.write_char(start)?; - self.escape_identifier(writer, ident)?; - writer.write_char(end) - } - - /// [`quote_identifier`](Self::quote_identifier) into an owned `String`. - #[inline] - pub fn quote_identifier_as_string(&self, ident: &str) -> String { - to_string(|buf| self.quote_identifier(buf, ident)) - } - - /// Write `string` with special characters neutralized, **without** wrapping - /// it in quotes. Call [`quote_string`](Self::quote_string) on the result - /// to produce a complete literal. - #[inline] - pub fn escape_string(&self, writer: &mut W, string: &str) -> fmt::Result { - dispatch!(self, fn escape_string, writer, string) - } - - /// [`escape_string`](Self::escape_string) into an owned `String`. - #[inline] - pub fn escape_string_as_string(&self, string: &str) -> String { - to_string(|buf| self.escape_string(buf, string)) - } - - /// Wrap an **already-escaped** string in this dialect's literal quoting - /// (`'…'`, `E'…'`, `N'…'`). - #[inline] - pub fn quote_string(&self, writer: &mut W, string: &str) -> fmt::Result { - dispatch!(self, fn quote_string, writer, string) - } - - /// [`quote_string`](Self::quote_string) into an owned `String`. - #[inline] - pub fn quote_string_as_string(&self, string: &str) -> String { - to_string(|buf| self.quote_string(buf, string)) - } - - /// Escape `raw` and wrap it as a complete string literal. - /// - /// Equivalent to `quote_string(escape_string(raw))` with no intermediate - /// `String`: writes the dialect prefix (`'` / `E'` / `N'`), then the - /// escaped body, then `'`. - #[inline] - pub fn write_string_literal(&self, writer: &mut W, raw: &str) -> fmt::Result { - match self { - Self::PostgreSql => writer.write_str("E'")?, - Self::MsSql => writer.write_str("N'")?, - _ => writer.write_char('\'')?, - } - self.escape_string(writer, raw)?; - writer.write_char('\'') - } - - /// [`write_string_literal`](Self::write_string_literal) into an owned `String`. - #[inline] - pub fn write_string_literal_as_string(&self, raw: &str) -> String { - to_string(|buf| self.write_string_literal(buf, raw)) - } - - /// Write `bytes` as a native blob/bytea/varbyte literal. - #[inline] - pub fn encode_bytes(&self, writer: &mut W, bytes: &[u8]) -> fmt::Result { - dispatch!(self, fn encode_bytes, writer, bytes) - } - - /// [`encode_bytes`](Self::encode_bytes) into an owned `String`. - #[inline] - pub fn encode_bytes_as_string(&self, bytes: &[u8]) -> String { - to_string(|buf| self.encode_bytes(buf, bytes)) - } - - /// Write a bit-string (`'0'`/`'1'` characters) as a native literal. - /// - /// Callers must pass only `'0'` and `'1'`; that is checked with - /// `debug_assert!`. Dialects that fold bits into an `i64` return `0` on - /// overflow (see [`bits_as_i64`]). - #[inline] - pub fn encode_bits(&self, writer: &mut W, bits: &str) -> fmt::Result { - dispatch!(self, fn encode_bits, writer, bits) - } - - /// [`encode_bits`](Self::encode_bits) into an owned `String`. - #[inline] - pub fn encode_bits_as_string(&self, bits: &str) -> String { - to_string(|buf| self.encode_bits(buf, bits)) - } -} diff --git a/src/dialect/mssql.rs b/src/dialect/mssql.rs deleted file mode 100644 index e11d5cd..0000000 --- a/src/dialect/mssql.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! [Microsoft SQL Server](https://www.microsoft.com/en-us/sql-server/) (T-SQL). -//! -//! • Strings: `N'…'` (NVARCHAR). Only `'` is special (`''`); NUL is stripped -//! because T-SQL string literals cannot hold U+0000. -//! • Identifiers: `[name]`; `]` doubled as `]]`. -//! • Bytes: `0xDEADBEEF` (no quotes). -//! • Bits: T-SQL `BIT` is boolean-like; multi-bit values become -//! `CAST( AS BIGINT)`. -//! • Bool: `1` / `0` (no `TRUE`/`FALSE` keyword). -//! • Placeholders: `@name` (native). -//! -//! Ref: - -use crate::internal::utils::replace_to; - -use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; - -pub(super) const NAME: &str = "MSSQL"; - -pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Int; - -pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::SquareBracket; - -pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::AtSign; - -pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { - super::escape_quote_and_strip_nul(writer, string) -} - -pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { - writer.write_str("N'")?; - writer.write_str(string)?; - writer.write_char('\'') -} - -pub(super) fn escape_identifier( - writer: &mut W, - ident: &str, -) -> std::fmt::Result { - replace_to(ident, ']', "]]", writer) -} - -pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { - writer.write_str("0x")?; - super::bytes_to_hex(writer, bytes) -} - -// Unsupported: Serializer::write_sql raises NotImplementedError via supports_bit. -pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { - write!(writer, "CAST({} AS BIGINT)", super::bits_as_i64(bits)) -} diff --git a/src/dialect/mysql.rs b/src/dialect/mysql.rs deleted file mode 100644 index f9a64ef..0000000 --- a/src/dialect/mysql.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! [MySQL](https://www.mysql.com/). -//! -//! • Strings: single-quoted; backslash-escape sequences. A raw tab is left -//! as-is (not a syntax hazard). `\x1A` (Ctrl+Z / Windows EOF) becomes `\Z`. -//! • Identifiers: backtick-quoted; backtick doubled. -//! • Bytes: `X'DEADBEEF'`. -//! • Bits: `b'10110101'` bit literal. -//! • Bool: `1` / `0` (`TINYINT`; no native BOOLEAN literal). -//! • Placeholders: `:name` (not native; MySQL uses `?`. Common clients rewrite). -//! -//! Ref: -//! - -use crate::internal::utils::replace_to; - -use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; - -pub(super) const NAME: &str = "MySql"; - -pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Int; - -pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::Backtick; - -pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; - -#[cfg_attr(feature = "nightly", optimize(speed))] -pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { - for ch in string.chars() { - match ch { - '\\' => writer.write_str("\\\\")?, - '\'' => writer.write_str("\\'")?, - '"' => writer.write_str("\\\"")?, - '\0' => writer.write_str("\\0")?, - '\n' => writer.write_str("\\n")?, - '\r' => writer.write_str("\\r")?, - '\x1a' => writer.write_str("\\Z")?, - c => writer.write_char(c)?, - } - } - Ok(()) -} - -pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { - super::wrap_single_quote(writer, string) -} - -pub(super) fn escape_identifier( - writer: &mut W, - ident: &str, -) -> std::fmt::Result { - replace_to(ident, '`', "``", writer) -} - -pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { - super::write_x_hex_literal(writer, bytes) -} - -pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { - super::debug_assert_bits(bits); - write!(writer, "b'{bits}'") -} diff --git a/src/dialect/oracle.rs b/src/dialect/oracle.rs deleted file mode 100644 index ea53a36..0000000 --- a/src/dialect/oracle.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! [Oracle Database](https://docs.oracle.com/en/database/oracle/oracle-database/). -//! -//! • Strings: single-quoted; only `'` is special (`''`). NUL is stripped -//! (`VARCHAR2` cannot hold it). -//! • Identifiers: double-quote quoted; `"` doubled. -//! • Bytes: `HEXTORAW('DEADBEEF')`. -//! • Bits: no native bit literal; integer value. -//! • Bool: `1` / `0`. SQL BOOLEAN exists only from 23c; this keeps pre-23c -//! compatibility. -//! • Placeholders: `:name` (native). -//! -//! Ref: - -use crate::internal::utils::replace_to; - -use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; - -pub(super) const NAME: &str = "Oracle"; - -pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Int; - -pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::DoubleQuote; - -pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; - -pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { - super::escape_quote_and_strip_nul(writer, string) -} - -pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { - super::wrap_single_quote(writer, string) -} - -pub(super) fn escape_identifier( - writer: &mut W, - ident: &str, -) -> std::fmt::Result { - replace_to(ident, '"', "\"\"", writer) -} - -pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { - writer.write_str("HEXTORAW('")?; - super::bytes_to_hex(writer, bytes)?; - writer.write_str("')") -} - -// Unsupported: Serializer::write_sql raises NotImplementedError via supports_bit. -pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { - write!(writer, "{}", super::bits_as_i64(bits)) -} diff --git a/src/dialect/postgresql.rs b/src/dialect/postgresql.rs deleted file mode 100644 index 988c078..0000000 --- a/src/dialect/postgresql.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! [PostgreSQL](https://www.postgresql.org/). -//! -//! • Strings: always `E'…'` escape-string syntax so `\n` / `\xNN` are -//! interpreted. Standard `'…'` with `''` doubling would not honor those -//! sequences (and `standard_conforming_strings` is on by default). -//! • Identifiers: double-quote quoted; `"` doubled. -//! • Bytes: `'\xDEADBEEF'::bytea` (hex format, 9.0+). -//! • Bits: `B'10110101'` bit-string literal. -//! • Bool: `TRUE` / `FALSE`. -//! • Placeholders: `:name` (not native; Postgres uses `$1`. Common clients -//! rewrite named params). -//! -//! Ref: -//! -//! - -use crate::internal::utils::replace_to; - -use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; - -pub(super) const NAME: &str = "PostgreSQL"; - -pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; - -pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::DoubleQuote; - -pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; - -#[cfg_attr(feature = "nightly", optimize(speed))] -pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { - for ch in string.chars() { - match ch { - '\\' => writer.write_str("\\\\")?, - '\'' => writer.write_str("\\'")?, - '\n' => writer.write_str("\\n")?, - '\r' => writer.write_str("\\r")?, - '\t' => writer.write_str("\\t")?, - '\0' => writer.write_str("\\x00")?, - c if (c as u32) < 32 || c as u32 == 127 => { - super::write_byte_hex_escape(writer, c as u8)?; - } - c => writer.write_char(c)?, - } - } - Ok(()) -} - -pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { - writer.write_str("E'")?; - writer.write_str(string)?; - writer.write_char('\'') -} - -pub(super) fn escape_identifier( - writer: &mut W, - ident: &str, -) -> std::fmt::Result { - replace_to(ident, '"', "\"\"", writer) -} - -pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { - writer.write_str("'\\x")?; - super::bytes_to_hex(writer, bytes)?; - writer.write_str("'::bytea") -} - -pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { - super::debug_assert_bits(bits); - write!(writer, "B'{bits}'") -} diff --git a/src/dialect/py.rs b/src/dialect/py.rs deleted file mode 100644 index 50520e9..0000000 --- a/src/dialect/py.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! Python class hierarchy around [`super::SqlDialect`]. -//! -//! The engine selects a dialect with a `Copy` enum. Python sees a -//! class hierarchy instead: -//! -//! * [`PySqlDialect`] — `rapidquery._core.dialect.SqlDialect`, the ABC -//! * one frozen subclass per variant (`PostgreSQL`, `DuckDB`, …) -//! -//! Extraction ([`FromPyObject`] for [`PySqlDialect`]) accepts **either** the -//! class (`PostgreSQL`) **or** an instance (`PostgreSQL()`). Matching is by -//! interned [`PyTypeObject`] pointer (see [`crate::typeref`]), so only the -//! 13 built-in classes convert — a Python subclass of `SqlDialect` does not. -//! -//! Instances compare and hash by native variant (`PostgreSQL() == PostgreSQL()`) -//! and `repr` as `PostgreSQL()`. - -use std::hash::{Hash, Hasher}; - -use crate::internal::utils::get_type_name; - -use super::SqlDialect; - -/// Abstract SQL dialect. -/// -/// Concrete dialects are frozen subclasses (`PostgreSQL`, `DuckDB`, ...). -/// Pass either the class or an instance wherever a dialect is required. -/// This base class cannot be instantiated. -/// -/// Instances of the same dialect compare equal and hash together. -#[rapidquery_macros::pyclass( - "SqlDialect", - subclass, - skip_from_py_object, - module = "rapidquery._core.dialect" -)] -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct PySqlDialect(pub SqlDialect); - -impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PySqlDialect { - type Error = pyo3::PyErr; - - fn extract( - obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, - ) -> Result { - // Pointer-identity against the 13 interned type objects. Cheaper than - // `isinstance` and rejects unknown subclasses by construction. - macro_rules! map { - ( - $ptr:expr, - $($name:ident => $native:ident,)+ - ) => { - $( - if $ptr == crate::typeref::$name { - return Ok(PySqlDialect(SqlDialect::$native)); - } - )+ - }; - } - - unsafe { - // SAFETY: `obj` is a borrowed `&Bound`, so `as_ptr()` is a - // live `PyObject*`. `PyType_CheckExact` is true when the caller - // passed the class itself (`PostgreSQL`); otherwise we take - // `Py_TYPE` of an instance (`PostgreSQL()`). Both pointers are - // interned `PyTypeObject*` values — we only compare addresses - // against `typeref`, we do not dereference them here. - // `get_type_name` requires a valid object pointer on the error - // path, which we have. - let type_ptr = if pyo3::ffi::PyType_CheckExact(obj.as_ptr()) == 1 { - obj.as_ptr() as *mut pyo3::ffi::PyTypeObject - } else { - pyo3::ffi::Py_TYPE(obj.as_ptr()) - }; - - map!( - type_ptr, - BIGQUERY_DIALECT_TYPE => BigQuery, - DATABRICKS_DIALECT_TYPE => Databricks, - DUCKDB_DIALECT_TYPE => DuckDb, - HIVE_DIALECT_TYPE => Hive, - MSSQL_DIALECT_TYPE => MsSql, - MYSQL_DIALECT_TYPE => MySql, - ORACLE_DIALECT_TYPE => Oracle, - POSTGRESQL_DIALECT_TYPE => PostgreSql, - REDSHIFT_DIALECT_TYPE => Redshift, - SNOWFLAKE_DIALECT_TYPE => Snowflake, - SPARK_DIALECT_TYPE => Spark, - SQLITE_DIALECT_TYPE => Sqlite, - TERADATA_DIALECT_TYPE => Teradata, - ); - - rapidquery_macros::pyerr!( - Err(PyTypeError), - "expected a dialect, got {}", - get_type_name(obj.py(), obj.as_ptr()) - ) - } - } -} - -#[pyo3::pymethods] -impl PySqlDialect { - /// Dialect identifier, identical to the Python class name (`"PostgreSQL"`). - #[getter] - #[inline] - fn name(&self) -> &'static str { - self.0.name() - } - - fn __repr__(&self) -> String { - let name = self.0.name(); - let mut buf = String::with_capacity(name.len() + 2); - buf.push_str(name); - buf.push_str("()"); - buf - } - - // Bound so only instances match; the class object is a type. - // PostgreSQL() == PostgreSQL is false; PostgreSQL() == PostgreSQL() is true. - fn __eq__(&self, other: &pyo3::Bound<'_, Self>) -> bool { - self.0 == other.get().0 - } - - fn __hash__(&self) -> u64 { - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - self.0.hash(&mut hasher); - hasher.finish() - } -} - -/// Generate one frozen Python class per native [`SqlDialect`] variant and -/// export them on the `dialect` submodule. -/// -/// Each class is an empty `extends=PySqlDialect` subclass. `__new__` stores -/// the matching native variant on the base. `immutable_type` (from the -/// `pyclass` macro) makes the concrete classes non-subclassable from Python. -macro_rules! impl_dialects { - ($($native:ident => $name:ident => $pyname:literal,)+) => { - $( - #[doc = concat!($pyname, " SQL dialect.")] - #[rapidquery_macros::pyclass( - $pyname, - extends = PySqlDialect, - module = "rapidquery._core.dialect" - )] - pub struct $name; - - #[pyo3::pymethods] - impl $name { - // Marker instance. Passing the class itself is equivalent - // at extract time; instances compare equal by variant. - #[new] - #[inline] - fn __new__() -> pyo3::PyClassInitializer { - pyo3::PyClassInitializer::from(PySqlDialect(SqlDialect::$native)) - .add_subclass(Self) - } - } - )+ - - #[pyo3::pymodule(name="dialect", gil_used = false)] - pub mod dialect_mod { - #[pymodule_export] - use super::{ - PySqlDialect, - $($name,)+ - }; - } - }; -} - -impl_dialects! { - BigQuery => PyBigQuery => "BigQuery", - Databricks => PyDatabricks => "Databricks", - DuckDb => PyDuckDb => "DuckDB", - Hive => PyHive => "Hive", - MsSql => PyMsSql => "MSSQL", - MySql => PyMySql => "MySql", - Oracle => PyOracle => "Oracle", - PostgreSql => PyPostgreSql => "PostgreSQL", - Redshift => PyRedshift => "Redshift", - Snowflake => PySnowflake => "Snowflake", - Spark => PySpark => "Spark", - Sqlite => PySqlite => "SQLite", - Teradata => PyTeradata => "Teradata", -} diff --git a/src/dialect/redshift.rs b/src/dialect/redshift.rs deleted file mode 100644 index 59f13ff..0000000 --- a/src/dialect/redshift.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! [Amazon Redshift](https://aws.amazon.com/redshift/). -//! -//! Postgres-derived, but **not** Postgres: default mode does not treat -//! backslash as an escape, and `E'…'` is not used. -//! -//! • Strings: single-quoted; only `'` is special (`''`). NUL is stripped. -//! • Identifiers: double-quote quoted; `"` doubled. -//! • Bytes: `TO_VARBYTE('', 'hex')`. -//! • Bits: no native bit literal; `CAST( AS BIGINT)`. -//! • Bool: `TRUE` / `FALSE`. -//! • Placeholders: `:name` (not native; rewritten client-side). -//! -//! Ref: -//! - -use crate::internal::utils::replace_to; - -use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; - -pub(super) const NAME: &str = "Redshift"; - -pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; - -pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::DoubleQuote; - -pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; - -pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { - super::escape_quote_and_strip_nul(writer, string) -} - -pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { - super::wrap_single_quote(writer, string) -} - -pub(super) fn escape_identifier( - writer: &mut W, - ident: &str, -) -> std::fmt::Result { - replace_to(ident, '"', "\"\"", writer) -} - -pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { - writer.write_str("TO_VARBYTE('")?; - super::bytes_to_hex(writer, bytes)?; - writer.write_str("', 'hex')") -} - -// Unsupported: Serializer::write_sql raises NotImplementedError via supports_bit. -pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { - write!(writer, "CAST({} AS BIGINT)", super::bits_as_i64(bits)) -} diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs deleted file mode 100644 index 6fdf968..0000000 --- a/src/dialect/snowflake.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! [Snowflake](https://www.snowflake.com/). -//! -//! • Strings: single-quoted; backslash-escape sequences (`\xNN` for other -//! control chars). -//! • Identifiers: double-quote quoted; `"` doubled. -//! • Bytes: `X'DEADBEEF'` hex literal (BINARY). -//! • Bits: no native bit literal; `TO_BINARY(LPAD(TO_HEX(), 16, '0'), 'HEX')`. -//! • Bool: `TRUE` / `FALSE`. -//! • Placeholders: `:name` (native-ish; Snowflake also binds by position). -//! -//! Ref: -//! - -use crate::internal::utils::replace_to; - -use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; - -pub(super) const NAME: &str = "Snowflake"; - -pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; - -pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::DoubleQuote; - -pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; - -#[cfg_attr(feature = "nightly", optimize(speed))] -pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { - for ch in string.chars() { - match ch { - '\\' => writer.write_str("\\\\")?, - '\'' => writer.write_str("\\'")?, - '\n' => writer.write_str("\\n")?, - '\r' => writer.write_str("\\r")?, - '\t' => writer.write_str("\\t")?, - '\0' => writer.write_str("\\0")?, - c if (c as u32) < 32 || c as u32 == 127 => { - super::write_byte_hex_escape(writer, c as u8)?; - } - c => writer.write_char(c)?, - } - } - Ok(()) -} - -pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { - super::wrap_single_quote(writer, string) -} - -pub(super) fn escape_identifier( - writer: &mut W, - ident: &str, -) -> std::fmt::Result { - replace_to(ident, '"', "\"\"", writer) -} - -pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { - super::write_x_hex_literal(writer, bytes) -} - -// Unsupported: Serializer::write_sql raises NotImplementedError via supports_bit. -pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { - write!( - writer, - "TO_BINARY(LPAD(TO_HEX({}), 16, '0'), 'HEX')", - super::bits_as_i64(bits) - ) -} diff --git a/src/dialect/spark.rs b/src/dialect/spark.rs deleted file mode 100644 index c2430d6..0000000 --- a/src/dialect/spark.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! [Apache Spark SQL](https://spark.apache.org/docs/latest/sql-ref.html). -//! -//! • Strings: single-quoted; Java/Scala backslash escapes (`\uNNNN` for -//! other control chars). -//! • Identifiers: backtick-quoted; backtick doubled. -//! • Bytes: `X'DEADBEEF'` hex literal. -//! • Bits: no native bit literal; `CAST( AS BIGINT)`. -//! • Bool: `TRUE` / `FALSE`. -//! • Placeholders: `:name` (not native; rewritten client-side). -//! -//! Ref: - -use crate::internal::utils::replace_to; - -use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; - -pub(super) const NAME: &str = "Spark"; - -pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; - -pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::Backtick; - -pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; - -#[cfg_attr(feature = "nightly", optimize(speed))] -pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { - for ch in string.chars() { - match ch { - '\\' => writer.write_str("\\\\")?, - '\'' => writer.write_str("\\'")?, - '"' => writer.write_str("\\\"")?, - '\n' => writer.write_str("\\n")?, - '\r' => writer.write_str("\\r")?, - '\t' => writer.write_str("\\t")?, - '\0' => writer.write_str("\\0")?, - c if (c as u32) < 32 || c as u32 == 127 => { - super::write_unicode4_escape(writer, c as u32)?; - } - c => writer.write_char(c)?, - } - } - Ok(()) -} - -pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { - super::wrap_single_quote(writer, string) -} - -pub(super) fn escape_identifier( - writer: &mut W, - ident: &str, -) -> std::fmt::Result { - replace_to(ident, '`', "``", writer) -} - -pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { - super::write_x_hex_literal(writer, bytes) -} - -// Unsupported: Serializer::write_sql raises NotImplementedError via supports_bit. -pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { - write!(writer, "CAST({} AS BIGINT)", super::bits_as_i64(bits)) -} diff --git a/src/dialect/sqlite.rs b/src/dialect/sqlite.rs deleted file mode 100644 index b246107..0000000 --- a/src/dialect/sqlite.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! [SQLite](https://www.sqlite.org/). -//! -//! • Strings: single-quoted; only `'` is special (`''`). Backslash is -//! **not** an escape. NUL is stripped because the C API treats U+0000 as -//! a terminator. -//! • Identifiers: double-quote quoted (SQLite also accepts backticks and -//! `[…]`; we standardize on `"`). -//! • Bytes: `X'DEADBEEF'` BLOB hex literal. -//! • Bits: no native bit literal; integer value. -//! • Bool: `1` / `0` (no native BOOLEAN type). -//! • Placeholders: `:name` (native; SQLite also accepts `?` / `$n` / `@name`). -//! -//! Ref: - -use crate::internal::utils::replace_to; - -use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; - -pub(super) const NAME: &str = "SQLite"; - -pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Int; - -pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::DoubleQuote; - -pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; - -pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { - super::escape_quote_and_strip_nul(writer, string) -} - -pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { - super::wrap_single_quote(writer, string) -} - -pub(super) fn escape_identifier( - writer: &mut W, - ident: &str, -) -> std::fmt::Result { - replace_to(ident, '"', "\"\"", writer) -} - -pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { - super::write_x_hex_literal(writer, bytes) -} - -// Unsupported: Serializer::write_sql raises NotImplementedError via supports_bit. -pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { - write!(writer, "{}", super::bits_as_i64(bits)) -} diff --git a/src/dialect/teradata.rs b/src/dialect/teradata.rs deleted file mode 100644 index 4894cbb..0000000 --- a/src/dialect/teradata.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! [Teradata](https://docs.teradata.com/). -//! -//! • Strings: single-quoted; only `'` is special (`''`). Escape sequences -//! depend on the session character set, so we never emit them. NUL is -//! stripped. -//! • Identifiers: single-quote quoted (`'name'`); `'` doubled. -//! • Bytes: `''XBV` (VARBYTE). -//! • Bits: `'10110101'B` bit literal. -//! • Bool: `TRUE` / `FALSE` (13.x+). -//! • Placeholders: `:name` (not native; rewritten client-side). -//! -//! Ref: - -use crate::internal::utils::replace_to; - -use super::{BoolFormat, IdentifierQuote, PlaceholderSyntax}; - -pub(super) const NAME: &str = "Teradata"; - -pub(super) const BOOL_FORMAT: BoolFormat = BoolFormat::Keyword; - -pub(super) const IDENTIFIER_QUOTE: IdentifierQuote = IdentifierQuote::SingleQuote; - -pub(super) const PLACEHOLDER_SYNTAX: PlaceholderSyntax = PlaceholderSyntax::Colon; - -pub(super) fn escape_string(writer: &mut W, string: &str) -> std::fmt::Result { - super::escape_quote_and_strip_nul(writer, string) -} - -pub(super) fn quote_string(writer: &mut W, string: &str) -> std::fmt::Result { - super::wrap_single_quote(writer, string) -} - -pub(super) fn escape_identifier( - writer: &mut W, - ident: &str, -) -> std::fmt::Result { - replace_to(ident, '\'', "''", writer) -} - -pub(super) fn encode_bytes(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { - writer.write_char('\'')?; - super::bytes_to_hex(writer, bytes)?; - writer.write_str("'XBV") -} - -pub(super) fn encode_bits(writer: &mut W, bits: &str) -> std::fmt::Result { - super::debug_assert_bits(bits); - write!(writer, "'{bits}'B") -} diff --git a/src/dialect/tests.rs b/src/dialect/tests.rs deleted file mode 100644 index 3dc60eb..0000000 --- a/src/dialect/tests.rs +++ /dev/null @@ -1,923 +0,0 @@ -//! Dialect quoting/escaping tests. -use super::*; - -/// Every dialect this crate supports, used to drive table-style tests. -const ALL_DIALECTS: &[SqlDialect] = &[ - SqlDialect::BigQuery, - SqlDialect::Databricks, - SqlDialect::DuckDb, - SqlDialect::Hive, - SqlDialect::MsSql, - SqlDialect::MySql, - SqlDialect::Oracle, - SqlDialect::PostgreSql, - SqlDialect::Redshift, - SqlDialect::Snowflake, - SqlDialect::Spark, - SqlDialect::Sqlite, - SqlDialect::Teradata, -]; - -/// Dialects whose `escape_string` interprets backslash sequences -/// (`\\`, `\n`, `\t`, ...). These MUST double a literal backslash. -const BACKSLASH_ESCAPING_DIALECTS: &[SqlDialect] = &[ - SqlDialect::BigQuery, - SqlDialect::Databricks, - SqlDialect::Hive, - SqlDialect::MySql, - SqlDialect::PostgreSql, - SqlDialect::Snowflake, - SqlDialect::Spark, -]; - -/// Dialects that only double single quotes and treat backslash as a -/// completely literal character (per their own doc comments). These MUST -/// pass a literal backslash straight through, unchanged. -const QUOTE_ONLY_DIALECTS: &[SqlDialect] = &[ - SqlDialect::DuckDb, - SqlDialect::MsSql, - SqlDialect::Oracle, - SqlDialect::Redshift, - SqlDialect::Sqlite, - SqlDialect::Teradata, -]; - -/// Dialects documented as stripping NUL bytes outright (rather than -/// escaping them) because their target string types can't hold NUL. -const NUL_STRIPPING_DIALECTS: &[SqlDialect] = &[ - SqlDialect::MsSql, - SqlDialect::Oracle, - SqlDialect::Redshift, - SqlDialect::Sqlite, - SqlDialect::Teradata, -]; - -fn sanity_check_coverage() { - // Every dialect must land in exactly one of the two escaping-style - // buckets above, and that bucket assignment must partition ALL_DIALECTS. - assert_eq!( - BACKSLASH_ESCAPING_DIALECTS.len() + QUOTE_ONLY_DIALECTS.len(), - ALL_DIALECTS.len(), - "every dialect must be classified as either backslash-escaping or quote-only" - ); -} - -// --------------------------------------------------------------------- -// 1. Single-quote handling: the one thing EVERY dialect must get right — -// though the *shape* of the escape differs by family: -// - QUOTE_ONLY dialects (ANSI-style) double it: ' -> '' -// - BACKSLASH_ESCAPING dialects (C/Java-style) prefix it: ' -> \' -// --------------------------------------------------------------------- - -#[test] -fn quote_only_dialects_double_a_lone_single_quote() { - sanity_check_coverage(); - for dialect in QUOTE_ONLY_DIALECTS { - let escaped = dialect.escape_string_as_string("'"); - assert_eq!( - escaped, - "''", - "{} did not double a lone single quote (got {escaped:?})", - dialect.name() - ); - } -} - -#[test] -fn backslash_escaping_dialects_prefix_a_lone_single_quote() { - for dialect in BACKSLASH_ESCAPING_DIALECTS { - let escaped = dialect.escape_string_as_string("'"); - assert_eq!( - escaped, - "\\'", - "{} did not backslash-escape a lone single quote (got {escaped:?})", - dialect.name() - ); - } -} - -#[test] -fn quote_only_dialects_double_multiple_single_quotes() { - for dialect in QUOTE_ONLY_DIALECTS.iter() { - let escaped = dialect.escape_string_as_string("''''"); - assert_eq!( - escaped, - "''''''''", - "{} mis-handled 4 consecutive quotes (got {escaped:?})", - dialect.name() - ); - } -} - -#[test] -fn backslash_escaping_dialects_prefix_multiple_single_quotes() { - for dialect in BACKSLASH_ESCAPING_DIALECTS { - let escaped = dialect.escape_string_as_string("''''"); - assert_eq!( - escaped, - "\\'\\'\\'\\'", - "{} mis-handled 4 consecutive quotes (got {escaped:?})", - dialect.name() - ); - } -} - -#[test] -fn every_dialect_neutralizes_the_apostrophe_in_a_classic_name() { - for dialect in ALL_DIALECTS { - let escaped = dialect.escape_string_as_string("O'Brien"); - let uses_backslash = BACKSLASH_ESCAPING_DIALECTS.contains(dialect); - let expected = if uses_backslash { - "O\\'Brien" - } else { - "O''Brien" - }; - assert_eq!( - escaped, - expected, - "{} did not neutralize the apostrophe in \"O'Brien\" as expected (got {escaped:?})", - dialect.name() - ); - } -} - -// --------------------------------------------------------------------- -// 2. Backslash handling must match each dialect's documented behavior. -// --------------------------------------------------------------------- - -#[test] -fn backslash_escaping_dialects_double_a_lone_backslash() { - for dialect in BACKSLASH_ESCAPING_DIALECTS { - let escaped = dialect.escape_string_as_string("\\"); - assert_eq!( - escaped, - "\\\\", - "{} should double a lone backslash (got {escaped:?})", - dialect.name() - ); - } -} - -#[test] -fn quote_only_dialects_pass_backslash_through_unchanged() { - for dialect in QUOTE_ONLY_DIALECTS.iter() { - let escaped = dialect.escape_string_as_string("\\"); - assert_eq!( - escaped, - "\\", - "{} must treat backslash as a literal, non-special character (got {escaped:?})", - dialect.name() - ); - } -} - -#[test] -fn quote_only_dialects_dont_touch_backslash_next_to_quote() { - // This is the classic footgun: a naive implementation might see `\'` - // and think it needs special handling. These dialects must NOT - // interpret backslash-quote as an escape sequence — the backslash - // stays literal and the quote still gets doubled independently. - for dialect in QUOTE_ONLY_DIALECTS.iter() { - let escaped = dialect.escape_string_as_string("\\'"); - assert_eq!( - escaped, - "\\''", - "{} mis-handled backslash immediately followed by a quote (got {escaped:?})", - dialect.name() - ); - } -} - -#[test] -fn backslash_escaping_dialects_handle_backslash_next_to_quote() { - for dialect in BACKSLASH_ESCAPING_DIALECTS { - let escaped = dialect.escape_string_as_string("\\'"); - assert_eq!( - escaped, - "\\\\\\'", - "{} mis-handled backslash immediately followed by a quote (got {escaped:?})", - dialect.name() - ); - } -} - -// --------------------------------------------------------------------- -// 3. NUL byte handling: strip vs. escape, per dialect contract. -// --------------------------------------------------------------------- - -/// Dialects documented as escaping NUL (rather than stripping or passing -/// it through raw) — i.e. the backslash-escaping family, all of which -/// have an explicit `'\0' => ...` arm. -const NUL_ESCAPING_DIALECTS: &[SqlDialect] = &[ - SqlDialect::BigQuery, - SqlDialect::Databricks, - SqlDialect::Hive, - SqlDialect::MySql, - SqlDialect::PostgreSql, - SqlDialect::Snowflake, - SqlDialect::Spark, -]; - -#[test] -fn nul_stripping_dialects_remove_nul_entirely() { - for dialect in NUL_STRIPPING_DIALECTS { - let escaped = dialect.escape_string_as_string("a\0b"); - assert_eq!( - escaped, - "ab", - "{} should strip NUL bytes outright (got {escaped:?})", - dialect.name() - ); - assert!( - !escaped.contains('\0'), - "{} left a raw NUL byte in its output", - dialect.name() - ); - } -} - -#[test] -fn nul_escaping_dialects_replace_nul_with_an_escape_sequence() { - for dialect in NUL_ESCAPING_DIALECTS { - let escaped = dialect.escape_string_as_string("a\0b"); - assert!( - !escaped.contains('\0'), - "{} left a raw NUL byte in escaped output (got {escaped:?})", - dialect.name() - ); - assert_ne!( - escaped, - "a\0b", - "{} should have replaced NUL with an escape sequence, not left it untouched", - dialect.name() - ); - } -} - -#[test] -fn duckdb_documents_nul_as_a_regular_passthrough_character() { - // DuckDB VARCHAR is UTF-8 and its docs don't call out NUL as - // unsupported, unlike MsSql/Oracle/Redshift/Sqlite/Teradata, which - // explicitly strip it. This test exists to make that asymmetry - // explicit and catch it if DuckDB's contract ever changes. - let escaped = SqlDialect::DuckDb.escape_string_as_string("a\0b"); - assert_eq!(escaped, "a\0b"); -} - -// --------------------------------------------------------------------- -// 4. Control characters (newline, tab, CR, and other ASCII control codes). -// --------------------------------------------------------------------- - -#[test] -fn backslash_escaping_dialects_never_emit_a_raw_newline_or_cr() { - // \n and \r get an explicit backslash-escape arm in every - // backslash-escaping dialect's implementation. - for dialect in BACKSLASH_ESCAPING_DIALECTS { - let escaped = dialect.escape_string_as_string("a\nb\rc"); - assert!( - !escaped.contains('\n') && !escaped.contains('\r'), - "{} left a raw \\n or \\r in escaped output (got {escaped:?})", - dialect.name() - ); - } -} - -#[test] -fn most_backslash_escaping_dialects_also_escape_tab() { - // MySQL's implementation intentionally has no explicit tab arm (a - // literal tab is not a syntax hazard inside a MySQL string literal, - // unlike a quote or backslash) so it's excluded here. Every other - // backslash-escaping dialect does translate \t -> \\t. - let tab_escaping: Vec = BACKSLASH_ESCAPING_DIALECTS - .iter() - .copied() - .filter(|d| *d != SqlDialect::MySql) - .collect(); - - for dialect in tab_escaping { - let escaped = dialect.escape_string_as_string("a\tb"); - assert!( - !escaped.contains('\t'), - "{} left a raw tab in escaped output (got {escaped:?})", - dialect.name() - ); - } -} - -#[test] -fn mysql_passes_a_raw_tab_through_unchanged() { - // Documents MySQL's actual (intentional) behavior explicitly, so a - // future change is a deliberate decision rather than a silent drift. - let escaped = SqlDialect::MySql.escape_string_as_string("a\tb"); - assert_eq!(escaped, "a\tb"); -} - -#[test] -fn quote_only_dialects_pass_newline_cr_tab_through_unchanged() { - // These dialects only double single quotes; per their own doc - // comments they don't treat backslash (or \n/\r/\t) as special. - // A raw newline etc. inside '...' is syntactically valid in these - // engines and is NOT an injection risk on its own — only the quote - // character matters for breaking out of the literal. - for dialect in QUOTE_ONLY_DIALECTS.iter() { - let escaped = dialect.escape_string_as_string("a\nb\rc\td"); - assert_eq!( - escaped, - "a\nb\rc\td", - "{} was expected to pass control chars through unchanged (got {escaped:?})", - dialect.name() - ); - } -} - -#[test] -fn every_dialect_neutralizes_low_ascii_control_bytes() { - // 0x01..=0x1F (excluding \n=0x0A, \r=0x0D, \t=0x09 already covered) - // and 0x7F (DEL) are the classic "invisible injection" characters. - // At minimum, no dialect should pass them through raw next to - // unescaped quotes in a way that changes the quote count. - for dialect in ALL_DIALECTS { - for byte in [0x01u8, 0x07, 0x1b, 0x1f, 0x7f] { - let ch = byte as char; - let input = format!("x{ch}y"); - let escaped = dialect.escape_string_as_string(&input); - // The escape function must not panic and must not introduce - // an unbalanced quote by accident. - assert_eq!( - escaped.matches('\'').count() % 2, - 0, - "{} produced an odd number of quotes for control byte 0x{byte:02X} (got \ - {escaped:?})", - dialect.name() - ); - } - } -} - -// --------------------------------------------------------------------- -// 5. End-to-end injection safety: escape_string + quote_string together -// must never let attacker input terminate the string literal early. -// --------------------------------------------------------------------- - -/// A grab-bag of classic SQL-injection-style payloads. -const HOSTILE_INPUTS: &[&str] = &[ - "", - "'", - "''", - "'; DROP TABLE users; --", - "' OR '1'='1", - "\\", - "\\'", - "\\\\", - "\\'; DROP TABLE users; --", - "a\0b", - "\n\r\t", - "'\0'", - "\\\0'", - "šŸ˜€'; DROP TABLE users;--šŸ˜€", - "back\\slash'quote\"doublequote", -]; - -/// For a single-quoted-literal dialect, walks the fully escaped+quoted -/// output character by character (respecting the dialect's own escape -/// convention) and confirms the literal's closing quote is genuinely the -/// last unescaped quote — i.e. nothing in the payload could prematurely -/// close the string. -/// -/// Operates entirely in `char` space (never byte-slicing a `&str`) so -/// multi-byte UTF-8 content (e.g. emoji) can't desync index arithmetic. -fn assert_no_premature_close(dialect: &SqlDialect, quoted: &str, uses_backslash_escapes: bool) { - let chars: Vec = quoted.chars().collect(); - - let first_quote_idx = chars.iter().position(|&c| c == '\'').unwrap_or_else(|| { - panic!( - "{}: quoted output has no opening quote: {quoted:?}", - dialect.name() - ) - }); - - let body = &chars[first_quote_idx + 1..]; - let mut i = 0; - let mut closed_at = None; - while i < body.len() { - let c = body[i]; - if uses_backslash_escapes && c == '\\' { - // Skip the escaped character (backslash always consumes the - // next char in these dialects' escape schemes). - i += 2; - continue; - } - if c == '\'' { - // A doubled quote (''), representing one literal quote, - // does NOT close the string — only a single trailing quote - // with no partner does. - if body.get(i + 1) == Some(&'\'') { - i += 2; - continue; - } - closed_at = Some(i); - break; - } - i += 1; - } - - let closed_at = closed_at.unwrap_or_else(|| { - panic!( - "{}: could not find a closing quote at all in {quoted:?}", - dialect.name() - ) - }); - - // The closing quote we found must be the LAST character of the - // string (i.e. nothing meaningful follows it) — otherwise the - // payload managed to close the literal early and inject trailing SQL. - let remainder: String = body[closed_at + 1..].iter().collect(); - assert!( - remainder.is_empty(), - "{}: literal closed early, leaving trailing unescaped content {remainder:?} in \ - {quoted:?}", - dialect.name() - ); -} - -#[test] -fn no_hostile_input_can_break_out_of_the_string_literal() { - for dialect in ALL_DIALECTS { - let uses_backslash = BACKSLASH_ESCAPING_DIALECTS.contains(dialect); - for payload in HOSTILE_INPUTS { - let escaped = dialect.escape_string_as_string(payload); - let quoted = dialect.quote_string_as_string(&escaped); - assert_no_premature_close(dialect, "ed, uses_backslash); - } - } -} - -#[test] -fn no_hostile_input_ever_panics_any_dialect_function() { - // A panic (e.g. from an unwrap on malformed UTF-8 boundary slicing) - // is itself a correctness bug worth catching explicitly. - for dialect in ALL_DIALECTS { - for payload in HOSTILE_INPUTS { - let escaped = dialect.escape_string_as_string(payload); - let _ = dialect.quote_string_as_string(&escaped); - } - } -} - -// --------------------------------------------------------------------- -// 6. Unicode correctness: multi-byte scalars must survive untouched. -// --------------------------------------------------------------------- - -#[test] -fn unicode_content_passes_through_unmangled() { - let samples = ["hĆ©llo", "ę—„ęœ¬čŖž", "emojišŸ˜€šŸŽ‰", "ƑoƱo", "Straße"]; - for dialect in ALL_DIALECTS { - for sample in samples { - let escaped = dialect.escape_string_as_string(sample); - assert_eq!( - escaped, - sample, - "{} corrupted unicode input {sample:?} (got {escaped:?})", - dialect.name() - ); - // Must still be valid UTF-8 after quoting (String guarantees - // this, but we assert to document the expectation). - let quoted = dialect.quote_string_as_string(&escaped); - assert!(quoted.contains(sample)); - } - } -} - -#[test] -fn unicode_mixed_with_quotes_still_escapes_correctly() { - for dialect in ALL_DIALECTS { - let escaped = dialect.escape_string_as_string("ę—„ęœ¬čŖž'quote'šŸ˜€"); - let uses_backslash = BACKSLASH_ESCAPING_DIALECTS.contains(dialect); - let quote_marker_count = if uses_backslash { - escaped.matches("\\'").count() - } else { - escaped.matches("''").count() - }; - assert_eq!( - quote_marker_count, - 2, - "{}: expected both quotes in the unicode string to be escaped (got {escaped:?})", - dialect.name() - ); - assert!(escaped.contains("ę—„ęœ¬čŖž")); - assert!(escaped.contains('šŸ˜€')); - } -} - -// --------------------------------------------------------------------- -// 7. Empty-input edge cases. -// --------------------------------------------------------------------- - -#[test] -fn empty_string_escapes_to_empty_string() { - for dialect in ALL_DIALECTS { - assert_eq!(dialect.escape_string_as_string(""), ""); - } -} - -#[test] -fn empty_string_quoted_is_just_the_delimiters() { - for dialect in ALL_DIALECTS { - let quoted = dialect.quote_string_as_string(""); - // Must contain exactly 2 single quotes and nothing else meaningful - // (allowing for a dialect-specific prefix like `E` or `N`). - assert_eq!( - quoted.matches('\'').count(), - 2, - "{}: empty quoted string should have exactly one open/close pair (got {quoted:?})", - dialect.name() - ); - } -} - -// --------------------------------------------------------------------- -// 8. encode_bytes: hex correctness, case, empty/boundary input. -// --------------------------------------------------------------------- - -#[test] -fn encode_bytes_empty_slice_does_not_panic_and_contains_no_hex_pairs() { - for dialect in ALL_DIALECTS { - let out = dialect.encode_bytes_as_string(&[]); - // No stray "00" or similar should appear from an off-by-one bug. - assert!( - !out.is_empty(), - "{}: encode_bytes(&[]) produced an empty string, expected at least wrapper syntax", - dialect.name() - ); - } -} - -#[test] -fn encode_bytes_uses_uppercase_hex_and_round_trips_all_byte_values() { - let bytes: Vec = (0u8..=255).collect(); - - // BigQuery renders each byte as its own \xNN escape (not a - // contiguous hex run via the shared bytes_to_hex() helper), so it's - // checked separately below. - let plain_hex_run_dialects: Vec = ALL_DIALECTS - .iter() - .copied() - .filter(|d| *d != SqlDialect::BigQuery) - .collect(); - - // The uppercase hex encoding of 0x00..=0xFF, in order — this is - // exactly what bytes_to_hex() must produce, byte for byte. Checking - // for this literal substring (rather than scanning all hex-looking - // characters in the output) avoids false positives from incidental - // lowercase letters in surrounding syntax like "bytea" or "'hex'". - let expected_hex_run: String = bytes.iter().map(|b| format!("{:02X}", b)).collect(); - - for dialect in plain_hex_run_dialects { - let out = dialect.encode_bytes_as_string(&bytes); - assert!( - out.contains(&expected_hex_run), - "{}: encode_bytes did not contain the expected uppercase hex run for all 256 byte \ - values (got {out:?})", - dialect.name() - ); - } - - // BigQuery: each byte must appear as its own \xNN, in order, with - // uppercase hex digits. - let expected_bigquery: String = bytes.iter().map(|b| format!("\\x{:02X}", b)).collect(); - let bigquery_out = SqlDialect::BigQuery.encode_bytes_as_string(&bytes); - assert!( - bigquery_out.contains(&expected_bigquery), - "BigQuery: encode_bytes did not contain the expected \\xNN-per-byte run (got \ - {bigquery_out:?})" - ); -} - -#[test] -fn encode_bytes_single_byte_zero_and_single_byte_max() { - for dialect in ALL_DIALECTS { - let zero = dialect.encode_bytes_as_string(&[0x00]); - let max = dialect.encode_bytes_as_string(&[0xFF]); - assert!(zero.contains("00"), "{}: {zero:?}", dialect.name()); - assert!(max.contains("FF"), "{}: {max:?}", dialect.name()); - assert_ne!(zero, max); - } -} - -// --------------------------------------------------------------------- -// 9. encode_bits: boundary lengths, all-zero, all-one, longer-than-i64. -// --------------------------------------------------------------------- - -#[test] -fn encode_bits_all_zero_and_all_one_single_bit() { - for dialect in ALL_DIALECTS { - let zero = dialect.encode_bits_as_string("0"); - let one = dialect.encode_bits_as_string("1"); - assert_ne!( - zero, - one, - "{}: encode_bits(\"0\") and encode_bits(\"1\") must differ", - dialect.name() - ); - } -} - -#[test] -fn encode_bits_does_not_panic_on_64_bit_boundary() { - // Exactly 64 ones is the classic off-by-one boundary for - // i64::from_str_radix-based implementations. - let sixty_four_ones = "1".repeat(64); - for dialect in ALL_DIALECTS { - let _ = dialect.encode_bits_as_string(&sixty_four_ones); - } -} - -#[test] -fn encode_bits_does_not_panic_on_overflow_longer_than_i64() { - // More than 64 bits overflows i64::from_str_radix; dialects that - // parse via i64 fall back to 0 on error rather than panicking. This - // test locks in "no panic" as the contract, regardless of the - // fallback value chosen. - let too_many_ones = "1".repeat(100); - for dialect in ALL_DIALECTS { - let _ = dialect.encode_bits_as_string(&too_many_ones); - } -} - -#[test] -fn encode_bits_empty_string_does_not_panic() { - for dialect in ALL_DIALECTS { - let _ = dialect.encode_bits_as_string(""); - } -} - -#[test] -fn encode_bits_known_value_is_consistent_across_dialects_that_parse_as_integer() { - // "10110101" == 181 decimal. Every dialect that renders this via an - // integer cast should have "181" appear somewhere in its output. - let bits = "10110101"; - let integer_based = [ - SqlDialect::BigQuery, // CAST(0b... AS INT64) -- uses 0b, not decimal - SqlDialect::Databricks, // CAST(...L AS BINARY) -- decimal - SqlDialect::Hive, // CAST(... AS BIGINT) -- decimal - SqlDialect::MsSql, // CAST(... AS BIGINT) -- decimal - SqlDialect::Oracle, // bare decimal - SqlDialect::Redshift, // CAST(... AS BIGINT) -- decimal - SqlDialect::Snowflake, // TO_HEX(...) -- decimal appears inside TO_HEX( - SqlDialect::Spark, // CAST(... AS BIGINT) -- decimal - SqlDialect::Sqlite, // bare decimal - ]; - for dialect in &integer_based { - let out = dialect.encode_bits_as_string(bits); - if *dialect == SqlDialect::BigQuery { - assert!( - out.contains("0b10110101"), - "{}: expected binary literal form (got {out:?})", - dialect.name() - ); - } else { - assert!( - out.contains("181"), - "{}: expected decimal value 181 to appear in output (got {out:?})", - dialect.name() - ); - } - } -} - -#[test] -fn encode_bits_literal_style_dialects_preserve_the_bit_string_verbatim() { - // Dialects with a native bit-string literal syntax should embed the - // exact input bits, not a numeric conversion. - let bits = "10110101"; - assert_eq!( - SqlDialect::PostgreSql.encode_bits_as_string(bits), - "B'10110101'" - ); - assert_eq!( - SqlDialect::DuckDb.encode_bits_as_string(bits), - "B'10110101'" - ); - assert_eq!(SqlDialect::MySql.encode_bits_as_string(bits), "b'10110101'"); - assert_eq!( - SqlDialect::Teradata.encode_bits_as_string(bits), - "'10110101'B" - ); -} - -// --------------------------------------------------------------------- -// 10. quote_string wrapping sanity: dialect-specific prefixes/suffixes. -// --------------------------------------------------------------------- - -#[test] -fn quote_string_wraps_with_expected_prefix_per_dialect() { - let escaped = "abc"; - assert!( - SqlDialect::PostgreSql - .quote_string_as_string(escaped) - .starts_with("E'") - ); - assert!( - SqlDialect::MsSql - .quote_string_as_string(escaped) - .starts_with("N'") - ); - // Plain single-quote wrapping dialects: no special prefix letter. - for dialect in &[ - SqlDialect::BigQuery, - SqlDialect::MySql, - SqlDialect::Sqlite, - SqlDialect::Snowflake, - SqlDialect::Oracle, - SqlDialect::Redshift, - SqlDialect::Teradata, - SqlDialect::Spark, - SqlDialect::Hive, - SqlDialect::Databricks, - SqlDialect::DuckDb, - ] { - let quoted = dialect.quote_string_as_string(escaped); - assert!( - quoted.starts_with('\''), - "{}: expected plain single-quote wrapping (got {quoted:?})", - dialect.name() - ); - } -} - -#[test] -fn write_string_literal_matches_quote_of_escape() { - let samples = ["abc", "O'Brien", "a\\b", "a\0b", ""]; - for dialect in [ - SqlDialect::PostgreSql, - SqlDialect::MsSql, - SqlDialect::Sqlite, - ] { - for sample in samples { - let mut escaped = String::new(); - dialect.escape_string(&mut escaped, sample).unwrap(); - let via_parts = dialect.quote_string_as_string(&escaped); - let via_helper = dialect.write_string_literal_as_string(sample); - assert_eq!( - via_helper, - via_parts, - "{}: write_string_literal({sample:?}) != quote(escape(.))", - dialect.name() - ); - } - } -} - -#[test] -fn quote_string_always_ends_with_a_single_quote() { - for dialect in ALL_DIALECTS { - let quoted = dialect.quote_string_as_string("abc"); - assert!( - quoted.ends_with('\''), - "{}: quoted string should end with a closing quote (got {quoted:?})", - dialect.name() - ); - } -} - -// --------------------------------------------------------------------- -// 11. Identifier quoting and hex helpers. -// --------------------------------------------------------------------- - -#[test] -fn sql_dialect_is_copy() { - fn assert_copy(_: T) {} - assert_copy(SqlDialect::Sqlite); - assert_copy(PlaceholderSyntax::Colon); -} - -#[test] -fn name_is_the_python_class_name() { - let expected = [ - (SqlDialect::BigQuery, "BigQuery"), - (SqlDialect::Databricks, "Databricks"), - (SqlDialect::DuckDb, "DuckDB"), - (SqlDialect::Hive, "Hive"), - (SqlDialect::MsSql, "MSSQL"), - (SqlDialect::MySql, "MySql"), - (SqlDialect::Oracle, "Oracle"), - (SqlDialect::PostgreSql, "PostgreSQL"), - (SqlDialect::Redshift, "Redshift"), - (SqlDialect::Snowflake, "Snowflake"), - (SqlDialect::Spark, "Spark"), - (SqlDialect::Sqlite, "SQLite"), - (SqlDialect::Teradata, "Teradata"), - ]; - assert_eq!(expected.len(), ALL_DIALECTS.len()); - for (dialect, name) in expected { - assert_eq!(dialect.name(), name); - } -} - -#[test] -fn quote_identifier_uses_expected_delimiters() { - assert_eq!(SqlDialect::MySql.quote_identifier_as_string("id"), "`id`"); - assert_eq!( - SqlDialect::PostgreSql.quote_identifier_as_string("id"), - "\"id\"" - ); - assert_eq!(SqlDialect::MsSql.quote_identifier_as_string("id"), "[id]"); - assert_eq!( - SqlDialect::Teradata.quote_identifier_as_string("id"), - "'id'" - ); - assert_eq!( - SqlDialect::BigQuery.quote_identifier_as_string("id"), - "`id`" - ); -} - -#[test] -fn quote_identifier_escapes_the_delimiter_in_the_body() { - assert_eq!( - SqlDialect::MySql.quote_identifier_as_string("a`b"), - "`a``b`" - ); - assert_eq!( - SqlDialect::PostgreSql.quote_identifier_as_string("a\"b"), - "\"a\"\"b\"" - ); - assert_eq!( - SqlDialect::MsSql.quote_identifier_as_string("a]b"), - "[a]]b]" - ); - assert_eq!( - SqlDialect::Teradata.quote_identifier_as_string("a'b"), - "'a''b'" - ); -} - -#[test] -fn escape_identifier_is_the_body_only() { - assert_eq!(SqlDialect::MySql.escape_identifier_as_string("a`b"), "a``b"); - assert_eq!( - SqlDialect::PostgreSql.escape_identifier_as_string("a\"b"), - "a\"\"b" - ); - assert_eq!(SqlDialect::MsSql.escape_identifier_as_string("a]b"), "a]]b"); -} - -#[test] -fn write_hex2_is_uppercase_two_digits() { - let mut s = String::new(); - write_hex2(&mut s, 0x00).unwrap(); - write_hex2(&mut s, 0xAB).unwrap(); - write_hex2(&mut s, 0xFF).unwrap(); - assert_eq!(s, "00ABFF"); -} - -#[test] -fn placeholder_sigil_matches_syntax() { - assert_eq!(PlaceholderSyntax::AtSign.sigil(), '@'); - assert_eq!(PlaceholderSyntax::Colon.sigil(), ':'); - assert_eq!(PlaceholderSyntax::DollarNamed.sigil(), '$'); -} - -#[test] -fn serializer_support_matrix() { - const BIT: &[SqlDialect] = &[ - SqlDialect::DuckDb, - SqlDialect::MySql, - SqlDialect::PostgreSql, - SqlDialect::Teradata, - ]; - const ARRAY: &[SqlDialect] = &[ - SqlDialect::BigQuery, - SqlDialect::DuckDb, - SqlDialect::PostgreSql, - ]; - const INET: &[SqlDialect] = &[SqlDialect::DuckDb, SqlDialect::PostgreSql]; - const MAC: &[SqlDialect] = &[SqlDialect::PostgreSql]; - - for dialect in ALL_DIALECTS { - assert_eq!( - dialect.supports_bit(), - BIT.contains(dialect), - "{} supports_bit", - dialect.name() - ); - assert_eq!( - dialect.supports_array(), - ARRAY.contains(dialect), - "{} supports_array", - dialect.name() - ); - assert_eq!( - dialect.supports_inet(), - INET.contains(dialect), - "{} supports_inet", - dialect.name() - ); - assert_eq!( - dialect.supports_mac_address(), - MAC.contains(dialect), - "{} supports_mac_address", - dialect.name() - ); - } -} diff --git a/src/expr/base.rs b/src/expr/base.rs index 98c0ee4..2a50cb2 100644 --- a/src/expr/base.rs +++ b/src/expr/base.rs @@ -86,18 +86,18 @@ impl PyExpr { fn prepare( slf: pyo3::PyRef<'_, Self>, py: pyo3::Python<'_>, - dialect: crate::dialect::py::PySqlDialect, + dialect: crate::dialect::PySqlDialect, ) -> pyo3::PyResult<(String, sql::PrepareParams)> { - sql::prepare(py, &slf.0, &dialect.0).map_err(Into::into) + sql::prepare(py, &slf.0, &dialect).map_err(Into::into) } /// Render this expression as raw SQL (values inlined as literals). fn to_sql( slf: pyo3::PyRef<'_, Self>, py: pyo3::Python<'_>, - dialect: crate::dialect::py::PySqlDialect, + dialect: crate::dialect::PySqlDialect, ) -> pyo3::PyResult { - sql::write_sql(py, &slf.0, &dialect.0).map_err(Into::into) + sql::write_sql(py, &slf.0, &dialect).map_err(Into::into) } fn __repr__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { diff --git a/src/expr/enum_/func.rs b/src/expr/enum_/func.rs index 8279f6d..12bb66e 100644 --- a/src/expr/enum_/func.rs +++ b/src/expr/enum_/func.rs @@ -114,11 +114,15 @@ impl Function { impl WriteSql for Function { fn write_sql( &self, - dialect: &crate::dialect::SqlDialect, + dialect: &crate::dialect::PySqlDialect, out: &mut W, ) -> PrepareResult<()> { self.name.write_sql(dialect, out)?; - let nt_inside = dialect.null_treatment_inside_parens(); + let nt_inside = matches!( + dialect.0.null_treatment_syntax, + crate::dialect::inner::NullTreatmentSyntax::InsideParens + ); + match &self.args { FunctionArguments::None => { // No parentheses to put the clause in; always postfix. diff --git a/src/expr/enum_/ident.rs b/src/expr/enum_/ident.rs index 18be7b6..d166855 100644 --- a/src/expr/enum_/ident.rs +++ b/src/expr/enum_/ident.rs @@ -1,6 +1,6 @@ //! SQL identifiers. -use crate::dialect::SqlDialect; +use crate::dialect::PySqlDialect; use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; /// One segment of a dotted identifier. @@ -60,10 +60,10 @@ impl> From for IdentPart { impl WriteSql for IdentPart { #[inline] - fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { match self { Self::Quote(name) => { - dialect.quote_identifier(out, name.as_str())?; + dialect.0.quote_identifier(out, name.as_str())?; } Self::Unquote(name) => { out.push_str(name.as_str()); @@ -248,7 +248,7 @@ impl std::str::FromStr for Ident { impl WriteSql for Ident { #[inline] - fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { write_separated(dialect, out, &self.0, ".") } } diff --git a/src/expr/enum_/mod.rs b/src/expr/enum_/mod.rs index 0ce3e10..a628acc 100644 --- a/src/expr/enum_/mod.rs +++ b/src/expr/enum_/mod.rs @@ -8,8 +8,10 @@ pub use ident::*; pub use operator::*; pub use value::*; -use crate::dialect::{LikeAnySyntax, SqlDialect}; -use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; +use crate::{ + dialect::{PySqlDialect, inner::LikeAnySyntax}, + sql::{PrepareResult, SqlWriter, WriteSql, write_separated}, +}; use std::fmt::Write; #[derive(Clone, Debug, PartialEq)] @@ -365,7 +367,7 @@ impl ExprEnum { #[inline] fn write_operand( &self, - dialect: &SqlDialect, + dialect: &PySqlDialect, out: &mut W, parent_prec: u8, paren_if_equal: bool, @@ -651,23 +653,26 @@ fn write_like_op(out: &mut W, case_sensitive: bool, negated: bool) } fn write_escape( - dialect: &SqlDialect, + dialect: &PySqlDialect, out: &mut W, escape: Option, ) -> PrepareResult<()> { let Some(c) = escape else { return Ok(()); }; + out.push_str(" ESCAPE "); let mut buf = [0u8; 4]; dialect + .0 .write_string_literal(out, c.encode_utf8(&mut buf)) .map_err(|_| crate::sql::PrepareError::Write)?; + Ok(()) } fn write_like( - dialect: &SqlDialect, + dialect: &PySqlDialect, out: &mut W, like: &Like, prec: u8, @@ -681,7 +686,7 @@ fn write_like( pattern.write_operand(dialect, out, prec, true)?; write_escape(dialect, out, like.escape) } - LikePattern::Any(patterns) => match dialect.like_any_syntax() { + LikePattern::Any(patterns) => match dialect.0.like_any_syntax { LikeAnySyntax::ArrayConstructor => { // `NOT LIKE ANY` in Postgres is a quantified NOT LIKE // (exists a non-match), not "matches none". @@ -732,7 +737,7 @@ fn write_like( impl WriteSql for ExprEnum { #[cfg_attr(feature = "nightly", optimize(speed))] - fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { match self { Self::Ident(ident) => ident.write_sql(dialect, out), Self::Value(value) => value.write_sql(dialect, out), @@ -983,7 +988,7 @@ impl WriteSql for ExprEnum { fn write_custom( sql: &str, args: &[ExprEnum], - dialect: &SqlDialect, + dialect: &PySqlDialect, out: &mut W, ) -> PrepareResult<()> { let bytes = sql.as_bytes(); diff --git a/src/expr/enum_/value.rs b/src/expr/enum_/value.rs index afe5eb8..c2a9fee 100644 --- a/src/expr/enum_/value.rs +++ b/src/expr/enum_/value.rs @@ -3,7 +3,7 @@ use pyo3::types::{PyAnyMethods, PyStringMethods}; use crate::data_type::{SerializerEnum, serializer_repr}; -use crate::dialect::SqlDialect; +use crate::dialect::PySqlDialect; use crate::internal::alias; use crate::sql::{PrepareResult, SqlWriter, WriteSql}; @@ -43,7 +43,7 @@ impl Value { } impl WriteSql for Value { - fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()> { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { out.push_value(dialect, &self.serializer, &self.object) } } diff --git a/src/expr/extractors.rs b/src/expr/extractors.rs index 5503a7f..1b25e12 100644 --- a/src/expr/extractors.rs +++ b/src/expr/extractors.rs @@ -117,10 +117,7 @@ fn extract_like_any<'py>( items.push(item.extract::()?.0); } if items.is_empty() { - return rapidquery_macros::pyerr!( - Err(PyValueError), - "LIKE ANY pattern list is empty" - ); + return rapidquery_macros::pyerr!(Err(PyValueError), "LIKE ANY pattern list is empty"); } Ok(LikePattern::Any(items)) } diff --git a/src/expr/mod.rs b/src/expr/mod.rs index cba542d..0105db3 100644 --- a/src/expr/mod.rs +++ b/src/expr/mod.rs @@ -23,7 +23,7 @@ pub mod expr_mod { #[pymodule_export] use super::nodes::{ PyBetweenExpr, PyBinaryExpr, PyCaseExpr, PyCastExpr, PyCollateExpr, PyCustomExpr, - PyInListExpr, PyIsDistinctFromExpr, PyIsExpr, PyIsNormalizedExpr, PyLikeExpr, - PyRLikeExpr, PySimilarToExpr, PyTrimExpr, PyTupleExpr, PyUnaryExpr, + PyInListExpr, PyIsDistinctFromExpr, PyIsExpr, PyIsNormalizedExpr, PyLikeExpr, PyRLikeExpr, + PySimilarToExpr, PyTrimExpr, PyTupleExpr, PyUnaryExpr, }; } diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index fd22235..4506d8f 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -570,10 +570,7 @@ impl PyCastExpr { return rapidquery_macros::pyerr!(Err(PyValueError), "unknown cast kind {:?}", kind); }; if format.is_none() && at_time_zone.is_some() { - return rapidquery_macros::pyerr!( - Err(PyValueError), - "AT TIME ZONE requires FORMAT" - ); + return rapidquery_macros::pyerr!(Err(PyValueError), "AT TIME ZONE requires FORMAT"); } let inner = super::enum_::Cast { diff --git a/src/lib.rs b/src/lib.rs index 7d2443d..79cb69f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,6 @@ pub mod sql; pub mod typeref; // Tasks until being stable (until this part) -// - Rewrite dialects and add a new dialect: CustomDialect // - Raise error when something is not supported by a dialect // - Test and coverage everything and edge cases // - Update docstrings, clean code, update Rust docs, add fmt/lint/coding-style rules diff --git a/src/sql.rs b/src/sql.rs index c87c6ec..80d7a18 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -9,7 +9,7 @@ use hashbrown::HashMap; use pyo3::types::PyAnyMethods; use crate::data_type::SerializerEnum; -use crate::dialect::SqlDialect; +use crate::dialect::PySqlDialect; use crate::internal::alias; /// Error produced while building SQL. @@ -92,7 +92,7 @@ pub trait SqlWriter: std::fmt::Write { /// unused. fn push_value( &mut self, - dialect: &SqlDialect, + dialect: &PySqlDialect, serializer: &SerializerEnum, object: &alias::PyObject, ) -> PrepareResult<()>; @@ -152,7 +152,7 @@ impl<'py> SqlWriter for BufferedSql<'py> { fn push_value( &mut self, - dialect: &SqlDialect, + dialect: &PySqlDialect, serializer: &SerializerEnum, object: &alias::PyObject, ) -> PrepareResult<()> { @@ -236,12 +236,12 @@ impl<'py> ParameterizedSql<'py> { impl ParameterizedSql<'_> { /// Register `param` and append its placeholder to the SQL in one step. #[inline] - pub fn push_param(&mut self, dialect: &SqlDialect, param: alias::PyObject) -> &mut Self { + pub fn push_param(&mut self, dialect: &PySqlDialect, param: alias::PyObject) -> &mut Self { // allocate param name let name = format!("param_{}", self.next_id); self.next_id += 1; - self.sql.push(dialect.placeholder_syntax().sigil()); + self.sql.push(dialect.0.placeholder_sigil); self.sql.push_str(&name); self.params.insert(name, param); self @@ -264,7 +264,7 @@ impl SqlWriter for ParameterizedSql<'_> { #[inline] fn push_value( &mut self, - dialect: &SqlDialect, + dialect: &PySqlDialect, _serializer: &SerializerEnum, object: &alias::PyObject, ) -> PrepareResult<()> { @@ -289,14 +289,22 @@ impl std::fmt::Write for ParameterizedSql<'_> { /// Render `self` into `out`. The writer chooses literal vs placeholder for values. pub trait WriteSql { - fn write_sql(&self, dialect: &SqlDialect, out: &mut W) -> PrepareResult<()>; + fn write_sql( + &self, + // NOTE FOR ME: + // Why `PySqlDialect` instead of Rust `Dialect`? + // because we accept custom serializers and custom serializer functions + // need to get dialect objects. + dialect: &PySqlDialect, + out: &mut W, + ) -> PrepareResult<()>; } /// Render `stmt` as raw SQL (values inlined as literals). pub fn write_sql<'py, T: WriteSql>( py: pyo3::Python<'py>, stmt: &T, - dialect: &SqlDialect, + dialect: &PySqlDialect, ) -> PrepareResult { let mut buf = BufferedSql::new(py); stmt.write_sql(dialect, &mut buf)?; @@ -307,7 +315,7 @@ pub fn write_sql<'py, T: WriteSql>( pub fn prepare<'py, T: WriteSql>( py: pyo3::Python<'py>, stmt: &T, - dialect: &SqlDialect, + dialect: &PySqlDialect, ) -> PrepareResult<(String, PrepareParams)> { let mut context = ParameterizedSql::new(py); stmt.write_sql(dialect, &mut context)?; @@ -320,7 +328,7 @@ pub fn prepare<'py, T: WriteSql>( /// Use `","` / `", "` for lists and `" AND "` / `" OR "` for predicates. #[cfg_attr(feature = "nightly", optimize(speed))] pub fn write_separated<'a, T, W>( - dialect: &SqlDialect, + dialect: &PySqlDialect, out: &mut W, items: impl IntoIterator, separator: &str, diff --git a/src/typeref.rs b/src/typeref.rs index 6ca54dc..de16106 100644 --- a/src/typeref.rs +++ b/src/typeref.rs @@ -1,19 +1,7 @@ //! Interned `PyTypeObject*` cache. // Dialect types -pub(crate) static mut BIGQUERY_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut DATABRICKS_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut DUCKDB_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut HIVE_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut MSSQL_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut MYSQL_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut ORACLE_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut POSTGRESQL_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut REDSHIFT_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut SNOWFLAKE_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut SPARK_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut SQLITE_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut TERADATA_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut SQL_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); // Data-type / serializer types pub(crate) static mut DATATYPE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); @@ -101,19 +89,7 @@ fn _initialize_typeref(py: pyo3::Python) { multiple_get_type_object_for!( py, // dialects - crate::dialect::py::PyBigQuery => BIGQUERY_DIALECT_TYPE, - crate::dialect::py::PyDatabricks => DATABRICKS_DIALECT_TYPE, - crate::dialect::py::PyDuckDb => DUCKDB_DIALECT_TYPE, - crate::dialect::py::PyHive => HIVE_DIALECT_TYPE, - crate::dialect::py::PyMsSql => MSSQL_DIALECT_TYPE, - crate::dialect::py::PyMySql => MYSQL_DIALECT_TYPE, - crate::dialect::py::PyOracle => ORACLE_DIALECT_TYPE, - crate::dialect::py::PyPostgreSql => POSTGRESQL_DIALECT_TYPE, - crate::dialect::py::PyRedshift => REDSHIFT_DIALECT_TYPE, - crate::dialect::py::PySnowflake => SNOWFLAKE_DIALECT_TYPE, - crate::dialect::py::PySpark => SPARK_DIALECT_TYPE, - crate::dialect::py::PySqlite => SQLITE_DIALECT_TYPE, - crate::dialect::py::PyTeradata => TERADATA_DIALECT_TYPE, + crate::dialect::PySqlDialect => SQL_DIALECT_TYPE, // data types / serializers crate::data_type::PyDataType => DATATYPE_TYPE, crate::data_type::PySerializer => SERIALIZER_TYPE, diff --git a/tests/conftest.py b/tests/conftest.py index b446193..73cb285 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,19 +2,20 @@ from rapidquery._core import _feature_only_stdlib_json from rapidquery.dialect import ( - MSSQL, BigQuery, Databricks, - DuckDB, + DuckDb, + GenericDialect, Hive, + MsSql, MySql, Oracle, - PostgreSQL, + PostgreSql, Redshift, Snowflake, Spark, SqlDialect, - SQLite, + Sqlite, Teradata, ) @@ -26,31 +27,56 @@ USING_ORJSON = False -ALL_DIALECTS = [ +# Builtin dialects only. GenericDialect needs every field as a keyword argument; +# tests that want one call `generic_dialect()`. +ALL_DIALECT_CLASSES: list[type[SqlDialect]] = [ BigQuery, Databricks, - DuckDB, + DuckDb, Hive, - MSSQL, + MsSql, MySql, Oracle, - PostgreSQL, + PostgreSql, Redshift, Snowflake, Spark, - SQLite, + Sqlite, Teradata, ] -ALL_DIALECT_NAMES = [cls.__name__ for cls in ALL_DIALECTS] +ALL_DIALECT_NAMES = [cls.__name__ for cls in ALL_DIALECT_CLASSES] +ALL_DIALECTS = tuple(cls() for cls in ALL_DIALECT_CLASSES) + +# Defaults match src/dialect/inner.rs `base()` used by the Rust unit tests. +_GENERIC_DEFAULTS = dict( + placeholder_sigil=":", + bit_format=None, + supports_array=False, + supports_inet=False, + supports_mac_address=False, + null_treatment_syntax="OutsideParens", + like_any_syntax="OrExpansion", + bool_format="Keyword", + identifier_format="DoubleQuote", + identifier_escape="Delimiters", + string_format="SingleQuote", + string_escape="Quote", + bytes_format="PrefixBigX", +) + + +def generic_dialect(**overrides) -> GenericDialect: + """Build a GenericDialect; pass only the fields the test cares about.""" + return GenericDialect(**{**_GENERIC_DEFAULTS, **overrides}) -@pytest.fixture(params=ALL_DIALECTS, ids=ALL_DIALECT_NAMES) +@pytest.fixture(params=ALL_DIALECT_CLASSES, ids=ALL_DIALECT_NAMES) def dialect_cls(request: pytest.FixtureRequest) -> type[SqlDialect]: - """Every concrete SqlDialect subclass.""" + """Every builtin SqlDialect subclass (not GenericDialect).""" return request.param -@pytest.fixture(params=ALL_DIALECTS, ids=ALL_DIALECT_NAMES) +@pytest.fixture(params=ALL_DIALECT_CLASSES, ids=ALL_DIALECT_NAMES) def dialect_instance(request: pytest.FixtureRequest) -> SqlDialect: - """An instance of every concrete SqlDialect subclass.""" + """An instance of every builtin SqlDialect subclass.""" return request.param() diff --git a/tests/test_data_type.py b/tests/test_data_type.py index 65d86ad..6e2374e 100644 --- a/tests/test_data_type.py +++ b/tests/test_data_type.py @@ -17,7 +17,7 @@ UnspecifiedSerializer, UuidSerializer, ) -from rapidquery.dialect import PostgreSQL +from rapidquery.dialect import PostgreSql class TestDataTypeNameNormalization: @@ -218,27 +218,23 @@ def test_boolean_family_infers_boolean_serializer(self, name): def test_boolean_serializer_correctly_rejects_non_bool_int(self): dt = DataType("boolean") with pytest.raises(TypeError): - dt.serialize(PostgreSQL, 1) + dt.serialize(PostgreSql(), 1) class TestDataTypeSerializeDelegation: def test_serialize_delegates_to_serializer(self): dt = DataType("integer") - assert dt.serialize(PostgreSQL, 42) == "42" + assert dt.serialize(PostgreSql(), 42) == "42" def test_serialize_with_explicit_custom_serializer(self): dt = DataType("weird_type", StringSerializer()) - result = dt.serialize(PostgreSQL, "hi") + result = dt.serialize(PostgreSql(), "hi") assert "hi" in result def test_serialize_propagates_type_errors_from_serializer(self): dt = DataType("integer") with pytest.raises(TypeError): - dt.serialize(PostgreSQL, "not an int") - - def test_serialize_accepts_dialect_class_or_instance(self): - dt = DataType("integer") - assert dt.serialize(PostgreSQL, 1) == dt.serialize(PostgreSQL(), 1) + dt.serialize(PostgreSql(), "not an int") class TestDataTypeEquality: diff --git a/tests/test_dialect.py b/tests/test_dialect.py new file mode 100644 index 0000000..9d2834f --- /dev/null +++ b/tests/test_dialect.py @@ -0,0 +1,468 @@ +"""Python API for SqlDialect, the 13 builtins, and GenericDialect. + +Escape and format algorithms are tested in Rust (`src/dialect/inner.rs`). +These tests cover property wiring, Python string conversion, and a smoke +check of each public method. +""" + +import pytest + +from rapidquery.dialect import ( + BigQuery, + Databricks, + DuckDb, + GenericDialect, + Hive, + MsSql, + MySql, + Oracle, + PostgreSql, + Redshift, + Snowflake, + Spark, + SqlDialect, + Sqlite, + Teradata, +) + +from .conftest import ALL_DIALECT_CLASSES, ALL_DIALECT_NAMES, generic_dialect + +# Expected DialectConfig for each builtin (src/dialect/builtin.rs). +BUILTIN_CONFIG = { + BigQuery: dict( + placeholder_sigil="@", + bit_format=None, + supports_array=True, + supports_inet=False, + supports_mac_address=False, + null_treatment_syntax="InsideParens", + like_any_syntax="OrExpansion", + bool_format="Keyword", + identifier_format="Backtick", + identifier_escape="SameAsString", + string_format="SingleQuote", + string_escape="BackslashHex", + bytes_format="PrefixBigBSlashX", + ), + Databricks: dict( + placeholder_sigil=":", + bit_format=None, + supports_array=False, + supports_inet=False, + supports_mac_address=False, + null_treatment_syntax="OutsideParens", + like_any_syntax="PatternList", + bool_format="Keyword", + identifier_format="Backtick", + identifier_escape="Delimiters", + string_format="SingleQuote", + string_escape="BackslashUnicodeQuote", + bytes_format="PrefixBigX", + ), + DuckDb: dict( + placeholder_sigil="$", + bit_format="PrefixBigB", + supports_array=True, + supports_inet=True, + supports_mac_address=False, + null_treatment_syntax="InsideParens", + like_any_syntax="OrExpansion", + bool_format="Keyword", + identifier_format="DoubleQuote", + identifier_escape="Delimiters", + string_format="SingleQuote", + string_escape="Quote", + bytes_format="PrefixBigX", + ), + Hive: dict( + placeholder_sigil=":", + bit_format=None, + supports_array=False, + supports_inet=False, + supports_mac_address=False, + null_treatment_syntax="OutsideParens", + like_any_syntax="OrExpansion", + bool_format="Keyword", + identifier_format="Backtick", + identifier_escape="Delimiters", + string_format="SingleQuote", + string_escape="BackslashUnicode", + bytes_format="Unhex", + ), + MsSql: dict( + placeholder_sigil="@", + bit_format=None, + supports_array=False, + supports_inet=False, + supports_mac_address=False, + null_treatment_syntax="OutsideParens", + like_any_syntax="OrExpansion", + bool_format="Int", + identifier_format="SquareBracket", + identifier_escape="Delimiters", + string_format="PrefixBigN", + string_escape="QuoteStripNul", + bytes_format="PrefixZeroX", + ), + MySql: dict( + placeholder_sigil=":", + bit_format="PrefixSmallB", + supports_array=False, + supports_inet=False, + supports_mac_address=False, + null_treatment_syntax="OutsideParens", + like_any_syntax="OrExpansion", + bool_format="Int", + identifier_format="Backtick", + identifier_escape="Delimiters", + string_format="SingleQuote", + string_escape="BackslashMysql", + bytes_format="PrefixBigX", + ), + Oracle: dict( + placeholder_sigil=":", + bit_format=None, + supports_array=False, + supports_inet=False, + supports_mac_address=False, + null_treatment_syntax="OutsideParens", + like_any_syntax="OrExpansion", + bool_format="Int", + identifier_format="DoubleQuote", + identifier_escape="Delimiters", + string_format="SingleQuote", + string_escape="QuoteStripNul", + bytes_format="HexToRaw", + ), + PostgreSql: dict( + placeholder_sigil=":", + bit_format="PrefixBigB", + supports_array=True, + supports_inet=True, + supports_mac_address=True, + null_treatment_syntax="OutsideParens", + like_any_syntax="ArrayConstructor", + bool_format="Keyword", + identifier_format="DoubleQuote", + identifier_escape="Delimiters", + string_format="PrefixBigE", + string_escape="BackslashHex", + bytes_format="SlashXBytea", + ), + Redshift: dict( + placeholder_sigil=":", + bit_format=None, + supports_array=False, + supports_inet=False, + supports_mac_address=False, + null_treatment_syntax="OutsideParens", + like_any_syntax="OrExpansion", + bool_format="Keyword", + identifier_format="DoubleQuote", + identifier_escape="Delimiters", + string_format="SingleQuote", + string_escape="QuoteStripNul", + bytes_format="ToVarbyte", + ), + Snowflake: dict( + placeholder_sigil=":", + bit_format=None, + supports_array=False, + supports_inet=False, + supports_mac_address=False, + null_treatment_syntax="OutsideParens", + like_any_syntax="PatternList", + bool_format="Keyword", + identifier_format="DoubleQuote", + identifier_escape="Delimiters", + string_format="SingleQuote", + string_escape="BackslashHexZero", + bytes_format="PrefixBigX", + ), + Spark: dict( + placeholder_sigil=":", + bit_format=None, + supports_array=False, + supports_inet=False, + supports_mac_address=False, + null_treatment_syntax="OutsideParens", + like_any_syntax="PatternList", + bool_format="Keyword", + identifier_format="Backtick", + identifier_escape="Delimiters", + string_format="SingleQuote", + string_escape="BackslashUnicodeQuote", + bytes_format="PrefixBigX", + ), + Sqlite: dict( + placeholder_sigil=":", + bit_format=None, + supports_array=False, + supports_inet=False, + supports_mac_address=False, + null_treatment_syntax="OutsideParens", + like_any_syntax="OrExpansion", + bool_format="Int", + identifier_format="DoubleQuote", + identifier_escape="Delimiters", + string_format="SingleQuote", + string_escape="QuoteStripNul", + bytes_format="PrefixBigX", + ), + Teradata: dict( + placeholder_sigil=":", + bit_format="PostfixBigB", + supports_array=False, + supports_inet=False, + supports_mac_address=False, + null_treatment_syntax="OutsideParens", + like_any_syntax="OrExpansion", + bool_format="Keyword", + identifier_format="SingleQuote", + identifier_escape="Delimiters", + string_format="SingleQuote", + string_escape="QuoteStripNul", + bytes_format="PostfixXbv", + ), +} + +_STRING_WRAP = { + "SingleQuote": "'{}'", + "PrefixBigE": "E'{}'", + "PrefixBigN": "N'{}'", +} +_BOOL_TRUE = {"Keyword": "TRUE", "Int": "1"} +_BOOL_FALSE = {"Keyword": "FALSE", "Int": "0"} +_BIT_WRAP = { + "PrefixBigB": "B'{}'", + "PrefixSmallB": "b'{}'", + "PostfixBigB": "'{}'B", +} +_BYTES_DEAD = { + "PrefixBigX": "X'DEAD'", + "PrefixZeroX": "0xDEAD", + "SlashXBytea": "'\\xDEAD'::bytea", + "PrefixBigBSlashX": "B'\\xDE\\xAD'", + "HexToRaw": "HEXTORAW('DEAD')", + "Unhex": "UNHEX('DEAD')", + "ToVarbyte": "TO_VARBYTE('DEAD', 'hex')", + "PostfixXbv": "'DEAD'XBV", +} + + +class TestSqlDialect: + def test_base_cannot_be_instantiated(self): + with pytest.raises(TypeError): + SqlDialect() + + def test_rejects_a_dialect_class_where_an_instance_is_required(self): + from rapidquery.data_type import IntegerSerializer + + with pytest.raises(TypeError, match="SqlDialect"): + IntegerSerializer().serialize(PostgreSql, 1) + + def test_all_expected_dialects_are_exported(self): + import rapidquery.dialect as dialect_module + + for name in ALL_DIALECT_NAMES: + assert hasattr(dialect_module, name) + assert hasattr(dialect_module, "GenericDialect") + assert hasattr(dialect_module, "SqlDialect") + + +class TestBuiltinDialects: + def test_is_subclass_of_sql_dialect(self, dialect_cls: type[SqlDialect]): + assert issubclass(dialect_cls, SqlDialect) + + def test_instance_is_sql_dialect(self, dialect_instance: SqlDialect): + assert isinstance(dialect_instance, SqlDialect) + + def test_constructs_with_no_arguments(self, dialect_cls: type[SqlDialect]): + dialect_cls() + + def test_rejects_unexpected_arguments(self, dialect_cls: type[SqlDialect]): + with pytest.raises(TypeError): + dialect_cls("unexpected") + + def test_cannot_be_subclassed(self, dialect_cls: type[SqlDialect]): + with pytest.raises(TypeError): + type(f"Sub{dialect_cls.__name__}", (dialect_cls,), {}) + + def test_frozen(self, dialect_instance: SqlDialect): + with pytest.raises(AttributeError): + dialect_instance.placeholder_sigil = "?" + + def test_builtin_config_covers_every_class(self): + assert set(BUILTIN_CONFIG) == set(ALL_DIALECT_CLASSES) + + def test_config_matches_builtin_table(self, dialect_cls: type[SqlDialect]): + dialect = dialect_cls() + for field, expected in BUILTIN_CONFIG[dialect_cls].items(): + assert getattr(dialect, field) == expected, field + + def test_same_dialect_instances_are_equal(self, dialect_cls: type[SqlDialect]): + assert dialect_cls() == dialect_cls() + assert hash(dialect_cls()) == hash(dialect_cls()) + assert len({dialect_cls(), dialect_cls()}) == 1 + + def test_instance_is_not_equal_to_its_class(self, dialect_cls: type[SqlDialect]): + assert dialect_cls() != dialect_cls + + def test_distinct_builtins(self): + assert len(set(ALL_DIALECT_CLASSES)) == len(ALL_DIALECT_CLASSES) + assert PostgreSql() != MySql() + assert len({PostgreSql(), MySql()}) == 2 + + def test_format_boolean_follows_bool_format(self, dialect_instance: SqlDialect): + kind = dialect_instance.bool_format + assert dialect_instance.format_boolean(True) == _BOOL_TRUE[kind] + assert dialect_instance.format_boolean(False) == _BOOL_FALSE[kind] + + def test_write_string_literal_wraps_plain_text(self, dialect_instance: SqlDialect): + wrapped = _STRING_WRAP[dialect_instance.string_format].format("hello") + assert dialect_instance.write_string_literal("hello") == wrapped + + def test_format_bytes_follows_bytes_format(self, dialect_instance: SqlDialect): + assert ( + dialect_instance.format_bytes(b"\xde\xad") + == _BYTES_DEAD[dialect_instance.bytes_format] + ) + + def test_format_bits(self, dialect_instance: SqlDialect): + if dialect_instance.bit_format is None: + with pytest.raises(ValueError, match="does not support literal bit"): + dialect_instance.format_bits("10") + return + expected = _BIT_WRAP[dialect_instance.bit_format].format("10") + assert dialect_instance.format_bits("10") == expected + + +class TestQuoteIdentifier: + def test_delimiter_escaping(self): + assert PostgreSql().quote_identifier('a"b') == '"a""b"' + assert MySql().quote_identifier("a`b") == "`a``b`" + assert MsSql().quote_identifier("a]b") == "[a]]b]" + assert Teradata().quote_identifier("a'b") == "'a''b'" + + def test_same_as_string_uses_string_escape(self): + # BigQuery: backticks + BackslashHex (same as string literals). + assert BigQuery().quote_identifier("foo'bar\\baz") == "`foo\\'bar\\\\baz`" + + def test_generic_delimiters_and_same_as_string(self): + doubled = generic_dialect( + identifier_format="DoubleQuote", identifier_escape="Delimiters" + ) + assert doubled.quote_identifier('a"b') == '"a""b"' + + same = generic_dialect( + identifier_format="Backtick", + identifier_escape="SameAsString", + string_escape="BackslashHex", + ) + assert same.quote_identifier("foo'bar") == "`foo\\'bar`" + + +class TestStringEscape: + # One representative input per StringEscape variant. Full matrices are in + # src/dialect/inner.rs. + @pytest.mark.parametrize( + "escape, raw, escaped", + [ + ("Quote", "O'Brien", "O''Brien"), + ("Quote", "a\0b", "a\0b"), + ("QuoteStripNul", "O'Brien", "O''Brien"), + ("QuoteStripNul", "a\0b", "ab"), + ("BackslashHex", "'", "\\'"), + ("BackslashHex", "\0", "\\x00"), + ("BackslashHex", "\x1a", "\\x1A"), + ("BackslashMysql", "\0", "\\0"), + ("BackslashMysql", '"', '\\"'), + ("BackslashMysql", "\x1a", "\\Z"), + ("BackslashMysql", "\t", "\t"), + ("BackslashUnicode", "\0", "\\0"), + ("BackslashUnicode", "\x01", "\\u0001"), + ("BackslashUnicode", '"', '"'), + ("BackslashHexZero", "\0", "\\0"), + ("BackslashHexZero", "\x1a", "\\x1A"), + ("BackslashUnicodeQuote", '"', '\\"'), + ("BackslashUnicodeQuote", "\x01", "\\u0001"), + ], + ) + def test_escape_string(self, escape: str, raw: str, escaped: str): + dialect = generic_dialect(string_escape=escape) + assert dialect.escape_string(raw) == escaped + assert dialect.write_string_literal(raw) == f"'{escaped}'" + + +class TestGenericDialect: + def test_is_sql_dialect(self): + dialect = generic_dialect() + assert isinstance(dialect, SqlDialect) + assert isinstance(dialect, GenericDialect) + assert issubclass(GenericDialect, SqlDialect) + + def test_cannot_be_subclassed(self): + with pytest.raises(TypeError): + type("SubGeneric", (GenericDialect,), {}) + + def test_all_arguments_are_required_and_keyword_only(self): + with pytest.raises(TypeError): + GenericDialect() + with pytest.raises(TypeError): + GenericDialect(":") # pyright: ignore[reportCallIssue] + + def test_unknown_format_name_is_value_error(self): + with pytest.raises(ValueError, match="string_escape"): + generic_dialect(string_escape="nope") + with pytest.raises(ValueError, match="bit_format"): + generic_dialect(bit_format="nope") + + def test_format_names_are_case_insensitive(self): + dialect = generic_dialect(bool_format="int", string_escape="BACKSLASHHEX") + assert dialect.bool_format == "Int" + assert dialect.string_escape == "BackslashHex" + + def test_placeholder_sigil_must_be_one_character(self): + with pytest.raises(ValueError, match="length 1"): + generic_dialect(placeholder_sigil="ab") + + def test_frozen(self): + dialect = generic_dialect() + with pytest.raises(AttributeError): + dialect.placeholder_sigil = "$" + + def test_equality_is_by_config(self): + a = generic_dialect(placeholder_sigil="$") + b = generic_dialect(placeholder_sigil="$") + c = generic_dialect(placeholder_sigil=":") + assert a == b + assert hash(a) == hash(b) + assert a != c + assert len({a, b, c}) == 2 + + def test_equal_to_builtin_with_the_same_config(self): + # Equality compares the inner Dialect, not the Python class. + assert generic_dialect(**BUILTIN_CONFIG[PostgreSql]) == PostgreSql() + assert generic_dialect() != PostgreSql() + + def test_repr_lists_every_field(self): + dialect = generic_dialect(placeholder_sigil="$", bit_format="PrefixBigB") + text = repr(dialect) + assert text.startswith("GenericDialect(") + assert "placeholder_sigil='$'" in text + assert 'bit_format="PrefixBigB"' in text + assert "supports_array=False" in text + assert 'string_escape="Quote"' in text + + def test_methods_honor_overrides(self): + dialect = generic_dialect( + bool_format="Int", + string_format="PrefixBigE", + string_escape="BackslashHex", + bit_format="PrefixSmallB", + bytes_format="PrefixZeroX", + ) + assert dialect.format_boolean(True) == "1" + assert dialect.write_string_literal("a'b\n") == "E'a\\'b\\n'" + assert dialect.format_bits("10") == "b'10'" + assert dialect.format_bytes(b"\x00") == "0x00" diff --git a/tests/test_expr.py b/tests/test_expr.py index dd21239..5eac6bb 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -3,11 +3,11 @@ from rapidquery.data_type import DataType from rapidquery.dialect import ( BigQuery, - DuckDB, - PostgreSQL, + DuckDb, + PostgreSql, Snowflake, SqlDialect, - SQLite, + Sqlite, ) from rapidquery.expr import ( Asterisk, @@ -65,14 +65,9 @@ def test_nodes_are_expr_subclasses(self): for node in nodes: assert isinstance(node, Expr) - def test_to_sql_and_prepare_accept_class_or_instance(self): + def test_render_succeeds_on_every_dialect(self, dialect_instance: SqlDialect): expr = BinaryExpr(Ident("id"), ">", 10) - assert expr.to_sql(PostgreSQL) == expr.to_sql(PostgreSQL()) - assert expr.prepare(PostgreSQL)[0] == expr.prepare(PostgreSQL())[0] - - def test_render_succeeds_on_every_dialect(self, dialect_cls: type[SqlDialect]): - expr = BinaryExpr(Ident("id"), ">", 10) - sql = expr.to_sql(dialect_cls) + sql = expr.to_sql(dialect_instance) assert "id" in sql assert "10" in sql @@ -116,16 +111,16 @@ def test_new(self): def test_to_sql(self): fn = Function("current_timestamp") - assert fn.to_sql(PostgreSQL) == '"current_timestamp"' + assert fn.to_sql(PostgreSql()) == '"current_timestamp"' fn = Function(Unquote("CURRENT_TIMESTAMP")) - assert fn.to_sql(PostgreSQL) == "CURRENT_TIMESTAMP" + assert fn.to_sql(PostgreSql()) == "CURRENT_TIMESTAMP" fn = Function(Unquote("NOW")).call() - assert fn.to_sql(PostgreSQL) == "NOW()" + assert fn.to_sql(PostgreSql()) == "NOW()" fn = Function(Unquote("NOW")) - assert fn.to_sql(PostgreSQL) == "NOW" + assert fn.to_sql(PostgreSql()) == "NOW" fn = ( Function("count") @@ -133,7 +128,7 @@ def test_to_sql(self): .filter(BinaryExpr(Ident("status"), "=", "completed")) ) assert ( - fn.to_sql(PostgreSQL) + fn.to_sql(PostgreSql()) == '"count"(*) FILTER (WHERE "status" = E\'completed\')' ) @@ -143,7 +138,7 @@ def test_to_sql(self): .filter(BinaryExpr(Ident("status"), "=", "completed")) ) assert ( - fn.to_sql(PostgreSQL) + fn.to_sql(PostgreSql()) == '"count"(ALL "id") FILTER (WHERE "status" = E\'completed\')' ) @@ -153,35 +148,35 @@ def test_to_sql(self): .filter(BinaryExpr(Ident("status"), "=", "completed")) ) assert ( - fn.to_sql(PostgreSQL) + fn.to_sql(PostgreSql()) == '"count"(DISTINCT "id") FILTER (WHERE "status" = E\'completed\')' ) fn = Function("now").call(distinct=True) - assert fn.to_sql(PostgreSQL) == '"now"()' + assert fn.to_sql(PostgreSql()) == '"now"()' fn = Function("custom").call(1, 2, Ident("id"), distinct=True) - assert fn.to_sql(PostgreSQL) == '"custom"(DISTINCT 1, 2, "id")' + assert fn.to_sql(PostgreSql()) == '"custom"(DISTINCT 1, 2, "id")' fn = Function("lag").call(Ident("x")).ignore_nulls() - assert fn.to_sql(PostgreSQL) == '"lag"("x") IGNORE NULLS' - assert fn.to_sql(BigQuery) == "`lag`(`x` IGNORE NULLS)" - assert fn.to_sql(DuckDB) == '"lag"("x" IGNORE NULLS)' + assert fn.to_sql(PostgreSql()) == '"lag"("x") IGNORE NULLS' + assert fn.to_sql(BigQuery()) == "`lag`(`x` IGNORE NULLS)" + assert fn.to_sql(DuckDb()) == '"lag"("x" IGNORE NULLS)' assert fn.get_null_treatment() == "IGNORE NULLS" fn = Function("lag").call(Ident("x")).respect_nulls() - assert fn.to_sql(PostgreSQL) == '"lag"("x") RESPECT NULLS' - assert fn.to_sql(BigQuery) == "`lag`(`x` RESPECT NULLS)" - assert fn.to_sql(DuckDB) == '"lag"("x" RESPECT NULLS)' + assert fn.to_sql(PostgreSql()) == '"lag"("x") RESPECT NULLS' + assert fn.to_sql(BigQuery()) == "`lag`(`x` RESPECT NULLS)" + assert fn.to_sql(DuckDb()) == '"lag"("x" RESPECT NULLS)' assert fn.get_null_treatment() == "RESPECT NULLS" fn = Function("lag").call().ignore_nulls() - assert fn.to_sql(PostgreSQL) == '"lag"() IGNORE NULLS' - assert fn.to_sql(BigQuery) == "`lag`(IGNORE NULLS)" + assert fn.to_sql(PostgreSql()) == '"lag"() IGNORE NULLS' + assert fn.to_sql(BigQuery()) == "`lag`(IGNORE NULLS)" fn = Function("lag").ignore_nulls() - assert fn.to_sql(PostgreSQL) == '"lag" IGNORE NULLS' - assert fn.to_sql(BigQuery) == "`lag` IGNORE NULLS" + assert fn.to_sql(PostgreSql()) == '"lag" IGNORE NULLS' + assert fn.to_sql(BigQuery()) == "`lag` IGNORE NULLS" fn = ( Function("count") @@ -190,11 +185,11 @@ def test_to_sql(self): .filter(BinaryExpr(Ident("status"), "=", "completed")) ) assert ( - fn.to_sql(PostgreSQL) + fn.to_sql(PostgreSql()) == '"count"("id") IGNORE NULLS FILTER (WHERE "status" = E\'completed\')' ) assert ( - fn.to_sql(BigQuery) + fn.to_sql(BigQuery()) == "`count`(`id` IGNORE NULLS) FILTER (WHERE `status` = 'completed')" ) @@ -213,11 +208,11 @@ def test_edge_cases(self): fn = Function("f").call("id") assert fn.get_args() == (Value("id"),) - assert fn.to_sql(PostgreSQL) == "\"f\"(E'id')" + assert fn.to_sql(PostgreSql()) == "\"f\"(E'id')" fn = Function(Ident("schema.fn")).call() assert fn.get_name() == Ident("schema.fn") - assert fn.to_sql(PostgreSQL) == '"schema.fn"()' + assert fn.to_sql(PostgreSql()) == '"schema.fn"()' first = Function("f").call(1) second = first.call(2, 3) @@ -226,18 +221,18 @@ def test_edge_cases(self): filtered = Function("f").filter(BinaryExpr(1, ">", 0)) replaced = filtered.filter(BinaryExpr(2, ">", 0)) - assert filtered.to_sql(PostgreSQL) == '"f" FILTER (WHERE 1 > 0)' - assert replaced.to_sql(PostgreSQL) == '"f" FILTER (WHERE 2 > 0)' + assert filtered.to_sql(PostgreSql()) == '"f" FILTER (WHERE 1 > 0)' + assert replaced.to_sql(PostgreSql()) == '"f" FILTER (WHERE 2 > 0)' ignored = Function("lag").call(Ident("x")).ignore_nulls() respected = ignored.respect_nulls() assert ignored.get_null_treatment() == "IGNORE NULLS" assert respected.get_null_treatment() == "RESPECT NULLS" - assert ignored.to_sql(PostgreSQL) == '"lag"("x") IGNORE NULLS' - assert respected.to_sql(PostgreSQL) == '"lag"("x") RESPECT NULLS' + assert ignored.to_sql(PostgreSql()) == '"lag"("x") IGNORE NULLS' + assert respected.to_sql(PostgreSql()) == '"lag"("x") RESPECT NULLS' fn = Function("count").call(1).filter(BinaryExpr(Ident("x"), ">", 2)) - sql, params = fn.prepare(PostgreSQL) + sql, params = fn.prepare(PostgreSql()) assert sql == '"count"(:param_0) FILTER (WHERE "x" > :param_1)' assert params == {"param_0": 1, "param_1": 2} @@ -265,15 +260,15 @@ def test_new(self): def test_to_sql(self): tpl = TupleExpr(1, 2, "A") - assert tpl.to_sql(PostgreSQL) == "(1, 2, E'A')" + assert tpl.to_sql(PostgreSql()) == "(1, 2, E'A')" - sql, params = tpl.prepare(PostgreSQL) + sql, params = tpl.prepare(PostgreSql()) assert sql == "(:param_0, :param_1, :param_2)" assert params == {"param_0": 1, "param_1": 2, "param_2": "A"} - assert TupleExpr().to_sql(PostgreSQL) == "()" - assert TupleExpr(1).to_sql(PostgreSQL) == "(1)" - assert TupleExpr((1, 2), Ident("A")).to_sql(PostgreSQL) == '((1, 2), "A")' + assert TupleExpr().to_sql(PostgreSql()) == "()" + assert TupleExpr(1).to_sql(PostgreSql()) == "(1)" + assert TupleExpr((1, 2), Ident("A")).to_sql(PostgreSql()) == '((1, 2), "A")' class TestUnaryExpr: @@ -314,36 +309,38 @@ def test_new(self): def test_to_sql(self): un = UnaryExpr("-", Function("now").call()) - assert un.to_sql(PostgreSQL) == '-"now"()' + assert un.to_sql(PostgreSql()) == '-"now"()' - assert UnaryExpr("NOT", 1).to_sql(PostgreSQL) == "NOT 1" - assert UnaryExpr("NOT", Ident("id")).to_sql(PostgreSQL) == 'NOT "id"' - assert UnaryExpr("+", 1).to_sql(PostgreSQL) == "+1" - assert UnaryExpr("~", Ident("id")).to_sql(PostgreSQL) == '~"id"' - assert UnaryExpr("-", UnaryExpr("-", 1)).to_sql(PostgreSQL) == "-(-1)" - assert UnaryExpr("NOT", UnaryExpr("NOT", 1)).to_sql(PostgreSQL) == "NOT (NOT 1)" - assert UnaryExpr("-", BinaryExpr(1, "+", 2)).to_sql(PostgreSQL) == "-(1 + 2)" + assert UnaryExpr("NOT", 1).to_sql(PostgreSql()) == "NOT 1" + assert UnaryExpr("NOT", Ident("id")).to_sql(PostgreSql()) == 'NOT "id"' + assert UnaryExpr("+", 1).to_sql(PostgreSql()) == "+1" + assert UnaryExpr("~", Ident("id")).to_sql(PostgreSql()) == '~"id"' + assert UnaryExpr("-", UnaryExpr("-", 1)).to_sql(PostgreSql()) == "-(-1)" + assert ( + UnaryExpr("NOT", UnaryExpr("NOT", 1)).to_sql(PostgreSql()) == "NOT (NOT 1)" + ) + assert UnaryExpr("-", BinaryExpr(1, "+", 2)).to_sql(PostgreSql()) == "-(1 + 2)" - sql, params = UnaryExpr("-", 1).prepare(PostgreSQL) + sql, params = UnaryExpr("-", 1).prepare(PostgreSql()) assert sql == "-:param_0" assert params == {"param_0": 1} - assert UnaryExpr("|/", 9).to_sql(PostgreSQL) == "|/9" - assert UnaryExpr("||/", 27).to_sql(PostgreSQL) == "||/27" - assert UnaryExpr("!!", 1).to_sql(PostgreSQL) == "!!1" - assert UnaryExpr("@", 1).to_sql(PostgreSQL) == "@1" - assert UnaryExpr("!", 9).to_sql(PostgreSQL) == "9!" - assert UnaryExpr("#", Ident("path")).to_sql(PostgreSQL) == '# "path"' - assert UnaryExpr("@-@", Ident("path")).to_sql(PostgreSQL) == '@-@ "path"' - assert UnaryExpr("@@", Ident("path")).to_sql(PostgreSQL) == '@@ "path"' - assert UnaryExpr("?-", Ident("path")).to_sql(PostgreSQL) == '?- "path"' - assert UnaryExpr("?|", Ident("path")).to_sql(PostgreSQL) == '?| "path"' - - assert UnaryExpr("!", UnaryExpr("-", 1)).to_sql(PostgreSQL) == "(-1)!" - assert UnaryExpr("-", UnaryExpr("!", 1)).to_sql(PostgreSQL) == "-(1!)" - assert UnaryExpr("!", UnaryExpr("!", 1)).to_sql(PostgreSQL) == "(1!)!" - - sql, params = UnaryExpr("|/", 9).prepare(PostgreSQL) + assert UnaryExpr("|/", 9).to_sql(PostgreSql()) == "|/9" + assert UnaryExpr("||/", 27).to_sql(PostgreSql()) == "||/27" + assert UnaryExpr("!!", 1).to_sql(PostgreSql()) == "!!1" + assert UnaryExpr("@", 1).to_sql(PostgreSql()) == "@1" + assert UnaryExpr("!", 9).to_sql(PostgreSql()) == "9!" + assert UnaryExpr("#", Ident("path")).to_sql(PostgreSql()) == '# "path"' + assert UnaryExpr("@-@", Ident("path")).to_sql(PostgreSql()) == '@-@ "path"' + assert UnaryExpr("@@", Ident("path")).to_sql(PostgreSql()) == '@@ "path"' + assert UnaryExpr("?-", Ident("path")).to_sql(PostgreSql()) == '?- "path"' + assert UnaryExpr("?|", Ident("path")).to_sql(PostgreSql()) == '?| "path"' + + assert UnaryExpr("!", UnaryExpr("-", 1)).to_sql(PostgreSql()) == "(-1)!" + assert UnaryExpr("-", UnaryExpr("!", 1)).to_sql(PostgreSql()) == "-(1!)" + assert UnaryExpr("!", UnaryExpr("!", 1)).to_sql(PostgreSql()) == "(1!)!" + + sql, params = UnaryExpr("|/", 9).prepare(PostgreSql()) assert sql == "|/:param_0" assert params == {"param_0": 9} @@ -372,32 +369,32 @@ def test_new(self): def test_to_sql(self): bin = BinaryExpr(Ident("id"), ">", 10) - assert bin.to_sql(PostgreSQL) == '"id" > 10' + assert bin.to_sql(PostgreSql()) == '"id" > 10' bin = BinaryExpr("str", "-custom-", 10) - assert bin.to_sql(PostgreSQL) == "E'str' -custom- 10" + assert bin.to_sql(PostgreSql()) == "E'str' -custom- 10" bin = BinaryExpr(Ident("id"), ">", BinaryExpr(456, "&", 10)) - assert bin.to_sql(PostgreSQL) == '"id" > 456 & 10' + assert bin.to_sql(PostgreSql()) == '"id" > 456 & 10' bin = BinaryExpr(Ident("id"), ">", BinaryExpr(456, "CusTom", 10)) - assert bin.to_sql(PostgreSQL) == '"id" > (456 CusTom 10)' + assert bin.to_sql(PostgreSql()) == '"id" > (456 CusTom 10)' bin = BinaryExpr(Ident("id"), ">", BinaryExpr(456, "<", 10)) - assert bin.to_sql(PostgreSQL) == '"id" > (456 < 10)' + assert bin.to_sql(PostgreSql()) == '"id" > (456 < 10)' - assert BinaryExpr(1, "!=", 2).to_sql(PostgreSQL) == "1 <> 2" - assert BinaryExpr(1, "AND", 2).to_sql(PostgreSQL) == "1 AND 2" + assert BinaryExpr(1, "!=", 2).to_sql(PostgreSql()) == "1 <> 2" + assert BinaryExpr(1, "AND", 2).to_sql(PostgreSql()) == "1 AND 2" assert ( - BinaryExpr(BinaryExpr(1, "+", 2), "AND", 3).to_sql(PostgreSQL) + BinaryExpr(BinaryExpr(1, "+", 2), "AND", 3).to_sql(PostgreSql()) == "1 + 2 AND 3" ) assert ( - BinaryExpr(1, "+", BinaryExpr(2, "AND", 3)).to_sql(PostgreSQL) + BinaryExpr(1, "+", BinaryExpr(2, "AND", 3)).to_sql(PostgreSql()) == "1 + (2 AND 3)" ) - sql, params = BinaryExpr(Ident("id"), ">", 10).prepare(PostgreSQL) + sql, params = BinaryExpr(Ident("id"), ">", 10).prepare(PostgreSql()) assert sql == '"id" > :param_0' assert params == {"param_0": 10} @@ -425,22 +422,22 @@ def test_new(self): def test_to_sql(self): is_ = IsExpr(Ident("id"), "NUll") - assert is_.to_sql(PostgreSQL) == '"id" IS NULL' + assert is_.to_sql(PostgreSql()) == '"id" IS NULL' is_ = IsExpr(Ident("id"), "FalSE") - assert is_.to_sql(PostgreSQL) == '"id" IS FALSE' + assert is_.to_sql(PostgreSql()) == '"id" IS FALSE' is_ = IsExpr(Ident("id"), "tRuE") - assert is_.to_sql(PostgreSQL) == '"id" IS TRUE' + assert is_.to_sql(PostgreSql()) == '"id" IS TRUE' is_ = IsExpr(Ident("id"), "unknown", negated=True) - assert is_.to_sql(PostgreSQL) == '"id" IS NOT UNKNOWN' + assert is_.to_sql(PostgreSql()) == '"id" IS NOT UNKNOWN' - sql, params = IsExpr(Ident("id"), "NULL").prepare(PostgreSQL) + sql, params = IsExpr(Ident("id"), "NULL").prepare(PostgreSql()) assert sql == '"id" IS NULL' assert params == {} - sql, params = IsExpr(1, "TRUE").prepare(PostgreSQL) + sql, params = IsExpr(1, "TRUE").prepare(PostgreSql()) assert sql == ":param_0 IS TRUE" assert params == {"param_0": 1} @@ -473,23 +470,25 @@ def test_new(self): IsNormalizedExpr(Ident("s"), "NFKC2") def test_to_sql(self): - assert IsNormalizedExpr(Ident("s")).to_sql(PostgreSQL) == ('"s" IS NORMALIZED') - assert IsNormalizedExpr(Ident("s"), negated=True).to_sql(PostgreSQL) == ( + assert IsNormalizedExpr(Ident("s")).to_sql(PostgreSql()) == ( + '"s" IS NORMALIZED' + ) + assert IsNormalizedExpr(Ident("s"), negated=True).to_sql(PostgreSql()) == ( '"s" IS NOT NORMALIZED' ) - assert IsNormalizedExpr(Ident("s"), "nFd").to_sql(PostgreSQL) == ( + assert IsNormalizedExpr(Ident("s"), "nFd").to_sql(PostgreSql()) == ( '"s" IS NFD NORMALIZED' ) assert ( - IsNormalizedExpr(Ident("s"), "NFKC", negated=True).to_sql(PostgreSQL) + IsNormalizedExpr(Ident("s"), "NFKC", negated=True).to_sql(PostgreSql()) == '"s" IS NOT NFKC NORMALIZED' ) - sql, params = IsNormalizedExpr(Ident("s"), "NFC").prepare(PostgreSQL) + sql, params = IsNormalizedExpr(Ident("s"), "NFC").prepare(PostgreSql()) assert sql == '"s" IS NFC NORMALIZED' assert params == {} - sql, params = IsNormalizedExpr("cafĆ©", "NFC").prepare(PostgreSQL) + sql, params = IsNormalizedExpr("cafĆ©", "NFC").prepare(PostgreSql()) assert sql == ":param_0 IS NFC NORMALIZED" assert params == {"param_0": "cafĆ©"} @@ -531,17 +530,17 @@ def test_new(self): def test_to_sql(self): idf = IsDistinctFromExpr(2, 3) - assert idf.to_sql(PostgreSQL) == "2 IS DISTINCT FROM 3" + assert idf.to_sql(PostgreSql()) == "2 IS DISTINCT FROM 3" idf = IsDistinctFromExpr(Ident("id"), 10, negated=True) - assert idf.to_sql(PostgreSQL) == '"id" IS NOT DISTINCT FROM 10' + assert idf.to_sql(PostgreSql()) == '"id" IS NOT DISTINCT FROM 10' - sql, params = idf.prepare(PostgreSQL) + sql, params = idf.prepare(PostgreSql()) assert sql == '"id" IS NOT DISTINCT FROM :param_0' assert params == {"param_0": 10} assert ( - IsDistinctFromExpr(Ident("a"), Ident("b")).to_sql(PostgreSQL) + IsDistinctFromExpr(Ident("a"), Ident("b")).to_sql(PostgreSql()) == '"a" IS DISTINCT FROM "b"' ) @@ -566,16 +565,16 @@ def test_new(self): def test_to_sql(self): lst = InListExpr(Ident("id"), [1, 2, 3]) - assert lst.to_sql(PostgreSQL) == '"id" IN (1, 2, 3)' + assert lst.to_sql(PostgreSql()) == '"id" IN (1, 2, 3)' - sql, params = lst.prepare(PostgreSQL) + sql, params = lst.prepare(PostgreSql()) assert sql == '"id" IN (:param_0, :param_1, :param_2)' assert params == {"param_0": 1, "param_1": 2, "param_2": 3} - assert InListExpr(Ident("id"), [], negated=True).to_sql(PostgreSQL) == ( + assert InListExpr(Ident("id"), [], negated=True).to_sql(PostgreSql()) == ( '"id" NOT IN ()' ) - assert InListExpr(Ident("id"), [1], negated=True).to_sql(PostgreSQL) == ( + assert InListExpr(Ident("id"), [1], negated=True).to_sql(PostgreSql()) == ( '"id" NOT IN (1)' ) @@ -600,15 +599,15 @@ def test_to_sql(self): bw = BetweenExpr( Ident("id"), BinaryExpr(Ident("old"), "|", 0), 10, negated=True ) - assert bw.to_sql(PostgreSQL) == '"id" NOT BETWEEN "old" | 0 AND 10' + assert bw.to_sql(PostgreSql()) == '"id" NOT BETWEEN "old" | 0 AND 10' bw = BetweenExpr(Ident("id"), 1, 10) - assert bw.to_sql(PostgreSQL) == '"id" BETWEEN 1 AND 10' + assert bw.to_sql(PostgreSql()) == '"id" BETWEEN 1 AND 10' bw = BetweenExpr(Ident("id"), BinaryExpr(1, "AND", 2), 10) - assert bw.to_sql(PostgreSQL) == '"id" BETWEEN (1 AND 2) AND 10' + assert bw.to_sql(PostgreSql()) == '"id" BETWEEN (1 AND 2) AND 10' - sql, params = BetweenExpr(Ident("id"), 1, 10).prepare(PostgreSQL) + sql, params = BetweenExpr(Ident("id"), 1, 10).prepare(PostgreSql()) assert sql == '"id" BETWEEN :param_0 AND :param_1' assert params == {"param_0": 1, "param_1": 10} @@ -652,59 +651,60 @@ def test_new(self): def test_to_sql(self): like = LikeExpr(Ident("name"), "%a%") - assert like.to_sql(PostgreSQL) == "\"name\" LIKE E'%a%'" + assert like.to_sql(PostgreSql()) == "\"name\" LIKE E'%a%'" assert ( - LikeExpr(Ident("name"), "%a%", case_sensitive=False).to_sql(PostgreSQL) + LikeExpr(Ident("name"), "%a%", case_sensitive=False).to_sql(PostgreSql()) == "\"name\" ILIKE E'%a%'" ) assert ( - LikeExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSQL) + LikeExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSql()) == "\"name\" NOT LIKE E'%a%'" ) assert ( - LikeExpr(Ident("name"), "%a%", escape="!").to_sql(PostgreSQL) + LikeExpr(Ident("name"), "%a%", escape="!").to_sql(PostgreSql()) == "\"name\" LIKE E'%a%' ESCAPE E'!'" ) - sql, params = LikeExpr(Ident("name"), "%a%").prepare(PostgreSQL) + sql, params = LikeExpr(Ident("name"), "%a%").prepare(PostgreSql()) assert sql == '"name" LIKE :param_0' assert params == {"param_0": "%a%"} any_like = LikeExpr(Ident("name"), ["%a%", "%b%"]) assert ( - any_like.to_sql(PostgreSQL) == "\"name\" LIKE ANY (ARRAY[E'%a%', E'%b%'])" + any_like.to_sql(PostgreSql()) == "\"name\" LIKE ANY (ARRAY[E'%a%', E'%b%'])" ) - assert any_like.to_sql(Snowflake) == "\"name\" LIKE ANY ('%a%', '%b%')" - assert any_like.to_sql(SQLite) == ( + assert any_like.to_sql(Snowflake()) == "\"name\" LIKE ANY ('%a%', '%b%')" + assert any_like.to_sql(Sqlite()) == ( "(\"name\" LIKE '%a%' OR \"name\" LIKE '%b%')" ) - sql, params = any_like.prepare(PostgreSQL) + sql, params = any_like.prepare(PostgreSql()) assert sql == '"name" LIKE ANY (ARRAY[:param_0, :param_1])' assert params == {"param_0": "%a%", "param_1": "%b%"} negated = LikeExpr(Ident("name"), ["%a%", "%b%"], negated=True) assert ( - negated.to_sql(PostgreSQL) + negated.to_sql(PostgreSql()) == "NOT (\"name\" LIKE ANY (ARRAY[E'%a%', E'%b%']))" ) - assert negated.to_sql(Snowflake) == "\"name\" NOT LIKE ANY ('%a%', '%b%')" - assert negated.to_sql(SQLite) == ( + assert negated.to_sql(Snowflake()) == "\"name\" NOT LIKE ANY ('%a%', '%b%')" + assert negated.to_sql(Sqlite()) == ( "(\"name\" NOT LIKE '%a%' AND \"name\" NOT LIKE '%b%')" ) ilike_any = LikeExpr(Ident("name"), ["%a%", "%b%"], case_sensitive=False) assert ( - ilike_any.to_sql(PostgreSQL) == "\"name\" ILIKE ANY (ARRAY[E'%a%', E'%b%'])" + ilike_any.to_sql(PostgreSql()) + == "\"name\" ILIKE ANY (ARRAY[E'%a%', E'%b%'])" ) escaped = LikeExpr(Ident("name"), ["%a%", "%b%"], escape="!") assert ( - escaped.to_sql(PostgreSQL) + escaped.to_sql(PostgreSql()) == "\"name\" LIKE ANY (ARRAY[E'%a%', E'%b%']) ESCAPE E'!'" ) - assert escaped.to_sql(SQLite) == ( + assert escaped.to_sql(Sqlite()) == ( "(\"name\" LIKE '%a%' ESCAPE '!' OR \"name\" LIKE '%b%' ESCAPE '!')" ) @@ -739,18 +739,18 @@ def test_new(self): def test_to_sql(self): sim = SimilarToExpr(Ident("name"), "%a%") - assert sim.to_sql(PostgreSQL) == "\"name\" SIMILAR TO E'%a%'" + assert sim.to_sql(PostgreSql()) == "\"name\" SIMILAR TO E'%a%'" assert ( - SimilarToExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSQL) + SimilarToExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSql()) == "\"name\" NOT SIMILAR TO E'%a%'" ) assert ( - SimilarToExpr(Ident("name"), "%a%", escape="!").to_sql(PostgreSQL) + SimilarToExpr(Ident("name"), "%a%", escape="!").to_sql(PostgreSql()) == "\"name\" SIMILAR TO E'%a%' ESCAPE E'!'" ) - sql, params = SimilarToExpr(Ident("name"), "%a%").prepare(PostgreSQL) + sql, params = SimilarToExpr(Ident("name"), "%a%").prepare(PostgreSql()) assert sql == '"name" SIMILAR TO :param_0' assert params == {"param_0": "%a%"} @@ -789,18 +789,18 @@ def test_new(self): def test_to_sql(self): rlike = RLikeExpr(Ident("name"), "%a%") - assert rlike.to_sql(PostgreSQL) == "\"name\" RLIKE E'%a%'" + assert rlike.to_sql(PostgreSql()) == "\"name\" RLIKE E'%a%'" assert ( - RLikeExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSQL) + RLikeExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSql()) == "\"name\" NOT RLIKE E'%a%'" ) assert ( - RLikeExpr(Ident("name"), "%a%", escape="!").to_sql(PostgreSQL) + RLikeExpr(Ident("name"), "%a%", escape="!").to_sql(PostgreSql()) == "\"name\" RLIKE E'%a%' ESCAPE E'!'" ) - sql, params = RLikeExpr(Ident("name"), "%a%").prepare(PostgreSQL) + sql, params = RLikeExpr(Ident("name"), "%a%").prepare(PostgreSql()) assert sql == '"name" RLIKE :param_0' assert params == {"param_0": "%a%"} @@ -840,9 +840,7 @@ def test_new(self): assert formatted.get_format() == Value("%Y-%m-%d") assert formatted.get_at_time_zone() is None - tz = CastExpr( - Ident("s"), "TIMESTAMP", format="%Y-%m-%d", at_time_zone="UTC" - ) + tz = CastExpr(Ident("s"), "TIMESTAMP", format="%Y-%m-%d", at_time_zone="UTC") assert tz.get_format() == Value("%Y-%m-%d") assert tz.get_at_time_zone() == Value("UTC") @@ -850,24 +848,24 @@ def test_new(self): CastExpr(Ident("s"), "TIMESTAMP", at_time_zone="UTC") def test_to_sql(self): - assert CastExpr(Ident("id"), "int").to_sql(PostgreSQL) == 'CAST("id" AS INT)' + assert CastExpr(Ident("id"), "int").to_sql(PostgreSql()) == 'CAST("id" AS INT)' assert ( - CastExpr(Ident("id"), "int", kind="SAFE_CAST").to_sql(PostgreSQL) + CastExpr(Ident("id"), "int", kind="SAFE_CAST").to_sql(PostgreSql()) == 'SAFE_CAST("id" AS INT)' ) assert ( - CastExpr(Ident("id"), "int", kind="TRY_CAST").to_sql(PostgreSQL) + CastExpr(Ident("id"), "int", kind="TRY_CAST").to_sql(PostgreSql()) == 'TRY_CAST("id" AS INT)' ) - assert CastExpr(Ident("id"), "int").to_sql(PostgreSQL) == 'CAST("id" AS INT)' - assert CastExpr(1, "varchar(255)").to_sql(PostgreSQL) == ( + assert CastExpr(Ident("id"), "int").to_sql(PostgreSql()) == 'CAST("id" AS INT)' + assert CastExpr(1, "varchar(255)").to_sql(PostgreSql()) == ( "CAST(1 AS varchar(255))" ) - assert CastExpr(Ident("id"), "INT ARRAY").to_sql(PostgreSQL) == ( + assert CastExpr(Ident("id"), "INT ARRAY").to_sql(PostgreSql()) == ( 'CAST("id" AS INT ARRAY)' ) assert ( - CastExpr(Ident("s"), "DATE", format="%Y-%m-%d").to_sql(PostgreSQL) + CastExpr(Ident("s"), "DATE", format="%Y-%m-%d").to_sql(PostgreSql()) == "CAST(\"s\" AS DATE FORMAT E'%Y-%m-%d')" ) assert ( @@ -877,15 +875,15 @@ def test_to_sql(self): kind="SAFE_CAST", format="%Y-%m-%d", at_time_zone="UTC", - ).to_sql(PostgreSQL) + ).to_sql(PostgreSql()) == "SAFE_CAST(\"s\" AS TIMESTAMP FORMAT E'%Y-%m-%d' AT TIME ZONE E'UTC')" ) - sql, params = CastExpr(1, "INT").prepare(PostgreSQL) + sql, params = CastExpr(1, "INT").prepare(PostgreSql()) assert sql == "CAST(:param_0 AS INT)" assert params == {"param_0": 1} - sql, params = CastExpr("s", "DATE", format="%Y-%m-%d").prepare(PostgreSQL) + sql, params = CastExpr("s", "DATE", format="%Y-%m-%d").prepare(PostgreSql()) assert sql == "CAST(:param_0 AS DATE FORMAT :param_1)" assert params == {"param_0": "s", "param_1": "%Y-%m-%d"} @@ -896,7 +894,7 @@ def test_to_sql(self): ) assert ( repr(CastExpr(Ident("s"), "DATE", format="%Y-%m-%d")) - == "CastExpr(Ident(\"s\"), \"DATE\", " + == 'CastExpr(Ident("s"), "DATE", ' "format=Value('%Y-%m-%d', StringSerializer()))" ) @@ -927,27 +925,25 @@ def test_new(self): TrimExpr(Ident("s"), characters="xy") # pyright: ignore def test_to_sql(self): - assert TrimExpr(Ident("s")).to_sql(PostgreSQL) == 'TRIM("s")' - assert TrimExpr(Ident("s"), where="LEADING").to_sql(PostgreSQL) == ( + assert TrimExpr(Ident("s")).to_sql(PostgreSql()) == 'TRIM("s")' + assert TrimExpr(Ident("s"), where="LEADING").to_sql(PostgreSql()) == ( 'TRIM(LEADING FROM "s")' ) - assert TrimExpr(Ident("s"), what="x").to_sql(PostgreSQL) == ( + assert TrimExpr(Ident("s"), what="x").to_sql(PostgreSql()) == ( "TRIM(E'x' FROM \"s\")" ) - assert TrimExpr(Ident("s"), where="BOTH", what="x").to_sql(PostgreSQL) == ( + assert TrimExpr(Ident("s"), where="BOTH", what="x").to_sql(PostgreSql()) == ( "TRIM(BOTH E'x' FROM \"s\")" ) - assert TrimExpr(Ident("s"), characters=["a", "b"]).to_sql(PostgreSQL) == ( + assert TrimExpr(Ident("s"), characters=["a", "b"]).to_sql(PostgreSql()) == ( "TRIM(\"s\", E'a', E'b')" ) - sql, params = TrimExpr(Ident("s"), what="x").prepare(PostgreSQL) + sql, params = TrimExpr(Ident("s"), what="x").prepare(PostgreSql()) assert sql == 'TRIM(:param_0 FROM "s")' assert params == {"param_0": "x"} - sql, params = TrimExpr(Ident("s"), characters=["a", "b"]).prepare( - PostgreSQL - ) + sql, params = TrimExpr(Ident("s"), characters=["a", "b"]).prepare(PostgreSql()) assert sql == 'TRIM("s", :param_0, :param_1)' assert params == {"param_0": "a", "param_1": "b"} @@ -981,21 +977,25 @@ def test_new(self): assert col.get_collation() == Ident("pg_catalog", "default") def test_to_sql(self): - assert CollateExpr(Ident("name"), "en_US").to_sql(PostgreSQL) == ( + assert CollateExpr(Ident("name"), "en_US").to_sql(PostgreSql()) == ( '"name" COLLATE "en_US"' ) - assert CollateExpr(Ident("name"), Unquote("C")).to_sql(PostgreSQL) == ( + assert CollateExpr(Ident("name"), Unquote("C")).to_sql(PostgreSql()) == ( '"name" COLLATE C' ) - assert CollateExpr(Ident("name"), Ident("pg_catalog", "default")).to_sql( - PostgreSQL - ) == '"name" COLLATE "pg_catalog"."default"' - assert CollateExpr(BinaryExpr(1, "+", 2), "en_US").to_sql(PostgreSQL) == ( + assert ( + CollateExpr(Ident("name"), Ident("pg_catalog", "default")).to_sql( + PostgreSql() + ) + == '"name" COLLATE "pg_catalog"."default"' + ) + assert CollateExpr(BinaryExpr(1, "+", 2), "en_US").to_sql(PostgreSql()) == ( '(1 + 2) COLLATE "en_US"' ) - assert BinaryExpr( - CollateExpr(Ident("a"), "c"), "+", 1 - ).to_sql(PostgreSQL) == '"a" COLLATE "c" + 1' + assert ( + BinaryExpr(CollateExpr(Ident("a"), "c"), "+", 1).to_sql(PostgreSql()) + == '"a" COLLATE "c" + 1' + ) assert ( repr(CollateExpr(Ident("name"), "en_US")) @@ -1038,11 +1038,11 @@ def test_new(self): CaseExpr().when(1, 2) # pyright: ignore def test_to_sql(self): - assert CaseExpr().to_sql(PostgreSQL) == "CASE END" + assert CaseExpr().to_sql(PostgreSql()) == "CASE END" searched = CaseExpr().when(BinaryExpr(Ident("id"), ">", 0), "yes").else_("no") assert ( - searched.to_sql(PostgreSQL) + searched.to_sql(PostgreSql()) == "CASE WHEN \"id\" > 0 THEN E'yes' ELSE E'no' END" ) @@ -1052,11 +1052,11 @@ def test_to_sql(self): .else_(0) ) assert ( - simple.to_sql(PostgreSQL) + simple.to_sql(PostgreSql()) == 'CASE "status" WHEN "status" = E\'open\' THEN 1 ELSE 0 END' ) - sql, params = searched.prepare(PostgreSQL) + sql, params = searched.prepare(PostgreSql()) assert sql == 'CASE WHEN "id" > :param_0 THEN :param_1 ELSE :param_2 END' assert params == {"param_0": 0, "param_1": "yes", "param_2": "no"} @@ -1072,21 +1072,21 @@ def test_new(self): assert custom.get_args() == (Ident("a"), Value(1)) def test_to_sql(self): - assert CustomExpr("1 = 1").to_sql(PostgreSQL) == "1 = 1" - assert CustomExpr("{} = {}", Ident("a"), 1).to_sql(PostgreSQL) == '"a" = 1' - assert CustomExpr("{{x}}").to_sql(PostgreSQL) == "{x}" - assert CustomExpr("a {{}} b").to_sql(PostgreSQL) == "a {} b" - assert CustomExpr("fn({})", Ident("id")).to_sql(PostgreSQL) == 'fn("id")' + assert CustomExpr("1 = 1").to_sql(PostgreSql()) == "1 = 1" + assert CustomExpr("{} = {}", Ident("a"), 1).to_sql(PostgreSql()) == '"a" = 1' + assert CustomExpr("{{x}}").to_sql(PostgreSql()) == "{x}" + assert CustomExpr("a {{}} b").to_sql(PostgreSql()) == "a {} b" + assert CustomExpr("fn({})", Ident("id")).to_sql(PostgreSql()) == 'fn("id")' with pytest.raises(ValueError, match="placeholders"): - CustomExpr("a {} {}", 1).to_sql(PostgreSQL) + CustomExpr("a {} {}", 1).to_sql(PostgreSql()) with pytest.raises(ValueError, match="placeholders"): - CustomExpr("a {}", 1, 2).to_sql(PostgreSQL) + CustomExpr("a {}", 1, 2).to_sql(PostgreSql()) - sql, params = CustomExpr("x = {}", 1).prepare(PostgreSQL) + sql, params = CustomExpr("x = {}", 1).prepare(PostgreSql()) assert sql == "x = :param_0" assert params == {"param_0": 1} with pytest.raises(ValueError, match="placeholders"): - CustomExpr("{} {}", 1).prepare(PostgreSQL) + CustomExpr("{} {}", 1).prepare(PostgreSql()) diff --git a/tests/test_ident.py b/tests/test_ident.py index 7ee67c8..3b50c2f 100644 --- a/tests/test_ident.py +++ b/tests/test_ident.py @@ -1,7 +1,7 @@ import pytest from rapidquery import Asterisk, Ident, Unquote -from rapidquery.dialect import MSSQL, MySql, PostgreSQL, SqlDialect, Teradata +from rapidquery.dialect import MySql, PostgreSql, SqlDialect class TestIdentConstruction: @@ -16,7 +16,7 @@ def test_single_str_is_one_quoted_part_even_with_dots(self): assert len(ident) == 1 assert ident[0] == "schema.table" - assert ident.to_sql(PostgreSQL) == '"schema.table"' + assert ident.to_sql(PostgreSql()) == '"schema.table"' def test_rejects_no_arguments(self): with pytest.raises(ValueError, match="empty"): @@ -34,7 +34,7 @@ def test_flattens_nested_ident(self): ident = Ident(Ident("t"), "id") assert ident.to_list() == ["t", "id"] - assert ident.to_sql(PostgreSQL) == '"t"."id"' + assert ident.to_sql(PostgreSql()) == '"t"."id"' def test_too_many_parts(self): with pytest.raises(ValueError, match="too many"): @@ -50,22 +50,22 @@ def test_str_star_is_quoted_column(self): ident = Ident("*") assert len(ident) == 1 assert ident[0] == "*" - assert ident.to_sql(PostgreSQL) == '"*"' - assert ident.to_sql(MySql) == "`*`" + assert ident.to_sql(PostgreSql()) == '"*"' + assert ident.to_sql(MySql()) == "`*`" def test_asterisk_class_is_wildcard(self): ident = Ident(Asterisk) assert ident[0] == Asterisk() - assert ident.to_sql(PostgreSQL) == "*" + assert ident.to_sql(PostgreSql()) == "*" def test_asterisk_instance_is_wildcard(self): ident = Ident(Asterisk()) - assert ident.to_sql(MySql) == "*" + assert ident.to_sql(MySql()) == "*" def test_qualified_wildcard(self): ident = Ident("t", Asterisk) - assert ident.to_sql(PostgreSQL) == '"t".*' - assert ident.to_sql(MySql) == "`t`.*" + assert ident.to_sql(PostgreSql()) == '"t".*' + assert ident.to_sql(MySql()) == "`t`.*" def test_asterisk_not_last_is_rejected(self): with pytest.raises(ValueError, match="asterisk"): @@ -76,11 +76,11 @@ class TestUnquote: def test_simple_name(self): part = Unquote("Users") assert part.name == "Users" - assert Ident(part).to_sql(PostgreSQL) == "Users" + assert Ident(part).to_sql(PostgreSql()) == "Users" def test_mixed_with_quoted_parts(self): ident = Ident("public", Unquote("Users"), "id") - assert ident.to_sql(PostgreSQL) == '"public".Users."id"' + assert ident.to_sql(PostgreSql()) == '"public".Users."id"' def test_equals_by_name(self): assert Unquote("id") == Unquote("id") @@ -136,26 +136,15 @@ def test_repr(self): class TestRender: - def test_class_and_instance_dialect_agree(self): - ident = Ident("id") - assert ident.to_sql(PostgreSQL) == ident.to_sql(PostgreSQL()) - assert ident.to_sql(MySql) == ident.to_sql(MySql()) - - def test_render_succeeds_on_every_dialect(self, dialect_cls: type[SqlDialect]): + def test_to_sql_uses_dialect_quote_identifier(self, dialect_instance: SqlDialect): ident = Ident("users", "id") - sql = ident.to_sql(dialect_cls) - assert "users" in sql - assert "id" in sql - assert "." in sql - - def test_delimiter_escaping(self): - assert Ident('a"b').to_sql(PostgreSQL) == '"a""b"' - assert Ident("a`b").to_sql(MySql) == "`a``b`" - assert Ident("a]b").to_sql(MSSQL) == "[a]]b]" - assert Ident("a'b").to_sql(Teradata) == "'a''b'" + sql = ident.to_sql(dialect_instance) + assert sql == ( + f"{dialect_instance.quote_identifier('users')}." + f"{dialect_instance.quote_identifier('id')}" + ) def test_quoted_injection_stays_inside_delimiters(self): payload = "a; DROP TABLE t--" - sql = Ident(payload).to_sql(PostgreSQL) - assert sql == f'"{payload}"' - assert sql.startswith('"') and sql.endswith('"') + sql = Ident(payload).to_sql(PostgreSql()) + assert sql == PostgreSql().quote_identifier(payload) diff --git a/tests/test_serializers.py b/tests/test_serializers.py index e734271..12d521c 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -1,3 +1,14 @@ +"""Serializer type checks, canonicalization, and dialect-method wiring. + +Quoting, byte wrappers, and boolean spelling come from the dialect +(`write_string_literal`, `format_bytes`, `format_boolean`, `format_bits`). +Escape-family tests live in test_dialect.py. + +Type errors and canonical forms are dialect-independent, so those cases +use a single dialect. Support flags (`bit_format`, `supports_array`, …) +are checked on every builtin. +""" + import datetime import enum import ipaddress @@ -21,86 +32,27 @@ IntegerSerializer, JSONSerializer, MacAddressSerializer, - Serializer, StringSerializer, TimeSerializer, UnspecifiedSerializer, UuidSerializer, ) from rapidquery.dialect import ( - MSSQL, BigQuery, - Databricks, - DuckDB, - Hive, + DuckDb, MySql, - Oracle, - PostgreSQL, - Redshift, - Snowflake, - Spark, + PostgreSql, SqlDialect, - SQLite, - Teradata, -) - -from .conftest import ALL_DIALECTS, USING_ORJSON - -# Quoted-literal wrap groups (payloads with no quotes/backslashes). -_PG = (PostgreSQL,) -_MSSQL = (MSSQL,) -_PLAIN_QUOTE = ( - BigQuery, - Databricks, - DuckDB, - Hive, - MySql, - Oracle, - Redshift, - Snowflake, - Spark, - SQLite, - Teradata, + Sqlite, ) -# String-escape families — same partition as src/dialect/tests.rs. -_BACKSLASH_ESCAPING = ( - BigQuery, - Databricks, - Hive, - MySql, - PostgreSQL, - Snowflake, - Spark, -) -_QUOTE_ONLY = ( - DuckDB, - MSSQL, - Oracle, - Redshift, - SQLite, - Teradata, -) -_NUL_STRIPPING = (MSSQL, Oracle, Redshift, SQLite, Teradata) -_NUL_X00 = (BigQuery, PostgreSQL) -_NUL_BACKSLASH_ZERO = (Databricks, Hive, MySql, Snowflake, Spark) -_DOUBLE_QUOTE_ESCAPING = (Databricks, MySql, Spark) -_HEX_CTRL = (BigQuery, PostgreSQL, Snowflake) -_UNICODE_CTRL = (Databricks, Hive, Spark) +from .conftest import ALL_DIALECT_NAMES, ALL_DIALECTS, USING_ORJSON -_BIT_OK = (DuckDB, MySql, PostgreSQL, Teradata) -_BIT_NO = tuple(d for d in ALL_DIALECTS if d not in _BIT_OK) -_ARRAY_OK = (BigQuery, DuckDB, PostgreSQL) -_ARRAY_NO = tuple(d for d in ALL_DIALECTS if d not in _ARRAY_OK) -_INET_NO = tuple(d for d in ALL_DIALECTS if d not in (DuckDB, PostgreSQL)) -_MAC_NO = tuple(d for d in ALL_DIALECTS if d is not PostgreSQL) +PG = PostgreSql() class ValidStrEnum(enum.StrEnum): HelloWorld = "Hello World" - Sticker = "šŸ˜€" - WithQuote = "'OK'" - Hex = "\x0d" class InvalidStrEnum(enum.Enum): @@ -112,857 +64,331 @@ class ExampleDataclass: x: int -def _custom_ok(dialect_name: str, obj: typing.Any) -> str: - return f"CUSTOM:{dialect_name}:{obj}" +def _custom_ok(dialect: SqlDialect, obj: typing.Any) -> str: + # serialize() passes the dialect as SqlDialect (the base), not the subclass. + assert isinstance(dialect, SqlDialect) + return f"CUSTOM:{dialect.placeholder_sigil}:{obj}" -def _custom_not_str(dialect_name: str, obj: typing.Any) -> int: +def _custom_not_str(dialect: SqlDialect, obj: typing.Any) -> int: return 12345 -def _quoted(dialect: type[SqlDialect] | SqlDialect, body: str) -> str: - name = dialect if isinstance(dialect, type) else type(dialect) - if name is PostgreSQL: - return f"E'{body}'" - if name is MSSQL: - return f"N'{body}'" - return f"'{body}'" - - -def _bytes_literal(dialect: type[SqlDialect] | SqlDialect, data: bytes) -> str: - hx = data.hex().upper() - name = dialect if isinstance(dialect, type) else type(dialect) - if name is BigQuery: - return "B'" + "".join(f"\\x{b:02X}" for b in data) + "'" - if name is Hive: - return f"UNHEX('{hx}')" - if name is MSSQL: - return f"0x{hx}" - if name is Oracle: - return f"HEXTORAW('{hx}')" - if name is PostgreSQL: - return f"'\\x{hx}'::bytea" - if name is Redshift: - return f"TO_VARBYTE('{hx}', 'hex')" - if name is Teradata: - return f"'{hx}'XBV" - return f"X'{hx}'" - - -@dataclass(frozen=True, slots=True) -class SerializeTestCase: - serializer: Serializer - dialects: typing.Iterable[type[SqlDialect] | SqlDialect] - input: typing.Any - output: str | type[Exception] - - -SERIALIZE_TEST_CASES: list[SerializeTestCase] = [ - # --- Integer --- - SerializeTestCase(IntegerSerializer(), ALL_DIALECTS, 1, "1"), - SerializeTestCase( - IntegerSerializer(), - ALL_DIALECTS, - 18_446_744_073_709_551_615, - "18446744073709551615", - ), - SerializeTestCase( - IntegerSerializer(), - ALL_DIALECTS, - -9_223_372_036_854_775_808, - "-9223372036854775808", - ), - SerializeTestCase(IntegerSerializer(), ALL_DIALECTS, 2.5, TypeError), - SerializeTestCase(IntegerSerializer(), ALL_DIALECTS, True, TypeError), - SerializeTestCase(IntegerSerializer(), ALL_DIALECTS, "invalid", TypeError), - # --- Float --- - SerializeTestCase(FloatSerializer(), ALL_DIALECTS, 1.1, "1.1"), - SerializeTestCase(FloatSerializer(), ALL_DIALECTS, 1, "1"), - SerializeTestCase(FloatSerializer(), ALL_DIALECTS, 1.0, "1.0"), - SerializeTestCase(FloatSerializer(), ALL_DIALECTS, Decimal("1.3435"), "1.3435"), - SerializeTestCase(FloatSerializer(), ALL_DIALECTS, "invalid", TypeError), - # --- Boolean --- - SerializeTestCase( - BooleanSerializer(), - ( - BigQuery, - Databricks, - DuckDB, - Hive, - PostgreSQL, - Redshift, - Snowflake, - Spark, - Teradata, - ), - True, - "TRUE", - ), - SerializeTestCase( - BooleanSerializer(), - (MSSQL, MySql, Oracle, SQLite), - True, - "1", - ), - SerializeTestCase( - BooleanSerializer(), - ( - BigQuery, - Databricks, - DuckDB, - Hive, - PostgreSQL, - Redshift, - Snowflake, - Spark, - Teradata, - ), - False, - "FALSE", - ), - SerializeTestCase( - BooleanSerializer(), - (MSSQL, MySql, Oracle, SQLite), - False, - "0", - ), - SerializeTestCase(BooleanSerializer(), ALL_DIALECTS, 1, TypeError), - SerializeTestCase(BooleanSerializer(), ALL_DIALECTS, object(), TypeError), - SerializeTestCase(BooleanSerializer(), ALL_DIALECTS, 0, TypeError), - # --- String | PostgreSQL --- - SerializeTestCase( - StringSerializer(), [PostgreSQL], "Hello World", "E'Hello World'" - ), - SerializeTestCase(StringSerializer(), [PostgreSQL], "šŸ˜€", "E'šŸ˜€'"), - SerializeTestCase(StringSerializer(), [PostgreSQL], "'OK'", "E'\\'OK\\''"), - SerializeTestCase(StringSerializer(), [PostgreSQL], "\x0d", "E'\\r'"), - # --- String | MSSQL --- - SerializeTestCase(StringSerializer(), [MSSQL], "Hello World", "N'Hello World'"), - SerializeTestCase(StringSerializer(), [MSSQL], "šŸ˜€", "N'šŸ˜€'"), - SerializeTestCase(StringSerializer(), [MSSQL], "'OK'", "N'''OK'''"), - SerializeTestCase(StringSerializer(), [MSSQL], "\x0d", "N'\r'"), - # --- String | DuckDB - Oracle - Redshift - SQLite - Teradata --- - SerializeTestCase( - StringSerializer(), - [DuckDB, Oracle, Redshift, SQLite, Teradata], - "Hello World", - "'Hello World'", - ), - SerializeTestCase( - StringSerializer(), [DuckDB, Oracle, Redshift, SQLite, Teradata], "šŸ˜€", "'šŸ˜€'" - ), - SerializeTestCase( - StringSerializer(), - [DuckDB, Oracle, Redshift, SQLite, Teradata], - "'OK'", - "'''OK'''", - ), - SerializeTestCase( - StringSerializer(), [DuckDB, Oracle, Redshift, SQLite, Teradata], "\x0d", "'\r'" - ), - # --- String --- - SerializeTestCase( - StringSerializer(), - ( - BigQuery, - Databricks, - Hive, - MySql, - Snowflake, - Spark, - ), - "Hello World", - "'Hello World'", - ), - SerializeTestCase( - StringSerializer(), - ( - BigQuery, - Databricks, - Hive, - MySql, - Snowflake, - Spark, - ), - "šŸ˜€", - "'šŸ˜€'", - ), - SerializeTestCase( - StringSerializer(), - ( - BigQuery, - Databricks, - Hive, - MySql, - Snowflake, - Spark, - ), - "'OK'", - "'\\'OK\\''", - ), - SerializeTestCase( - StringSerializer(), - ( - BigQuery, - Databricks, - Hive, - MySql, - Snowflake, - Spark, - ), - "\x0d", - "'\\r'", - ), - SerializeTestCase( - StringSerializer(), - ( - BigQuery, - Databricks, - Hive, - MySql, - Snowflake, - Spark, - ), - ValidStrEnum.HelloWorld, - "'Hello World'", - ), - SerializeTestCase( - StringSerializer(), - ( - BigQuery, - Databricks, - Hive, - MySql, - Snowflake, - Spark, - ), - ValidStrEnum.Sticker, - "'šŸ˜€'", - ), - SerializeTestCase( - StringSerializer(), - ( - BigQuery, - Databricks, - Hive, - MySql, - Snowflake, - Spark, - ), - ValidStrEnum.WithQuote, - "'\\'OK\\''", - ), - SerializeTestCase( - StringSerializer(), - ( - BigQuery, - Databricks, - Hive, - MySql, - Snowflake, - Spark, - ), - ValidStrEnum.Hex, - "'\\r'", - ), - SerializeTestCase( - StringSerializer(), - ALL_DIALECTS, - InvalidStrEnum.Variant, - TypeError, - ), - SerializeTestCase( - StringSerializer(), - ALL_DIALECTS, - 1, - TypeError, - ), - # --- Uuid | PostgreSQL --- - SerializeTestCase( - UuidSerializer(), - [PostgreSQL], - "103596b1-db75-4ae0-907a-a4a9bc4dab85", - "E'103596b1-db75-4ae0-907a-a4a9bc4dab85'", - ), - SerializeTestCase( - UuidSerializer(), - [PostgreSQL], - uuid.UUID("103596b1-db75-4ae0-907a-a4a9bc4dab85"), - "E'103596b1-db75-4ae0-907a-a4a9bc4dab85'", - ), - SerializeTestCase( - UuidSerializer(), - [PostgreSQL], - "103596-db75-4ae0-907a-a4a9bc4d85", - ValueError, - ), - SerializeTestCase( - UuidSerializer(), - [PostgreSQL], - 6428739, - TypeError, - ), - # --- Uuid | MSSQL --- - SerializeTestCase( - UuidSerializer(), - [MSSQL], - "103596b1-db75-4ae0-907a-a4a9bc4dab85", - "N'103596b1-db75-4ae0-907a-a4a9bc4dab85'", - ), - SerializeTestCase( - UuidSerializer(), - [MSSQL], - uuid.UUID("103596b1-db75-4ae0-907a-a4a9bc4dab85"), - "N'103596b1-db75-4ae0-907a-a4a9bc4dab85'", - ), - # --- Uuid --- - SerializeTestCase( - UuidSerializer(), - [i for i in ALL_DIALECTS if i not in (MSSQL, PostgreSQL)], - "103596b1-db75-4ae0-907a-a4a9bc4dab85", - "'103596b1-db75-4ae0-907a-a4a9bc4dab85'", - ), - SerializeTestCase( - UuidSerializer(), - [i for i in ALL_DIALECTS if i not in (MSSQL, PostgreSQL)], - uuid.UUID("103596b1-db75-4ae0-907a-a4a9bc4dab85"), - "'103596b1-db75-4ae0-907a-a4a9bc4dab85'", - ), - # --- Bytes | BigQuery --- - SerializeTestCase( - BytesSerializer(), [BigQuery], b"simple", "B'\\x73\\x69\\x6D\\x70\\x6C\\x65'" - ), - # --- Bytes | X'...' format --- - SerializeTestCase( - BytesSerializer(), - [Databricks, DuckDB, MySql, Snowflake, Spark, SQLite], - b"simple", - "X'73696D706C65'", - ), - # --- Bytes | Hive --- - SerializeTestCase(BytesSerializer(), [Hive], b"simple", "UNHEX('73696D706C65')"), - # --- Bytes | MSSQL --- - SerializeTestCase(BytesSerializer(), [MSSQL], b"simple", "0x73696D706C65"), - # --- Bytes | Oracle --- - SerializeTestCase( - BytesSerializer(), [Oracle], b"simple", "HEXTORAW('73696D706C65')" - ), - # --- Bytes | PostgreSQL --- - SerializeTestCase( - BytesSerializer(), [PostgreSQL], b"simple", "'\\x73696D706C65'::bytea" - ), - # --- Bytes | Redshift --- - SerializeTestCase( - BytesSerializer(), [Redshift], b"simple", "TO_VARBYTE('73696D706C65', 'hex')" - ), - # --- Bytes | Teradata --- - SerializeTestCase(BytesSerializer(), [Teradata], b"simple", "'73696D706C65'XBV"), - # --- Bit | MySql --- - SerializeTestCase(BitSerializer(), [MySql], "10", "b'10'"), - # --- Bit | Teradata --- - SerializeTestCase(BitSerializer(), [Teradata], "10", "'10'B"), - # --- Bit --- - SerializeTestCase(BitSerializer(), [DuckDB, PostgreSQL], "10", "B'10'"), - SerializeTestCase(BitSerializer(), _BIT_NO, "10", NotImplementedError), - SerializeTestCase(BitSerializer(), _BIT_NO, 123, NotImplementedError), - # --- JSON | PostgreSQL --- - SerializeTestCase(JSONSerializer(), [PostgreSQL], 10, "E'10'"), - SerializeTestCase(JSONSerializer(), [PostgreSQL], "10", """E'"10"'"""), - SerializeTestCase( - JSONSerializer(), [PostgreSQL], [1, "Ali", 1.0, {}], """E'[1,"Ali",1.0,{}]'""" - ), - SerializeTestCase( - JSONSerializer(), - [PostgreSQL], - datetime.datetime(2024, 10, 1, tzinfo=datetime.timezone.utc), - """E'"2024-10-01T00:00:00+00:00"'""" if USING_ORJSON else TypeError, - ), - SerializeTestCase( - JSONSerializer(), - [PostgreSQL], - uuid.UUID("103596b1-db75-4ae0-907a-a4a9bc4dab85"), - """E'"103596b1-db75-4ae0-907a-a4a9bc4dab85"'""" if USING_ORJSON else TypeError, - ), - SerializeTestCase( - JSONSerializer(), - [PostgreSQL], - ExampleDataclass(x=1), - """E'{"x":1}'""" if USING_ORJSON else TypeError, - ), - SerializeTestCase( - JSONSerializer(), - [PostgreSQL], - ValidStrEnum.HelloWorld, - """E'"Hello World"'""" if USING_ORJSON else TypeError, - ), - # --- Date --- - SerializeTestCase( - DateSerializer(), _PG, datetime.date(2024, 1, 15), "E'2024-01-15'" - ), - SerializeTestCase( - DateSerializer(), _MSSQL, datetime.date(2024, 1, 15), "N'2024-01-15'" - ), - SerializeTestCase( - DateSerializer(), _PLAIN_QUOTE, datetime.date(2024, 1, 15), "'2024-01-15'" - ), - SerializeTestCase( - DateSerializer(), - ALL_DIALECTS, - datetime.datetime(2024, 1, 15, 10, 0), - TypeError, - ), - SerializeTestCase(DateSerializer(), ALL_DIALECTS, "2024-01-15", TypeError), - # --- Time --- - SerializeTestCase(TimeSerializer(), _PG, datetime.time(13, 45, 30), "E'13:45:30'"), - SerializeTestCase( - TimeSerializer(), _MSSQL, datetime.time(13, 45, 30), "N'13:45:30'" - ), - SerializeTestCase( - TimeSerializer(), _PLAIN_QUOTE, datetime.time(13, 45, 30), "'13:45:30'" - ), - SerializeTestCase( - TimeSerializer(), - _PG, - datetime.time(1, 2, 3, 456789), - "E'01:02:03.456789'", - ), - SerializeTestCase( - TimeSerializer(), - _MSSQL, - datetime.time(1, 2, 3, 456789), - "N'01:02:03.456789'", - ), - SerializeTestCase( - TimeSerializer(), - _PLAIN_QUOTE, - datetime.time(1, 2, 3, 456789), - "'01:02:03.456789'", - ), - SerializeTestCase( - TimeSerializer(), ALL_DIALECTS, datetime.date(2024, 1, 1), TypeError - ), - # --- Datetime --- - SerializeTestCase( - DatetimeSerializer(), - _PG, - datetime.datetime(2024, 1, 15, 10, 30, 0), - "E'2024-01-15 10:30:00'", - ), - SerializeTestCase( - DatetimeSerializer(), - _MSSQL, - datetime.datetime(2024, 1, 15, 10, 30, 0), - "N'2024-01-15 10:30:00'", - ), - SerializeTestCase( - DatetimeSerializer(), - _PLAIN_QUOTE, - datetime.datetime(2024, 1, 15, 10, 30, 0), - "'2024-01-15 10:30:00'", - ), - SerializeTestCase( - DatetimeSerializer(), - _PG, - datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc), - "E'2024-01-15 10:30:00+00:00'", - ), - SerializeTestCase( - DatetimeSerializer(), - _MSSQL, - datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc), - "N'2024-01-15 10:30:00+00:00'", - ), - SerializeTestCase( - DatetimeSerializer(), - _PLAIN_QUOTE, - datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc), - "'2024-01-15 10:30:00+00:00'", - ), - SerializeTestCase( - DatetimeSerializer(), - _PG, - datetime.datetime( - 2024, - 1, - 15, - 10, - 30, - 0, - tzinfo=datetime.timezone(datetime.timedelta(hours=-5)), - ), - "E'2024-01-15 10:30:00-05:00'", - ), - SerializeTestCase( - DatetimeSerializer(), - _MSSQL, - datetime.datetime( - 2024, - 1, - 15, - 10, - 30, - 0, - tzinfo=datetime.timezone(datetime.timedelta(hours=-5)), - ), - "N'2024-01-15 10:30:00-05:00'", - ), - SerializeTestCase( - DatetimeSerializer(), - _PLAIN_QUOTE, - datetime.datetime( - 2024, - 1, - 15, - 10, - 30, - 0, - tzinfo=datetime.timezone(datetime.timedelta(hours=-5)), - ), - "'2024-01-15 10:30:00-05:00'", - ), - SerializeTestCase(DatetimeSerializer(), _PG, 0, "E'1970-01-01 00:00:00+00:00'"), - SerializeTestCase(DatetimeSerializer(), _MSSQL, 0, "N'1970-01-01 00:00:00+00:00'"), - SerializeTestCase( - DatetimeSerializer(), _PLAIN_QUOTE, 0, "'1970-01-01 00:00:00+00:00'" - ), - SerializeTestCase( - DatetimeSerializer(), _PG, 0.5, "E'1970-01-01 00:00:00.500+00:00'" - ), - SerializeTestCase( - DatetimeSerializer(), _MSSQL, 0.5, "N'1970-01-01 00:00:00.500+00:00'" - ), - SerializeTestCase( - DatetimeSerializer(), _PLAIN_QUOTE, 0.5, "'1970-01-01 00:00:00.500+00:00'" - ), - SerializeTestCase( - DatetimeSerializer(), ALL_DIALECTS, datetime.date(2024, 1, 15), TypeError - ), - SerializeTestCase( - DatetimeSerializer(), ALL_DIALECTS, "2024-01-15T10:30:00", TypeError - ), - SerializeTestCase(DatetimeSerializer(), ALL_DIALECTS, 10**20, OverflowError), - # --- Inet --- - SerializeTestCase(InetSerializer(), _PG, "192.168.1.1", "E'192.168.1.1/32'"), - SerializeTestCase(InetSerializer(), [DuckDB], "192.168.1.1", "'192.168.1.1/32'"), - SerializeTestCase( - InetSerializer(), - _PG, - ipaddress.ip_address("192.168.1.1"), - "E'192.168.1.1/32'", - ), - SerializeTestCase( - InetSerializer(), - [DuckDB], - ipaddress.ip_address("192.168.1.1"), - "'192.168.1.1/32'", - ), - SerializeTestCase(InetSerializer(), _PG, "192.168.1.0/24", "E'192.168.1.0/24'"), - SerializeTestCase(InetSerializer(), [DuckDB], "192.168.1.0/24", "'192.168.1.0/24'"), - SerializeTestCase(InetSerializer(), _PG, "::1", "E'::1/128'"), - SerializeTestCase(InetSerializer(), [DuckDB], "::1", "'::1/128'"), - SerializeTestCase(InetSerializer(), _PG, "2001:db8::/32", "E'2001:db8::/32'"), - SerializeTestCase(InetSerializer(), [DuckDB], "2001:db8::/32", "'2001:db8::/32'"), - SerializeTestCase(InetSerializer(), [PostgreSQL, DuckDB], "not-an-ip", ValueError), - SerializeTestCase(InetSerializer(), _INET_NO, "192.168.1.1", NotImplementedError), - # --- MacAddress --- - SerializeTestCase( - MacAddressSerializer(), _PG, "01:23:45:67:89:ab", "E'01:23:45:67:89:AB'" - ), - SerializeTestCase( - MacAddressSerializer(), _PG, "01-23-45-67-89-AB", "E'01:23:45:67:89:AB'" - ), - SerializeTestCase(MacAddressSerializer(), [PostgreSQL], "not-a-mac", ValueError), - SerializeTestCase( - MacAddressSerializer(), _MAC_NO, "01:23:45:67:89:ab", NotImplementedError - ), - # --- Unspecified --- - SerializeTestCase(UnspecifiedSerializer(), ALL_DIALECTS, 123, "123"), - SerializeTestCase(UnspecifiedSerializer(), ALL_DIALECTS, "hello", "hello"), - SerializeTestCase(UnspecifiedSerializer(), ALL_DIALECTS, None, "None"), - # --- Array --- - SerializeTestCase( - ArraySerializer(IntegerSerializer()), _ARRAY_OK, [1, 2, 3], "ARRAY[1, 2, 3]" - ), - SerializeTestCase( - ArraySerializer(IntegerSerializer()), _ARRAY_OK, (1, 2, 3), "ARRAY[1, 2, 3]" - ), - SerializeTestCase(ArraySerializer(IntegerSerializer()), _ARRAY_OK, [], "ARRAY[]"), - SerializeTestCase( - ArraySerializer(ArraySerializer(IntegerSerializer())), - _ARRAY_OK, - [[1, 2], [3]], - "ARRAY[ARRAY[1, 2], ARRAY[3]]", - ), - SerializeTestCase( - ArraySerializer(IntegerSerializer()), _ARRAY_OK, "not a list", TypeError - ), - SerializeTestCase( - ArraySerializer(IntegerSerializer()), _ARRAY_OK, [1, "x"], TypeError - ), - SerializeTestCase( - ArraySerializer(IntegerSerializer()), - _ARRAY_NO, - [1, 2, 3], - NotImplementedError, - ), - # --- Custom --- - SerializeTestCase( - CustomSerializer(_custom_ok), [PostgreSQL], 1, "CUSTOM:PostgreSQL:1" - ), - SerializeTestCase( - CustomSerializer(_custom_not_str), # pyright: ignore - ALL_DIALECTS, - "x", - TypeError, - ), -] - - -@pytest.mark.parametrize("testcase", SERIALIZE_TEST_CASES) -def test_serialize(testcase: SerializeTestCase): - for dialect in testcase.dialects: - try: - output = testcase.serializer.serialize(dialect, testcase.input) - except Exception as error: - if isinstance(testcase.output, str): - raise - - assert type(error) is testcase.output - - else: - assert output == testcase.output, ( - f"{testcase.serializer!r}({dialect!r}, {testcase.input!r}) == {output!r} != {testcase.output!r}" - ) - - -def test_serialize_cases_cover_all_serializers(): - assert {type(c.serializer) for c in SERIALIZE_TEST_CASES} == { - ArraySerializer, - BitSerializer, - BooleanSerializer, - BytesSerializer, - CustomSerializer, - DateSerializer, - DatetimeSerializer, - FloatSerializer, - InetSerializer, - IntegerSerializer, - JSONSerializer, - MacAddressSerializer, - StringSerializer, - TimeSerializer, - UnspecifiedSerializer, - UuidSerializer, - } +class TestIntegerSerializer: + def test_values(self): + s = IntegerSerializer() + assert s.serialize(PG, 1) == "1" + assert s.serialize(PG, 18_446_744_073_709_551_615) == "18446744073709551615" + assert s.serialize(PG, -9_223_372_036_854_775_808) == "-9223372036854775808" - -def test_unsupported_serializer_error_message(): - with pytest.raises( - NotImplementedError, match="SQLite does not support BitSerializer" - ): - BitSerializer().serialize(SQLite, "10") - with pytest.raises( - NotImplementedError, match="MySql does not support ArraySerializer" - ): - ArraySerializer(IntegerSerializer()).serialize(MySql, [1, 2]) - with pytest.raises( - NotImplementedError, match="SQLite does not support InetSerializer" - ): - InetSerializer().serialize(SQLite, "192.168.1.1") - with pytest.raises( - NotImplementedError, match="DuckDB does not support MacAddressSerializer" - ): - MacAddressSerializer().serialize(DuckDB, "01:23:45:67:89:ab") - with pytest.raises( - NotImplementedError, match="BigQuery does not support BitSerializer" - ): - ArraySerializer(BitSerializer()).serialize(BigQuery, ["10"]) + @pytest.mark.parametrize("value", [2.5, True, "invalid"]) + def test_rejects_non_int(self, value: typing.Any): + with pytest.raises(TypeError): + IntegerSerializer().serialize(PG, value) -# --------------------------------------------------------------------------- -# String serialization escapes / edge cases -# --------------------------------------------------------------------------- +class TestFloatSerializer: + def test_values(self): + s = FloatSerializer() + assert s.serialize(PG, 1.1) == "1.1" + assert s.serialize(PG, 1) == "1" + assert s.serialize(PG, 1.0) == "1.0" + assert s.serialize(PG, Decimal("1.3435")) == "1.3435" + def test_rejects_non_numeric(self): + with pytest.raises(TypeError): + FloatSerializer().serialize(PG, "invalid") -@pytest.mark.parametrize("dialect", ALL_DIALECTS) -def test_string_empty_is_quoted(dialect: type[SqlDialect]): - assert StringSerializer().serialize(dialect, "") == _quoted(dialect, "") + @pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")]) + def test_rejects_non_finite(self, value: float): + with pytest.raises(ValueError): + FloatSerializer().serialize(PG, value) -@pytest.mark.parametrize("dialect", _QUOTE_ONLY) -def test_string_quote_only_doubles_single_quote(dialect: type[SqlDialect]): - s = StringSerializer() - assert s.serialize(dialect, "O'Brien") == _quoted(dialect, "O''Brien") - assert s.serialize(dialect, "''''") == _quoted(dialect, "''''''''") - +@pytest.mark.parametrize("dialect", ALL_DIALECTS, ids=ALL_DIALECT_NAMES) +def test_boolean_follows_format_boolean(dialect: SqlDialect): + s = BooleanSerializer() + assert s.serialize(dialect, True) == dialect.format_boolean(True) + assert s.serialize(dialect, False) == dialect.format_boolean(False) -@pytest.mark.parametrize("dialect", _BACKSLASH_ESCAPING) -def test_string_backslash_family_escapes_single_quote(dialect: type[SqlDialect]): - s = StringSerializer() - assert s.serialize(dialect, "O'Brien") == _quoted(dialect, "O\\'Brien") - assert s.serialize(dialect, "''''") == _quoted(dialect, "\\'\\'\\'\\'") - -@pytest.mark.parametrize("dialect", _QUOTE_ONLY) -def test_string_quote_only_passes_backslash_through(dialect: type[SqlDialect]): - s = StringSerializer() - assert s.serialize(dialect, "\\") == _quoted(dialect, "\\") - assert s.serialize(dialect, "\\'") == _quoted(dialect, "\\''") +@pytest.mark.parametrize("value", [1, 0, object()]) +def test_boolean_rejects_non_bool(value: typing.Any): + with pytest.raises(TypeError): + BooleanSerializer().serialize(PG, value) -@pytest.mark.parametrize("dialect", _BACKSLASH_ESCAPING) -def test_string_backslash_family_doubles_backslash(dialect: type[SqlDialect]): +@pytest.mark.parametrize("dialect", ALL_DIALECTS, ids=ALL_DIALECT_NAMES) +def test_string_follows_write_string_literal(dialect: SqlDialect): s = StringSerializer() - assert s.serialize(dialect, "\\") == _quoted(dialect, "\\\\") - assert s.serialize(dialect, "\\'") == _quoted(dialect, "\\\\\\'") - - -@pytest.mark.parametrize("dialect", _NUL_STRIPPING) -def test_string_nul_is_stripped(dialect: type[SqlDialect]): - assert StringSerializer().serialize(dialect, "a\0b") == _quoted(dialect, "ab") - - -@pytest.mark.parametrize("dialect", _NUL_X00) -def test_string_nul_is_hex_escaped(dialect: type[SqlDialect]): - assert StringSerializer().serialize(dialect, "a\0b") == _quoted(dialect, "a\\x00b") - - -@pytest.mark.parametrize("dialect", _NUL_BACKSLASH_ZERO) -def test_string_nul_is_backslash_zero(dialect: type[SqlDialect]): - assert StringSerializer().serialize(dialect, "a\0b") == _quoted(dialect, "a\\0b") - - -def test_string_nul_duckdb_passthrough(): - assert StringSerializer().serialize(DuckDB, "a\0b") == "'a\0b'" - - -@pytest.mark.parametrize("dialect", _BACKSLASH_ESCAPING) -def test_string_backslash_family_escapes_newline_and_cr(dialect: type[SqlDialect]): - assert StringSerializer().serialize(dialect, "a\nb\rc") == _quoted( - dialect, "a\\nb\\rc" - ) - + for raw in ("Hello World", "O'Brien", "šŸ˜€", "a\nb", "", ValidStrEnum.HelloWorld): + assert s.serialize(dialect, raw) == dialect.write_string_literal(raw) -@pytest.mark.parametrize("dialect", _QUOTE_ONLY) -def test_string_quote_only_passes_newline_cr_tab_through(dialect: type[SqlDialect]): - assert StringSerializer().serialize(dialect, "a\nb\rc\td") == _quoted( - dialect, "a\nb\rc\td" - ) - - -@pytest.mark.parametrize( - "dialect", - [d for d in _BACKSLASH_ESCAPING if d is not MySql], -) -def test_string_backslash_family_escapes_tab(dialect: type[SqlDialect]): - assert StringSerializer().serialize(dialect, "a\tb") == _quoted(dialect, "a\\tb") - - -def test_string_mysql_passes_tab_through(): - assert StringSerializer().serialize(MySql, "a\tb") == "'a\tb'" +@pytest.mark.parametrize("value", [InvalidStrEnum.Variant, 1, None, b"bytes"]) +def test_string_rejects_non_str(value: typing.Any): + with pytest.raises(TypeError): + StringSerializer().serialize(PG, value) -@pytest.mark.parametrize("dialect", _HEX_CTRL) -def test_string_other_controls_hex_escape(dialect: type[SqlDialect]): - assert StringSerializer().serialize(dialect, "\x01\x1a\x7f") == _quoted( - dialect, "\\x01\\x1A\\x7F" - ) +class TestUuidSerializer: + _TEXT = "103596b1-db75-4ae0-907a-a4a9bc4dab85" -@pytest.mark.parametrize("dialect", _UNICODE_CTRL) -def test_string_other_controls_unicode_escape(dialect: type[SqlDialect]): - assert StringSerializer().serialize(dialect, "\x01\x1a\x7f") == _quoted( - dialect, "\\u0001\\u001A\\u007F" - ) + def test_uuid_and_str(self): + s = UuidSerializer() + expected = PG.write_string_literal(self._TEXT) + assert s.serialize(PG, self._TEXT) == expected + assert s.serialize(PG, uuid.UUID(self._TEXT)) == expected + def test_invalid_str(self): + with pytest.raises(ValueError): + UuidSerializer().serialize(PG, "103596-db75-4ae0-907a-a4a9bc4d85") -def test_string_mysql_ctrl_z_and_raw_other_controls(): - assert StringSerializer().serialize(MySql, "\x01\x1a\x7f") == "'\x01\\Z\x7f'" + def test_rejects_non_uuid(self): + with pytest.raises(TypeError): + UuidSerializer().serialize(PG, 6428739) -@pytest.mark.parametrize("dialect", _QUOTE_ONLY) -def test_string_quote_only_passes_other_controls_through(dialect: type[SqlDialect]): - assert StringSerializer().serialize(dialect, "\x01\x1a\x7f") == _quoted( - dialect, "\x01\x1a\x7f" - ) +@pytest.mark.parametrize("dialect", ALL_DIALECTS, ids=ALL_DIALECT_NAMES) +@pytest.mark.parametrize("data", [b"", b"simple", b"\x00", b"\xff", b"\x00\x01\xff"]) +def test_bytes_follows_format_bytes(dialect: SqlDialect, data: bytes): + assert BytesSerializer().serialize(dialect, data) == dialect.format_bytes(data) -@pytest.mark.parametrize("dialect", _DOUBLE_QUOTE_ESCAPING) -def test_string_double_quote_is_escaped(dialect: type[SqlDialect]): - assert StringSerializer().serialize(dialect, 'say "hi"') == _quoted( - dialect, 'say \\"hi\\"' - ) +def test_bytes_all_256_values(): + data = bytes(range(256)) + assert BytesSerializer().serialize(PG, data) == PG.format_bytes(data) @pytest.mark.parametrize( - "dialect", - [d for d in ALL_DIALECTS if d not in _DOUBLE_QUOTE_ESCAPING], + "value", [bytearray(b"abc"), memoryview(b"abc"), "abc", 1, None] ) -def test_string_double_quote_is_literal(dialect: type[SqlDialect]): - assert StringSerializer().serialize(dialect, 'say "hi"') == _quoted( - dialect, 'say "hi"' - ) - - -@pytest.mark.parametrize("dialect", _QUOTE_ONLY) -def test_string_injection_quote_only(dialect: type[SqlDialect]): - out = StringSerializer().serialize(dialect, "' OR 1=1 --") - assert out == _quoted(dialect, "'' OR 1=1 --") - assert " OR 1=1 --" in out - - -@pytest.mark.parametrize("dialect", _BACKSLASH_ESCAPING) -def test_string_injection_backslash_family(dialect: type[SqlDialect]): - out = StringSerializer().serialize(dialect, "' OR 1=1 --") - assert out == _quoted(dialect, "\\' OR 1=1 --") - - -@pytest.mark.parametrize("dialect", ALL_DIALECTS) -def test_string_unicode_passthrough(dialect: type[SqlDialect]): - assert StringSerializer().serialize(dialect, "ę—„ęœ¬čŖžšŸ˜€") == _quoted( - dialect, "ę—„ęœ¬čŖžšŸ˜€" - ) - - -@pytest.mark.parametrize("value", [123, b"bytes", None]) -@pytest.mark.parametrize("dialect", ALL_DIALECTS) -def test_string_rejects_non_str(dialect: type[SqlDialect], value: typing.Any): +def test_bytes_rejects_non_exact_bytes(value: typing.Any): with pytest.raises(TypeError): - StringSerializer().serialize(dialect, value) - - -# --------------------------------------------------------------------------- -# Bytes serialization escapes / edge cases -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("dialect", ALL_DIALECTS) -@pytest.mark.parametrize( - "data", - [b"", b"\x00", b"\xff", b"\x00\x01\xff"], -) -def test_bytes_hex_wrappers(dialect: type[SqlDialect], data: bytes): - assert BytesSerializer().serialize(dialect, data) == _bytes_literal(dialect, data) - - -@pytest.mark.parametrize("dialect", ALL_DIALECTS) -def test_bytes_all_256_values_uppercase_hex(dialect: type[SqlDialect]): - data = bytes(range(256)) - out = BytesSerializer().serialize(dialect, data) - assert out == _bytes_literal(dialect, data) - if dialect is BigQuery: - expected_run = "".join(f"\\x{b:02X}" for b in data) - else: - expected_run = "".join(f"{b:02X}" for b in data) + BytesSerializer().serialize(PG, value) + + +class TestBitSerializer: + def test_follows_format_bits_when_supported(self, dialect_instance: SqlDialect): + if dialect_instance.bit_format is None: + with pytest.raises( + NotImplementedError, match="does not support BitSerializer" + ): + BitSerializer().serialize(dialect_instance, "10") + return + assert BitSerializer().serialize( + dialect_instance, "10" + ) == dialect_instance.format_bits("10") + + def test_rejects_non_bit_string(self): + with pytest.raises(ValueError): + BitSerializer().serialize(PG, "102") + with pytest.raises(ValueError): + BitSerializer().serialize(PG, "") + with pytest.raises(TypeError): + BitSerializer().serialize(PG, 10) + + +class TestJSONSerializer: + def test_dumps_then_write_string_literal(self): + s = JSONSerializer() + cases = [ + (10, "10"), + ("10", '"10"'), + ([1, "Ali", 1.0, {}], '[1,"Ali",1.0,{}]'), + ] + for obj, inner in cases: + assert s.serialize(PG, obj) == PG.write_string_literal(inner) + + def test_orjson_only_values(self): + s = JSONSerializer() + extras = [ + ( + datetime.datetime(2024, 10, 1, tzinfo=datetime.timezone.utc), + '"2024-10-01T00:00:00+00:00"', + ), + ( + uuid.UUID("103596b1-db75-4ae0-907a-a4a9bc4dab85"), + '"103596b1-db75-4ae0-907a-a4a9bc4dab85"', + ), + (ExampleDataclass(x=1), '{"x":1}'), + (ValidStrEnum.HelloWorld, '"Hello World"'), + ] + for obj, inner in extras: + if USING_ORJSON: + assert s.serialize(PG, obj) == PG.write_string_literal(inner) + else: + with pytest.raises(TypeError): + s.serialize(PG, obj) + + +class TestDateTimeSerializers: + def test_date(self): + value = datetime.date(2024, 1, 15) + assert DateSerializer().serialize(PG, value) == PG.write_string_literal( + "2024-01-15" + ) + + def test_date_rejects_datetime_and_str(self): + with pytest.raises(TypeError): + DateSerializer().serialize(PG, datetime.datetime(2024, 1, 15, 10, 0)) + with pytest.raises(TypeError): + DateSerializer().serialize(PG, "2024-01-15") + + def test_time(self): + s = TimeSerializer() + assert s.serialize(PG, datetime.time(13, 45, 30)) == PG.write_string_literal( + "13:45:30" + ) + assert s.serialize(PG, datetime.time(1, 2, 3, 456789)) == ( + PG.write_string_literal("01:02:03.456789") + ) + + def test_time_rejects_date(self): + with pytest.raises(TypeError): + TimeSerializer().serialize(PG, datetime.date(2024, 1, 1)) + + def test_datetime_naive_and_aware(self): + s = DatetimeSerializer() + naive = datetime.datetime(2024, 1, 15, 10, 30, 0) + utc = datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc) + offset = datetime.datetime( + 2024, + 1, + 15, + 10, + 30, + 0, + tzinfo=datetime.timezone(datetime.timedelta(hours=-5)), + ) + assert s.serialize(PG, naive) == PG.write_string_literal("2024-01-15 10:30:00") + assert s.serialize(PG, utc) == PG.write_string_literal( + "2024-01-15 10:30:00+00:00" + ) + assert s.serialize(PG, offset) == PG.write_string_literal( + "2024-01-15 10:30:00-05:00" + ) + + def test_datetime_unix_timestamp(self): + s = DatetimeSerializer() + assert s.serialize(PG, 0) == PG.write_string_literal( + "1970-01-01 00:00:00+00:00" + ) + assert s.serialize(PG, 0.5) == PG.write_string_literal( + "1970-01-01 00:00:00.500+00:00" + ) + + def test_datetime_rejects_date_and_str(self): + with pytest.raises(TypeError): + DatetimeSerializer().serialize(PG, datetime.date(2024, 1, 15)) + with pytest.raises(TypeError): + DatetimeSerializer().serialize(PG, "2024-01-15T10:30:00") + + def test_datetime_overflow(self): + with pytest.raises(OverflowError): + DatetimeSerializer().serialize(PG, 10**20) + + +class TestInetSerializer: + def test_canonical_cidr(self, dialect_instance: SqlDialect): + if not dialect_instance.supports_inet: + with pytest.raises( + NotImplementedError, match="does not support InetSerializer" + ): + InetSerializer().serialize(dialect_instance, "192.168.1.1") + return + s = InetSerializer() + cases = [ + ("192.168.1.1", "192.168.1.1/32"), + (ipaddress.ip_address("192.168.1.1"), "192.168.1.1/32"), + ("192.168.1.0/24", "192.168.1.0/24"), + ("::1", "::1/128"), + ("2001:db8::/32", "2001:db8::/32"), + ] + for obj, inner in cases: + assert s.serialize(dialect_instance, obj) == ( + dialect_instance.write_string_literal(inner) + ) - assert expected_run in out + def test_invalid_address(self): + with pytest.raises(ValueError): + InetSerializer().serialize(PG, "not-an-ip") + + +class TestMacAddressSerializer: + def test_canonical_form(self, dialect_instance: SqlDialect): + if not dialect_instance.supports_mac_address: + with pytest.raises( + NotImplementedError, match="does not support MacAddressSerializer" + ): + MacAddressSerializer().serialize(dialect_instance, "01:23:45:67:89:ab") + return + s = MacAddressSerializer() + expected = dialect_instance.write_string_literal("01:23:45:67:89:AB") + assert s.serialize(dialect_instance, "01:23:45:67:89:ab") == expected + assert s.serialize(dialect_instance, "01-23-45-67-89-AB") == expected + + def test_invalid_address(self): + with pytest.raises(ValueError): + MacAddressSerializer().serialize(PG, "not-a-mac") + + +class TestUnspecifiedSerializer: + def test_uses_str(self): + s = UnspecifiedSerializer() + assert s.serialize(PG, 123) == "123" + assert s.serialize(PG, "hello") == "hello" + assert s.serialize(PG, None) == "None" + + +class TestArraySerializer: + def test_renders_array_constructor(self, dialect_instance: SqlDialect): + s = ArraySerializer(IntegerSerializer()) + if not dialect_instance.supports_array: + with pytest.raises( + NotImplementedError, match="does not support ArraySerializer" + ): + s.serialize(dialect_instance, [1, 2, 3]) + return + assert s.serialize(dialect_instance, [1, 2, 3]) == "ARRAY[1, 2, 3]" + assert s.serialize(dialect_instance, (1, 2, 3)) == "ARRAY[1, 2, 3]" + assert s.serialize(dialect_instance, []) == "ARRAY[]" + nested = ArraySerializer(ArraySerializer(IntegerSerializer())) + assert nested.serialize(dialect_instance, [[1, 2], [3]]) == ( + "ARRAY[ARRAY[1, 2], ARRAY[3]]" + ) + + def test_rejects_non_sequence_and_bad_element(self): + s = ArraySerializer(IntegerSerializer()) + with pytest.raises(TypeError): + s.serialize(PG, "not a list") + with pytest.raises(TypeError): + s.serialize(PG, [1, "x"]) + + def test_nested_unsupported_element(self): + # BigQuery supports ARRAY but not bit literals; the element check fires. + with pytest.raises(NotImplementedError, match="does not support BitSerializer"): + ArraySerializer(BitSerializer()).serialize(BigQuery(), ["10"]) + + +class TestCustomSerializer: + def test_receives_dialect_instance(self): + dialect = DuckDb() + assert CustomSerializer(_custom_ok).serialize(dialect, 1) == "CUSTOM:$:1" + + def test_must_return_str(self): + with pytest.raises(TypeError): + CustomSerializer(_custom_not_str).serialize(PG, "x") # pyright: ignore -@pytest.mark.parametrize( - "value", - [bytearray(b"abc"), memoryview(b"abc"), "abc", 1, None], -) -@pytest.mark.parametrize("dialect", ALL_DIALECTS) -def test_bytes_rejects_non_exact_bytes(dialect: type[SqlDialect], value: typing.Any): - with pytest.raises(TypeError): - BytesSerializer().serialize(dialect, value) +def test_unsupported_serializer_error_message(): + with pytest.raises(NotImplementedError, match="does not support BitSerializer"): + BitSerializer().serialize(Sqlite(), "10") + with pytest.raises(NotImplementedError, match="does not support ArraySerializer"): + ArraySerializer(IntegerSerializer()).serialize(MySql(), [1, 2]) + with pytest.raises(NotImplementedError, match="does not support InetSerializer"): + InetSerializer().serialize(Sqlite(), "192.168.1.1") + with pytest.raises( + NotImplementedError, match="does not support MacAddressSerializer" + ): + MacAddressSerializer().serialize(DuckDb(), "01:23:45:67:89:ab") diff --git a/tests/test_value.py b/tests/test_value.py index ea5495c..f18ed7b 100644 --- a/tests/test_value.py +++ b/tests/test_value.py @@ -24,7 +24,7 @@ UnspecifiedSerializer, UuidSerializer, ) -from rapidquery.dialect import PostgreSQL +from rapidquery.dialect import PostgreSql from rapidquery.expr import Expr, Value from tests.conftest import USING_ORJSON @@ -112,23 +112,19 @@ def test_data_type_serializer_override(self): class TestValueToSql: def test_integer_literal(self): - assert Value(42).to_sql(PostgreSQL) == "42" + assert Value(42).to_sql(PostgreSql()) == "42" def test_string_literal_matches_string_serializer(self): - assert Value("hi").to_sql(PostgreSQL) == StringSerializer().serialize( - PostgreSQL, "hi" + assert Value("hi").to_sql(PostgreSql()) == StringSerializer().serialize( + PostgreSql(), "hi" ) def test_none_is_null(self): - assert Value(None).to_sql(PostgreSQL) == "NULL" - - def test_accepts_dialect_class_or_instance(self): - value = Value(1) - assert value.to_sql(PostgreSQL) == value.to_sql(PostgreSQL()) + assert Value(None).to_sql(PostgreSql()) == "NULL" def test_propagates_serializer_type_error(self): with pytest.raises(TypeError): - Value("not an int", IntegerSerializer()).to_sql(PostgreSQL) + Value("not an int", IntegerSerializer()).to_sql(PostgreSql()) class TestValueEquality: From f60b2a1f9259f6eb1dadd15159add5d33fb25f59 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Wed, 23 Sep 2026 20:06:01 +0330 Subject: [PATCH 43/87] Done 3 tasks 1. Fix Teradata `identifier_format` 2. Fix BigQuery identifier escape 3. Remove `ALL` keyword from Function, it was unnecessary --- rapidquery/expr.pyi | 7 +++--- src/dialect/builtin.rs | 28 ++++++++++----------- src/dialect/convert.rs | 5 +--- src/dialect/inner.rs | 46 +++++++++++++++++----------------- src/expr/enum_/func.rs | 56 +++++++++++------------------------------- src/expr/func.rs | 25 ++++++------------- src/sql.rs | 3 +++ 7 files changed, 65 insertions(+), 105 deletions(-) diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index 8571675..5b0610b 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -233,14 +233,13 @@ class Function(Expr): ValueError: If ``name`` is an empty or invalid identifier. """ - def call(self, *args: object, distinct: bool | None = None) -> Self: + def call(self, *args: object, distinct: bool = False) -> Self: """ Attach an argument list (including empty parentheses). Args: *args: Positional argument expressions. - distinct: ``True`` inserts ``DISTINCT``, ``False`` inserts - ``ALL``, omitted inserts neither. + distinct: ``True`` inserts ``DISTINCT``. Returns: A new ``Function`` with this argument list. @@ -291,7 +290,7 @@ class Function(Expr): The function name as an ``Ident``. """ - def get_distinct(self) -> bool | None: ... + def get_distinct(self) -> bool: ... def get_args(self) -> tuple[Expr, ...] | None: """ Return the positional arguments. diff --git a/src/dialect/builtin.rs b/src/dialect/builtin.rs index 2d4c4eb..3c1e508 100644 --- a/src/dialect/builtin.rs +++ b/src/dialect/builtin.rs @@ -44,7 +44,7 @@ impl_builtin_dialects!( like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::Backtick, - identifier_escape: IdentifierEscape::SameAsString, + identifier_escape: IdentifierEscape::Backslash, string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashHex, bytes_format: BytesFormat::PrefixBigBSlashX, @@ -63,7 +63,7 @@ impl_builtin_dialects!( like_any_syntax: LikeAnySyntax::PatternList, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::Backtick, - identifier_escape: IdentifierEscape::Delimiters, + identifier_escape: IdentifierEscape::Double, string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashUnicodeQuote, bytes_format: BytesFormat::PrefixBigX, @@ -82,7 +82,7 @@ impl_builtin_dialects!( like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::DoubleQuote, - identifier_escape: IdentifierEscape::Delimiters, + identifier_escape: IdentifierEscape::Double, string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::PrefixBigX, @@ -101,7 +101,7 @@ impl_builtin_dialects!( like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::Backtick, - identifier_escape: IdentifierEscape::Delimiters, + identifier_escape: IdentifierEscape::Double, string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashUnicode, bytes_format: BytesFormat::Unhex, @@ -120,7 +120,7 @@ impl_builtin_dialects!( like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, identifier_format: IdentifierFormat::SquareBracket, - identifier_escape: IdentifierEscape::Delimiters, + identifier_escape: IdentifierEscape::Double, string_format: StringFormat::PrefixBigN, string_escape: StringEscape::QuoteStripNul, bytes_format: BytesFormat::PrefixZeroX, @@ -139,7 +139,7 @@ impl_builtin_dialects!( like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, identifier_format: IdentifierFormat::Backtick, - identifier_escape: IdentifierEscape::Delimiters, + identifier_escape: IdentifierEscape::Double, string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashMysql, bytes_format: BytesFormat::PrefixBigX, @@ -158,7 +158,7 @@ impl_builtin_dialects!( like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, identifier_format: IdentifierFormat::DoubleQuote, - identifier_escape: IdentifierEscape::Delimiters, + identifier_escape: IdentifierEscape::Double, string_format: StringFormat::SingleQuote, string_escape: StringEscape::QuoteStripNul, bytes_format: BytesFormat::HexToRaw, @@ -177,7 +177,7 @@ impl_builtin_dialects!( like_any_syntax: LikeAnySyntax::ArrayConstructor, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::DoubleQuote, - identifier_escape: IdentifierEscape::Delimiters, + identifier_escape: IdentifierEscape::Double, string_format: StringFormat::PrefixBigE, string_escape: StringEscape::BackslashHex, bytes_format: BytesFormat::SlashXBytea, @@ -196,7 +196,7 @@ impl_builtin_dialects!( like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::DoubleQuote, - identifier_escape: IdentifierEscape::Delimiters, + identifier_escape: IdentifierEscape::Double, string_format: StringFormat::SingleQuote, string_escape: StringEscape::QuoteStripNul, bytes_format: BytesFormat::ToVarbyte, @@ -215,7 +215,7 @@ impl_builtin_dialects!( like_any_syntax: LikeAnySyntax::PatternList, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::DoubleQuote, - identifier_escape: IdentifierEscape::Delimiters, + identifier_escape: IdentifierEscape::Double, string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashHexZero, bytes_format: BytesFormat::PrefixBigX, @@ -234,7 +234,7 @@ impl_builtin_dialects!( like_any_syntax: LikeAnySyntax::PatternList, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::Backtick, - identifier_escape: IdentifierEscape::Delimiters, + identifier_escape: IdentifierEscape::Double, string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashUnicodeQuote, bytes_format: BytesFormat::PrefixBigX, @@ -253,7 +253,7 @@ impl_builtin_dialects!( like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, identifier_format: IdentifierFormat::DoubleQuote, - identifier_escape: IdentifierEscape::Delimiters, + identifier_escape: IdentifierEscape::Double, string_format: StringFormat::SingleQuote, string_escape: StringEscape::QuoteStripNul, bytes_format: BytesFormat::PrefixBigX, @@ -271,8 +271,8 @@ impl_builtin_dialects!( null_treatment_syntax: NullTreatmentSyntax::OutsideParens, like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, - identifier_format: IdentifierFormat::SingleQuote, - identifier_escape: IdentifierEscape::Delimiters, + identifier_format: IdentifierFormat::DoubleQuote, + identifier_escape: IdentifierEscape::Double, string_format: StringFormat::SingleQuote, string_escape: StringEscape::QuoteStripNul, bytes_format: BytesFormat::PostfixXbv, diff --git a/src/dialect/convert.rs b/src/dialect/convert.rs index ee05875..47643bf 100644 --- a/src/dialect/convert.rs +++ b/src/dialect/convert.rs @@ -72,10 +72,7 @@ named!( named!( IdentifierEscape, identifier_escape_name, - parse_identifier_escape { - Delimiters, - SameAsString - } + parse_identifier_escape { Double, Backslash } ); named!( StringFormat, diff --git a/src/dialect/inner.rs b/src/dialect/inner.rs index 6f722ba..4571eb1 100644 --- a/src/dialect/inner.rs +++ b/src/dialect/inner.rs @@ -58,6 +58,8 @@ pub enum BoolFormat { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum IdentifierFormat { /// `'name'` + /// + /// TODO: delete this SingleQuote, /// `"name"` DoubleQuote, @@ -70,15 +72,10 @@ pub enum IdentifierFormat { /// How to escape identifiers #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum IdentifierEscape { - /// Escape identifier delimiters. - /// - /// - `IdentifierFormat::SingleQuote`: Escape `'` - /// - `IdentifierFormat::DoubleQuote`: Escape `"` - /// - `IdentifierFormat::Backtick`: Escape `` ` `` - /// - `IdentifierFormat::SquareBracket`: Escape `]` - Delimiters, - /// Escape same as how we escape string. - SameAsString, + /// Escape identifier delimiters with doubling. + Double, + /// Escape identifier delimiters with backslash. + Backslash, } /// Delimiters wrapped around a literal string. @@ -265,7 +262,7 @@ impl Dialect { writer.write_char(start)?; match self.identifier_escape { - IdentifierEscape::Delimiters => match self.identifier_format { + IdentifierEscape::Double => match self.identifier_format { IdentifierFormat::SingleQuote => { utils::replace_to(ident, '\'', "''", writer)?; } @@ -279,7 +276,20 @@ impl Dialect { utils::replace_to(ident, ']', "]]", writer)?; } }, - IdentifierEscape::SameAsString => self.escape_string(writer, ident)?, + IdentifierEscape::Backslash => match self.identifier_format { + IdentifierFormat::SingleQuote => { + utils::replace_to(ident, '\'', "\\'", writer)?; + } + IdentifierFormat::DoubleQuote => { + utils::replace_to(ident, '"', "\\\"", writer)?; + } + IdentifierFormat::Backtick => { + utils::replace_to(ident, '`', "\\`", writer)?; + } + IdentifierFormat::SquareBracket => { + utils::replace_to(ident, ']', "\\]", writer)?; + } + }, } writer.write_char(end) @@ -474,7 +484,7 @@ mod tests { like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::DoubleQuote, - identifier_escape: IdentifierEscape::Delimiters, + identifier_escape: IdentifierEscape::Backslash, string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::PrefixBigX, @@ -624,18 +634,6 @@ mod tests { let _ = cfg.format_bits(&mut String::new(), "1"); } - #[test] - fn same_as_string_identifier_uses_string_escape() { - let mut cfg = base(); - cfg.identifier_format = IdentifierFormat::Backtick; - cfg.identifier_escape = IdentifierEscape::SameAsString; - cfg.string_escape = StringEscape::BackslashHex; - assert_eq!( - collect(|buf| cfg.quote_identifier(buf, "foo'bar\\baz")), - "`foo\\'bar\\\\baz`" - ); - } - #[test] fn write_string_literal_prefix_big_e_backslash_hex() { let mut cfg = base(); diff --git a/src/expr/enum_/func.rs b/src/expr/enum_/func.rs index 12bb66e..d19c5a3 100644 --- a/src/expr/enum_/func.rs +++ b/src/expr/enum_/func.rs @@ -3,24 +3,6 @@ use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; use super::ExprEnum; -/// How duplicate values are treated inside function argument lists. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum DuplicateTreatment { - /// Consider only unique values. - Distinct, - /// Retain all duplicate values (the default). - All, -} - -impl DuplicateTreatment { - pub fn as_sql(self) -> &'static str { - match self { - Self::Distinct => "DISTINCT", - Self::All => "ALL", - } - } -} - /// How `NULL`s are treated in some window/aggregate functions. /// /// Placement is dialect-specific: postfix `LAG(x) IGNORE NULLS`, or @@ -47,11 +29,8 @@ pub enum FunctionArguments { /// Used for special functions like `CURRENT_TIMESTAMP` that are invoked /// without parentheses. None, - /// A normal function argument list, including `DISTINCT` / `ALL`. - List { - duplicate_treatment: Option, - args: Vec, - }, + /// A normal function argument list, including `DISTINCT`. + List { distinct: bool, args: Vec }, } #[derive(Clone, Debug, PartialEq)] @@ -74,10 +53,7 @@ impl Function { let mut buf = format!("Function({})", self.name.python_repr()); match &self.args { FunctionArguments::None => (), - FunctionArguments::List { - duplicate_treatment, - args, - } => { + FunctionArguments::List { distinct, args } => { buf.push_str(".call("); for (i, arg) in args.iter().enumerate() { if i > 0 { @@ -85,14 +61,12 @@ impl Function { } buf.push_str(&arg.python_repr(py)?); } - if let Some(dt) = duplicate_treatment { + + if *distinct { if !args.is_empty() { buf.push_str(", "); } - match dt { - DuplicateTreatment::Distinct => buf.push_str("distinct=True"), - DuplicateTreatment::All => buf.push_str("distinct=False"), - } + buf.push_str("distinct=True"); } buf.push(')'); } @@ -117,6 +91,10 @@ impl WriteSql for Function { dialect: &crate::dialect::PySqlDialect, out: &mut W, ) -> PrepareResult<()> { + // TODO: + // - `DuplicateTreatment::All` is not supported by SQLite/BigQuery, we should skip it + // - BigQuery supports `WHERE condition` instead of `FILTER (WHERE condition)` + self.name.write_sql(dialect, out)?; let nt_inside = matches!( dialect.0.null_treatment_syntax, @@ -131,18 +109,13 @@ impl WriteSql for Function { out.push_str(nt.as_sql()); } } - FunctionArguments::List { - duplicate_treatment, - args, - } => { + FunctionArguments::List { distinct, args } => { out.push_char('('); - if let Some(dt) = duplicate_treatment - && !args.is_empty() - { - out.push_str(dt.as_sql()); - out.push_char(' '); + if *distinct && !args.is_empty() { + out.push_str("DISTINCT "); } + write_separated(dialect, out, args, ", ")?; if nt_inside && let Some(nt) = self.null_treatment { if !args.is_empty() { @@ -150,6 +123,7 @@ impl WriteSql for Function { } out.push_str(nt.as_sql()); } + out.push_char(')'); if !nt_inside && let Some(nt) = self.null_treatment { out.push_char(' '); diff --git a/src/expr/func.rs b/src/expr/func.rs index 1820869..52afe61 100644 --- a/src/expr/func.rs +++ b/src/expr/func.rs @@ -3,7 +3,7 @@ use pyo3::types::{PyAnyMethods, PyTupleMethods}; use crate::internal::{alias, utils}; use super::base::PyExpr; -use super::enum_::{DuplicateTreatment, FunctionArguments, NullTreatment}; +use super::enum_::{FunctionArguments, NullTreatment}; use super::extractors::{ConditionExtractor, IdentExtractor, LiteralExprExtractor}; /// SQL function call. @@ -65,11 +65,11 @@ impl PyFunction { Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) } - #[pyo3(signature = (*args, distinct = None))] + #[pyo3(signature = (*args, distinct = false))] fn call( slf: pyo3::PyRef<'_, Self>, args: alias::ArgsType<'_>, - distinct: Option, + distinct: bool, ) -> pyo3::PyResult { let mut inner = Self::inner(&slf).clone(); let mut extracted = Vec::with_capacity(args.len()); @@ -77,11 +77,7 @@ impl PyFunction { extracted.push(item.extract::()?.0); } inner.args = FunctionArguments::List { - duplicate_treatment: match distinct { - Some(true) => Some(DuplicateTreatment::Distinct), - Some(false) => Some(DuplicateTreatment::All), - None => None, - }, + distinct, args: extracted, }; Self::wrap(slf.py(), inner) @@ -113,19 +109,12 @@ impl PyFunction { PyExpr(super::enum_::ExprEnum::Ident(inner.name.clone())).into_subclasses(slf.py()) } - fn get_distinct(slf: pyo3::PyRef<'_, Self>) -> Option { + fn get_distinct(slf: pyo3::PyRef<'_, Self>) -> bool { let inner = Self::inner(&slf); match inner.args { - FunctionArguments::None => None, - FunctionArguments::List { - duplicate_treatment: None, - .. - } => None, - FunctionArguments::List { - duplicate_treatment: Some(x), - .. - } => Some(matches!(x, DuplicateTreatment::Distinct)), + FunctionArguments::None => false, + FunctionArguments::List { distinct, .. } => distinct, } } diff --git a/src/sql.rs b/src/sql.rs index 80d7a18..c2e871c 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -268,6 +268,9 @@ impl SqlWriter for ParameterizedSql<'_> { _serializer: &SerializerEnum, object: &alias::PyObject, ) -> PrepareResult<()> { + // TODO: use positional parameters instead of named parameters + // some of dialects and their native clients does not support + // named parameters. self.push_param(dialect, object.clone_ref(self.py)); Ok(()) } From 186f4c865ed5222aeaf0f05af85ddec9bc5ffc51 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Wed, 23 Sep 2026 21:12:13 +0330 Subject: [PATCH 44/87] Use positional parameters instead of named parameters, so developers can use all of dialects with their native clients. Even libpg doesn't support named parameters... --- rapidquery/dialect.pyi | 4 +-- rapidquery/expr.pyi | 2 +- src/dialect/base.rs | 4 +-- src/dialect/builtin.rs | 26 +++++++------- src/dialect/convert.rs | 34 ++++++++++-------- src/dialect/generic.rs | 20 ++++++++--- src/dialect/inner.rs | 28 +++++++-------- src/expr/base.rs | 19 +++++++---- src/expr/enum_/func.rs | 1 - src/expr/func.rs | 24 ++++--------- src/expr/nodes.rs | 72 ++++++++++++++------------------------- src/internal/utils/mod.rs | 24 +++++++++++++ src/sql.rs | 32 +++++++++-------- 13 files changed, 151 insertions(+), 139 deletions(-) diff --git a/rapidquery/dialect.pyi b/rapidquery/dialect.pyi index 6a2958a..c178139 100644 --- a/rapidquery/dialect.pyi +++ b/rapidquery/dialect.pyi @@ -12,7 +12,7 @@ class SqlDialect: """ @property - def placeholder_sigil(self) -> str: ... + def positional_parameter(self) -> str: ... @property def bit_format(self) -> str | None: ... @property @@ -51,7 +51,7 @@ class GenericDialect(SqlDialect): def __new__( cls, *, - placeholder_sigil: str, + positional_parameter: str, bit_format: str | None, supports_array: bool, supports_inet: bool, diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index 5b0610b..6143dab 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -19,7 +19,7 @@ class Expr: Render this expression as raw SQL (values inlined as literals). """ - def prepare(self, dialect: SqlDialect) -> tuple[str, dict[str, typing.Any]]: + def prepare(self, dialect: SqlDialect) -> tuple[str, tuple[typing.Any]]: """ Render this expression as a parameterized statement. """ diff --git a/src/dialect/base.rs b/src/dialect/base.rs index a94e8ca..0902ab5 100644 --- a/src/dialect/base.rs +++ b/src/dialect/base.rs @@ -53,8 +53,8 @@ fn collect(write: impl FnOnce(&mut String) -> std::fmt::Result) -> String { #[pyo3::pymethods] impl PySqlDialect { #[getter] - fn get_placeholder_sigil(&self) -> char { - self.0.placeholder_sigil + fn get_positional_parameter(&self) -> String { + convert::positional_parameter_name(self.0.positional_parameter) } #[getter] diff --git a/src/dialect/builtin.rs b/src/dialect/builtin.rs index 3c1e508..c537028 100644 --- a/src/dialect/builtin.rs +++ b/src/dialect/builtin.rs @@ -35,7 +35,7 @@ impl_builtin_dialects!( /// BigQuery SQL dialect. PyBigQuery = "BigQuery" { Dialect { - placeholder_sigil: '@', + positional_parameter: PositionalParameter::Simple('?'), bit_format: None, supports_array: true, supports_inet: false, @@ -54,7 +54,7 @@ impl_builtin_dialects!( /// Databricks SQL dialect. PyDatabricks = "Databricks" { Dialect { - placeholder_sigil: ':', + positional_parameter: PositionalParameter::Simple('?'), bit_format: None, supports_array: false, supports_inet: false, @@ -73,7 +73,7 @@ impl_builtin_dialects!( /// DuckDB SQL dialect. PyDuckDb = "DuckDb" { Dialect { - placeholder_sigil: '$', + positional_parameter: PositionalParameter::Numbered('$'), bit_format: Some(BitFormat::PrefixBigB), supports_array: true, supports_inet: true, @@ -92,7 +92,7 @@ impl_builtin_dialects!( /// Hive SQL dialect. PyHive = "Hive" { Dialect { - placeholder_sigil: ':', + positional_parameter: PositionalParameter::Simple('?'), bit_format: None, supports_array: false, supports_inet: false, @@ -111,7 +111,7 @@ impl_builtin_dialects!( /// Microsoft SQL Server dialect. PyMsSql = "MsSql" { Dialect { - placeholder_sigil: '@', + positional_parameter: PositionalParameter::Simple('?'), bit_format: None, supports_array: false, supports_inet: false, @@ -130,7 +130,7 @@ impl_builtin_dialects!( /// MySQL dialect. PyMySql = "MySql" { Dialect { - placeholder_sigil: ':', + positional_parameter: PositionalParameter::Simple('?'), bit_format: Some(BitFormat::PrefixSmallB), supports_array: false, supports_inet: false, @@ -149,7 +149,7 @@ impl_builtin_dialects!( /// Oracle SQL dialect. PyOracle = "Oracle" { Dialect { - placeholder_sigil: ':', + positional_parameter: PositionalParameter::Numbered(':'), bit_format: None, supports_array: false, supports_inet: false, @@ -168,7 +168,7 @@ impl_builtin_dialects!( /// PostgreSQL dialect. PyPostgreSql = "PostgreSql" { Dialect { - placeholder_sigil: ':', + positional_parameter: PositionalParameter::Numbered('$'), bit_format: Some(BitFormat::PrefixBigB), supports_array: true, supports_inet: true, @@ -187,7 +187,7 @@ impl_builtin_dialects!( /// Amazon Redshift dialect. PyRedshift = "Redshift" { Dialect { - placeholder_sigil: ':', + positional_parameter: PositionalParameter::Numbered('$'), bit_format: None, supports_array: false, supports_inet: false, @@ -206,7 +206,7 @@ impl_builtin_dialects!( /// Snowflake SQL dialect. PySnowflake = "Snowflake" { Dialect { - placeholder_sigil: ':', + positional_parameter: PositionalParameter::Numbered(':'), bit_format: None, supports_array: false, supports_inet: false, @@ -225,7 +225,7 @@ impl_builtin_dialects!( /// Apache Spark SQL dialect. PySpark = "Spark" { Dialect { - placeholder_sigil: ':', + positional_parameter: PositionalParameter::Simple('?'), bit_format: None, supports_array: false, supports_inet: false, @@ -244,7 +244,7 @@ impl_builtin_dialects!( /// SQLite dialect. PySqlite = "Sqlite" { Dialect { - placeholder_sigil: ':', + positional_parameter: PositionalParameter::Numbered('?'), bit_format: None, supports_array: false, supports_inet: false, @@ -263,7 +263,7 @@ impl_builtin_dialects!( /// Teradata SQL dialect. PyTeradata = "Teradata" { Dialect { - placeholder_sigil: ':', + positional_parameter: PositionalParameter::Simple('?'), bit_format: Some(BitFormat::PostfixBigB), supports_array: false, supports_inet: false, diff --git a/src/dialect/convert.rs b/src/dialect/convert.rs index 47643bf..fd16c77 100644 --- a/src/dialect/convert.rs +++ b/src/dialect/convert.rs @@ -4,7 +4,7 @@ use super::inner::{ BitFormat, BoolFormat, BytesFormat, IdentifierEscape, IdentifierFormat, LikeAnySyntax, - NullTreatmentSyntax, StringEscape, StringFormat, + NullTreatmentSyntax, PositionalParameter, StringEscape, StringFormat, }; macro_rules! named { @@ -63,7 +63,6 @@ named!( IdentifierFormat, identifier_format_name, parse_identifier_format { - SingleQuote, DoubleQuote, Backtick, SquareBracket @@ -111,6 +110,24 @@ named!( } ); +pub(super) fn positional_parameter_name(pp: PositionalParameter) -> String { + match pp { + PositionalParameter::Simple(c) => c.to_string(), + PositionalParameter::Numbered(c) => format!("{c}NN"), + } +} + +/// Zero-alloc extractor: accept `"X"` or `"XNN"` and return `X` as `char`. +/// Any other shape is `None`. +#[inline] +pub(super) fn parse_positional_parameter(s: &str) -> Option { + match s.as_bytes() { + [b] => Some(PositionalParameter::Simple(*b as char)), + [b, b'N', b'N'] => Some(PositionalParameter::Numbered(*b as char)), + _ => None, + } +} + pub(super) const BIT_FORMATS: &str = "PrefixBigB, PrefixSmallB, PostfixBigB"; pub(super) const NULL_TREATMENT_SYNTAXES: &str = "InsideParens, OutsideParens"; pub(super) const LIKE_ANY_SYNTAXES: &str = "ArrayConstructor, PatternList, OrExpansion"; @@ -139,16 +156,3 @@ pub(super) fn parse_field( ), } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_is_case_insensitive_and_round_trips() { - assert_eq!(parse_bit_format("prefixbigb"), Some(BitFormat::PrefixBigB)); - assert_eq!(bit_format_name(BitFormat::PrefixBigB), "PrefixBigB"); - assert_eq!(parse_bool_format("INT"), Some(BoolFormat::Int)); - assert!(parse_bytes_format("nope").is_none()); - } -} diff --git a/src/dialect/generic.rs b/src/dialect/generic.rs index 58d1c8b..12fafd4 100644 --- a/src/dialect/generic.rs +++ b/src/dialect/generic.rs @@ -1,3 +1,5 @@ +use crate::dialect::convert::parse_positional_parameter; + use super::base::PySqlDialect; use super::convert; use super::inner::Dialect; @@ -15,7 +17,7 @@ impl PyGenericDialect { #[new] #[pyo3(signature = ( *, - placeholder_sigil, + positional_paremeter, bit_format, supports_array, supports_inet, @@ -31,7 +33,7 @@ impl PyGenericDialect { ))] #[allow(clippy::too_many_arguments)] fn __new__( - placeholder_sigil: char, + positional_paremeter: &str, bit_format: Option<&str>, supports_array: bool, supports_inet: bool, @@ -54,8 +56,18 @@ impl PyGenericDialect { convert::BIT_FORMATS, )?), }; + + let positional_parameter = + parse_positional_parameter(positional_paremeter).ok_or_else(|| { + rapidquery_macros::pyerr!( + PyValueError, + "invalid positional_parameter: {:?}. expected \"X\" or \"XNN\" formats which X is placeholder character", + positional_paremeter + ) + })?; + let inner = Dialect { - placeholder_sigil, + positional_parameter, bit_format, supports_array, supports_inet, @@ -120,7 +132,7 @@ impl PyGenericDialect { }; format!( "GenericDialect(placeholder_sigil={:?}, bit_format={bit}, supports_array={}, supports_inet={}, supports_mac_address={}, null_treatment_syntax={:?}, like_any_syntax={:?}, bool_format={:?}, identifier_format={:?}, identifier_escape={:?}, string_format={:?}, string_escape={:?}, bytes_format={:?})", - d.placeholder_sigil, + convert::positional_parameter_name(d.positional_parameter), py_bool(d.supports_array), py_bool(d.supports_inet), py_bool(d.supports_mac_address), diff --git a/src/dialect/inner.rs b/src/dialect/inner.rs index 4571eb1..1ef629a 100644 --- a/src/dialect/inner.rs +++ b/src/dialect/inner.rs @@ -2,6 +2,15 @@ use std::fmt::{self, Write}; use crate::internal::utils; +/// Positional parameter syntax +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum PositionalParameter { + /// e.g. `'?'` + Simple(char), + /// e.g. `'$'` -> `'$1'` + Numbered(char), +} + /// The way dialect accepts literal bit values #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum BitFormat { @@ -57,10 +66,6 @@ pub enum BoolFormat { /// Delimiters wrapped around a quoted identifier. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum IdentifierFormat { - /// `'name'` - /// - /// TODO: delete this - SingleQuote, /// `"name"` DoubleQuote, /// `` `name` `` @@ -200,10 +205,8 @@ impl BytesFormat { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Dialect { - /// Named-placeholder sigil used when building parameterized SQL. - /// - /// e.g. `'$'`, `'@'`, `':'` - pub placeholder_sigil: char, + /// Positional parameter syntax. + pub positional_parameter: PositionalParameter, /// How the dialect accepts literal bit values if supported. /// /// Set [`Option::None`] if literal bit isn't supported. @@ -254,7 +257,6 @@ impl Dialect { #[inline] pub fn quote_identifier(&self, writer: &mut W, ident: &str) -> fmt::Result { let (start, end) = match self.identifier_format { - IdentifierFormat::SingleQuote => ('\'', '\''), IdentifierFormat::DoubleQuote => ('"', '"'), IdentifierFormat::Backtick => ('`', '`'), IdentifierFormat::SquareBracket => ('[', ']'), @@ -263,9 +265,6 @@ impl Dialect { match self.identifier_escape { IdentifierEscape::Double => match self.identifier_format { - IdentifierFormat::SingleQuote => { - utils::replace_to(ident, '\'', "''", writer)?; - } IdentifierFormat::DoubleQuote => { utils::replace_to(ident, '"', "\"\"", writer)?; } @@ -277,9 +276,6 @@ impl Dialect { } }, IdentifierEscape::Backslash => match self.identifier_format { - IdentifierFormat::SingleQuote => { - utils::replace_to(ident, '\'', "\\'", writer)?; - } IdentifierFormat::DoubleQuote => { utils::replace_to(ident, '"', "\\\"", writer)?; } @@ -475,7 +471,7 @@ mod tests { fn base() -> Dialect { Dialect { - placeholder_sigil: ':', + positional_parameter: PositionalParameter::Simple(':'), bit_format: None, supports_array: false, supports_inet: false, diff --git a/src/expr/base.rs b/src/expr/base.rs index 2a50cb2..52ba592 100644 --- a/src/expr/base.rs +++ b/src/expr/base.rs @@ -8,7 +8,7 @@ // `between` / `in_` / `is_null` / `is_distinct_from` / `cast`; `case`; // `custom`. Prefer named methods over v0 dunders that return `Expr`. -use crate::internal::alias; +use crate::internal::{alias, utils}; use crate::sql; use super::enum_::ExprEnum; @@ -83,12 +83,19 @@ impl PyExpr { #[pyo3::pymethods] impl PyExpr { /// Render this expression as a parameterized statement. - fn prepare( - slf: pyo3::PyRef<'_, Self>, - py: pyo3::Python<'_>, + fn prepare<'py>( + slf: pyo3::PyRef<'py, Self>, + py: pyo3::Python<'py>, dialect: crate::dialect::PySqlDialect, - ) -> pyo3::PyResult<(String, sql::PrepareParams)> { - sql::prepare(py, &slf.0, &dialect).map_err(Into::into) + ) -> pyo3::PyResult<(String, alias::BoundObject<'py>)> { + let (sql, params) = sql::prepare(py, &slf.0, &dialect)?; + + let params = utils::iterator_to_tuple( + slf.py(), + params.len() as isize, + params.into_iter().map(|x| Ok(x.as_ptr())), + )?; + Ok((sql, params)) } /// Render this expression as raw SQL (values inlined as literals). diff --git a/src/expr/enum_/func.rs b/src/expr/enum_/func.rs index d19c5a3..7ce856a 100644 --- a/src/expr/enum_/func.rs +++ b/src/expr/enum_/func.rs @@ -92,7 +92,6 @@ impl WriteSql for Function { out: &mut W, ) -> PrepareResult<()> { // TODO: - // - `DuplicateTreatment::All` is not supported by SQLite/BigQuery, we should skip it // - BigQuery supports `WHERE condition` instead of `FILTER (WHERE condition)` self.name.write_sql(dialect, out)?; diff --git a/src/expr/func.rs b/src/expr/func.rs index 52afe61..098e0af 100644 --- a/src/expr/func.rs +++ b/src/expr/func.rs @@ -125,23 +125,13 @@ impl PyFunction { match &inner.args { FunctionArguments::None => Ok(utils::new_pynone(py)), FunctionArguments::List { args, .. } => { - // instead of creating a Vec and then creating a PyTuple, which is `T(n) = 2n` - // we can use unsafe tuple to create PyTuple directly, which is `T(n) = n` - let tuple = unsafe { pyo3::ffi::PyTuple_New(args.len() as isize) }; - - for (ix, arg) in args.iter().enumerate() { - match PyExpr(arg.clone()).into_subclasses(py) { - Ok(bound) => unsafe { - pyo3::ffi::PyTuple_SetItem(tuple, ix as isize, bound.into_ptr()); - }, - Err(err) => unsafe { - pyo3::ffi::Py_DECREF(tuple); - return Err(err); - }, - } - } - - Ok(unsafe { pyo3::Bound::from_owned_ptr(py, tuple) }) + let len = args.len() as isize; + utils::iterator_to_tuple( + slf.py(), + len, + args.iter() + .map(|x| PyExpr(x.clone()).into_subclasses(py).map(|x| x.as_ptr())), + ) } } } diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index 4506d8f..f0faa0a 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -345,23 +345,15 @@ impl PyInListExpr { let py = slf.py(); let inner = Self::inner(&slf); - // instead of creating a Vec and then creating a PyTuple, which is `T(n) = 2n` - // we can use unsafe tuple to create PyTuple directly, which is `T(n) = n` - let tuple = unsafe { pyo3::ffi::PyTuple_New(inner.list.len() as isize) }; - - for (ix, arg) in inner.list.iter().enumerate() { - match PyExpr(arg.clone()).into_subclasses(py) { - Ok(bound) => unsafe { - pyo3::ffi::PyTuple_SetItem(tuple, ix as isize, bound.into_ptr()); - }, - Err(err) => unsafe { - pyo3::ffi::Py_DECREF(tuple); - return Err(err); - }, - } - } - - Ok(unsafe { pyo3::Bound::from_owned_ptr(py, tuple) }) + let len = inner.list.len() as isize; + utils::iterator_to_tuple( + slf.py(), + len, + inner + .list + .iter() + .map(|x| PyExpr(x.clone()).into_subclasses(py).map(|x| x.as_ptr())), + ) } fn get_negated(slf: pyo3::PyRef<'_, Self>) -> bool { @@ -442,19 +434,13 @@ impl PyLikeExpr { match &inner.pattern { super::enum_::LikePattern::One(p) => super::base::into_subclasses(py, (**p).clone()), super::enum_::LikePattern::Any(ps) => { - let tuple = unsafe { pyo3::ffi::PyTuple_New(ps.len() as isize) }; - for (ix, arg) in ps.iter().enumerate() { - match PyExpr(arg.clone()).into_subclasses(py) { - Ok(bound) => unsafe { - pyo3::ffi::PyTuple_SetItem(tuple, ix as isize, bound.into_ptr()); - }, - Err(err) => unsafe { - pyo3::ffi::Py_DECREF(tuple); - return Err(err); - }, - } - } - Ok(unsafe { pyo3::Bound::from_owned_ptr(py, tuple) }) + let len = ps.len() as isize; + utils::iterator_to_tuple( + slf.py(), + len, + ps.iter() + .map(|x| PyExpr(x.clone()).into_subclasses(py).map(|x| x.as_ptr())), + ) } } } @@ -832,22 +818,14 @@ impl PyCustomExpr { let py = slf.py(); let inner = Self::inner(&slf); - // instead of creating a Vec and then creating a PyTuple, which is `T(n) = 2n` - // we can use unsafe tuple to create PyTuple directly, which is `T(n) = n` - let tuple = unsafe { pyo3::ffi::PyTuple_New(inner.args.len() as isize) }; - - for (ix, arg) in inner.args.iter().enumerate() { - match PyExpr(arg.clone()).into_subclasses(py) { - Ok(bound) => unsafe { - pyo3::ffi::PyTuple_SetItem(tuple, ix as isize, bound.into_ptr()); - }, - Err(err) => unsafe { - pyo3::ffi::Py_DECREF(tuple); - return Err(err); - }, - } - } - - Ok(unsafe { pyo3::Bound::from_owned_ptr(py, tuple) }) + let len = inner.args.len() as isize; + utils::iterator_to_tuple( + slf.py(), + len, + inner + .args + .iter() + .map(|x| PyExpr(x.clone()).into_subclasses(py).map(|x| x.as_ptr())), + ) } } diff --git a/src/internal/utils/mod.rs b/src/internal/utils/mod.rs index ed63642..957b224 100644 --- a/src/internal/utils/mod.rs +++ b/src/internal/utils/mod.rs @@ -60,3 +60,27 @@ pub unsafe fn get_type_name<'a>(py: pyo3::Python<'a>, obj: *mut pyo3::ffi::PyObj pub fn new_pynone<'py>(py: pyo3::Python<'py>) -> pyo3::Bound<'py, pyo3::PyAny> { unsafe { pyo3::Bound::from_borrowed_ptr(py, pyo3::ffi::Py_None()) } } + +/// Converts smallvec into PyTuple using pyo3 FFI +#[inline] +pub fn iterator_to_tuple<'py, I: Iterator>>( + py: pyo3::Python<'py>, + len: isize, + iter: I, +) -> pyo3::PyResult> { + let tuple = unsafe { pyo3::ffi::PyTuple_New(len) }; + + for (ix, item) in iter.enumerate() { + match item { + Ok(ptr) => unsafe { + pyo3::ffi::PyTuple_SetItem(tuple, ix as isize, ptr); + }, + Err(err) => unsafe { + pyo3::ffi::Py_DECREF(tuple); + return Err(err); + }, + } + } + + Ok(unsafe { pyo3::Bound::from_owned_ptr(py, tuple) }) +} diff --git a/src/sql.rs b/src/sql.rs index c2e871c..7ac6e1e 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -5,11 +5,11 @@ //! [`SqlWriter::push_value`]: raw inlines a literal, prepared emits a //! placeholder. -use hashbrown::HashMap; use pyo3::types::PyAnyMethods; use crate::data_type::SerializerEnum; use crate::dialect::PySqlDialect; +use crate::dialect::inner::PositionalParameter; use crate::internal::alias; /// Error produced while building SQL. @@ -77,7 +77,7 @@ impl From for pyo3::PyErr { } pub type PrepareResult = Result; -pub type PrepareParams = HashMap; +pub type PrepareParams = smallvec::SmallVec<[alias::PyObject; 1]>; /// Output target for [`WriteSql`]. /// @@ -197,8 +197,8 @@ impl<'py> ParameterizedSql<'py> { Self { py, sql: String::new(), - params: HashMap::new(), - next_id: 0, + params: PrepareParams::new(), + next_id: 1, } } @@ -207,8 +207,8 @@ impl<'py> ParameterizedSql<'py> { Self { py, sql: String::with_capacity(capacity), - params: HashMap::new(), - next_id: 0, + params: PrepareParams::new(), + next_id: 1, } } @@ -237,13 +237,18 @@ impl ParameterizedSql<'_> { /// Register `param` and append its placeholder to the SQL in one step. #[inline] pub fn push_param(&mut self, dialect: &PySqlDialect, param: alias::PyObject) -> &mut Self { - // allocate param name - let name = format!("param_{}", self.next_id); - self.next_id += 1; + match dialect.0.positional_parameter { + PositionalParameter::Simple(c) => { + self.sql.push(c); + } + PositionalParameter::Numbered(c) => { + let p = format!("{}{}", c, self.next_id); + self.sql.push_str(p.as_str()); + self.next_id += 1; + } + } - self.sql.push(dialect.0.placeholder_sigil); - self.sql.push_str(&name); - self.params.insert(name, param); + self.params.push(param); self } } @@ -268,9 +273,6 @@ impl SqlWriter for ParameterizedSql<'_> { _serializer: &SerializerEnum, object: &alias::PyObject, ) -> PrepareResult<()> { - // TODO: use positional parameters instead of named parameters - // some of dialects and their native clients does not support - // named parameters. self.push_param(dialect, object.clone_ref(self.py)); Ok(()) } From 72d3cfc16f445ffb7cdf3178cf97302de82a8acd Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Thu, 24 Sep 2026 16:19:59 +0330 Subject: [PATCH 45/87] Temporary remove GenericDialect, Fix FILTER WHERE conditions for Function --- rapidquery/dialect.py | 2 - rapidquery/dialect.pyi | 20 ------ rapidquery/expr.pyi | 18 ++--- src/dialect/base.rs | 6 +- src/dialect/builtin.rs | 39 +++++++---- src/dialect/convert.rs | 2 +- src/dialect/generic.rs | 154 ---------------------------------------- src/dialect/inner.rs | 24 +++++-- src/dialect/mod.rs | 8 +-- src/expr/enum_/func.rs | 156 ++++++++++++++++++++++++----------------- src/expr/func.rs | 59 ++++++---------- src/sql.rs | 15 +++- 12 files changed, 183 insertions(+), 320 deletions(-) delete mode 100644 src/dialect/generic.rs diff --git a/rapidquery/dialect.py b/rapidquery/dialect.py index 1a9f3cb..18ad990 100644 --- a/rapidquery/dialect.py +++ b/rapidquery/dialect.py @@ -6,7 +6,6 @@ BigQuery, Databricks, DuckDb, - GenericDialect, Hive, MsSql, MySql, @@ -24,7 +23,6 @@ "BigQuery", "Databricks", "DuckDb", - "GenericDialect", "Hive", "MsSql", "MySql", diff --git a/rapidquery/dialect.pyi b/rapidquery/dialect.pyi index c178139..ad24332 100644 --- a/rapidquery/dialect.pyi +++ b/rapidquery/dialect.pyi @@ -46,26 +46,6 @@ class SqlDialect: def __eq__(self, other: object) -> bool: ... def __hash__(self) -> int: ... -@final -class GenericDialect(SqlDialect): - def __new__( - cls, - *, - positional_parameter: str, - bit_format: str | None, - supports_array: bool, - supports_inet: bool, - supports_mac_address: bool, - null_treatment_syntax: str, - like_any_syntax: str, - bool_format: str, - identifier_format: str, - identifier_escape: str, - string_format: str, - string_escape: str, - bytes_format: str, - ) -> Self: ... - @final class BigQuery(SqlDialect): def __new__(cls) -> Self: ... diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index 6143dab..14636e5 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -218,7 +218,9 @@ class Function(Expr): Each method returns a new frozen instance. """ - def __new__(cls, name: str | Ident | Unquote, /) -> Self: + def __new__( + cls, name: str | Ident | Unquote, /, *args: object, distinct: bool = False + ) -> Self: """ Create a function name with no argument list. @@ -233,18 +235,6 @@ class Function(Expr): ValueError: If ``name`` is an empty or invalid identifier. """ - def call(self, *args: object, distinct: bool = False) -> Self: - """ - Attach an argument list (including empty parentheses). - - Args: - *args: Positional argument expressions. - distinct: ``True`` inserts ``DISTINCT``. - - Returns: - A new ``Function`` with this argument list. - """ - def filter(self, predicate: Expr, /) -> Self: """ Attach a ``FILTER (WHERE …)`` predicate. @@ -291,7 +281,7 @@ class Function(Expr): """ def get_distinct(self) -> bool: ... - def get_args(self) -> tuple[Expr, ...] | None: + def get_args(self) -> tuple[Expr, ...]: """ Return the positional arguments. diff --git a/src/dialect/base.rs b/src/dialect/base.rs index 0902ab5..e980ec6 100644 --- a/src/dialect/base.rs +++ b/src/dialect/base.rs @@ -78,8 +78,10 @@ impl PySqlDialect { } #[getter] - fn get_null_treatment_syntax(&self) -> &'static str { - convert::null_treatment_syntax_name(self.0.null_treatment_syntax) + fn get_null_treatment_syntax(&self) -> Option<&'static str> { + self.0 + .null_treatment_syntax + .map(convert::null_treatment_syntax_name) } #[getter] diff --git a/src/dialect/builtin.rs b/src/dialect/builtin.rs index c537028..385832e 100644 --- a/src/dialect/builtin.rs +++ b/src/dialect/builtin.rs @@ -40,7 +40,7 @@ impl_builtin_dialects!( supports_array: true, supports_inet: false, supports_mac_address: false, - null_treatment_syntax: NullTreatmentSyntax::InsideParens, + null_treatment_syntax: Some(NullTreatmentSyntax::InsideParens), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::Backtick, @@ -48,6 +48,7 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashHex, bytes_format: BytesFormat::PrefixBigBSlashX, + function_filter_syntax: Some(FunctionFilterSyntax::InsideWhere), } } @@ -59,7 +60,7 @@ impl_builtin_dialects!( supports_array: false, supports_inet: false, supports_mac_address: false, - null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::PatternList, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::Backtick, @@ -67,6 +68,7 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashUnicodeQuote, bytes_format: BytesFormat::PrefixBigX, + function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), } } @@ -78,7 +80,7 @@ impl_builtin_dialects!( supports_array: true, supports_inet: true, supports_mac_address: false, - null_treatment_syntax: NullTreatmentSyntax::InsideParens, + null_treatment_syntax: Some(NullTreatmentSyntax::InsideParens), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::DoubleQuote, @@ -86,6 +88,7 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::PrefixBigX, + function_filter_syntax: Some(FunctionFilterSyntax::Filter), } } @@ -97,7 +100,7 @@ impl_builtin_dialects!( supports_array: false, supports_inet: false, supports_mac_address: false, - null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::Backtick, @@ -105,6 +108,7 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashUnicode, bytes_format: BytesFormat::Unhex, + function_filter_syntax: None, } } @@ -116,7 +120,7 @@ impl_builtin_dialects!( supports_array: false, supports_inet: false, supports_mac_address: false, - null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, identifier_format: IdentifierFormat::SquareBracket, @@ -124,6 +128,7 @@ impl_builtin_dialects!( string_format: StringFormat::PrefixBigN, string_escape: StringEscape::QuoteStripNul, bytes_format: BytesFormat::PrefixZeroX, + function_filter_syntax: None, } } @@ -135,7 +140,7 @@ impl_builtin_dialects!( supports_array: false, supports_inet: false, supports_mac_address: false, - null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, identifier_format: IdentifierFormat::Backtick, @@ -143,6 +148,7 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashMysql, bytes_format: BytesFormat::PrefixBigX, + function_filter_syntax: None, } } @@ -154,7 +160,7 @@ impl_builtin_dialects!( supports_array: false, supports_inet: false, supports_mac_address: false, - null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, identifier_format: IdentifierFormat::DoubleQuote, @@ -162,6 +168,7 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::QuoteStripNul, bytes_format: BytesFormat::HexToRaw, + function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), } } @@ -173,7 +180,7 @@ impl_builtin_dialects!( supports_array: true, supports_inet: true, supports_mac_address: true, - null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::ArrayConstructor, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::DoubleQuote, @@ -181,6 +188,7 @@ impl_builtin_dialects!( string_format: StringFormat::PrefixBigE, string_escape: StringEscape::BackslashHex, bytes_format: BytesFormat::SlashXBytea, + function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), } } @@ -192,7 +200,7 @@ impl_builtin_dialects!( supports_array: false, supports_inet: false, supports_mac_address: false, - null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::DoubleQuote, @@ -200,6 +208,7 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::QuoteStripNul, bytes_format: BytesFormat::ToVarbyte, + function_filter_syntax: None, } } @@ -211,7 +220,7 @@ impl_builtin_dialects!( supports_array: false, supports_inet: false, supports_mac_address: false, - null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::PatternList, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::DoubleQuote, @@ -219,6 +228,7 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashHexZero, bytes_format: BytesFormat::PrefixBigX, + function_filter_syntax: None, } } @@ -230,7 +240,7 @@ impl_builtin_dialects!( supports_array: false, supports_inet: false, supports_mac_address: false, - null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::PatternList, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::Backtick, @@ -238,6 +248,7 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashUnicodeQuote, bytes_format: BytesFormat::PrefixBigX, + function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), } } @@ -249,7 +260,7 @@ impl_builtin_dialects!( supports_array: false, supports_inet: false, supports_mac_address: false, - null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, identifier_format: IdentifierFormat::DoubleQuote, @@ -257,6 +268,7 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::QuoteStripNul, bytes_format: BytesFormat::PrefixBigX, + function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), } } @@ -268,7 +280,7 @@ impl_builtin_dialects!( supports_array: false, supports_inet: false, supports_mac_address: false, - null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + null_treatment_syntax: Some(NullTreatmentSyntax::InsideParens), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::DoubleQuote, @@ -276,6 +288,7 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::QuoteStripNul, bytes_format: BytesFormat::PostfixXbv, + function_filter_syntax: None, } } ); diff --git a/src/dialect/convert.rs b/src/dialect/convert.rs index fd16c77..900a5a4 100644 --- a/src/dialect/convert.rs +++ b/src/dialect/convert.rs @@ -42,7 +42,7 @@ named!( null_treatment_syntax_name, parse_null_treatment_syntax { InsideParens, - OutsideParens + Postfix } ); named!( diff --git a/src/dialect/generic.rs b/src/dialect/generic.rs deleted file mode 100644 index 12fafd4..0000000 --- a/src/dialect/generic.rs +++ /dev/null @@ -1,154 +0,0 @@ -use crate::dialect::convert::parse_positional_parameter; - -use super::base::PySqlDialect; -use super::convert; -use super::inner::Dialect; - -/// Frozen SQL dialect whose quoting and literal rules are set at construction. -#[rapidquery_macros::pyclass( - "GenericDialect", - extends = PySqlDialect, - module = "rapidquery._core.dialect" -)] -pub struct PyGenericDialect; - -#[pyo3::pymethods] -impl PyGenericDialect { - #[new] - #[pyo3(signature = ( - *, - positional_paremeter, - bit_format, - supports_array, - supports_inet, - supports_mac_address, - null_treatment_syntax, - like_any_syntax, - bool_format, - identifier_format, - identifier_escape, - string_format, - string_escape, - bytes_format, - ))] - #[allow(clippy::too_many_arguments)] - fn __new__( - positional_paremeter: &str, - bit_format: Option<&str>, - supports_array: bool, - supports_inet: bool, - supports_mac_address: bool, - null_treatment_syntax: &str, - like_any_syntax: &str, - bool_format: &str, - identifier_format: &str, - identifier_escape: &str, - string_format: &str, - string_escape: &str, - bytes_format: &str, - ) -> pyo3::PyResult> { - let bit_format = match bit_format { - None => None, - Some(s) => Some(convert::parse_field( - "bit_format", - s, - convert::parse_bit_format, - convert::BIT_FORMATS, - )?), - }; - - let positional_parameter = - parse_positional_parameter(positional_paremeter).ok_or_else(|| { - rapidquery_macros::pyerr!( - PyValueError, - "invalid positional_parameter: {:?}. expected \"X\" or \"XNN\" formats which X is placeholder character", - positional_paremeter - ) - })?; - - let inner = Dialect { - positional_parameter, - bit_format, - supports_array, - supports_inet, - supports_mac_address, - null_treatment_syntax: convert::parse_field( - "null_treatment_syntax", - null_treatment_syntax, - convert::parse_null_treatment_syntax, - convert::NULL_TREATMENT_SYNTAXES, - )?, - like_any_syntax: convert::parse_field( - "like_any_syntax", - like_any_syntax, - convert::parse_like_any_syntax, - convert::LIKE_ANY_SYNTAXES, - )?, - bool_format: convert::parse_field( - "bool_format", - bool_format, - convert::parse_bool_format, - convert::BOOL_FORMATS, - )?, - identifier_format: convert::parse_field( - "identifier_format", - identifier_format, - convert::parse_identifier_format, - convert::IDENTIFIER_FORMATS, - )?, - identifier_escape: convert::parse_field( - "identifier_escape", - identifier_escape, - convert::parse_identifier_escape, - convert::IDENTIFIER_ESCAPES, - )?, - string_format: convert::parse_field( - "string_format", - string_format, - convert::parse_string_format, - convert::STRING_FORMATS, - )?, - string_escape: convert::parse_field( - "string_escape", - string_escape, - convert::parse_string_escape, - convert::STRING_ESCAPES, - )?, - bytes_format: convert::parse_field( - "bytes_format", - bytes_format, - convert::parse_bytes_format, - convert::BYTES_FORMATS, - )?, - }; - Ok(pyo3::PyClassInitializer::from(PySqlDialect(inner)).add_subclass(Self)) - } - - fn __repr__(slf: pyo3::PyRef<'_, Self>) -> String { - let d = &slf.as_super().0; - let bit = match d.bit_format { - None => String::from("None"), - Some(fmt) => format!("{:?}", convert::bit_format_name(fmt)), - }; - format!( - "GenericDialect(placeholder_sigil={:?}, bit_format={bit}, supports_array={}, supports_inet={}, supports_mac_address={}, null_treatment_syntax={:?}, like_any_syntax={:?}, bool_format={:?}, identifier_format={:?}, identifier_escape={:?}, string_format={:?}, string_escape={:?}, bytes_format={:?})", - convert::positional_parameter_name(d.positional_parameter), - py_bool(d.supports_array), - py_bool(d.supports_inet), - py_bool(d.supports_mac_address), - convert::null_treatment_syntax_name(d.null_treatment_syntax), - convert::like_any_syntax_name(d.like_any_syntax), - convert::bool_format_name(d.bool_format), - convert::identifier_format_name(d.identifier_format), - convert::identifier_escape_name(d.identifier_escape), - convert::string_format_name(d.string_format), - convert::string_escape_name(d.string_escape), - convert::bytes_format_name(d.bytes_format), - ) - } -} - -#[inline] -fn py_bool(v: bool) -> &'static str { - if v { "True" } else { "False" } -} diff --git a/src/dialect/inner.rs b/src/dialect/inner.rs index 1ef629a..278da11 100644 --- a/src/dialect/inner.rs +++ b/src/dialect/inner.rs @@ -40,7 +40,7 @@ pub enum NullTreatmentSyntax { /// e.g. `FIRST_VALUE(x IGNORE NULLS)` InsideParens, /// e.g. `FIRST_VALUE(x) IGNORE NULLS` - OutsideParens, + Postfix, } /// How `LIKE ANY` / `ILIKE ANY` is rendered. @@ -203,6 +203,17 @@ impl BytesFormat { } } +/// Function `FILTER` syntax +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FunctionFilterSyntax { + /// e.g. FIRST_VALUE(x) FILTER (id > 10) + Filter, + /// e.g. FIRST_VALUE(x) FILTER (WHERE id > 10) + FilterWhere, + /// e.g. FIRST_VALUE(x WHERE id > 10) + InsideWhere, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Dialect { /// Positional parameter syntax. @@ -217,15 +228,15 @@ pub struct Dialect { pub supports_inet: bool, /// Whether the dialect supports `MACADDR` data type. pub supports_mac_address: bool, - /// How the dialect supports `{IGNORE|RESPECT} NULLS` expressions in `Function`s. - pub null_treatment_syntax: NullTreatmentSyntax, + /// How the dialect supports `{IGNORE|RESPECT} NULLS` expressions in `Function`s if supported. + pub null_treatment_syntax: Option, /// How `LIKE ANY` / `ILIKE ANY` is rendered. pub like_any_syntax: LikeAnySyntax, /// How the dialect accepts literal boolean values. pub bool_format: BoolFormat, /// Specifies how delimiters should wrap around a quoted identifier. pub identifier_format: IdentifierFormat, - /// Specifies how to escape identifiers + /// Specifies how to escape identifiers. pub identifier_escape: IdentifierEscape, /// Specifies how delimiters should wrap around a literal string value. pub string_format: StringFormat, @@ -233,6 +244,8 @@ pub struct Dialect { pub string_escape: StringEscape, /// How the dialect accepts literal bytes values. pub bytes_format: BytesFormat, + /// How function calling `FILTER` keyword rendered if supported. + pub function_filter_syntax: Option, } impl Dialect { @@ -476,7 +489,7 @@ mod tests { supports_array: false, supports_inet: false, supports_mac_address: false, - null_treatment_syntax: NullTreatmentSyntax::OutsideParens, + null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::DoubleQuote, @@ -484,6 +497,7 @@ mod tests { string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::PrefixBigX, + function_filter_syntax: None, } } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 3cfd4c0..4af0fd5 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1,21 +1,21 @@ mod base; mod builtin; mod convert; -mod generic; pub mod inner; +// TODO: rewrite GenericDialect again + pub use base::PySqlDialect; pub use builtin::{ PyBigQuery, PyDatabricks, PyDuckDb, PyHive, PyMsSql, PyMySql, PyOracle, PyPostgreSql, PyRedshift, PySnowflake, PySpark, PySqlite, PyTeradata, }; -pub use generic::PyGenericDialect; #[pyo3::pymodule(name = "dialect", gil_used = false)] pub mod dialect_mod { #[pymodule_export] use super::{ - PyBigQuery, PyDatabricks, PyDuckDb, PyGenericDialect, PyHive, PyMsSql, PyMySql, PyOracle, - PyPostgreSql, PyRedshift, PySnowflake, PySpark, PySqlDialect, PySqlite, PyTeradata, + PyBigQuery, PyDatabricks, PyDuckDb, PyHive, PyMsSql, PyMySql, PyOracle, PyPostgreSql, + PyRedshift, PySnowflake, PySpark, PySqlDialect, PySqlite, PyTeradata, }; } diff --git a/src/expr/enum_/func.rs b/src/expr/enum_/func.rs index 7ce856a..628b0c0 100644 --- a/src/expr/enum_/func.rs +++ b/src/expr/enum_/func.rs @@ -1,7 +1,8 @@ +use super::ExprEnum; use super::ident::Ident; -use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; -use super::ExprEnum; +use crate::dialect::inner::{FunctionFilterSyntax, NullTreatmentSyntax}; +use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; /// How `NULL`s are treated in some window/aggregate functions. /// @@ -24,22 +25,15 @@ impl NullTreatment { } } -#[derive(Clone, Debug, PartialEq)] -pub enum FunctionArguments { - /// Used for special functions like `CURRENT_TIMESTAMP` that are invoked - /// without parentheses. - None, - /// A normal function argument list, including `DISTINCT`. - List { distinct: bool, args: Vec }, -} - #[derive(Clone, Debug, PartialEq)] pub struct Function { /// The function name pub name: Ident, + /// `DISTINCT` keyword + pub distinct: bool, /// The arguments to the function, including any options specified within the /// delimiting parentheses. - pub args: FunctionArguments, + pub args: Vec, /// e.g. `x > 5` in `COUNT(x) FILTER (WHERE x > 5)` pub filter: Option>, /// `IGNORE NULLS` / `RESPECT NULLS`. Inside the parens on BigQuery @@ -50,27 +44,21 @@ pub struct Function { impl Function { pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = format!("Function({})", self.name.python_repr()); - match &self.args { - FunctionArguments::None => (), - FunctionArguments::List { distinct, args } => { - buf.push_str(".call("); - for (i, arg) in args.iter().enumerate() { - if i > 0 { - buf.push_str(", "); - } - buf.push_str(&arg.python_repr(py)?); - } + let mut buf = format!("Function({}", self.name.python_repr()); - if *distinct { - if !args.is_empty() { - buf.push_str(", "); - } - buf.push_str("distinct=True"); - } - buf.push(')'); + for arg in self.args.iter() { + buf.push_str(", "); + buf.push_str(&arg.python_repr(py)?); + } + + if self.distinct { + if !self.args.is_empty() { + buf.push_str(", "); } + buf.push_str("distinct=True"); } + buf.push(')'); + match self.null_treatment { Some(NullTreatment::IgnoreNulls) => buf.push_str(".ignore_nulls()"), Some(NullTreatment::RespectNulls) => buf.push_str(".respect_nulls()"), @@ -91,50 +79,86 @@ impl WriteSql for Function { dialect: &crate::dialect::PySqlDialect, out: &mut W, ) -> PrepareResult<()> { - // TODO: - // - BigQuery supports `WHERE condition` instead of `FILTER (WHERE condition)` - + // "name"([DISTINCT] args [IGNORE|RESPECT NULLS] [WHERE expr] ) [IGNORE|RESPECT NULLS] [FILTER ([WHERE] expr)] self.name.write_sql(dialect, out)?; - let nt_inside = matches!( - dialect.0.null_treatment_syntax, - crate::dialect::inner::NullTreatmentSyntax::InsideParens - ); - - match &self.args { - FunctionArguments::None => { - // No parentheses to put the clause in; always postfix. - if let Some(nt) = self.null_treatment { - out.push_char(' '); - out.push_str(nt.as_sql()); - } - } - FunctionArguments::List { distinct, args } => { - out.push_char('('); - if *distinct && !args.is_empty() { - out.push_str("DISTINCT "); - } + let args_available = !self.args.is_empty(); - write_separated(dialect, out, args, ", ")?; - if nt_inside && let Some(nt) = self.null_treatment { - if !args.is_empty() { - out.push_char(' '); - } - out.push_str(nt.as_sql()); - } + out.push_char('('); + out.push_str_if(self.distinct && args_available, "DISTINCT "); + + write_separated(dialect, out, &self.args, ", ")?; + + if self.null_treatment.is_some() && dialect.0.null_treatment_syntax.is_none() { + return Err(rapidquery_macros::pyerr!( + PyRuntimeError, + "null_treatment is not supported by this dialect", + ) + .into()); + } + if self.filter.is_some() && dialect.0.function_filter_syntax.is_none() { + return Err(rapidquery_macros::pyerr!( + PyRuntimeError, + "function filter is not supported by this dialect", + ) + .into()); + } + + // Inside parens IGNORE/RESPECT NULLS + if let Some(x) = self.null_treatment + && matches!( + dialect.0.null_treatment_syntax, + Some(NullTreatmentSyntax::InsideParens) + ) + { + out.push_char_if(args_available, ' '); + out.push_str(x.as_sql()); + } + + // Inside parens WHERE filter + if let Some(x) = &self.filter + && matches!( + dialect.0.function_filter_syntax, + Some(FunctionFilterSyntax::InsideWhere) + ) + { + out.push_char_if(args_available, ' '); + out.push_str("WHERE "); + x.write_sql(dialect, out)?; + } - out.push_char(')'); - if !nt_inside && let Some(nt) = self.null_treatment { + out.push_char(')'); + + // Postfix IGNORE/RESPECT NULLS + if let Some(x) = self.null_treatment + && matches!( + dialect.0.null_treatment_syntax, + Some(NullTreatmentSyntax::Postfix) + ) + { + out.push_char(' '); + out.push_str(x.as_sql()); + } + + // Postfix WHERE filter + if let Some(x) = &self.filter { + match dialect.0.function_filter_syntax { + Some(FunctionFilterSyntax::FilterWhere) => { + out.push_char(' '); + out.push_str("FILTER (WHERE "); + x.write_sql(dialect, out)?; + out.push_char(')'); + } + Some(FunctionFilterSyntax::Filter) => { out.push_char(' '); - out.push_str(nt.as_sql()); + out.push_str("FILTER ("); + x.write_sql(dialect, out)?; + out.push_char(')'); } + _ => {} } } - if let Some(filter) = &self.filter { - out.push_str(" FILTER (WHERE "); - filter.write_sql(dialect, out)?; - out.push_char(')'); - } + Ok(()) } } diff --git a/src/expr/func.rs b/src/expr/func.rs index 098e0af..d4f72af 100644 --- a/src/expr/func.rs +++ b/src/expr/func.rs @@ -3,7 +3,7 @@ use pyo3::types::{PyAnyMethods, PyTupleMethods}; use crate::internal::{alias, utils}; use super::base::PyExpr; -use super::enum_::{FunctionArguments, NullTreatment}; +use super::enum_::NullTreatment; use super::extractors::{ConditionExtractor, IdentExtractor, LiteralExprExtractor}; /// SQL function call. @@ -54,33 +54,25 @@ impl PyFunction { #[pyo3::pymethods] impl PyFunction { #[new] - #[pyo3(signature = (name, /))] - fn __new__(name: IdentExtractor) -> pyo3::PyResult> { - let inner = super::enum_::Function { - name: name.0, - args: FunctionArguments::None, - filter: None, - null_treatment: None, - }; - Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) - } - - #[pyo3(signature = (*args, distinct = false))] - fn call( - slf: pyo3::PyRef<'_, Self>, + #[pyo3(signature = (name, /, *args, distinct = false))] + fn __new__( + name: IdentExtractor, args: alias::ArgsType<'_>, distinct: bool, - ) -> pyo3::PyResult { - let mut inner = Self::inner(&slf).clone(); + ) -> pyo3::PyResult> { let mut extracted = Vec::with_capacity(args.len()); for item in args.iter() { extracted.push(item.extract::()?.0); } - inner.args = FunctionArguments::List { - distinct, + + let inner = super::enum_::Function { + name: name.0, args: extracted, + distinct, + filter: None, + null_treatment: None, }; - Self::wrap(slf.py(), inner) + Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) } fn filter( @@ -111,29 +103,22 @@ impl PyFunction { fn get_distinct(slf: pyo3::PyRef<'_, Self>) -> bool { let inner = Self::inner(&slf); - - match inner.args { - FunctionArguments::None => false, - FunctionArguments::List { distinct, .. } => distinct, - } + inner.distinct } fn get_args(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { let py = slf.py(); let inner = Self::inner(&slf); - match &inner.args { - FunctionArguments::None => Ok(utils::new_pynone(py)), - FunctionArguments::List { args, .. } => { - let len = args.len() as isize; - utils::iterator_to_tuple( - slf.py(), - len, - args.iter() - .map(|x| PyExpr(x.clone()).into_subclasses(py).map(|x| x.as_ptr())), - ) - } - } + let len = inner.args.len() as isize; + utils::iterator_to_tuple( + slf.py(), + len, + inner + .args + .iter() + .map(|x| PyExpr(x.clone()).into_subclasses(py).map(|x| x.as_ptr())), + ) } fn get_filter(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { diff --git a/src/sql.rs b/src/sql.rs index 7ac6e1e..dc6cc4f 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -85,8 +85,19 @@ pub type PrepareParams = smallvec::SmallVec<[alias::PyObject; 1]>; /// named placeholders. Everything else is `push_str` / `push_char`. pub trait SqlWriter: std::fmt::Write { fn push_str(&mut self, s: &str) -> &mut Self; + + #[inline] + fn push_str_if(&mut self, cond: bool, s: &str) -> &mut Self { + if cond { self.push_str(s) } else { self } + } + fn push_char(&mut self, c: char) -> &mut Self; + #[inline] + fn push_char_if(&mut self, cond: bool, c: char) -> &mut Self { + if cond { self.push_char(c) } else { self } + } + /// Raw (`SqlBuf`): `None` → `NULL`, else `serializer.write_sql` into the /// SQL buffer. Prepared: bind `object` as a placeholder; `serializer` is /// unused. @@ -311,7 +322,7 @@ pub fn write_sql<'py, T: WriteSql>( stmt: &T, dialect: &PySqlDialect, ) -> PrepareResult { - let mut buf = BufferedSql::new(py); + let mut buf = BufferedSql::with_capacity(py, 256); stmt.write_sql(dialect, &mut buf)?; Ok(buf.into_string()) } @@ -322,7 +333,7 @@ pub fn prepare<'py, T: WriteSql>( stmt: &T, dialect: &PySqlDialect, ) -> PrepareResult<(String, PrepareParams)> { - let mut context = ParameterizedSql::new(py); + let mut context = ParameterizedSql::with_capacity(py, 256); stmt.write_sql(dialect, &mut context)?; Ok(context.into_parts()) } From e148bc9733b0b0a681b3b4a3cd8a077dc9070113 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Fri, 25 Sep 2026 16:13:15 +0330 Subject: [PATCH 46/87] Rename ExprEnum to ExprInner and split nodes from it --- AGENTS.md | 2 +- rapidquery/dialect.pyi | 33 -- src/dialect/base.rs | 104 ---- src/dialect/convert.rs | 158 ------ src/dialect/mod.rs | 1 - src/expr/base.rs | 46 +- src/expr/enum_/func.rs | 6 +- src/expr/enum_/mod.rs | 1071 ++++-------------------------------- src/expr/enum_/nodes.rs | 873 +++++++++++++++++++++++++++++ src/expr/enum_/operator.rs | 57 ++ src/expr/extractors.rs | 32 +- src/expr/func.rs | 8 +- src/expr/ident.rs | 8 +- src/expr/nodes.rs | 21 +- src/expr/value.rs | 16 +- 15 files changed, 1118 insertions(+), 1318 deletions(-) delete mode 100644 src/dialect/convert.rs create mode 100644 src/expr/enum_/nodes.rs diff --git a/AGENTS.md b/AGENTS.md index 8e9cfef..616a5ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Keep these `refactor/v1` divergences from the PR (do not revert them): ``` src/dialect/ SqlDialect enum + engines + tests; py.rs class tree src/data_type/ DataType, interned names, Serializer enum; py.rs class tree -src/expr/ ExprEnum + PyExpr ABC; ident.rs / value.rs / func / nodes +src/expr/ ExprInner + PyExpr ABC; ident.rs / value.rs / func / nodes src/sql.rs BufferedSql, ParameterizedSql, WriteSql src/internal/ aliases, JSON dumps helper, string utils, submodule helper src/typeref.rs cached PyTypeObject pointers diff --git a/rapidquery/dialect.pyi b/rapidquery/dialect.pyi index ad24332..4bcfb67 100644 --- a/rapidquery/dialect.pyi +++ b/rapidquery/dialect.pyi @@ -10,39 +10,6 @@ class SqlDialect: Instances of the same dialect compare equal and hash together. """ - - @property - def positional_parameter(self) -> str: ... - @property - def bit_format(self) -> str | None: ... - @property - def supports_array(self) -> bool: ... - @property - def supports_inet(self) -> bool: ... - @property - def supports_mac_address(self) -> bool: ... - @property - def null_treatment_syntax(self) -> str: ... - @property - def like_any_syntax(self) -> str: ... - @property - def bool_format(self) -> str: ... - @property - def identifier_format(self) -> str: ... - @property - def identifier_escape(self) -> str: ... - @property - def string_format(self) -> str: ... - @property - def string_escape(self) -> str: ... - @property - def bytes_format(self) -> str: ... - def format_boolean(self, value: bool) -> str: ... - def escape_string(self, string: str) -> str: ... - def quote_identifier(self, ident: str) -> str: ... - def write_string_literal(self, raw: str) -> str: ... - def format_bits(self, bits: str) -> str: ... - def format_bytes(self, data: bytes) -> str: ... def __eq__(self, other: object) -> bool: ... def __hash__(self) -> int: ... diff --git a/src/dialect/base.rs b/src/dialect/base.rs index e980ec6..9a4c45d 100644 --- a/src/dialect/base.rs +++ b/src/dialect/base.rs @@ -2,7 +2,6 @@ use std::hash::{Hash, Hasher}; use crate::internal::utils; -use super::convert; use super::inner::Dialect; /// Abstract SQL dialect. @@ -44,111 +43,8 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PySqlDialect { } } -fn collect(write: impl FnOnce(&mut String) -> std::fmt::Result) -> String { - let mut buf = String::new(); - let _ = write(&mut buf); - buf -} - #[pyo3::pymethods] impl PySqlDialect { - #[getter] - fn get_positional_parameter(&self) -> String { - convert::positional_parameter_name(self.0.positional_parameter) - } - - #[getter] - fn get_bit_format(&self) -> Option<&'static str> { - self.0.bit_format.map(convert::bit_format_name) - } - - #[getter] - fn get_supports_array(&self) -> bool { - self.0.supports_array - } - - #[getter] - fn get_supports_inet(&self) -> bool { - self.0.supports_inet - } - - #[getter] - fn get_supports_mac_address(&self) -> bool { - self.0.supports_mac_address - } - - #[getter] - fn get_null_treatment_syntax(&self) -> Option<&'static str> { - self.0 - .null_treatment_syntax - .map(convert::null_treatment_syntax_name) - } - - #[getter] - fn get_like_any_syntax(&self) -> &'static str { - convert::like_any_syntax_name(self.0.like_any_syntax) - } - - #[getter] - fn get_bool_format(&self) -> &'static str { - convert::bool_format_name(self.0.bool_format) - } - - #[getter] - fn get_identifier_format(&self) -> &'static str { - convert::identifier_format_name(self.0.identifier_format) - } - - #[getter] - fn get_identifier_escape(&self) -> &'static str { - convert::identifier_escape_name(self.0.identifier_escape) - } - - #[getter] - fn get_string_format(&self) -> &'static str { - convert::string_format_name(self.0.string_format) - } - - #[getter] - fn get_string_escape(&self) -> &'static str { - convert::string_escape_name(self.0.string_escape) - } - - #[getter] - fn get_bytes_format(&self) -> &'static str { - convert::bytes_format_name(self.0.bytes_format) - } - - fn format_boolean(&self, value: bool) -> String { - collect(|buf| self.0.format_boolean(buf, value)) - } - - fn escape_string(&self, string: &str) -> String { - collect(|buf| self.0.escape_string(buf, string)) - } - - fn quote_identifier(&self, ident: &str) -> String { - collect(|buf| self.0.quote_identifier(buf, ident)) - } - - fn write_string_literal(&self, raw: &str) -> String { - collect(|buf| self.0.write_string_literal(buf, raw)) - } - - fn format_bits(&self, bits: &str) -> pyo3::PyResult { - if self.0.bit_format.is_none() { - return rapidquery_macros::pyerr!( - Err(PyValueError), - "this dialect does not support literal bit values" - ); - } - Ok(collect(|buf| self.0.format_bits(buf, bits))) - } - - fn format_bytes(&self, data: &[u8]) -> String { - collect(|buf| self.0.format_bytes(buf, data)) - } - // Bound so only instances match; the class object is a type. // PostgreSql() == PostgreSql is false; PostgreSql() == PostgreSql() is true. fn __eq__(&self, other: &pyo3::Bound<'_, Self>) -> bool { diff --git a/src/dialect/convert.rs b/src/dialect/convert.rs deleted file mode 100644 index 900a5a4..0000000 --- a/src/dialect/convert.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Python string names for [`super::inner`] format enums. -//! -//! Conversion lives here so the enums themselves stay data-only. - -use super::inner::{ - BitFormat, BoolFormat, BytesFormat, IdentifierEscape, IdentifierFormat, LikeAnySyntax, - NullTreatmentSyntax, PositionalParameter, StringEscape, StringFormat, -}; - -macro_rules! named { - ($ty:ty, $to_str:ident, $parse:ident { $($v:ident),+ $(,)? }) => { - #[inline] - pub(super) fn $to_str(v: $ty) -> &'static str { - match v { - $(<$ty>::$v => stringify!($v),)+ - } - } - - #[inline] - pub(super) fn $parse(s: &str) -> Option<$ty> { - $( - if s.eq_ignore_ascii_case(stringify!($v)) { - return Some(<$ty>::$v); - } - )+ - None - } - }; -} - -named!( - BitFormat, - bit_format_name, - parse_bit_format { - PrefixBigB, - PrefixSmallB, - PostfixBigB - } -); -named!( - NullTreatmentSyntax, - null_treatment_syntax_name, - parse_null_treatment_syntax { - InsideParens, - Postfix - } -); -named!( - LikeAnySyntax, - like_any_syntax_name, - parse_like_any_syntax { - ArrayConstructor, - PatternList, - OrExpansion - } -); -named!( - BoolFormat, - bool_format_name, - parse_bool_format { Keyword, Int } -); -named!( - IdentifierFormat, - identifier_format_name, - parse_identifier_format { - DoubleQuote, - Backtick, - SquareBracket - } -); -named!( - IdentifierEscape, - identifier_escape_name, - parse_identifier_escape { Double, Backslash } -); -named!( - StringFormat, - string_format_name, - parse_string_format { - SingleQuote, - PrefixBigE, - PrefixBigN - } -); -named!( - StringEscape, - string_escape_name, - parse_string_escape { - Quote, - QuoteStripNul, - BackslashHex, - BackslashMysql, - BackslashUnicode, - BackslashHexZero, - BackslashUnicodeQuote - } -); -named!( - BytesFormat, - bytes_format_name, - parse_bytes_format { - PrefixBigX, - PrefixZeroX, - SlashXBytea, - PrefixBigBSlashX, - HexToRaw, - Unhex, - ToVarbyte, - PostfixXbv - } -); - -pub(super) fn positional_parameter_name(pp: PositionalParameter) -> String { - match pp { - PositionalParameter::Simple(c) => c.to_string(), - PositionalParameter::Numbered(c) => format!("{c}NN"), - } -} - -/// Zero-alloc extractor: accept `"X"` or `"XNN"` and return `X` as `char`. -/// Any other shape is `None`. -#[inline] -pub(super) fn parse_positional_parameter(s: &str) -> Option { - match s.as_bytes() { - [b] => Some(PositionalParameter::Simple(*b as char)), - [b, b'N', b'N'] => Some(PositionalParameter::Numbered(*b as char)), - _ => None, - } -} - -pub(super) const BIT_FORMATS: &str = "PrefixBigB, PrefixSmallB, PostfixBigB"; -pub(super) const NULL_TREATMENT_SYNTAXES: &str = "InsideParens, OutsideParens"; -pub(super) const LIKE_ANY_SYNTAXES: &str = "ArrayConstructor, PatternList, OrExpansion"; -pub(super) const BOOL_FORMATS: &str = "Keyword, Int"; -pub(super) const IDENTIFIER_FORMATS: &str = "SingleQuote, DoubleQuote, Backtick, SquareBracket"; -pub(super) const IDENTIFIER_ESCAPES: &str = "Delimiters, SameAsString"; -pub(super) const STRING_FORMATS: &str = "SingleQuote, PrefixBigE, PrefixBigN"; -pub(super) const STRING_ESCAPES: &str = "Quote, QuoteStripNul, BackslashHex, BackslashMysql, BackslashUnicode, BackslashHexZero, BackslashUnicodeQuote"; -pub(super) const BYTES_FORMATS: &str = "PrefixBigX, PrefixZeroX, SlashXBytea, PrefixBigBSlashX, HexToRaw, Unhex, ToVarbyte, PostfixXbv"; - -#[inline] -pub(super) fn parse_field( - field: &str, - value: &str, - parse: impl Fn(&str) -> Option, - expected: &str, -) -> pyo3::PyResult { - match parse(value) { - Some(v) => Ok(v), - None => rapidquery_macros::pyerr!( - Err(PyValueError), - "unknown {} {:?}; expected {}", - field, - value, - expected - ), - } -} diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 4af0fd5..f4e3194 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1,6 +1,5 @@ mod base; mod builtin; -mod convert; pub mod inner; // TODO: rewrite GenericDialect again diff --git a/src/expr/base.rs b/src/expr/base.rs index 52ba592..0cdcbd0 100644 --- a/src/expr/base.rs +++ b/src/expr/base.rs @@ -1,6 +1,6 @@ //! SQL expressions. //! -//! [`PyExpr`] holds [`ExprEnum`]. Concrete nodes (`Ident`, `Binary`, …) are +//! [`PyExpr`] holds [`ExprInner`]. Concrete nodes (`Ident`, `Binary`, …) are //! empty frozen subclasses. Shared methods live on the base. //! // TODO: `to_sql` / `prepare`; `eq` / `ne` / `lt` / `le` / `gt` / `ge`; `add` / `sub` / `mul` / `div` / `mod`; @@ -11,7 +11,7 @@ use crate::internal::{alias, utils}; use crate::sql; -use super::enum_::ExprEnum; +use super::enum_::ExprInner; /// Abstract SQL expression. /// @@ -24,7 +24,7 @@ use super::enum_::ExprEnum; module = "rapidquery._core.expr" )] #[derive(Debug, Clone)] -pub struct PyExpr(pub ExprEnum); +pub struct PyExpr(pub ExprInner); #[inline(always)] fn wrap<'py, T>(py: pyo3::Python<'py>, base: PyExpr) -> pyo3::PyResult> @@ -44,7 +44,7 @@ where #[inline] pub(super) fn into_subclasses<'py>( py: pyo3::Python<'py>, - expr: ExprEnum, + expr: ExprInner, ) -> pyo3::PyResult> { PyExpr(expr).into_subclasses(py) } @@ -57,25 +57,25 @@ impl PyExpr { py: pyo3::Python<'py>, ) -> pyo3::PyResult> { match &self.0 { - ExprEnum::Ident(_) => wrap::(py, self), - ExprEnum::Value(_) => wrap::(py, self), - ExprEnum::Tuple(_) => wrap::(py, self), - ExprEnum::Unary(_) => wrap::(py, self), - ExprEnum::Binary(_) => wrap::(py, self), - ExprEnum::Function(_) => wrap::(py, self), - ExprEnum::Is(_) => wrap::(py, self), - ExprEnum::IsNormalized(_) => wrap::(py, self), - ExprEnum::IsDistinctFrom(_) => wrap::(py, self), - ExprEnum::InList(_) => wrap::(py, self), - ExprEnum::Between(_) => wrap::(py, self), - ExprEnum::Like(_) => wrap::(py, self), - ExprEnum::SimilarTo(_) => wrap::(py, self), - ExprEnum::RLike(_) => wrap::(py, self), - ExprEnum::Cast(_) => wrap::(py, self), - ExprEnum::Trim(_) => wrap::(py, self), - ExprEnum::Collate(_) => wrap::(py, self), - ExprEnum::Case(_) => wrap::(py, self), - ExprEnum::Custom(_) => wrap::(py, self), + ExprInner::Ident(_) => wrap::(py, self), + ExprInner::Value(_) => wrap::(py, self), + ExprInner::Tuple(_) => wrap::(py, self), + ExprInner::Unary(_) => wrap::(py, self), + ExprInner::Binary(_) => wrap::(py, self), + ExprInner::Function(_) => wrap::(py, self), + ExprInner::Is(_) => wrap::(py, self), + ExprInner::IsNormalized(_) => wrap::(py, self), + ExprInner::IsDistinctFrom(_) => wrap::(py, self), + ExprInner::InList(_) => wrap::(py, self), + ExprInner::Between(_) => wrap::(py, self), + ExprInner::Like(_) => wrap::(py, self), + ExprInner::SimilarTo(_) => wrap::(py, self), + ExprInner::RLike(_) => wrap::(py, self), + ExprInner::Cast(_) => wrap::(py, self), + ExprInner::Trim(_) => wrap::(py, self), + ExprInner::Collate(_) => wrap::(py, self), + ExprInner::Case(_) => wrap::(py, self), + ExprInner::Custom(_) => wrap::(py, self), } } } diff --git a/src/expr/enum_/func.rs b/src/expr/enum_/func.rs index 628b0c0..4d132fc 100644 --- a/src/expr/enum_/func.rs +++ b/src/expr/enum_/func.rs @@ -1,4 +1,4 @@ -use super::ExprEnum; +use super::ExprInner; use super::ident::Ident; use crate::dialect::inner::{FunctionFilterSyntax, NullTreatmentSyntax}; @@ -33,9 +33,9 @@ pub struct Function { pub distinct: bool, /// The arguments to the function, including any options specified within the /// delimiting parentheses. - pub args: Vec, + pub args: Vec, /// e.g. `x > 5` in `COUNT(x) FILTER (WHERE x > 5)` - pub filter: Option>, + pub filter: Option>, /// `IGNORE NULLS` / `RESPECT NULLS`. Inside the parens on BigQuery /// and DuckDB; after the call on other dialects. pub null_treatment: Option, diff --git a/src/expr/enum_/mod.rs b/src/expr/enum_/mod.rs index a628acc..7a8e8e6 100644 --- a/src/expr/enum_/mod.rs +++ b/src/expr/enum_/mod.rs @@ -1,169 +1,51 @@ mod func; mod ident; +mod nodes; mod operator; mod value; pub use func::*; pub use ident::*; +pub use nodes::*; pub use operator::*; pub use value::*; use crate::{ - dialect::{PySqlDialect, inner::LikeAnySyntax}, - sql::{PrepareResult, SqlWriter, WriteSql, write_separated}, + dialect::PySqlDialect, + sql::{PrepareResult, SqlWriter, WriteSql}, }; -use std::fmt::Write; - -#[derive(Clone, Debug, PartialEq)] -pub struct Unary { - pub op: UnaryOp, - pub expr: Box, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct Binary { - pub left: Box, - pub op: BinaryOp, - pub right: Box, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct Is { - pub expr: Box, - pub negated: bool, - pub kind: IsKind, -} - -/// `expr IS [NOT] [form] NORMALIZED`. -#[derive(Clone, Debug, PartialEq)] -pub struct IsNormalized { - pub expr: Box, - pub negated: bool, - pub form: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct IsDistinctFrom { - pub left: Box, - pub right: Box, - pub negated: bool, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct InList { - pub expr: Box, - pub list: Vec, - pub negated: bool, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct Between { - pub expr: Box, - pub negated: bool, - pub low: Box, - pub high: Box, -} - -/// Pattern side of [`Like`]: one expression, or a non-empty ANY list. -#[derive(Clone, Debug, PartialEq)] -pub enum LikePattern { - /// `col LIKE p` - One(Box), - /// `col LIKE ANY …` — never empty. - Any(Vec), -} - -#[derive(Clone, Debug, PartialEq)] -pub struct Like { - pub case_sensitive: bool, - pub negated: bool, - pub expr: Box, - pub pattern: LikePattern, - pub escape: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct SimilarTo { - pub negated: bool, - pub expr: Box, - pub pattern: Box, - pub escape: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct RLike { - pub negated: bool, - pub expr: Box, - pub pattern: Box, - pub escape: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct Cast { - pub kind: CastKind, - pub expr: Box, - pub data_type_name: crate::data_type::DataTypeName, - pub format: Option>, - pub at_time_zone: Option>, -} - -/// `TRIM([BOTH|LEADING|TRAILING] [ FROM] [, chars…])`. -#[derive(Clone, Debug, PartialEq)] -pub struct Trim { - pub expr: Box, - pub where_: Option, - pub what: Option>, - /// Comma-list form `TRIM(expr, c1, c2)`. Never empty when `Some`. - pub characters: Option>, -} - -/// `expr COLLATE collation`. -#[derive(Clone, Debug, PartialEq)] -pub struct Collate { - pub expr: Box, - pub collation: Ident, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct Case { - pub operand: Option>, - pub when: Vec<(ExprEnum, ExprEnum)>, - pub else_result: Option>, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct Custom { - pub sql: arcstr::ArcStr, - pub args: Vec, -} /// SQL expression tree. /// +/// Concrete variants wrap the node structs in [`nodes`]. [`Ident`], [`Value`], +/// and [`Function`] live in sibling modules and already implement `python_repr` +/// / [`WriteSql`]; this enum only dispatches. +/// /// TODO: `Exists`, `InSubquery`, `Subquery`, and `Quantified` (`ANY` / /// `ALL` / `SOME`) once `Query` exists. #[derive(Clone, Debug, PartialEq)] -pub enum ExprEnum { - /// Represents a SQL identifier +pub enum ExprInner { + /// Identifier (`schema.table.column`, `*`). Ident(Ident), - /// A Python object plus the Serializer used to emit it as SQL. + /// Bound or inlined SQL value plus its serializer. Value(Value), - /// Represents a SQL tuple - Tuple(Vec), - /// Unary operation + /// Parenthesized row `(a, b, …)`. + Tuple(Tuple), + /// Unary operation (`NOT x`, `-x`, postfix `x!`). Unary(Unary), - /// Binary operation + /// Binary operation (`a + b`, `a AND b`). Binary(Binary), - /// Function + /// Function call. Function(Function), - /// IS [NOT] + /// `expr IS [NOT] {NULL|TRUE|FALSE|UNKNOWN}`. Is(Is), - /// IS [NOT] [form] NORMALIZED + /// `expr IS [NOT] [form] NORMALIZED`. IsNormalized(IsNormalized), - /// IS [NOT] DISTINCT FROM + /// `left IS [NOT] DISTINCT FROM right`. IsDistinctFrom(IsDistinctFrom), - /// [NOT] IN + /// `expr [NOT] IN (list…)`. InList(InList), - /// [NOT] BETWEEN AND + /// `expr [NOT] BETWEEN low AND high`. Between(Between), Like(Like), SimilarTo(SimilarTo), @@ -175,143 +57,42 @@ pub enum ExprEnum { Custom(Custom), } -fn join_reprs(py: pyo3::Python<'_>, buf: &mut String, items: &[ExprEnum]) -> pyo3::PyResult<()> { - for (i, item) in items.iter().enumerate() { - if i > 0 { - buf.push_str(", "); - } - buf.push_str(&item.python_repr(py)?); - } - Ok(()) -} - -impl From for ExprEnum { - #[inline] - fn from(value: Ident) -> Self { - Self::Ident(value) - } -} - -impl From for ExprEnum { - #[inline] - fn from(value: Value) -> Self { - Self::Value(value) - } -} - -impl From for ExprEnum { - #[inline] - fn from(value: Unary) -> Self { - Self::Unary(value) - } -} - -impl From for ExprEnum { - #[inline] - fn from(value: Binary) -> Self { - Self::Binary(value) - } -} - -impl From for ExprEnum { - #[inline] - fn from(value: Function) -> Self { - Self::Function(value) - } -} - -impl From for ExprEnum { - #[inline] - fn from(value: Is) -> Self { - Self::Is(value) - } -} - -impl From for ExprEnum { - #[inline] - fn from(value: IsNormalized) -> Self { - Self::IsNormalized(value) - } -} - -impl From for ExprEnum { - #[inline] - fn from(value: IsDistinctFrom) -> Self { - Self::IsDistinctFrom(value) - } -} - -impl From for ExprEnum { - #[inline] - fn from(value: InList) -> Self { - Self::InList(value) - } -} - -impl From for ExprEnum { - #[inline] - fn from(value: Between) -> Self { - Self::Between(value) - } -} - -impl From for ExprEnum { - #[inline] - fn from(value: Like) -> Self { - Self::Like(value) - } -} - -impl From for ExprEnum { - #[inline] - fn from(value: SimilarTo) -> Self { - Self::SimilarTo(value) - } -} - -impl From for ExprEnum { - #[inline] - fn from(value: RLike) -> Self { - Self::RLike(value) - } -} - -impl From for ExprEnum { - #[inline] - fn from(value: Cast) -> Self { - Self::Cast(value) - } -} - -impl From for ExprEnum { - #[inline] - fn from(value: Trim) -> Self { - Self::Trim(value) - } -} - -impl From for ExprEnum { - #[inline] - fn from(value: Collate) -> Self { - Self::Collate(value) - } -} - -impl From for ExprEnum { - #[inline] - fn from(value: Case) -> Self { - Self::Case(value) - } -} - -impl From for ExprEnum { - #[inline] - fn from(value: Custom) -> Self { - Self::Custom(value) - } +macro_rules! impl_from_expr_inner { + ($($ty:ty => $variant:ident),+ $(,)?) => { + $( + impl From<$ty> for ExprInner { + #[inline] + fn from(value: $ty) -> Self { + Self::$variant(value) + } + } + )+ + }; } -impl ExprEnum { +impl_from_expr_inner! { + Ident => Ident, + Value => Value, + Tuple => Tuple, + Unary => Unary, + Binary => Binary, + Function => Function, + Is => Is, + IsNormalized => IsNormalized, + IsDistinctFrom => IsDistinctFrom, + InList => InList, + Between => Between, + Like => Like, + SimilarTo => SimilarTo, + RLike => RLike, + Cast => Cast, + Trim => Trim, + Collate => Collate, + Case => Case, + Custom => Custom, +} + +impl ExprInner { /// `true` when this node can appear as a SQL condition (`WHERE`, /// `WHEN`, `FILTER`). Identifiers and bound values are not conditions. #[inline] @@ -323,49 +104,33 @@ impl ExprEnum { #[inline] fn precedence(&self) -> u8 { match self { - Self::Ident(_) - | Self::Value(_) - | Self::Tuple(_) - | Self::Function(_) - | Self::Cast(_) - | Self::Trim(_) - | Self::Collate(_) - | Self::Case(_) - | Self::Custom(_) => 0, - Self::Unary(_) => 1, - Self::Binary(Binary { op, .. }) => match op { - BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => 2, - BinaryOp::Add | BinaryOp::Sub => 3, - BinaryOp::BitAnd - | BinaryOp::BitOr - | BinaryOp::BitXor - | BinaryOp::LShift - | BinaryOp::RShift - | BinaryOp::StringConcat => 4, - BinaryOp::Eq - | BinaryOp::NotEq - | BinaryOp::Lt - | BinaryOp::LtEq - | BinaryOp::Gt - | BinaryOp::GtEq - | BinaryOp::Custom(_) => 5, - BinaryOp::And => 6, - BinaryOp::Xor => 7, - BinaryOp::Or => 8, - }, - Self::Is(_) - | Self::IsNormalized(_) - | Self::IsDistinctFrom(_) - | Self::InList(_) - | Self::Between(_) - | Self::Like(_) - | Self::SimilarTo(_) - | Self::RLike(_) => 5, + Self::Ident(_) | Self::Value(_) | Self::Function(_) => 0, + Self::Tuple(_) => Tuple::PRECEDENCE, + Self::Unary(_) => Unary::PRECEDENCE, + Self::Binary(b) => b.precedence(), + Self::Is(_) => Is::PRECEDENCE, + Self::IsNormalized(_) => IsNormalized::PRECEDENCE, + Self::InList(_) => InList::PRECEDENCE, + Self::IsDistinctFrom(_) => IsDistinctFrom::PRECEDENCE, + Self::Between(_) => Between::PRECEDENCE, + Self::Like(_) => Like::PRECEDENCE, + Self::SimilarTo(_) => SimilarTo::PRECEDENCE, + Self::RLike(_) => RLike::PRECEDENCE, + Self::Cast(_) => Cast::PRECEDENCE, + Self::Trim(_) => Trim::PRECEDENCE, + Self::Collate(_) => Collate::PRECEDENCE, + Self::Case(_) => Case::PRECEDENCE, + Self::Custom(_) => Custom::PRECEDENCE, } } + /// Write `self` as an operand of a parent at `parent_prec`. + /// + /// Parenthesize when this node binds more loosely than the parent, or + /// when `paren_if_equal` and the precedences match (right-associativity + /// / postfix nesting). #[inline] - fn write_operand( + pub(crate) fn write_operand( &self, dialect: &PySqlDialect, out: &mut W, @@ -387,654 +152,52 @@ impl ExprEnum { #[inline] pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { match self { - Self::Ident(ident) => Ok(ident.python_repr()), - Self::Value(value) => value.python_repr(py), - Self::Tuple(items) => { - let mut buf = String::from("TupleExpr("); - join_reprs(py, &mut buf, items)?; - buf.push(')'); - Ok(buf) - } - Self::Unary(Unary { op, expr }) => Ok(format!( - "UnaryExpr({:?}, {})", - op.as_sql(), - expr.python_repr(py)? - )), - Self::Binary(Binary { left, op, right }) => Ok(format!( - "BinaryExpr({}, {:?}, {})", - left.python_repr(py)?, - op.as_sql(), - right.python_repr(py)? - )), - Self::Function(func) => func.python_repr(py), - Self::Is(Is { - expr, - negated, - kind, - }) => { - let mut buf = format!("IsExpr({}, {:?}", expr.python_repr(py)?, kind.as_sql()); - if *negated { - buf.push_str(", negated=True"); - } - buf.push(')'); - Ok(buf) - } - Self::IsNormalized(IsNormalized { - expr, - negated, - form, - }) => { - let mut buf = format!("IsNormalizedExpr({}", expr.python_repr(py)?); - if let Some(form) = form { - write!(buf, ", {:?}", form.as_sql()).unwrap(); - } - if *negated { - buf.push_str(", negated=True"); - } - buf.push(')'); - Ok(buf) - } - Self::IsDistinctFrom(IsDistinctFrom { - left, - right, - negated, - }) => { - let mut buf = format!( - "IsDistinctFromExpr({}, {}", - left.python_repr(py)?, - right.python_repr(py)? - ); - if *negated { - buf.push_str(", negated=True"); - } - buf.push(')'); - Ok(buf) - } - Self::InList(InList { - expr, - list, - negated, - }) => { - let mut buf = format!("InListExpr({}, (", expr.python_repr(py)?); - join_reprs(py, &mut buf, list)?; - buf.push(')'); - if *negated { - buf.push_str(", negated=True"); - } - buf.push(')'); - Ok(buf) - } - Self::Between(Between { - expr, - negated, - low, - high, - }) => { - let mut buf = format!( - "BetweenExpr({}, {}, {}", - expr.python_repr(py)?, - low.python_repr(py)?, - high.python_repr(py)? - ); - if *negated { - buf.push_str(", negated=True"); - } - buf.push(')'); - Ok(buf) - } - Self::Like(Like { - case_sensitive, - negated, - expr, - pattern, - escape, - }) => { - let mut buf = format!("LikeExpr({}, ", expr.python_repr(py)?); - match pattern { - LikePattern::One(p) => buf.push_str(&p.python_repr(py)?), - LikePattern::Any(ps) => { - buf.push('['); - join_reprs(py, &mut buf, ps)?; - buf.push(']'); - } - } - if !*case_sensitive { - buf.push_str(", case_sensitive=False"); - } - if *negated { - buf.push_str(", negated=True"); - } - if let Some(c) = escape { - write!(buf, ", escape={c:?}").unwrap(); - } - buf.push(')'); - Ok(buf) - } - Self::SimilarTo(SimilarTo { - negated, - expr, - pattern, - escape, - }) => { - let mut buf = format!( - "SimilarToExpr({}, {}", - expr.python_repr(py)?, - pattern.python_repr(py)? - ); - if *negated { - buf.push_str(", negated=True"); - } - if let Some(c) = escape { - write!(buf, ", escape={c:?}").unwrap(); - } - buf.push(')'); - Ok(buf) - } - Self::RLike(RLike { - negated, - expr, - pattern, - escape, - }) => { - let mut buf = format!( - "RLikeExpr({}, {}", - expr.python_repr(py)?, - pattern.python_repr(py)? - ); - if *negated { - buf.push_str(", negated=True"); - } - if let Some(c) = escape { - write!(buf, ", escape={c:?}").unwrap(); - } - buf.push(')'); - Ok(buf) - } - Self::Cast(Cast { - kind, - expr, - data_type_name: data_type, - format, - at_time_zone, - }) => { - let mut buf = format!( - "CastExpr({}, {:?}", - expr.python_repr(py)?, - data_type.as_str() - ); - if *kind != CastKind::Cast { - write!(buf, ", kind={:?}", kind.as_sql()).unwrap(); - } - if let Some(format) = format { - write!(buf, ", format={}", format.python_repr(py)?).unwrap(); - } - if let Some(tz) = at_time_zone { - write!(buf, ", at_time_zone={}", tz.python_repr(py)?).unwrap(); - } - buf.push(')'); - Ok(buf) - } - Self::Trim(Trim { - expr, - where_, - what, - characters, - }) => { - let mut buf = format!("TrimExpr({}", expr.python_repr(py)?); - if let Some(where_) = where_ { - write!(buf, ", where={:?}", where_.as_sql()).unwrap(); - } - if let Some(what) = what { - write!(buf, ", what={}", what.python_repr(py)?).unwrap(); - } - if let Some(characters) = characters { - buf.push_str(", characters=["); - join_reprs(py, &mut buf, characters)?; - buf.push(']'); - } - buf.push(')'); - Ok(buf) - } - Self::Collate(Collate { expr, collation }) => Ok(format!( - "CollateExpr({}, {})", - expr.python_repr(py)?, - collation.python_repr() - )), - Self::Case(Case { - operand, - when: when_then, - else_result, - }) => { - let mut buf = String::from("CaseExpr(["); - for (i, (when, then)) in when_then.iter().enumerate() { - if i > 0 { - buf.push_str(", "); - } - write!( - buf, - "({}, {})", - when.python_repr(py)?, - then.python_repr(py)? - ) - .unwrap(); - } - buf.push(']'); - if let Some(operand) = operand { - write!(buf, ", operand={}", operand.python_repr(py)?).unwrap(); - } - if let Some(else_result) = else_result { - write!(buf, ", else_={}", else_result.python_repr(py)?).unwrap(); - } - buf.push(')'); - Ok(buf) - } - Self::Custom(Custom { sql, args }) => { - let mut buf = format!("CustomExpr({sql:?}"); - for arg in args { - buf.push_str(", "); - buf.push_str(&arg.python_repr(py)?); - } - buf.push(')'); - Ok(buf) - } - } - } -} - -fn write_like_op(out: &mut W, case_sensitive: bool, negated: bool) { - if negated { - out.push_str("NOT "); - } - if case_sensitive { - out.push_str("LIKE"); - } else { - out.push_str("ILIKE"); - } -} - -fn write_escape( - dialect: &PySqlDialect, - out: &mut W, - escape: Option, -) -> PrepareResult<()> { - let Some(c) = escape else { - return Ok(()); - }; - - out.push_str(" ESCAPE "); - let mut buf = [0u8; 4]; - dialect - .0 - .write_string_literal(out, c.encode_utf8(&mut buf)) - .map_err(|_| crate::sql::PrepareError::Write)?; - - Ok(()) -} - -fn write_like( - dialect: &PySqlDialect, - out: &mut W, - like: &Like, - prec: u8, -) -> PrepareResult<()> { - match &like.pattern { - LikePattern::One(pattern) => { - like.expr.write_operand(dialect, out, prec, false)?; - out.push_char(' '); - write_like_op(out, like.case_sensitive, like.negated); - out.push_char(' '); - pattern.write_operand(dialect, out, prec, true)?; - write_escape(dialect, out, like.escape) + Self::Ident(x) => Ok(x.python_repr()), + Self::Value(x) => x.python_repr(py), + Self::Tuple(x) => x.python_repr(py), + Self::Unary(x) => x.python_repr(py), + Self::Binary(x) => x.python_repr(py), + Self::Function(x) => x.python_repr(py), + Self::Is(x) => x.python_repr(py), + Self::IsNormalized(x) => x.python_repr(py), + Self::IsDistinctFrom(x) => x.python_repr(py), + Self::InList(x) => x.python_repr(py), + Self::Between(x) => x.python_repr(py), + Self::Like(x) => x.python_repr(py), + Self::SimilarTo(x) => x.python_repr(py), + Self::RLike(x) => x.python_repr(py), + Self::Cast(x) => x.python_repr(py), + Self::Trim(x) => x.python_repr(py), + Self::Collate(x) => x.python_repr(py), + Self::Case(x) => x.python_repr(py), + Self::Custom(x) => x.python_repr(py), } - LikePattern::Any(patterns) => match dialect.0.like_any_syntax { - LikeAnySyntax::ArrayConstructor => { - // `NOT LIKE ANY` in Postgres is a quantified NOT LIKE - // (exists a non-match), not "matches none". - if like.negated { - out.push_str("NOT ("); - } - like.expr.write_operand(dialect, out, prec, false)?; - out.push_char(' '); - write_like_op(out, like.case_sensitive, false); - out.push_str(" ANY (ARRAY["); - write_separated(dialect, out, patterns, ", ")?; - out.push_str("])"); - write_escape(dialect, out, like.escape)?; - if like.negated { - out.push_char(')'); - } - Ok(()) - } - LikeAnySyntax::PatternList => { - like.expr.write_operand(dialect, out, prec, false)?; - out.push_char(' '); - write_like_op(out, like.case_sensitive, like.negated); - out.push_str(" ANY ("); - write_separated(dialect, out, patterns, ", ")?; - out.push_char(')'); - write_escape(dialect, out, like.escape) - } - LikeAnySyntax::OrExpansion => { - out.push_char('('); - let sep = if like.negated { " AND " } else { " OR " }; - for (i, pattern) in patterns.iter().enumerate() { - if i > 0 { - out.push_str(sep); - } - like.expr.write_operand(dialect, out, prec, false)?; - out.push_char(' '); - write_like_op(out, like.case_sensitive, like.negated); - out.push_char(' '); - pattern.write_operand(dialect, out, prec, true)?; - write_escape(dialect, out, like.escape)?; - } - out.push_char(')'); - Ok(()) - } - }, } } -impl WriteSql for ExprEnum { +impl WriteSql for ExprInner { #[cfg_attr(feature = "nightly", optimize(speed))] fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { match self { - Self::Ident(ident) => ident.write_sql(dialect, out), - Self::Value(value) => value.write_sql(dialect, out), - Self::Tuple(items) => { - out.push_char('('); - write_separated(dialect, out, items, ", ")?; - out.push_char(')'); - Ok(()) - } - Self::Unary(Unary { op, expr }) => match op { - UnaryOp::PGPostfixFactorial => { - expr.write_operand(dialect, out, self.precedence(), true)?; - out.push_char('!'); - Ok(()) - } - UnaryOp::Not - | UnaryOp::Hash - | UnaryOp::AtDashAt - | UnaryOp::DoubleAt - | UnaryOp::QuestionDash - | UnaryOp::QuestionPipe => { - out.push_str(op.as_sql()); - out.push_char(' '); - expr.write_operand(dialect, out, self.precedence(), true) - } - UnaryOp::Plus - | UnaryOp::Minus - | UnaryOp::BitNot - | UnaryOp::PGAbs - | UnaryOp::PGSquareRoot - | UnaryOp::PGCubeRoot - | UnaryOp::PGPrefixFactorial => { - out.push_str(op.as_sql()); - expr.write_operand(dialect, out, self.precedence(), true) - } - }, - Self::Binary(Binary { left, op, right }) => { - let prec = self.precedence(); - left.write_operand(dialect, out, prec, false)?; - out.push_char(' '); - out.push_str(op.as_sql()); - out.push_char(' '); - right.write_operand(dialect, out, prec, true) - } - Self::Function(func) => func.write_sql(dialect, out), - Self::Is(Is { - expr, - negated, - kind, - }) => { - expr.write_operand(dialect, out, self.precedence(), false)?; - if *negated { - out.push_str(" IS NOT "); - } else { - out.push_str(" IS "); - } - out.push_str(kind.as_sql()); - Ok(()) - } - Self::IsNormalized(IsNormalized { - expr, - negated, - form, - }) => { - expr.write_operand(dialect, out, self.precedence(), false)?; - if *negated { - out.push_str(" IS NOT "); - } else { - out.push_str(" IS "); - } - if let Some(form) = form { - out.push_str(form.as_sql()); - out.push_char(' '); - } - out.push_str("NORMALIZED"); - Ok(()) - } - Self::IsDistinctFrom(IsDistinctFrom { - left, - right, - negated, - }) => { - let prec = self.precedence(); - left.write_operand(dialect, out, prec, false)?; - if *negated { - out.push_str(" IS NOT DISTINCT FROM "); - } else { - out.push_str(" IS DISTINCT FROM "); - } - right.write_operand(dialect, out, prec, true) - } - Self::InList(InList { - expr, - list, - negated, - }) => { - expr.write_operand(dialect, out, self.precedence(), false)?; - if *negated { - out.push_str(" NOT IN ("); - } else { - out.push_str(" IN ("); - } - write_separated(dialect, out, list, ", ")?; - out.push_char(')'); - Ok(()) - } - Self::Between(Between { - expr, - negated, - low, - high, - }) => { - let prec = self.precedence(); - expr.write_operand(dialect, out, prec, false)?; - if *negated { - out.push_str(" NOT BETWEEN "); - } else { - out.push_str(" BETWEEN "); - } - low.write_operand(dialect, out, prec, true)?; - out.push_str(" AND "); - high.write_operand(dialect, out, prec, true) - } - Self::Like(like) => write_like(dialect, out, like, self.precedence()), - Self::SimilarTo(SimilarTo { - negated, - expr, - pattern, - escape, - }) => { - let prec = self.precedence(); - expr.write_operand(dialect, out, prec, false)?; - out.push_char(' '); - if *negated { - out.push_str("NOT SIMILAR TO "); - } else { - out.push_str("SIMILAR TO "); - } - - pattern.write_operand(dialect, out, prec, true)?; - write_escape(dialect, out, *escape) - } - Self::RLike(RLike { - negated, - expr, - pattern, - escape, - }) => { - let prec = self.precedence(); - expr.write_operand(dialect, out, prec, false)?; - out.push_char(' '); - if *negated { - out.push_str("NOT RLIKE "); - } else { - out.push_str("RLIKE "); - } - - pattern.write_operand(dialect, out, prec, true)?; - write_escape(dialect, out, *escape) - } - Self::Cast(Cast { - kind, - expr, - data_type_name: data_type, - format, - at_time_zone, - }) => { - out.push_str(kind.as_sql()); - out.push_char('('); - expr.write_sql(dialect, out)?; - out.push_str(" AS "); - data_type.write_sql(dialect, out)?; - if let Some(format) = format { - out.push_str(" FORMAT "); - format.write_sql(dialect, out)?; - if let Some(tz) = at_time_zone { - out.push_str(" AT TIME ZONE "); - tz.write_sql(dialect, out)?; - } - } - out.push_char(')'); - Ok(()) - } - Self::Trim(Trim { - expr, - where_, - what, - characters, - }) => { - out.push_str("TRIM("); - if let Some(where_) = where_ { - out.push_str(where_.as_sql()); - out.push_char(' '); - } - if let Some(what) = what { - what.write_sql(dialect, out)?; - out.push_str(" FROM "); - expr.write_sql(dialect, out)?; - } else if where_.is_some() { - out.push_str("FROM "); - expr.write_sql(dialect, out)?; - } else { - expr.write_sql(dialect, out)?; - } - if let Some(characters) = characters { - for c in characters { - out.push_str(", "); - c.write_sql(dialect, out)?; - } - } - out.push_char(')'); - Ok(()) - } - Self::Collate(Collate { expr, collation }) => { - expr.write_operand(dialect, out, self.precedence(), false)?; - out.push_str(" COLLATE "); - collation.write_sql(dialect, out) - } - Self::Case(Case { - operand, - when: when_then, - else_result, - }) => { - out.push_str("CASE"); - if let Some(operand) = operand { - out.push_char(' '); - operand.write_sql(dialect, out)?; - } - for (when, then) in when_then { - out.push_str(" WHEN "); - when.write_sql(dialect, out)?; - out.push_str(" THEN "); - then.write_sql(dialect, out)?; - } - if let Some(else_result) = else_result { - out.push_str(" ELSE "); - else_result.write_sql(dialect, out)?; - } - out.push_str(" END"); - Ok(()) - } - Self::Custom(Custom { sql, args }) => write_custom(sql, args, dialect, out), - } - } -} - -#[cfg_attr(feature = "nightly", optimize(speed))] -fn write_custom( - sql: &str, - args: &[ExprEnum], - dialect: &PySqlDialect, - out: &mut W, -) -> PrepareResult<()> { - let bytes = sql.as_bytes(); - let mut i = 0; - let mut start = 0; - let mut used = 0usize; - - while i < bytes.len() { - if bytes[i] == b'{' { - if i + 1 < bytes.len() && bytes[i + 1] == b'{' { - out.push_str(&sql[start..i]); - out.push_char('{'); - i += 2; - start = i; - continue; - } - if i + 1 < bytes.len() && bytes[i + 1] == b'}' { - out.push_str(&sql[start..i]); - if used >= args.len() { - return Err(crate::sql::PrepareError::CustomPlaceholders { - expected: used + 1, - got: args.len(), - }); - } - args[used].write_sql(dialect, out)?; - used += 1; - i += 2; - start = i; - continue; - } - } else if bytes[i] == b'}' && i + 1 < bytes.len() && bytes[i + 1] == b'}' { - out.push_str(&sql[start..i]); - out.push_char('}'); - i += 2; - start = i; - continue; + Self::Ident(x) => x.write_sql(dialect, out), + Self::Value(x) => x.write_sql(dialect, out), + Self::Tuple(x) => x.write_sql(dialect, out), + Self::Unary(x) => x.write_sql(dialect, out), + Self::Binary(x) => x.write_sql(dialect, out), + Self::Function(x) => x.write_sql(dialect, out), + Self::Is(x) => x.write_sql(dialect, out), + Self::IsNormalized(x) => x.write_sql(dialect, out), + Self::IsDistinctFrom(x) => x.write_sql(dialect, out), + Self::InList(x) => x.write_sql(dialect, out), + Self::Between(x) => x.write_sql(dialect, out), + Self::Like(x) => x.write_sql(dialect, out), + Self::SimilarTo(x) => x.write_sql(dialect, out), + Self::RLike(x) => x.write_sql(dialect, out), + Self::Cast(x) => x.write_sql(dialect, out), + Self::Trim(x) => x.write_sql(dialect, out), + Self::Collate(x) => x.write_sql(dialect, out), + Self::Case(x) => x.write_sql(dialect, out), + Self::Custom(x) => x.write_sql(dialect, out), } - i += 1; - } - out.push_str(&sql[start..]); - - if used != args.len() { - return Err(crate::sql::PrepareError::CustomPlaceholders { - expected: used, - got: args.len(), - }); } - Ok(()) } diff --git a/src/expr/enum_/nodes.rs b/src/expr/enum_/nodes.rs new file mode 100644 index 0000000..86da9d2 --- /dev/null +++ b/src/expr/enum_/nodes.rs @@ -0,0 +1,873 @@ +use super::{ + BinaryOp, CastKind, ExprInner, Ident, IsKind, NormalizationForm, TrimWhere, UnaryOp, + UnarySpacing, +}; +use crate::dialect::{PySqlDialect, inner::LikeAnySyntax}; +use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; +use std::fmt::Write; + +fn join_reprs(py: pyo3::Python<'_>, buf: &mut String, items: &[ExprInner]) -> pyo3::PyResult<()> { + for (i, item) in items.iter().enumerate() { + if i > 0 { + buf.push_str(", "); + } + buf.push_str(&item.python_repr(py)?); + } + Ok(()) +} + +fn write_escape( + dialect: &PySqlDialect, + out: &mut W, + escape: Option, +) -> PrepareResult<()> { + let Some(c) = escape else { + return Ok(()); + }; + + out.push_str(" ESCAPE "); + let mut buf = [0u8; 4]; + dialect + .0 + .write_string_literal(out, c.encode_utf8(&mut buf)) + .map_err(|_| crate::sql::PrepareError::Write)?; + + Ok(()) +} + +/// Parenthesized row `(a, b, …)`. No trailing comma on a singleton. +#[derive(Clone, Debug, PartialEq)] +pub struct Tuple { + pub items: Vec, +} + +impl Tuple { + pub(crate) const PRECEDENCE: u8 = 0; + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut buf = String::from("TupleExpr("); + join_reprs(py, &mut buf, &self.items)?; + buf.push(')'); + Ok(buf) + } +} + +impl WriteSql for Tuple { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + out.push_char('('); + write_separated(dialect, out, &self.items, ", ")?; + out.push_char(')'); + Ok(()) + } +} + +/// Unary operation (`NOT x`, `-x`, `|/x`, postfix `x!`). +/// +/// Precedence 1. Nested equal-precedence operands are parenthesized. +#[derive(Clone, Debug, PartialEq)] +pub struct Unary { + pub op: UnaryOp, + pub expr: Box, +} + +impl Unary { + pub(crate) const PRECEDENCE: u8 = 1; + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + Ok(format!( + "UnaryExpr({:?}, {})", + self.op.as_sql(), + self.expr.python_repr(py)? + )) + } +} + +impl WriteSql for Unary { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + match self.op.spacing() { + UnarySpacing::Postfix => { + self.expr + .write_operand(dialect, out, Self::PRECEDENCE, true)?; + out.push_char('!'); + Ok(()) + } + UnarySpacing::SpacedPrefix => { + out.push_str(self.op.as_sql()); + out.push_char(' '); + self.expr + .write_operand(dialect, out, Self::PRECEDENCE, true) + } + UnarySpacing::GluedPrefix => { + out.push_str(self.op.as_sql()); + self.expr + .write_operand(dialect, out, Self::PRECEDENCE, true) + } + } + } +} + +/// Binary operation (`a + b`, `a AND b`, custom operators). +/// +/// `!=` is stored and emitted as `<>`. Precedence follows [`BinaryOp::precedence`]. +/// The right operand is parenthesized when it has equal precedence. +#[derive(Clone, Debug, PartialEq)] +pub struct Binary { + pub left: Box, + pub op: BinaryOp, + pub right: Box, +} + +impl Binary { + #[inline] + pub(crate) fn precedence(&self) -> u8 { + self.op.precedence() + } + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + Ok(format!( + "BinaryExpr({}, {:?}, {})", + self.left.python_repr(py)?, + self.op.as_sql(), + self.right.python_repr(py)? + )) + } +} + +impl WriteSql for Binary { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + let prec = self.precedence(); + self.left.write_operand(dialect, out, prec, false)?; + out.push_char(' '); + out.push_str(self.op.as_sql()); + out.push_char(' '); + self.right.write_operand(dialect, out, prec, true) + } +} + +/// `expr IS [NOT] {NULL|TRUE|FALSE|UNKNOWN}`. +#[derive(Clone, Debug, PartialEq)] +pub struct Is { + pub expr: Box, + pub negated: bool, + pub kind: IsKind, +} + +impl Is { + pub(crate) const PRECEDENCE: u8 = 5; + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut buf = format!( + "IsExpr({}, {:?}", + self.expr.python_repr(py)?, + self.kind.as_sql() + ); + if self.negated { + buf.push_str(", negated=True"); + } + buf.push(')'); + Ok(buf) + } +} + +impl WriteSql for Is { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + self.expr + .write_operand(dialect, out, Self::PRECEDENCE, false)?; + if self.negated { + out.push_str(" IS NOT "); + } else { + out.push_str(" IS "); + } + out.push_str(self.kind.as_sql()); + Ok(()) + } +} + +/// `expr IS [NOT] [form] NORMALIZED`. +/// +/// When [`form`](Self::form) is `None`, the form token is omitted (`IS NORMALIZED`). +#[derive(Clone, Debug, PartialEq)] +pub struct IsNormalized { + pub expr: Box, + pub negated: bool, + pub form: Option, +} + +impl IsNormalized { + pub(crate) const PRECEDENCE: u8 = 5; + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut buf = format!("IsNormalizedExpr({}", self.expr.python_repr(py)?); + if let Some(form) = self.form { + write!(buf, ", {:?}", form.as_sql()).unwrap(); + } + if self.negated { + buf.push_str(", negated=True"); + } + buf.push(')'); + Ok(buf) + } +} + +impl WriteSql for IsNormalized { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + self.expr + .write_operand(dialect, out, Self::PRECEDENCE, false)?; + if self.negated { + out.push_str(" IS NOT "); + } else { + out.push_str(" IS "); + } + if let Some(form) = self.form { + out.push_str(form.as_sql()); + out.push_char(' '); + } + out.push_str("NORMALIZED"); + Ok(()) + } +} + +/// `left IS [NOT] DISTINCT FROM right`. +#[derive(Clone, Debug, PartialEq)] +pub struct IsDistinctFrom { + pub left: Box, + pub right: Box, + pub negated: bool, +} + +impl IsDistinctFrom { + pub(crate) const PRECEDENCE: u8 = 5; + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut buf = format!( + "IsDistinctFromExpr({}, {}", + self.left.python_repr(py)?, + self.right.python_repr(py)? + ); + if self.negated { + buf.push_str(", negated=True"); + } + buf.push(')'); + Ok(buf) + } +} + +impl WriteSql for IsDistinctFrom { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + self.left + .write_operand(dialect, out, Self::PRECEDENCE, false)?; + if self.negated { + out.push_str(" IS NOT DISTINCT FROM "); + } else { + out.push_str(" IS DISTINCT FROM "); + } + self.right + .write_operand(dialect, out, Self::PRECEDENCE, true) + } +} + +/// `expr [NOT] IN (list…)`. +/// +/// An empty list emits `IN ()`. +#[derive(Clone, Debug, PartialEq)] +pub struct InList { + pub expr: Box, + pub list: Vec, + pub negated: bool, +} + +impl InList { + pub(crate) const PRECEDENCE: u8 = 5; + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut buf = format!("InListExpr({}, (", self.expr.python_repr(py)?); + join_reprs(py, &mut buf, &self.list)?; + buf.push(')'); + if self.negated { + buf.push_str(", negated=True"); + } + buf.push(')'); + Ok(buf) + } +} + +impl WriteSql for InList { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + self.expr + .write_operand(dialect, out, Self::PRECEDENCE, false)?; + if self.negated { + out.push_str(" NOT IN ("); + } else { + out.push_str(" IN ("); + } + write_separated(dialect, out, &self.list, ", ")?; + out.push_char(')'); + Ok(()) + } +} + +/// `expr [NOT] BETWEEN low AND high`. +/// +/// No `SYMMETRIC` clause. +#[derive(Clone, Debug, PartialEq)] +pub struct Between { + pub expr: Box, + pub negated: bool, + pub low: Box, + pub high: Box, +} + +impl Between { + pub(crate) const PRECEDENCE: u8 = 5; + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut buf = format!( + "BetweenExpr({}, {}, {}", + self.expr.python_repr(py)?, + self.low.python_repr(py)?, + self.high.python_repr(py)? + ); + if self.negated { + buf.push_str(", negated=True"); + } + buf.push(')'); + Ok(buf) + } +} + +impl WriteSql for Between { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + self.expr + .write_operand(dialect, out, Self::PRECEDENCE, false)?; + if self.negated { + out.push_str(" NOT BETWEEN "); + } else { + out.push_str(" BETWEEN "); + } + self.low + .write_operand(dialect, out, Self::PRECEDENCE, true)?; + out.push_str(" AND "); + self.high + .write_operand(dialect, out, Self::PRECEDENCE, true) + } +} + +/// Pattern side of [`Like`]: one expression, or a non-empty ANY list. +#[derive(Clone, Debug, PartialEq)] +pub enum LikePattern { + /// `col LIKE p` + One(Box), + /// `col LIKE ANY …` — never empty. + Any(Vec), +} + +impl LikePattern { + fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + match self { + Self::One(p) => p.python_repr(py), + Self::Any(ps) => { + let mut buf = String::from("["); + join_reprs(py, &mut buf, ps)?; + buf.push(']'); + Ok(buf) + } + } + } +} + +/// `expr [NOT] LIKE / ILIKE [ANY] pattern [ESCAPE char]`. +/// +/// `case_sensitive == false` emits `ILIKE`. `ESCAPE` is a single inlined +/// character (never a bind). +/// +/// LIKE ANY follows [`LikeAnySyntax`]: Postgres `ANY (ARRAY[…])` wraps +/// negation as `NOT (x LIKE ANY …)` because `NOT LIKE ANY` is a quantified +/// NOT LIKE; Snowflake/Spark/Databricks use `LIKE ANY (p, …)`; everyone else +/// expands to parenthesized `OR` of scalar `LIKE`s (`AND` of `NOT LIKE` when +/// negated). +#[derive(Clone, Debug, PartialEq)] +pub struct Like { + pub case_sensitive: bool, + pub negated: bool, + pub expr: Box, + pub pattern: LikePattern, + pub escape: Option, +} + +impl Like { + pub(crate) const PRECEDENCE: u8 = 5; + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut buf = format!( + "LikeExpr({}, {}", + self.expr.python_repr(py)?, + self.pattern.python_repr(py)? + ); + if !self.case_sensitive { + buf.push_str(", case_sensitive=False"); + } + if self.negated { + buf.push_str(", negated=True"); + } + if let Some(c) = self.escape { + write!(buf, ", escape={c:?}").unwrap(); + } + buf.push(')'); + Ok(buf) + } +} + +fn write_like_op(out: &mut W, case_sensitive: bool, negated: bool) { + if negated { + out.push_str("NOT "); + } + if case_sensitive { + out.push_str("LIKE"); + } else { + out.push_str("ILIKE"); + } +} + +impl WriteSql for Like { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + let prec = Self::PRECEDENCE; + match &self.pattern { + LikePattern::One(pattern) => { + self.expr.write_operand(dialect, out, prec, false)?; + out.push_char(' '); + write_like_op(out, self.case_sensitive, self.negated); + out.push_char(' '); + pattern.write_operand(dialect, out, prec, true)?; + write_escape(dialect, out, self.escape) + } + LikePattern::Any(patterns) => match dialect.0.like_any_syntax { + LikeAnySyntax::ArrayConstructor => { + // `NOT LIKE ANY` in Postgres is a quantified NOT LIKE + // (exists a non-match), not "matches none". + if self.negated { + out.push_str("NOT ("); + } + self.expr.write_operand(dialect, out, prec, false)?; + out.push_char(' '); + write_like_op(out, self.case_sensitive, false); + out.push_str(" ANY (ARRAY["); + write_separated(dialect, out, patterns, ", ")?; + out.push_str("])"); + write_escape(dialect, out, self.escape)?; + if self.negated { + out.push_char(')'); + } + Ok(()) + } + LikeAnySyntax::PatternList => { + self.expr.write_operand(dialect, out, prec, false)?; + out.push_char(' '); + write_like_op(out, self.case_sensitive, self.negated); + out.push_str(" ANY ("); + write_separated(dialect, out, patterns, ", ")?; + out.push_char(')'); + write_escape(dialect, out, self.escape) + } + LikeAnySyntax::OrExpansion => { + out.push_char('('); + let sep = if self.negated { " AND " } else { " OR " }; + for (i, pattern) in patterns.iter().enumerate() { + if i > 0 { + out.push_str(sep); + } + self.expr.write_operand(dialect, out, prec, false)?; + out.push_char(' '); + write_like_op(out, self.case_sensitive, self.negated); + out.push_char(' '); + pattern.write_operand(dialect, out, prec, true)?; + write_escape(dialect, out, self.escape)?; + } + out.push_char(')'); + Ok(()) + } + }, + } + } +} + +/// `expr [NOT] SIMILAR TO pattern [ESCAPE char]`. +#[derive(Clone, Debug, PartialEq)] +pub struct SimilarTo { + pub negated: bool, + pub expr: Box, + pub pattern: Box, + pub escape: Option, +} + +impl SimilarTo { + pub(crate) const PRECEDENCE: u8 = 5; + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut buf = format!( + "SimilarToExpr({}, {}", + self.expr.python_repr(py)?, + self.pattern.python_repr(py)? + ); + if self.negated { + buf.push_str(", negated=True"); + } + if let Some(c) = self.escape { + write!(buf, ", escape={c:?}").unwrap(); + } + buf.push(')'); + Ok(buf) + } +} + +impl WriteSql for SimilarTo { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + self.expr + .write_operand(dialect, out, Self::PRECEDENCE, false)?; + out.push_char(' '); + if self.negated { + out.push_str("NOT SIMILAR TO "); + } else { + out.push_str("SIMILAR TO "); + } + + self.pattern + .write_operand(dialect, out, Self::PRECEDENCE, true)?; + write_escape(dialect, out, self.escape) + } +} + +/// `expr [NOT] RLIKE pattern [ESCAPE char]`. +#[derive(Clone, Debug, PartialEq)] +pub struct RLike { + pub negated: bool, + pub expr: Box, + pub pattern: Box, + pub escape: Option, +} + +impl RLike { + pub(crate) const PRECEDENCE: u8 = 5; + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut buf = format!( + "RLikeExpr({}, {}", + self.expr.python_repr(py)?, + self.pattern.python_repr(py)? + ); + if self.negated { + buf.push_str(", negated=True"); + } + if let Some(c) = self.escape { + write!(buf, ", escape={c:?}").unwrap(); + } + buf.push(')'); + Ok(buf) + } +} + +impl WriteSql for RLike { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + self.expr + .write_operand(dialect, out, Self::PRECEDENCE, false)?; + out.push_char(' '); + if self.negated { + out.push_str("NOT RLIKE "); + } else { + out.push_str("RLIKE "); + } + + self.pattern + .write_operand(dialect, out, Self::PRECEDENCE, true)?; + write_escape(dialect, out, self.escape) + } +} + +/// `KIND(expr AS type [FORMAT format [AT TIME ZONE tz]])`. +/// +/// [`kind`](Self::kind) is `CAST` / `TRY_CAST` / `SAFE_CAST`. `FORMAT` and +/// `AT TIME ZONE` are omitted when unset; timezone requires format. +#[derive(Clone, Debug, PartialEq)] +pub struct Cast { + pub kind: CastKind, + pub expr: Box, + pub data_type_name: crate::data_type::DataTypeName, + pub format: Option>, + pub at_time_zone: Option>, +} + +impl Cast { + pub(crate) const PRECEDENCE: u8 = 0; + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut buf = format!( + "CastExpr({}, {:?}", + self.expr.python_repr(py)?, + self.data_type_name.as_str() + ); + if self.kind != CastKind::Cast { + write!(buf, ", kind={:?}", self.kind.as_sql()).unwrap(); + } + if let Some(format) = &self.format { + write!(buf, ", format={}", format.python_repr(py)?).unwrap(); + } + if let Some(tz) = &self.at_time_zone { + write!(buf, ", at_time_zone={}", tz.python_repr(py)?).unwrap(); + } + buf.push(')'); + Ok(buf) + } +} + +impl WriteSql for Cast { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + out.push_str(self.kind.as_sql()); + out.push_char('('); + self.expr.write_sql(dialect, out)?; + out.push_str(" AS "); + self.data_type_name.write_sql(dialect, out)?; + if let Some(format) = &self.format { + out.push_str(" FORMAT "); + format.write_sql(dialect, out)?; + if let Some(tz) = &self.at_time_zone { + out.push_str(" AT TIME ZONE "); + tz.write_sql(dialect, out)?; + } + } + out.push_char(')'); + Ok(()) + } +} + +/// `TRIM([BOTH|LEADING|TRAILING] [ FROM] [, chars…])`. +/// +/// [`what`](Self::what) and [`characters`](Self::characters) are mutually +/// exclusive. `characters` is never empty when `Some`. +#[derive(Clone, Debug, PartialEq)] +pub struct Trim { + pub expr: Box, + pub where_: Option, + pub what: Option>, + /// Comma-list form `TRIM(expr, c1, c2)`. Never empty when `Some`. + pub characters: Option>, +} + +impl Trim { + pub(crate) const PRECEDENCE: u8 = 0; + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut buf = format!("TrimExpr({}", self.expr.python_repr(py)?); + if let Some(where_) = self.where_ { + write!(buf, ", where={:?}", where_.as_sql()).unwrap(); + } + if let Some(what) = &self.what { + write!(buf, ", what={}", what.python_repr(py)?).unwrap(); + } + if let Some(characters) = &self.characters { + buf.push_str(", characters=["); + join_reprs(py, &mut buf, characters)?; + buf.push(']'); + } + buf.push(')'); + Ok(buf) + } +} + +impl WriteSql for Trim { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + out.push_str("TRIM("); + if let Some(where_) = self.where_ { + out.push_str(where_.as_sql()); + out.push_char(' '); + } + if let Some(what) = &self.what { + what.write_sql(dialect, out)?; + out.push_str(" FROM "); + self.expr.write_sql(dialect, out)?; + } else if self.where_.is_some() { + out.push_str("FROM "); + self.expr.write_sql(dialect, out)?; + } else { + self.expr.write_sql(dialect, out)?; + } + if let Some(characters) = &self.characters { + for c in characters { + out.push_str(", "); + c.write_sql(dialect, out)?; + } + } + out.push_char(')'); + Ok(()) + } +} + +/// `expr COLLATE collation`. +/// +/// Precedence 0, so a binary operand is parenthesized. +#[derive(Clone, Debug, PartialEq)] +pub struct Collate { + pub expr: Box, + pub collation: Ident, +} + +impl Collate { + pub(crate) const PRECEDENCE: u8 = 0; + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + Ok(format!( + "CollateExpr({}, {})", + self.expr.python_repr(py)?, + self.collation.python_repr() + )) + } +} + +impl WriteSql for Collate { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + self.expr + .write_operand(dialect, out, Self::PRECEDENCE, false)?; + out.push_str(" COLLATE "); + self.collation.write_sql(dialect, out) + } +} + +/// `CASE [operand] WHEN … THEN … [ELSE …] END`. +#[derive(Clone, Debug, PartialEq)] +pub struct Case { + pub operand: Option>, + pub when: Vec<(ExprInner, ExprInner)>, + pub else_result: Option>, +} + +impl Case { + pub(crate) const PRECEDENCE: u8 = 0; + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut buf = String::from("CaseExpr(["); + for (i, (when, then)) in self.when.iter().enumerate() { + if i > 0 { + buf.push_str(", "); + } + write!( + buf, + "({}, {})", + when.python_repr(py)?, + then.python_repr(py)? + ) + .unwrap(); + } + buf.push(']'); + if let Some(operand) = &self.operand { + write!(buf, ", operand={}", operand.python_repr(py)?).unwrap(); + } + if let Some(else_result) = &self.else_result { + write!(buf, ", else_={}", else_result.python_repr(py)?).unwrap(); + } + buf.push(')'); + Ok(buf) + } +} + +impl WriteSql for Case { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + out.push_str("CASE"); + if let Some(operand) = &self.operand { + out.push_char(' '); + operand.write_sql(dialect, out)?; + } + for (when, then) in &self.when { + out.push_str(" WHEN "); + when.write_sql(dialect, out)?; + out.push_str(" THEN "); + then.write_sql(dialect, out)?; + } + if let Some(else_result) = &self.else_result { + out.push_str(" ELSE "); + else_result.write_sql(dialect, out)?; + } + out.push_str(" END"); + Ok(()) + } +} + +/// Escape-hatch SQL template. `{}` slots are filled by argument expressions. +/// `{{` / `}}` emit literal braces. Slot count must match [`args`](Self::args). +#[derive(Clone, Debug, PartialEq)] +pub struct Custom { + pub sql: arcstr::ArcStr, + pub args: Vec, +} + +impl Custom { + pub(crate) const PRECEDENCE: u8 = 0; + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut buf = format!("CustomExpr({:?}", self.sql); + for arg in &self.args { + buf.push_str(", "); + buf.push_str(&arg.python_repr(py)?); + } + buf.push(')'); + Ok(buf) + } +} + +impl WriteSql for Custom { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + write_custom(&self.sql, &self.args, dialect, out) + } +} + +#[cfg_attr(feature = "nightly", optimize(speed))] +fn write_custom( + sql: &str, + args: &[ExprInner], + dialect: &PySqlDialect, + out: &mut W, +) -> PrepareResult<()> { + let bytes = sql.as_bytes(); + let mut i = 0; + let mut start = 0; + let mut used = 0usize; + + while i < bytes.len() { + if bytes[i] == b'{' { + if i + 1 < bytes.len() && bytes[i + 1] == b'{' { + out.push_str(&sql[start..i]); + out.push_char('{'); + i += 2; + start = i; + continue; + } + if i + 1 < bytes.len() && bytes[i + 1] == b'}' { + out.push_str(&sql[start..i]); + if used >= args.len() { + return Err(crate::sql::PrepareError::CustomPlaceholders { + expected: used + 1, + got: args.len(), + }); + } + args[used].write_sql(dialect, out)?; + used += 1; + i += 2; + start = i; + continue; + } + } else if bytes[i] == b'}' && i + 1 < bytes.len() && bytes[i + 1] == b'}' { + out.push_str(&sql[start..i]); + out.push_char('}'); + i += 2; + start = i; + continue; + } + i += 1; + } + out.push_str(&sql[start..]); + + if used != args.len() { + return Err(crate::sql::PrepareError::CustomPlaceholders { + expected: used, + got: args.len(), + }); + } + Ok(()) +} diff --git a/src/expr/enum_/operator.rs b/src/expr/enum_/operator.rs index 90eaeab..39541b3 100644 --- a/src/expr/enum_/operator.rs +++ b/src/expr/enum_/operator.rs @@ -68,6 +68,38 @@ impl UnaryOp { Self::QuestionPipe => "?|", } } + + /// How this operator is written relative to its operand. + #[inline] + pub(crate) fn spacing(self) -> UnarySpacing { + match self { + Self::PGPostfixFactorial => UnarySpacing::Postfix, + Self::Not + | Self::Hash + | Self::AtDashAt + | Self::DoubleAt + | Self::QuestionDash + | Self::QuestionPipe => UnarySpacing::SpacedPrefix, + Self::Plus + | Self::Minus + | Self::BitNot + | Self::PGAbs + | Self::PGSquareRoot + | Self::PGCubeRoot + | Self::PGPrefixFactorial => UnarySpacing::GluedPrefix, + } + } +} + +/// How a [`UnaryOp`] is written relative to its operand. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UnarySpacing { + /// Operand then operator, no space (`9!`). + Postfix, + /// Operator, space, operand (`NOT x`, `# path`). + SpacedPrefix, + /// Operator glued to the operand (`-1`, `|/9`). + GluedPrefix, } /// Binary operator. Unknown spellings become [`BinaryOp::Custom`]. @@ -150,6 +182,31 @@ impl BinaryOp { Self::Custom(s) => s.as_str(), } } + + /// Lower is tighter binding. Used by [`super::Binary`] and parenthesization. + #[inline] + pub fn precedence(&self) -> u8 { + match self { + Self::Mul | Self::Div | Self::Mod => 2, + Self::Add | Self::Sub => 3, + Self::BitAnd + | Self::BitOr + | Self::BitXor + | Self::LShift + | Self::RShift + | Self::StringConcat => 4, + Self::Eq + | Self::NotEq + | Self::Lt + | Self::LtEq + | Self::Gt + | Self::GtEq + | Self::Custom(_) => 5, + Self::And => 6, + Self::Xor => 7, + Self::Or => 8, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/src/expr/extractors.rs b/src/expr/extractors.rs index 1b25e12..0f84f41 100644 --- a/src/expr/extractors.rs +++ b/src/expr/extractors.rs @@ -2,7 +2,7 @@ use std::str::FromStr; use pyo3::types::{PyAnyMethods, PyListMethods, PyTupleMethods}; -use super::enum_::{ExprEnum, Ident, IdentPart, LikePattern}; +use super::enum_::{ExprInner, Ident, IdentPart, LikePattern, Tuple}; use crate::internal::utils; /// `true` when `obj` is `Asterisk` or `type[Asterisk]`. @@ -16,8 +16,8 @@ unsafe fn is_asterisk(obj: *mut pyo3::ffi::PyObject) -> bool { type_ptr == crate::typeref::ASTERISK_TYPE } -/// Literal-priority extractor: a bare `str` becomes [`ExprEnum::Value`]. -pub struct LiteralExprExtractor(pub ExprEnum); +/// Literal-priority extractor: a bare `str` becomes [`ExprInner::Value`]. +pub struct LiteralExprExtractor(pub ExprInner); /// Extracts a native [`Ident`](crate::Ident) from a Python object. /// @@ -26,12 +26,12 @@ pub struct LiteralExprExtractor(pub ExprEnum); pub struct IdentExtractor(pub Ident); /// Accepts `PyExpr` only when it is a condition, not a value or ident. -pub struct ConditionExtractor(pub ExprEnum); +pub struct ConditionExtractor(pub ExprInner); fn extract_expr( obj: pyo3::prelude::Borrowed<'_, '_, pyo3::prelude::PyAny>, str_as_ident: bool, -) -> pyo3::PyResult { +) -> pyo3::PyResult { let ptr = obj.as_ptr(); unsafe { if crate::typeref::check(ptr, crate::typeref::EXPR_TYPE) { @@ -44,25 +44,25 @@ fn extract_expr( let part = IdentPart::unquoted(unquote.get().0.as_str()) .map_err(|err| rapidquery_macros::pyerr!(PyValueError, "{}", err))?; - return Ok(ExprEnum::Ident(Ident::from_one(part))); + return Ok(ExprInner::Ident(Ident::from_one(part))); } if is_asterisk(ptr) { - return Ok(ExprEnum::Ident(Ident::from_one(IdentPart::Asterisk))); + return Ok(ExprInner::Ident(Ident::from_one(IdentPart::Asterisk))); } if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { let s = utils::borrow_py_str_buffer(obj.py(), ptr)?; - // a bare `str` becomes [`ExprEnum::Ident`] if `str_as_ident` + // a bare `str` becomes [`ExprInner::Ident`] if `str_as_ident` if str_as_ident { let inner = Ident::from_str(s) .map_err(|err| rapidquery_macros::pyerr!(PyValueError, "{}", err))?; - return Ok(ExprEnum::Ident(inner)); + return Ok(ExprInner::Ident(inner)); } - return Ok(ExprEnum::Value(super::enum_::Value::new( + return Ok(ExprInner::Value(super::enum_::Value::new( obj.as_any().clone().unbind(), crate::data_type::SerializerEnum::String, ))); @@ -76,11 +76,11 @@ fn extract_expr( items.push(extract_expr(item.as_borrowed(), str_as_ident)?); } - return Ok(ExprEnum::Tuple(items)); + return Ok(Tuple { items }.into()); } } - Ok(ExprEnum::Value(super::enum_::Value::infer_bound( + Ok(ExprInner::Value(super::enum_::Value::infer_bound( obj.as_any().clone(), ))) } @@ -125,7 +125,7 @@ fn extract_like_any<'py>( /// Comma-list `TRIM(expr, c1, c2)` characters: exact `list`/`tuple`, never empty. pub fn extract_trim_characters( obj: pyo3::prelude::Borrowed<'_, '_, pyo3::prelude::PyAny>, -) -> pyo3::PyResult> { +) -> pyo3::PyResult> { let ptr = obj.as_ptr(); unsafe { if pyo3::ffi::PyList_CheckExact(ptr) == 1 { @@ -146,7 +146,7 @@ pub fn extract_trim_characters( fn collect_trim_characters<'py>( iter: impl IntoIterator>, -) -> pyo3::PyResult> { +) -> pyo3::PyResult> { let mut items = Vec::new(); for item in iter { items.push(item.extract::()?.0); @@ -183,10 +183,10 @@ pub(super) fn extract_ident_argument( let expr = obj.cast_unchecked::(); match &expr.get().0 { - ExprEnum::Ident(ident) => { + ExprInner::Ident(ident) => { return Ok(ExtractedIdentArgument::Ident(ident.clone())); } - _ => unreachable!("Ident instance always holds ExprEnum::Ident"), + _ => unreachable!("Ident instance always holds ExprInner::Ident"), } } diff --git a/src/expr/func.rs b/src/expr/func.rs index d4f72af..c00531c 100644 --- a/src/expr/func.rs +++ b/src/expr/func.rs @@ -29,12 +29,12 @@ impl PyFunction { #[inline(always)] fn inner<'a>(slf: &'a pyo3::PyRef<'a, PyFunction>) -> &'a super::enum_::Function { debug_assert!( - matches!(&slf.as_super().0, super::enum_::ExprEnum::Function(..)), - "PyFunction instance always holds ExprEnum::Function" + matches!(&slf.as_super().0, super::enum_::ExprInner::Function(..)), + "PyFunction instance always holds ExprInner::Function" ); match &slf.as_super().0 { - super::enum_::ExprEnum::Function(v) => v, + super::enum_::ExprInner::Function(v) => v, _ => unsafe { std::hint::unreachable_unchecked() }, } } @@ -98,7 +98,7 @@ impl PyFunction { fn get_name(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { let inner = Self::inner(&slf); - PyExpr(super::enum_::ExprEnum::Ident(inner.name.clone())).into_subclasses(slf.py()) + PyExpr(super::enum_::ExprInner::Ident(inner.name.clone())).into_subclasses(slf.py()) } fn get_distinct(slf: pyo3::PyRef<'_, Self>) -> bool { diff --git a/src/expr/ident.rs b/src/expr/ident.rs index 6c4ce7b..6d842a0 100644 --- a/src/expr/ident.rs +++ b/src/expr/ident.rs @@ -54,12 +54,12 @@ impl PyIdent { #[inline(always)] fn inner<'a>(slf: &'a pyo3::PyRef<'a, PyIdent>) -> &'a Ident { debug_assert!( - matches!(&slf.as_super().0, super::enum_::ExprEnum::Ident(..)), - "PyIdent instance always holds ExprEnum::Ident" + matches!(&slf.as_super().0, super::enum_::ExprInner::Ident(..)), + "PyIdent instance always holds ExprInner::Ident" ); match &slf.as_super().0 { - super::enum_::ExprEnum::Ident(ident) => ident, + super::enum_::ExprInner::Ident(ident) => ident, _ => unsafe { std::hint::unreachable_unchecked() }, } } @@ -91,7 +91,7 @@ impl PyIdent { }; Ok( - pyo3::PyClassInitializer::from(super::base::PyExpr(super::enum_::ExprEnum::Ident( + pyo3::PyClassInitializer::from(super::base::PyExpr(super::enum_::ExprInner::Ident( ident, ))) .add_subclass(PyIdent), diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index f0faa0a..9dbc84a 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -1,7 +1,7 @@ use pyo3::types::{PyAnyMethods, PyTupleMethods}; use super::base::PyExpr; -use super::enum_::ExprEnum; +use super::enum_::ExprInner; use super::extractors::{ ConditionExtractor, IdentExtractor, LiteralExprExtractor, extract_like_pattern, extract_trim_characters, @@ -31,13 +31,13 @@ macro_rules! empty_node { #[inline] fn inner<'a>(slf: &'a pyo3::PyRef<'a, $name>) -> &'a $inner { debug_assert!( - matches!(&slf.as_super().0, ExprEnum::$variant(..)), - concat!(stringify!($name), " instance always holds ExprEnum::", stringify!($variant)) + matches!(&slf.as_super().0, ExprInner::$variant(..)), + concat!(stringify!($name), " instance always holds ExprInner::", stringify!($variant)) ); match &slf.as_super().0 { - ExprEnum::$variant(v) => v, + ExprInner::$variant(v) => v, _ => unsafe { std::hint::unreachable_unchecked() }, } } @@ -48,7 +48,7 @@ macro_rules! empty_node { empty_node! { /// Parenthesized row / tuple expression `(a, b, …)`. - PyTupleExpr(Tuple([ExprEnum])) => "TupleExpr", + PyTupleExpr(Tuple(super::enum_::Tuple)) => "TupleExpr", /// Unary expression (`NOT x`, `-x`, `|/x`, `x!`, …). PyUnaryExpr(Unary(super::enum_::Unary)) => "UnaryExpr", @@ -65,10 +65,10 @@ empty_node! { /// `a IS [NOT] DISTINCT FROM b`. PyIsDistinctFromExpr(IsDistinctFrom(super::enum_::IsDistinctFrom)) => "IsDistinctFromExpr", - // /// `expr [NOT] IN (… )`. + /// `expr [NOT] IN (… )`. PyInListExpr(InList(super::enum_::InList)) => "InListExpr", - // /// `expr [NOT] BETWEEN low AND high`. + /// `expr [NOT] BETWEEN low AND high`. PyBetweenExpr(Between(super::enum_::Between)) => "BetweenExpr", /// `expr [NOT] LIKE / ILIKE [ANY] pattern`. @@ -109,14 +109,17 @@ impl PyTupleExpr { exprs.push(item.extract::()?.0); } - Ok(pyo3::PyClassInitializer::from(PyExpr(ExprEnum::Tuple(exprs))).add_subclass(Self)) + Ok( + pyo3::PyClassInitializer::from(PyExpr(super::enum_::Tuple { items: exprs }.into())) + .add_subclass(Self), + ) } fn get_items( slf: pyo3::PyRef<'_, Self>, ) -> pyo3::PyResult> { let py = slf.py(); - let items = Self::inner(&slf); + let items = &Self::inner(&slf).items; let mut out = Vec::with_capacity(items.len()); for item in items { diff --git a/src/expr/value.rs b/src/expr/value.rs index e6457c2..3a936f7 100644 --- a/src/expr/value.rs +++ b/src/expr/value.rs @@ -29,12 +29,12 @@ impl PyValue { #[inline(always)] fn inner<'a>(slf: &'a pyo3::PyRef<'a, PyValue>) -> &'a Value { debug_assert!( - matches!(&slf.as_super().0, super::enum_::ExprEnum::Value(..)), - "PyValue instance always holds ExprEnum::Value" + matches!(&slf.as_super().0, super::enum_::ExprInner::Value(..)), + "PyValue instance always holds ExprInner::Value" ); match &slf.as_super().0 { - super::enum_::ExprEnum::Value(v) => v, + super::enum_::ExprInner::Value(v) => v, _ => unsafe { std::hint::unreachable_unchecked() }, } } @@ -66,12 +66,12 @@ impl PyValue { let expr_inner = expr.get().0.clone(); debug_assert!( - matches!(expr_inner, super::enum_::ExprEnum::Value(..)), - "PyValue instance always holds ExprEnum::Value" + matches!(expr_inner, super::enum_::ExprInner::Value(..)), + "PyValue instance always holds ExprInner::Value" ); let inner = match expr_inner { - super::enum_::ExprEnum::Value(v) => v, + super::enum_::ExprInner::Value(v) => v, _ => unsafe { std::hint::unreachable_unchecked() }, }; @@ -80,7 +80,7 @@ impl PyValue { None => inner, }; - let new_expr = super::base::PyExpr(super::enum_::ExprEnum::Value(value)); + let new_expr = super::base::PyExpr(super::enum_::ExprInner::Value(value)); return pyo3::PyClassInitializer::from(new_expr).add_subclass(PyValue); } @@ -89,7 +89,7 @@ impl PyValue { None => Value::infer_bound(object), }; - let expr = super::base::PyExpr(super::enum_::ExprEnum::Value(value)); + let expr = super::base::PyExpr(super::enum_::ExprInner::Value(value)); pyo3::PyClassInitializer::from(expr).add_subclass(PyValue) } From 875d4f1beca7e151d1a5b2653cf89448867749c6 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Fri, 25 Sep 2026 17:23:30 +0330 Subject: [PATCH 47/87] Add ILikeExpressionOptions and move Like into enum_/like.rs Case-insensitive LIKE now follows the dialect: native ILIKE, plain LIKE, or LOWER(expr) LIKE LOWER(pattern). The Like AST node and its WriteSql helpers live in expr/enum_/like.rs. --- src/dialect/builtin.rs | 13 +++ src/dialect/inner.rs | 26 ++++++ src/expr/enum_/like.rs | 201 ++++++++++++++++++++++++++++++++++++++++ src/expr/enum_/mod.rs | 2 + src/expr/enum_/nodes.rs | 142 +--------------------------- 5 files changed, 244 insertions(+), 140 deletions(-) create mode 100644 src/expr/enum_/like.rs diff --git a/src/dialect/builtin.rs b/src/dialect/builtin.rs index 385832e..45b9219 100644 --- a/src/dialect/builtin.rs +++ b/src/dialect/builtin.rs @@ -49,6 +49,7 @@ impl_builtin_dialects!( string_escape: StringEscape::BackslashHex, bytes_format: BytesFormat::PrefixBigBSlashX, function_filter_syntax: Some(FunctionFilterSyntax::InsideWhere), + ilike_expression_options: ILikeExpressionOptions::UseLikeLower, } } @@ -69,6 +70,7 @@ impl_builtin_dialects!( string_escape: StringEscape::BackslashUnicodeQuote, bytes_format: BytesFormat::PrefixBigX, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), + ilike_expression_options: ILikeExpressionOptions::Supported, } } @@ -89,6 +91,7 @@ impl_builtin_dialects!( string_escape: StringEscape::Quote, bytes_format: BytesFormat::PrefixBigX, function_filter_syntax: Some(FunctionFilterSyntax::Filter), + ilike_expression_options: ILikeExpressionOptions::Supported, } } @@ -109,6 +112,7 @@ impl_builtin_dialects!( string_escape: StringEscape::BackslashUnicode, bytes_format: BytesFormat::Unhex, function_filter_syntax: None, + ilike_expression_options: ILikeExpressionOptions::UseLikeLower, } } @@ -129,6 +133,7 @@ impl_builtin_dialects!( string_escape: StringEscape::QuoteStripNul, bytes_format: BytesFormat::PrefixZeroX, function_filter_syntax: None, + ilike_expression_options: ILikeExpressionOptions::UseLike, } } @@ -149,6 +154,7 @@ impl_builtin_dialects!( string_escape: StringEscape::BackslashMysql, bytes_format: BytesFormat::PrefixBigX, function_filter_syntax: None, + ilike_expression_options: ILikeExpressionOptions::UseLike, } } @@ -169,6 +175,7 @@ impl_builtin_dialects!( string_escape: StringEscape::QuoteStripNul, bytes_format: BytesFormat::HexToRaw, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), + ilike_expression_options: ILikeExpressionOptions::UseLikeLower, } } @@ -189,6 +196,7 @@ impl_builtin_dialects!( string_escape: StringEscape::BackslashHex, bytes_format: BytesFormat::SlashXBytea, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), + ilike_expression_options: ILikeExpressionOptions::Supported, } } @@ -209,6 +217,7 @@ impl_builtin_dialects!( string_escape: StringEscape::QuoteStripNul, bytes_format: BytesFormat::ToVarbyte, function_filter_syntax: None, + ilike_expression_options: ILikeExpressionOptions::Supported, } } @@ -229,6 +238,7 @@ impl_builtin_dialects!( string_escape: StringEscape::BackslashHexZero, bytes_format: BytesFormat::PrefixBigX, function_filter_syntax: None, + ilike_expression_options: ILikeExpressionOptions::Supported, } } @@ -249,6 +259,7 @@ impl_builtin_dialects!( string_escape: StringEscape::BackslashUnicodeQuote, bytes_format: BytesFormat::PrefixBigX, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), + ilike_expression_options: ILikeExpressionOptions::Supported, } } @@ -269,6 +280,7 @@ impl_builtin_dialects!( string_escape: StringEscape::QuoteStripNul, bytes_format: BytesFormat::PrefixBigX, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), + ilike_expression_options: ILikeExpressionOptions::UseLike, } } @@ -289,6 +301,7 @@ impl_builtin_dialects!( string_escape: StringEscape::QuoteStripNul, bytes_format: BytesFormat::PostfixXbv, function_filter_syntax: None, + ilike_expression_options: ILikeExpressionOptions::UseLikeLower, } } ); diff --git a/src/dialect/inner.rs b/src/dialect/inner.rs index 278da11..fb9186c 100644 --- a/src/dialect/inner.rs +++ b/src/dialect/inner.rs @@ -54,6 +54,29 @@ pub enum LikeAnySyntax { OrExpansion, } +/// Does dialect support `ILIKE` expression or we should fallback to another options? +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ILikeExpressionOptions { + /// `ILIKE` is supported + Supported, + /// Use `LIKE` expression and `LOWER()` function + UseLikeLower, + /// Fallback to `LIKE` expression + UseLike, +} + +impl ILikeExpressionOptions { + /// `(use_ilike, wrap_lower)` when the LIKE is case-insensitive. + #[inline] + pub fn case_insensitive_sql(self) -> (bool, bool) { + match self { + Self::Supported => (true, false), + Self::UseLike => (false, false), + Self::UseLikeLower => (false, true), + } + } +} + /// How a boolean literal is spelled. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum BoolFormat { @@ -246,6 +269,8 @@ pub struct Dialect { pub bytes_format: BytesFormat, /// How function calling `FILTER` keyword rendered if supported. pub function_filter_syntax: Option, + /// How case-insensitive `LIKE` is spelled (`ILIKE`, `LIKE`, or `LOWER` + `LIKE`). + pub ilike_expression_options: ILikeExpressionOptions, } impl Dialect { @@ -498,6 +523,7 @@ mod tests { string_escape: StringEscape::Quote, bytes_format: BytesFormat::PrefixBigX, function_filter_syntax: None, + ilike_expression_options: ILikeExpressionOptions::UseLike, } } diff --git a/src/expr/enum_/like.rs b/src/expr/enum_/like.rs new file mode 100644 index 0000000..44fde3f --- /dev/null +++ b/src/expr/enum_/like.rs @@ -0,0 +1,201 @@ +//! `LIKE` / `ILIKE` / `LIKE ANY` AST node. + +use super::{ExprInner, nodes::write_escape}; +use crate::dialect::{PySqlDialect, inner::LikeAnySyntax}; +use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; +use std::fmt::Write; + +/// Pattern side of [`Like`]: one expression, or a non-empty ANY list. +#[derive(Clone, Debug, PartialEq)] +pub enum LikePattern { + /// `col LIKE p` + One(Box), + /// `col LIKE ANY …` — never empty. + Any(Vec), +} + +impl LikePattern { + fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + match self { + Self::One(p) => p.python_repr(py), + Self::Any(ps) => { + let mut buf = String::from("["); + for (i, item) in ps.iter().enumerate() { + if i > 0 { + buf.push_str(", "); + } + buf.push_str(&item.python_repr(py)?); + } + buf.push(']'); + Ok(buf) + } + } + } +} + +/// `expr [NOT] LIKE / ILIKE [ANY] pattern [ESCAPE char]`. +/// +/// `case_sensitive == false` follows the dialect's `ilike_expression_options` +/// (`ILIKE`, plain `LIKE`, or `LOWER(expr) LIKE LOWER(pattern)`). `ESCAPE` is +/// a single inlined character (never a bind). +/// +/// LIKE ANY follows [`LikeAnySyntax`]: Postgres `ANY (ARRAY[…])` wraps +/// negation as `NOT (x LIKE ANY …)` because `NOT LIKE ANY` is a quantified +/// NOT LIKE; Snowflake/Spark/Databricks use `LIKE ANY (p, …)`; everyone else +/// expands to parenthesized `OR` of scalar `LIKE`s (`AND` of `NOT LIKE` when +/// negated). +#[derive(Clone, Debug, PartialEq)] +pub struct Like { + pub case_sensitive: bool, + pub negated: bool, + pub expr: Box, + pub pattern: LikePattern, + pub escape: Option, +} + +impl Like { + pub(crate) const PRECEDENCE: u8 = 5; + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut buf = format!( + "LikeExpr({}, {}", + self.expr.python_repr(py)?, + self.pattern.python_repr(py)? + ); + if !self.case_sensitive { + buf.push_str(", case_sensitive=False"); + } + if self.negated { + buf.push_str(", negated=True"); + } + if let Some(c) = self.escape { + write!(buf, ", escape={c:?}").unwrap(); + } + buf.push(')'); + Ok(buf) + } +} + +fn write_like_operand( + dialect: &PySqlDialect, + out: &mut W, + expr: &ExprInner, + wrap_lower: bool, + paren_if_equal: bool, +) -> PrepareResult<()> { + if wrap_lower { + out.push_str("LOWER("); + expr.write_sql(dialect, out)?; + out.push_char(')'); + Ok(()) + } else { + expr.write_operand(dialect, out, Like::PRECEDENCE, paren_if_equal) + } +} + +fn write_like_keyword(out: &mut W, ilike: bool) { + out.push_str(if ilike { "ILIKE" } else { "LIKE" }); +} + +fn write_like_infix(out: &mut W, negated: bool, ilike: bool) { + out.push_char(' '); + if negated { + out.push_str("NOT "); + } + write_like_keyword(out, ilike); + out.push_char(' '); +} + +fn write_like_pattern_list( + dialect: &PySqlDialect, + out: &mut W, + patterns: &[ExprInner], + wrap_lower: bool, +) -> PrepareResult<()> { + if wrap_lower { + for (i, pattern) in patterns.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + write_like_operand(dialect, out, pattern, true, false)?; + } + Ok(()) + } else { + write_separated(dialect, out, patterns, ", ") + } +} + +/// `[NOT] (x LIKE|ILIKE ANY (ARRAY?[…]))` +fn write_like_any_native( + dialect: &PySqlDialect, + out: &mut W, + like: &Like, + patterns: &[ExprInner], + ilike: bool, + wrap_lower: bool, + array: bool, +) -> PrepareResult<()> { + if like.negated { + out.push_str("NOT ("); + } + write_like_operand(dialect, out, &like.expr, wrap_lower, false)?; + out.push_char(' '); + write_like_keyword(out, ilike); + if array { + out.push_str(" ANY (ARRAY["); + } else { + out.push_str(" ANY ("); + } + write_like_pattern_list(dialect, out, patterns, wrap_lower)?; + if array { + out.push_str("])"); + } else { + out.push_char(')'); + } + if like.negated { + out.push_char(')'); + } + Ok(()) +} + +impl WriteSql for Like { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + let (ilike, wrap_lower) = if self.case_sensitive { + (false, false) + } else { + dialect.0.ilike_expression_options.case_insensitive_sql() + }; + + match &self.pattern { + LikePattern::One(pattern) => { + write_like_operand(dialect, out, &self.expr, wrap_lower, false)?; + write_like_infix(out, self.negated, ilike); + write_like_operand(dialect, out, pattern, wrap_lower, true)?; + write_escape(dialect, out, self.escape) + } + LikePattern::Any(patterns) => match dialect.0.like_any_syntax { + LikeAnySyntax::ArrayConstructor => { + write_like_any_native(dialect, out, self, patterns, ilike, wrap_lower, true) + } + LikeAnySyntax::PatternList => { + write_like_any_native(dialect, out, self, patterns, ilike, wrap_lower, false) + } + LikeAnySyntax::OrExpansion => { + let sep = if self.negated { " AND " } else { " OR " }; + out.push_char('('); + for (i, pattern) in patterns.iter().enumerate() { + if i > 0 { + out.push_str(sep); + } + write_like_operand(dialect, out, &self.expr, wrap_lower, false)?; + write_like_infix(out, self.negated, ilike); + write_like_operand(dialect, out, pattern, wrap_lower, true)?; + write_escape(dialect, out, self.escape)?; + } + out.push_char(')'); + Ok(()) + } + }, + } + } +} diff --git a/src/expr/enum_/mod.rs b/src/expr/enum_/mod.rs index 7a8e8e6..b73c9b9 100644 --- a/src/expr/enum_/mod.rs +++ b/src/expr/enum_/mod.rs @@ -1,11 +1,13 @@ mod func; mod ident; +mod like; mod nodes; mod operator; mod value; pub use func::*; pub use ident::*; +pub use like::*; pub use nodes::*; pub use operator::*; pub use value::*; diff --git a/src/expr/enum_/nodes.rs b/src/expr/enum_/nodes.rs index 86da9d2..9379097 100644 --- a/src/expr/enum_/nodes.rs +++ b/src/expr/enum_/nodes.rs @@ -2,7 +2,7 @@ use super::{ BinaryOp, CastKind, ExprInner, Ident, IsKind, NormalizationForm, TrimWhere, UnaryOp, UnarySpacing, }; -use crate::dialect::{PySqlDialect, inner::LikeAnySyntax}; +use crate::dialect::PySqlDialect; use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; use std::fmt::Write; @@ -16,7 +16,7 @@ fn join_reprs(py: pyo3::Python<'_>, buf: &mut String, items: &[ExprInner]) -> py Ok(()) } -fn write_escape( +pub(super) fn write_escape( dialect: &PySqlDialect, out: &mut W, escape: Option, @@ -352,144 +352,6 @@ impl WriteSql for Between { } } -/// Pattern side of [`Like`]: one expression, or a non-empty ANY list. -#[derive(Clone, Debug, PartialEq)] -pub enum LikePattern { - /// `col LIKE p` - One(Box), - /// `col LIKE ANY …` — never empty. - Any(Vec), -} - -impl LikePattern { - fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - match self { - Self::One(p) => p.python_repr(py), - Self::Any(ps) => { - let mut buf = String::from("["); - join_reprs(py, &mut buf, ps)?; - buf.push(']'); - Ok(buf) - } - } - } -} - -/// `expr [NOT] LIKE / ILIKE [ANY] pattern [ESCAPE char]`. -/// -/// `case_sensitive == false` emits `ILIKE`. `ESCAPE` is a single inlined -/// character (never a bind). -/// -/// LIKE ANY follows [`LikeAnySyntax`]: Postgres `ANY (ARRAY[…])` wraps -/// negation as `NOT (x LIKE ANY …)` because `NOT LIKE ANY` is a quantified -/// NOT LIKE; Snowflake/Spark/Databricks use `LIKE ANY (p, …)`; everyone else -/// expands to parenthesized `OR` of scalar `LIKE`s (`AND` of `NOT LIKE` when -/// negated). -#[derive(Clone, Debug, PartialEq)] -pub struct Like { - pub case_sensitive: bool, - pub negated: bool, - pub expr: Box, - pub pattern: LikePattern, - pub escape: Option, -} - -impl Like { - pub(crate) const PRECEDENCE: u8 = 5; - - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = format!( - "LikeExpr({}, {}", - self.expr.python_repr(py)?, - self.pattern.python_repr(py)? - ); - if !self.case_sensitive { - buf.push_str(", case_sensitive=False"); - } - if self.negated { - buf.push_str(", negated=True"); - } - if let Some(c) = self.escape { - write!(buf, ", escape={c:?}").unwrap(); - } - buf.push(')'); - Ok(buf) - } -} - -fn write_like_op(out: &mut W, case_sensitive: bool, negated: bool) { - if negated { - out.push_str("NOT "); - } - if case_sensitive { - out.push_str("LIKE"); - } else { - out.push_str("ILIKE"); - } -} - -impl WriteSql for Like { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { - let prec = Self::PRECEDENCE; - match &self.pattern { - LikePattern::One(pattern) => { - self.expr.write_operand(dialect, out, prec, false)?; - out.push_char(' '); - write_like_op(out, self.case_sensitive, self.negated); - out.push_char(' '); - pattern.write_operand(dialect, out, prec, true)?; - write_escape(dialect, out, self.escape) - } - LikePattern::Any(patterns) => match dialect.0.like_any_syntax { - LikeAnySyntax::ArrayConstructor => { - // `NOT LIKE ANY` in Postgres is a quantified NOT LIKE - // (exists a non-match), not "matches none". - if self.negated { - out.push_str("NOT ("); - } - self.expr.write_operand(dialect, out, prec, false)?; - out.push_char(' '); - write_like_op(out, self.case_sensitive, false); - out.push_str(" ANY (ARRAY["); - write_separated(dialect, out, patterns, ", ")?; - out.push_str("])"); - write_escape(dialect, out, self.escape)?; - if self.negated { - out.push_char(')'); - } - Ok(()) - } - LikeAnySyntax::PatternList => { - self.expr.write_operand(dialect, out, prec, false)?; - out.push_char(' '); - write_like_op(out, self.case_sensitive, self.negated); - out.push_str(" ANY ("); - write_separated(dialect, out, patterns, ", ")?; - out.push_char(')'); - write_escape(dialect, out, self.escape) - } - LikeAnySyntax::OrExpansion => { - out.push_char('('); - let sep = if self.negated { " AND " } else { " OR " }; - for (i, pattern) in patterns.iter().enumerate() { - if i > 0 { - out.push_str(sep); - } - self.expr.write_operand(dialect, out, prec, false)?; - out.push_char(' '); - write_like_op(out, self.case_sensitive, self.negated); - out.push_char(' '); - pattern.write_operand(dialect, out, prec, true)?; - write_escape(dialect, out, self.escape)?; - } - out.push_char(')'); - Ok(()) - } - }, - } - } -} - /// `expr [NOT] SIMILAR TO pattern [ESCAPE char]`. #[derive(Clone, Debug, PartialEq)] pub struct SimilarTo { From 4ca3bd9063e3f7cb453287b633010bc4fb83b448 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Sat, 26 Sep 2026 14:46:31 +0330 Subject: [PATCH 48/87] Fix SIMILAR TO and RLIKE expressions --- rapidquery/expr.pyi | 26 +----- src/dialect/builtin.rs | 26 ++++++ src/dialect/inner.rs | 23 +++++ src/expr/enum_/like.rs | 180 +++++++++++++++++++++++++++++++++++++++- src/expr/enum_/nodes.rs | 92 -------------------- src/expr/nodes.rs | 20 +---- tests/test_expr.py | 77 +++++++++-------- 7 files changed, 271 insertions(+), 173 deletions(-) diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index 14636e5..490d6fd 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -809,7 +809,6 @@ class SimilarToExpr(Expr): /, *, negated: bool = False, - escape: str | None = None, ) -> Self: """ Create a ``SIMILAR TO`` predicate. @@ -818,7 +817,6 @@ class SimilarToExpr(Expr): expr: The tested expression. pattern: The pattern. negated: If ``True``, emit ``NOT``. - escape: Optional single-character ``ESCAPE`` clause. Returns: A new ``SimilarToExpr``. @@ -848,17 +846,9 @@ class SimilarToExpr(Expr): ``True`` if the predicate is negated. """ - def get_escape(self) -> str | None: - """ - Return the ``ESCAPE`` character, if any. - - Returns: - A single character, or ``None``. - """ - @final class RLikeExpr(Expr): - """``expr [NOT] RLIKE pattern``.""" + """Regex match (``RLIKE``, ``~``, ``REGEXP_LIKE``, …).""" def __new__( cls, @@ -867,16 +857,14 @@ class RLikeExpr(Expr): /, *, negated: bool = False, - escape: str | None = None, ) -> Self: """ - Create a ``RLIKE`` predicate. + Create a regex-match predicate. Args: expr: The tested expression. pattern: The pattern. - negated: If ``True``, emit ``NOT``. - escape: Optional single-character ``ESCAPE`` clause. + negated: If ``True``, emit ``NOT`` / ``!~``. Returns: A new ``RLikeExpr``. @@ -906,14 +894,6 @@ class RLikeExpr(Expr): ``True`` if the predicate is negated. """ - def get_escape(self) -> str | None: - """ - Return the ``ESCAPE`` character, if any. - - Returns: - A single character, or ``None``. - """ - @final class CastExpr(Expr): """``CAST(expr AS type [FORMAT format [AT TIME ZONE tz]])``.""" diff --git a/src/dialect/builtin.rs b/src/dialect/builtin.rs index 45b9219..0fadcde 100644 --- a/src/dialect/builtin.rs +++ b/src/dialect/builtin.rs @@ -50,6 +50,8 @@ impl_builtin_dialects!( bytes_format: BytesFormat::PrefixBigBSlashX, function_filter_syntax: Some(FunctionFilterSyntax::InsideWhere), ilike_expression_options: ILikeExpressionOptions::UseLikeLower, + supports_similar_to: false, + rlike_expression_options: Some(RLikeExpressionOptions::RegexpContains), } } @@ -71,6 +73,8 @@ impl_builtin_dialects!( bytes_format: BytesFormat::PrefixBigX, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::Supported, + supports_similar_to: false, + rlike_expression_options: Some(RLikeExpressionOptions::InfixRLike), } } @@ -92,6 +96,8 @@ impl_builtin_dialects!( bytes_format: BytesFormat::PrefixBigX, function_filter_syntax: Some(FunctionFilterSyntax::Filter), ilike_expression_options: ILikeExpressionOptions::Supported, + supports_similar_to: true, + rlike_expression_options: Some(RLikeExpressionOptions::RegexpMatches), } } @@ -113,6 +119,8 @@ impl_builtin_dialects!( bytes_format: BytesFormat::Unhex, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLikeLower, + supports_similar_to: false, + rlike_expression_options: Some(RLikeExpressionOptions::InfixRLike), } } @@ -134,6 +142,8 @@ impl_builtin_dialects!( bytes_format: BytesFormat::PrefixZeroX, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLike, + supports_similar_to: false, + rlike_expression_options: None, } } @@ -155,6 +165,8 @@ impl_builtin_dialects!( bytes_format: BytesFormat::PrefixBigX, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLike, + supports_similar_to: false, + rlike_expression_options: Some(RLikeExpressionOptions::InfixRLike), } } @@ -176,6 +188,8 @@ impl_builtin_dialects!( bytes_format: BytesFormat::HexToRaw, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::UseLikeLower, + supports_similar_to: false, + rlike_expression_options: Some(RLikeExpressionOptions::RegexpLike), } } @@ -197,6 +211,8 @@ impl_builtin_dialects!( bytes_format: BytesFormat::SlashXBytea, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::Supported, + supports_similar_to: true, + rlike_expression_options: Some(RLikeExpressionOptions::Tilde), } } @@ -218,6 +234,8 @@ impl_builtin_dialects!( bytes_format: BytesFormat::ToVarbyte, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::Supported, + supports_similar_to: true, + rlike_expression_options: Some(RLikeExpressionOptions::Tilde), } } @@ -239,6 +257,8 @@ impl_builtin_dialects!( bytes_format: BytesFormat::PrefixBigX, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::Supported, + supports_similar_to: false, + rlike_expression_options: Some(RLikeExpressionOptions::InfixRLike), } } @@ -260,6 +280,8 @@ impl_builtin_dialects!( bytes_format: BytesFormat::PrefixBigX, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::Supported, + supports_similar_to: false, + rlike_expression_options: Some(RLikeExpressionOptions::InfixRLike), } } @@ -281,6 +303,8 @@ impl_builtin_dialects!( bytes_format: BytesFormat::PrefixBigX, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::UseLike, + supports_similar_to: false, + rlike_expression_options: None, } } @@ -302,6 +326,8 @@ impl_builtin_dialects!( bytes_format: BytesFormat::PostfixXbv, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLikeLower, + supports_similar_to: false, + rlike_expression_options: None, } } ); diff --git a/src/dialect/inner.rs b/src/dialect/inner.rs index fb9186c..3f19c85 100644 --- a/src/dialect/inner.rs +++ b/src/dialect/inner.rs @@ -77,6 +77,23 @@ impl ILikeExpressionOptions { } } +/// How regex `RLike` is spelled. +/// +/// [`None`] on [`Dialect::rlike_expression_options`] means unsupported. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RLikeExpressionOptions { + /// `expr [NOT] RLIKE pattern` — MySQL, Hive, Spark, Databricks, Snowflake. + InfixRLike, + /// `expr ~ pattern` / `expr !~ pattern` — PostgreSQL, Redshift. + Tilde, + /// `REGEXP_LIKE(expr, pattern)` — Oracle. + RegexpLike, + /// `REGEXP_CONTAINS(expr, pattern)` — BigQuery. + RegexpContains, + /// `regexp_matches(expr, pattern)` — DuckDB (substring; DuckDB `~` is full match). + RegexpMatches, +} + /// How a boolean literal is spelled. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum BoolFormat { @@ -271,6 +288,10 @@ pub struct Dialect { pub function_filter_syntax: Option, /// How case-insensitive `LIKE` is spelled (`ILIKE`, `LIKE`, or `LOWER` + `LIKE`). pub ilike_expression_options: ILikeExpressionOptions, + /// Whether `SIMILAR TO` is documented. + pub supports_similar_to: bool, + /// How regex `RLike` is spelled. [`None`] means unsupported. + pub rlike_expression_options: Option, } impl Dialect { @@ -524,6 +545,8 @@ mod tests { bytes_format: BytesFormat::PrefixBigX, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLike, + supports_similar_to: false, + rlike_expression_options: None, } } diff --git a/src/expr/enum_/like.rs b/src/expr/enum_/like.rs index 44fde3f..1a7106a 100644 --- a/src/expr/enum_/like.rs +++ b/src/expr/enum_/like.rs @@ -1,7 +1,10 @@ -//! `LIKE` / `ILIKE` / `LIKE ANY` AST node. +//! `LIKE` / `ILIKE` / `LIKE ANY` / `SIMILAR TO` / `RLIKE` AST nodes. use super::{ExprInner, nodes::write_escape}; -use crate::dialect::{PySqlDialect, inner::LikeAnySyntax}; +use crate::dialect::{ + PySqlDialect, + inner::{LikeAnySyntax, RLikeExpressionOptions}, +}; use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; use std::fmt::Write; @@ -199,3 +202,176 @@ impl WriteSql for Like { } } } + +/// `expr [NOT] SIMILAR TO pattern`. +/// +/// Emitted only when [`Dialect::supports_similar_to`](crate::dialect::inner::Dialect::supports_similar_to) +/// is set (PostgreSQL, Redshift, DuckDB). Other dialects raise. +/// +/// No `ESCAPE` clause: regex engines use `\` (and dialect string-literal +/// rules). See [PostgreSQL pattern matching](https://www.postgresql.org/docs/current/functions-matching.html), +/// [Redshift SIMILAR TO](https://docs.aws.amazon.com/redshift/latest/dg/pattern-matching-conditions-similar-to.html), +/// [DuckDB pattern matching](https://duckdb.org/docs/current/sql/functions/pattern_matching.html). +#[derive(Clone, Debug, PartialEq)] +pub struct SimilarTo { + pub negated: bool, + pub expr: Box, + pub pattern: Box, +} + +impl SimilarTo { + pub(crate) const PRECEDENCE: u8 = 5; + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut buf = format!( + "SimilarToExpr({}, {}", + self.expr.python_repr(py)?, + self.pattern.python_repr(py)? + ); + if self.negated { + buf.push_str(", negated=True"); + } + buf.push(')'); + Ok(buf) + } +} + +impl WriteSql for SimilarTo { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + if !dialect.0.supports_similar_to { + return Err(rapidquery_macros::pyerr!( + PyRuntimeError, + "SIMILAR TO is not supported by this dialect", + ) + .into()); + } + + self.expr + .write_operand(dialect, out, Self::PRECEDENCE, false)?; + out.push_char(' '); + if self.negated { + out.push_str("NOT SIMILAR TO "); + } else { + out.push_str("SIMILAR TO "); + } + self.pattern + .write_operand(dialect, out, Self::PRECEDENCE, true) + } +} + +/// Regex match: spelling follows [`RLikeExpressionOptions`]. +/// +/// - MySQL / Hive / Spark / Databricks / Snowflake: `expr [NOT] RLIKE pattern` +/// ([MySQL REGEXP](https://dev.mysql.com/doc/refman/8.4/en/regexp.html), +/// [Spark LIKE/RLIKE](https://spark.apache.org/docs/latest/sql-ref-syntax-qry-select-like.html), +/// [Snowflake RLIKE](https://docs.snowflake.com/en/sql-reference/functions/rlike)). +/// Snowflake implicitly anchors the pattern (`^…$`); that rewrite is later. +/// - PostgreSQL / Redshift: `expr ~ pattern` / `expr !~ pattern` +/// ([PostgreSQL POSIX](https://www.postgresql.org/docs/current/functions-matching.html)). +/// - Oracle: `REGEXP_LIKE(expr, pattern)`. +/// - BigQuery: `REGEXP_CONTAINS(expr, pattern)`. +/// - DuckDB: `regexp_matches(expr, pattern)` (substring; DuckDB `~` is full match). +/// - MsSql / Sqlite / Teradata: raise. +/// +/// Function names are unquoted. No `ESCAPE` clause. +#[derive(Clone, Debug, PartialEq)] +pub struct RLike { + pub negated: bool, + pub expr: Box, + pub pattern: Box, +} + +impl RLike { + pub(crate) const PRECEDENCE: u8 = 5; + + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut buf = format!( + "RLikeExpr({}, {}", + self.expr.python_repr(py)?, + self.pattern.python_repr(py)? + ); + if self.negated { + buf.push_str(", negated=True"); + } + buf.push(')'); + Ok(buf) + } +} + +/// `NOT? name(expr, pattern)` — unquoted function rewrite. +fn write_regex_call( + dialect: &PySqlDialect, + out: &mut W, + name: &str, + negated: bool, + expr: &ExprInner, + pattern: &ExprInner, +) -> PrepareResult<()> { + if negated { + out.push_str("NOT "); + } + out.push_str(name); + out.push_char('('); + expr.write_sql(dialect, out)?; + out.push_str(", "); + pattern.write_sql(dialect, out)?; + out.push_char(')'); + Ok(()) +} + +impl WriteSql for RLike { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + let Some(opt) = dialect.0.rlike_expression_options else { + return Err(rapidquery_macros::pyerr!( + PyRuntimeError, + "RLIKE is not supported by this dialect", + ) + .into()); + }; + + match opt { + RLikeExpressionOptions::InfixRLike => { + self.expr + .write_operand(dialect, out, Self::PRECEDENCE, false)?; + if self.negated { + out.push_str(" NOT RLIKE "); + } else { + out.push_str(" RLIKE "); + } + self.pattern + .write_operand(dialect, out, Self::PRECEDENCE, true) + } + RLikeExpressionOptions::Tilde => { + self.expr + .write_operand(dialect, out, Self::PRECEDENCE, false)?; + out.push_str(if self.negated { " !~ " } else { " ~ " }); + self.pattern + .write_operand(dialect, out, Self::PRECEDENCE, true) + } + RLikeExpressionOptions::RegexpLike => write_regex_call( + dialect, + out, + "REGEXP_LIKE", + self.negated, + &self.expr, + &self.pattern, + ), + RLikeExpressionOptions::RegexpContains => write_regex_call( + dialect, + out, + "REGEXP_CONTAINS", + self.negated, + &self.expr, + &self.pattern, + ), + RLikeExpressionOptions::RegexpMatches => write_regex_call( + dialect, + out, + "regexp_matches", + self.negated, + &self.expr, + &self.pattern, + ), + } + } +} diff --git a/src/expr/enum_/nodes.rs b/src/expr/enum_/nodes.rs index 9379097..b372af7 100644 --- a/src/expr/enum_/nodes.rs +++ b/src/expr/enum_/nodes.rs @@ -352,98 +352,6 @@ impl WriteSql for Between { } } -/// `expr [NOT] SIMILAR TO pattern [ESCAPE char]`. -#[derive(Clone, Debug, PartialEq)] -pub struct SimilarTo { - pub negated: bool, - pub expr: Box, - pub pattern: Box, - pub escape: Option, -} - -impl SimilarTo { - pub(crate) const PRECEDENCE: u8 = 5; - - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = format!( - "SimilarToExpr({}, {}", - self.expr.python_repr(py)?, - self.pattern.python_repr(py)? - ); - if self.negated { - buf.push_str(", negated=True"); - } - if let Some(c) = self.escape { - write!(buf, ", escape={c:?}").unwrap(); - } - buf.push(')'); - Ok(buf) - } -} - -impl WriteSql for SimilarTo { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { - self.expr - .write_operand(dialect, out, Self::PRECEDENCE, false)?; - out.push_char(' '); - if self.negated { - out.push_str("NOT SIMILAR TO "); - } else { - out.push_str("SIMILAR TO "); - } - - self.pattern - .write_operand(dialect, out, Self::PRECEDENCE, true)?; - write_escape(dialect, out, self.escape) - } -} - -/// `expr [NOT] RLIKE pattern [ESCAPE char]`. -#[derive(Clone, Debug, PartialEq)] -pub struct RLike { - pub negated: bool, - pub expr: Box, - pub pattern: Box, - pub escape: Option, -} - -impl RLike { - pub(crate) const PRECEDENCE: u8 = 5; - - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = format!( - "RLikeExpr({}, {}", - self.expr.python_repr(py)?, - self.pattern.python_repr(py)? - ); - if self.negated { - buf.push_str(", negated=True"); - } - if let Some(c) = self.escape { - write!(buf, ", escape={c:?}").unwrap(); - } - buf.push(')'); - Ok(buf) - } -} - -impl WriteSql for RLike { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { - self.expr - .write_operand(dialect, out, Self::PRECEDENCE, false)?; - out.push_char(' '); - if self.negated { - out.push_str("NOT RLIKE "); - } else { - out.push_str("RLIKE "); - } - - self.pattern - .write_operand(dialect, out, Self::PRECEDENCE, true)?; - write_escape(dialect, out, self.escape) - } -} - /// `KIND(expr AS type [FORMAT format [AT TIME ZONE tz]])`. /// /// [`kind`](Self::kind) is `CAST` / `TRY_CAST` / `SAFE_CAST`. `FORMAT` and diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index 9dbc84a..68175bb 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -77,7 +77,7 @@ empty_node! { /// `expr [NOT] SIMILAR TO pattern`. PySimilarToExpr(SimilarTo(super::enum_::SimilarTo)) => "SimilarToExpr", - /// `expr [NOT] RLIKE pattern`. + /// Regex match (`RLIKE`, `~`, `REGEXP_LIKE`, …). PyRLikeExpr(RLike(super::enum_::RLike)) => "RLikeExpr", /// `CAST(expr AS type [FORMAT …])`. @@ -467,18 +467,16 @@ impl PyLikeExpr { #[pyo3::pymethods] impl PySimilarToExpr { #[new] - #[pyo3(signature = (expr, pattern, /, *, negated = false, escape = None))] + #[pyo3(signature = (expr, pattern, /, *, negated = false))] fn __new__( expr: LiteralExprExtractor, pattern: LiteralExprExtractor, negated: bool, - escape: Option, ) -> pyo3::PyClassInitializer { let inner = super::enum_::SimilarTo { negated, expr: Box::new(expr.0), pattern: Box::new(pattern.0), - escape, }; pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self) } @@ -497,28 +495,21 @@ impl PySimilarToExpr { let inner = Self::inner(&slf); inner.negated } - - fn get_escape(slf: pyo3::PyRef<'_, Self>) -> Option { - let inner = Self::inner(&slf); - inner.escape - } } #[pyo3::pymethods] impl PyRLikeExpr { #[new] - #[pyo3(signature = (expr, pattern, /, *, negated = false, escape = None))] + #[pyo3(signature = (expr, pattern, /, *, negated = false))] fn __new__( expr: LiteralExprExtractor, pattern: LiteralExprExtractor, negated: bool, - escape: Option, ) -> pyo3::PyClassInitializer { let inner = super::enum_::RLike { negated, expr: Box::new(expr.0), pattern: Box::new(pattern.0), - escape, }; pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self) } @@ -537,11 +528,6 @@ impl PyRLikeExpr { let inner = Self::inner(&slf); inner.negated } - - fn get_escape(slf: pyo3::PyRef<'_, Self>) -> Option { - let inner = Self::inner(&slf); - inner.escape - } } #[pyo3::pymethods] diff --git a/tests/test_expr.py b/tests/test_expr.py index 5eac6bb..0b013bd 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -4,8 +4,13 @@ from rapidquery.dialect import ( BigQuery, DuckDb, + MsSql, + MySql, + Oracle, PostgreSql, + Redshift, Snowflake, + Spark, SqlDialect, Sqlite, ) @@ -721,47 +726,40 @@ def test_new(self): assert sim.get_expr() == Ident("name") assert sim.get_pattern() == Value("%a%") assert sim.get_negated() is False - assert sim.get_escape() is None - sim = SimilarToExpr( - Ident("name"), - Ident("pattern"), - negated=True, - escape="!", - ) + sim = SimilarToExpr(Ident("name"), Ident("pattern"), negated=True) assert sim.get_expr() == Ident("name") assert sim.get_pattern() == Ident("pattern") assert sim.get_negated() is True - assert sim.get_escape() == "!" - - with pytest.raises((TypeError, ValueError)): - SimilarToExpr(Ident("n"), "p", escape="ab") # pyright: ignore def test_to_sql(self): sim = SimilarToExpr(Ident("name"), "%a%") assert sim.to_sql(PostgreSql()) == "\"name\" SIMILAR TO E'%a%'" + assert sim.to_sql(Redshift()) == "\"name\" SIMILAR TO '%a%'" + assert sim.to_sql(DuckDb()) == "\"name\" SIMILAR TO '%a%'" assert ( SimilarToExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSql()) == "\"name\" NOT SIMILAR TO E'%a%'" ) - assert ( - SimilarToExpr(Ident("name"), "%a%", escape="!").to_sql(PostgreSql()) - == "\"name\" SIMILAR TO E'%a%' ESCAPE E'!'" - ) - sql, params = SimilarToExpr(Ident("name"), "%a%").prepare(PostgreSql()) + sql, params = sim.prepare(PostgreSql()) assert sql == '"name" SIMILAR TO :param_0' assert params == {"param_0": "%a%"} + with pytest.raises(RuntimeError, match="SIMILAR TO is not supported"): + sim.to_sql(MySql()) + with pytest.raises(RuntimeError, match="SIMILAR TO is not supported"): + sim.to_sql(Sqlite()) + assert ( - repr(SimilarToExpr(Ident("name"), "%a%")) + repr(sim) == "SimilarToExpr(Ident(\"name\"), Value('%a%', StringSerializer()))" ) assert ( - repr(SimilarToExpr(Ident("name"), "%a%", negated=True, escape="!")) + repr(SimilarToExpr(Ident("name"), "%a%", negated=True)) == "SimilarToExpr(Ident(\"name\"), Value('%a%', StringSerializer()), " - "negated=True, escape='!')" + "negated=True)" ) @@ -771,47 +769,48 @@ def test_new(self): assert rlike.get_expr() == Ident("name") assert rlike.get_pattern() == Value("%a%") assert rlike.get_negated() is False - assert rlike.get_escape() is None - rlike = RLikeExpr( - Ident("name"), - Ident("pattern"), - negated=True, - escape="!", - ) + rlike = RLikeExpr(Ident("name"), Ident("pattern"), negated=True) assert rlike.get_expr() == Ident("name") assert rlike.get_pattern() == Ident("pattern") assert rlike.get_negated() is True - assert rlike.get_escape() == "!" - - with pytest.raises((TypeError, ValueError)): - RLikeExpr(Ident("n"), "p", escape="ab") # pyright: ignore def test_to_sql(self): rlike = RLikeExpr(Ident("name"), "%a%") - assert rlike.to_sql(PostgreSql()) == "\"name\" RLIKE E'%a%'" + assert rlike.to_sql(PostgreSql()) == "\"name\" ~ E'%a%'" + assert rlike.to_sql(MySql()) == "`name` RLIKE '%a%'" + assert rlike.to_sql(Spark()) == "`name` RLIKE '%a%'" + assert rlike.to_sql(Snowflake()) == "\"name\" RLIKE '%a%'" + assert rlike.to_sql(Oracle()) == 'REGEXP_LIKE("name", \'%a%\')' + assert rlike.to_sql(BigQuery()) == "REGEXP_CONTAINS(`name`, '%a%')" + assert rlike.to_sql(DuckDb()) == 'regexp_matches("name", \'%a%\')' assert ( RLikeExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSql()) - == "\"name\" NOT RLIKE E'%a%'" + == "\"name\" !~ E'%a%'" ) assert ( - RLikeExpr(Ident("name"), "%a%", escape="!").to_sql(PostgreSql()) - == "\"name\" RLIKE E'%a%' ESCAPE E'!'" + RLikeExpr(Ident("name"), "%a%", negated=True).to_sql(Oracle()) + == 'NOT REGEXP_LIKE("name", \'%a%\')' ) - sql, params = RLikeExpr(Ident("name"), "%a%").prepare(PostgreSql()) - assert sql == '"name" RLIKE :param_0' + sql, params = rlike.prepare(PostgreSql()) + assert sql == '"name" ~ :param_0' assert params == {"param_0": "%a%"} + with pytest.raises(RuntimeError, match="RLIKE is not supported"): + rlike.to_sql(Sqlite()) + with pytest.raises(RuntimeError, match="RLIKE is not supported"): + rlike.to_sql(MsSql()) + assert ( - repr(RLikeExpr(Ident("name"), "%a%")) + repr(rlike) == "RLikeExpr(Ident(\"name\"), Value('%a%', StringSerializer()))" ) assert ( - repr(RLikeExpr(Ident("name"), "%a%", negated=True, escape="!")) + repr(RLikeExpr(Ident("name"), "%a%", negated=True)) == "RLikeExpr(Ident(\"name\"), Value('%a%', StringSerializer()), " - "negated=True, escape='!')" + "negated=True)" ) From f10cd460ed52e1f5dc07bbfeabe4ff7d407fa978 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 28 Sep 2026 17:35:02 +0330 Subject: [PATCH 49/87] Update .gitignore --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 965592f..13ca0d8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ /target __pycache__/ *.so -backup/ -backup-py/ /.vscode /*_cache +/.zed From 6402be0fa843412279f7d7c53572daa4aec6b46c Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 28 Sep 2026 17:52:56 +0330 Subject: [PATCH 50/87] Remove dialect-specific unary operators. Use CustomExpr for this dialect-specific unaries. Remove XOR binary operator because it was ONLY for MySQL. Remove IsNormalizedExpr because it was ONLY for PostgreSQL. --- rapidquery/expr.pyi | 97 ++------------------------- src/expr/base.rs | 1 - src/expr/enum_/mod.rs | 6 -- src/expr/enum_/nodes.rs | 55 +--------------- src/expr/enum_/operator.rs | 130 +++++++------------------------------ src/expr/mod.rs | 4 +- src/expr/nodes.rs | 50 -------------- 7 files changed, 30 insertions(+), 313 deletions(-) diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index 490d6fd..d907022 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -333,27 +333,11 @@ class TupleExpr(Expr): @final class UnaryExpr(Expr): - """Unary expression (``NOT x``, ``-x``, ``|/x``, ``x!``, …).""" + """Unary expression (``NOT x``, …).""" def __new__( cls, - operator: typing.Literal[ - "NOT", - "+", - "-", - "~", - "@-@", - "@@", - "#", - "@", - "||/", - "!", - "!!", - "|/", - "?-", - "?|", - ] - | str, + operator: typing.Literal["NOT", "+", "-", "~"] | str, expr: object, /, ) -> Self: @@ -361,10 +345,7 @@ class UnaryExpr(Expr): Create a unary expression. Args: - operator: A known unary spelling (``NOT``, ``+``, ``-``, - ``~``, ``|/``, ``||/``, ``@``, ``!!``, postfix ``!``, - ``#``, ``@-@``, ``@@``, ``?-``, ``?|``). ``!`` is - postfix factorial, not Hive prefix NOT. + operator: A known unary spelling (``NOT``, ``+``, ``-``, ``~``). expr: The operand. Returns: @@ -376,27 +357,12 @@ class UnaryExpr(Expr): def get_operator( self, - ) -> typing.Literal[ - "NOT", - "+", - "-", - "~", - "@-@", - "@@", - "#", - "@", - "||/", - "!", - "!!", - "|/", - "?-", - "?|", - ]: + ) -> typing.Literal["NOT", "+", "-", "~"]: """ Return the unary operator spelling. Returns: - The canonical SQL token (``NOT``, ``|/``, ``!``, …). + The canonical SQL token. """ def get_expr(self) -> Expr: @@ -535,59 +501,6 @@ class IsExpr(Expr): ``True`` if the predicate is negated. """ -@final -class IsNormalizedExpr(Expr): - """``expr IS [NOT] [form] NORMALIZED``.""" - - def __new__( - cls, - expr: object, - form: typing.Literal["NFC", "NFD", "NFKC", "NFKD"] | str | None = None, - /, - *, - negated: bool = False, - ) -> Self: - """ - Create an ``IS [form] NORMALIZED`` predicate. - - Args: - expr: The tested expression. - form: Optional ``NFC``, ``NFD``, ``NFKC``, or ``NFKD``. - Omitted form emits ``IS NORMALIZED`` (PostgreSQL - defaults to NFC). - negated: If ``True``, emit ``IS NOT``. - - Returns: - A new ``IsNormalizedExpr``. - - Raises: - ValueError: If ``form`` is not one of the accepted spellings. - """ - - def get_expr(self) -> Expr: - """ - Return the tested expression. - - Returns: - The left-hand expression. - """ - - def get_form(self) -> typing.Literal["NFC", "NFD", "NFKC", "NFKD"] | None: - """ - Return the normalization form, if any. - - Returns: - ``NFC``, ``NFD``, ``NFKC``, ``NFKD``, or ``None``. - """ - - def get_negated(self) -> bool: - """ - Return whether this is ``IS NOT … NORMALIZED``. - - Returns: - ``True`` if the predicate is negated. - """ - @final class IsDistinctFromExpr(Expr): """``a IS [NOT] DISTINCT FROM b``.""" diff --git a/src/expr/base.rs b/src/expr/base.rs index 0cdcbd0..5708acb 100644 --- a/src/expr/base.rs +++ b/src/expr/base.rs @@ -64,7 +64,6 @@ impl PyExpr { ExprInner::Binary(_) => wrap::(py, self), ExprInner::Function(_) => wrap::(py, self), ExprInner::Is(_) => wrap::(py, self), - ExprInner::IsNormalized(_) => wrap::(py, self), ExprInner::IsDistinctFrom(_) => wrap::(py, self), ExprInner::InList(_) => wrap::(py, self), ExprInner::Between(_) => wrap::(py, self), diff --git a/src/expr/enum_/mod.rs b/src/expr/enum_/mod.rs index b73c9b9..296eb66 100644 --- a/src/expr/enum_/mod.rs +++ b/src/expr/enum_/mod.rs @@ -41,8 +41,6 @@ pub enum ExprInner { Function(Function), /// `expr IS [NOT] {NULL|TRUE|FALSE|UNKNOWN}`. Is(Is), - /// `expr IS [NOT] [form] NORMALIZED`. - IsNormalized(IsNormalized), /// `left IS [NOT] DISTINCT FROM right`. IsDistinctFrom(IsDistinctFrom), /// `expr [NOT] IN (list…)`. @@ -80,7 +78,6 @@ impl_from_expr_inner! { Binary => Binary, Function => Function, Is => Is, - IsNormalized => IsNormalized, IsDistinctFrom => IsDistinctFrom, InList => InList, Between => Between, @@ -111,7 +108,6 @@ impl ExprInner { Self::Unary(_) => Unary::PRECEDENCE, Self::Binary(b) => b.precedence(), Self::Is(_) => Is::PRECEDENCE, - Self::IsNormalized(_) => IsNormalized::PRECEDENCE, Self::InList(_) => InList::PRECEDENCE, Self::IsDistinctFrom(_) => IsDistinctFrom::PRECEDENCE, Self::Between(_) => Between::PRECEDENCE, @@ -161,7 +157,6 @@ impl ExprInner { Self::Binary(x) => x.python_repr(py), Self::Function(x) => x.python_repr(py), Self::Is(x) => x.python_repr(py), - Self::IsNormalized(x) => x.python_repr(py), Self::IsDistinctFrom(x) => x.python_repr(py), Self::InList(x) => x.python_repr(py), Self::Between(x) => x.python_repr(py), @@ -188,7 +183,6 @@ impl WriteSql for ExprInner { Self::Binary(x) => x.write_sql(dialect, out), Self::Function(x) => x.write_sql(dialect, out), Self::Is(x) => x.write_sql(dialect, out), - Self::IsNormalized(x) => x.write_sql(dialect, out), Self::IsDistinctFrom(x) => x.write_sql(dialect, out), Self::InList(x) => x.write_sql(dialect, out), Self::Between(x) => x.write_sql(dialect, out), diff --git a/src/expr/enum_/nodes.rs b/src/expr/enum_/nodes.rs index b372af7..f18d105 100644 --- a/src/expr/enum_/nodes.rs +++ b/src/expr/enum_/nodes.rs @@ -1,7 +1,4 @@ -use super::{ - BinaryOp, CastKind, ExprInner, Ident, IsKind, NormalizationForm, TrimWhere, UnaryOp, - UnarySpacing, -}; +use super::{BinaryOp, CastKind, ExprInner, Ident, IsKind, TrimWhere, UnaryOp, UnarySpacing}; use crate::dialect::PySqlDialect; use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; use std::fmt::Write; @@ -85,12 +82,6 @@ impl Unary { impl WriteSql for Unary { fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { match self.op.spacing() { - UnarySpacing::Postfix => { - self.expr - .write_operand(dialect, out, Self::PRECEDENCE, true)?; - out.push_char('!'); - Ok(()) - } UnarySpacing::SpacedPrefix => { out.push_str(self.op.as_sql()); out.push_char(' '); @@ -183,50 +174,6 @@ impl WriteSql for Is { } } -/// `expr IS [NOT] [form] NORMALIZED`. -/// -/// When [`form`](Self::form) is `None`, the form token is omitted (`IS NORMALIZED`). -#[derive(Clone, Debug, PartialEq)] -pub struct IsNormalized { - pub expr: Box, - pub negated: bool, - pub form: Option, -} - -impl IsNormalized { - pub(crate) const PRECEDENCE: u8 = 5; - - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = format!("IsNormalizedExpr({}", self.expr.python_repr(py)?); - if let Some(form) = self.form { - write!(buf, ", {:?}", form.as_sql()).unwrap(); - } - if self.negated { - buf.push_str(", negated=True"); - } - buf.push(')'); - Ok(buf) - } -} - -impl WriteSql for IsNormalized { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { - self.expr - .write_operand(dialect, out, Self::PRECEDENCE, false)?; - if self.negated { - out.push_str(" IS NOT "); - } else { - out.push_str(" IS "); - } - if let Some(form) = self.form { - out.push_str(form.as_sql()); - out.push_char(' '); - } - out.push_str("NORMALIZED"); - Ok(()) - } -} - /// `left IS [NOT] DISTINCT FROM right`. #[derive(Clone, Debug, PartialEq)] pub struct IsDistinctFrom { diff --git a/src/expr/enum_/operator.rs b/src/expr/enum_/operator.rs index 39541b3..1dfcf2f 100644 --- a/src/expr/enum_/operator.rs +++ b/src/expr/enum_/operator.rs @@ -5,46 +5,28 @@ pub enum UnaryOp { Plus, Minus, BitNot, - /// `@-@` — PostgreSQL/Redshift geometric length. - AtDashAt, - /// `@@` — PostgreSQL/Redshift geometric center. - DoubleAt, - /// `#` — PostgreSQL/Redshift geometric npoints. - Hash, - /// `@` — PostgreSQL absolute value. - PGAbs, - /// `||/` — PostgreSQL cube root. - PGCubeRoot, - /// `!` postfix — PostgreSQL factorial (`9!`). - PGPostfixFactorial, - /// `!!` — PostgreSQL prefix factorial. - PGPrefixFactorial, - /// `|/` — PostgreSQL square root. - PGSquareRoot, - /// `?-` — PostgreSQL/Redshift geometric is-horizontal. - QuestionDash, - /// `?|` — PostgreSQL/Redshift geometric is-vertical. - QuestionPipe, +} + +/// How a [`UnaryOp`] is written relative to its operand. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UnarySpacing { + /// Operator, space, operand (`NOT x`, `# path`). + SpacedPrefix, + /// Operator glued to the operand (`-1`, `|/9`). + GluedPrefix, } impl UnaryOp { #[inline] pub fn parse(s: &str) -> Option { + if s.eq_ignore_ascii_case("not") { + return Some(Self::Not); + } + match s { - "NOT" | "not" | "Not" => Some(Self::Not), "+" => Some(Self::Plus), "-" => Some(Self::Minus), "~" => Some(Self::BitNot), - "@-@" => Some(Self::AtDashAt), - "@@" => Some(Self::DoubleAt), - "#" => Some(Self::Hash), - "@" => Some(Self::PGAbs), - "||/" => Some(Self::PGCubeRoot), - "!" => Some(Self::PGPostfixFactorial), - "!!" => Some(Self::PGPrefixFactorial), - "|/" => Some(Self::PGSquareRoot), - "?-" => Some(Self::QuestionDash), - "?|" => Some(Self::QuestionPipe), _ => None, } } @@ -56,16 +38,6 @@ impl UnaryOp { Self::Plus => "+", Self::Minus => "-", Self::BitNot => "~", - Self::AtDashAt => "@-@", - Self::DoubleAt => "@@", - Self::Hash => "#", - Self::PGAbs => "@", - Self::PGCubeRoot => "||/", - Self::PGPostfixFactorial => "!", - Self::PGPrefixFactorial => "!!", - Self::PGSquareRoot => "|/", - Self::QuestionDash => "?-", - Self::QuestionPipe => "?|", } } @@ -73,35 +45,12 @@ impl UnaryOp { #[inline] pub(crate) fn spacing(self) -> UnarySpacing { match self { - Self::PGPostfixFactorial => UnarySpacing::Postfix, - Self::Not - | Self::Hash - | Self::AtDashAt - | Self::DoubleAt - | Self::QuestionDash - | Self::QuestionPipe => UnarySpacing::SpacedPrefix, - Self::Plus - | Self::Minus - | Self::BitNot - | Self::PGAbs - | Self::PGSquareRoot - | Self::PGCubeRoot - | Self::PGPrefixFactorial => UnarySpacing::GluedPrefix, + Self::Not => UnarySpacing::SpacedPrefix, + Self::Plus | Self::Minus | Self::BitNot => UnarySpacing::GluedPrefix, } } } -/// How a [`UnaryOp`] is written relative to its operand. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum UnarySpacing { - /// Operand then operator, no space (`9!`). - Postfix, - /// Operator, space, operand (`NOT x`, `# path`). - SpacedPrefix, - /// Operator glued to the operand (`-1`, `|/9`). - GluedPrefix, -} - /// Binary operator. Unknown spellings become [`BinaryOp::Custom`]. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum BinaryOp { @@ -118,7 +67,6 @@ pub enum BinaryOp { GtEq, And, Or, - Xor, BitAnd, BitOr, BitXor, @@ -131,6 +79,13 @@ pub enum BinaryOp { impl BinaryOp { #[inline] pub fn parse(s: &str) -> Self { + if s.eq_ignore_ascii_case("and") { + return Self::And; + } + if s.eq_ignore_ascii_case("or") { + return Self::Or; + } + match s { "+" => Self::Add, "-" => Self::Sub, @@ -143,9 +98,6 @@ impl BinaryOp { "<=" => Self::LtEq, ">" => Self::Gt, ">=" => Self::GtEq, - "AND" | "and" | "And" => Self::And, - "OR" | "or" | "Or" => Self::Or, - "XOR" | "xor" | "Xor" => Self::Xor, "&" => Self::BitAnd, "|" => Self::BitOr, "^" => Self::BitXor, @@ -172,7 +124,6 @@ impl BinaryOp { Self::GtEq => ">=", Self::And => "AND", Self::Or => "OR", - Self::Xor => "XOR", Self::BitAnd => "&", Self::BitOr => "|", Self::BitXor => "^", @@ -203,7 +154,6 @@ impl BinaryOp { | Self::GtEq | Self::Custom(_) => 5, Self::And => 6, - Self::Xor => 7, Self::Or => 8, } } @@ -244,42 +194,6 @@ impl IsKind { } } -/// Unicode normalization form for [`super::IsNormalized`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum NormalizationForm { - Nfc, - Nfd, - Nfkc, - Nfkd, -} - -impl NormalizationForm { - #[inline] - pub fn parse(s: &str) -> Option { - if s.eq_ignore_ascii_case("NFC") { - Some(Self::Nfc) - } else if s.eq_ignore_ascii_case("NFD") { - Some(Self::Nfd) - } else if s.eq_ignore_ascii_case("NFKC") { - Some(Self::Nfkc) - } else if s.eq_ignore_ascii_case("NFKD") { - Some(Self::Nfkd) - } else { - None - } - } - - #[inline] - pub fn as_sql(self) -> &'static str { - match self { - Self::Nfc => "NFC", - Self::Nfd => "NFD", - Self::Nfkc => "NFKC", - Self::Nfkd => "NFKD", - } - } -} - /// Unary operator. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum CastKind { diff --git a/src/expr/mod.rs b/src/expr/mod.rs index 0105db3..d712ec5 100644 --- a/src/expr/mod.rs +++ b/src/expr/mod.rs @@ -23,7 +23,7 @@ pub mod expr_mod { #[pymodule_export] use super::nodes::{ PyBetweenExpr, PyBinaryExpr, PyCaseExpr, PyCastExpr, PyCollateExpr, PyCustomExpr, - PyInListExpr, PyIsDistinctFromExpr, PyIsExpr, PyIsNormalizedExpr, PyLikeExpr, PyRLikeExpr, - PySimilarToExpr, PyTrimExpr, PyTupleExpr, PyUnaryExpr, + PyInListExpr, PyIsDistinctFromExpr, PyIsExpr, PyLikeExpr, PyRLikeExpr, PySimilarToExpr, + PyTrimExpr, PyTupleExpr, PyUnaryExpr, }; } diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index 68175bb..2c3639d 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -59,9 +59,6 @@ empty_node! { /// `expr IS [NOT] {NULL|TRUE|FALSE|UNKNOWN}`. PyIsExpr(Is(super::enum_::Is)) => "IsExpr", - /// `expr IS [NOT] [form] NORMALIZED`. - PyIsNormalizedExpr(IsNormalized(super::enum_::IsNormalized)) => "IsNormalizedExpr", - /// `a IS [NOT] DISTINCT FROM b`. PyIsDistinctFromExpr(IsDistinctFrom(super::enum_::IsDistinctFrom)) => "IsDistinctFromExpr", @@ -237,53 +234,6 @@ impl PyIsExpr { } } -#[pyo3::pymethods] -impl PyIsNormalizedExpr { - #[new] - #[pyo3(signature = (expr, form = None, /, *, negated = false))] - fn __new__( - expr: LiteralExprExtractor, - form: Option<&str>, - negated: bool, - ) -> pyo3::PyResult> { - let form = match form { - None => None, - Some(s) => { - let Some(parsed) = super::enum_::NormalizationForm::parse(s) else { - return rapidquery_macros::pyerr!( - Err(PyValueError), - "unknown normalization form {:?}; expected NFC, NFD, NFKC, or NFKD", - s - ); - }; - Some(parsed) - } - }; - - let inner = super::enum_::IsNormalized { - expr: Box::new(expr.0), - negated, - form, - }; - Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) - } - - fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - let inner = Self::inner(&slf); - super::base::into_subclasses(slf.py(), (*inner.expr).clone()) - } - - fn get_form(slf: pyo3::PyRef<'_, Self>) -> Option<&'static str> { - let inner = Self::inner(&slf); - inner.form.map(super::enum_::NormalizationForm::as_sql) - } - - fn get_negated(slf: pyo3::PyRef<'_, Self>) -> bool { - let inner = Self::inner(&slf); - inner.negated - } -} - #[pyo3::pymethods] impl PyIsDistinctFromExpr { #[new] From 1738ac50d28e066548e7914f76f89f4838a09ed8 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 28 Sep 2026 18:54:43 +0330 Subject: [PATCH 51/87] Do tasks 4 --- rapidquery/expr.py | 2 - src/dialect/builtin.rs | 211 ++++++++++++++++++++++++++ src/dialect/inner.rs | 84 +++++++++++ src/expr/base.rs | 12 +- src/expr/enum_/nodes.rs | 306 +++++++++++++++++++++++++++++--------- src/expr/func.rs | 19 +-- src/expr/nodes.rs | 79 +++++----- src/internal/utils/mod.rs | 17 ++- tests/conftest.py | 23 --- tests/test_dialect.py | 3 +- tests/test_expr.py | 129 ++++++++-------- 11 files changed, 662 insertions(+), 223 deletions(-) diff --git a/rapidquery/expr.py b/rapidquery/expr.py index a64d335..a877e81 100644 --- a/rapidquery/expr.py +++ b/rapidquery/expr.py @@ -16,7 +16,6 @@ InListExpr, IsDistinctFromExpr, IsExpr, - IsNormalizedExpr, LikeExpr, RLikeExpr, SimilarToExpr, @@ -41,7 +40,6 @@ "InListExpr", "IsDistinctFromExpr", "IsExpr", - "IsNormalizedExpr", "LikeExpr", "RLikeExpr", "SimilarToExpr", diff --git a/src/dialect/builtin.rs b/src/dialect/builtin.rs index 0fadcde..4189335 100644 --- a/src/dialect/builtin.rs +++ b/src/dialect/builtin.rs @@ -52,6 +52,12 @@ impl_builtin_dialects!( ilike_expression_options: ILikeExpressionOptions::UseLikeLower, supports_similar_to: false, rlike_expression_options: Some(RLikeExpressionOptions::RegexpContains), + string_concat_syntax: StringConcatSyntax::Pipes, + bit_xor_syntax: Some(BitXorSyntax::Caret), + modulo_syntax: ModuloSyntax::Percent, + is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), + tuple_syntax: None, + parenthesize_in_between: false, } } @@ -75,6 +81,12 @@ impl_builtin_dialects!( ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: false, rlike_expression_options: Some(RLikeExpressionOptions::InfixRLike), + string_concat_syntax: StringConcatSyntax::Pipes, + bit_xor_syntax: Some(BitXorSyntax::Caret), + modulo_syntax: ModuloSyntax::Percent, + is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), + tuple_syntax: Some(TupleSyntax::Struct), + parenthesize_in_between: false, } } @@ -98,6 +110,12 @@ impl_builtin_dialects!( ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: true, rlike_expression_options: Some(RLikeExpressionOptions::RegexpMatches), + string_concat_syntax: StringConcatSyntax::Pipes, + bit_xor_syntax: Some(BitXorSyntax::XorCall), + modulo_syntax: ModuloSyntax::Percent, + is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), + tuple_syntax: Some(TupleSyntax::Row { empty_ok: true }), + parenthesize_in_between: false, } } @@ -121,6 +139,12 @@ impl_builtin_dialects!( ilike_expression_options: ILikeExpressionOptions::UseLikeLower, supports_similar_to: false, rlike_expression_options: Some(RLikeExpressionOptions::InfixRLike), + string_concat_syntax: StringConcatSyntax::Pipes, + bit_xor_syntax: Some(BitXorSyntax::Caret), + modulo_syntax: ModuloSyntax::Percent, + is_distinct_from_syntax: Some(IsDistinctFromSyntax::NullSafeEqual), + tuple_syntax: Some(TupleSyntax::Struct), + parenthesize_in_between: false, } } @@ -144,6 +168,12 @@ impl_builtin_dialects!( ilike_expression_options: ILikeExpressionOptions::UseLike, supports_similar_to: false, rlike_expression_options: None, + string_concat_syntax: StringConcatSyntax::Plus, + bit_xor_syntax: Some(BitXorSyntax::Caret), + modulo_syntax: ModuloSyntax::Percent, + is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), + tuple_syntax: None, + parenthesize_in_between: true, } } @@ -167,6 +197,12 @@ impl_builtin_dialects!( ilike_expression_options: ILikeExpressionOptions::UseLike, supports_similar_to: false, rlike_expression_options: Some(RLikeExpressionOptions::InfixRLike), + string_concat_syntax: StringConcatSyntax::Concat, + bit_xor_syntax: Some(BitXorSyntax::Caret), + modulo_syntax: ModuloSyntax::Percent, + is_distinct_from_syntax: Some(IsDistinctFromSyntax::NullSafeEqual), + tuple_syntax: Some(TupleSyntax::Row { empty_ok: false }), + parenthesize_in_between: false, } } @@ -190,6 +226,12 @@ impl_builtin_dialects!( ilike_expression_options: ILikeExpressionOptions::UseLikeLower, supports_similar_to: false, rlike_expression_options: Some(RLikeExpressionOptions::RegexpLike), + string_concat_syntax: StringConcatSyntax::Pipes, + bit_xor_syntax: None, + modulo_syntax: ModuloSyntax::Call, + is_distinct_from_syntax: None, + tuple_syntax: None, + parenthesize_in_between: false, } } @@ -213,6 +255,12 @@ impl_builtin_dialects!( ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: true, rlike_expression_options: Some(RLikeExpressionOptions::Tilde), + string_concat_syntax: StringConcatSyntax::Pipes, + bit_xor_syntax: Some(BitXorSyntax::Hash), + modulo_syntax: ModuloSyntax::Percent, + is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), + tuple_syntax: Some(TupleSyntax::Row { empty_ok: true }), + parenthesize_in_between: false, } } @@ -236,6 +284,12 @@ impl_builtin_dialects!( ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: true, rlike_expression_options: Some(RLikeExpressionOptions::Tilde), + string_concat_syntax: StringConcatSyntax::Pipes, + bit_xor_syntax: None, + modulo_syntax: ModuloSyntax::Percent, + is_distinct_from_syntax: None, + tuple_syntax: None, + parenthesize_in_between: false, } } @@ -259,6 +313,12 @@ impl_builtin_dialects!( ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: false, rlike_expression_options: Some(RLikeExpressionOptions::InfixRLike), + string_concat_syntax: StringConcatSyntax::Pipes, + bit_xor_syntax: Some(BitXorSyntax::BitXorCall), + modulo_syntax: ModuloSyntax::Percent, + is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), + tuple_syntax: None, + parenthesize_in_between: false, } } @@ -282,6 +342,12 @@ impl_builtin_dialects!( ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: false, rlike_expression_options: Some(RLikeExpressionOptions::InfixRLike), + string_concat_syntax: StringConcatSyntax::Pipes, + bit_xor_syntax: Some(BitXorSyntax::Caret), + modulo_syntax: ModuloSyntax::Percent, + is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), + tuple_syntax: Some(TupleSyntax::Struct), + parenthesize_in_between: false, } } @@ -305,6 +371,12 @@ impl_builtin_dialects!( ilike_expression_options: ILikeExpressionOptions::UseLike, supports_similar_to: false, rlike_expression_options: None, + string_concat_syntax: StringConcatSyntax::Pipes, + bit_xor_syntax: None, + modulo_syntax: ModuloSyntax::Percent, + is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), + tuple_syntax: Some(TupleSyntax::Parentheses), + parenthesize_in_between: false, } } @@ -328,6 +400,145 @@ impl_builtin_dialects!( ilike_expression_options: ILikeExpressionOptions::UseLikeLower, supports_similar_to: false, rlike_expression_options: None, + string_concat_syntax: StringConcatSyntax::Pipes, + bit_xor_syntax: Some(BitXorSyntax::BitXorCall), + modulo_syntax: ModuloSyntax::Infix, + is_distinct_from_syntax: None, + tuple_syntax: None, + parenthesize_in_between: false, } } ); + +#[cfg(test)] +mod tests { + use super::*; + + fn expect( + dialect: Dialect, + concat: StringConcatSyntax, + xor: Option, + modulo: ModuloSyntax, + distinct: Option, + tuple: Option, + parenthesize_in_between: bool, + ) { + assert_eq!(dialect.string_concat_syntax, concat); + assert_eq!(dialect.bit_xor_syntax, xor); + assert_eq!(dialect.modulo_syntax, modulo); + assert_eq!(dialect.is_distinct_from_syntax, distinct); + assert_eq!(dialect.tuple_syntax, tuple); + assert_eq!(dialect.parenthesize_in_between, parenthesize_in_between); + } + + #[test] + fn operator_syntax_matrix() { + use BitXorSyntax::*; + use IsDistinctFromSyntax::*; + use ModuloSyntax::*; + use StringConcatSyntax::*; + use TupleSyntax::*; + + expect( + PyBigQuery::CONFIG, + Pipes, + Some(Caret), + Percent, + Some(Standard), + None, + false, + ); + expect( + PyDatabricks::CONFIG, + Pipes, + Some(Caret), + Percent, + Some(Standard), + Some(Struct), + false, + ); + expect( + PyDuckDb::CONFIG, + Pipes, + Some(XorCall), + Percent, + Some(Standard), + Some(Row { empty_ok: true }), + false, + ); + expect( + PyHive::CONFIG, + Pipes, + Some(Caret), + Percent, + Some(NullSafeEqual), + Some(Struct), + false, + ); + expect( + PyMsSql::CONFIG, + Plus, + Some(Caret), + Percent, + Some(Standard), + None, + true, + ); + expect( + PyMySql::CONFIG, + Concat, + Some(Caret), + Percent, + Some(NullSafeEqual), + Some(Row { empty_ok: false }), + false, + ); + expect(PyOracle::CONFIG, Pipes, None, Call, None, None, false); + expect( + PyPostgreSql::CONFIG, + Pipes, + Some(Hash), + Percent, + Some(Standard), + Some(Row { empty_ok: true }), + false, + ); + expect(PyRedshift::CONFIG, Pipes, None, Percent, None, None, false); + expect( + PySnowflake::CONFIG, + Pipes, + Some(BitXorCall), + Percent, + Some(Standard), + None, + false, + ); + expect( + PySpark::CONFIG, + Pipes, + Some(Caret), + Percent, + Some(Standard), + Some(Struct), + false, + ); + expect( + PySqlite::CONFIG, + Pipes, + None, + Percent, + Some(Standard), + Some(Parentheses), + false, + ); + expect( + PyTeradata::CONFIG, + Pipes, + Some(BitXorCall), + Infix, + None, + None, + false, + ); + } +} diff --git a/src/dialect/inner.rs b/src/dialect/inner.rs index 3f19c85..bb29aaa 100644 --- a/src/dialect/inner.rs +++ b/src/dialect/inner.rs @@ -94,6 +94,68 @@ pub enum RLikeExpressionOptions { RegexpMatches, } +/// How [`crate::expr::enum_::BinaryOp::StringConcat`] (`||`) is spelled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum StringConcatSyntax { + /// `left || right`. + Pipes, + /// `left + right`. SQL Server `||` is 2025+; classic concat is `+`. + Plus, + /// `CONCAT(left, right)`. MySQL `||` is logical OR unless `PIPES_AS_CONCAT`. + Concat, +} + +/// How [`crate::expr::enum_::BinaryOp::BitXor`] (`^`) is spelled. +/// +/// [`None`] on [`Dialect::bit_xor_syntax`] means unsupported. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BitXorSyntax { + /// `left ^ right`. + Caret, + /// `left # right`. PostgreSQL `^` is exponent. + Hash, + /// `xor(left, right)`. + XorCall, + /// `BITXOR(left, right)`. + BitXorCall, +} + +/// How [`crate::expr::enum_::BinaryOp::Mod`] (`%`) is spelled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ModuloSyntax { + /// `left % right`. + Percent, + /// `MOD(left, right)`. + Call, + /// `left MOD right`. Teradata's arithmetic operator; `MOD()` is an ODBC extension. + Infix, +} + +/// How `IS [NOT] DISTINCT FROM` is spelled. +/// +/// [`None`] on [`Dialect::is_distinct_from_syntax`] means unsupported. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum IsDistinctFromSyntax { + /// `left IS [NOT] DISTINCT FROM right`. + Standard, + /// `<=>` is null-safe equal (`IS NOT DISTINCT FROM`). + /// `IS DISTINCT FROM` is `NOT (left <=> right)`. + NullSafeEqual, +} + +/// How a tuple / row value is spelled. +/// +/// [`None`] on [`Dialect::tuple_syntax`] means unsupported. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TupleSyntax { + /// `ROW(...)`. `empty_ok` allows arity 0 (`ROW()`). + Row { empty_ok: bool }, + /// `struct(...)`. Arity 0 is unsupported. + Struct, + /// `(a, b, ...)`. Arity 0 and 1 are unsupported. + Parentheses, +} + /// How a boolean literal is spelled. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum BoolFormat { @@ -292,6 +354,22 @@ pub struct Dialect { pub supports_similar_to: bool, /// How regex `RLike` is spelled. [`None`] means unsupported. pub rlike_expression_options: Option, + /// How `||` (string concat) is spelled. + pub string_concat_syntax: StringConcatSyntax, + /// How bitwise XOR (`^` in the AST) is spelled. [`None`] means unsupported. + pub bit_xor_syntax: Option, + /// How modulo (`%` in the AST) is spelled. + pub modulo_syntax: ModuloSyntax, + /// How `IS [NOT] DISTINCT FROM` is spelled. [`None`] means unsupported. + pub is_distinct_from_syntax: Option, + /// How a tuple value is spelled. [`None`] means unsupported. + pub tuple_syntax: Option, + /// Wrap `IN` / `BETWEEN` in parentheses. + /// + /// SQL Server ranks `AND` above `IN`/`BETWEEN`/`OR`, and those four share + /// a level evaluated left to right. The parentheses keep the predicate + /// from being parsed as part of the left operand. + pub parenthesize_in_between: bool, } impl Dialect { @@ -547,6 +625,12 @@ mod tests { ilike_expression_options: ILikeExpressionOptions::UseLike, supports_similar_to: false, rlike_expression_options: None, + string_concat_syntax: StringConcatSyntax::Pipes, + bit_xor_syntax: None, + modulo_syntax: ModuloSyntax::Percent, + is_distinct_from_syntax: None, + tuple_syntax: None, + parenthesize_in_between: false, } } diff --git a/src/expr/base.rs b/src/expr/base.rs index 5708acb..82cfd70 100644 --- a/src/expr/base.rs +++ b/src/expr/base.rs @@ -89,11 +89,13 @@ impl PyExpr { ) -> pyo3::PyResult<(String, alias::BoundObject<'py>)> { let (sql, params) = sql::prepare(py, &slf.0, &dialect)?; - let params = utils::iterator_to_tuple( - slf.py(), - params.len() as isize, - params.into_iter().map(|x| Ok(x.as_ptr())), - )?; + let params = unsafe { + utils::iterator_to_tuple( + slf.py(), + params.len() as isize, + params.into_iter().map(|x| Ok(x.into_ptr())), + )? + }; Ok((sql, params)) } diff --git a/src/expr/enum_/nodes.rs b/src/expr/enum_/nodes.rs index f18d105..c703810 100644 --- a/src/expr/enum_/nodes.rs +++ b/src/expr/enum_/nodes.rs @@ -1,8 +1,15 @@ use super::{BinaryOp, CastKind, ExprInner, Ident, IsKind, TrimWhere, UnaryOp, UnarySpacing}; use crate::dialect::PySqlDialect; +use crate::dialect::inner::{ + BitXorSyntax, IsDistinctFromSyntax, ModuloSyntax, StringConcatSyntax, TupleSyntax, +}; use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; use std::fmt::Write; +fn unsupported(message: &'static str) -> crate::sql::PrepareError { + rapidquery_macros::pyerr!(PyRuntimeError, message).into() +} + fn join_reprs(py: pyo3::Python<'_>, buf: &mut String, items: &[ExprInner]) -> pyo3::PyResult<()> { for (i, item) in items.iter().enumerate() { if i > 0 { @@ -32,7 +39,7 @@ pub(super) fn write_escape( Ok(()) } -/// Parenthesized row `(a, b, …)`. No trailing comma on a singleton. +/// Row value. SQL spelling depends on [`TupleSyntax`]. #[derive(Clone, Debug, PartialEq)] pub struct Tuple { pub items: Vec, @@ -51,7 +58,35 @@ impl Tuple { impl WriteSql for Tuple { fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { - out.push_char('('); + let Some(syntax) = dialect.0.tuple_syntax else { + return Err(unsupported( + "tuple values are not supported by this dialect", + )); + }; + + let arity = self.items.len(); + match syntax { + TupleSyntax::Row { empty_ok } => { + if arity == 0 && !empty_ok { + return Err(unsupported("empty tuple is not supported by this dialect")); + } + out.push_str("ROW("); + } + TupleSyntax::Struct => { + if arity == 0 { + return Err(unsupported("empty tuple is not supported by this dialect")); + } + out.push_str("struct("); + } + TupleSyntax::Parentheses => { + if arity < 2 { + return Err(unsupported( + "a tuple needs at least two elements on this dialect", + )); + } + out.push_char('('); + } + } write_separated(dialect, out, &self.items, ", ")?; out.push_char(')'); Ok(()) @@ -124,14 +159,116 @@ impl Binary { } } +fn is_arithmetic(op: &BinaryOp) -> bool { + matches!( + op, + BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod + ) +} + +/// `name(left, right)` with an unquoted function name. +fn write_binary_call( + dialect: &PySqlDialect, + out: &mut W, + name: &str, + left: &ExprInner, + right: &ExprInner, +) -> PrepareResult<()> { + out.push_str(name); + out.push_char('('); + left.write_sql(dialect, out)?; + out.push_str(", "); + right.write_sql(dialect, out)?; + out.push_char(')'); + Ok(()) +} + +/// Infix `left SPELLING right`. +/// +/// `paren_arithmetic` parenthesizes a `+ - * / %` side. Infix `||` (and MsSql +/// `+` used as concat) needs that because dialect precedence disagrees with +/// the tree: SQLite binds `||` tighter than arithmetic, and MsSql `+` is the +/// same token as addition. +fn write_infix( + bin: &Binary, + dialect: &PySqlDialect, + out: &mut W, + spelling: &str, + paren_arithmetic: bool, +) -> PrepareResult<()> { + let prec = bin.precedence(); + write_infix_side(dialect, out, &bin.left, prec, false, paren_arithmetic)?; + out.push_char(' '); + out.push_str(spelling); + out.push_char(' '); + write_infix_side(dialect, out, &bin.right, prec, true, paren_arithmetic) +} + +fn write_infix_side( + dialect: &PySqlDialect, + out: &mut W, + expr: &ExprInner, + prec: u8, + paren_if_equal: bool, + paren_arithmetic: bool, +) -> PrepareResult<()> { + if paren_arithmetic && matches!(expr, ExprInner::Binary(b) if is_arithmetic(&b.op)) { + out.push_char('('); + expr.write_sql(dialect, out)?; + out.push_char(')'); + return Ok(()); + } + expr.write_operand(dialect, out, prec, paren_if_equal) +} + +fn write_concat( + bin: &Binary, + dialect: &PySqlDialect, + out: &mut W, +) -> PrepareResult<()> { + match dialect.0.string_concat_syntax { + StringConcatSyntax::Concat => { + write_binary_call(dialect, out, "CONCAT", &bin.left, &bin.right) + } + StringConcatSyntax::Pipes => write_infix(bin, dialect, out, "||", true), + StringConcatSyntax::Plus => write_infix(bin, dialect, out, "+", true), + } +} + +fn write_bitxor( + bin: &Binary, + dialect: &PySqlDialect, + out: &mut W, +) -> PrepareResult<()> { + match dialect.0.bit_xor_syntax { + None => Err(unsupported("bitwise XOR is not supported by this dialect")), + Some(BitXorSyntax::Caret) => write_infix(bin, dialect, out, "^", false), + Some(BitXorSyntax::Hash) => write_infix(bin, dialect, out, "#", false), + Some(BitXorSyntax::XorCall) => { + write_binary_call(dialect, out, "xor", &bin.left, &bin.right) + } + Some(BitXorSyntax::BitXorCall) => { + write_binary_call(dialect, out, "BITXOR", &bin.left, &bin.right) + } + } +} + +fn write_mod(bin: &Binary, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + match dialect.0.modulo_syntax { + ModuloSyntax::Percent => write_infix(bin, dialect, out, "%", false), + ModuloSyntax::Call => write_binary_call(dialect, out, "MOD", &bin.left, &bin.right), + ModuloSyntax::Infix => write_infix(bin, dialect, out, "MOD", false), + } +} + impl WriteSql for Binary { fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { - let prec = self.precedence(); - self.left.write_operand(dialect, out, prec, false)?; - out.push_char(' '); - out.push_str(self.op.as_sql()); - out.push_char(' '); - self.right.write_operand(dialect, out, prec, true) + match &self.op { + BinaryOp::StringConcat => write_concat(self, dialect, out), + BinaryOp::BitXor => write_bitxor(self, dialect, out), + BinaryOp::Mod => write_mod(self, dialect, out), + _ => write_infix(self, dialect, out, self.op.as_sql(), false), + } } } @@ -201,21 +338,44 @@ impl IsDistinctFrom { impl WriteSql for IsDistinctFrom { fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { - self.left - .write_operand(dialect, out, Self::PRECEDENCE, false)?; - if self.negated { - out.push_str(" IS NOT DISTINCT FROM "); - } else { - out.push_str(" IS DISTINCT FROM "); + match dialect.0.is_distinct_from_syntax { + None => Err(unsupported( + "IS DISTINCT FROM is not supported by this dialect", + )), + Some(IsDistinctFromSyntax::Standard) => { + self.left + .write_operand(dialect, out, Self::PRECEDENCE, false)?; + if self.negated { + out.push_str(" IS NOT DISTINCT FROM "); + } else { + out.push_str(" IS DISTINCT FROM "); + } + self.right + .write_operand(dialect, out, Self::PRECEDENCE, true) + } + Some(IsDistinctFromSyntax::NullSafeEqual) => { + // `<=>` is null-safe equal, i.e. `IS NOT DISTINCT FROM`. + if !self.negated { + out.push_str("NOT ("); + } + self.left + .write_operand(dialect, out, Self::PRECEDENCE, false)?; + out.push_str(" <=> "); + self.right + .write_operand(dialect, out, Self::PRECEDENCE, true)?; + if !self.negated { + out.push_char(')'); + } + Ok(()) + } } - self.right - .write_operand(dialect, out, Self::PRECEDENCE, true) } } /// `expr [NOT] IN (list…)`. /// -/// An empty list emits `IN ()`. +/// An empty list is `FALSE` (`NOT IN` is `TRUE`), spelled with the dialect's +/// boolean literal. The tested expression is not written. #[derive(Clone, Debug, PartialEq)] pub struct InList { pub expr: Box, @@ -240,6 +400,15 @@ impl InList { impl WriteSql for InList { fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + if self.list.is_empty() { + // `x IN ()` is false and `x NOT IN ()` is true, including when `x` is NULL. + dialect.0.format_boolean(out, self.negated)?; + return Ok(()); + } + + if dialect.0.parenthesize_in_between { + out.push_char('('); + } self.expr .write_operand(dialect, out, Self::PRECEDENCE, false)?; if self.negated { @@ -249,6 +418,9 @@ impl WriteSql for InList { } write_separated(dialect, out, &self.list, ", ")?; out.push_char(')'); + if dialect.0.parenthesize_in_between { + out.push_char(')'); + } Ok(()) } } @@ -284,6 +456,9 @@ impl Between { impl WriteSql for Between { fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + if dialect.0.parenthesize_in_between { + out.push_char('('); + } self.expr .write_operand(dialect, out, Self::PRECEDENCE, false)?; if self.negated { @@ -295,7 +470,11 @@ impl WriteSql for Between { .write_operand(dialect, out, Self::PRECEDENCE, true)?; out.push_str(" AND "); self.high - .write_operand(dialect, out, Self::PRECEDENCE, true) + .write_operand(dialect, out, Self::PRECEDENCE, true)?; + if dialect.0.parenthesize_in_between { + out.push_char(')'); + } + Ok(()) } } @@ -529,62 +708,53 @@ impl Custom { } impl WriteSql for Custom { + #[cfg_attr(feature = "nightly", optimize(speed))] fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { - write_custom(&self.sql, &self.args, dialect, out) - } -} - -#[cfg_attr(feature = "nightly", optimize(speed))] -fn write_custom( - sql: &str, - args: &[ExprInner], - dialect: &PySqlDialect, - out: &mut W, -) -> PrepareResult<()> { - let bytes = sql.as_bytes(); - let mut i = 0; - let mut start = 0; - let mut used = 0usize; - - while i < bytes.len() { - if bytes[i] == b'{' { - if i + 1 < bytes.len() && bytes[i + 1] == b'{' { - out.push_str(&sql[start..i]); - out.push_char('{'); - i += 2; - start = i; - continue; - } - if i + 1 < bytes.len() && bytes[i + 1] == b'}' { - out.push_str(&sql[start..i]); - if used >= args.len() { - return Err(crate::sql::PrepareError::CustomPlaceholders { - expected: used + 1, - got: args.len(), - }); + let bytes = self.sql.as_bytes(); + let mut i = 0; + let mut start = 0; + let mut used = 0usize; + + while i < bytes.len() { + if bytes[i] == b'{' { + if i + 1 < bytes.len() && bytes[i + 1] == b'{' { + out.push_str(&self.sql[start..i]); + out.push_char('{'); + i += 2; + start = i; + continue; + } + if i + 1 < bytes.len() && bytes[i + 1] == b'}' { + out.push_str(&self.sql[start..i]); + if used >= self.args.len() { + return Err(crate::sql::PrepareError::CustomPlaceholders { + expected: used + 1, + got: self.args.len(), + }); + } + self.args[used].write_sql(dialect, out)?; + used += 1; + i += 2; + start = i; + continue; } - args[used].write_sql(dialect, out)?; - used += 1; + } else if bytes[i] == b'}' && i + 1 < bytes.len() && bytes[i + 1] == b'}' { + out.push_str(&self.sql[start..i]); + out.push_char('}'); i += 2; start = i; continue; } - } else if bytes[i] == b'}' && i + 1 < bytes.len() && bytes[i + 1] == b'}' { - out.push_str(&sql[start..i]); - out.push_char('}'); - i += 2; - start = i; - continue; - } - i += 1; - } - out.push_str(&sql[start..]); + i += 1; + } + out.push_str(&self.sql[start..]); - if used != args.len() { - return Err(crate::sql::PrepareError::CustomPlaceholders { - expected: used, - got: args.len(), - }); + if used != self.args.len() { + return Err(crate::sql::PrepareError::CustomPlaceholders { + expected: used, + got: self.args.len(), + }); + } + Ok(()) } - Ok(()) } diff --git a/src/expr/func.rs b/src/expr/func.rs index c00531c..832caa2 100644 --- a/src/expr/func.rs +++ b/src/expr/func.rs @@ -110,15 +110,16 @@ impl PyFunction { let py = slf.py(); let inner = Self::inner(&slf); - let len = inner.args.len() as isize; - utils::iterator_to_tuple( - slf.py(), - len, - inner - .args - .iter() - .map(|x| PyExpr(x.clone()).into_subclasses(py).map(|x| x.as_ptr())), - ) + unsafe { + utils::iterator_to_tuple( + slf.py(), + inner.args.len() as isize, + inner + .args + .iter() + .map(|x| PyExpr(x.clone()).into_subclasses(py).map(|x| x.into_ptr())), + ) + } } fn get_filter(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index 2c3639d..8bdb9c0 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -112,17 +112,19 @@ impl PyTupleExpr { ) } - fn get_items( - slf: pyo3::PyRef<'_, Self>, - ) -> pyo3::PyResult> { + fn get_items(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { let py = slf.py(); let items = &Self::inner(&slf).items; - let mut out = Vec::with_capacity(items.len()); - for item in items { - out.push(super::base::into_subclasses(py, item.clone())?); + unsafe { + utils::iterator_to_tuple( + py, + items.len() as isize, + items + .iter() + .map(|x| super::base::into_subclasses(py, x.clone()).map(|x| x.into_ptr())), + ) } - pyo3::types::PyTuple::new(py, out) } } @@ -298,15 +300,16 @@ impl PyInListExpr { let py = slf.py(); let inner = Self::inner(&slf); - let len = inner.list.len() as isize; - utils::iterator_to_tuple( - slf.py(), - len, - inner - .list - .iter() - .map(|x| PyExpr(x.clone()).into_subclasses(py).map(|x| x.as_ptr())), - ) + unsafe { + utils::iterator_to_tuple( + slf.py(), + inner.list.len() as isize, + inner + .list + .iter() + .map(|x| PyExpr(x.clone()).into_subclasses(py).map(|x| x.into_ptr())), + ) + } } fn get_negated(slf: pyo3::PyRef<'_, Self>) -> bool { @@ -386,15 +389,14 @@ impl PyLikeExpr { let inner = Self::inner(&slf); match &inner.pattern { super::enum_::LikePattern::One(p) => super::base::into_subclasses(py, (**p).clone()), - super::enum_::LikePattern::Any(ps) => { - let len = ps.len() as isize; + super::enum_::LikePattern::Any(ps) => unsafe { utils::iterator_to_tuple( slf.py(), - len, + ps.len() as isize, ps.iter() - .map(|x| PyExpr(x.clone()).into_subclasses(py).map(|x| x.as_ptr())), + .map(|x| PyExpr(x.clone()).into_subclasses(py).map(|x| x.into_ptr())), ) - } + }, } } @@ -608,13 +610,15 @@ impl PyTrimExpr { let inner = Self::inner(&slf); match inner.characters.as_ref() { None => Ok(utils::new_pynone(py)), - Some(chars) => { - let mut out = Vec::with_capacity(chars.len()); - for c in chars { - out.push(super::base::into_subclasses(py, c.clone())?); - } - Ok(pyo3::types::PyTuple::new(py, out)?.into_any()) - } + Some(chars) => unsafe { + utils::iterator_to_tuple( + py, + chars.len() as isize, + chars + .iter() + .map(|x| super::base::into_subclasses(py, x.clone()).map(|y| y.into_ptr())), + ) + }, } } } @@ -757,14 +761,15 @@ impl PyCustomExpr { let py = slf.py(); let inner = Self::inner(&slf); - let len = inner.args.len() as isize; - utils::iterator_to_tuple( - slf.py(), - len, - inner - .args - .iter() - .map(|x| PyExpr(x.clone()).into_subclasses(py).map(|x| x.as_ptr())), - ) + unsafe { + utils::iterator_to_tuple( + slf.py(), + inner.args.len() as isize, + inner + .args + .iter() + .map(|x| PyExpr(x.clone()).into_subclasses(py).map(|x| x.into_ptr())), + ) + } } } diff --git a/src/internal/utils/mod.rs b/src/internal/utils/mod.rs index 957b224..dd81f5c 100644 --- a/src/internal/utils/mod.rs +++ b/src/internal/utils/mod.rs @@ -63,24 +63,27 @@ pub fn new_pynone<'py>(py: pyo3::Python<'py>) -> pyo3::Bound<'py, pyo3::PyAny> { /// Converts smallvec into PyTuple using pyo3 FFI #[inline] -pub fn iterator_to_tuple<'py, I: Iterator>>( +pub unsafe fn iterator_to_tuple< + 'py, + I: Iterator>, +>( py: pyo3::Python<'py>, len: isize, iter: I, ) -> pyo3::PyResult> { - let tuple = unsafe { pyo3::ffi::PyTuple_New(len) }; + let tuple = pyo3::ffi::PyTuple_New(len); for (ix, item) in iter.enumerate() { match item { - Ok(ptr) => unsafe { + Ok(ptr) => { pyo3::ffi::PyTuple_SetItem(tuple, ix as isize, ptr); - }, - Err(err) => unsafe { + } + Err(err) => { pyo3::ffi::Py_DECREF(tuple); return Err(err); - }, + } } } - Ok(unsafe { pyo3::Bound::from_owned_ptr(py, tuple) }) + Ok(pyo3::Bound::from_owned_ptr(py, tuple)) } diff --git a/tests/conftest.py b/tests/conftest.py index 73cb285..24e2140 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,6 @@ BigQuery, Databricks, DuckDb, - GenericDialect, Hive, MsSql, MySql, @@ -47,28 +46,6 @@ ALL_DIALECT_NAMES = [cls.__name__ for cls in ALL_DIALECT_CLASSES] ALL_DIALECTS = tuple(cls() for cls in ALL_DIALECT_CLASSES) -# Defaults match src/dialect/inner.rs `base()` used by the Rust unit tests. -_GENERIC_DEFAULTS = dict( - placeholder_sigil=":", - bit_format=None, - supports_array=False, - supports_inet=False, - supports_mac_address=False, - null_treatment_syntax="OutsideParens", - like_any_syntax="OrExpansion", - bool_format="Keyword", - identifier_format="DoubleQuote", - identifier_escape="Delimiters", - string_format="SingleQuote", - string_escape="Quote", - bytes_format="PrefixBigX", -) - - -def generic_dialect(**overrides) -> GenericDialect: - """Build a GenericDialect; pass only the fields the test cares about.""" - return GenericDialect(**{**_GENERIC_DEFAULTS, **overrides}) - @pytest.fixture(params=ALL_DIALECT_CLASSES, ids=ALL_DIALECT_NAMES) def dialect_cls(request: pytest.FixtureRequest) -> type[SqlDialect]: diff --git a/tests/test_dialect.py b/tests/test_dialect.py index 9d2834f..ee30aec 100644 --- a/tests/test_dialect.py +++ b/tests/test_dialect.py @@ -11,7 +11,6 @@ BigQuery, Databricks, DuckDb, - GenericDialect, Hive, MsSql, MySql, @@ -25,7 +24,7 @@ Teradata, ) -from .conftest import ALL_DIALECT_CLASSES, ALL_DIALECT_NAMES, generic_dialect +from .conftest import ALL_DIALECT_CLASSES, ALL_DIALECT_NAMES # Expected DialectConfig for each builtin (src/dialect/builtin.rs). BUILTIN_CONFIG = { diff --git a/tests/test_expr.py b/tests/test_expr.py index 0b013bd..9ee6e2c 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -13,6 +13,7 @@ Spark, SqlDialect, Sqlite, + Teradata, ) from rapidquery.expr import ( Asterisk, @@ -28,7 +29,6 @@ InListExpr, IsDistinctFromExpr, IsExpr, - IsNormalizedExpr, LikeExpr, RLikeExpr, SimilarToExpr, @@ -265,15 +265,24 @@ def test_new(self): def test_to_sql(self): tpl = TupleExpr(1, 2, "A") - assert tpl.to_sql(PostgreSql()) == "(1, 2, E'A')" + assert tpl.to_sql(PostgreSql()) == "ROW(1, 2, E'A')" sql, params = tpl.prepare(PostgreSql()) - assert sql == "(:param_0, :param_1, :param_2)" + assert sql == "ROW(:param_0, :param_1, :param_2)" assert params == {"param_0": 1, "param_1": 2, "param_2": "A"} - assert TupleExpr().to_sql(PostgreSql()) == "()" - assert TupleExpr(1).to_sql(PostgreSql()) == "(1)" - assert TupleExpr((1, 2), Ident("A")).to_sql(PostgreSql()) == '((1, 2), "A")' + assert TupleExpr().to_sql(PostgreSql()) == "ROW()" + assert TupleExpr(1).to_sql(PostgreSql()) == "ROW(1)" + assert ( + TupleExpr((1, 2), Ident("A")).to_sql(PostgreSql()) == 'ROW(ROW(1, 2), "A")' + ) + + assert TupleExpr(1, 2).to_sql(Sqlite()) == "(1, 2)" + with pytest.raises(RuntimeError, match="at least two elements"): + TupleExpr(1).to_sql(Sqlite()) + assert TupleExpr(1, 2).to_sql(Spark()) == "struct(1, 2)" + with pytest.raises(RuntimeError, match="tuple values are not supported"): + TupleExpr(1, 2).to_sql(BigQuery()) class TestUnaryExpr: @@ -403,6 +412,27 @@ def test_to_sql(self): assert sql == '"id" > :param_0' assert params == {"param_0": 10} + concat = BinaryExpr(1, "||", BinaryExpr(2, "+", 3)) + assert concat.to_sql(PostgreSql()) == "1 || (2 + 3)" + assert concat.to_sql(Sqlite()) == "1 || (2 + 3)" + assert concat.to_sql(MsSql()) == "1 + (2 + 3)" + assert concat.to_sql(MySql()) == "CONCAT(1, 2 + 3)" + assert ( + BinaryExpr(BinaryExpr(1, "||", 2), "||", 3).to_sql(MySql()) + == "CONCAT(CONCAT(1, 2), 3)" + ) + + assert BinaryExpr(1, "^", 2).to_sql(PostgreSql()) == "1 # 2" + assert BinaryExpr(1, "^", 2).to_sql(DuckDb()) == "xor(1, 2)" + assert BinaryExpr(1, "^", 2).to_sql(Snowflake()) == "BITXOR(1, 2)" + assert BinaryExpr(1, "^", 2).to_sql(MySql()) == "1 ^ 2" + with pytest.raises(RuntimeError, match="bitwise XOR is not supported"): + BinaryExpr(1, "^", 2).to_sql(Sqlite()) + + assert BinaryExpr(5, "%", 2).to_sql(Oracle()) == "MOD(5, 2)" + assert BinaryExpr(5, "%", 2).to_sql(Teradata()) == "5 MOD 2" + assert BinaryExpr(5, "%", 2).to_sql(PostgreSql()) == "5 % 2" + def test_repr(self): assert ( repr(BinaryExpr(Ident("id"), ">", 10)) @@ -454,64 +484,6 @@ def test_repr(self): ) -class TestIsNormalizedExpr: - def test_new(self): - norm = IsNormalizedExpr(Ident("s")) - assert norm.get_expr() == Ident("s") - assert norm.get_form() is None - assert norm.get_negated() is False - - norm = IsNormalizedExpr("cafĆ©", "nfc") - assert norm.get_expr() == Value("cafĆ©") - assert norm.get_form() == "NFC" - assert norm.get_negated() is False - - norm = IsNormalizedExpr(Ident("s"), "NFKD", negated=True) - assert norm.get_expr() == Ident("s") - assert norm.get_form() == "NFKD" - assert norm.get_negated() is True - - with pytest.raises(ValueError, match="unknown normalization form"): - IsNormalizedExpr(Ident("s"), "NFKC2") - - def test_to_sql(self): - assert IsNormalizedExpr(Ident("s")).to_sql(PostgreSql()) == ( - '"s" IS NORMALIZED' - ) - assert IsNormalizedExpr(Ident("s"), negated=True).to_sql(PostgreSql()) == ( - '"s" IS NOT NORMALIZED' - ) - assert IsNormalizedExpr(Ident("s"), "nFd").to_sql(PostgreSql()) == ( - '"s" IS NFD NORMALIZED' - ) - assert ( - IsNormalizedExpr(Ident("s"), "NFKC", negated=True).to_sql(PostgreSql()) - == '"s" IS NOT NFKC NORMALIZED' - ) - - sql, params = IsNormalizedExpr(Ident("s"), "NFC").prepare(PostgreSql()) - assert sql == '"s" IS NFC NORMALIZED' - assert params == {} - - sql, params = IsNormalizedExpr("cafĆ©", "NFC").prepare(PostgreSql()) - assert sql == ":param_0 IS NFC NORMALIZED" - assert params == {"param_0": "cafĆ©"} - - assert repr(IsNormalizedExpr(Ident("s"))) == 'IsNormalizedExpr(Ident("s"))' - assert ( - repr(IsNormalizedExpr(Ident("s"), "NFC")) - == 'IsNormalizedExpr(Ident("s"), "NFC")' - ) - assert ( - repr(IsNormalizedExpr(Ident("s"), negated=True)) - == 'IsNormalizedExpr(Ident("s"), negated=True)' - ) - assert ( - repr(IsNormalizedExpr(Ident("s"), "NFC", negated=True)) - == 'IsNormalizedExpr(Ident("s"), "NFC", negated=True)' - ) - - class TestIsDistinctFromExpr: def test_new(self): idf = IsDistinctFromExpr(2, 3) @@ -549,6 +521,11 @@ def test_to_sql(self): == '"a" IS DISTINCT FROM "b"' ) + assert IsDistinctFromExpr(1, 2).to_sql(MySql()) == "NOT (1 <=> 2)" + assert IsDistinctFromExpr(1, 2, negated=True).to_sql(MySql()) == "1 <=> 2" + with pytest.raises(RuntimeError, match="IS DISTINCT FROM is not supported"): + IsDistinctFromExpr(1, 2).to_sql(Oracle()) + class TestInListExpr: def test_new(self): @@ -576,12 +553,21 @@ def test_to_sql(self): assert sql == '"id" IN (:param_0, :param_1, :param_2)' assert params == {"param_0": 1, "param_1": 2, "param_2": 3} - assert InListExpr(Ident("id"), [], negated=True).to_sql(PostgreSql()) == ( - '"id" NOT IN ()' - ) + assert InListExpr(Ident("id"), []).to_sql(PostgreSql()) == "FALSE" + assert InListExpr(Ident("id"), [], negated=True).to_sql(PostgreSql()) == "TRUE" + assert InListExpr(Ident("id"), [], negated=True).to_sql(MySql()) == "1" assert InListExpr(Ident("id"), [1], negated=True).to_sql(PostgreSql()) == ( '"id" NOT IN (1)' ) + assert InListExpr(Ident("id"), [1, 2]).to_sql(MsSql()) == "([id] IN (1, 2))" + assert ( + BinaryExpr( + InListExpr(Ident("id"), [1]), + "OR", + InListExpr(Ident("id"), [2]), + ).to_sql(MsSql()) + == "([id] IN (1)) OR ([id] IN (2))" + ) class TestBetweenExpr: @@ -607,6 +593,9 @@ def test_to_sql(self): assert bw.to_sql(PostgreSql()) == '"id" NOT BETWEEN "old" | 0 AND 10' bw = BetweenExpr(Ident("id"), 1, 10) + assert BetweenExpr(Ident("id"), 1, 10, negated=True).to_sql(MsSql()) == ( + "([id] NOT BETWEEN 1 AND 10)" + ) assert bw.to_sql(PostgreSql()) == '"id" BETWEEN 1 AND 10' bw = BetweenExpr(Ident("id"), BinaryExpr(1, "AND", 2), 10) @@ -781,9 +770,9 @@ def test_to_sql(self): assert rlike.to_sql(MySql()) == "`name` RLIKE '%a%'" assert rlike.to_sql(Spark()) == "`name` RLIKE '%a%'" assert rlike.to_sql(Snowflake()) == "\"name\" RLIKE '%a%'" - assert rlike.to_sql(Oracle()) == 'REGEXP_LIKE("name", \'%a%\')' + assert rlike.to_sql(Oracle()) == "REGEXP_LIKE(\"name\", '%a%')" assert rlike.to_sql(BigQuery()) == "REGEXP_CONTAINS(`name`, '%a%')" - assert rlike.to_sql(DuckDb()) == 'regexp_matches("name", \'%a%\')' + assert rlike.to_sql(DuckDb()) == "regexp_matches(\"name\", '%a%')" assert ( RLikeExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSql()) @@ -791,7 +780,7 @@ def test_to_sql(self): ) assert ( RLikeExpr(Ident("name"), "%a%", negated=True).to_sql(Oracle()) - == 'NOT REGEXP_LIKE("name", \'%a%\')' + == "NOT REGEXP_LIKE(\"name\", '%a%')" ) sql, params = rlike.prepare(PostgreSql()) From bde2046f6423c0de02e894b33c5bfd4de0c1d995 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Sat, 26 Sep 2026 14:46:31 +0330 Subject: [PATCH 52/87] Fix SIMILAR TO and RLIKE expressions --- TASKS.md | 265 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 TASKS.md diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..d8a13da --- /dev/null +++ b/TASKS.md @@ -0,0 +1,265 @@ +All six research tracks finished. Below is the WriteSql audit: what we emit today, where official docs disagree, and the work to do next. No code in this turn. + +**Policy (already in `src/lib.rs`):** if a dialect does not document a construct, `to_sql` / `prepare` should raise. Do not invent `LOWER()`, `REGEXP_LIKE()`, or `CONCAT()` unless a dialect flag names that rewrite. + +**WriteSql inventory** + +| Writer | File | +| ------------------------------------- | ------------------------------------------------------------------ | +| `Function` | `src/expr/enum_/func.rs` | +| `Ident` / `IdentPart` | `src/expr/enum_/ident.rs` | +| `Value` → `SerializerEnum::write_sql` | `src/expr/enum_/value.rs`, `src/data_type/serializer/serialize.rs` | +| `DataTypeName` / `PyDataType` | `src/data_type/name.rs`, `src/data_type/mod.rs` | +| `ExprEnum` arms | `src/expr/enum_/mod.rs` | +| Placeholders | `ParameterizedSql::push_param` in `src/sql.rs` | +| Quote / escape / bytes / bits / bool | `src/dialect/inner.rs` | + +--- + +## 0. Cross-cutting: identifiers and placeholders + +**Emit:** always-quote `IdentPart::Quote`; Unquote = `[A-Za-z_][A-Za-z0-9_]*`; Asterisk = `*`; parts joined with `.`; params = `{sigil}param_n` + dict. + +### P0 + +1. **Teradata identifiers** use `'name'` (`IdentifierFormat::SingleQuote`). Official delimited names use `"…"` (U+0022). `'users'` is a character literal. +2. **BigQuery** identifier escape does not emit `` \` ``. A backtick inside a name splits the identifier. +3. **PostgreSQL `:param_0` is not libpq SQL.** Native parameters are `$1`. MySQL/Hive PREPARE/JDBC use `?`. + +### Tasks + +**A.** Doccomments on `Ident` / `IdentPart` `WriteSql` and `Dialect::quote_identifier`: always-quote; Unquote is a conservative subset; Asterisk vs `"*"`; named `{sigil}param_n` is driver-named style, not wire protocol. + +**B.** + +1. Teradata: `identifier_format = DoubleQuote`, escape delimiters (`"` → `""`). +2. BigQuery: escape `` ` `` as `` \` `` (string-escape table). +3. Placeholder policy: either native (`$1` / `?`) or keep named and document that PG/MySQL/Hive SQL is not wire-native. Do not claim `:param_0` is libpq. + +**C.** Optional `QUOTE_IDENT`; SQL Server empty qualifiers (`db..tbl`); per-dialect Unquote charset. + +**D.** Teradata `"a""b"`; BigQuery name containing backtick; PG `$1` vs `:param_0` documentation tests. + +Official: [BQ lexical](https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/lexical) Ā· [PG lexical](https://www.postgresql.org/docs/current/sql-syntax-lexical.html) Ā· [PG PREPARE](https://www.postgresql.org/docs/current/sql-prepare.html) Ā· [SQLite keywords](https://www.sqlite.org/lang_keywords.html) Ā· [MySQL identifiers](https://dev.mysql.com/doc/refman/8.4/en/identifiers.html) + +--- + +## 1. Function (`Function::write_sql`) — your template + +**Emit:** quoted name; `( [DISTINCT\|ALL ] args [ IGNORE NULLS ] )` or postfix NT; then always ` FILTER (WHERE …)`. NT inside parens only for BigQuery and DuckDB. + +### Official vs emit + +| Dialect | DISTINCT / ALL | FILTER | IGNORE/RESPECT NULLS | +| ----------------------- | --------------------------- | ------------------------------------------------- | ------------------------------------ | +| PostgreSql | D and ALL | `FILTER (WHERE)` | none | +| Sqlite | DISTINCT only | `FILTER (WHERE)` on aggregates | none | +| BigQuery | DISTINCT only | **in-call `WHERE`** (Preview) | **inside** parens | +| DuckDb | DISTINCT | `FILTER (pred)`; railroad also `FILTER (WHERE …)` | inside on window fns | +| Databricks / Spark | D; Databricks COUNT has ALL | `FILTER (WHERE)` | postfix, then OVER | +| Oracle 23+ | D and ALL | `FILTER (WHERE)` | postfix (ANSI) and inside | +| Snowflake / Redshift | D; Redshift has ALL | none | postfix + OVER | +| MsSql | D and ALL | none | postfix + OVER (2022+) | +| MySql / Hive / Teradata | D; Teradata has ALL | none | Teradata **inside**; MySQL/Hive none | + +Bare `CURRENT_TIMESTAMP` is a **keyword/literal token** on SQLite (and similar on PG/Oracle). Quoting it (`"CURRENT_TIMESTAMP"`) kills the special meaning. + +### P0 + +1. `FILTER (WHERE)` on MySQL, MsSql, Hive, Snowflake, Redshift, Teradata. +2. BigQuery: official filter is `fn(x WHERE cond)`, not `fn(x) FILTER (WHERE cond)`. +3. `ALL` on BigQuery and SQLite (existing TODO). +4. NT on PostgreSQL, SQLite, MySQL, Hive. +5. Teradata NT is inside parens; builtin is `OutsideParens`. +6. Always-quoted niladic `CURRENT_TIMESTAMP` on SQLite. + +### Tasks + +**A.** Expand the Function `WriteSql` comment (keep BQ + SQLite links) with the URLs below. + +**B.** (`func.rs`, dialect flags) + +1. Skip `ALL` where not in syntax (BQ, SQLite; also Snowflake COUNT / Hive). +2. BigQuery FILTER → in-call `WHERE` after args/NT. +3. Omit FILTER unless dialect supports it (PG, SQLite, DuckDB, Spark, Databricks, Oracle 23+). +4. Omit NT unless dialect supports it (BQ, DuckDB, Snowflake, Spark, Databricks, Oracle, Redshift, Teradata, MsSql 2022+). +5. Teradata `null_treatment_syntax = InsideParens`. +6. Bare SQL-standard datetime keywords: do not quote. +7. DuckDB: railroad is `FILTER ( WHERE filter_expr )`; examples omit `WHERE`. Keep `WHERE` unless a rendered parse test fails. + +Suggested flags: `duplicate_treatment_all: bool`, `filter_syntax: None | FilterWhere | FilterBare | InsideWhere`. + +**C.** OVER, WITHIN GROUP, in-arg ORDER BY, named `=>` args, BQ HAVING MAX/MIN / LIMIT. Function-specific NT allowlists. `COUNT(*)` vs empty `COUNT()`. + +**D.** ALL omitted on BQ/SQLite; FILTER spelling per dialect; NT in vs post vs omitted; unquoted `CURRENT_TIMESTAMP` on SQLite. + +Official: [BQ aggregate calls](https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/aggregate-function-calls) Ā· [PG expressions](https://www.postgresql.org/docs/current/sql-expressions.html) Ā· [SQLite agg](https://www.sqlite.org/lang_aggfunc.html) Ā· [DuckDB FILTER](https://duckdb.org/docs/current/sql/query_syntax/filter.html) Ā· [Oracle FILTER](https://docs.oracle.com/en/database/oracle/oracle-database/26/sqlrf/Aggregate-Functions.html) Ā· [Spark windows](https://spark.apache.org/docs/latest/sql-ref-syntax-qry-select-window.html) Ā· [Snowflake FIRST_VALUE](https://docs.snowflake.com/en/sql-reference/functions/first_value) + +--- + +## 2. LIKE / ILIKE / LIKE ANY / SIMILAR TO / RLIKE + +**Emit:** `LIKE`/`ILIKE` + inlined ESCAPE; ANY via ArrayConstructor / PatternList / OrExpansion; `SIMILAR TO` and `RLIKE` ungated. + +### P0 + +1. **ILIKE** on BigQuery, Hive, MsSql, MySql, Oracle, Sqlite, Teradata. +2. **SIMILAR TO** except PostgreSql, Redshift, DuckDb. +3. **RLIKE** infix + ESCAPE: PG/Redshift want `~`; Oracle `REGEXP_LIKE()`; BigQuery `REGEXP_CONTAINS()`; MsSql none; SQLite user `REGEXP`. ESCAPE is not a regex clause even where RLIKE exists (MySQL/Hive/Spark/Databricks/Snowflake). +4. PostgreSql `LIKE ANY (ARRAY[…]) ESCAPE` — ESCAPE is not composable with ANY. +5. Spark/Databricks `NOT LIKE ANY` = ā€œfails at least oneā€, not ā€œmatches noneā€. +6. BigQuery LIKE has **no ESCAPE clause**. + +### Tasks + +**A.** Doccomments on `write_like` / SimilarTo / RLike: ANY shapes; why PG wraps `NOT (LIKE ANY)`; ungated SIMILAR TO / RLIKE. + +**B.** + +| Node | Gate | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ILIKE | Only PG, Redshift, DuckDB, Snowflake, Spark, Databricks. Else LIKE (no silent `LOWER()`). | +| LIKE ANY + ESCAPE | Omit ESCAPE on PG ANY, Spark/Databricks quantified, BigQuery always. Keep on Snowflake ANY and scalar/OrExpansion. | +| LIKE ANY negation | PatternList: `NOT (x LIKE ANY (…))`, not `x NOT LIKE ANY`. Keep PG wrap and OrExpansion AND of NOT LIKE. | +| SIMILAR TO | PG, Redshift, DuckDB only. | +| RLIKE | MySQL/Hive/Spark/Databricks/Snowflake: infix, **never ESCAPE**. Oracle: `REGEXP_LIKE`. PG/Redshift: `~`. DuckDB: `regexp_matches` or `~` (full match). BigQuery: `REGEXP_CONTAINS`. Else unsupported. | + +**C.** ILIKE via COLLATE/LOWER; LIKE ALL; GLOB; Snowflake RLIKE anchoring. + +**D.** Per-dialect LIKE / ILIKE / ANY negation / SIMILAR TO / RLIKE snapshots; PG ANY without ESCAPE; no `RLIKE … ESCAPE`. + +Official: [PG matching](https://www.postgresql.org/docs/current/functions-matching.html) Ā· [BQ operators](https://cloud.google.com/bigquery/docs/reference/standard-sql/operators) Ā· [Snowflake LIKE ANY](https://docs.snowflake.com/en/sql-reference/functions/like_any) Ā· [Spark LIKE](https://spark.apache.org/docs/latest/sql-ref-syntax-qry-select-like.html) Ā· [MySQL REGEXP](https://dev.mysql.com/doc/refman/8.4/en/regexp.html) + +--- + +## 3. Literals (`Value` → serializers + dialect formatters) + +**Emit:** bool keyword or `1`/`0`; strings via wrap+escape; bytes via `BytesFormat`; bits or raise; date/time/json/uuid/inet/mac as **quoted strings**; arrays `ARRAY[…]` only if `supports_array`. + +### P0 + +1. **PostgreSQL NUL:** code zero cannot appear in a string constant. `E'\x00'` is still NUL after unescape. **Reject NUL.** +2. **Snowflake `X'HEX'`** is not a documented BINARY constant (`TO_BINARY`). +3. **DuckDB `X'HEX'`** is not the documented blob form (`'\xAA'::BLOB`). +4. **Oracle untyped `'YYYY-MM-DD'`** follows `NLS_DATE_FORMAT`. Official DATE literal is `DATE 'YYYY-MM-DD'`. +5. **QuoteStripNul** deletes U+0000 (MsSql, Oracle, Redshift, Sqlite, Teradata) — payload change. Prefer reject. + +Boolean `1`/`0` on MsSql/MySql/Oracle/Sqlite matches those engines’ usual constants. Keyword TRUE/FALSE is correct on the others. + +### Tasks + +**A.** Doccomments on `SerializerEnum::write_sql`, `write_string_literal`, `format_bytes`, `format_bits`: wrap/escape; PG `E` + NUL illegal; MsSql `N'` = nvarchar; bytes wrappers; temporals/json/uuid are string literals. + +**B.** + +1. Reject U+0000 on PostgreSQL (and any dialect that forbids NUL in strings). +2. Snowflake bytes → `TO_BINARY('…', 'HEX')`. +3. DuckDB bytes → `'\xHH…'::BLOB`. +4. Oracle dates → `DATE 'YYYY-MM-DD'`; timestamps → `TIMESTAMP '…'`. +5. Stop stripping NUL: reject. +6. High value: typed `DATE '…'` / `TIMESTAMP '…'` on BQ, Databricks, DuckDB, Hive, PG, Snowflake, Spark, Teradata ANSI. + +**C.** JSON `JSON '…'` / `PARSE_JSON` / `'…'::jsonb`; UUID/`inet` casts; DuckDB LIST `[…]`; Spark/Hive `array()`; Postgres `'…'` + `''` with `E` only when backslash is needed. + +**D.** Golden per dialect: bool, `O'Brien`, NUL (error), bytes, empty bytes, date, JSON, UUID, ARRAY or raise. + +Official: [PG lexical](https://www.postgresql.org/docs/current/sql-syntax-lexical.html) Ā· [BQ lexical](https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/lexical) Ā· [Snowflake binary I/O](https://docs.snowflake.com/en/sql-reference/binary-input-output) Ā· [Oracle literals](https://docs.oracle.com/en/database/oracle/oracle-database/26/sqlrf/Literals.html) Ā· [DuckDB blob](https://duckdb.org/docs/current/sql/data_types/blob.html) + +--- + +## 4. Operators, tuples, predicates + +**Emit:** unary spacing groups; binary `left OP right`; `!=` → `<>`; `XOR` keyword; `^` bitwise XOR; `||` concat; tuple `(a, b)`; `IN ()` for empty; `IS NORMALIZED`; no BETWEEN SYMMETRIC. + +### P0 + +1. MySQL default `||` is **logical OR**, not concat. +2. SQL Server `||` concat is **2025+**; classic concat is `+`. +3. `XOR` keyword is MySQL logical XOR only. +4. `^` is **exponent** on PostgreSQL, Redshift, DuckDB. PG bitwise XOR is `#`. +5. `%` is not an operator on Oracle (`MOD()`) or Teradata (`MOD`). +6. `!` postfix factorial: **removed in PostgreSQL 14**; Hive/Spark/Databricks/MySQL `!` is logical NOT. DuckDB still has `4!`. +7. Geometric unaries `@-@` `@@` `#` `?-` `?|` are **PostgreSQL geometric**, not Redshift. +8. `IS [form] NORMALIZED` is PostgreSQL-only. +9. `IS DISTINCT FROM` undocumented on MySQL/Hive (use `<=>`), Oracle, Redshift, Teradata; MsSql from 2022. +10. Empty `IN ()` invalid except SQLite (and BQ empty-set _semantics_ via subquery). +11. Singleton `(x)` is grouping, not a 1-tuple, on PG/SQLite/Databricks/Spark/DuckDB. +12. Snowflake bitwise is functions (`BITAND`/`BITXOR`), not `& | ^ ~`. + +`<>` is the portable inequality spelling on every opened comparison page. + +### Tasks + +**A.** Doccomments on unary/binary/tuple/Is/IsNormalized/IsDistinctFrom/InList/Between: ungated spellings; `!=`→`<>`; `!` factorial; empty `IN ()`. Do not call geometric unaries Redshift. + +**B.** + +| Node | Action | +| ---------------- | --------------------------------------------------------------------------------------- | +| `\|\|` | MySQL: `CONCAT()` (or require `PIPES_AS_CONCAT`). MsSql: `+` until 2025. | +| `XOR` | MySQL only. | +| `BitXor` | PG `#`; DuckDB `xor()`; Snowflake `BITXOR()`; BQ/Spark/Hive/MySQL/MsSql `^`. | +| `Mod` | Oracle `MOD()`; Teradata `MOD`. | +| PG unaries | PG (+ Redshift numeric `@ \|/ \|\|/`). Factorial → `factorial()`. Geometric → PG only. | +| `IsNormalized` | PostgreSql only. | +| `IsDistinctFrom` | `<=>` on MySQL/Hive; refuse Oracle/Redshift/Teradata; MsSql ≄2022. | +| Empty `IN` | Rewrite to `FALSE`/`TRUE`; never emit `IN ()`. | +| `Tuple` | PG `ROW(...)` including 0/1 field; Spark/Databricks/Hive `struct()`; SQLite refuse 0/1. | +| Extra parens | Always paren `\|\|` vs `+-`/`*/`; MsSql `IN`/`BETWEEN` vs `OR`. | + +**C.** BETWEEN SYMMETRIC; integer `DIV`/`//`; SQLite row-IN via subquery once Query exists. + +**D.** Concat on MySQL vs PG; `^` PG exponent vs MySQL bitwise; `UnaryExpr("!")` DuckDB vs Hive; empty IN; `IsNormalized` PG only; `<=>` on MySQL. + +Official: [PG math](https://www.postgresql.org/docs/current/functions-math.html) Ā· [PG 14 factorial](https://www.postgresql.org/docs/14/release-14.html) Ā· [MySQL logical ops](https://dev.mysql.com/doc/refman/8.4/en/logical-operators.html) Ā· [SQLite rowvalue](https://www.sqlite.org/rowvalue.html) Ā· [BQ operators](https://cloud.google.com/bigquery/docs/reference/standard-sql/operators) + +--- + +## 5. CAST, TRIM, COLLATE, CASE, Custom, DataTypeName + +**Emit:** `KIND(expr AS type[ FORMAT … [ AT TIME ZONE …]])`; TRIM SQL FROM and comma-list; `expr COLLATE ident`; CASE simple/searched; Custom `{}`; type name verbatim. + +### P0 + +1. **SAFE_CAST** is BigQuery-only. **TRY_CAST** is not on PG/MySQL/SQLite/Hive/Oracle. Teradata is **`TRYCAST`**. Snowflake TRY_CAST is **string source only**. +2. **FORMAT / AT TIME ZONE** is BigQuery CAST grammar (Hive datetime FORMAT; Teradata type FORMAT). Invalid on PG, MySQL (different AT TIME ZONE production), SQL Server, Snowflake, DuckDB, Spark CAST, SQLite, Oracle (positional _fmt_, not keyword FORMAT). +3. **TRIM(LEADING FROM …)** invalid on BigQuery, Snowflake, DuckDB, SQLite, Hive (`TRIM(value[, set])` / `trim(s[, chars])`). +4. **COLLATE ident** invalid on BigQuery (`COLLATE(s,'und:ci')`) and Redshift (`COLLATE(s,'ci')`). Snowflake spec is a **string**. + +CASE simple + searched + omitted ELSE → NULL is portable. + +### Type-name traps + +- BQ: INT64 / STRING / BYTES / BOOL / FLOAT64 / `ARRAY` (INT is an alias of INT64). +- MySQL CAST: `SIGNED`, not `INT`. +- MsSql bool: `bit`, not BOOLEAN. +- PG: `bytea`, `timestamp with time zone` (interned `TIMESTAMP WITH TIMEZONE` has no space in TIMEZONE). +- Spark/Hive/Databricks: `ARRAY`, not `INT ARRAY`. + +### Tasks + +**A.** Doccomments on Cast / Trim / Collate / Case / Custom / DataTypeName with the URLs in the CAST research (BQ conversion functions, PG type casts, TRIM string functions, COLLATE expressions). + +**B.** + +- Kind allow-list (table above). Do not map SAFE_CAST → TRY_CAST unless the dialect documents that twin. +- FORMAT / AT TIME ZONE only for BigQuery (Hive FORMAT only datetime↔string). +- TRIM: (1) SQL FROM — PG, MySQL, Redshift, Spark/Databricks, Oracle one-char, Teradata, MsSql 2022; (2) `trim(expr[, chars])` — BQ, Snowflake, DuckDB, SQLite; (3) Hive `trim(expr)` spaces only. +- COLLATE: infix ident (PG, MySQL, MsSql, Oracle, SQLite, DuckDB, Databricks); function+string (BQ, Redshift, Snowflake). +- DataTypeName map for CAST: INT→INT64 (BQ), SIGNED (MySQL), STRING (BQ/Spark/Hive/Databricks), BYTEA (PG), BINARY (Spark), bit (MsSql bool), `ARRAY` vs `T[]`. + +**C.** `::`, CONVERT, Oracle `DEFAULT … ON CONVERSION ERROR`, Databricks `?::`. + +**D.** Per dialect: CAST kind accepted/rejected; FORMAT omitted except BQ; TRIM form; COLLATE form; `CAST(x AS INT64)` BQ vs `INTEGER` PG vs `SIGNED` MySQL. + +Official: [BQ CAST](https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/conversion_functions) Ā· [PG CAST](https://www.postgresql.org/docs/current/sql-expressions.html#SQL-SYNTAX-TYPE-CASTS) Ā· [BQ TRIM](https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/string_functions#trim) Ā· [PG COLLATE](https://www.postgresql.org/docs/current/sql-expressions.html#SQL-SYNTAX-COLLATE-EXPRS) Ā· [Oracle TRIM](https://docs.oracle.com/en/database/oracle/oracle-database/23/sqlrf/TRIM.html) + +--- + +## Suggested order (when you implement) + +Do **A (doccomments)** first, Function-style, next to each `write_sql`. Then **B gates that omit invalid SQL** (FILTER, NT, ALL, ILIKE, SIMILAR TO, RLIKE, CAST FORMAT, TRIM FROM, COLLATE, empty IN, Teradata quotes, PG NUL). Then **spelling rewrites** (BQ in-call WHERE, RLIKE `~` / `REGEXP_LIKE`, LIKE ANY negation, Snowflake/DuckDB bytes, Oracle `DATE '…'`). **C** stays later (OVER, WITHIN GROUP, typed JSON, `::`). + +**Do not guess.** If a dialect page does not show the production, raise. Silent `LOWER()` / `CONCAT()` / `REGEXP_LIKE()` only after an explicit dialect flag. + +**UNVERIFIED leftovers:** Teradata Help Center is a JS SPA (identifier quoting, LIKE ANY, bit `'101'B`). DuckDB `FILTER (WHERE` vs `FILTER (` — railroad includes WHERE. Hive LIKE ESCAPE production not on the current UDF page. Spark OSS 4.2 catalog lists CAST/`::`; `TRY_CAST` is Databricks-documented. From 39970d6e7407b2073d034ad3b88c47c8a7ddb0db Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 28 Sep 2026 19:07:26 +0330 Subject: [PATCH 53/87] Task 3 --- src/data_type/serializer/serialize.rs | 166 +++++++++--- src/data_type/serializer/serializer_enum.rs | 11 +- src/dialect/builtin.rs | 159 +++++++---- src/dialect/inner.rs | 283 ++++++++++++++++---- src/expr/enum_/nodes.rs | 3 +- src/sql.rs | 31 ++- tests/test_expr.py | 42 +-- tests/test_serializers.py | 201 ++++++++------ 8 files changed, 635 insertions(+), 261 deletions(-) diff --git a/src/data_type/serializer/serialize.rs b/src/data_type/serializer/serialize.rs index 9e9b3e9..580cf8d 100644 --- a/src/data_type/serializer/serialize.rs +++ b/src/data_type/serializer/serialize.rs @@ -10,7 +10,10 @@ use std::str::FromStr; use pyo3::types::{PyAnyMethods, PyTzInfoAccess}; use crate::dialect::PySqlDialect; -use crate::dialect::inner::Dialect; +use crate::dialect::inner::{ + ArrayFormat, Dialect, FormatError, InetFormat, JsonFormat, MacFormat, TemporalLiteral, + UuidFormat, +}; use crate::internal::alias; use crate::internal::utils::{borrow_py_str_buffer, get_type_name}; @@ -21,14 +24,6 @@ impl super::serializer_enum::SerializerEnum { object: &alias::BoundObject<'_>, out: &mut String, ) -> pyo3::PyResult<()> { - if !self.is_supported_by(&dialect.0) { - return rapidquery_macros::pyerr!( - Err(PyNotImplementedError), - "this dialect does not support {}", - super::class_name(self) - ); - } - let py = object.py(); let ptr = object.as_ptr(); unsafe { @@ -83,8 +78,92 @@ unsafe fn py_bytes<'a>(ptr: *mut pyo3::ffi::PyObject) -> &'a [u8] { } #[inline] -fn write_quoted(dialect: &Dialect, out: &mut String, raw: &str) { - let _ = dialect.write_string_literal(out, raw); +fn write_quoted(dialect: &Dialect, out: &mut String, raw: &str) -> pyo3::PyResult<()> { + dialect.write_string_literal(out, raw).map_err(Into::into) +} + +#[inline] +fn write_temporal( + dialect: &Dialect, + out: &mut String, + format: TemporalLiteral, + keyword: &str, + raw: &str, +) -> pyo3::PyResult<()> { + if matches!(format, TemporalLiteral::TypedKeyword) { + out.push_str(keyword); + out.push(' '); + } + write_quoted(dialect, out, raw) +} + +#[inline] +fn write_uuid_text(dialect: &Dialect, out: &mut String, raw: &str) -> pyo3::PyResult<()> { + match dialect.uuid_format { + UuidFormat::Quoted => write_quoted(dialect, out, raw), + UuidFormat::UuidKeyword => { + out.push_str("UUID "); + write_quoted(dialect, out, raw) + } + UuidFormat::CastUuid => { + write_quoted(dialect, out, raw)?; + out.push_str("::uuid"); + Ok(()) + } + } +} + +#[inline] +fn write_json_text(dialect: &Dialect, out: &mut String, raw: &str) -> pyo3::PyResult<()> { + match dialect.json_format { + JsonFormat::Quoted => write_quoted(dialect, out, raw), + JsonFormat::JsonKeyword => { + out.push_str("JSON "); + write_quoted(dialect, out, raw) + } + JsonFormat::ParseJson => { + out.push_str("PARSE_JSON("); + write_quoted(dialect, out, raw)?; + out.push(')'); + Ok(()) + } + JsonFormat::CastJson => { + write_quoted(dialect, out, raw)?; + out.push_str("::json"); + Ok(()) + } + JsonFormat::CastJsonb => { + write_quoted(dialect, out, raw)?; + out.push_str("::jsonb"); + Ok(()) + } + } +} + +#[inline] +fn write_inet_text(dialect: &Dialect, out: &mut String, raw: &str) -> pyo3::PyResult<()> { + match dialect.inet_format { + InetFormat::None => Err(FormatError::Unsupported("InetSerializer").into()), + InetFormat::Quoted => write_quoted(dialect, out, raw), + InetFormat::Cast => { + write_quoted(dialect, out, raw)?; + out.push_str("::inet"); + Ok(()) + } + } +} + +#[inline] +fn write_mac_text(dialect: &Dialect, out: &mut String, raw: &str) -> pyo3::PyResult<()> { + match dialect.mac_format { + MacFormat::None => Err(FormatError::Unsupported("MacAddressSerializer").into()), + MacFormat::Quoted => write_quoted(dialect, out, raw), + MacFormat::Cast => { + write_quoted(dialect, out, raw)?; + out.push_str("::macaddr"); + Ok(()) + } + } } /// Serializes any Python `int` as a bare decimal literal (`str(int)`), @@ -163,7 +242,7 @@ unsafe fn write_bool( ) -> pyo3::PyResult<()> { if pyo3::ffi::PyBool_Check(ptr) == 1 { let value = pyo3::ffi::Py_True() == ptr; - let _ = dialect.format_boolean(out, value); + dialect.format_boolean(out, value)?; Ok(()) } else { rapidquery_macros::pyerr!( @@ -186,8 +265,7 @@ unsafe fn write_str( out: &mut String, ) -> pyo3::PyResult<()> { if pyo3::ffi::PyUnicode_Check(ptr) == 1 { - write_quoted(dialect, out, borrow_py_str_buffer(py, ptr)?); - return Ok(()); + return write_quoted(dialect, out, borrow_py_str_buffer(py, ptr)?); } rapidquery_macros::pyerr!( @@ -208,15 +286,13 @@ unsafe fn write_uuid( ) -> pyo3::PyResult<()> { if crate::typeref::check_exact(ptr, crate::typeref::STD_UUID_TYPE) { let text = py_to_string(py, ptr)?; - write_quoted(dialect, out, &text); - Ok(()) + write_uuid_text(dialect, out, &text) } else if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { let borrowed = borrow_py_str_buffer(py, ptr)?; let parsed = uuid::Uuid::try_parse(borrowed) .map_err(|e| rapidquery_macros::pyerr!(PyValueError, "invalid uuid str: {}", e))?; - write_quoted(dialect, out, &parsed.hyphenated().to_string()); - Ok(()) + write_uuid_text(dialect, out, &parsed.hyphenated().to_string()) } else { rapidquery_macros::pyerr!( Err(PyTypeError), @@ -236,7 +312,7 @@ unsafe fn write_bytes( out: &mut String, ) -> pyo3::PyResult<()> { if pyo3::ffi::PyBytes_CheckExact(ptr) == 1 { - let _ = dialect.format_bytes(out, py_bytes(ptr)); + dialect.format_bytes(out, py_bytes(ptr))?; Ok(()) } else { Err(rapidquery_macros::pyerr!( @@ -265,7 +341,7 @@ unsafe fn write_bit( raw ); } - let _ = dialect.format_bits(out, raw); + dialect.format_bits(out, raw)?; Ok(()) } else { rapidquery_macros::pyerr!( @@ -289,10 +365,7 @@ unsafe fn write_json( ) -> pyo3::PyResult<()> { crate::internal::modules::json_dumps( unsafe { pyo3::Bound::from_borrowed_ptr(py, ptr) }, - |text| { - write_quoted(dialect, out, text); - Ok(()) - }, + |text| write_json_text(dialect, out, text), ) } @@ -310,8 +383,7 @@ unsafe fn write_date( pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; let date: chrono::NaiveDate = val.extract()?; let text = date.format("%Y-%m-%d").to_string(); - write_quoted(dialect, out, &text); - Ok(()) + write_temporal(dialect, out, dialect.date_format, "DATE", &text) } else { Err(rapidquery_macros::pyerr!( PyTypeError, @@ -335,8 +407,7 @@ unsafe fn write_time( pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; let time: chrono::NaiveTime = val.extract()?; let text = time.format("%H:%M:%S%.f").to_string(); - write_quoted(dialect, out, &text); - Ok(()) + write_temporal(dialect, out, dialect.time_format, "TIME", &text) } else { Err(rapidquery_macros::pyerr!( PyTypeError, @@ -368,14 +439,12 @@ unsafe fn write_datetime( if val.get_tzinfo().is_none() { let datetime: chrono::NaiveDateTime = val.extract()?; let text = datetime.format("%Y-%m-%d %H:%M:%S%.f").to_string(); - write_quoted(dialect, out, &text); - return Ok(()); + return write_temporal(dialect, out, dialect.timestamp_format, "TIMESTAMP", &text); } let datetime: chrono::DateTime = val.extract()?; let text = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").to_string(); - write_quoted(dialect, out, &text); - return Ok(()); + return write_temporal(dialect, out, dialect.timestamp_format, "TIMESTAMP", &text); } if pyo3::ffi::PyLong_CheckExact(ptr) == 1 { @@ -387,8 +456,7 @@ unsafe fn write_datetime( return match chrono::Utc.timestamp_opt(num, 0) { chrono::offset::LocalResult::Single(datetime) => { let text = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").to_string(); - write_quoted(dialect, out, &text); - Ok(()) + write_temporal(dialect, out, dialect.timestamp_format, "TIMESTAMP", &text) } _ => Err(rapidquery_macros::pyerr!( PyOverflowError, @@ -405,8 +473,7 @@ unsafe fn write_datetime( { chrono::offset::LocalResult::Single(datetime) => { let text = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").to_string(); - write_quoted(dialect, out, &text); - Ok(()) + write_temporal(dialect, out, dialect.timestamp_format, "TIMESTAMP", &text) } _ => Err(rapidquery_macros::pyerr!( PyOverflowError, @@ -436,8 +503,7 @@ unsafe fn write_inet( let net = ipnetwork::IpNetwork::from_str(&text) .map(|net| net.to_string()) .map_err(|e| rapidquery_macros::pyerr!(PyValueError, e.to_string()))?; - write_quoted(dialect, out, &net); - Ok(()) + write_inet_text(dialect, out, &net) } /// # Safety @@ -454,8 +520,7 @@ unsafe fn write_mac_address( .map(|mac| mac.to_string()) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; - write_quoted(dialect, out, &addr); - Ok(()) + write_mac_text(dialect, out, &addr) } /// # Safety @@ -487,7 +552,24 @@ unsafe fn write_array( ); }; - out.push_str("ARRAY["); + let close = match dialect.0.array_format { + ArrayFormat::None => { + return Err(FormatError::Unsupported("ArraySerializer").into()); + } + ArrayFormat::Constructor => { + out.push_str("ARRAY["); + ']' + } + ArrayFormat::List => { + out.push('['); + ']' + } + ArrayFormat::Function => { + out.push_str("array("); + ')' + } + }; + for i in 0..len { if i > 0 { out.push_str(", "); @@ -499,7 +581,7 @@ unsafe fn write_array( let borrowed = alias::BoundObject::from_borrowed_ptr(py, item); element.write_sql(dialect, &borrowed, out)?; } - out.push(']'); + out.push(close); Ok(()) } diff --git a/src/data_type/serializer/serializer_enum.rs b/src/data_type/serializer/serializer_enum.rs index 4865b15..59fd796 100644 --- a/src/data_type/serializer/serializer_enum.rs +++ b/src/data_type/serializer/serializer_enum.rs @@ -102,9 +102,14 @@ impl SerializerEnum { pub fn is_supported_by(&self, dialect: &crate::dialect::inner::Dialect) -> bool { match self { Self::Bit => dialect.bit_format.is_some(), - Self::Array(_) => dialect.supports_array, - Self::Inet => dialect.supports_inet, - Self::MacAddress => dialect.supports_mac_address, + Self::Array(_) => !matches!( + dialect.array_format, + crate::dialect::inner::ArrayFormat::None + ), + Self::Inet => !matches!(dialect.inet_format, crate::dialect::inner::InetFormat::None), + Self::MacAddress => { + !matches!(dialect.mac_format, crate::dialect::inner::MacFormat::None) + } _ => true, } } diff --git a/src/dialect/builtin.rs b/src/dialect/builtin.rs index 4189335..3bdcf7c 100644 --- a/src/dialect/builtin.rs +++ b/src/dialect/builtin.rs @@ -32,14 +32,15 @@ macro_rules! impl_builtin_dialects { } impl_builtin_dialects!( + /// BigQuery SQL dialect. PyBigQuery = "BigQuery" { Dialect { positional_parameter: PositionalParameter::Simple('?'), bit_format: None, - supports_array: true, - supports_inet: false, - supports_mac_address: false, + array_format: ArrayFormat::Constructor, + inet_format: InetFormat::None, + mac_format: MacFormat::None, null_treatment_syntax: Some(NullTreatmentSyntax::InsideParens), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -48,6 +49,11 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashHex, bytes_format: BytesFormat::PrefixBigBSlashX, + date_format: TemporalLiteral::TypedKeyword, + time_format: TemporalLiteral::TypedKeyword, + timestamp_format: TemporalLiteral::TypedKeyword, + json_format: JsonFormat::JsonKeyword, + uuid_format: UuidFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::InsideWhere), ilike_expression_options: ILikeExpressionOptions::UseLikeLower, supports_similar_to: false, @@ -66,9 +72,9 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Simple('?'), bit_format: None, - supports_array: false, - supports_inet: false, - supports_mac_address: false, + array_format: ArrayFormat::None, + inet_format: InetFormat::None, + mac_format: MacFormat::None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::PatternList, bool_format: BoolFormat::Keyword, @@ -77,6 +83,11 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashUnicodeQuote, bytes_format: BytesFormat::PrefixBigX, + date_format: TemporalLiteral::TypedKeyword, + time_format: TemporalLiteral::Quoted, + timestamp_format: TemporalLiteral::TypedKeyword, + json_format: JsonFormat::Quoted, + uuid_format: UuidFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: false, @@ -95,9 +106,9 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Numbered('$'), bit_format: Some(BitFormat::PrefixBigB), - supports_array: true, - supports_inet: true, - supports_mac_address: false, + array_format: ArrayFormat::List, + inet_format: InetFormat::Cast, + mac_format: MacFormat::None, null_treatment_syntax: Some(NullTreatmentSyntax::InsideParens), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -105,7 +116,12 @@ impl_builtin_dialects!( identifier_escape: IdentifierEscape::Double, string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, - bytes_format: BytesFormat::PrefixBigX, + bytes_format: BytesFormat::SlashXBlob, + date_format: TemporalLiteral::TypedKeyword, + time_format: TemporalLiteral::TypedKeyword, + timestamp_format: TemporalLiteral::TypedKeyword, + json_format: JsonFormat::Quoted, + uuid_format: UuidFormat::UuidKeyword, function_filter_syntax: Some(FunctionFilterSyntax::Filter), ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: true, @@ -124,9 +140,9 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Simple('?'), bit_format: None, - supports_array: false, - supports_inet: false, - supports_mac_address: false, + array_format: ArrayFormat::Function, + inet_format: InetFormat::None, + mac_format: MacFormat::None, null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -135,6 +151,11 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashUnicode, bytes_format: BytesFormat::Unhex, + date_format: TemporalLiteral::TypedKeyword, + time_format: TemporalLiteral::Quoted, + timestamp_format: TemporalLiteral::TypedKeyword, + json_format: JsonFormat::Quoted, + uuid_format: UuidFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLikeLower, supports_similar_to: false, @@ -153,17 +174,22 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Simple('?'), bit_format: None, - supports_array: false, - supports_inet: false, - supports_mac_address: false, + array_format: ArrayFormat::None, + inet_format: InetFormat::None, + mac_format: MacFormat::None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, identifier_format: IdentifierFormat::SquareBracket, identifier_escape: IdentifierEscape::Double, string_format: StringFormat::PrefixBigN, - string_escape: StringEscape::QuoteStripNul, + string_escape: StringEscape::Quote, bytes_format: BytesFormat::PrefixZeroX, + date_format: TemporalLiteral::Quoted, + time_format: TemporalLiteral::Quoted, + timestamp_format: TemporalLiteral::Quoted, + json_format: JsonFormat::Quoted, + uuid_format: UuidFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLike, supports_similar_to: false, @@ -182,9 +208,9 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Simple('?'), bit_format: Some(BitFormat::PrefixSmallB), - supports_array: false, - supports_inet: false, - supports_mac_address: false, + array_format: ArrayFormat::None, + inet_format: InetFormat::None, + mac_format: MacFormat::None, null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, @@ -193,6 +219,11 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashMysql, bytes_format: BytesFormat::PrefixBigX, + date_format: TemporalLiteral::Quoted, + time_format: TemporalLiteral::Quoted, + timestamp_format: TemporalLiteral::Quoted, + json_format: JsonFormat::Quoted, + uuid_format: UuidFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLike, supports_similar_to: false, @@ -211,17 +242,22 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Numbered(':'), bit_format: None, - supports_array: false, - supports_inet: false, - supports_mac_address: false, + array_format: ArrayFormat::None, + inet_format: InetFormat::None, + mac_format: MacFormat::None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, identifier_format: IdentifierFormat::DoubleQuote, identifier_escape: IdentifierEscape::Double, string_format: StringFormat::SingleQuote, - string_escape: StringEscape::QuoteStripNul, + string_escape: StringEscape::Quote, bytes_format: BytesFormat::HexToRaw, + date_format: TemporalLiteral::TypedKeyword, + time_format: TemporalLiteral::Quoted, + timestamp_format: TemporalLiteral::TypedKeyword, + json_format: JsonFormat::Quoted, + uuid_format: UuidFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::UseLikeLower, supports_similar_to: false, @@ -240,9 +276,9 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Numbered('$'), bit_format: Some(BitFormat::PrefixBigB), - supports_array: true, - supports_inet: true, - supports_mac_address: true, + array_format: ArrayFormat::Constructor, + inet_format: InetFormat::Cast, + mac_format: MacFormat::Cast, null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::ArrayConstructor, bool_format: BoolFormat::Keyword, @@ -251,6 +287,11 @@ impl_builtin_dialects!( string_format: StringFormat::PrefixBigE, string_escape: StringEscape::BackslashHex, bytes_format: BytesFormat::SlashXBytea, + date_format: TemporalLiteral::TypedKeyword, + time_format: TemporalLiteral::TypedKeyword, + timestamp_format: TemporalLiteral::TypedKeyword, + json_format: JsonFormat::CastJsonb, + uuid_format: UuidFormat::CastUuid, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: true, @@ -269,17 +310,22 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Numbered('$'), bit_format: None, - supports_array: false, - supports_inet: false, - supports_mac_address: false, + array_format: ArrayFormat::None, + inet_format: InetFormat::None, + mac_format: MacFormat::None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::DoubleQuote, identifier_escape: IdentifierEscape::Double, string_format: StringFormat::SingleQuote, - string_escape: StringEscape::QuoteStripNul, + string_escape: StringEscape::Quote, bytes_format: BytesFormat::ToVarbyte, + date_format: TemporalLiteral::Quoted, + time_format: TemporalLiteral::Quoted, + timestamp_format: TemporalLiteral::Quoted, + json_format: JsonFormat::Quoted, + uuid_format: UuidFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: true, @@ -298,9 +344,9 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Numbered(':'), bit_format: None, - supports_array: false, - supports_inet: false, - supports_mac_address: false, + array_format: ArrayFormat::None, + inet_format: InetFormat::None, + mac_format: MacFormat::None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::PatternList, bool_format: BoolFormat::Keyword, @@ -308,7 +354,12 @@ impl_builtin_dialects!( identifier_escape: IdentifierEscape::Double, string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashHexZero, - bytes_format: BytesFormat::PrefixBigX, + bytes_format: BytesFormat::ToBinary, + date_format: TemporalLiteral::TypedKeyword, + time_format: TemporalLiteral::Quoted, + timestamp_format: TemporalLiteral::TypedKeyword, + json_format: JsonFormat::ParseJson, + uuid_format: UuidFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: false, @@ -327,9 +378,9 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Simple('?'), bit_format: None, - supports_array: false, - supports_inet: false, - supports_mac_address: false, + array_format: ArrayFormat::Function, + inet_format: InetFormat::None, + mac_format: MacFormat::None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::PatternList, bool_format: BoolFormat::Keyword, @@ -338,6 +389,11 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashUnicodeQuote, bytes_format: BytesFormat::PrefixBigX, + date_format: TemporalLiteral::TypedKeyword, + time_format: TemporalLiteral::Quoted, + timestamp_format: TemporalLiteral::TypedKeyword, + json_format: JsonFormat::Quoted, + uuid_format: UuidFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: false, @@ -356,17 +412,22 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Numbered('?'), bit_format: None, - supports_array: false, - supports_inet: false, - supports_mac_address: false, + array_format: ArrayFormat::None, + inet_format: InetFormat::None, + mac_format: MacFormat::None, null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, identifier_format: IdentifierFormat::DoubleQuote, identifier_escape: IdentifierEscape::Double, string_format: StringFormat::SingleQuote, - string_escape: StringEscape::QuoteStripNul, + string_escape: StringEscape::Quote, bytes_format: BytesFormat::PrefixBigX, + date_format: TemporalLiteral::Quoted, + time_format: TemporalLiteral::Quoted, + timestamp_format: TemporalLiteral::Quoted, + json_format: JsonFormat::Quoted, + uuid_format: UuidFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::UseLike, supports_similar_to: false, @@ -385,17 +446,22 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Simple('?'), bit_format: Some(BitFormat::PostfixBigB), - supports_array: false, - supports_inet: false, - supports_mac_address: false, + array_format: ArrayFormat::None, + inet_format: InetFormat::None, + mac_format: MacFormat::None, null_treatment_syntax: Some(NullTreatmentSyntax::InsideParens), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, identifier_format: IdentifierFormat::DoubleQuote, identifier_escape: IdentifierEscape::Double, string_format: StringFormat::SingleQuote, - string_escape: StringEscape::QuoteStripNul, + string_escape: StringEscape::Quote, bytes_format: BytesFormat::PostfixXbv, + date_format: TemporalLiteral::TypedKeyword, + time_format: TemporalLiteral::Quoted, + timestamp_format: TemporalLiteral::TypedKeyword, + json_format: JsonFormat::Quoted, + uuid_format: UuidFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLikeLower, supports_similar_to: false, @@ -408,6 +474,7 @@ impl_builtin_dialects!( parenthesize_in_between: false, } } + ); #[cfg(test)] diff --git a/src/dialect/inner.rs b/src/dialect/inner.rs index bb29aaa..55771af 100644 --- a/src/dialect/inner.rs +++ b/src/dialect/inner.rs @@ -2,6 +2,32 @@ use std::fmt::{self, Write}; use crate::internal::utils; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FormatError { + Write, + NulInString, + Unsupported(&'static str), +} + +impl From for FormatError { + #[inline] + fn from(_: fmt::Error) -> Self { + Self::Write + } +} + +impl fmt::Display for FormatError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Write => f.write_str("failed to write SQL"), + Self::NulInString => f.write_str("NUL is not allowed in a string literal"), + Self::Unsupported(name) => write!(f, "this dialect does not support {name}"), + } + } +} + +impl std::error::Error for FormatError {} + /// Positional parameter syntax #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum PositionalParameter { @@ -199,17 +225,15 @@ pub enum StringFormat { /// How to escape literal strings. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum StringEscape { - /// `'` → `''`. Other characters, including NUL, are unchanged. + /// `'` → `''`. Other characters are unchanged. Quote, - /// `'` → `''`. NUL is deleted. - QuoteStripNul, - /// `\\`, `\'`, `\n`, `\r`, `\t`; NUL and other C0/DEL as `\xNN`. + /// `\\`, `\'`, `\n`, `\r`, `\t`; other C0/DEL as `\xNN`. BackslashHex, - /// MySQL: also `\"` and `\Z` for 0x1A; NUL as `\0`; tab and other controls pass through. + /// MySQL: also `\"` and `\Z` for 0x1A; tab and other controls pass through. BackslashMysql, - /// Hive: `\0` for NUL; other C0/DEL as `\uNNNN`. No `\"`. + /// Hive: other C0/DEL as `\uNNNN`. No `\"`. BackslashUnicode, - /// Snowflake: `\0` for NUL; other C0/DEL as `\xNN`. + /// Snowflake: other C0/DEL as `\xNN`. BackslashHexZero, /// Spark/Databricks: [`Self::BackslashUnicode`] plus `\"`. BackslashUnicodeQuote, @@ -221,10 +245,6 @@ impl StringEscape { fn write(self, writer: &mut W, string: &str) -> fmt::Result { match self { Self::Quote => utils::replace_to(string, '\'', "''", writer), - Self::QuoteStripNul => { - const REPLACEMENTS: [(u8, &str); 2] = [(b'\'', "''"), (b'\0', "")]; - utils::replace_ascii_to(string, &REPLACEMENTS, writer) - } Self::BackslashHex => write_backslash_hex(writer, string), Self::BackslashMysql => write_backslash_mysql(writer, string), Self::BackslashUnicode => write_backslash_unicode(writer, string, false), @@ -253,6 +273,10 @@ pub enum BytesFormat { ToVarbyte, /// `'DEADBEEF'XBV` PostfixXbv, + /// `TO_BINARY('DEADBEEF', 'HEX')` + ToBinary, + /// `'\xHH…'::BLOB`; empty is `''::BLOB` + SlashXBlob, } impl BytesFormat { @@ -301,6 +325,22 @@ impl BytesFormat { bytes_to_hex(writer, bytes)?; writer.write_str("'XBV") } + Self::ToBinary => { + writer.write_str("TO_BINARY('")?; + bytes_to_hex(writer, bytes)?; + writer.write_str("', 'HEX')") + } + Self::SlashXBlob => { + if bytes.is_empty() { + writer.write_str("''::BLOB") + } else { + writer.write_char('\'')?; + for &b in bytes { + write_byte_hex_escape(writer, b)?; + } + writer.write_str("'::BLOB") + } + } } } } @@ -316,6 +356,62 @@ pub enum FunctionFilterSyntax { InsideWhere, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ArrayFormat { + None, + /// `ARRAY[…]` + Constructor, + /// `[…]` + List, + /// `array(…)` + Function, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum InetFormat { + None, + Quoted, + /// `'…'::inet` + Cast, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum MacFormat { + None, + Quoted, + /// `'…'::macaddr` + Cast, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TemporalLiteral { + Quoted, + /// `DATE` / `TIME` / `TIMESTAMP` then a string literal. + TypedKeyword, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JsonFormat { + Quoted, + /// `JSON '…'` + JsonKeyword, + /// `PARSE_JSON('…')` + ParseJson, + /// `'…'::json` + CastJson, + /// `'…'::jsonb` + CastJsonb, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum UuidFormat { + Quoted, + /// `UUID '…'` + UuidKeyword, + /// `'…'::uuid` + CastUuid, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Dialect { /// Positional parameter syntax. @@ -324,12 +420,9 @@ pub struct Dialect { /// /// Set [`Option::None`] if literal bit isn't supported. pub bit_format: Option, - /// Whether the dialect supports `ARRAY[...]` data type. - pub supports_array: bool, - /// Whether the dialect supports `INET`/`CIDR` data types. - pub supports_inet: bool, - /// Whether the dialect supports `MACADDR` data type. - pub supports_mac_address: bool, + pub array_format: ArrayFormat, + pub inet_format: InetFormat, + pub mac_format: MacFormat, /// How the dialect supports `{IGNORE|RESPECT} NULLS` expressions in `Function`s if supported. pub null_treatment_syntax: Option, /// How `LIKE ANY` / `ILIKE ANY` is rendered. @@ -346,6 +439,11 @@ pub struct Dialect { pub string_escape: StringEscape, /// How the dialect accepts literal bytes values. pub bytes_format: BytesFormat, + pub date_format: TemporalLiteral, + pub time_format: TemporalLiteral, + pub timestamp_format: TemporalLiteral, + pub json_format: JsonFormat, + pub uuid_format: UuidFormat, /// How function calling `FILTER` keyword rendered if supported. pub function_filter_syntax: Option, /// How case-insensitive `LIKE` is spelled (`ILIKE`, `LIKE`, or `LOWER` + `LIKE`). @@ -375,19 +473,28 @@ pub struct Dialect { impl Dialect { /// Write a boolean literal (`TRUE`/`FALSE` or `1`/`0`). #[inline] - pub fn format_boolean(&self, writer: &mut W, value: bool) -> fmt::Result { + pub fn format_boolean( + &self, + writer: &mut W, + value: bool, + ) -> Result<(), FormatError> { match (self.bool_format, value) { - (BoolFormat::Int, false) => write!(writer, "0"), - (BoolFormat::Int, true) => write!(writer, "1"), - (BoolFormat::Keyword, false) => write!(writer, "FALSE"), - (BoolFormat::Keyword, true) => write!(writer, "TRUE"), + (BoolFormat::Int, false) => writer.write_str("0")?, + (BoolFormat::Int, true) => writer.write_str("1")?, + (BoolFormat::Keyword, false) => writer.write_str("FALSE")?, + (BoolFormat::Keyword, true) => writer.write_str("TRUE")?, } + Ok(()) } /// Write `string` with special characters neutralized, without wrapping it. #[inline] - pub fn escape_string(&self, writer: &mut W, string: &str) -> fmt::Result { - self.string_escape.write(writer, string) + pub fn escape_string( + &self, + writer: &mut W, + string: &str, + ) -> Result<(), FormatError> { + self.string_escape.write(writer, string).map_err(Into::into) } /// Escape `ident` and wrap it in this dialect's identifier delimiters. @@ -430,47 +537,69 @@ impl Dialect { /// Escape `raw` and wrap it as a complete string literal. #[inline] - pub fn write_string_literal(&self, writer: &mut W, raw: &str) -> fmt::Result { + pub fn write_string_literal( + &self, + writer: &mut W, + raw: &str, + ) -> Result<(), FormatError> { + if raw.contains('\0') { + return Err(FormatError::NulInString); + } + + if matches!(self.string_format, StringFormat::PrefixBigE) && !needs_c_escape(raw) { + writer.write_char('\'')?; + StringEscape::Quote.write(writer, raw)?; + writer.write_char('\'')?; + return Ok(()); + } + match self.string_format { StringFormat::PrefixBigE => writer.write_str("E'")?, StringFormat::PrefixBigN => writer.write_str("N'")?, StringFormat::SingleQuote => writer.write_char('\'')?, } self.escape_string(writer, raw)?; - writer.write_char('\'') + writer.write_char('\'')?; + Ok(()) } /// Write a bit-string (`'0'`/`'1'` characters) as a native literal. /// /// Native formats embed `bits` as-is. Callers must pass only `'0'` and `'1'`; /// that is checked with `debug_assert!`. - /// - /// # Panics - /// - /// Panics when the dialect does not support literal bit values and - /// [`Self::bit_format`] is [`None`]. Callers should check - /// [`Option::is_some`] on [`Self::bit_format`] before calling this method. #[inline] - pub fn format_bits(&self, writer: &mut W, bits: &str) -> fmt::Result { + pub fn format_bits( + &self, + writer: &mut W, + bits: &str, + ) -> Result<(), FormatError> { debug_assert!( bits.chars().all(|c| c == '0' || c == '1'), "format_bits: invalid input: {bits}" ); match self.bit_format { - Some(format) => format.write(writer, bits), - None => panic!( - "format_bits: dialect does not support literal bit values (bit_format is None)" - ), + Some(format) => format.write(writer, bits).map_err(Into::into), + None => Err(FormatError::Unsupported("BitSerializer")), } } /// Write `bytes` as a native blob/bytea/varbyte literal. #[inline] - pub fn format_bytes(&self, writer: &mut W, bytes: &[u8]) -> fmt::Result { - self.bytes_format.write(writer, bytes) + pub fn format_bytes( + &self, + writer: &mut W, + bytes: &[u8], + ) -> Result<(), FormatError> { + self.bytes_format.write(writer, bytes).map_err(Into::into) } } +#[inline] +fn needs_c_escape(s: &str) -> bool { + s.chars() + .any(|c| matches!(c, '\\' | '\n' | '\r' | '\t') || (c as u32) < 32 || c as u32 == 127) +} + const HEX_DIGITS: &[u8; 16] = b"0123456789ABCDEF"; /// Write `byte` as two uppercase hex digits. @@ -592,27 +721,33 @@ fn write_backslash_hex_zero(writer: &mut W, string: &str) -> fmt::Resu mod tests { use super::*; - fn collect(write: impl FnOnce(&mut String) -> fmt::Result) -> String { + fn collect_fmt(write: impl FnOnce(&mut String) -> fmt::Result) -> String { + let mut buf = String::new(); + write(&mut buf).unwrap(); + buf + } + + fn collect(write: impl FnOnce(&mut String) -> Result<(), FormatError>) -> String { let mut buf = String::new(); write(&mut buf).unwrap(); buf } fn escape(kind: StringEscape, string: &str) -> String { - collect(|buf| kind.write(buf, string)) + collect_fmt(|buf| kind.write(buf, string)) } fn bytes(kind: BytesFormat, bytes: &[u8]) -> String { - collect(|buf| kind.write(buf, bytes)) + collect_fmt(|buf| kind.write(buf, bytes)) } fn base() -> Dialect { Dialect { positional_parameter: PositionalParameter::Simple(':'), bit_format: None, - supports_array: false, - supports_inet: false, - supports_mac_address: false, + array_format: ArrayFormat::None, + inet_format: InetFormat::None, + mac_format: MacFormat::None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -621,6 +756,11 @@ mod tests { string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::PrefixBigX, + date_format: TemporalLiteral::Quoted, + time_format: TemporalLiteral::Quoted, + timestamp_format: TemporalLiteral::Quoted, + json_format: JsonFormat::Quoted, + uuid_format: UuidFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLike, supports_similar_to: false, @@ -635,13 +775,10 @@ mod tests { } #[test] - fn quote_vs_quote_strip_nul() { - for kind in [StringEscape::Quote, StringEscape::QuoteStripNul] { - assert_eq!(escape(kind, "O'Brien"), "O''Brien"); - assert_eq!(escape(kind, "'"), "''"); - } + fn quote_doubles_apostrophe() { + assert_eq!(escape(StringEscape::Quote, "O'Brien"), "O''Brien"); + assert_eq!(escape(StringEscape::Quote, "'"), "''"); assert_eq!(escape(StringEscape::Quote, "a\0b"), "a\0b"); - assert_eq!(escape(StringEscape::QuoteStripNul, "a\0b"), "ab"); } #[test] @@ -743,21 +880,32 @@ mod tests { assert_eq!(bytes(BytesFormat::PostfixXbv, empty), "''XBV"); assert_eq!(bytes(BytesFormat::PostfixXbv, zero), "'00'XBV"); assert_eq!(bytes(BytesFormat::PostfixXbv, dead), "'DEAD'XBV"); + + assert_eq!(bytes(BytesFormat::ToBinary, empty), "TO_BINARY('', 'HEX')"); + assert_eq!(bytes(BytesFormat::ToBinary, zero), "TO_BINARY('00', 'HEX')"); + assert_eq!( + bytes(BytesFormat::ToBinary, dead), + "TO_BINARY('DEAD', 'HEX')" + ); + + assert_eq!(bytes(BytesFormat::SlashXBlob, empty), "''::BLOB"); + assert_eq!(bytes(BytesFormat::SlashXBlob, zero), "'\\x00'::BLOB"); + assert_eq!(bytes(BytesFormat::SlashXBlob, dead), "'\\xDE\\xAD'::BLOB"); } #[test] fn bit_format_literals() { let bits = "10110101"; assert_eq!( - collect(|buf| BitFormat::PrefixBigB.write(buf, bits)), + collect_fmt(|buf| BitFormat::PrefixBigB.write(buf, bits)), "B'10110101'" ); assert_eq!( - collect(|buf| BitFormat::PrefixSmallB.write(buf, bits)), + collect_fmt(|buf| BitFormat::PrefixSmallB.write(buf, bits)), "b'10110101'" ); assert_eq!( - collect(|buf| BitFormat::PostfixBigB.write(buf, bits)), + collect_fmt(|buf| BitFormat::PostfixBigB.write(buf, bits)), "'10110101'B" ); @@ -771,17 +919,36 @@ mod tests { } #[test] - #[should_panic(expected = "bit_format is None")] - fn format_bits_panics_when_unsupported() { + fn format_bits_errors_when_unsupported() { + let cfg = base(); + assert_eq!( + cfg.format_bits(&mut String::new(), "1"), + Err(FormatError::Unsupported("BitSerializer")) + ); + } + + #[test] + fn write_string_literal_rejects_nul() { let cfg = base(); - let _ = cfg.format_bits(&mut String::new(), "1"); + assert_eq!( + cfg.write_string_literal(&mut String::new(), "a\0b"), + Err(FormatError::NulInString) + ); } #[test] - fn write_string_literal_prefix_big_e_backslash_hex() { + fn write_string_literal_prefix_big_e_when_needed() { let mut cfg = base(); cfg.string_format = StringFormat::PrefixBigE; cfg.string_escape = StringEscape::BackslashHex; + assert_eq!( + collect(|buf| cfg.write_string_literal(buf, "hello")), + "'hello'" + ); + assert_eq!( + collect(|buf| cfg.write_string_literal(buf, "O'Brien")), + "'O''Brien'" + ); assert_eq!( collect(|buf| cfg.write_string_literal(buf, "hello'world\n")), "E'hello\\'world\\n'" diff --git a/src/expr/enum_/nodes.rs b/src/expr/enum_/nodes.rs index c703810..5ac181e 100644 --- a/src/expr/enum_/nodes.rs +++ b/src/expr/enum_/nodes.rs @@ -33,8 +33,7 @@ pub(super) fn write_escape( let mut buf = [0u8; 4]; dialect .0 - .write_string_literal(out, c.encode_utf8(&mut buf)) - .map_err(|_| crate::sql::PrepareError::Write)?; + .write_string_literal(out, c.encode_utf8(&mut buf))?; Ok(()) } diff --git a/src/sql.rs b/src/sql.rs index dc6cc4f..a44f6db 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -9,7 +9,7 @@ use pyo3::types::PyAnyMethods; use crate::data_type::SerializerEnum; use crate::dialect::PySqlDialect; -use crate::dialect::inner::PositionalParameter; +use crate::dialect::inner::{FormatError, PositionalParameter}; use crate::internal::alias; /// Error produced while building SQL. @@ -51,6 +51,35 @@ impl From for PrepareError { } } +impl From for PrepareError { + fn from(err: FormatError) -> Self { + match err { + FormatError::Write => Self::Write, + other => Self::Python(other.into()), + } + } +} + +impl From for pyo3::PyErr { + fn from(err: FormatError) -> Self { + match err { + FormatError::Write => { + rapidquery_macros::pyerr!(PyRuntimeError, "failed to write SQL") + } + FormatError::NulInString => { + rapidquery_macros::pyerr!(PyValueError, "NUL is not allowed in a string literal") + } + FormatError::Unsupported(name) => { + rapidquery_macros::pyerr!( + PyNotImplementedError, + "this dialect does not support {}", + name + ) + } + } + } +} + impl From for PrepareError { fn from(err: pyo3::PyErr) -> Self { Self::Python(err) diff --git a/tests/test_expr.py b/tests/test_expr.py index 9ee6e2c..165938a 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -213,7 +213,7 @@ def test_edge_cases(self): fn = Function("f").call("id") assert fn.get_args() == (Value("id"),) - assert fn.to_sql(PostgreSql()) == "\"f\"(E'id')" + assert fn.to_sql(PostgreSql()) == "\"f\"('id')" fn = Function(Ident("schema.fn")).call() assert fn.get_name() == Ident("schema.fn") @@ -265,7 +265,7 @@ def test_new(self): def test_to_sql(self): tpl = TupleExpr(1, 2, "A") - assert tpl.to_sql(PostgreSql()) == "ROW(1, 2, E'A')" + assert tpl.to_sql(PostgreSql()) == "ROW(1, 2, 'A')" sql, params = tpl.prepare(PostgreSql()) assert sql == "ROW(:param_0, :param_1, :param_2)" @@ -386,7 +386,7 @@ def test_to_sql(self): assert bin.to_sql(PostgreSql()) == '"id" > 10' bin = BinaryExpr("str", "-custom-", 10) - assert bin.to_sql(PostgreSql()) == "E'str' -custom- 10" + assert bin.to_sql(PostgreSql()) == "'str' -custom- 10" bin = BinaryExpr(Ident("id"), ">", BinaryExpr(456, "&", 10)) assert bin.to_sql(PostgreSql()) == '"id" > 456 & 10' @@ -645,19 +645,19 @@ def test_new(self): def test_to_sql(self): like = LikeExpr(Ident("name"), "%a%") - assert like.to_sql(PostgreSql()) == "\"name\" LIKE E'%a%'" + assert like.to_sql(PostgreSql()) == "\"name\" LIKE '%a%'" assert ( LikeExpr(Ident("name"), "%a%", case_sensitive=False).to_sql(PostgreSql()) - == "\"name\" ILIKE E'%a%'" + == "\"name\" ILIKE '%a%'" ) assert ( LikeExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSql()) - == "\"name\" NOT LIKE E'%a%'" + == "\"name\" NOT LIKE '%a%'" ) assert ( LikeExpr(Ident("name"), "%a%", escape="!").to_sql(PostgreSql()) - == "\"name\" LIKE E'%a%' ESCAPE E'!'" + == "\"name\" LIKE '%a%' ESCAPE '!'" ) sql, params = LikeExpr(Ident("name"), "%a%").prepare(PostgreSql()) @@ -666,7 +666,7 @@ def test_to_sql(self): any_like = LikeExpr(Ident("name"), ["%a%", "%b%"]) assert ( - any_like.to_sql(PostgreSql()) == "\"name\" LIKE ANY (ARRAY[E'%a%', E'%b%'])" + any_like.to_sql(PostgreSql()) == "\"name\" LIKE ANY (ARRAY['%a%', '%b%'])" ) assert any_like.to_sql(Snowflake()) == "\"name\" LIKE ANY ('%a%', '%b%')" assert any_like.to_sql(Sqlite()) == ( @@ -680,7 +680,7 @@ def test_to_sql(self): negated = LikeExpr(Ident("name"), ["%a%", "%b%"], negated=True) assert ( negated.to_sql(PostgreSql()) - == "NOT (\"name\" LIKE ANY (ARRAY[E'%a%', E'%b%']))" + == "NOT (\"name\" LIKE ANY (ARRAY['%a%', '%b%']))" ) assert negated.to_sql(Snowflake()) == "\"name\" NOT LIKE ANY ('%a%', '%b%')" assert negated.to_sql(Sqlite()) == ( @@ -690,13 +690,13 @@ def test_to_sql(self): ilike_any = LikeExpr(Ident("name"), ["%a%", "%b%"], case_sensitive=False) assert ( ilike_any.to_sql(PostgreSql()) - == "\"name\" ILIKE ANY (ARRAY[E'%a%', E'%b%'])" + == "\"name\" ILIKE ANY (ARRAY['%a%', '%b%'])" ) escaped = LikeExpr(Ident("name"), ["%a%", "%b%"], escape="!") assert ( escaped.to_sql(PostgreSql()) - == "\"name\" LIKE ANY (ARRAY[E'%a%', E'%b%']) ESCAPE E'!'" + == "\"name\" LIKE ANY (ARRAY['%a%', '%b%']) ESCAPE '!'" ) assert escaped.to_sql(Sqlite()) == ( "(\"name\" LIKE '%a%' ESCAPE '!' OR \"name\" LIKE '%b%' ESCAPE '!')" @@ -723,13 +723,13 @@ def test_new(self): def test_to_sql(self): sim = SimilarToExpr(Ident("name"), "%a%") - assert sim.to_sql(PostgreSql()) == "\"name\" SIMILAR TO E'%a%'" + assert sim.to_sql(PostgreSql()) == "\"name\" SIMILAR TO '%a%'" assert sim.to_sql(Redshift()) == "\"name\" SIMILAR TO '%a%'" assert sim.to_sql(DuckDb()) == "\"name\" SIMILAR TO '%a%'" assert ( SimilarToExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSql()) - == "\"name\" NOT SIMILAR TO E'%a%'" + == "\"name\" NOT SIMILAR TO '%a%'" ) sql, params = sim.prepare(PostgreSql()) @@ -766,7 +766,7 @@ def test_new(self): def test_to_sql(self): rlike = RLikeExpr(Ident("name"), "%a%") - assert rlike.to_sql(PostgreSql()) == "\"name\" ~ E'%a%'" + assert rlike.to_sql(PostgreSql()) == "\"name\" ~ '%a%'" assert rlike.to_sql(MySql()) == "`name` RLIKE '%a%'" assert rlike.to_sql(Spark()) == "`name` RLIKE '%a%'" assert rlike.to_sql(Snowflake()) == "\"name\" RLIKE '%a%'" @@ -776,7 +776,7 @@ def test_to_sql(self): assert ( RLikeExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSql()) - == "\"name\" !~ E'%a%'" + == "\"name\" !~ '%a%'" ) assert ( RLikeExpr(Ident("name"), "%a%", negated=True).to_sql(Oracle()) @@ -854,7 +854,7 @@ def test_to_sql(self): ) assert ( CastExpr(Ident("s"), "DATE", format="%Y-%m-%d").to_sql(PostgreSql()) - == "CAST(\"s\" AS DATE FORMAT E'%Y-%m-%d')" + == "CAST(\"s\" AS DATE FORMAT '%Y-%m-%d')" ) assert ( CastExpr( @@ -864,7 +864,7 @@ def test_to_sql(self): format="%Y-%m-%d", at_time_zone="UTC", ).to_sql(PostgreSql()) - == "SAFE_CAST(\"s\" AS TIMESTAMP FORMAT E'%Y-%m-%d' AT TIME ZONE E'UTC')" + == "SAFE_CAST(\"s\" AS TIMESTAMP FORMAT '%Y-%m-%d' AT TIME ZONE 'UTC')" ) sql, params = CastExpr(1, "INT").prepare(PostgreSql()) @@ -918,13 +918,13 @@ def test_to_sql(self): 'TRIM(LEADING FROM "s")' ) assert TrimExpr(Ident("s"), what="x").to_sql(PostgreSql()) == ( - "TRIM(E'x' FROM \"s\")" + "TRIM('x' FROM \"s\")" ) assert TrimExpr(Ident("s"), where="BOTH", what="x").to_sql(PostgreSql()) == ( - "TRIM(BOTH E'x' FROM \"s\")" + "TRIM(BOTH 'x' FROM \"s\")" ) assert TrimExpr(Ident("s"), characters=["a", "b"]).to_sql(PostgreSql()) == ( - "TRIM(\"s\", E'a', E'b')" + "TRIM(\"s\", 'a', 'b')" ) sql, params = TrimExpr(Ident("s"), what="x").prepare(PostgreSql()) @@ -1031,7 +1031,7 @@ def test_to_sql(self): searched = CaseExpr().when(BinaryExpr(Ident("id"), ">", 0), "yes").else_("no") assert ( searched.to_sql(PostgreSql()) - == "CASE WHEN \"id\" > 0 THEN E'yes' ELSE E'no' END" + == "CASE WHEN \"id\" > 0 THEN 'yes' ELSE 'no' END" ) simple = ( diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 12d521c..2c66ef1 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -1,13 +1,4 @@ -"""Serializer type checks, canonicalization, and dialect-method wiring. - -Quoting, byte wrappers, and boolean spelling come from the dialect -(`write_string_literal`, `format_bytes`, `format_boolean`, `format_bits`). -Escape-family tests live in test_dialect.py. - -Type errors and canonical forms are dialect-independent, so those cases -use a single dialect. Support flags (`bit_format`, `supports_array`, …) -are checked on every builtin. -""" +"""Serializer type checks, canonicalization, and literal spelling.""" import datetime import enum @@ -40,8 +31,11 @@ from rapidquery.dialect import ( BigQuery, DuckDb, + MsSql, MySql, + Oracle, PostgreSql, + Snowflake, SqlDialect, Sqlite, ) @@ -49,10 +43,20 @@ from .conftest import ALL_DIALECT_NAMES, ALL_DIALECTS, USING_ORJSON PG = PostgreSql() - - -class ValidStrEnum(enum.StrEnum): - HelloWorld = "Hello World" +BOOL_INT_NAMES = {"MsSql", "MySql", "Oracle", "Sqlite"} +BIT_SQL = { + "PostgreSql": "B'10'", + "DuckDb": "B'10'", + "MySql": "b'10'", + "Teradata": "'10'B", +} +ARRAY_SQL = { + "PostgreSql": "ARRAY[1, 2, 3]", + "BigQuery": "ARRAY[1, 2, 3]", + "DuckDb": "[1, 2, 3]", + "Spark": "array(1, 2, 3)", + "Hive": "array(1, 2, 3)", +} class InvalidStrEnum(enum.Enum): @@ -65,9 +69,8 @@ class ExampleDataclass: def _custom_ok(dialect: SqlDialect, obj: typing.Any) -> str: - # serialize() passes the dialect as SqlDialect (the base), not the subclass. assert isinstance(dialect, SqlDialect) - return f"CUSTOM:{dialect.placeholder_sigil}:{obj}" + return f"CUSTOM:{obj}" def _custom_not_str(dialect: SqlDialect, obj: typing.Any) -> int: @@ -106,10 +109,14 @@ def test_rejects_non_finite(self, value: float): @pytest.mark.parametrize("dialect", ALL_DIALECTS, ids=ALL_DIALECT_NAMES) -def test_boolean_follows_format_boolean(dialect: SqlDialect): +def test_boolean_spelling(dialect: SqlDialect): s = BooleanSerializer() - assert s.serialize(dialect, True) == dialect.format_boolean(True) - assert s.serialize(dialect, False) == dialect.format_boolean(False) + if type(dialect).__name__ in BOOL_INT_NAMES: + assert s.serialize(dialect, True) == "1" + assert s.serialize(dialect, False) == "0" + else: + assert s.serialize(dialect, True) == "TRUE" + assert s.serialize(dialect, False) == "FALSE" @pytest.mark.parametrize("value", [1, 0, object()]) @@ -119,10 +126,13 @@ def test_boolean_rejects_non_bool(value: typing.Any): @pytest.mark.parametrize("dialect", ALL_DIALECTS, ids=ALL_DIALECT_NAMES) -def test_string_follows_write_string_literal(dialect: SqlDialect): +def test_string_obrien_and_nul(dialect: SqlDialect): s = StringSerializer() - for raw in ("Hello World", "O'Brien", "šŸ˜€", "a\nb", "", ValidStrEnum.HelloWorld): - assert s.serialize(dialect, raw) == dialect.write_string_literal(raw) + sql = s.serialize(dialect, "O'Brien") + assert "O'Brien" not in sql + assert "O''Brien" in sql or "O\\'Brien" in sql + with pytest.raises(ValueError, match="NUL"): + s.serialize(dialect, "a\0b") @pytest.mark.parametrize("value", [InvalidStrEnum.Variant, 1, None, b"bytes"]) @@ -136,9 +146,11 @@ class TestUuidSerializer: def test_uuid_and_str(self): s = UuidSerializer() - expected = PG.write_string_literal(self._TEXT) + expected = f"'{self._TEXT}'::uuid" assert s.serialize(PG, self._TEXT) == expected assert s.serialize(PG, uuid.UUID(self._TEXT)) == expected + assert s.serialize(DuckDb(), self._TEXT) == f"UUID '{self._TEXT}'" + assert s.serialize(Sqlite(), self._TEXT) == f"'{self._TEXT}'" def test_invalid_str(self): with pytest.raises(ValueError): @@ -149,15 +161,14 @@ def test_rejects_non_uuid(self): UuidSerializer().serialize(PG, 6428739) -@pytest.mark.parametrize("dialect", ALL_DIALECTS, ids=ALL_DIALECT_NAMES) -@pytest.mark.parametrize("data", [b"", b"simple", b"\x00", b"\xff", b"\x00\x01\xff"]) -def test_bytes_follows_format_bytes(dialect: SqlDialect, data: bytes): - assert BytesSerializer().serialize(dialect, data) == dialect.format_bytes(data) - - -def test_bytes_all_256_values(): - data = bytes(range(256)) - assert BytesSerializer().serialize(PG, data) == PG.format_bytes(data) +def test_bytes_pg_snowflake_duckdb(): + dead = b"\xde\xad" + assert BytesSerializer().serialize(PG, dead) == "'\\xDEAD'::bytea" + assert BytesSerializer().serialize(PG, b"") == "'\\x'::bytea" + assert BytesSerializer().serialize(Snowflake(), dead) == "TO_BINARY('DEAD', 'HEX')" + assert BytesSerializer().serialize(Snowflake(), b"") == "TO_BINARY('', 'HEX')" + assert BytesSerializer().serialize(DuckDb(), dead) == "'\\xDE\\xAD'::BLOB" + assert BytesSerializer().serialize(DuckDb(), b"") == "''::BLOB" @pytest.mark.parametrize( @@ -169,16 +180,15 @@ def test_bytes_rejects_non_exact_bytes(value: typing.Any): class TestBitSerializer: - def test_follows_format_bits_when_supported(self, dialect_instance: SqlDialect): - if dialect_instance.bit_format is None: + def test_follows_bit_format_when_supported(self, dialect_instance: SqlDialect): + name = type(dialect_instance).__name__ + if name not in BIT_SQL: with pytest.raises( NotImplementedError, match="does not support BitSerializer" ): BitSerializer().serialize(dialect_instance, "10") return - assert BitSerializer().serialize( - dialect_instance, "10" - ) == dialect_instance.format_bits("10") + assert BitSerializer().serialize(dialect_instance, "10") == BIT_SQL[name] def test_rejects_non_bit_string(self): with pytest.raises(ValueError): @@ -190,33 +200,30 @@ def test_rejects_non_bit_string(self): class TestJSONSerializer: - def test_dumps_then_write_string_literal(self): + def test_dumps_then_json_format(self): s = JSONSerializer() - cases = [ - (10, "10"), - ("10", '"10"'), - ([1, "Ali", 1.0, {}], '[1,"Ali",1.0,{}]'), - ] - for obj, inner in cases: - assert s.serialize(PG, obj) == PG.write_string_literal(inner) + assert s.serialize(PG, 10) == "'10'::jsonb" + assert s.serialize(PG, "10") == '\'"10"\'::jsonb' + assert s.serialize(BigQuery(), {"a": 1}) == "JSON '{\"a\":1}'" + assert s.serialize(Snowflake(), {"a": 1}) == "PARSE_JSON('{\"a\":1}')" + assert s.serialize(Sqlite(), {"a": 1}) == "'{\"a\":1}'" def test_orjson_only_values(self): s = JSONSerializer() extras = [ ( datetime.datetime(2024, 10, 1, tzinfo=datetime.timezone.utc), - '"2024-10-01T00:00:00+00:00"', + '\'"2024-10-01T00:00:00+00:00"\'::jsonb', ), ( uuid.UUID("103596b1-db75-4ae0-907a-a4a9bc4dab85"), - '"103596b1-db75-4ae0-907a-a4a9bc4dab85"', + '\'"103596b1-db75-4ae0-907a-a4a9bc4dab85"\'::jsonb', ), - (ExampleDataclass(x=1), '{"x":1}'), - (ValidStrEnum.HelloWorld, '"Hello World"'), + (ExampleDataclass(x=1), '\'{"x":1}\'::jsonb'), ] - for obj, inner in extras: + for obj, expected in extras: if USING_ORJSON: - assert s.serialize(PG, obj) == PG.write_string_literal(inner) + assert s.serialize(PG, obj) == expected else: with pytest.raises(TypeError): s.serialize(PG, obj) @@ -225,9 +232,9 @@ def test_orjson_only_values(self): class TestDateTimeSerializers: def test_date(self): value = datetime.date(2024, 1, 15) - assert DateSerializer().serialize(PG, value) == PG.write_string_literal( - "2024-01-15" - ) + assert DateSerializer().serialize(PG, value) == "DATE '2024-01-15'" + assert DateSerializer().serialize(Oracle(), value) == "DATE '2024-01-15'" + assert DateSerializer().serialize(Sqlite(), value) == "'2024-01-15'" def test_date_rejects_datetime_and_str(self): with pytest.raises(TypeError): @@ -237,12 +244,9 @@ def test_date_rejects_datetime_and_str(self): def test_time(self): s = TimeSerializer() - assert s.serialize(PG, datetime.time(13, 45, 30)) == PG.write_string_literal( - "13:45:30" - ) - assert s.serialize(PG, datetime.time(1, 2, 3, 456789)) == ( - PG.write_string_literal("01:02:03.456789") - ) + assert s.serialize(PG, datetime.time(13, 45, 30)) == "TIME '13:45:30'" + assert s.serialize(PG, datetime.time(1, 2, 3, 456789)) == "TIME '01:02:03.456789'" + assert s.serialize(Oracle(), datetime.time(13, 45, 30)) == "'13:45:30'" def test_time_rejects_date(self): with pytest.raises(TypeError): @@ -261,22 +265,16 @@ def test_datetime_naive_and_aware(self): 0, tzinfo=datetime.timezone(datetime.timedelta(hours=-5)), ) - assert s.serialize(PG, naive) == PG.write_string_literal("2024-01-15 10:30:00") - assert s.serialize(PG, utc) == PG.write_string_literal( - "2024-01-15 10:30:00+00:00" - ) - assert s.serialize(PG, offset) == PG.write_string_literal( - "2024-01-15 10:30:00-05:00" - ) + assert s.serialize(PG, naive) == "TIMESTAMP '2024-01-15 10:30:00'" + assert s.serialize(PG, utc) == "TIMESTAMP '2024-01-15 10:30:00+00:00'" + assert s.serialize(PG, offset) == "TIMESTAMP '2024-01-15 10:30:00-05:00'" + assert s.serialize(Oracle(), naive) == "TIMESTAMP '2024-01-15 10:30:00'" + assert s.serialize(Sqlite(), naive) == "'2024-01-15 10:30:00'" def test_datetime_unix_timestamp(self): s = DatetimeSerializer() - assert s.serialize(PG, 0) == PG.write_string_literal( - "1970-01-01 00:00:00+00:00" - ) - assert s.serialize(PG, 0.5) == PG.write_string_literal( - "1970-01-01 00:00:00.500+00:00" - ) + assert s.serialize(PG, 0) == "TIMESTAMP '1970-01-01 00:00:00+00:00'" + assert s.serialize(PG, 0.5) == "TIMESTAMP '1970-01-01 00:00:00.500+00:00'" def test_datetime_rejects_date_and_str(self): with pytest.raises(TypeError): @@ -291,7 +289,8 @@ def test_datetime_overflow(self): class TestInetSerializer: def test_canonical_cidr(self, dialect_instance: SqlDialect): - if not dialect_instance.supports_inet: + name = type(dialect_instance).__name__ + if name not in {"PostgreSql", "DuckDb"}: with pytest.raises( NotImplementedError, match="does not support InetSerializer" ): @@ -306,9 +305,7 @@ def test_canonical_cidr(self, dialect_instance: SqlDialect): ("2001:db8::/32", "2001:db8::/32"), ] for obj, inner in cases: - assert s.serialize(dialect_instance, obj) == ( - dialect_instance.write_string_literal(inner) - ) + assert s.serialize(dialect_instance, obj) == f"'{inner}'::inet" def test_invalid_address(self): with pytest.raises(ValueError): @@ -317,14 +314,14 @@ def test_invalid_address(self): class TestMacAddressSerializer: def test_canonical_form(self, dialect_instance: SqlDialect): - if not dialect_instance.supports_mac_address: + if type(dialect_instance).__name__ != "PostgreSql": with pytest.raises( NotImplementedError, match="does not support MacAddressSerializer" ): MacAddressSerializer().serialize(dialect_instance, "01:23:45:67:89:ab") return s = MacAddressSerializer() - expected = dialect_instance.write_string_literal("01:23:45:67:89:AB") + expected = "'01:23:45:67:89:AB'::macaddr" assert s.serialize(dialect_instance, "01:23:45:67:89:ab") == expected assert s.serialize(dialect_instance, "01-23-45-67-89-AB") == expected @@ -344,19 +341,36 @@ def test_uses_str(self): class TestArraySerializer: def test_renders_array_constructor(self, dialect_instance: SqlDialect): s = ArraySerializer(IntegerSerializer()) - if not dialect_instance.supports_array: + name = type(dialect_instance).__name__ + expected = ARRAY_SQL.get(name) + if expected is None: with pytest.raises( NotImplementedError, match="does not support ArraySerializer" ): s.serialize(dialect_instance, [1, 2, 3]) return - assert s.serialize(dialect_instance, [1, 2, 3]) == "ARRAY[1, 2, 3]" - assert s.serialize(dialect_instance, (1, 2, 3)) == "ARRAY[1, 2, 3]" - assert s.serialize(dialect_instance, []) == "ARRAY[]" - nested = ArraySerializer(ArraySerializer(IntegerSerializer())) - assert nested.serialize(dialect_instance, [[1, 2], [3]]) == ( - "ARRAY[ARRAY[1, 2], ARRAY[3]]" + assert s.serialize(dialect_instance, [1, 2, 3]) == expected + assert s.serialize(dialect_instance, (1, 2, 3)) == expected + empty = ( + expected.replace("1, 2, 3", "") + if "array(" in expected + else expected.replace("1, 2, 3", "") ) + assert s.serialize(dialect_instance, []) == empty + nested = ArraySerializer(ArraySerializer(IntegerSerializer())) + inner = expected + if name in {"PostgreSql", "BigQuery"}: + assert nested.serialize(dialect_instance, [[1, 2], [3]]) == ( + "ARRAY[ARRAY[1, 2], ARRAY[3]]" + ) + elif name == "DuckDb": + assert nested.serialize(dialect_instance, [[1, 2], [3]]) == "[[1, 2], [3]]" + elif name in {"Spark", "Hive"}: + assert nested.serialize(dialect_instance, [[1, 2], [3]]) == ( + "array(array(1, 2), array(3))" + ) + else: + raise AssertionError(inner) def test_rejects_non_sequence_and_bad_element(self): s = ArraySerializer(IntegerSerializer()) @@ -366,7 +380,6 @@ def test_rejects_non_sequence_and_bad_element(self): s.serialize(PG, [1, "x"]) def test_nested_unsupported_element(self): - # BigQuery supports ARRAY but not bit literals; the element check fires. with pytest.raises(NotImplementedError, match="does not support BitSerializer"): ArraySerializer(BitSerializer()).serialize(BigQuery(), ["10"]) @@ -374,7 +387,7 @@ def test_nested_unsupported_element(self): class TestCustomSerializer: def test_receives_dialect_instance(self): dialect = DuckDb() - assert CustomSerializer(_custom_ok).serialize(dialect, 1) == "CUSTOM:$:1" + assert CustomSerializer(_custom_ok).serialize(dialect, 1) == "CUSTOM:1" def test_must_return_str(self): with pytest.raises(TypeError): @@ -392,3 +405,15 @@ def test_unsupported_serializer_error_message(): NotImplementedError, match="does not support MacAddressSerializer" ): MacAddressSerializer().serialize(DuckDb(), "01:23:45:67:89:ab") + + +class TestLiteralGoldens: + def test_pg_plain_string_skips_e_prefix(self): + assert StringSerializer().serialize(PG, "hello") == "'hello'" + assert StringSerializer().serialize(PG, "a\nb") == "E'a\\nb'" + + def test_mssql_nvarchar_prefix(self): + assert StringSerializer().serialize(MsSql(), "hi") == "N'hi'" + + def test_pg_e_needed_for_backslash(self): + assert StringSerializer().serialize(PG, "a\\b") == "E'a\\\\b'" From 94473dbe19e00b38f45d09ce2d173697b021dac0 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Tue, 29 Sep 2026 11:57:28 +0330 Subject: [PATCH 54/87] Clean code, remove some dead codes --- src/data_type/serializer/serialize.rs | 141 +++++++++----------- src/data_type/serializer/serializer_enum.rs | 17 --- src/dialect/builtin.rs | 111 ++++++++------- src/dialect/inner.rs | 27 ++-- src/expr/enum_/like.rs | 20 ++- src/expr/enum_/nodes.rs | 102 +++++--------- src/internal/utils/mod.rs | 3 + src/typeref.rs | 36 ----- 8 files changed, 193 insertions(+), 264 deletions(-) diff --git a/src/data_type/serializer/serialize.rs b/src/data_type/serializer/serialize.rs index 580cf8d..2bf0cf7 100644 --- a/src/data_type/serializer/serialize.rs +++ b/src/data_type/serializer/serialize.rs @@ -97,75 +97,6 @@ fn write_temporal( write_quoted(dialect, out, raw) } -#[inline] -fn write_uuid_text(dialect: &Dialect, out: &mut String, raw: &str) -> pyo3::PyResult<()> { - match dialect.uuid_format { - UuidFormat::Quoted => write_quoted(dialect, out, raw), - UuidFormat::UuidKeyword => { - out.push_str("UUID "); - write_quoted(dialect, out, raw) - } - UuidFormat::CastUuid => { - write_quoted(dialect, out, raw)?; - out.push_str("::uuid"); - Ok(()) - } - } -} - -#[inline] -fn write_json_text(dialect: &Dialect, out: &mut String, raw: &str) -> pyo3::PyResult<()> { - match dialect.json_format { - JsonFormat::Quoted => write_quoted(dialect, out, raw), - JsonFormat::JsonKeyword => { - out.push_str("JSON "); - write_quoted(dialect, out, raw) - } - JsonFormat::ParseJson => { - out.push_str("PARSE_JSON("); - write_quoted(dialect, out, raw)?; - out.push(')'); - Ok(()) - } - JsonFormat::CastJson => { - write_quoted(dialect, out, raw)?; - out.push_str("::json"); - Ok(()) - } - JsonFormat::CastJsonb => { - write_quoted(dialect, out, raw)?; - out.push_str("::jsonb"); - Ok(()) - } - } -} - -#[inline] -fn write_inet_text(dialect: &Dialect, out: &mut String, raw: &str) -> pyo3::PyResult<()> { - match dialect.inet_format { - InetFormat::None => Err(FormatError::Unsupported("InetSerializer").into()), - InetFormat::Quoted => write_quoted(dialect, out, raw), - InetFormat::Cast => { - write_quoted(dialect, out, raw)?; - out.push_str("::inet"); - Ok(()) - } - } -} - -#[inline] -fn write_mac_text(dialect: &Dialect, out: &mut String, raw: &str) -> pyo3::PyResult<()> { - match dialect.mac_format { - MacFormat::None => Err(FormatError::Unsupported("MacAddressSerializer").into()), - MacFormat::Quoted => write_quoted(dialect, out, raw), - MacFormat::Cast => { - write_quoted(dialect, out, raw)?; - out.push_str("::macaddr"); - Ok(()) - } - } -} - /// Serializes any Python `int` as a bare decimal literal (`str(int)`), /// including values beyond i64/u64. `bool` is rejected (`PyLong_CheckExact`). /// @@ -275,6 +206,22 @@ unsafe fn write_str( ) } +#[inline] +fn write_uuid_text(dialect: &Dialect, out: &mut String, raw: &str) -> pyo3::PyResult<()> { + match dialect.uuid_format { + UuidFormat::Quoted => write_quoted(dialect, out, raw), + UuidFormat::UuidKeyword => { + out.push_str("UUID "); + write_quoted(dialect, out, raw) + } + UuidFormat::CastUuid => { + write_quoted(dialect, out, raw)?; + out.push_str("::uuid"); + Ok(()) + } + } +} + /// # Safety /// `ptr` must be *borrowed*, non-null, and a valid Python object. #[inline] @@ -352,6 +299,33 @@ unsafe fn write_bit( } } +#[inline] +fn write_json_text(dialect: &Dialect, out: &mut String, raw: &str) -> pyo3::PyResult<()> { + match dialect.json_format { + JsonFormat::Quoted => write_quoted(dialect, out, raw), + JsonFormat::JsonKeyword => { + out.push_str("JSON "); + write_quoted(dialect, out, raw) + } + JsonFormat::ParseJson => { + out.push_str("PARSE_JSON("); + write_quoted(dialect, out, raw)?; + out.push(')'); + Ok(()) + } + JsonFormat::CastJson => { + write_quoted(dialect, out, raw)?; + out.push_str("::json"); + Ok(()) + } + JsonFormat::CastJsonb => { + write_quoted(dialect, out, raw)?; + out.push_str("::jsonb"); + Ok(()) + } + } +} + /// JSON-encodable object → dumps text → dialect string literal. /// /// # Safety @@ -503,7 +477,16 @@ unsafe fn write_inet( let net = ipnetwork::IpNetwork::from_str(&text) .map(|net| net.to_string()) .map_err(|e| rapidquery_macros::pyerr!(PyValueError, e.to_string()))?; - write_inet_text(dialect, out, &net) + + match dialect.inet_format { + None => Err(FormatError::Unsupported("InetSerializer").into()), + Some(InetFormat::Quoted) => write_quoted(dialect, out, &net), + Some(InetFormat::Cast) => { + write_quoted(dialect, out, &net)?; + out.push_str("::inet"); + Ok(()) + } + } } /// # Safety @@ -520,7 +503,15 @@ unsafe fn write_mac_address( .map(|mac| mac.to_string()) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; - write_mac_text(dialect, out, &addr) + match dialect.mac_format { + None => Err(FormatError::Unsupported("MacAddressSerializer").into()), + Some(MacFormat::Quoted) => write_quoted(dialect, out, &addr), + Some(MacFormat::Cast) => { + write_quoted(dialect, out, &addr)?; + out.push_str("::macaddr"); + Ok(()) + } + } } /// # Safety @@ -553,18 +544,18 @@ unsafe fn write_array( }; let close = match dialect.0.array_format { - ArrayFormat::None => { + None => { return Err(FormatError::Unsupported("ArraySerializer").into()); } - ArrayFormat::Constructor => { + Some(ArrayFormat::Constructor) => { out.push_str("ARRAY["); ']' } - ArrayFormat::List => { + Some(ArrayFormat::List) => { out.push('['); ']' } - ArrayFormat::Function => { + Some(ArrayFormat::Function) => { out.push_str("array("); ')' } diff --git a/src/data_type/serializer/serializer_enum.rs b/src/data_type/serializer/serializer_enum.rs index 59fd796..87cc457 100644 --- a/src/data_type/serializer/serializer_enum.rs +++ b/src/data_type/serializer/serializer_enum.rs @@ -97,23 +97,6 @@ impl SerializerEnum { } } - /// Whether `dialect` can emit a native literal for this serializer. - #[inline] - pub fn is_supported_by(&self, dialect: &crate::dialect::inner::Dialect) -> bool { - match self { - Self::Bit => dialect.bit_format.is_some(), - Self::Array(_) => !matches!( - dialect.array_format, - crate::dialect::inner::ArrayFormat::None - ), - Self::Inet => !matches!(dialect.inet_format, crate::dialect::inner::InetFormat::None), - Self::MacAddress => { - !matches!(dialect.mac_format, crate::dialect::inner::MacFormat::None) - } - _ => true, - } - } - pub fn infer_bound(bound: &alias::BoundObject) -> Self { unsafe { let object_ptr = bound.as_ptr(); diff --git a/src/dialect/builtin.rs b/src/dialect/builtin.rs index 3bdcf7c..03c1159 100644 --- a/src/dialect/builtin.rs +++ b/src/dialect/builtin.rs @@ -32,15 +32,14 @@ macro_rules! impl_builtin_dialects { } impl_builtin_dialects!( - /// BigQuery SQL dialect. PyBigQuery = "BigQuery" { Dialect { positional_parameter: PositionalParameter::Simple('?'), bit_format: None, - array_format: ArrayFormat::Constructor, - inet_format: InetFormat::None, - mac_format: MacFormat::None, + array_format: Some(ArrayFormat::Constructor), + inet_format: None, + mac_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::InsideParens), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -72,9 +71,9 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Simple('?'), bit_format: None, - array_format: ArrayFormat::None, - inet_format: InetFormat::None, - mac_format: MacFormat::None, + array_format: None, + inet_format: None, + mac_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::PatternList, bool_format: BoolFormat::Keyword, @@ -106,9 +105,9 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Numbered('$'), bit_format: Some(BitFormat::PrefixBigB), - array_format: ArrayFormat::List, - inet_format: InetFormat::Cast, - mac_format: MacFormat::None, + array_format: Some(ArrayFormat::List), + inet_format: Some(InetFormat::Cast), + mac_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::InsideParens), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -127,10 +126,10 @@ impl_builtin_dialects!( supports_similar_to: true, rlike_expression_options: Some(RLikeExpressionOptions::RegexpMatches), string_concat_syntax: StringConcatSyntax::Pipes, - bit_xor_syntax: Some(BitXorSyntax::XorCall), + bit_xor_syntax: Some(BitXorSyntax::XORFunction), modulo_syntax: ModuloSyntax::Percent, is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), - tuple_syntax: Some(TupleSyntax::Row { empty_ok: true }), + tuple_syntax: Some(TupleSyntax::Row), parenthesize_in_between: false, } } @@ -140,9 +139,9 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Simple('?'), bit_format: None, - array_format: ArrayFormat::Function, - inet_format: InetFormat::None, - mac_format: MacFormat::None, + array_format: Some(ArrayFormat::Function), + inet_format: None, + mac_format: None, null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -174,9 +173,9 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Simple('?'), bit_format: None, - array_format: ArrayFormat::None, - inet_format: InetFormat::None, - mac_format: MacFormat::None, + array_format: None, + inet_format: None, + mac_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, @@ -208,9 +207,9 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Simple('?'), bit_format: Some(BitFormat::PrefixSmallB), - array_format: ArrayFormat::None, - inet_format: InetFormat::None, - mac_format: MacFormat::None, + array_format: None, + inet_format: None, + mac_format: None, null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, @@ -228,11 +227,11 @@ impl_builtin_dialects!( ilike_expression_options: ILikeExpressionOptions::UseLike, supports_similar_to: false, rlike_expression_options: Some(RLikeExpressionOptions::InfixRLike), - string_concat_syntax: StringConcatSyntax::Concat, + string_concat_syntax: StringConcatSyntax::ConcatFunction, bit_xor_syntax: Some(BitXorSyntax::Caret), modulo_syntax: ModuloSyntax::Percent, is_distinct_from_syntax: Some(IsDistinctFromSyntax::NullSafeEqual), - tuple_syntax: Some(TupleSyntax::Row { empty_ok: false }), + tuple_syntax: Some(TupleSyntax::RowRejectEmpty), parenthesize_in_between: false, } } @@ -242,9 +241,9 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Numbered(':'), bit_format: None, - array_format: ArrayFormat::None, - inet_format: InetFormat::None, - mac_format: MacFormat::None, + array_format: None, + inet_format: None, + mac_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, @@ -264,7 +263,7 @@ impl_builtin_dialects!( rlike_expression_options: Some(RLikeExpressionOptions::RegexpLike), string_concat_syntax: StringConcatSyntax::Pipes, bit_xor_syntax: None, - modulo_syntax: ModuloSyntax::Call, + modulo_syntax: ModuloSyntax::Function, is_distinct_from_syntax: None, tuple_syntax: None, parenthesize_in_between: false, @@ -276,9 +275,9 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Numbered('$'), bit_format: Some(BitFormat::PrefixBigB), - array_format: ArrayFormat::Constructor, - inet_format: InetFormat::Cast, - mac_format: MacFormat::Cast, + array_format: Some(ArrayFormat::Constructor), + inet_format: Some(InetFormat::Cast), + mac_format: Some(MacFormat::Cast), null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::ArrayConstructor, bool_format: BoolFormat::Keyword, @@ -300,7 +299,7 @@ impl_builtin_dialects!( bit_xor_syntax: Some(BitXorSyntax::Hash), modulo_syntax: ModuloSyntax::Percent, is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), - tuple_syntax: Some(TupleSyntax::Row { empty_ok: true }), + tuple_syntax: Some(TupleSyntax::Row), parenthesize_in_between: false, } } @@ -310,9 +309,9 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Numbered('$'), bit_format: None, - array_format: ArrayFormat::None, - inet_format: InetFormat::None, - mac_format: MacFormat::None, + array_format: None, + inet_format: None, + mac_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -344,9 +343,9 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Numbered(':'), bit_format: None, - array_format: ArrayFormat::None, - inet_format: InetFormat::None, - mac_format: MacFormat::None, + array_format: None, + inet_format: None, + mac_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::PatternList, bool_format: BoolFormat::Keyword, @@ -365,7 +364,7 @@ impl_builtin_dialects!( supports_similar_to: false, rlike_expression_options: Some(RLikeExpressionOptions::InfixRLike), string_concat_syntax: StringConcatSyntax::Pipes, - bit_xor_syntax: Some(BitXorSyntax::BitXorCall), + bit_xor_syntax: Some(BitXorSyntax::BitXORFunction), modulo_syntax: ModuloSyntax::Percent, is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), tuple_syntax: None, @@ -378,9 +377,9 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Simple('?'), bit_format: None, - array_format: ArrayFormat::Function, - inet_format: InetFormat::None, - mac_format: MacFormat::None, + array_format: Some(ArrayFormat::Function), + inet_format: None, + mac_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::PatternList, bool_format: BoolFormat::Keyword, @@ -412,9 +411,9 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Numbered('?'), bit_format: None, - array_format: ArrayFormat::None, - inet_format: InetFormat::None, - mac_format: MacFormat::None, + array_format: None, + inet_format: None, + mac_format: None, null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, @@ -446,9 +445,9 @@ impl_builtin_dialects!( Dialect { positional_parameter: PositionalParameter::Simple('?'), bit_format: Some(BitFormat::PostfixBigB), - array_format: ArrayFormat::None, - inet_format: InetFormat::None, - mac_format: MacFormat::None, + array_format: None, + inet_format: None, + mac_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::InsideParens), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -467,7 +466,7 @@ impl_builtin_dialects!( supports_similar_to: false, rlike_expression_options: None, string_concat_syntax: StringConcatSyntax::Pipes, - bit_xor_syntax: Some(BitXorSyntax::BitXorCall), + bit_xor_syntax: Some(BitXorSyntax::BitXORFunction), modulo_syntax: ModuloSyntax::Infix, is_distinct_from_syntax: None, tuple_syntax: None, @@ -527,10 +526,10 @@ mod tests { expect( PyDuckDb::CONFIG, Pipes, - Some(XorCall), + Some(XORFunction), Percent, Some(Standard), - Some(Row { empty_ok: true }), + Some(Row), false, ); expect( @@ -553,28 +552,28 @@ mod tests { ); expect( PyMySql::CONFIG, - Concat, + ConcatFunction, Some(Caret), Percent, Some(NullSafeEqual), - Some(Row { empty_ok: false }), + Some(RowRejectEmpty), false, ); - expect(PyOracle::CONFIG, Pipes, None, Call, None, None, false); + expect(PyOracle::CONFIG, Pipes, None, Function, None, None, false); expect( PyPostgreSql::CONFIG, Pipes, Some(Hash), Percent, Some(Standard), - Some(Row { empty_ok: true }), + Some(Row), false, ); expect(PyRedshift::CONFIG, Pipes, None, Percent, None, None, false); expect( PySnowflake::CONFIG, Pipes, - Some(BitXorCall), + Some(BitXORFunction), Percent, Some(Standard), None, @@ -601,7 +600,7 @@ mod tests { expect( PyTeradata::CONFIG, Pipes, - Some(BitXorCall), + Some(BitXORFunction), Infix, None, None, diff --git a/src/dialect/inner.rs b/src/dialect/inner.rs index 55771af..89a0160 100644 --- a/src/dialect/inner.rs +++ b/src/dialect/inner.rs @@ -128,7 +128,7 @@ pub enum StringConcatSyntax { /// `left + right`. SQL Server `||` is 2025+; classic concat is `+`. Plus, /// `CONCAT(left, right)`. MySQL `||` is logical OR unless `PIPES_AS_CONCAT`. - Concat, + ConcatFunction, } /// How [`crate::expr::enum_::BinaryOp::BitXor`] (`^`) is spelled. @@ -141,9 +141,9 @@ pub enum BitXorSyntax { /// `left # right`. PostgreSQL `^` is exponent. Hash, /// `xor(left, right)`. - XorCall, + XORFunction, /// `BITXOR(left, right)`. - BitXorCall, + BitXORFunction, } /// How [`crate::expr::enum_::BinaryOp::Mod`] (`%`) is spelled. @@ -152,7 +152,7 @@ pub enum ModuloSyntax { /// `left % right`. Percent, /// `MOD(left, right)`. - Call, + Function, /// `left MOD right`. Teradata's arithmetic operator; `MOD()` is an ODBC extension. Infix, } @@ -175,7 +175,9 @@ pub enum IsDistinctFromSyntax { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum TupleSyntax { /// `ROW(...)`. `empty_ok` allows arity 0 (`ROW()`). - Row { empty_ok: bool }, + Row, + /// `ROW(...)`. Rejects empty (`ROW()`). + RowRejectEmpty, /// `struct(...)`. Arity 0 is unsupported. Struct, /// `(a, b, ...)`. Arity 0 and 1 are unsupported. @@ -358,7 +360,6 @@ pub enum FunctionFilterSyntax { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ArrayFormat { - None, /// `ARRAY[…]` Constructor, /// `[…]` @@ -369,7 +370,6 @@ pub enum ArrayFormat { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum InetFormat { - None, Quoted, /// `'…'::inet` Cast, @@ -377,7 +377,6 @@ pub enum InetFormat { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum MacFormat { - None, Quoted, /// `'…'::macaddr` Cast, @@ -420,9 +419,9 @@ pub struct Dialect { /// /// Set [`Option::None`] if literal bit isn't supported. pub bit_format: Option, - pub array_format: ArrayFormat, - pub inet_format: InetFormat, - pub mac_format: MacFormat, + pub array_format: Option, + pub inet_format: Option, + pub mac_format: Option, /// How the dialect supports `{IGNORE|RESPECT} NULLS` expressions in `Function`s if supported. pub null_treatment_syntax: Option, /// How `LIKE ANY` / `ILIKE ANY` is rendered. @@ -745,9 +744,9 @@ mod tests { Dialect { positional_parameter: PositionalParameter::Simple(':'), bit_format: None, - array_format: ArrayFormat::None, - inet_format: InetFormat::None, - mac_format: MacFormat::None, + array_format: None, + inet_format: None, + mac_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, diff --git a/src/expr/enum_/like.rs b/src/expr/enum_/like.rs index 1a7106a..cf6c30a 100644 --- a/src/expr/enum_/like.rs +++ b/src/expr/enum_/like.rs @@ -1,6 +1,6 @@ //! `LIKE` / `ILIKE` / `LIKE ANY` / `SIMILAR TO` / `RLIKE` AST nodes. -use super::{ExprInner, nodes::write_escape}; +use super::ExprInner; use crate::dialect::{ PySqlDialect, inner::{LikeAnySyntax, RLikeExpressionOptions}, @@ -8,6 +8,24 @@ use crate::dialect::{ use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; use std::fmt::Write; +pub(super) fn write_escape( + dialect: &PySqlDialect, + out: &mut W, + escape: Option, +) -> PrepareResult<()> { + let Some(c) = escape else { + return Ok(()); + }; + + out.push_str(" ESCAPE "); + let mut buf = [0u8; 4]; + dialect + .0 + .write_string_literal(out, c.encode_utf8(&mut buf))?; + + Ok(()) +} + /// Pattern side of [`Like`]: one expression, or a non-empty ANY list. #[derive(Clone, Debug, PartialEq)] pub enum LikePattern { diff --git a/src/expr/enum_/nodes.rs b/src/expr/enum_/nodes.rs index 5ac181e..183226a 100644 --- a/src/expr/enum_/nodes.rs +++ b/src/expr/enum_/nodes.rs @@ -6,6 +6,7 @@ use crate::dialect::inner::{ use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; use std::fmt::Write; +#[inline] fn unsupported(message: &'static str) -> crate::sql::PrepareError { rapidquery_macros::pyerr!(PyRuntimeError, message).into() } @@ -20,24 +21,6 @@ fn join_reprs(py: pyo3::Python<'_>, buf: &mut String, items: &[ExprInner]) -> py Ok(()) } -pub(super) fn write_escape( - dialect: &PySqlDialect, - out: &mut W, - escape: Option, -) -> PrepareResult<()> { - let Some(c) = escape else { - return Ok(()); - }; - - out.push_str(" ESCAPE "); - let mut buf = [0u8; 4]; - dialect - .0 - .write_string_literal(out, c.encode_utf8(&mut buf))?; - - Ok(()) -} - /// Row value. SQL spelling depends on [`TupleSyntax`]. #[derive(Clone, Debug, PartialEq)] pub struct Tuple { @@ -65,8 +48,11 @@ impl WriteSql for Tuple { let arity = self.items.len(); match syntax { - TupleSyntax::Row { empty_ok } => { - if arity == 0 && !empty_ok { + TupleSyntax::Row => { + out.push_str("ROW("); + } + TupleSyntax::RowRejectEmpty => { + if arity == 0 { return Err(unsupported("empty tuple is not supported by this dialect")); } out.push_str("ROW("); @@ -158,7 +144,8 @@ impl Binary { } } -fn is_arithmetic(op: &BinaryOp) -> bool { +#[inline] +fn is_arithmetic_binary_op(op: &BinaryOp) -> bool { matches!( op, BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod @@ -166,6 +153,7 @@ fn is_arithmetic(op: &BinaryOp) -> bool { } /// `name(left, right)` with an unquoted function name. +#[inline] fn write_binary_call( dialect: &PySqlDialect, out: &mut W, @@ -188,6 +176,7 @@ fn write_binary_call( /// `+` used as concat) needs that because dialect precedence disagrees with /// the tree: SQLite binds `||` tighter than arithmetic, and MsSql `+` is the /// same token as addition. +#[inline] fn write_infix( bin: &Binary, dialect: &PySqlDialect, @@ -203,6 +192,7 @@ fn write_infix( write_infix_side(dialect, out, &bin.right, prec, true, paren_arithmetic) } +#[inline] fn write_infix_side( dialect: &PySqlDialect, out: &mut W, @@ -211,7 +201,7 @@ fn write_infix_side( paren_if_equal: bool, paren_arithmetic: bool, ) -> PrepareResult<()> { - if paren_arithmetic && matches!(expr, ExprInner::Binary(b) if is_arithmetic(&b.op)) { + if paren_arithmetic && matches!(expr, ExprInner::Binary(b) if is_arithmetic_binary_op(&b.op)) { out.push_char('('); expr.write_sql(dialect, out)?; out.push_char(')'); @@ -220,52 +210,34 @@ fn write_infix_side( expr.write_operand(dialect, out, prec, paren_if_equal) } -fn write_concat( - bin: &Binary, - dialect: &PySqlDialect, - out: &mut W, -) -> PrepareResult<()> { - match dialect.0.string_concat_syntax { - StringConcatSyntax::Concat => { - write_binary_call(dialect, out, "CONCAT", &bin.left, &bin.right) - } - StringConcatSyntax::Pipes => write_infix(bin, dialect, out, "||", true), - StringConcatSyntax::Plus => write_infix(bin, dialect, out, "+", true), - } -} - -fn write_bitxor( - bin: &Binary, - dialect: &PySqlDialect, - out: &mut W, -) -> PrepareResult<()> { - match dialect.0.bit_xor_syntax { - None => Err(unsupported("bitwise XOR is not supported by this dialect")), - Some(BitXorSyntax::Caret) => write_infix(bin, dialect, out, "^", false), - Some(BitXorSyntax::Hash) => write_infix(bin, dialect, out, "#", false), - Some(BitXorSyntax::XorCall) => { - write_binary_call(dialect, out, "xor", &bin.left, &bin.right) - } - Some(BitXorSyntax::BitXorCall) => { - write_binary_call(dialect, out, "BITXOR", &bin.left, &bin.right) - } - } -} - -fn write_mod(bin: &Binary, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { - match dialect.0.modulo_syntax { - ModuloSyntax::Percent => write_infix(bin, dialect, out, "%", false), - ModuloSyntax::Call => write_binary_call(dialect, out, "MOD", &bin.left, &bin.right), - ModuloSyntax::Infix => write_infix(bin, dialect, out, "MOD", false), - } -} - impl WriteSql for Binary { fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { match &self.op { - BinaryOp::StringConcat => write_concat(self, dialect, out), - BinaryOp::BitXor => write_bitxor(self, dialect, out), - BinaryOp::Mod => write_mod(self, dialect, out), + BinaryOp::StringConcat => match dialect.0.string_concat_syntax { + StringConcatSyntax::ConcatFunction => { + write_binary_call(dialect, out, "CONCAT", &self.left, &self.right) + } + StringConcatSyntax::Pipes => write_infix(self, dialect, out, "||", true), + StringConcatSyntax::Plus => write_infix(self, dialect, out, "+", true), + }, + BinaryOp::BitXor => match dialect.0.bit_xor_syntax { + None => Err(unsupported("bitwise XOR is not supported by this dialect")), + Some(BitXorSyntax::Caret) => write_infix(self, dialect, out, "^", false), + Some(BitXorSyntax::Hash) => write_infix(self, dialect, out, "#", false), + Some(BitXorSyntax::XORFunction) => { + write_binary_call(dialect, out, "xor", &self.left, &self.right) + } + Some(BitXorSyntax::BitXORFunction) => { + write_binary_call(dialect, out, "BITXOR", &self.left, &self.right) + } + }, + BinaryOp::Mod => match dialect.0.modulo_syntax { + ModuloSyntax::Percent => write_infix(self, dialect, out, "%", false), + ModuloSyntax::Function => { + write_binary_call(dialect, out, "MOD", &self.left, &self.right) + } + ModuloSyntax::Infix => write_infix(self, dialect, out, "MOD", false), + }, _ => write_infix(self, dialect, out, self.op.as_sql(), false), } } diff --git a/src/internal/utils/mod.rs b/src/internal/utils/mod.rs index dd81f5c..f8597c3 100644 --- a/src/internal/utils/mod.rs +++ b/src/internal/utils/mod.rs @@ -62,6 +62,9 @@ pub fn new_pynone<'py>(py: pyo3::Python<'py>) -> pyo3::Bound<'py, pyo3::PyAny> { } /// Converts smallvec into PyTuple using pyo3 FFI +/// +/// # Safety +/// The items returned by the iterator must be owned, not borrowed. #[inline] pub unsafe fn iterator_to_tuple< 'py, diff --git a/src/typeref.rs b/src/typeref.rs index de16106..76afe5a 100644 --- a/src/typeref.rs +++ b/src/typeref.rs @@ -6,24 +6,6 @@ pub(crate) static mut SQL_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr: // Data-type / serializer types pub(crate) static mut DATATYPE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); pub(crate) static mut SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut INTEGER_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut FLOAT_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut BOOLEAN_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut STRING_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut UUID_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut BYTES_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut BIT_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut JSON_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut DATE_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut TIME_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut DATETIME_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut INET_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut MACADDRESS_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = - std::ptr::null_mut(); -pub(crate) static mut UNSPECIFIED_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = - std::ptr::null_mut(); -pub(crate) static mut ARRAY_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -pub(crate) static mut CUSTOM_SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); // Ident types pub(crate) static mut IDENT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); @@ -42,7 +24,6 @@ pub(crate) static mut STD_UUID_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::nu pub(crate) static mut STD_DATETIME_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); pub(crate) static mut STD_DATE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); pub(crate) static mut STD_TIME_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -// pub(crate) static mut STD_ENUM_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); unsafe fn get_type_object_for( py: pyo3::Python, @@ -93,22 +74,6 @@ fn _initialize_typeref(py: pyo3::Python) { // data types / serializers crate::data_type::PyDataType => DATATYPE_TYPE, crate::data_type::PySerializer => SERIALIZER_TYPE, - crate::data_type::PyIntegerSerializer => INTEGER_SERIALIZER_TYPE, - crate::data_type::PyFloatSerializer => FLOAT_SERIALIZER_TYPE, - crate::data_type::PyBooleanSerializer => BOOLEAN_SERIALIZER_TYPE, - crate::data_type::PyStringSerializer => STRING_SERIALIZER_TYPE, - crate::data_type::PyUuidSerializer => UUID_SERIALIZER_TYPE, - crate::data_type::PyBytesSerializer => BYTES_SERIALIZER_TYPE, - crate::data_type::PyBitSerializer => BIT_SERIALIZER_TYPE, - crate::data_type::PyJSONSerializer => JSON_SERIALIZER_TYPE, - crate::data_type::PyDateSerializer => DATE_SERIALIZER_TYPE, - crate::data_type::PyTimeSerializer => TIME_SERIALIZER_TYPE, - crate::data_type::PyDatetimeSerializer => DATETIME_SERIALIZER_TYPE, - crate::data_type::PyInetSerializer => INET_SERIALIZER_TYPE, - crate::data_type::PyMacAddressSerializer => MACADDRESS_SERIALIZER_TYPE, - crate::data_type::PyUnspecifiedSerializer => UNSPECIFIED_SERIALIZER_TYPE, - crate::data_type::PyArraySerializer => ARRAY_SERIALIZER_TYPE, - crate::data_type::PyCustomSerializer => CUSTOM_SERIALIZER_TYPE, crate::expr::base::PyExpr => EXPR_TYPE, crate::expr::ident::PyIdent => IDENT_TYPE, crate::expr::ident::PyUnquote => UNQUOTE_TYPE, @@ -118,7 +83,6 @@ fn _initialize_typeref(py: pyo3::Python) { STD_DECIMAL_TYPE = look_up_type_object(c"decimal", c"Decimal"); STD_UUID_TYPE = look_up_type_object(c"uuid", c"UUID"); - // STD_ENUM_TYPE = look_up_type_object(c"enum", c"EnumMeta"); pyo3::ffi::PyDateTime_IMPORT(); let datetime_capsule = pyo3::ffi::PyCapsule_Import(c"datetime.datetime_CAPI".as_ptr(), 1) From 28a9421223fb80e55de7c8309fbc9f1757237e50 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Tue, 29 Sep 2026 13:03:40 +0330 Subject: [PATCH 55/87] Make CAST, TRIM, and COLLATE rendering dialect-aware Encode per-dialect TRY_CAST spelling, `::` support, TRIM form, and COLLATE form on Dialect. Render TRY_CAST as SAFE_CAST (BigQuery) or TRYCAST (Teradata) at write time, and error when a dialect does not support the requested form. Simplify Cast by dropping FORMAT / AT TIME ZONE and replacing SafeCast with an opt-in DoubleColon kind. Simplify Trim to a required side plus an optional character expression instead of `what` plus a character list. --- src/dialect/builtin.rs | 122 +++++++++++++++++++++++ src/dialect/inner.rs | 56 +++++++++++ src/expr/enum_/nodes.rs | 192 ++++++++++++++++++++++++------------- src/expr/enum_/operator.rs | 11 ++- src/expr/extractors.rs | 35 ------- src/expr/nodes.rs | 93 ++++-------------- src/lib.rs | 1 + 7 files changed, 329 insertions(+), 181 deletions(-) diff --git a/src/dialect/builtin.rs b/src/dialect/builtin.rs index 03c1159..2471c52 100644 --- a/src/dialect/builtin.rs +++ b/src/dialect/builtin.rs @@ -63,6 +63,10 @@ impl_builtin_dialects!( is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), tuple_syntax: None, parenthesize_in_between: false, + try_cast_syntax: Some(TryCastSyntax::SafeCast), + supports_double_colon: false, + trim_syntax: TrimSyntax::Function, + collate_syntax: Some(CollateSyntax::FunctionString), } } @@ -97,6 +101,10 @@ impl_builtin_dialects!( is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), tuple_syntax: Some(TupleSyntax::Struct), parenthesize_in_between: false, + try_cast_syntax: Some(TryCastSyntax::TryCast), + supports_double_colon: true, + trim_syntax: TrimSyntax::SqlFrom, + collate_syntax: Some(CollateSyntax::InfixIdent), } } @@ -131,6 +139,10 @@ impl_builtin_dialects!( is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), tuple_syntax: Some(TupleSyntax::Row), parenthesize_in_between: false, + try_cast_syntax: Some(TryCastSyntax::TryCast), + supports_double_colon: true, + trim_syntax: TrimSyntax::Function, + collate_syntax: Some(CollateSyntax::InfixIdent), } } @@ -165,6 +177,10 @@ impl_builtin_dialects!( is_distinct_from_syntax: Some(IsDistinctFromSyntax::NullSafeEqual), tuple_syntax: Some(TupleSyntax::Struct), parenthesize_in_between: false, + try_cast_syntax: None, + supports_double_colon: false, + trim_syntax: TrimSyntax::SpacesOnly, + collate_syntax: None, } } @@ -199,6 +215,10 @@ impl_builtin_dialects!( is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), tuple_syntax: None, parenthesize_in_between: true, + try_cast_syntax: Some(TryCastSyntax::TryCast), + supports_double_colon: false, + trim_syntax: TrimSyntax::SqlFrom, + collate_syntax: Some(CollateSyntax::InfixIdent), } } @@ -233,6 +253,10 @@ impl_builtin_dialects!( is_distinct_from_syntax: Some(IsDistinctFromSyntax::NullSafeEqual), tuple_syntax: Some(TupleSyntax::RowRejectEmpty), parenthesize_in_between: false, + try_cast_syntax: None, + supports_double_colon: false, + trim_syntax: TrimSyntax::SqlFrom, + collate_syntax: Some(CollateSyntax::InfixIdent), } } @@ -267,6 +291,10 @@ impl_builtin_dialects!( is_distinct_from_syntax: None, tuple_syntax: None, parenthesize_in_between: false, + try_cast_syntax: None, + supports_double_colon: false, + trim_syntax: TrimSyntax::SqlFrom, + collate_syntax: Some(CollateSyntax::InfixIdent), } } @@ -301,6 +329,10 @@ impl_builtin_dialects!( is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), tuple_syntax: Some(TupleSyntax::Row), parenthesize_in_between: false, + try_cast_syntax: None, + supports_double_colon: true, + trim_syntax: TrimSyntax::SqlFrom, + collate_syntax: Some(CollateSyntax::InfixIdent), } } @@ -335,6 +367,10 @@ impl_builtin_dialects!( is_distinct_from_syntax: None, tuple_syntax: None, parenthesize_in_between: false, + try_cast_syntax: Some(TryCastSyntax::TryCast), + supports_double_colon: true, + trim_syntax: TrimSyntax::SqlFrom, + collate_syntax: Some(CollateSyntax::FunctionString), } } @@ -369,6 +405,10 @@ impl_builtin_dialects!( is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), tuple_syntax: None, parenthesize_in_between: false, + try_cast_syntax: Some(TryCastSyntax::TryCast), + supports_double_colon: true, + trim_syntax: TrimSyntax::Function, + collate_syntax: Some(CollateSyntax::FunctionString), } } @@ -403,6 +443,10 @@ impl_builtin_dialects!( is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), tuple_syntax: Some(TupleSyntax::Struct), parenthesize_in_between: false, + try_cast_syntax: Some(TryCastSyntax::TryCast), + supports_double_colon: true, + trim_syntax: TrimSyntax::SqlFrom, + collate_syntax: None, } } @@ -437,6 +481,10 @@ impl_builtin_dialects!( is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), tuple_syntax: Some(TupleSyntax::Parentheses), parenthesize_in_between: false, + try_cast_syntax: None, + supports_double_colon: false, + trim_syntax: TrimSyntax::Function, + collate_syntax: Some(CollateSyntax::InfixIdent), } } @@ -471,6 +519,10 @@ impl_builtin_dialects!( is_distinct_from_syntax: None, tuple_syntax: None, parenthesize_in_between: false, + try_cast_syntax: Some(TryCastSyntax::TryCastWord), + supports_double_colon: false, + trim_syntax: TrimSyntax::SqlFrom, + collate_syntax: None, } } @@ -607,4 +659,74 @@ mod tests { false, ); } + + #[test] + fn cast_trim_collate_matrix() { + use CollateSyntax::*; + use TrimSyntax::*; + use TryCastSyntax::*; + + fn expect( + dialect: Dialect, + try_cast: Option, + double_colon: bool, + trim: TrimSyntax, + collate: Option, + ) { + assert_eq!(dialect.try_cast_syntax, try_cast); + assert_eq!(dialect.supports_double_colon, double_colon); + assert_eq!(dialect.trim_syntax, trim); + assert_eq!(dialect.collate_syntax, collate); + } + + expect( + PyBigQuery::CONFIG, + Some(SafeCast), + false, + Function, + Some(FunctionString), + ); + expect( + PyDatabricks::CONFIG, + Some(TryCast), + true, + SqlFrom, + Some(InfixIdent), + ); + expect( + PyDuckDb::CONFIG, + Some(TryCast), + true, + Function, + Some(InfixIdent), + ); + expect(PyHive::CONFIG, None, false, SpacesOnly, None); + expect( + PyMsSql::CONFIG, + Some(TryCast), + false, + SqlFrom, + Some(InfixIdent), + ); + expect(PyMySql::CONFIG, None, false, SqlFrom, Some(InfixIdent)); + expect(PyOracle::CONFIG, None, false, SqlFrom, Some(InfixIdent)); + expect(PyPostgreSql::CONFIG, None, true, SqlFrom, Some(InfixIdent)); + expect( + PyRedshift::CONFIG, + Some(TryCast), + true, + SqlFrom, + Some(FunctionString), + ); + expect( + PySnowflake::CONFIG, + Some(TryCast), + true, + Function, + Some(FunctionString), + ); + expect(PySpark::CONFIG, Some(TryCast), true, SqlFrom, None); + expect(PySqlite::CONFIG, None, false, Function, Some(InfixIdent)); + expect(PyTeradata::CONFIG, Some(TryCastWord), false, SqlFrom, None); + } } diff --git a/src/dialect/inner.rs b/src/dialect/inner.rs index 89a0160..675dfe1 100644 --- a/src/dialect/inner.rs +++ b/src/dialect/inner.rs @@ -169,6 +169,50 @@ pub enum IsDistinctFromSyntax { NullSafeEqual, } +/// How `TRY_CAST` is spelled. [`None`] on [`Dialect::try_cast_syntax`] means unsupported. +/// +/// Callers ask for `TRY_CAST`. BigQuery emits `SAFE_CAST`; Teradata emits `TRYCAST`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TryCastSyntax { + /// `TRY_CAST(expr AS type)`. + TryCast, + /// `TRYCAST(expr AS type)`. + TryCastWord, + /// `SAFE_CAST(expr AS type)`. BigQuery's spelling of a null-on-failure cast. + SafeCast, +} + +impl TryCastSyntax { + #[inline] + pub fn as_sql(self) -> &'static str { + match self { + Self::TryCast => "TRY_CAST", + Self::TryCastWord => "TRYCAST", + Self::SafeCast => "SAFE_CAST", + } + } +} + +/// How `TRIM` is spelled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TrimSyntax { + /// `TRIM([LEADING|TRAILING] [chars FROM] expr)`. + SqlFrom, + /// `TRIM` / `LTRIM` / `RTRIM` `(expr[, chars])`. + Function, + /// `TRIM` / `LTRIM` / `RTRIM` `(expr)`. A character set raises. + SpacesOnly, +} + +/// How `COLLATE` is spelled. [`None`] on [`Dialect::collate_syntax`] means unsupported. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CollateSyntax { + /// `expr COLLATE ident`. + InfixIdent, + /// `COLLATE(expr, 'body')`. The identifier body is a string literal. + FunctionString, +} + /// How a tuple / row value is spelled. /// /// [`None`] on [`Dialect::tuple_syntax`] means unsupported. @@ -467,6 +511,14 @@ pub struct Dialect { /// a level evaluated left to right. The parentheses keep the predicate /// from being parsed as part of the left operand. pub parenthesize_in_between: bool, + /// How `TRY_CAST` is spelled. [`None`] means unsupported. + pub try_cast_syntax: Option, + /// Whether `expr::type` is documented. + pub supports_double_colon: bool, + /// How `TRIM` / `LTRIM` / `RTRIM` is spelled. + pub trim_syntax: TrimSyntax, + /// How `COLLATE` is spelled. [`None`] means unsupported. + pub collate_syntax: Option, } impl Dialect { @@ -770,6 +822,10 @@ mod tests { is_distinct_from_syntax: None, tuple_syntax: None, parenthesize_in_between: false, + try_cast_syntax: None, + supports_double_colon: false, + trim_syntax: TrimSyntax::SqlFrom, + collate_syntax: None, } } diff --git a/src/expr/enum_/nodes.rs b/src/expr/enum_/nodes.rs index 183226a..a761d62 100644 --- a/src/expr/enum_/nodes.rs +++ b/src/expr/enum_/nodes.rs @@ -1,7 +1,8 @@ use super::{BinaryOp, CastKind, ExprInner, Ident, IsKind, TrimWhere, UnaryOp, UnarySpacing}; use crate::dialect::PySqlDialect; use crate::dialect::inner::{ - BitXorSyntax, IsDistinctFromSyntax, ModuloSyntax, StringConcatSyntax, TupleSyntax, + BitXorSyntax, CollateSyntax, IsDistinctFromSyntax, ModuloSyntax, StringConcatSyntax, + TrimSyntax, TupleSyntax, }; use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; use std::fmt::Write; @@ -449,17 +450,15 @@ impl WriteSql for Between { } } -/// `KIND(expr AS type [FORMAT format [AT TIME ZONE tz]])`. +/// `CAST(expr AS type)`, `TRY_CAST(expr AS type)`, or `(expr)::type`. /// -/// [`kind`](Self::kind) is `CAST` / `TRY_CAST` / `SAFE_CAST`. `FORMAT` and -/// `AT TIME ZONE` are omitted when unset; timezone requires format. +/// [`kind`](Self::kind) stays `CAST` / `TRY_CAST` / `::`. BigQuery spells +/// `TRY_CAST` as `SAFE_CAST`; Teradata spells it as `TRYCAST`. #[derive(Clone, Debug, PartialEq)] pub struct Cast { pub kind: CastKind, pub expr: Box, pub data_type_name: crate::data_type::DataTypeName, - pub format: Option>, - pub at_time_zone: Option>, } impl Cast { @@ -474,48 +473,60 @@ impl Cast { if self.kind != CastKind::Cast { write!(buf, ", kind={:?}", self.kind.as_sql()).unwrap(); } - if let Some(format) = &self.format { - write!(buf, ", format={}", format.python_repr(py)?).unwrap(); - } - if let Some(tz) = &self.at_time_zone { - write!(buf, ", at_time_zone={}", tz.python_repr(py)?).unwrap(); - } buf.push(')'); Ok(buf) } -} -impl WriteSql for Cast { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { - out.push_str(self.kind.as_sql()); + fn write_call( + &self, + dialect: &PySqlDialect, + out: &mut W, + name: &str, + ) -> PrepareResult<()> { + out.push_str(name); out.push_char('('); self.expr.write_sql(dialect, out)?; out.push_str(" AS "); self.data_type_name.write_sql(dialect, out)?; - if let Some(format) = &self.format { - out.push_str(" FORMAT "); - format.write_sql(dialect, out)?; - if let Some(tz) = &self.at_time_zone { - out.push_str(" AT TIME ZONE "); - tz.write_sql(dialect, out)?; - } - } out.push_char(')'); Ok(()) } } -/// `TRIM([BOTH|LEADING|TRAILING] [ FROM] [, chars…])`. +impl WriteSql for Cast { + fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + match self.kind { + CastKind::Cast => self.write_call(dialect, out, "CAST"), + CastKind::TryCast => match dialect.0.try_cast_syntax { + Some(syntax) => self.write_call(dialect, out, syntax.as_sql()), + None => Err(unsupported("try cast is not supported by this dialect")), + }, + CastKind::DoubleColon => { + if !dialect.0.supports_double_colon { + return Err(unsupported( + "double-colon cast is not supported by this dialect", + )); + } + out.push_char('('); + self.expr.write_sql(dialect, out)?; + out.push_str(")::"); + self.data_type_name.write_sql(dialect, out)?; + Ok(()) + } + } + } +} + +/// `TRIM` of [`expr`](Self::expr), optionally from one side, optionally of +/// [`characters`](Self::characters). /// -/// [`what`](Self::what) and [`characters`](Self::characters) are mutually -/// exclusive. `characters` is never empty when `Some`. +/// [`where_`](Self::where_) defaults to both ends. The SQL spelling is +/// `TRIM([LEADING|TRAILING] [chars FROM] expr)` or `TRIM` / `LTRIM` / `RTRIM`. #[derive(Clone, Debug, PartialEq)] pub struct Trim { pub expr: Box, - pub where_: Option, - pub what: Option>, - /// Comma-list form `TRIM(expr, c1, c2)`. Never empty when `Some`. - pub characters: Option>, + pub where_: TrimWhere, + pub characters: Option>, } impl Trim { @@ -523,47 +534,77 @@ impl Trim { pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { let mut buf = format!("TrimExpr({}", self.expr.python_repr(py)?); - if let Some(where_) = self.where_ { - write!(buf, ", where={:?}", where_.as_sql()).unwrap(); - } - if let Some(what) = &self.what { - write!(buf, ", what={}", what.python_repr(py)?).unwrap(); + if self.where_ != TrimWhere::Both { + write!(buf, ", where={:?}", self.where_.as_sql()).unwrap(); } if let Some(characters) = &self.characters { - buf.push_str(", characters=["); - join_reprs(py, &mut buf, characters)?; - buf.push(']'); + write!(buf, ", characters={}", characters.python_repr(py)?).unwrap(); } buf.push(')'); Ok(buf) } } +#[inline] +fn write_trim_function( + trim: &Trim, + dialect: &PySqlDialect, + out: &mut W, +) -> PrepareResult<()> { + let name = match trim.where_ { + TrimWhere::Both => "TRIM", + TrimWhere::Leading => "LTRIM", + TrimWhere::Trailing => "RTRIM", + }; + out.push_str(name); + out.push_char('('); + trim.expr.write_sql(dialect, out)?; + if let Some(characters) = &trim.characters { + out.push_str(", "); + characters.write_sql(dialect, out)?; + } + out.push_char(')'); + Ok(()) +} + impl WriteSql for Trim { fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { - out.push_str("TRIM("); - if let Some(where_) = self.where_ { - out.push_str(where_.as_sql()); - out.push_char(' '); - } - if let Some(what) = &self.what { - what.write_sql(dialect, out)?; - out.push_str(" FROM "); - self.expr.write_sql(dialect, out)?; - } else if self.where_.is_some() { - out.push_str("FROM "); - self.expr.write_sql(dialect, out)?; - } else { - self.expr.write_sql(dialect, out)?; - } - if let Some(characters) = &self.characters { - for c in characters { - out.push_str(", "); - c.write_sql(dialect, out)?; + match dialect.0.trim_syntax { + TrimSyntax::SqlFrom => { + out.push_str("TRIM("); + match (&self.where_, &self.characters) { + (TrimWhere::Both, None) => self.expr.write_sql(dialect, out)?, + (TrimWhere::Both, Some(characters)) => { + characters.write_sql(dialect, out)?; + out.push_str(" FROM "); + self.expr.write_sql(dialect, out)?; + } + (side, None) => { + out.push_str(side.as_sql()); + out.push_str(" FROM "); + self.expr.write_sql(dialect, out)?; + } + (side, Some(characters)) => { + out.push_str(side.as_sql()); + out.push_char(' '); + characters.write_sql(dialect, out)?; + out.push_str(" FROM "); + self.expr.write_sql(dialect, out)?; + } + } + out.push_char(')'); + Ok(()) + } + TrimSyntax::Function => write_trim_function(self, dialect, out), + TrimSyntax::SpacesOnly => { + if self.characters.is_some() { + return Err(unsupported( + "trim characters are not supported by this dialect", + )); + } + write_trim_function(self, dialect, out) } } - out.push_char(')'); - Ok(()) } } @@ -590,10 +631,33 @@ impl Collate { impl WriteSql for Collate { fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { - self.expr - .write_operand(dialect, out, Self::PRECEDENCE, false)?; - out.push_str(" COLLATE "); - self.collation.write_sql(dialect, out) + match dialect.0.collate_syntax { + None => Err(unsupported("COLLATE is not supported by this dialect")), + Some(CollateSyntax::InfixIdent) => { + self.expr + .write_operand(dialect, out, Self::PRECEDENCE, false)?; + out.push_str(" COLLATE "); + self.collation.write_sql(dialect, out) + } + Some(CollateSyntax::FunctionString) => { + let body = match self.collation.parts() { + [super::IdentPart::Quote(body) | super::IdentPart::Unquote(body)] + if !body.is_empty() => + { + body.as_str() + } + _ => { + return Err(unsupported("collation must be a single name")); + } + }; + out.push_str("COLLATE("); + self.expr.write_sql(dialect, out)?; + out.push_str(", "); + dialect.0.write_string_literal(out, body)?; + out.push_char(')'); + Ok(()) + } + } } } diff --git a/src/expr/enum_/operator.rs b/src/expr/enum_/operator.rs index 1dfcf2f..95c5a35 100644 --- a/src/expr/enum_/operator.rs +++ b/src/expr/enum_/operator.rs @@ -194,12 +194,13 @@ impl IsKind { } } -/// Unary operator. +/// `CAST` / `TRY_CAST` / `::`. Dialect spelling of `TRY_CAST` is chosen at render time. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum CastKind { Cast, TryCast, - SafeCast, + /// `expr::type`. Opt-in; a plain `CAST` is never rewritten to this. + DoubleColon, } impl CastKind { @@ -209,8 +210,8 @@ impl CastKind { Some(Self::Cast) } else if s.eq_ignore_ascii_case("TRY_CAST") { Some(Self::TryCast) - } else if s.eq_ignore_ascii_case("SAFE_CAST") { - Some(Self::SafeCast) + } else if s == "::" { + Some(Self::DoubleColon) } else { None } @@ -221,7 +222,7 @@ impl CastKind { match self { Self::Cast => "CAST", Self::TryCast => "TRY_CAST", - Self::SafeCast => "SAFE_CAST", + Self::DoubleColon => "::", } } } diff --git a/src/expr/extractors.rs b/src/expr/extractors.rs index 0f84f41..c709d25 100644 --- a/src/expr/extractors.rs +++ b/src/expr/extractors.rs @@ -122,41 +122,6 @@ fn extract_like_any<'py>( Ok(LikePattern::Any(items)) } -/// Comma-list `TRIM(expr, c1, c2)` characters: exact `list`/`tuple`, never empty. -pub fn extract_trim_characters( - obj: pyo3::prelude::Borrowed<'_, '_, pyo3::prelude::PyAny>, -) -> pyo3::PyResult> { - let ptr = obj.as_ptr(); - unsafe { - if pyo3::ffi::PyList_CheckExact(ptr) == 1 { - let list = obj.cast_unchecked::(); - return collect_trim_characters(list.iter()); - } - if pyo3::ffi::PyTuple_CheckExact(ptr) == 1 { - let tuple = obj.cast_unchecked::(); - return collect_trim_characters(tuple.iter()); - } - } - rapidquery_macros::pyerr!( - Err(PyTypeError), - "TRIM characters must be a list or tuple, got {}", - unsafe { crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) } - ) -} - -fn collect_trim_characters<'py>( - iter: impl IntoIterator>, -) -> pyo3::PyResult> { - let mut items = Vec::new(); - for item in iter { - items.push(item.extract::()?.0); - } - if items.is_empty() { - return rapidquery_macros::pyerr!(Err(PyValueError), "TRIM character list is empty"); - } - Ok(items) -} - pub(super) enum ExtractedIdentArgument { Ident(Ident), Part(IdentPart), diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index 8bdb9c0..7967cb5 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -4,7 +4,6 @@ use super::base::PyExpr; use super::enum_::ExprInner; use super::extractors::{ ConditionExtractor, IdentExtractor, LiteralExprExtractor, extract_like_pattern, - extract_trim_characters, }; use crate::data_type::DataTypeNameExtractor; use crate::internal::{alias, utils}; @@ -77,10 +76,10 @@ empty_node! { /// Regex match (`RLIKE`, `~`, `REGEXP_LIKE`, …). PyRLikeExpr(RLike(super::enum_::RLike)) => "RLikeExpr", - /// `CAST(expr AS type [FORMAT …])`. + /// `CAST(expr AS type)`, `TRY_CAST(expr AS type)`, or `(expr)::type`. PyCastExpr(Cast(super::enum_::Cast)) => "CastExpr", - /// `TRIM([BOTH|LEADING|TRAILING] [what FROM] expr)`. + /// `TRIM` of `expr`, optionally from one side and optionally of `characters`. PyTrimExpr(Trim(super::enum_::Trim)) => "TrimExpr", /// `expr COLLATE collation`. @@ -485,27 +484,20 @@ impl PyRLikeExpr { #[pyo3::pymethods] impl PyCastExpr { #[new] - #[pyo3(signature = (expr, data_type_name, /, *, kind=String::from("CAST"), format=None, at_time_zone=None))] + #[pyo3(signature = (expr, data_type_name, /, *, kind = "CAST"))] fn __new__( expr: LiteralExprExtractor, data_type_name: DataTypeNameExtractor, - kind: String, - format: Option, - at_time_zone: Option, + kind: &str, ) -> pyo3::PyResult> { - let Some(kind) = super::enum_::CastKind::parse(&kind) else { + let Some(kind) = super::enum_::CastKind::parse(kind) else { return rapidquery_macros::pyerr!(Err(PyValueError), "unknown cast kind {:?}", kind); }; - if format.is_none() && at_time_zone.is_some() { - return rapidquery_macros::pyerr!(Err(PyValueError), "AT TIME ZONE requires FORMAT"); - } let inner = super::enum_::Cast { kind, expr: Box::new(expr.0), data_type_name: data_type_name.0, - format: format.map(|LiteralExprExtractor(e)| Box::new(e)), - at_time_zone: at_time_zone.map(|LiteralExprExtractor(e)| Box::new(e)), }; Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) } @@ -524,65 +516,29 @@ impl PyCastExpr { let inner = Self::inner(&slf); inner.kind.as_sql() } - - fn get_format(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - let inner = Self::inner(&slf); - match inner.format.as_ref() { - Some(expr) => super::base::into_subclasses(slf.py(), (**expr).clone()), - None => Ok(utils::new_pynone(slf.py())), - } - } - - fn get_at_time_zone(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - let inner = Self::inner(&slf); - match inner.at_time_zone.as_ref() { - Some(expr) => super::base::into_subclasses(slf.py(), (**expr).clone()), - None => Ok(utils::new_pynone(slf.py())), - } - } } #[pyo3::pymethods] impl PyTrimExpr { #[new] - #[pyo3(signature = (expr, /, *, r#where = None, what = None, characters = None))] + #[pyo3(signature = (expr, /, *, r#where = "BOTH", characters = None))] fn __new__( expr: LiteralExprExtractor, - r#where: Option<&str>, - what: Option, - characters: Option>, + r#where: &str, + characters: Option, ) -> pyo3::PyResult> { - if what.is_some() && characters.is_some() { + let Some(where_) = super::enum_::TrimWhere::parse(r#where) else { return rapidquery_macros::pyerr!( Err(PyValueError), - "TRIM what and characters cannot both be set" + "unknown TRIM where {:?}; expected BOTH, LEADING, or TRAILING", + r#where ); - } - - let where_ = match r#where { - None => None, - Some(s) => { - let Some(parsed) = super::enum_::TrimWhere::parse(s) else { - return rapidquery_macros::pyerr!( - Err(PyValueError), - "unknown TRIM where {:?}; expected BOTH, LEADING, or TRAILING", - s - ); - }; - Some(parsed) - } - }; - - let characters = match characters { - None => None, - Some(obj) => Some(extract_trim_characters(obj.as_borrowed())?), }; let inner = super::enum_::Trim { expr: Box::new(expr.0), where_, - what: what.map(|LiteralExprExtractor(e)| Box::new(e)), - characters, + characters: characters.map(|LiteralExprExtractor(e)| Box::new(e)), }; Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) } @@ -592,33 +548,16 @@ impl PyTrimExpr { super::base::into_subclasses(slf.py(), (*inner.expr).clone()) } - fn get_where(slf: pyo3::PyRef<'_, Self>) -> Option<&'static str> { + fn get_where(slf: pyo3::PyRef<'_, Self>) -> &'static str { let inner = Self::inner(&slf); - inner.where_.map(super::enum_::TrimWhere::as_sql) - } - - fn get_what(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - let inner = Self::inner(&slf); - match inner.what.as_ref() { - Some(expr) => super::base::into_subclasses(slf.py(), (**expr).clone()), - None => Ok(utils::new_pynone(slf.py())), - } + inner.where_.as_sql() } fn get_characters(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - let py = slf.py(); let inner = Self::inner(&slf); match inner.characters.as_ref() { - None => Ok(utils::new_pynone(py)), - Some(chars) => unsafe { - utils::iterator_to_tuple( - py, - chars.len() as isize, - chars - .iter() - .map(|x| super::base::into_subclasses(py, x.clone()).map(|y| y.into_ptr())), - ) - }, + Some(expr) => super::base::into_subclasses(slf.py(), (**expr).clone()), + None => Ok(utils::new_pynone(slf.py())), } } } diff --git a/src/lib.rs b/src/lib.rs index 79cb69f..34d6efb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ pub mod typeref; // - Test `Expr` methods // - Benchmark and compare with old version // - Open PR +// - Restore GenericDialect // - Start writing query ASTs. /// RapidQuery native extension. From 37a0be97bf8eb285d1b030bbd4a2c6b8cb727ad5 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Wed, 30 Sep 2026 20:05:38 +0330 Subject: [PATCH 56/87] Fix bugs and optimize dialects --- AGENTS.md | 4 +- Cargo.toml | 3 + TASKS.md | 265 ----------------- src/data_type/serializer/serialize.rs | 133 +++++---- src/dialect/builtin.rs | 269 ++---------------- src/dialect/inner.rs | 391 +++++++++++++++++++------- src/expr/enum_/func.rs | 26 +- src/expr/enum_/like.rs | 51 +++- src/expr/enum_/mod.rs | 4 +- src/expr/enum_/nodes.rs | 104 +++++-- src/expr/enum_/operator.rs | 13 +- src/lib.rs | 7 +- src/sql.rs | 6 +- tests/test_serializers.py | 12 +- 14 files changed, 582 insertions(+), 706 deletions(-) delete mode 100644 TASKS.md diff --git a/AGENTS.md b/AGENTS.md index 616a5ee..763fce1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ Reference the PR without checking it out as the working tree: `git show origin/m ```bash uv sync --dev maturin develop # or: uv run maturin develop -cargo test --workspace --lib --bins --tests +cargo test --workspace --lib --bins --tests --no-default-features --features auto-initialize cargo clippy --workspace --all-targets -- -D warnings # project lints are warn-level; CI does not run clippy cargo fmt # no rustfmt.toml on this branch; CI does not run rustfmt uv run pytest -vv # CI command; pytest is in [dependency-groups] dev @@ -65,7 +65,7 @@ uv run pytest -vv # CI command; pytest is in [dependency-groups] dev There is no Python linter/typechecker configured. Do not add ruff/mypy/a new test runner unless asked. -After meaningful Rust changes: run `cargo test --workspace --lib --bins --tests`. After Python-facing changes: also `uv run pytest -vv`. +After meaningful Rust changes: run `cargo test --workspace --lib --bins --tests --no-default-features --features auto-initialize`. After Python-facing changes: also `uv run pytest -vv`. `cargo test --workspace` (including doctests) currently fails: rustdoc builds `rapidquery-macros` without syn’s `full` feature. `pyerr` examples are marked `rust,no-test`. diff --git a/Cargo.toml b/Cargo.toml index 2e00ec7..887d324 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,9 @@ uuid = { version = "1.26.1", default-features = false } default = ["extension-module"] extension-module = ["pyo3/extension-module"] nightly = ["hashbrown/nightly"] +# Test harness only. `extension-module` does not link libpython, so Rust tests +# that touch Python use `--no-default-features --features auto-initialize`. +auto-initialize = ["pyo3/auto-initialize"] # Testing features only-stdlib-json = [] diff --git a/TASKS.md b/TASKS.md deleted file mode 100644 index d8a13da..0000000 --- a/TASKS.md +++ /dev/null @@ -1,265 +0,0 @@ -All six research tracks finished. Below is the WriteSql audit: what we emit today, where official docs disagree, and the work to do next. No code in this turn. - -**Policy (already in `src/lib.rs`):** if a dialect does not document a construct, `to_sql` / `prepare` should raise. Do not invent `LOWER()`, `REGEXP_LIKE()`, or `CONCAT()` unless a dialect flag names that rewrite. - -**WriteSql inventory** - -| Writer | File | -| ------------------------------------- | ------------------------------------------------------------------ | -| `Function` | `src/expr/enum_/func.rs` | -| `Ident` / `IdentPart` | `src/expr/enum_/ident.rs` | -| `Value` → `SerializerEnum::write_sql` | `src/expr/enum_/value.rs`, `src/data_type/serializer/serialize.rs` | -| `DataTypeName` / `PyDataType` | `src/data_type/name.rs`, `src/data_type/mod.rs` | -| `ExprEnum` arms | `src/expr/enum_/mod.rs` | -| Placeholders | `ParameterizedSql::push_param` in `src/sql.rs` | -| Quote / escape / bytes / bits / bool | `src/dialect/inner.rs` | - ---- - -## 0. Cross-cutting: identifiers and placeholders - -**Emit:** always-quote `IdentPart::Quote`; Unquote = `[A-Za-z_][A-Za-z0-9_]*`; Asterisk = `*`; parts joined with `.`; params = `{sigil}param_n` + dict. - -### P0 - -1. **Teradata identifiers** use `'name'` (`IdentifierFormat::SingleQuote`). Official delimited names use `"…"` (U+0022). `'users'` is a character literal. -2. **BigQuery** identifier escape does not emit `` \` ``. A backtick inside a name splits the identifier. -3. **PostgreSQL `:param_0` is not libpq SQL.** Native parameters are `$1`. MySQL/Hive PREPARE/JDBC use `?`. - -### Tasks - -**A.** Doccomments on `Ident` / `IdentPart` `WriteSql` and `Dialect::quote_identifier`: always-quote; Unquote is a conservative subset; Asterisk vs `"*"`; named `{sigil}param_n` is driver-named style, not wire protocol. - -**B.** - -1. Teradata: `identifier_format = DoubleQuote`, escape delimiters (`"` → `""`). -2. BigQuery: escape `` ` `` as `` \` `` (string-escape table). -3. Placeholder policy: either native (`$1` / `?`) or keep named and document that PG/MySQL/Hive SQL is not wire-native. Do not claim `:param_0` is libpq. - -**C.** Optional `QUOTE_IDENT`; SQL Server empty qualifiers (`db..tbl`); per-dialect Unquote charset. - -**D.** Teradata `"a""b"`; BigQuery name containing backtick; PG `$1` vs `:param_0` documentation tests. - -Official: [BQ lexical](https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/lexical) Ā· [PG lexical](https://www.postgresql.org/docs/current/sql-syntax-lexical.html) Ā· [PG PREPARE](https://www.postgresql.org/docs/current/sql-prepare.html) Ā· [SQLite keywords](https://www.sqlite.org/lang_keywords.html) Ā· [MySQL identifiers](https://dev.mysql.com/doc/refman/8.4/en/identifiers.html) - ---- - -## 1. Function (`Function::write_sql`) — your template - -**Emit:** quoted name; `( [DISTINCT\|ALL ] args [ IGNORE NULLS ] )` or postfix NT; then always ` FILTER (WHERE …)`. NT inside parens only for BigQuery and DuckDB. - -### Official vs emit - -| Dialect | DISTINCT / ALL | FILTER | IGNORE/RESPECT NULLS | -| ----------------------- | --------------------------- | ------------------------------------------------- | ------------------------------------ | -| PostgreSql | D and ALL | `FILTER (WHERE)` | none | -| Sqlite | DISTINCT only | `FILTER (WHERE)` on aggregates | none | -| BigQuery | DISTINCT only | **in-call `WHERE`** (Preview) | **inside** parens | -| DuckDb | DISTINCT | `FILTER (pred)`; railroad also `FILTER (WHERE …)` | inside on window fns | -| Databricks / Spark | D; Databricks COUNT has ALL | `FILTER (WHERE)` | postfix, then OVER | -| Oracle 23+ | D and ALL | `FILTER (WHERE)` | postfix (ANSI) and inside | -| Snowflake / Redshift | D; Redshift has ALL | none | postfix + OVER | -| MsSql | D and ALL | none | postfix + OVER (2022+) | -| MySql / Hive / Teradata | D; Teradata has ALL | none | Teradata **inside**; MySQL/Hive none | - -Bare `CURRENT_TIMESTAMP` is a **keyword/literal token** on SQLite (and similar on PG/Oracle). Quoting it (`"CURRENT_TIMESTAMP"`) kills the special meaning. - -### P0 - -1. `FILTER (WHERE)` on MySQL, MsSql, Hive, Snowflake, Redshift, Teradata. -2. BigQuery: official filter is `fn(x WHERE cond)`, not `fn(x) FILTER (WHERE cond)`. -3. `ALL` on BigQuery and SQLite (existing TODO). -4. NT on PostgreSQL, SQLite, MySQL, Hive. -5. Teradata NT is inside parens; builtin is `OutsideParens`. -6. Always-quoted niladic `CURRENT_TIMESTAMP` on SQLite. - -### Tasks - -**A.** Expand the Function `WriteSql` comment (keep BQ + SQLite links) with the URLs below. - -**B.** (`func.rs`, dialect flags) - -1. Skip `ALL` where not in syntax (BQ, SQLite; also Snowflake COUNT / Hive). -2. BigQuery FILTER → in-call `WHERE` after args/NT. -3. Omit FILTER unless dialect supports it (PG, SQLite, DuckDB, Spark, Databricks, Oracle 23+). -4. Omit NT unless dialect supports it (BQ, DuckDB, Snowflake, Spark, Databricks, Oracle, Redshift, Teradata, MsSql 2022+). -5. Teradata `null_treatment_syntax = InsideParens`. -6. Bare SQL-standard datetime keywords: do not quote. -7. DuckDB: railroad is `FILTER ( WHERE filter_expr )`; examples omit `WHERE`. Keep `WHERE` unless a rendered parse test fails. - -Suggested flags: `duplicate_treatment_all: bool`, `filter_syntax: None | FilterWhere | FilterBare | InsideWhere`. - -**C.** OVER, WITHIN GROUP, in-arg ORDER BY, named `=>` args, BQ HAVING MAX/MIN / LIMIT. Function-specific NT allowlists. `COUNT(*)` vs empty `COUNT()`. - -**D.** ALL omitted on BQ/SQLite; FILTER spelling per dialect; NT in vs post vs omitted; unquoted `CURRENT_TIMESTAMP` on SQLite. - -Official: [BQ aggregate calls](https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/aggregate-function-calls) Ā· [PG expressions](https://www.postgresql.org/docs/current/sql-expressions.html) Ā· [SQLite agg](https://www.sqlite.org/lang_aggfunc.html) Ā· [DuckDB FILTER](https://duckdb.org/docs/current/sql/query_syntax/filter.html) Ā· [Oracle FILTER](https://docs.oracle.com/en/database/oracle/oracle-database/26/sqlrf/Aggregate-Functions.html) Ā· [Spark windows](https://spark.apache.org/docs/latest/sql-ref-syntax-qry-select-window.html) Ā· [Snowflake FIRST_VALUE](https://docs.snowflake.com/en/sql-reference/functions/first_value) - ---- - -## 2. LIKE / ILIKE / LIKE ANY / SIMILAR TO / RLIKE - -**Emit:** `LIKE`/`ILIKE` + inlined ESCAPE; ANY via ArrayConstructor / PatternList / OrExpansion; `SIMILAR TO` and `RLIKE` ungated. - -### P0 - -1. **ILIKE** on BigQuery, Hive, MsSql, MySql, Oracle, Sqlite, Teradata. -2. **SIMILAR TO** except PostgreSql, Redshift, DuckDb. -3. **RLIKE** infix + ESCAPE: PG/Redshift want `~`; Oracle `REGEXP_LIKE()`; BigQuery `REGEXP_CONTAINS()`; MsSql none; SQLite user `REGEXP`. ESCAPE is not a regex clause even where RLIKE exists (MySQL/Hive/Spark/Databricks/Snowflake). -4. PostgreSql `LIKE ANY (ARRAY[…]) ESCAPE` — ESCAPE is not composable with ANY. -5. Spark/Databricks `NOT LIKE ANY` = ā€œfails at least oneā€, not ā€œmatches noneā€. -6. BigQuery LIKE has **no ESCAPE clause**. - -### Tasks - -**A.** Doccomments on `write_like` / SimilarTo / RLike: ANY shapes; why PG wraps `NOT (LIKE ANY)`; ungated SIMILAR TO / RLIKE. - -**B.** - -| Node | Gate | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ILIKE | Only PG, Redshift, DuckDB, Snowflake, Spark, Databricks. Else LIKE (no silent `LOWER()`). | -| LIKE ANY + ESCAPE | Omit ESCAPE on PG ANY, Spark/Databricks quantified, BigQuery always. Keep on Snowflake ANY and scalar/OrExpansion. | -| LIKE ANY negation | PatternList: `NOT (x LIKE ANY (…))`, not `x NOT LIKE ANY`. Keep PG wrap and OrExpansion AND of NOT LIKE. | -| SIMILAR TO | PG, Redshift, DuckDB only. | -| RLIKE | MySQL/Hive/Spark/Databricks/Snowflake: infix, **never ESCAPE**. Oracle: `REGEXP_LIKE`. PG/Redshift: `~`. DuckDB: `regexp_matches` or `~` (full match). BigQuery: `REGEXP_CONTAINS`. Else unsupported. | - -**C.** ILIKE via COLLATE/LOWER; LIKE ALL; GLOB; Snowflake RLIKE anchoring. - -**D.** Per-dialect LIKE / ILIKE / ANY negation / SIMILAR TO / RLIKE snapshots; PG ANY without ESCAPE; no `RLIKE … ESCAPE`. - -Official: [PG matching](https://www.postgresql.org/docs/current/functions-matching.html) Ā· [BQ operators](https://cloud.google.com/bigquery/docs/reference/standard-sql/operators) Ā· [Snowflake LIKE ANY](https://docs.snowflake.com/en/sql-reference/functions/like_any) Ā· [Spark LIKE](https://spark.apache.org/docs/latest/sql-ref-syntax-qry-select-like.html) Ā· [MySQL REGEXP](https://dev.mysql.com/doc/refman/8.4/en/regexp.html) - ---- - -## 3. Literals (`Value` → serializers + dialect formatters) - -**Emit:** bool keyword or `1`/`0`; strings via wrap+escape; bytes via `BytesFormat`; bits or raise; date/time/json/uuid/inet/mac as **quoted strings**; arrays `ARRAY[…]` only if `supports_array`. - -### P0 - -1. **PostgreSQL NUL:** code zero cannot appear in a string constant. `E'\x00'` is still NUL after unescape. **Reject NUL.** -2. **Snowflake `X'HEX'`** is not a documented BINARY constant (`TO_BINARY`). -3. **DuckDB `X'HEX'`** is not the documented blob form (`'\xAA'::BLOB`). -4. **Oracle untyped `'YYYY-MM-DD'`** follows `NLS_DATE_FORMAT`. Official DATE literal is `DATE 'YYYY-MM-DD'`. -5. **QuoteStripNul** deletes U+0000 (MsSql, Oracle, Redshift, Sqlite, Teradata) — payload change. Prefer reject. - -Boolean `1`/`0` on MsSql/MySql/Oracle/Sqlite matches those engines’ usual constants. Keyword TRUE/FALSE is correct on the others. - -### Tasks - -**A.** Doccomments on `SerializerEnum::write_sql`, `write_string_literal`, `format_bytes`, `format_bits`: wrap/escape; PG `E` + NUL illegal; MsSql `N'` = nvarchar; bytes wrappers; temporals/json/uuid are string literals. - -**B.** - -1. Reject U+0000 on PostgreSQL (and any dialect that forbids NUL in strings). -2. Snowflake bytes → `TO_BINARY('…', 'HEX')`. -3. DuckDB bytes → `'\xHH…'::BLOB`. -4. Oracle dates → `DATE 'YYYY-MM-DD'`; timestamps → `TIMESTAMP '…'`. -5. Stop stripping NUL: reject. -6. High value: typed `DATE '…'` / `TIMESTAMP '…'` on BQ, Databricks, DuckDB, Hive, PG, Snowflake, Spark, Teradata ANSI. - -**C.** JSON `JSON '…'` / `PARSE_JSON` / `'…'::jsonb`; UUID/`inet` casts; DuckDB LIST `[…]`; Spark/Hive `array()`; Postgres `'…'` + `''` with `E` only when backslash is needed. - -**D.** Golden per dialect: bool, `O'Brien`, NUL (error), bytes, empty bytes, date, JSON, UUID, ARRAY or raise. - -Official: [PG lexical](https://www.postgresql.org/docs/current/sql-syntax-lexical.html) Ā· [BQ lexical](https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/lexical) Ā· [Snowflake binary I/O](https://docs.snowflake.com/en/sql-reference/binary-input-output) Ā· [Oracle literals](https://docs.oracle.com/en/database/oracle/oracle-database/26/sqlrf/Literals.html) Ā· [DuckDB blob](https://duckdb.org/docs/current/sql/data_types/blob.html) - ---- - -## 4. Operators, tuples, predicates - -**Emit:** unary spacing groups; binary `left OP right`; `!=` → `<>`; `XOR` keyword; `^` bitwise XOR; `||` concat; tuple `(a, b)`; `IN ()` for empty; `IS NORMALIZED`; no BETWEEN SYMMETRIC. - -### P0 - -1. MySQL default `||` is **logical OR**, not concat. -2. SQL Server `||` concat is **2025+**; classic concat is `+`. -3. `XOR` keyword is MySQL logical XOR only. -4. `^` is **exponent** on PostgreSQL, Redshift, DuckDB. PG bitwise XOR is `#`. -5. `%` is not an operator on Oracle (`MOD()`) or Teradata (`MOD`). -6. `!` postfix factorial: **removed in PostgreSQL 14**; Hive/Spark/Databricks/MySQL `!` is logical NOT. DuckDB still has `4!`. -7. Geometric unaries `@-@` `@@` `#` `?-` `?|` are **PostgreSQL geometric**, not Redshift. -8. `IS [form] NORMALIZED` is PostgreSQL-only. -9. `IS DISTINCT FROM` undocumented on MySQL/Hive (use `<=>`), Oracle, Redshift, Teradata; MsSql from 2022. -10. Empty `IN ()` invalid except SQLite (and BQ empty-set _semantics_ via subquery). -11. Singleton `(x)` is grouping, not a 1-tuple, on PG/SQLite/Databricks/Spark/DuckDB. -12. Snowflake bitwise is functions (`BITAND`/`BITXOR`), not `& | ^ ~`. - -`<>` is the portable inequality spelling on every opened comparison page. - -### Tasks - -**A.** Doccomments on unary/binary/tuple/Is/IsNormalized/IsDistinctFrom/InList/Between: ungated spellings; `!=`→`<>`; `!` factorial; empty `IN ()`. Do not call geometric unaries Redshift. - -**B.** - -| Node | Action | -| ---------------- | --------------------------------------------------------------------------------------- | -| `\|\|` | MySQL: `CONCAT()` (or require `PIPES_AS_CONCAT`). MsSql: `+` until 2025. | -| `XOR` | MySQL only. | -| `BitXor` | PG `#`; DuckDB `xor()`; Snowflake `BITXOR()`; BQ/Spark/Hive/MySQL/MsSql `^`. | -| `Mod` | Oracle `MOD()`; Teradata `MOD`. | -| PG unaries | PG (+ Redshift numeric `@ \|/ \|\|/`). Factorial → `factorial()`. Geometric → PG only. | -| `IsNormalized` | PostgreSql only. | -| `IsDistinctFrom` | `<=>` on MySQL/Hive; refuse Oracle/Redshift/Teradata; MsSql ≄2022. | -| Empty `IN` | Rewrite to `FALSE`/`TRUE`; never emit `IN ()`. | -| `Tuple` | PG `ROW(...)` including 0/1 field; Spark/Databricks/Hive `struct()`; SQLite refuse 0/1. | -| Extra parens | Always paren `\|\|` vs `+-`/`*/`; MsSql `IN`/`BETWEEN` vs `OR`. | - -**C.** BETWEEN SYMMETRIC; integer `DIV`/`//`; SQLite row-IN via subquery once Query exists. - -**D.** Concat on MySQL vs PG; `^` PG exponent vs MySQL bitwise; `UnaryExpr("!")` DuckDB vs Hive; empty IN; `IsNormalized` PG only; `<=>` on MySQL. - -Official: [PG math](https://www.postgresql.org/docs/current/functions-math.html) Ā· [PG 14 factorial](https://www.postgresql.org/docs/14/release-14.html) Ā· [MySQL logical ops](https://dev.mysql.com/doc/refman/8.4/en/logical-operators.html) Ā· [SQLite rowvalue](https://www.sqlite.org/rowvalue.html) Ā· [BQ operators](https://cloud.google.com/bigquery/docs/reference/standard-sql/operators) - ---- - -## 5. CAST, TRIM, COLLATE, CASE, Custom, DataTypeName - -**Emit:** `KIND(expr AS type[ FORMAT … [ AT TIME ZONE …]])`; TRIM SQL FROM and comma-list; `expr COLLATE ident`; CASE simple/searched; Custom `{}`; type name verbatim. - -### P0 - -1. **SAFE_CAST** is BigQuery-only. **TRY_CAST** is not on PG/MySQL/SQLite/Hive/Oracle. Teradata is **`TRYCAST`**. Snowflake TRY_CAST is **string source only**. -2. **FORMAT / AT TIME ZONE** is BigQuery CAST grammar (Hive datetime FORMAT; Teradata type FORMAT). Invalid on PG, MySQL (different AT TIME ZONE production), SQL Server, Snowflake, DuckDB, Spark CAST, SQLite, Oracle (positional _fmt_, not keyword FORMAT). -3. **TRIM(LEADING FROM …)** invalid on BigQuery, Snowflake, DuckDB, SQLite, Hive (`TRIM(value[, set])` / `trim(s[, chars])`). -4. **COLLATE ident** invalid on BigQuery (`COLLATE(s,'und:ci')`) and Redshift (`COLLATE(s,'ci')`). Snowflake spec is a **string**. - -CASE simple + searched + omitted ELSE → NULL is portable. - -### Type-name traps - -- BQ: INT64 / STRING / BYTES / BOOL / FLOAT64 / `ARRAY` (INT is an alias of INT64). -- MySQL CAST: `SIGNED`, not `INT`. -- MsSql bool: `bit`, not BOOLEAN. -- PG: `bytea`, `timestamp with time zone` (interned `TIMESTAMP WITH TIMEZONE` has no space in TIMEZONE). -- Spark/Hive/Databricks: `ARRAY`, not `INT ARRAY`. - -### Tasks - -**A.** Doccomments on Cast / Trim / Collate / Case / Custom / DataTypeName with the URLs in the CAST research (BQ conversion functions, PG type casts, TRIM string functions, COLLATE expressions). - -**B.** - -- Kind allow-list (table above). Do not map SAFE_CAST → TRY_CAST unless the dialect documents that twin. -- FORMAT / AT TIME ZONE only for BigQuery (Hive FORMAT only datetime↔string). -- TRIM: (1) SQL FROM — PG, MySQL, Redshift, Spark/Databricks, Oracle one-char, Teradata, MsSql 2022; (2) `trim(expr[, chars])` — BQ, Snowflake, DuckDB, SQLite; (3) Hive `trim(expr)` spaces only. -- COLLATE: infix ident (PG, MySQL, MsSql, Oracle, SQLite, DuckDB, Databricks); function+string (BQ, Redshift, Snowflake). -- DataTypeName map for CAST: INT→INT64 (BQ), SIGNED (MySQL), STRING (BQ/Spark/Hive/Databricks), BYTEA (PG), BINARY (Spark), bit (MsSql bool), `ARRAY` vs `T[]`. - -**C.** `::`, CONVERT, Oracle `DEFAULT … ON CONVERSION ERROR`, Databricks `?::`. - -**D.** Per dialect: CAST kind accepted/rejected; FORMAT omitted except BQ; TRIM form; COLLATE form; `CAST(x AS INT64)` BQ vs `INTEGER` PG vs `SIGNED` MySQL. - -Official: [BQ CAST](https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/conversion_functions) Ā· [PG CAST](https://www.postgresql.org/docs/current/sql-expressions.html#SQL-SYNTAX-TYPE-CASTS) Ā· [BQ TRIM](https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/string_functions#trim) Ā· [PG COLLATE](https://www.postgresql.org/docs/current/sql-expressions.html#SQL-SYNTAX-COLLATE-EXPRS) Ā· [Oracle TRIM](https://docs.oracle.com/en/database/oracle/oracle-database/23/sqlrf/TRIM.html) - ---- - -## Suggested order (when you implement) - -Do **A (doccomments)** first, Function-style, next to each `write_sql`. Then **B gates that omit invalid SQL** (FILTER, NT, ALL, ILIKE, SIMILAR TO, RLIKE, CAST FORMAT, TRIM FROM, COLLATE, empty IN, Teradata quotes, PG NUL). Then **spelling rewrites** (BQ in-call WHERE, RLIKE `~` / `REGEXP_LIKE`, LIKE ANY negation, Snowflake/DuckDB bytes, Oracle `DATE '…'`). **C** stays later (OVER, WITHIN GROUP, typed JSON, `::`). - -**Do not guess.** If a dialect page does not show the production, raise. Silent `LOWER()` / `CONCAT()` / `REGEXP_LIKE()` only after an explicit dialect flag. - -**UNVERIFIED leftovers:** Teradata Help Center is a JS SPA (identifier quoting, LIKE ANY, bit `'101'B`). DuckDB `FILTER (WHERE` vs `FILTER (` — railroad includes WHERE. Hive LIKE ESCAPE production not on the current UDF page. Spark OSS 4.2 catalog lists CAST/`::`; `TRY_CAST` is Databricks-documented. diff --git a/src/data_type/serializer/serialize.rs b/src/data_type/serializer/serialize.rs index 2bf0cf7..9397a3d 100644 --- a/src/data_type/serializer/serialize.rs +++ b/src/data_type/serializer/serialize.rs @@ -1,8 +1,4 @@ //! Write a Python value as a SQL literal into a `String`. -//! -//! One buffer, dialect writers (`write_string_literal`, `encode_bytes`, …). -//! Type checks are exact (`int` is not `bool`; `date` is not `datetime`). -//! JSON dumps go through [`crate::internal::modules::json_dumps`]. use std::fmt::Write; use std::str::FromStr; @@ -11,8 +7,7 @@ use pyo3::types::{PyAnyMethods, PyTzInfoAccess}; use crate::dialect::PySqlDialect; use crate::dialect::inner::{ - ArrayFormat, Dialect, FormatError, InetFormat, JsonFormat, MacFormat, TemporalLiteral, - UuidFormat, + ArrayFormat, Dialect, FormatError, InetFormat, JsonFormat, MacFormat, StringFormat, UuidFormat, }; use crate::internal::alias; use crate::internal::utils::{borrow_py_str_buffer, get_type_name}; @@ -36,9 +31,9 @@ impl super::serializer_enum::SerializerEnum { Self::Bytes => write_bytes(py, ptr, &dialect.0, out), Self::Bit => write_bit(py, ptr, &dialect.0, out), Self::Json => write_json(py, ptr, &dialect.0, out), - Self::Date => write_date(py, ptr, &dialect.0, out), - Self::Time => write_time(py, ptr, &dialect.0, out), - Self::Datetime => write_datetime(py, ptr, &dialect.0, out), + Self::Date => write_date(py, ptr, out), + Self::Time => write_time(py, ptr, out), + Self::Datetime => write_datetime(py, ptr, out), Self::Inet => write_inet(py, ptr, &dialect.0, out), Self::MacAddress => write_mac_address(py, ptr, &dialect.0, out), Self::Array(element) => write_array(py, ptr, dialect, element, out), @@ -82,23 +77,31 @@ fn write_quoted(dialect: &Dialect, out: &mut String, raw: &str) -> pyo3::PyResul dialect.write_string_literal(out, raw).map_err(Into::into) } +/// Open a string literal and write `body` inside it. +/// +/// `body` is digits, `-`, `:`, `.`, `+`, and space only: no quote, backslash, +/// NUL, or C0. `PrefixBigE` therefore stays a plain quote, the same branch +/// `write_string_literal` takes when the body needs no C escape. `PrefixBigN` +/// still writes `N'`. #[inline] -fn write_temporal( +fn write_quoted_body( dialect: &Dialect, out: &mut String, - format: TemporalLiteral, - keyword: &str, - raw: &str, + body_fn: impl FnOnce(&mut String) -> std::fmt::Result, ) -> pyo3::PyResult<()> { - if matches!(format, TemporalLiteral::TypedKeyword) { - out.push_str(keyword); - out.push(' '); + match dialect.string_format { + StringFormat::PrefixBigN => out.push_str("N'"), + StringFormat::SingleQuote | StringFormat::PrefixBigE => out.push('\''), } - write_quoted(dialect, out, raw) + body_fn(out).map_err(|_| rapidquery_macros::pyerr!(PyRuntimeError, "failed to write SQL"))?; + out.push('\''); + Ok(()) } -/// Serializes any Python `int` as a bare decimal literal (`str(int)`), -/// including values beyond i64/u64. `bool` is rejected (`PyLong_CheckExact`). +/// Serializes any Python `int` as a bare decimal literal (`str(int)`). +/// +/// Values that fit in `i64` are written with [`write!`]. Larger values use +/// `PyObject_Str`. `bool` is rejected (`PyLong_CheckExact`). /// /// # Safety /// `ptr` must be *borrowed*, non-null, and a valid Python object. @@ -108,17 +111,27 @@ unsafe fn write_integer( ptr: *mut pyo3::ffi::PyObject, out: &mut String, ) -> pyo3::PyResult<()> { - if pyo3::ffi::PyLong_CheckExact(ptr) == 1 { - let text = py_to_string(py, ptr)?; - out.push_str(&text); - Ok(()) - } else { - rapidquery_macros::pyerr!( + if pyo3::ffi::PyLong_CheckExact(ptr) != 1 { + return rapidquery_macros::pyerr!( Err(PyTypeError), "expected int for integer serialization, got {}", get_type_name(py, ptr) - ) + ); + } + + let mut overflow = 0i32; + let n = pyo3::ffi::PyLong_AsLongLongAndOverflow(ptr, &mut overflow); + if overflow != 0 { + let text = py_to_string(py, ptr)?; + out.push_str(&text); + return Ok(()); } + + if n == -1 && !pyo3::ffi::PyErr_Occurred().is_null() { + return Err(pyo3::PyErr::fetch(py)); + } + + write!(out, "{n}").map_err(|_| rapidquery_macros::pyerr!(PyRuntimeError, "failed to write SQL")) } /// `float` / exact `int` / `decimal.Decimal` as a bare numeric literal. @@ -207,15 +220,19 @@ unsafe fn write_str( } #[inline] -fn write_uuid_text(dialect: &Dialect, out: &mut String, raw: &str) -> pyo3::PyResult<()> { +fn write_uuid_text( + dialect: &Dialect, + out: &mut String, + body: impl FnOnce(&mut String) -> std::fmt::Result, +) -> pyo3::PyResult<()> { match dialect.uuid_format { - UuidFormat::Quoted => write_quoted(dialect, out, raw), + UuidFormat::Quoted => write_quoted_body(dialect, out, body), UuidFormat::UuidKeyword => { out.push_str("UUID "); - write_quoted(dialect, out, raw) + write_quoted_body(dialect, out, body) } UuidFormat::CastUuid => { - write_quoted(dialect, out, raw)?; + write_quoted_body(dialect, out, body)?; out.push_str("::uuid"); Ok(()) } @@ -232,14 +249,22 @@ unsafe fn write_uuid( out: &mut String, ) -> pyo3::PyResult<()> { if crate::typeref::check_exact(ptr, crate::typeref::STD_UUID_TYPE) { - let text = py_to_string(py, ptr)?; - write_uuid_text(dialect, out, &text) + let rendered = pyo3::ffi::PyObject_Str(ptr); + if rendered.is_null() { + return Err(pyo3::PyErr::fetch(py)); + } + let result = match borrow_py_str_buffer(py, rendered) { + Ok(text) => write_uuid_text(dialect, out, |w| w.write_str(text)), + Err(err) => Err(err), + }; + pyo3::ffi::Py_DECREF(rendered); + result } else if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { let borrowed = borrow_py_str_buffer(py, ptr)?; let parsed = uuid::Uuid::try_parse(borrowed) .map_err(|e| rapidquery_macros::pyerr!(PyValueError, "invalid uuid str: {}", e))?; - write_uuid_text(dialect, out, &parsed.hyphenated().to_string()) + write_uuid_text(dialect, out, |w| write!(w, "{}", parsed.hyphenated())) } else { rapidquery_macros::pyerr!( Err(PyTypeError), @@ -349,15 +374,16 @@ unsafe fn write_json( unsafe fn write_date( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { if crate::typeref::check_exact(ptr, crate::typeref::STD_DATE_TYPE) { let val: pyo3::Bound<'_, pyo3::types::PyDate> = pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; + let date: chrono::NaiveDate = val.extract()?; - let text = date.format("%Y-%m-%d").to_string(); - write_temporal(dialect, out, dialect.date_format, "DATE", &text) + + let _ = date.format("%Y-%m-%d").write_to(out); + Ok(()) } else { Err(rapidquery_macros::pyerr!( PyTypeError, @@ -373,15 +399,15 @@ unsafe fn write_date( unsafe fn write_time( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { if crate::typeref::check_exact(ptr, crate::typeref::STD_TIME_TYPE) { let val: pyo3::Bound<'_, pyo3::types::PyTime> = pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; + let time: chrono::NaiveTime = val.extract()?; - let text = time.format("%H:%M:%S%.f").to_string(); - write_temporal(dialect, out, dialect.time_format, "TIME", &text) + let _ = time.format("%H:%M:%S%.f").write_to(out); + Ok(()) } else { Err(rapidquery_macros::pyerr!( PyTypeError, @@ -401,7 +427,6 @@ unsafe fn write_time( unsafe fn write_datetime( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { use chrono::TimeZone; @@ -412,13 +437,14 @@ unsafe fn write_datetime( if val.get_tzinfo().is_none() { let datetime: chrono::NaiveDateTime = val.extract()?; - let text = datetime.format("%Y-%m-%d %H:%M:%S%.f").to_string(); - return write_temporal(dialect, out, dialect.timestamp_format, "TIMESTAMP", &text); + + let _ = datetime.format("%Y-%m-%d %H:%M:%S%.f").write_to(out); + return Ok(()); } let datetime: chrono::DateTime = val.extract()?; - let text = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").to_string(); - return write_temporal(dialect, out, dialect.timestamp_format, "TIMESTAMP", &text); + let _ = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").write_to(out); + return Ok(()); } if pyo3::ffi::PyLong_CheckExact(ptr) == 1 { @@ -427,10 +453,11 @@ unsafe fn write_datetime( if has_overflow != 0 { return Err(pyo3::exceptions::PyOverflowError::new_err("out of range")); } + return match chrono::Utc.timestamp_opt(num, 0) { chrono::offset::LocalResult::Single(datetime) => { - let text = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").to_string(); - write_temporal(dialect, out, dialect.timestamp_format, "TIMESTAMP", &text) + let _ = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").write_to(out); + Ok(()) } _ => Err(rapidquery_macros::pyerr!( PyOverflowError, @@ -446,8 +473,8 @@ unsafe fn write_datetime( .timestamp_opt(num.trunc() as i64, (num.fract() * 1_000_000_000.0) as u32) { chrono::offset::LocalResult::Single(datetime) => { - let text = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").to_string(); - write_temporal(dialect, out, dialect.timestamp_format, "TIMESTAMP", &text) + let _ = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").write_to(out); + Ok(()) } _ => Err(rapidquery_macros::pyerr!( PyOverflowError, @@ -475,14 +502,13 @@ unsafe fn write_inet( ) -> pyo3::PyResult<()> { let text = py_to_string(py, ptr)?; let net = ipnetwork::IpNetwork::from_str(&text) - .map(|net| net.to_string()) .map_err(|e| rapidquery_macros::pyerr!(PyValueError, e.to_string()))?; match dialect.inet_format { None => Err(FormatError::Unsupported("InetSerializer").into()), - Some(InetFormat::Quoted) => write_quoted(dialect, out, &net), + Some(InetFormat::Quoted) => write_quoted_body(dialect, out, |w| write!(w, "{net}")), Some(InetFormat::Cast) => { - write_quoted(dialect, out, &net)?; + write_quoted_body(dialect, out, |w| write!(w, "{net}"))?; out.push_str("::inet"); Ok(()) } @@ -500,14 +526,13 @@ unsafe fn write_mac_address( ) -> pyo3::PyResult<()> { let text = py_to_string(py, ptr)?; let addr = mac_address::MacAddress::from_str(&text) - .map(|mac| mac.to_string()) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; match dialect.mac_format { None => Err(FormatError::Unsupported("MacAddressSerializer").into()), - Some(MacFormat::Quoted) => write_quoted(dialect, out, &addr), + Some(MacFormat::Quoted) => write_quoted_body(dialect, out, |w| write!(w, "{addr}")), Some(MacFormat::Cast) => { - write_quoted(dialect, out, &addr)?; + write_quoted_body(dialect, out, |w| write!(w, "{addr}"))?; out.push_str("::macaddr"); Ok(()) } diff --git a/src/dialect/builtin.rs b/src/dialect/builtin.rs index 2471c52..b42e12c 100644 --- a/src/dialect/builtin.rs +++ b/src/dialect/builtin.rs @@ -48,9 +48,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashHex, bytes_format: BytesFormat::PrefixBigBSlashX, - date_format: TemporalLiteral::TypedKeyword, - time_format: TemporalLiteral::TypedKeyword, - timestamp_format: TemporalLiteral::TypedKeyword, json_format: JsonFormat::JsonKeyword, uuid_format: UuidFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::InsideWhere), @@ -67,6 +64,8 @@ impl_builtin_dialects!( supports_double_colon: false, trim_syntax: TrimSyntax::Function, collate_syntax: Some(CollateSyntax::FunctionString), + like_escape_syntax: LikeEscapeSyntax::None, + bitwise_syntax: BitwiseSyntax::Infix, } } @@ -86,9 +85,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashUnicodeQuote, bytes_format: BytesFormat::PrefixBigX, - date_format: TemporalLiteral::TypedKeyword, - time_format: TemporalLiteral::Quoted, - timestamp_format: TemporalLiteral::TypedKeyword, json_format: JsonFormat::Quoted, uuid_format: UuidFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), @@ -105,6 +101,8 @@ impl_builtin_dialects!( supports_double_colon: true, trim_syntax: TrimSyntax::SqlFrom, collate_syntax: Some(CollateSyntax::InfixIdent), + like_escape_syntax: LikeEscapeSyntax::Scalar, + bitwise_syntax: BitwiseSyntax::Infix, } } @@ -124,9 +122,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::SlashXBlob, - date_format: TemporalLiteral::TypedKeyword, - time_format: TemporalLiteral::TypedKeyword, - timestamp_format: TemporalLiteral::TypedKeyword, json_format: JsonFormat::Quoted, uuid_format: UuidFormat::UuidKeyword, function_filter_syntax: Some(FunctionFilterSyntax::Filter), @@ -143,6 +138,8 @@ impl_builtin_dialects!( supports_double_colon: true, trim_syntax: TrimSyntax::Function, collate_syntax: Some(CollateSyntax::InfixIdent), + like_escape_syntax: LikeEscapeSyntax::Scalar, + bitwise_syntax: BitwiseSyntax::Infix, } } @@ -162,9 +159,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashUnicode, bytes_format: BytesFormat::Unhex, - date_format: TemporalLiteral::TypedKeyword, - time_format: TemporalLiteral::Quoted, - timestamp_format: TemporalLiteral::TypedKeyword, json_format: JsonFormat::Quoted, uuid_format: UuidFormat::Quoted, function_filter_syntax: None, @@ -181,6 +175,8 @@ impl_builtin_dialects!( supports_double_colon: false, trim_syntax: TrimSyntax::SpacesOnly, collate_syntax: None, + like_escape_syntax: LikeEscapeSyntax::None, + bitwise_syntax: BitwiseSyntax::Infix, } } @@ -200,9 +196,6 @@ impl_builtin_dialects!( string_format: StringFormat::PrefixBigN, string_escape: StringEscape::Quote, bytes_format: BytesFormat::PrefixZeroX, - date_format: TemporalLiteral::Quoted, - time_format: TemporalLiteral::Quoted, - timestamp_format: TemporalLiteral::Quoted, json_format: JsonFormat::Quoted, uuid_format: UuidFormat::Quoted, function_filter_syntax: None, @@ -219,6 +212,8 @@ impl_builtin_dialects!( supports_double_colon: false, trim_syntax: TrimSyntax::SqlFrom, collate_syntax: Some(CollateSyntax::InfixIdent), + like_escape_syntax: LikeEscapeSyntax::Scalar, + bitwise_syntax: BitwiseSyntax::InfixNoShift, } } @@ -238,9 +233,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashMysql, bytes_format: BytesFormat::PrefixBigX, - date_format: TemporalLiteral::Quoted, - time_format: TemporalLiteral::Quoted, - timestamp_format: TemporalLiteral::Quoted, json_format: JsonFormat::Quoted, uuid_format: UuidFormat::Quoted, function_filter_syntax: None, @@ -257,6 +249,8 @@ impl_builtin_dialects!( supports_double_colon: false, trim_syntax: TrimSyntax::SqlFrom, collate_syntax: Some(CollateSyntax::InfixIdent), + like_escape_syntax: LikeEscapeSyntax::Scalar, + bitwise_syntax: BitwiseSyntax::Infix, } } @@ -276,9 +270,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::HexToRaw, - date_format: TemporalLiteral::TypedKeyword, - time_format: TemporalLiteral::Quoted, - timestamp_format: TemporalLiteral::TypedKeyword, json_format: JsonFormat::Quoted, uuid_format: UuidFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), @@ -295,6 +286,8 @@ impl_builtin_dialects!( supports_double_colon: false, trim_syntax: TrimSyntax::SqlFrom, collate_syntax: Some(CollateSyntax::InfixIdent), + like_escape_syntax: LikeEscapeSyntax::Scalar, + bitwise_syntax: BitwiseSyntax::BitAndOnly, } } @@ -314,9 +307,6 @@ impl_builtin_dialects!( string_format: StringFormat::PrefixBigE, string_escape: StringEscape::BackslashHex, bytes_format: BytesFormat::SlashXBytea, - date_format: TemporalLiteral::TypedKeyword, - time_format: TemporalLiteral::TypedKeyword, - timestamp_format: TemporalLiteral::TypedKeyword, json_format: JsonFormat::CastJsonb, uuid_format: UuidFormat::CastUuid, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), @@ -333,6 +323,8 @@ impl_builtin_dialects!( supports_double_colon: true, trim_syntax: TrimSyntax::SqlFrom, collate_syntax: Some(CollateSyntax::InfixIdent), + like_escape_syntax: LikeEscapeSyntax::Scalar, + bitwise_syntax: BitwiseSyntax::Infix, } } @@ -352,9 +344,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::ToVarbyte, - date_format: TemporalLiteral::Quoted, - time_format: TemporalLiteral::Quoted, - timestamp_format: TemporalLiteral::Quoted, json_format: JsonFormat::Quoted, uuid_format: UuidFormat::Quoted, function_filter_syntax: None, @@ -371,6 +360,8 @@ impl_builtin_dialects!( supports_double_colon: true, trim_syntax: TrimSyntax::SqlFrom, collate_syntax: Some(CollateSyntax::FunctionString), + like_escape_syntax: LikeEscapeSyntax::Scalar, + bitwise_syntax: BitwiseSyntax::InfixAndShift, } } @@ -390,9 +381,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashHexZero, bytes_format: BytesFormat::ToBinary, - date_format: TemporalLiteral::TypedKeyword, - time_format: TemporalLiteral::Quoted, - timestamp_format: TemporalLiteral::TypedKeyword, json_format: JsonFormat::ParseJson, uuid_format: UuidFormat::Quoted, function_filter_syntax: None, @@ -409,6 +397,8 @@ impl_builtin_dialects!( supports_double_colon: true, trim_syntax: TrimSyntax::Function, collate_syntax: Some(CollateSyntax::FunctionString), + like_escape_syntax: LikeEscapeSyntax::IncludingAny, + bitwise_syntax: BitwiseSyntax::BitShiftFunctions, } } @@ -428,9 +418,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashUnicodeQuote, bytes_format: BytesFormat::PrefixBigX, - date_format: TemporalLiteral::TypedKeyword, - time_format: TemporalLiteral::Quoted, - timestamp_format: TemporalLiteral::TypedKeyword, json_format: JsonFormat::Quoted, uuid_format: UuidFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), @@ -447,6 +434,8 @@ impl_builtin_dialects!( supports_double_colon: true, trim_syntax: TrimSyntax::SqlFrom, collate_syntax: None, + like_escape_syntax: LikeEscapeSyntax::Scalar, + bitwise_syntax: BitwiseSyntax::Infix, } } @@ -466,9 +455,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::PrefixBigX, - date_format: TemporalLiteral::Quoted, - time_format: TemporalLiteral::Quoted, - timestamp_format: TemporalLiteral::Quoted, json_format: JsonFormat::Quoted, uuid_format: UuidFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), @@ -485,6 +471,8 @@ impl_builtin_dialects!( supports_double_colon: false, trim_syntax: TrimSyntax::Function, collate_syntax: Some(CollateSyntax::InfixIdent), + like_escape_syntax: LikeEscapeSyntax::Scalar, + bitwise_syntax: BitwiseSyntax::Infix, } } @@ -504,9 +492,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::PostfixXbv, - date_format: TemporalLiteral::TypedKeyword, - time_format: TemporalLiteral::Quoted, - timestamp_format: TemporalLiteral::TypedKeyword, json_format: JsonFormat::Quoted, uuid_format: UuidFormat::Quoted, function_filter_syntax: None, @@ -523,210 +508,8 @@ impl_builtin_dialects!( supports_double_colon: false, trim_syntax: TrimSyntax::SqlFrom, collate_syntax: None, + like_escape_syntax: LikeEscapeSyntax::Scalar, + bitwise_syntax: BitwiseSyntax::ShiftFunctions, } } - ); - -#[cfg(test)] -mod tests { - use super::*; - - fn expect( - dialect: Dialect, - concat: StringConcatSyntax, - xor: Option, - modulo: ModuloSyntax, - distinct: Option, - tuple: Option, - parenthesize_in_between: bool, - ) { - assert_eq!(dialect.string_concat_syntax, concat); - assert_eq!(dialect.bit_xor_syntax, xor); - assert_eq!(dialect.modulo_syntax, modulo); - assert_eq!(dialect.is_distinct_from_syntax, distinct); - assert_eq!(dialect.tuple_syntax, tuple); - assert_eq!(dialect.parenthesize_in_between, parenthesize_in_between); - } - - #[test] - fn operator_syntax_matrix() { - use BitXorSyntax::*; - use IsDistinctFromSyntax::*; - use ModuloSyntax::*; - use StringConcatSyntax::*; - use TupleSyntax::*; - - expect( - PyBigQuery::CONFIG, - Pipes, - Some(Caret), - Percent, - Some(Standard), - None, - false, - ); - expect( - PyDatabricks::CONFIG, - Pipes, - Some(Caret), - Percent, - Some(Standard), - Some(Struct), - false, - ); - expect( - PyDuckDb::CONFIG, - Pipes, - Some(XORFunction), - Percent, - Some(Standard), - Some(Row), - false, - ); - expect( - PyHive::CONFIG, - Pipes, - Some(Caret), - Percent, - Some(NullSafeEqual), - Some(Struct), - false, - ); - expect( - PyMsSql::CONFIG, - Plus, - Some(Caret), - Percent, - Some(Standard), - None, - true, - ); - expect( - PyMySql::CONFIG, - ConcatFunction, - Some(Caret), - Percent, - Some(NullSafeEqual), - Some(RowRejectEmpty), - false, - ); - expect(PyOracle::CONFIG, Pipes, None, Function, None, None, false); - expect( - PyPostgreSql::CONFIG, - Pipes, - Some(Hash), - Percent, - Some(Standard), - Some(Row), - false, - ); - expect(PyRedshift::CONFIG, Pipes, None, Percent, None, None, false); - expect( - PySnowflake::CONFIG, - Pipes, - Some(BitXORFunction), - Percent, - Some(Standard), - None, - false, - ); - expect( - PySpark::CONFIG, - Pipes, - Some(Caret), - Percent, - Some(Standard), - Some(Struct), - false, - ); - expect( - PySqlite::CONFIG, - Pipes, - None, - Percent, - Some(Standard), - Some(Parentheses), - false, - ); - expect( - PyTeradata::CONFIG, - Pipes, - Some(BitXORFunction), - Infix, - None, - None, - false, - ); - } - - #[test] - fn cast_trim_collate_matrix() { - use CollateSyntax::*; - use TrimSyntax::*; - use TryCastSyntax::*; - - fn expect( - dialect: Dialect, - try_cast: Option, - double_colon: bool, - trim: TrimSyntax, - collate: Option, - ) { - assert_eq!(dialect.try_cast_syntax, try_cast); - assert_eq!(dialect.supports_double_colon, double_colon); - assert_eq!(dialect.trim_syntax, trim); - assert_eq!(dialect.collate_syntax, collate); - } - - expect( - PyBigQuery::CONFIG, - Some(SafeCast), - false, - Function, - Some(FunctionString), - ); - expect( - PyDatabricks::CONFIG, - Some(TryCast), - true, - SqlFrom, - Some(InfixIdent), - ); - expect( - PyDuckDb::CONFIG, - Some(TryCast), - true, - Function, - Some(InfixIdent), - ); - expect(PyHive::CONFIG, None, false, SpacesOnly, None); - expect( - PyMsSql::CONFIG, - Some(TryCast), - false, - SqlFrom, - Some(InfixIdent), - ); - expect(PyMySql::CONFIG, None, false, SqlFrom, Some(InfixIdent)); - expect(PyOracle::CONFIG, None, false, SqlFrom, Some(InfixIdent)); - expect(PyPostgreSql::CONFIG, None, true, SqlFrom, Some(InfixIdent)); - expect( - PyRedshift::CONFIG, - Some(TryCast), - true, - SqlFrom, - Some(FunctionString), - ); - expect( - PySnowflake::CONFIG, - Some(TryCast), - true, - Function, - Some(FunctionString), - ); - expect(PySpark::CONFIG, Some(TryCast), true, SqlFrom, None); - expect(PySqlite::CONFIG, None, false, Function, Some(InfixIdent)); - expect(PyTeradata::CONFIG, Some(TryCastWord), false, SqlFrom, None); - } -} diff --git a/src/dialect/inner.rs b/src/dialect/inner.rs index 675dfe1..c24c25f 100644 --- a/src/dialect/inner.rs +++ b/src/dialect/inner.rs @@ -213,6 +213,44 @@ pub enum CollateSyntax { FunctionString, } +/// Where an inlined `LIKE` `ESCAPE` clause is legal. +/// +/// `escape: None` writes nothing. A set escape character on a form this +/// variant does not allow raises. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum LikeEscapeSyntax { + /// No `ESCAPE` clause (BigQuery; Hive, whose operator is `A [NOT] LIKE B`). + None, + /// Scalar `LIKE` and an `OR` expansion. Native `LIKE ANY` has no slot. + Scalar, + /// Scalar, `OR` expansion, and `LIKE ANY (p, …) ESCAPE`. + IncludingAny, +} + +/// How `&`, `|`, `~`, `<<`, and `>>` are spelled. +/// +/// [`crate::expr::enum_::BinaryOp::BitXor`] stays on [`BitXorSyntax`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BitwiseSyntax { + /// `& | ~ << >>`. + Infix, + /// `& << >>`. `|` and `~` raise. + /// + /// Redshift documents those three and not bitwise OR or NOT + /// (`^` is exponentiation and stays on [`BitXorSyntax`]). + InfixAndShift, + /// `& | ~`. Shifts raise (SQL Server). + InfixNoShift, + /// `BITAND` / `BITOR` / `BITNOT` / `BITSHIFTLEFT` / `BITSHIFTRIGHT`. + BitShiftFunctions, + /// `BITAND` / `BITOR` / `BITNOT` / `SHIFTLEFT` / `SHIFTRIGHT` (Teradata). + ShiftFunctions, + /// `BITAND` only. `|`, `~`, `<<`, and `>>` raise (Oracle). + BitAndOnly, + /// All five raise. + None, +} + /// How a tuple / row value is spelled. /// /// [`None`] on [`Dialect::tuple_syntax`] means unsupported. @@ -346,9 +384,7 @@ impl BytesFormat { } Self::PrefixBigBSlashX => { writer.write_str("B'")?; - for &b in bytes { - write_byte_hex_escape(writer, b)?; - } + write_bytes_slash_x(writer, bytes)?; writer.write_char('\'') } Self::HexToRaw => { @@ -381,9 +417,7 @@ impl BytesFormat { writer.write_str("''::BLOB") } else { writer.write_char('\'')?; - for &b in bytes { - write_byte_hex_escape(writer, b)?; - } + write_bytes_slash_x(writer, bytes)?; writer.write_str("'::BLOB") } } @@ -426,13 +460,6 @@ pub enum MacFormat { Cast, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum TemporalLiteral { - Quoted, - /// `DATE` / `TIME` / `TIMESTAMP` then a string literal. - TypedKeyword, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum JsonFormat { Quoted, @@ -482,9 +509,6 @@ pub struct Dialect { pub string_escape: StringEscape, /// How the dialect accepts literal bytes values. pub bytes_format: BytesFormat, - pub date_format: TemporalLiteral, - pub time_format: TemporalLiteral, - pub timestamp_format: TemporalLiteral, pub json_format: JsonFormat, pub uuid_format: UuidFormat, /// How function calling `FILTER` keyword rendered if supported. @@ -519,6 +543,10 @@ pub struct Dialect { pub trim_syntax: TrimSyntax, /// How `COLLATE` is spelled. [`None`] means unsupported. pub collate_syntax: Option, + /// Where `LIKE` `ESCAPE` may be written. + pub like_escape_syntax: LikeEscapeSyntax, + /// How `&` `|` `~` `<<` `>>` are spelled. XOR stays on [`Self::bit_xor_syntax`]. + pub bitwise_syntax: BitwiseSyntax, } impl Dialect { @@ -575,7 +603,7 @@ impl Dialect { utils::replace_to(ident, '"', "\\\"", writer)?; } IdentifierFormat::Backtick => { - utils::replace_to(ident, '`', "\\`", writer)?; + write_bigquery_ident_body(writer, ident)?; } IdentifierFormat::SquareBracket => { utils::replace_to(ident, ']', "\\]", writer)?; @@ -597,13 +625,6 @@ impl Dialect { return Err(FormatError::NulInString); } - if matches!(self.string_format, StringFormat::PrefixBigE) && !needs_c_escape(raw) { - writer.write_char('\'')?; - StringEscape::Quote.write(writer, raw)?; - writer.write_char('\'')?; - return Ok(()); - } - match self.string_format { StringFormat::PrefixBigE => writer.write_str("E'")?, StringFormat::PrefixBigN => writer.write_str("N'")?, @@ -645,12 +666,6 @@ impl Dialect { } } -#[inline] -fn needs_c_escape(s: &str) -> bool { - s.chars() - .any(|c| matches!(c, '\\' | '\n' | '\r' | '\t') || (c as u32) < 32 || c as u32 == 127) -} - const HEX_DIGITS: &[u8; 16] = b"0123456789ABCDEF"; /// Write `byte` as two uppercase hex digits. @@ -665,10 +680,41 @@ fn write_hex2(writer: &mut W, byte: u8) -> fmt::Result { } /// Write `bytes` as a contiguous uppercase hex run (`DEADBEEF`). +/// +/// 64 raw bytes fill a 128-byte stack buffer and are written once. #[inline] fn bytes_to_hex(writer: &mut W, bytes: &[u8]) -> fmt::Result { - for &b in bytes { - write_hex2(writer, b)?; + let mut buf = [0u8; 128]; + for chunk in bytes.chunks(64) { + for (i, &byte) in chunk.iter().enumerate() { + let o = i * 2; + buf[o] = HEX_DIGITS[(byte >> 4) as usize]; + buf[o + 1] = HEX_DIGITS[(byte & 0x0F) as usize]; + } + let n = chunk.len() * 2; + // SAFETY: `buf[..n]` is filled only from `HEX_DIGITS` (ASCII). + writer.write_str(unsafe { std::str::from_utf8_unchecked(&buf[..n]) })?; + } + Ok(()) +} + +/// Write each byte as `\xNN` (uppercase hex). +/// +/// 32 raw bytes fill a 128-byte stack buffer and are written once. +#[inline] +fn write_bytes_slash_x(writer: &mut W, bytes: &[u8]) -> fmt::Result { + let mut buf = [0u8; 128]; + for chunk in bytes.chunks(32) { + for (i, &byte) in chunk.iter().enumerate() { + let o = i * 4; + buf[o] = b'\\'; + buf[o + 1] = b'x'; + buf[o + 2] = HEX_DIGITS[(byte >> 4) as usize]; + buf[o + 3] = HEX_DIGITS[(byte & 0x0F) as usize]; + } + let n = chunk.len() * 4; + // SAFETY: `buf[..n]` is ASCII (`\`, `x`, and hex digits). + writer.write_str(unsafe { std::str::from_utf8_unchecked(&buf[..n]) })?; } Ok(()) } @@ -689,83 +735,133 @@ fn write_unicode4_escape(writer: &mut W, code: u32) -> fmt::Result { write_hex2(writer, code as u8) } +/// Write `bytes[from..to]` as a string. +/// +/// # Safety invariant +/// `bytes` is the raw view of a `&str`. Callers split only on ASCII bytes, +/// which are char boundaries, or at `bytes.len()`. +#[inline] +fn write_byte_span(writer: &mut W, bytes: &[u8], from: usize, to: usize) -> fmt::Result { + if from == to { + return Ok(()); + } + // SAFETY: see invariant above. + let chunk = unsafe { bytes.get_unchecked(from..to) }; + writer.write_str(unsafe { std::str::from_utf8_unchecked(chunk) }) +} + +enum SpanEscape { + Keep, + Literal(&'static str), + HexByte, + Unicode4, +} + +/// Copy each plain span with one `write_str`. Special bytes are ASCII. #[cfg_attr(feature = "nightly", optimize(speed))] -fn write_backslash_hex(writer: &mut W, string: &str) -> fmt::Result { - for ch in string.chars() { - match ch { - '\\' => writer.write_str("\\\\")?, - '\'' => writer.write_str("\\'")?, - '\n' => writer.write_str("\\n")?, - '\r' => writer.write_str("\\r")?, - '\t' => writer.write_str("\\t")?, - '\0' => writer.write_str("\\x00")?, - c if (c as u32) < 32 || c as u32 == 127 => { - write_byte_hex_escape(writer, c as u8)?; +fn write_span_escapes( + writer: &mut W, + string: &str, + mut classify: impl FnMut(u8) -> SpanEscape, +) -> fmt::Result { + let bytes = string.as_bytes(); + let mut start = 0; + let mut i = 0; + while i < bytes.len() { + match classify(bytes[i]) { + SpanEscape::Keep => i += 1, + SpanEscape::Literal(repl) => { + write_byte_span(writer, bytes, start, i)?; + writer.write_str(repl)?; + i += 1; + start = i; + } + SpanEscape::HexByte => { + write_byte_span(writer, bytes, start, i)?; + write_byte_hex_escape(writer, bytes[i])?; + i += 1; + start = i; + } + SpanEscape::Unicode4 => { + write_byte_span(writer, bytes, start, i)?; + write_unicode4_escape(writer, bytes[i] as u32)?; + i += 1; + start = i; } - c => writer.write_char(c)?, } } - Ok(()) + write_byte_span(writer, bytes, start, bytes.len()) +} + +/// BigQuery quoted identifiers use the string escape table, plus `\``. +/// +/// `'` is left as itself. Only known escapes are emitted. +fn write_bigquery_ident_body(writer: &mut W, ident: &str) -> fmt::Result { + write_span_escapes(writer, ident, |b| match b { + b'\\' => SpanEscape::Literal("\\\\"), + b'`' => SpanEscape::Literal("\\`"), + b'\n' => SpanEscape::Literal("\\n"), + b'\r' => SpanEscape::Literal("\\r"), + b'\t' => SpanEscape::Literal("\\t"), + b if b < 32 || b == 127 => SpanEscape::HexByte, + _ => SpanEscape::Keep, + }) +} + +fn write_backslash_hex(writer: &mut W, string: &str) -> fmt::Result { + write_span_escapes(writer, string, |b| match b { + b'\\' => SpanEscape::Literal("\\\\"), + b'\'' => SpanEscape::Literal("\\'"), + b'\n' => SpanEscape::Literal("\\n"), + b'\r' => SpanEscape::Literal("\\r"), + b'\t' => SpanEscape::Literal("\\t"), + b if b < 32 || b == 127 => SpanEscape::HexByte, + _ => SpanEscape::Keep, + }) } -#[cfg_attr(feature = "nightly", optimize(speed))] fn write_backslash_mysql(writer: &mut W, string: &str) -> fmt::Result { - for ch in string.chars() { - match ch { - '\\' => writer.write_str("\\\\")?, - '\'' => writer.write_str("\\'")?, - '"' => writer.write_str("\\\"")?, - '\0' => writer.write_str("\\0")?, - '\n' => writer.write_str("\\n")?, - '\r' => writer.write_str("\\r")?, - '\x1a' => writer.write_str("\\Z")?, - c => writer.write_char(c)?, - } - } - Ok(()) + write_span_escapes(writer, string, |b| match b { + b'\\' => SpanEscape::Literal("\\\\"), + b'\'' => SpanEscape::Literal("\\'"), + b'"' => SpanEscape::Literal("\\\""), + 0 => SpanEscape::Literal("\\0"), + b'\n' => SpanEscape::Literal("\\n"), + b'\r' => SpanEscape::Literal("\\r"), + 0x1a => SpanEscape::Literal("\\Z"), + _ => SpanEscape::Keep, + }) } -#[cfg_attr(feature = "nightly", optimize(speed))] fn write_backslash_unicode( writer: &mut W, string: &str, escape_double_quote: bool, ) -> fmt::Result { - for ch in string.chars() { - match ch { - '\\' => writer.write_str("\\\\")?, - '\'' => writer.write_str("\\'")?, - '"' if escape_double_quote => writer.write_str("\\\"")?, - '\n' => writer.write_str("\\n")?, - '\r' => writer.write_str("\\r")?, - '\t' => writer.write_str("\\t")?, - '\0' => writer.write_str("\\0")?, - c if (c as u32) < 32 || c as u32 == 127 => { - write_unicode4_escape(writer, c as u32)?; - } - c => writer.write_char(c)?, - } - } - Ok(()) + write_span_escapes(writer, string, |b| match b { + b'\\' => SpanEscape::Literal("\\\\"), + b'\'' => SpanEscape::Literal("\\'"), + b'"' if escape_double_quote => SpanEscape::Literal("\\\""), + b'\n' => SpanEscape::Literal("\\n"), + b'\r' => SpanEscape::Literal("\\r"), + b'\t' => SpanEscape::Literal("\\t"), + 0 => SpanEscape::Literal("\\0"), + b if b < 32 || b == 127 => SpanEscape::Unicode4, + _ => SpanEscape::Keep, + }) } -#[cfg_attr(feature = "nightly", optimize(speed))] fn write_backslash_hex_zero(writer: &mut W, string: &str) -> fmt::Result { - for ch in string.chars() { - match ch { - '\\' => writer.write_str("\\\\")?, - '\'' => writer.write_str("\\'")?, - '\n' => writer.write_str("\\n")?, - '\r' => writer.write_str("\\r")?, - '\t' => writer.write_str("\\t")?, - '\0' => writer.write_str("\\0")?, - c if (c as u32) < 32 || c as u32 == 127 => { - write_byte_hex_escape(writer, c as u8)?; - } - c => writer.write_char(c)?, - } - } - Ok(()) + write_span_escapes(writer, string, |b| match b { + b'\\' => SpanEscape::Literal("\\\\"), + b'\'' => SpanEscape::Literal("\\'"), + b'\n' => SpanEscape::Literal("\\n"), + b'\r' => SpanEscape::Literal("\\r"), + b'\t' => SpanEscape::Literal("\\t"), + 0 => SpanEscape::Literal("\\0"), + b if b < 32 || b == 127 => SpanEscape::HexByte, + _ => SpanEscape::Keep, + }) } #[cfg(test)] @@ -807,9 +903,6 @@ mod tests { string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::PrefixBigX, - date_format: TemporalLiteral::Quoted, - time_format: TemporalLiteral::Quoted, - timestamp_format: TemporalLiteral::Quoted, json_format: JsonFormat::Quoted, uuid_format: UuidFormat::Quoted, function_filter_syntax: None, @@ -826,6 +919,8 @@ mod tests { supports_double_colon: false, trim_syntax: TrimSyntax::SqlFrom, collate_syntax: None, + like_escape_syntax: LikeEscapeSyntax::None, + bitwise_syntax: BitwiseSyntax::None, } } @@ -1009,4 +1104,110 @@ mod tests { "E'hello\\'world\\n'" ); } + + struct CountWrites { + buf: String, + strs: usize, + chars: usize, + } + + impl fmt::Write for CountWrites { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.strs += 1; + self.buf.push_str(s); + Ok(()) + } + + fn write_char(&mut self, c: char) -> fmt::Result { + self.chars += 1; + self.buf.push(c); + Ok(()) + } + } + + #[test] + fn backslash_plain_string_is_one_write_str() { + for kind in [ + StringEscape::BackslashHex, + StringEscape::BackslashMysql, + StringEscape::BackslashUnicode, + StringEscape::BackslashHexZero, + StringEscape::BackslashUnicodeQuote, + ] { + let mut out = CountWrites { + buf: String::new(), + strs: 0, + chars: 0, + }; + kind.write(&mut out, "plain text").unwrap(); + assert_eq!(out.strs, 1, "{kind:?}"); + assert_eq!(out.chars, 0, "{kind:?}"); + assert_eq!(out.buf, "plain text"); + + let mut mixed = CountWrites { + buf: String::new(), + strs: 0, + chars: 0, + }; + kind.write(&mut mixed, "a'b\\c").unwrap(); + assert_eq!(mixed.chars, 0, "{kind:?} wrote a char at a time"); + assert_eq!(mixed.buf, escape(kind, "a'b\\c")); + } + } + + fn quote_bigquery(name: &str) -> String { + let mut cfg = base(); + cfg.identifier_format = IdentifierFormat::Backtick; + cfg.identifier_escape = IdentifierEscape::Backslash; + collect_fmt(|buf| cfg.quote_identifier(buf, name)) + } + + #[test] + fn bigquery_identifier_escapes_backslash() { + assert_eq!(quote_bigquery(r"a\b"), r"`a\\b`"); + assert_eq!(quote_bigquery("a\\`b"), r"`a\\\`b`"); + assert_eq!(quote_bigquery(r"a\"), r"`a\\`"); + assert_eq!(quote_bigquery("a\nb"), r"`a\nb`"); + assert_eq!(quote_bigquery("a\rb"), r"`a\rb`"); + assert_eq!(quote_bigquery("a\tb"), r"`a\tb`"); + assert_eq!(quote_bigquery("\u{1}"), r"`\x01`"); + assert_eq!(quote_bigquery("\0"), r"`\x00`"); + assert_eq!(quote_bigquery("\u{7f}"), r"`\x7F`"); + assert_eq!(quote_bigquery("a'b"), "`a'b`"); + assert_eq!(quote_bigquery("`"), r"`\``"); + + let mut out = CountWrites { + buf: String::new(), + strs: 0, + chars: 0, + }; + let mut cfg = base(); + cfg.identifier_format = IdentifierFormat::Backtick; + cfg.identifier_escape = IdentifierEscape::Backslash; + cfg.quote_identifier(&mut out, "plain").unwrap(); + assert_eq!(out.buf, "`plain`"); + assert_eq!(out.strs, 1); + assert_eq!(out.chars, 2); + } + + #[test] + fn bytes_longer_than_one_chunk() { + let long: Vec = (0..80).map(|i| (i * 3) as u8).collect(); + let hex: String = long.iter().map(|b| format!("{b:02X}")).collect(); + let xnn: String = long.iter().map(|b| format!("\\x{b:02X}")).collect(); + assert_eq!(bytes(BytesFormat::PrefixBigX, &long), format!("X'{hex}'")); + assert_eq!(bytes(BytesFormat::PrefixZeroX, &long), format!("0x{hex}")); + assert_eq!( + bytes(BytesFormat::PrefixBigBSlashX, &long), + format!("B'{xnn}'") + ); + assert_eq!( + bytes(BytesFormat::SlashXBlob, &long), + format!("'{xnn}'::BLOB") + ); + assert_eq!( + bytes(BytesFormat::SlashXBytea, &long), + format!("'\\x{hex}'::bytea") + ); + } } diff --git a/src/expr/enum_/func.rs b/src/expr/enum_/func.rs index 4d132fc..bd4dbc0 100644 --- a/src/expr/enum_/func.rs +++ b/src/expr/enum_/func.rs @@ -79,7 +79,8 @@ impl WriteSql for Function { dialect: &crate::dialect::PySqlDialect, out: &mut W, ) -> PrepareResult<()> { - // "name"([DISTINCT] args [IGNORE|RESPECT NULLS] [WHERE expr] ) [IGNORE|RESPECT NULLS] [FILTER ([WHERE] expr)] + // "name"([DISTINCT] args [IGNORE|RESPECT NULLS] [WHERE expr]) + // [FILTER ([WHERE] expr)] [IGNORE|RESPECT NULLS] self.name.write_sql(dialect, out)?; let args_available = !self.args.is_empty(); @@ -129,18 +130,7 @@ impl WriteSql for Function { out.push_char(')'); - // Postfix IGNORE/RESPECT NULLS - if let Some(x) = self.null_treatment - && matches!( - dialect.0.null_treatment_syntax, - Some(NullTreatmentSyntax::Postfix) - ) - { - out.push_char(' '); - out.push_str(x.as_sql()); - } - - // Postfix WHERE filter + // FILTER, then postfix IGNORE/RESPECT NULLS (immediately before OVER). if let Some(x) = &self.filter { match dialect.0.function_filter_syntax { Some(FunctionFilterSyntax::FilterWhere) => { @@ -159,6 +149,16 @@ impl WriteSql for Function { } } + if let Some(x) = self.null_treatment + && matches!( + dialect.0.null_treatment_syntax, + Some(NullTreatmentSyntax::Postfix) + ) + { + out.push_char(' '); + out.push_str(x.as_sql()); + } + Ok(()) } } diff --git a/src/expr/enum_/like.rs b/src/expr/enum_/like.rs index cf6c30a..ca2a613 100644 --- a/src/expr/enum_/like.rs +++ b/src/expr/enum_/like.rs @@ -3,7 +3,7 @@ use super::ExprInner; use crate::dialect::{ PySqlDialect, - inner::{LikeAnySyntax, RLikeExpressionOptions}, + inner::{LikeAnySyntax, LikeEscapeSyntax, RLikeExpressionOptions}, }; use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; use std::fmt::Write; @@ -58,13 +58,16 @@ impl LikePattern { /// /// `case_sensitive == false` follows the dialect's `ilike_expression_options` /// (`ILIKE`, plain `LIKE`, or `LOWER(expr) LIKE LOWER(pattern)`). `ESCAPE` is -/// a single inlined character (never a bind). +/// a single inlined character (never a bind). A set escape character raises +/// when [`LikeEscapeSyntax`] does not allow it on that form. /// /// LIKE ANY follows [`LikeAnySyntax`]: Postgres `ANY (ARRAY[…])` wraps /// negation as `NOT (x LIKE ANY …)` because `NOT LIKE ANY` is a quantified /// NOT LIKE; Snowflake/Spark/Databricks use `LIKE ANY (p, …)`; everyone else /// expands to parenthesized `OR` of scalar `LIKE`s (`AND` of `NOT LIKE` when -/// negated). +/// negated). Snowflake appends `ESCAPE` after the pattern list, inside that +/// negation wrap. Postgres, Spark, and Databricks have no `ESCAPE` slot on +/// native `LIKE ANY`. #[derive(Clone, Debug, PartialEq)] pub struct Like { pub case_sensitive: bool, @@ -146,7 +149,39 @@ fn write_like_pattern_list( } } -/// `[NOT] (x LIKE|ILIKE ANY (ARRAY?[…]))` +/// `ESCAPE` is legal on this form. `native_any` is `LIKE ANY` that is not an +/// `OR` expansion. +#[inline] +fn like_escape_allowed(syntax: LikeEscapeSyntax, native_any: bool) -> bool { + match syntax { + LikeEscapeSyntax::None => false, + LikeEscapeSyntax::Scalar => !native_any, + LikeEscapeSyntax::IncludingAny => true, + } +} + +#[inline] +fn ensure_like_escape_is_supported( + syntax: LikeEscapeSyntax, + escape: Option, + native_any: bool, +) -> PrepareResult<()> { + if escape.is_none() || like_escape_allowed(syntax, native_any) { + return Ok(()); + } + + Err(rapidquery_macros::pyerr!( + PyRuntimeError, + if native_any { + "LIKE ANY ESCAPE is not supported by this dialect" + } else { + "LIKE ESCAPE is not supported by this dialect" + } + ) + .into()) +} + +/// `[NOT] (x LIKE|ILIKE ANY (ARRAY?[…]) [ESCAPE char])` fn write_like_any_native( dialect: &PySqlDialect, out: &mut W, @@ -173,6 +208,7 @@ fn write_like_any_native( } else { out.push_char(')'); } + write_escape(dialect, out, like.escape)?; if like.negated { out.push_char(')'); } @@ -187,6 +223,13 @@ impl WriteSql for Like { dialect.0.ilike_expression_options.case_insensitive_sql() }; + ensure_like_escape_is_supported( + dialect.0.like_escape_syntax, + self.escape, + matches!(self.pattern, LikePattern::Any(_)) + && !matches!(dialect.0.like_any_syntax, LikeAnySyntax::OrExpansion), + )?; + match &self.pattern { LikePattern::One(pattern) => { write_like_operand(dialect, out, &self.expr, wrap_lower, false)?; diff --git a/src/expr/enum_/mod.rs b/src/expr/enum_/mod.rs index 296eb66..b337bed 100644 --- a/src/expr/enum_/mod.rs +++ b/src/expr/enum_/mod.rs @@ -105,8 +105,8 @@ impl ExprInner { match self { Self::Ident(_) | Self::Value(_) | Self::Function(_) => 0, Self::Tuple(_) => Tuple::PRECEDENCE, - Self::Unary(_) => Unary::PRECEDENCE, - Self::Binary(b) => b.precedence(), + Self::Unary(u) => u.op.precedence(), + Self::Binary(b) => b.op.precedence(), Self::Is(_) => Is::PRECEDENCE, Self::InList(_) => InList::PRECEDENCE, Self::IsDistinctFrom(_) => IsDistinctFrom::PRECEDENCE, diff --git a/src/expr/enum_/nodes.rs b/src/expr/enum_/nodes.rs index a761d62..66c0224 100644 --- a/src/expr/enum_/nodes.rs +++ b/src/expr/enum_/nodes.rs @@ -1,8 +1,8 @@ use super::{BinaryOp, CastKind, ExprInner, Ident, IsKind, TrimWhere, UnaryOp, UnarySpacing}; use crate::dialect::PySqlDialect; use crate::dialect::inner::{ - BitXorSyntax, CollateSyntax, IsDistinctFromSyntax, ModuloSyntax, StringConcatSyntax, - TrimSyntax, TupleSyntax, + BitXorSyntax, BitwiseSyntax, CollateSyntax, IsDistinctFromSyntax, ModuloSyntax, + StringConcatSyntax, TrimSyntax, TupleSyntax, }; use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; use std::fmt::Write; @@ -79,9 +79,11 @@ impl WriteSql for Tuple { } } -/// Unary operation (`NOT x`, `-x`, `|/x`, postfix `x!`). +/// Unary operation (`NOT x`, `-x`, `~x`). /// -/// Precedence 1. Nested equal-precedence operands are parenthesized. +/// `NOT` binds between comparisons and `AND`. `+`, `-`, and `~` bind tighter +/// than multiplication. Nested equal-precedence operands are parenthesized. +/// `~` is `BITNOT(expr)` where the dialect's bitwise profile says so. #[derive(Clone, Debug, PartialEq)] pub struct Unary { pub op: UnaryOp, @@ -89,8 +91,6 @@ pub struct Unary { } impl Unary { - pub(crate) const PRECEDENCE: u8 = 1; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { Ok(format!( "UnaryExpr({:?}, {})", @@ -102,17 +102,38 @@ impl Unary { impl WriteSql for Unary { fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + if self.op == UnaryOp::BitNot { + match dialect.0.bitwise_syntax { + BitwiseSyntax::Infix | BitwiseSyntax::InfixNoShift => { + out.push_char('~'); + return self.expr.write_operand( + dialect, + out, + UnaryOp::BitNot.precedence(), + true, + ); + } + BitwiseSyntax::BitShiftFunctions | BitwiseSyntax::ShiftFunctions => { + out.push_str("BITNOT("); + self.expr.write_sql(dialect, out)?; + out.push_char(')'); + return Ok(()); + } + BitwiseSyntax::BitAndOnly | BitwiseSyntax::None | BitwiseSyntax::InfixAndShift => { + return Err(unsupported("bitwise NOT is not supported by this dialect")); + } + } + } + let prec = self.op.precedence(); match self.op.spacing() { UnarySpacing::SpacedPrefix => { out.push_str(self.op.as_sql()); out.push_char(' '); - self.expr - .write_operand(dialect, out, Self::PRECEDENCE, true) + self.expr.write_operand(dialect, out, prec, true) } UnarySpacing::GluedPrefix => { out.push_str(self.op.as_sql()); - self.expr - .write_operand(dialect, out, Self::PRECEDENCE, true) + self.expr.write_operand(dialect, out, prec, true) } } } @@ -130,11 +151,6 @@ pub struct Binary { } impl Binary { - #[inline] - pub(crate) fn precedence(&self) -> u8 { - self.op.precedence() - } - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { Ok(format!( "BinaryExpr({}, {:?}, {})", @@ -185,7 +201,7 @@ fn write_infix( spelling: &str, paren_arithmetic: bool, ) -> PrepareResult<()> { - let prec = bin.precedence(); + let prec = bin.op.precedence(); write_infix_side(dialect, out, &bin.left, prec, false, paren_arithmetic)?; out.push_char(' '); out.push_str(spelling); @@ -239,6 +255,62 @@ impl WriteSql for Binary { } ModuloSyntax::Infix => write_infix(self, dialect, out, "MOD", false), }, + BinaryOp::BitAnd => match dialect.0.bitwise_syntax { + BitwiseSyntax::Infix + | BitwiseSyntax::InfixNoShift + | BitwiseSyntax::InfixAndShift => write_infix(self, dialect, out, "&", false), + BitwiseSyntax::BitShiftFunctions + | BitwiseSyntax::ShiftFunctions + | BitwiseSyntax::BitAndOnly => { + write_binary_call(dialect, out, "BITAND", &self.left, &self.right) + } + BitwiseSyntax::None => { + Err(unsupported("bitwise AND is not supported by this dialect")) + } + }, + BinaryOp::BitOr => match dialect.0.bitwise_syntax { + BitwiseSyntax::Infix | BitwiseSyntax::InfixNoShift => { + write_infix(self, dialect, out, "|", false) + } + BitwiseSyntax::BitShiftFunctions | BitwiseSyntax::ShiftFunctions => { + write_binary_call(dialect, out, "BITOR", &self.left, &self.right) + } + BitwiseSyntax::BitAndOnly | BitwiseSyntax::None | BitwiseSyntax::InfixAndShift => { + Err(unsupported("bitwise OR is not supported by this dialect")) + } + }, + BinaryOp::LShift => match dialect.0.bitwise_syntax { + BitwiseSyntax::Infix | BitwiseSyntax::InfixAndShift => { + write_infix(self, dialect, out, "<<", false) + } + BitwiseSyntax::BitShiftFunctions => { + write_binary_call(dialect, out, "BITSHIFTLEFT", &self.left, &self.right) + } + BitwiseSyntax::ShiftFunctions => { + write_binary_call(dialect, out, "SHIFTLEFT", &self.left, &self.right) + } + BitwiseSyntax::InfixNoShift | BitwiseSyntax::BitAndOnly | BitwiseSyntax::None => { + Err(unsupported( + "bitwise shift is not supported by this dialect", + )) + } + }, + BinaryOp::RShift => match dialect.0.bitwise_syntax { + BitwiseSyntax::Infix | BitwiseSyntax::InfixAndShift => { + write_infix(self, dialect, out, ">>", false) + } + BitwiseSyntax::BitShiftFunctions => { + write_binary_call(dialect, out, "BITSHIFTRIGHT", &self.left, &self.right) + } + BitwiseSyntax::ShiftFunctions => { + write_binary_call(dialect, out, "SHIFTRIGHT", &self.left, &self.right) + } + BitwiseSyntax::InfixNoShift | BitwiseSyntax::BitAndOnly | BitwiseSyntax::None => { + Err(unsupported( + "bitwise shift is not supported by this dialect", + )) + } + }, _ => write_infix(self, dialect, out, self.op.as_sql(), false), } } diff --git a/src/expr/enum_/operator.rs b/src/expr/enum_/operator.rs index 95c5a35..671e997 100644 --- a/src/expr/enum_/operator.rs +++ b/src/expr/enum_/operator.rs @@ -49,6 +49,15 @@ impl UnaryOp { Self::Plus | Self::Minus | Self::BitNot => UnarySpacing::GluedPrefix, } } + + /// Lower is tighter binding. `NOT` sits between comparisons and `AND`. + #[inline] + pub(crate) fn precedence(self) -> u8 { + match self { + Self::Not => 6, + Self::Plus | Self::Minus | Self::BitNot => 1, + } + } } /// Binary operator. Unknown spellings become [`BinaryOp::Custom`]. @@ -153,8 +162,8 @@ impl BinaryOp { | Self::Gt | Self::GtEq | Self::Custom(_) => 5, - Self::And => 6, - Self::Or => 8, + Self::And => 7, + Self::Or => 9, } } } diff --git a/src/lib.rs b/src/lib.rs index 34d6efb..ef206d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,16 +9,17 @@ pub mod sql; pub mod typeref; // Tasks until being stable (until this part) -// - Raise error when something is not supported by a dialect +// - Clean code +// - Restore GenericDialect ( very helpful for testing ) // - Test and coverage everything and edge cases -// - Update docstrings, clean code, update Rust docs, add fmt/lint/coding-style rules +// - Update docstrings / doccomments +// - Add fmt/lint/coding-style rules // Later tasks: // - Complete `Expr` methods // - Test `Expr` methods // - Benchmark and compare with old version // - Open PR -// - Restore GenericDialect // - Start writing query ASTs. /// RapidQuery native extension. diff --git a/src/sql.rs b/src/sql.rs index a44f6db..877f8d6 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -5,6 +5,8 @@ //! [`SqlWriter::push_value`]: raw inlines a literal, prepared emits a //! placeholder. +use std::fmt::Write; + use pyo3::types::PyAnyMethods; use crate::data_type::SerializerEnum; @@ -282,8 +284,8 @@ impl ParameterizedSql<'_> { self.sql.push(c); } PositionalParameter::Numbered(c) => { - let p = format!("{}{}", c, self.next_id); - self.sql.push_str(p.as_str()); + self.sql.push(c); + let _ = write!(self.sql, "{}", self.next_id); self.next_id += 1; } } diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 2c66ef1..69cbf15 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -203,7 +203,7 @@ class TestJSONSerializer: def test_dumps_then_json_format(self): s = JSONSerializer() assert s.serialize(PG, 10) == "'10'::jsonb" - assert s.serialize(PG, "10") == '\'"10"\'::jsonb' + assert s.serialize(PG, "10") == "'\"10\"'::jsonb" assert s.serialize(BigQuery(), {"a": 1}) == "JSON '{\"a\":1}'" assert s.serialize(Snowflake(), {"a": 1}) == "PARSE_JSON('{\"a\":1}')" assert s.serialize(Sqlite(), {"a": 1}) == "'{\"a\":1}'" @@ -213,13 +213,13 @@ def test_orjson_only_values(self): extras = [ ( datetime.datetime(2024, 10, 1, tzinfo=datetime.timezone.utc), - '\'"2024-10-01T00:00:00+00:00"\'::jsonb', + "'\"2024-10-01T00:00:00+00:00\"'::jsonb", ), ( uuid.UUID("103596b1-db75-4ae0-907a-a4a9bc4dab85"), - '\'"103596b1-db75-4ae0-907a-a4a9bc4dab85"\'::jsonb', + "'\"103596b1-db75-4ae0-907a-a4a9bc4dab85\"'::jsonb", ), - (ExampleDataclass(x=1), '\'{"x":1}\'::jsonb'), + (ExampleDataclass(x=1), "'{\"x\":1}'::jsonb"), ] for obj, expected in extras: if USING_ORJSON: @@ -245,7 +245,9 @@ def test_date_rejects_datetime_and_str(self): def test_time(self): s = TimeSerializer() assert s.serialize(PG, datetime.time(13, 45, 30)) == "TIME '13:45:30'" - assert s.serialize(PG, datetime.time(1, 2, 3, 456789)) == "TIME '01:02:03.456789'" + assert ( + s.serialize(PG, datetime.time(1, 2, 3, 456789)) == "TIME '01:02:03.456789'" + ) assert s.serialize(Oracle(), datetime.time(13, 45, 30)) == "'13:45:30'" def test_time_rejects_date(self): From 2c7f5edbce199895c9a8138581bb1b25e6617725 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Wed, 30 Sep 2026 20:15:41 +0330 Subject: [PATCH 57/87] Remove dialect-specific interned data types to remove magic behaviour --- src/data_type/name.rs | 88 ++++++++++--------------------------------- 1 file changed, 19 insertions(+), 69 deletions(-) diff --git a/src/data_type/name.rs b/src/data_type/name.rs index cb906ec..37831a0 100644 --- a/src/data_type/name.rs +++ b/src/data_type/name.rs @@ -25,10 +25,12 @@ pub enum DataTypeGroup { Custom, } -/// Canonical SQL type spelling plus its [`DataTypeGroup`]. +/// SQL type spelling plus its [`DataTypeGroup`]. /// -/// Known names are interned (`arcstr::literal!`). Unknown and parameterized +/// Known names are interned (`arcstr::literal!`) under that same spelling +/// (case-folded to the traditional uppercase form). Unknown and parameterized /// names keep the caller's spelling and are [`DataTypeGroup::Custom`]. +/// Synonyms are not rewritten to a different name. #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] pub struct DataTypeName { name: arcstr::ArcStr, @@ -47,7 +49,7 @@ impl DataTypeName { } } -/// Longest interned alias is `"timestamp without timezone"` (26 bytes). +/// Longest interned name is `"timestamp with timezone"` (23 bytes). const LOOKUP_BUF: usize = 32; fn interned(value: &str) -> Option { @@ -63,24 +65,13 @@ fn interned(value: &str) -> Option { lookup_alias(lower) } -fn custom_from_str(value: &str) -> DataTypeName { +fn custom_name>(value: T) -> DataTypeName { DataTypeName { - name: arcstr::ArcStr::from(value), + name: value.into(), group: DataTypeGroup::Custom, } } -fn custom_from_string(value: String) -> DataTypeName { - DataTypeName { - name: arcstr::ArcStr::from(value), - group: DataTypeGroup::Custom, - } -} - -fn is_parameterized(value: &str) -> bool { - value.as_bytes().contains(&b'(') -} - // Interned no-arg types. `arcstr::literal!` shares one allocation per spelling. macro_rules! simple_data_types { ( @@ -112,19 +103,11 @@ simple_data_types! { // --- String --- (CHARACTER_DATA_TYPE => ("CHARACTER", String), "character"); (CHAR_DATA_TYPE => ("CHAR", String), "char"); - (CHARACTER_VARYING_DATA_TYPE => ("CHARACTER VARYING", String), "character varying" | "characater varying"); + (CHARACTER_VARYING_DATA_TYPE => ("CHARACTER VARYING", String), "character varying"); (CHAR_VARYING_DATA_TYPE => ("CHAR VARYING", String), "char varying"); (VARCHAR_DATA_TYPE => ("VARCHAR", String), "varchar"); (NVARCHAR_DATA_TYPE => ("NVARCHAR", String), "nvarchar"); - (CHARACTER_LARGE_OBJECT_DATA_TYPE => ("CHARACTER LARGE OBJECT", String), "character large object"); - (CHAR_LARGE_OBJECT_DATA_TYPE => ("CHAR LARGE OBJECT", String), "char large object"); - (CLOB_DATA_TYPE => ("CLOB", String), "clob"); - (REGCLASS_DATA_TYPE => ("REGCLASS", String), "regclass"); (TEXT_DATA_TYPE => ("TEXT", String), "text"); - (TINYTEXT_DATA_TYPE => ("TINYTEXT", String), "tinytext"); - (MEDIUMTEXT_DATA_TYPE => ("MEDIUMTEXT", String), "mediumtext"); - (LONGTEXT_DATA_TYPE => ("LONGTEXT", String), "longtext"); - (STRING_DATA_TYPE => ("STRING", String), "string"); // --- Uuid --- (UUID_DATA_TYPE => ("UUID", Uuid), "uuid"); @@ -133,74 +116,42 @@ simple_data_types! { (BINARY_DATA_TYPE => ("BINARY", Bytes), "binary"); (VARBINARY_DATA_TYPE => ("VARBINARY", Bytes), "varbinary"); (BLOB_DATA_TYPE => ("BLOB", Bytes), "blob"); - (TINYBLOB_DATA_TYPE => ("TINYBLOB", Bytes), "tinyblob"); - (MEDIUMBLOB_DATA_TYPE => ("MEDIUMBLOB", Bytes), "mediumblob"); - (LONGBLOB_DATA_TYPE => ("LONGBLOB", Bytes), "longblob"); - (BYTES_DATA_TYPE => ("BYTES", Bytes), "bytes"); (BYTEA_DATA_TYPE => ("BYTEA", Bytes), "bytea"); // --- Bit --- (BIT_DATA_TYPE => ("BIT", Bit), "bit"); (BIT_VARYING_DATA_TYPE => ("BIT VARYING", Bit), "bit varying"); - (VARBIT_DATA_TYPE => ("VARBIT", Bit), "varbit"); // --- Float (includes decimal/numeric) --- (NUMERIC_DATA_TYPE => ("NUMERIC", Float), "numeric"); (DECIMAL_DATA_TYPE => ("DECIMAL", Float), "decimal"); - (BIGNUMERIC_DATA_TYPE => ("BIGNUMERIC", Float), "bignumeric"); - (BIGDECIMAL_DATA_TYPE => ("BIGDECIMAL", Float), "bigdecimal"); (DEC_DATA_TYPE => ("DEC", Float), "dec"); (FLOAT_DATA_TYPE => ("FLOAT", Float), "float"); - (FLOAT4_DATA_TYPE => ("FLOAT4", Float), "float4"); - (FLOAT8_DATA_TYPE => ("FLOAT8", Float), "float8"); - (FLOAT64_DATA_TYPE => ("FLOAT64", Float), "float64"); (REAL_DATA_TYPE => ("REAL", Float), "real"); - (REAL_UNSIGNED_DATA_TYPE => ("REAL UNSIGNED", Float), "real unsigned"); (DOUBLE_DATA_TYPE => ("DOUBLE", Float), "double"); - (DOUBLE_UNSIGNED_DATA_TYPE => ("DOUBLE UNSIGNED", Float), "double unsigned"); (DOUBLE_PRECISION_DATA_TYPE => ("DOUBLE PRECISION", Float), "double precision"); - (DOUBLE_PRECISION_UNSIGNED_DATA_TYPE => ("DOUBLE PRECISION UNSIGNED", Float), "double precision unsigned"); // --- Integer --- (TINYINT_DATA_TYPE => ("TINYINT", Integer), "tinyint"); - (TINYINT_UNSIGNED_DATA_TYPE => ("TINYINT UNSIGNED", Integer), "tinyint unsigned" | "utinyint"); - (INT2_DATA_TYPE => ("INT2", Integer), "int2"); - (INT2_UNSIGNED_DATA_TYPE => ("INT2 UNSIGNED", Integer), "int2 unsigned"); (SMALLINT_DATA_TYPE => ("SMALLINT", Integer), "smallint"); - (SMALLINT_UNSIGNED_DATA_TYPE => ("SMALLINT UNSIGNED", Integer), "smallint unsigned" | "usmallint"); - (MEDIUMINT_DATA_TYPE => ("MEDIUMINT", Integer), "mediumint"); - (MEDIUMINT_UNSIGNED_DATA_TYPE => ("MEDIUMINT UNSIGNED", Integer), "mediumint unsigned"); (INT_DATA_TYPE => ("INT", Integer), "int"); - (INT4_DATA_TYPE => ("INT4", Integer), "int4"); - (INT8_DATA_TYPE => ("INT8", Integer), "int8"); - (INT64_DATA_TYPE => ("INT64", Integer), "int64"); (INTEGER_DATA_TYPE => ("INTEGER", Integer), "integer"); - (UNSIGNED_INTEGER_DATA_TYPE => ("UNSIGNED INTEGER", Integer), "unsigned integer" | "integer unsigned" | "int unsigned"); - (INT4_UNSIGNED_DATA_TYPE => ("INT4 UNSIGNED", Integer), "int4 unsigned"); - (HUGEINT_DATA_TYPE => ("HUGEINT", Integer), "hugeint"); - (UHUGEINT_DATA_TYPE => ("UHUGEINT", Integer), "uhugeint"); (BIGINT_DATA_TYPE => ("BIGINT", Integer), "bigint"); - (BIGINT_UNSIGNED_DATA_TYPE => ("BIGINT UNSIGNED", Integer), "bigint unsigned" | "unsigned bigint" | "unsigned bitint" | "ubigint"); - (INT8_UNSIGNED_DATA_TYPE => ("INT8 UNSIGNED", Integer), "int8 unsigned"); - (SIGNED_DATA_TYPE => ("SIGNED", Integer), "signed"); - (SIGNED_INTEGER_DATA_TYPE => ("SIGNED INTEGER", Integer), "signed integer"); - (UNSIGNED_DATA_TYPE => ("UNSIGNED", Integer), "unsigned"); // --- Boolean --- - (BOOLEAN_DATA_TYPE => ("BOOLEAN", Boolean), "boolean" | "bool"); + (BOOLEAN_DATA_TYPE => ("BOOLEAN", Boolean), "boolean"); // --- Date --- (DATE_DATA_TYPE => ("DATE", Date), "date"); // --- Time --- - (TIME_DATA_TYPE => ("TIME", Time), "time" | "time without timezone"); + (TIME_DATA_TYPE => ("TIME", Time), "time"); (TIME_WITH_TIMEZONE_DATA_TYPE => ("TIME WITH TIMEZONE", Time), "time with timezone"); // --- Datetime --- (DATETIME_DATA_TYPE => ("DATETIME", Datetime), "datetime"); - (TIMESTAMP_DATA_TYPE => ("TIMESTAMP", Datetime), "timestamp" | "timestamp without timezone"); - (TIMESTAMP_WITH_TIMEZONE_DATA_TYPE => ("TIMESTAMP WITH TIMEZONE", Datetime), "timestamp with timezone" | "timestamptz"); - (TIMESTAMPNTZ_DATA_TYPE => ("TIMESTAMPNTZ", Datetime), "timestampntz"); + (TIMESTAMP_DATA_TYPE => ("TIMESTAMP", Datetime), "timestamp"); + (TIMESTAMP_WITH_TIMEZONE_DATA_TYPE => ("TIMESTAMP WITH TIMEZONE", Datetime), "timestamp with timezone"); // --- Json --- (JSON_DATA_TYPE => ("JSON", Json), "json"); @@ -211,31 +162,30 @@ simple_data_types! { // --- MacAddress --- (MACADDRESS_DATA_TYPE => ("MACADDRESS", MacAddress), "macaddress" | "macaddr"); +} - // --- Custom --- - (ANY_DATA_TYPE => ("ANY", Custom), "any"); - (TRIGGER_DATA_TYPE => ("TRIGGER", Custom), "trigger"); - (UNSPECIFIED_DATA_TYPE => ("UNSPECIFIED", Custom), "unspecified"); +fn is_parameterized(value: &str) -> bool { + value.as_bytes().contains(&b'(') } impl From<&str> for DataTypeName { fn from(value: &str) -> Self { if is_parameterized(value) { - return custom_from_str(value); + return custom_name(value); } - interned(value).unwrap_or_else(|| custom_from_str(value)) + interned(value).unwrap_or_else(|| custom_name(value)) } } impl From for DataTypeName { fn from(value: String) -> Self { if is_parameterized(&value) { - return custom_from_string(value); + return custom_name(value); } if let Some(dt) = interned(&value) { return dt; } - custom_from_string(value) + custom_name(value) } } From d080421efaaa510a00552d533a30316bbb23ccef Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Thu, 1 Oct 2026 11:21:32 +0330 Subject: [PATCH 58/87] Refactor serializers, remove some dialect-specific keyword while serializing, support prefix/suffix parameters for serializers --- rapidquery-macros/lib.rs | 82 ++-- src/data_type/mod.rs | 18 +- src/data_type/name.rs | 14 +- .../serializer/{serialize.rs => functions.rs} | 371 ++++++------------ src/data_type/serializer/inner.rs | 262 +++++++++++++ src/data_type/serializer/mod.rs | 261 ++++++------ src/data_type/serializer/serializer_enum.rs | 169 -------- src/data_type/tests.rs | 44 +-- src/dialect/base.rs | 7 +- src/dialect/builtin.rs | 80 ++-- src/dialect/inner.rs | 52 +-- src/expr/base.rs | 7 +- src/expr/enum_/value.rs | 10 +- src/expr/extractors.rs | 2 +- src/expr/func.rs | 7 +- src/expr/ident.rs | 25 +- src/expr/nodes.rs | 8 +- src/expr/value.rs | 8 +- src/sql.rs | 8 +- 19 files changed, 635 insertions(+), 800 deletions(-) rename src/data_type/serializer/{serialize.rs => functions.rs} (52%) create mode 100644 src/data_type/serializer/inner.rs delete mode 100644 src/data_type/serializer/serializer_enum.rs diff --git a/rapidquery-macros/lib.rs b/rapidquery-macros/lib.rs index 54b6035..9262ff5 100644 --- a/rapidquery-macros/lib.rs +++ b/rapidquery-macros/lib.rs @@ -1,47 +1,61 @@ use proc_macro::TokenStream; use quote::quote; -use syn::{ - parse::{Parse, ParseStream}, - spanned::Spanned, -}; +use syn::parse::{Parse, ParseStream}; struct PyClassInput { - name: syn::LitStr, - module: syn::LitStr, + name: String, + module: String, meta_args: Vec, } impl Parse for PyClassInput { - /// Accepts `#name, args...` format. - /// - /// `module = "..."` overrides the default `rapidquery._core` and is not - /// forwarded as a duplicate `#[pyclass]` argument. fn parse(input: ParseStream) -> syn::Result { - let name: syn::LitStr = input.parse()?; - let mut module = syn::LitStr::new("rapidquery._core", name.span()); - let mut meta_args = Vec::new(); + let first: syn::LitStr = input.parse()?; + let (module, name) = if input.peek(syn::Token![+]) { + // Concat syntax + // + // "module" + "name" + input.parse::()?; + let class_name: syn::LitStr = input.parse()?; + let module = first.value(); + let module = module.strip_suffix('.').unwrap_or(&module); + if module.is_empty() || class_name.value().is_empty() { + return Err(syn::Error::new( + class_name.span(), + "expected \"module\" + \"Class\" with non-empty module and class name", + )); + } + (module.to_owned(), class_name.value()) + } else { + // Literal string syntax + // + // "module.name" + let value = first.value(); + let Some((module, name)) = value.rsplit_once('.') else { + return Err(syn::Error::new( + first.span(), + "expected \"module.Class\" or \"module\" + \"Class\"; module is required", + )); + }; + if module.is_empty() || name.is_empty() || module.split('.').any(|s| s.is_empty()) { + return Err(syn::Error::new( + first.span(), + "expected \"module.Class\" with non-empty module and class name", + )); + } + (module.to_owned(), name.to_owned()) + }; + let mut meta_args = Vec::new(); while input.peek(syn::token::Comma) { input.parse::()?; - if input.is_empty() { break; } - - let meta = input.parse::()?; - match meta { - syn::Meta::NameValue(nv) if nv.path.is_ident("module") => match nv.value { - syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - }) => module = s.clone(), - _ => return Err(syn::Error::new(nv.span(), "module expects literal string")), - }, - _ => meta_args.push(meta), - } + meta_args.push(input.parse()?); } - Ok(PyClassInput { + Ok(Self { name, module, meta_args, @@ -49,10 +63,6 @@ impl Parse for PyClassInput { } } -/// Shortcut for `pyo3::pyclass(module=..., name=..., immutable_type, frozen, ...)`. -/// -/// Default module is `rapidquery._core`. Pass `module = "rapidquery._core.dialect"` -/// (or another path) to override. #[proc_macro_attribute] pub fn pyclass(attr: TokenStream, item: TokenStream) -> TokenStream { let PyClassInput { @@ -62,14 +72,20 @@ pub fn pyclass(attr: TokenStream, item: TokenStream) -> TokenStream { } = syn::parse_macro_input!(attr as PyClassInput); let item = syn::parse_macro_input!(item as syn::ItemStruct); - let extra_tokens = if meta_args.is_empty() { + // Fresh tokens, no input span, so rustc cannot substitute the dotted literal. + let args: proc_macro2::TokenStream = + format!("module = \"{module}\", name = \"{name}\", immutable_type, frozen") + .parse() + .expect("pyclass args"); + + let extra = if meta_args.is_empty() { quote! {} } else { quote! { , #(#meta_args),* } }; quote! { - #[pyo3::pyclass(module=#module, name=#name, immutable_type, frozen #extra_tokens)] + #[pyo3::pyclass(#args #extra)] #item } .into() diff --git a/src/data_type/mod.rs b/src/data_type/mod.rs index 132b571..4c11d1b 100644 --- a/src/data_type/mod.rs +++ b/src/data_type/mod.rs @@ -12,7 +12,7 @@ mod serializer; #[cfg(test)] mod tests; -pub use name::{DataTypeGroup, DataTypeName}; +pub use name::{DataTypeKind, DataTypeName}; pub use serializer::*; use crate::internal::{alias, utils}; @@ -36,23 +36,16 @@ use crate::internal::{alias, utils}; /// type. /// /// Clone of a [`Serializer::Custom`] value requires the GIL. -#[rapidquery_macros::pyclass( - "DataType", - from_py_object, - module = "rapidquery._core.data_type", - eq, - hash, - generic -)] +#[rapidquery_macros::pyclass("rapidquery.data_type.DataType", from_py_object, eq, hash, generic)] #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct PyDataType { pub name: DataTypeName, - pub serializer: SerializerEnum, + pub serializer: SerializerInner, } impl PyDataType { #[inline] - pub fn new(name: DataTypeName, serializer: SerializerEnum) -> Self { + pub fn new(name: DataTypeName, serializer: SerializerInner) -> Self { Self { name, serializer } } @@ -62,7 +55,8 @@ impl PyDataType { #[inline] pub fn from_name(name: impl Into) -> Self { let name = name.into(); - let serializer = SerializerEnum::from_group(name.group()); + let serializer = SerializerInner::new(SerializerType::from_kind(name.kind()), None, None); + Self { name, serializer } } } diff --git a/src/data_type/name.rs b/src/data_type/name.rs index 37831a0..ed3d701 100644 --- a/src/data_type/name.rs +++ b/src/data_type/name.rs @@ -7,7 +7,7 @@ use crate::sql::{PrepareResult, SqlWriter, WriteSql}; /// distinct as groups so a later layer can treat storage differently; both /// currently infer [`crate::data_type::Serializer::Json`]. #[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord, Hash)] -pub enum DataTypeGroup { +pub enum DataTypeKind { String, Uuid, Bytes, @@ -34,7 +34,7 @@ pub enum DataTypeGroup { #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] pub struct DataTypeName { name: arcstr::ArcStr, - group: DataTypeGroup, + kind: DataTypeKind, } impl DataTypeName { @@ -44,8 +44,8 @@ impl DataTypeName { } #[inline] - pub fn group(&self) -> DataTypeGroup { - self.group + pub fn kind(&self) -> DataTypeKind { + self.kind } } @@ -68,7 +68,7 @@ fn interned(value: &str) -> Option { fn custom_name>(value: T) -> DataTypeName { DataTypeName { name: value.into(), - group: DataTypeGroup::Custom, + kind: DataTypeKind::Custom, } } @@ -77,14 +77,14 @@ macro_rules! simple_data_types { ( $( $(#[$docs:meta])* - ($upcase:ident => ($realname:literal, $group:ident), $($possible_names:literal)|+); + ($upcase:ident => ($realname:literal, $kind:ident), $($possible_names:literal)|+); )+ ) => { $( $(#[$docs])* pub(super) const $upcase: DataTypeName = DataTypeName { name: arcstr::literal!($realname), - group: DataTypeGroup::$group, + kind: DataTypeKind::$kind, }; )+ diff --git a/src/data_type/serializer/serialize.rs b/src/data_type/serializer/functions.rs similarity index 52% rename from src/data_type/serializer/serialize.rs rename to src/data_type/serializer/functions.rs index 9397a3d..201290c 100644 --- a/src/data_type/serializer/serialize.rs +++ b/src/data_type/serializer/functions.rs @@ -1,57 +1,20 @@ -//! Write a Python value as a SQL literal into a `String`. - -use std::fmt::Write; -use std::str::FromStr; - use pyo3::types::{PyAnyMethods, PyTzInfoAccess}; -use crate::dialect::PySqlDialect; -use crate::dialect::inner::{ - ArrayFormat, Dialect, FormatError, InetFormat, JsonFormat, MacFormat, StringFormat, UuidFormat, +use crate::{ + dialect::{ + PySqlDialect, + inner::{ArrayFormat, Dialect, FormatError, StringFormat}, + }, + internal::{alias, utils}, }; -use crate::internal::alias; -use crate::internal::utils::{borrow_py_str_buffer, get_type_name}; - -impl super::serializer_enum::SerializerEnum { - pub fn write_sql( - &self, - dialect: &PySqlDialect, - object: &alias::BoundObject<'_>, - out: &mut String, - ) -> pyo3::PyResult<()> { - let py = object.py(); - let ptr = object.as_ptr(); - unsafe { - match self { - Self::Integer => write_integer(py, ptr, out), - Self::Float => write_float(py, ptr, out), - Self::Boolean => write_bool(py, ptr, &dialect.0, out), - Self::String => write_str(py, ptr, &dialect.0, out), - Self::Uuid => write_uuid(py, ptr, &dialect.0, out), - Self::Bytes => write_bytes(py, ptr, &dialect.0, out), - Self::Bit => write_bit(py, ptr, &dialect.0, out), - Self::Json => write_json(py, ptr, &dialect.0, out), - Self::Date => write_date(py, ptr, out), - Self::Time => write_time(py, ptr, out), - Self::Datetime => write_datetime(py, ptr, out), - Self::Inet => write_inet(py, ptr, &dialect.0, out), - Self::MacAddress => write_mac_address(py, ptr, &dialect.0, out), - Self::Array(element) => write_array(py, ptr, dialect, element, out), - Self::Unspecified => { - let text = py_to_string(py, ptr)?; - out.push_str(&text); - Ok(()) - } - Self::Custom(s) => write_custom(py, ptr, dialect, s, object, out), - } - } - } -} +use std::{fmt::Write, str::FromStr}; -/// `str(obj)` copied into a Rust `String`. Used when the unicode is a -/// temporary (`PyObject_Str`) that must be `DECREF`'d before return. +/// `str(obj)` copied into a Rust `String`. +/// +/// # Safety +/// `ptr` must be *borrowed*, non-null, and a valid Python object. #[inline] -unsafe fn py_to_string( +pub(super) unsafe fn pyobject_string( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, ) -> pyo3::PyResult { @@ -59,30 +22,15 @@ unsafe fn py_to_string( if s.is_null() { return Err(pyo3::PyErr::fetch(py)); } - let out = borrow_py_str_buffer(py, s).map(str::to_owned); + let out = utils::borrow_py_str_buffer(py, s).map(str::to_owned); pyo3::ffi::Py_DECREF(s); out } -#[inline] -unsafe fn py_bytes<'a>(ptr: *mut pyo3::ffi::PyObject) -> &'a [u8] { - let buf = pyo3::ffi::PyBytes_AsString(ptr) as *const u8; - let len = pyo3::ffi::PyBytes_Size(ptr) as usize; - debug_assert!(!buf.is_null()); - std::slice::from_raw_parts(buf, len) -} - -#[inline] -fn write_quoted(dialect: &Dialect, out: &mut String, raw: &str) -> pyo3::PyResult<()> { - dialect.write_string_literal(out, raw).map_err(Into::into) -} - /// Open a string literal and write `body` inside it. /// -/// `body` is digits, `-`, `:`, `.`, `+`, and space only: no quote, backslash, -/// NUL, or C0. `PrefixBigE` therefore stays a plain quote, the same branch -/// `write_string_literal` takes when the body needs no C escape. `PrefixBigN` -/// still writes `N'`. +/// # Note +/// `body` must be digits, `-`, `:`, `.`, `+`, and space only: no quote, backslash, NUL, or C0. #[inline] fn write_quoted_body( dialect: &Dialect, @@ -98,15 +46,8 @@ fn write_quoted_body( Ok(()) } -/// Serializes any Python `int` as a bare decimal literal (`str(int)`). -/// -/// Values that fit in `i64` are written with [`write!`]. Larger values use -/// `PyObject_Str`. `bool` is rejected (`PyLong_CheckExact`). -/// -/// # Safety -/// `ptr` must be *borrowed*, non-null, and a valid Python object. #[inline] -unsafe fn write_integer( +pub(super) unsafe fn write_integer( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, out: &mut String, @@ -115,14 +56,16 @@ unsafe fn write_integer( return rapidquery_macros::pyerr!( Err(PyTypeError), "expected int for integer serialization, got {}", - get_type_name(py, ptr) + utils::get_type_name(py, ptr) ); } let mut overflow = 0i32; let n = pyo3::ffi::PyLong_AsLongLongAndOverflow(ptr, &mut overflow); + if overflow != 0 { - let text = py_to_string(py, ptr)?; + // Fallback to `str(obj)` for very long integers. + let text = pyobject_string(py, ptr)?; out.push_str(&text); return Ok(()); } @@ -131,18 +74,12 @@ unsafe fn write_integer( return Err(pyo3::PyErr::fetch(py)); } - write!(out, "{n}").map_err(|_| rapidquery_macros::pyerr!(PyRuntimeError, "failed to write SQL")) + let _ = write!(out, "{n}"); + Ok(()) } -/// `float` / exact `int` / `decimal.Decimal` as a bare numeric literal. -/// -/// Only a real `float` is formatted from the f64 (`{:?}` so `1.0` stays -/// distinct from `1`). Non-finite floats are rejected. `str` is not accepted. -/// -/// # Safety -/// `ptr` must be *borrowed*, non-null, and a valid Python object. #[inline] -unsafe fn write_float( +pub(super) unsafe fn write_float( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, out: &mut String, @@ -156,6 +93,8 @@ unsafe fn write_float( v ); } + + // `{:?}` so `1.0` stays distinct from `1` let _ = write!(out, "{v:?}"); return Ok(()); } @@ -163,7 +102,8 @@ unsafe fn write_float( if pyo3::ffi::PyLong_CheckExact(ptr) == 1 || crate::typeref::check_exact(ptr, crate::typeref::STD_DECIMAL_TYPE) { - let text = py_to_string(py, ptr)?; + // Fallback to `str(obj)` for int/decimal.Decimal + let text = pyobject_string(py, ptr)?; out.push_str(&text); return Ok(()); } @@ -171,14 +111,12 @@ unsafe fn write_float( rapidquery_macros::pyerr!( Err(PyTypeError), "expected float/int/Decimal for numeric serialization, got {}", - get_type_name(py, ptr) + utils::get_type_name(py, ptr) ) } -/// # Safety -/// `ptr` must be *borrowed*, non-null, and a valid Python object. #[inline] -unsafe fn write_bool( +pub(super) unsafe fn write_bool( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, dialect: &Dialect, @@ -192,120 +130,92 @@ unsafe fn write_bool( rapidquery_macros::pyerr!( Err(PyTypeError), "expected bool for boolean serialization, got {}", - get_type_name(py, ptr) + utils::get_type_name(py, ptr) ) } } -/// Exact `str`, or an enum member whose `.value` is a `str`. -/// -/// # Safety -/// `ptr` must be *borrowed*, non-null, and a valid Python object. #[inline] -unsafe fn write_str( +pub(super) unsafe fn write_str( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { if pyo3::ffi::PyUnicode_Check(ptr) == 1 { - return write_quoted(dialect, out, borrow_py_str_buffer(py, ptr)?); + return dialect + .write_string_literal(out, utils::borrow_py_str_buffer(py, ptr)?) + .map_err(Into::into); } rapidquery_macros::pyerr!( Err(PyTypeError), "expected str for string serialization, got {}", - get_type_name(py, ptr) + utils::get_type_name(py, ptr) ) } #[inline] -fn write_uuid_text( - dialect: &Dialect, - out: &mut String, - body: impl FnOnce(&mut String) -> std::fmt::Result, -) -> pyo3::PyResult<()> { - match dialect.uuid_format { - UuidFormat::Quoted => write_quoted_body(dialect, out, body), - UuidFormat::UuidKeyword => { - out.push_str("UUID "); - write_quoted_body(dialect, out, body) - } - UuidFormat::CastUuid => { - write_quoted_body(dialect, out, body)?; - out.push_str("::uuid"); - Ok(()) - } - } -} - -/// # Safety -/// `ptr` must be *borrowed*, non-null, and a valid Python object. -#[inline] -unsafe fn write_uuid( +pub(super) unsafe fn write_uuid( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { if crate::typeref::check_exact(ptr, crate::typeref::STD_UUID_TYPE) { - let rendered = pyo3::ffi::PyObject_Str(ptr); - if rendered.is_null() { - return Err(pyo3::PyErr::fetch(py)); - } - let result = match borrow_py_str_buffer(py, rendered) { - Ok(text) => write_uuid_text(dialect, out, |w| w.write_str(text)), - Err(err) => Err(err), - }; - pyo3::ffi::Py_DECREF(rendered); - result - } else if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { - let borrowed = borrow_py_str_buffer(py, ptr)?; + let rendered = pyobject_string(py, ptr)?; + return write_quoted_body(dialect, out, |w| write!(w, "{}", rendered)); + } + + if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { + let borrowed = utils::borrow_py_str_buffer(py, ptr)?; let parsed = uuid::Uuid::try_parse(borrowed) .map_err(|e| rapidquery_macros::pyerr!(PyValueError, "invalid uuid str: {}", e))?; - write_uuid_text(dialect, out, |w| write!(w, "{}", parsed.hyphenated())) - } else { - rapidquery_macros::pyerr!( - Err(PyTypeError), - "expected uuid.UUID/str for UUID serialization, got {}", - get_type_name(py, ptr) - ) + return write_quoted_body(dialect, out, |w| write!(w, "{}", parsed.hyphenated())); } + + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected uuid.UUID/str for UUID serialization, got {}", + utils::get_type_name(py, ptr) + ) } -/// # Safety -/// `ptr` must be *borrowed*, non-null, and a valid Python object. #[inline] -unsafe fn write_bytes( +pub(super) unsafe fn write_bytes( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { if pyo3::ffi::PyBytes_CheckExact(ptr) == 1 { - dialect.format_bytes(out, py_bytes(ptr))?; - Ok(()) + let buf = pyo3::ffi::PyBytes_AsString(ptr) as *const u8; + let len = pyo3::ffi::PyBytes_Size(ptr) as usize; + debug_assert!(!buf.is_null()); + + dialect + .format_bytes(out, std::slice::from_raw_parts(buf, len)) + .map_err(Into::into) } else { - Err(rapidquery_macros::pyerr!( - PyTypeError, + rapidquery_macros::pyerr!( + Err(PyTypeError), "expected bytes for binary serialization, got {}", - get_type_name(py, ptr) - )) + utils::get_type_name(py, ptr) + ) } } -/// # Safety -/// `ptr` must be *borrowed*, non-null, and a valid Python object. #[inline] -unsafe fn write_bit( +pub(super) unsafe fn write_bit( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { - let raw = borrow_py_str_buffer(py, ptr)?; + let raw = utils::borrow_py_str_buffer(py, ptr)?; + if raw.is_empty() || !raw.bytes().all(|b| b == b'0' || b == b'1') { return rapidquery_macros::pyerr!( Err(PyValueError), @@ -313,50 +223,19 @@ unsafe fn write_bit( raw ); } - dialect.format_bits(out, raw)?; - Ok(()) + + dialect.format_bits(out, raw).map_err(Into::into) } else { rapidquery_macros::pyerr!( Err(PyTypeError), "expected str of bits for bit serialization, got {}", - get_type_name(py, ptr) + utils::get_type_name(py, ptr) ) } } #[inline] -fn write_json_text(dialect: &Dialect, out: &mut String, raw: &str) -> pyo3::PyResult<()> { - match dialect.json_format { - JsonFormat::Quoted => write_quoted(dialect, out, raw), - JsonFormat::JsonKeyword => { - out.push_str("JSON "); - write_quoted(dialect, out, raw) - } - JsonFormat::ParseJson => { - out.push_str("PARSE_JSON("); - write_quoted(dialect, out, raw)?; - out.push(')'); - Ok(()) - } - JsonFormat::CastJson => { - write_quoted(dialect, out, raw)?; - out.push_str("::json"); - Ok(()) - } - JsonFormat::CastJsonb => { - write_quoted(dialect, out, raw)?; - out.push_str("::jsonb"); - Ok(()) - } - } -} - -/// JSON-encodable object → dumps text → dialect string literal. -/// -/// # Safety -/// `ptr` must be *borrowed*, non-null, and a valid Python object. -#[inline] -unsafe fn write_json( +pub(super) unsafe fn write_json( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, dialect: &Dialect, @@ -364,14 +243,12 @@ unsafe fn write_json( ) -> pyo3::PyResult<()> { crate::internal::modules::json_dumps( unsafe { pyo3::Bound::from_borrowed_ptr(py, ptr) }, - |text| write_json_text(dialect, out, text), + |text| dialect.write_string_literal(out, text).map_err(Into::into), ) } -/// # Safety -/// `ptr` must be *borrowed*, non-null, and a valid Python object. #[inline] -unsafe fn write_date( +pub(super) unsafe fn write_date( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, out: &mut String, @@ -385,18 +262,16 @@ unsafe fn write_date( let _ = date.format("%Y-%m-%d").write_to(out); Ok(()) } else { - Err(rapidquery_macros::pyerr!( - PyTypeError, + rapidquery_macros::pyerr!( + Err(PyTypeError), "expected datetime.date for date serialization, got {}", - get_type_name(py, ptr) - )) + utils::get_type_name(py, ptr) + ) } } -/// # Safety -/// `ptr` must be *borrowed*, non-null, and a valid Python object. #[inline] -unsafe fn write_time( +pub(super) unsafe fn write_time( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, out: &mut String, @@ -409,22 +284,16 @@ unsafe fn write_time( let _ = time.format("%H:%M:%S%.f").write_to(out); Ok(()) } else { - Err(rapidquery_macros::pyerr!( - PyTypeError, + rapidquery_macros::pyerr!( + Err(PyTypeError), "expected datetime.time for time serialization, got {}", - get_type_name(py, ptr) - )) + utils::get_type_name(py, ptr) + ) } } -/// Naive `datetime` keeps wall time. Aware `datetime` keeps its offset -/// (`DateTime`), not a conversion to UTC. `int`/`float` are -/// UTC epoch seconds. -/// -/// # Safety -/// `ptr` must be *borrowed*, non-null, and a valid Python object. #[inline] -unsafe fn write_datetime( +pub(super) unsafe fn write_datetime( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, out: &mut String, @@ -451,7 +320,7 @@ unsafe fn write_datetime( let mut has_overflow: i32 = 0; let num = pyo3::ffi::PyLong_AsLongLongAndOverflow(ptr, &mut has_overflow); if has_overflow != 0 { - return Err(pyo3::exceptions::PyOverflowError::new_err("out of range")); + return rapidquery_macros::pyerr!(Err(PyOverflowError), "out of range"); } return match chrono::Utc.timestamp_opt(num, 0) { @@ -459,11 +328,11 @@ unsafe fn write_datetime( let _ = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").write_to(out); Ok(()) } - _ => Err(rapidquery_macros::pyerr!( - PyOverflowError, + _ => rapidquery_macros::pyerr!( + Err(PyOverflowError), "timestamp is invalid for datetime serialization: {}", num - )), + ), }; } @@ -476,77 +345,62 @@ unsafe fn write_datetime( let _ = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").write_to(out); Ok(()) } - _ => Err(rapidquery_macros::pyerr!( - PyOverflowError, + _ => rapidquery_macros::pyerr!( + Err(PyOverflowError), "timestamp is invalid for datetime serialization: {}", num - )), + ), }; } - Err(rapidquery_macros::pyerr!( - PyTypeError, + rapidquery_macros::pyerr!( + Err(PyTypeError), "expected datetime.datetime/int/float for datetime serialization, got {}", - get_type_name(py, ptr) - )) + utils::get_type_name(py, ptr) + ) } -/// # Safety -/// `ptr` must be *borrowed*, non-null, and a valid Python object. #[inline] -unsafe fn write_inet( +pub(super) unsafe fn write_inet( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { - let text = py_to_string(py, ptr)?; + if !dialect.supports_inet { + return Err(FormatError::Unsupported("INET data type").into()); + } + + let text = pyobject_string(py, ptr)?; let net = ipnetwork::IpNetwork::from_str(&text) .map_err(|e| rapidquery_macros::pyerr!(PyValueError, e.to_string()))?; - match dialect.inet_format { - None => Err(FormatError::Unsupported("InetSerializer").into()), - Some(InetFormat::Quoted) => write_quoted_body(dialect, out, |w| write!(w, "{net}")), - Some(InetFormat::Cast) => { - write_quoted_body(dialect, out, |w| write!(w, "{net}"))?; - out.push_str("::inet"); - Ok(()) - } - } + write_quoted_body(dialect, out, |w| write!(w, "{net}")) } -/// # Safety -/// `ptr` must be *borrowed*, non-null, and a valid Python object. #[inline] -unsafe fn write_mac_address( +pub(super) unsafe fn write_macaddr( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { - let text = py_to_string(py, ptr)?; + if !dialect.supports_macaddr { + return Err(FormatError::Unsupported("MACADDR data type").into()); + } + + let text = pyobject_string(py, ptr)?; let addr = mac_address::MacAddress::from_str(&text) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; - match dialect.mac_format { - None => Err(FormatError::Unsupported("MacAddressSerializer").into()), - Some(MacFormat::Quoted) => write_quoted_body(dialect, out, |w| write!(w, "{addr}")), - Some(MacFormat::Cast) => { - write_quoted_body(dialect, out, |w| write!(w, "{addr}"))?; - out.push_str("::macaddr"); - Ok(()) - } - } + write_quoted_body(dialect, out, |w| write!(w, "{addr}")) } -/// # Safety -/// `ptr` must be *borrowed*, non-null, and a valid Python object. -#[inline] -unsafe fn write_array( +pub(super) unsafe fn write_array( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, dialect: &PySqlDialect, - element: &super::serializer_enum::SerializerEnum, + element: &super::inner::SerializerInner, out: &mut String, ) -> pyo3::PyResult<()> { let (len, getter): ( @@ -564,7 +418,7 @@ unsafe fn write_array( return rapidquery_macros::pyerr!( Err(PyTypeError), "expected list/tuple for array serialization, got {}", - get_type_name(py, ptr) + utils::get_type_name(py, ptr) ); }; @@ -601,10 +455,8 @@ unsafe fn write_array( Ok(()) } -/// # Safety -/// `ptr` must be *borrowed*, non-null, and a valid Python object. #[inline] -unsafe fn write_custom( +pub(super) unsafe fn write_custom( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, dialect: &PySqlDialect, @@ -619,9 +471,10 @@ unsafe fn write_custom( return rapidquery_macros::pyerr!( Err(PyTypeError), "custom serializer must return str, got {}", - get_type_name(py, output.as_ptr()) + utils::get_type_name(py, output.as_ptr()) ); } - out.push_str(borrow_py_str_buffer(py, output.as_ptr())?); + + out.push_str(utils::borrow_py_str_buffer(py, output.as_ptr())?); Ok(()) } diff --git a/src/data_type/serializer/inner.rs b/src/data_type/serializer/inner.rs new file mode 100644 index 0000000..197d905 --- /dev/null +++ b/src/data_type/serializer/inner.rs @@ -0,0 +1,262 @@ +use crate::{ + dialect::PySqlDialect, + internal::{alias, modules}, +}; + +/// A thin enum which specifies the serializer type. +/// +/// You can use it to serialize different Python objects. +#[derive(Debug, Clone)] +pub enum SerializerType { + Integer, + Float, + Boolean, + String, + Uuid, + Bytes, + Bit, + Json, + Date, + Time, + Datetime, + Inet, + MacAddress, + Unspecified, + Array(Box), + Custom(alias::PyObject), +} + +/// How a Python object is written as a SQL literal. +/// +/// It wraps [`SerializerType`] with additional `prefix` and `suffix` fields. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SerializerInner { + pub r#type: SerializerType, + pub prefix: Option, + pub suffix: Option, +} + +impl PartialEq for SerializerType { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Integer, Self::Integer) + | (Self::Float, Self::Float) + | (Self::Boolean, Self::Boolean) + | (Self::String, Self::String) + | (Self::Uuid, Self::Uuid) + | (Self::Bytes, Self::Bytes) + | (Self::Bit, Self::Bit) + | (Self::Json, Self::Json) + | (Self::Date, Self::Date) + | (Self::Time, Self::Time) + | (Self::Datetime, Self::Datetime) + | (Self::Inet, Self::Inet) + | (Self::MacAddress, Self::MacAddress) + | (Self::Unspecified, Self::Unspecified) => true, + (Self::Array(a), Self::Array(b)) => a == b, + (Self::Custom(a), Self::Custom(b)) => { + // We prefer to do not use Python level equal operator + // to avoid GIL problems. This guarantees safety. + std::ptr::eq(a.as_ptr(), b.as_ptr()) + } + _ => false, + } + } +} +impl Eq for SerializerType {} + +impl std::hash::Hash for SerializerType { + fn hash(&self, state: &mut H) { + std::mem::discriminant(self).hash(state); + match self { + Self::Array(element) => element.hash(state), + Self::Custom(function) => { + // Same note like [`SerializerType::eq`] + (function.as_ptr() as usize).hash(state) + } + _ => {} + } + } +} + +impl SerializerType { + /// Built-in serializer for a [`DataTypeKind`]. + /// + /// [`DataTypeKind::Custom`] maps to [`Self::Unspecified`]. Json and Jsonb + /// share [`Self::Json`]. + #[inline] + pub fn from_kind(kind: crate::data_type::DataTypeKind) -> Self { + match kind { + crate::data_type::DataTypeKind::Integer => Self::Integer, + crate::data_type::DataTypeKind::Float => Self::Float, + crate::data_type::DataTypeKind::Boolean => Self::Boolean, + crate::data_type::DataTypeKind::String => Self::String, + crate::data_type::DataTypeKind::Uuid => Self::Uuid, + crate::data_type::DataTypeKind::Bytes => Self::Bytes, + crate::data_type::DataTypeKind::Bit => Self::Bit, + crate::data_type::DataTypeKind::Json | crate::data_type::DataTypeKind::Jsonb => { + Self::Json + } + crate::data_type::DataTypeKind::Date => Self::Date, + crate::data_type::DataTypeKind::Time => Self::Time, + crate::data_type::DataTypeKind::Datetime => Self::Datetime, + crate::data_type::DataTypeKind::Inet => Self::Inet, + crate::data_type::DataTypeKind::MacAddress => Self::MacAddress, + crate::data_type::DataTypeKind::Custom => Self::Unspecified, + } + } + + pub fn infer_bound(bound: &alias::BoundObject) -> Self { + unsafe { + let object_ptr = bound.as_ptr(); + let object_type_ptr = pyo3::ffi::Py_TYPE(object_ptr); + + // None / bool -> Boolean + // + // Why `None`? + // Actually we don't use [`Serializer`] on `None` values. + // So it's not important what [`Serializer`] is. + // `None` values are handled by [`crate::sql::SqlWriter`] + if pyo3::ffi::Py_IsNone(object_ptr) == 1 || pyo3::ffi::PyBool_Check(object_ptr) == 1 { + return Self::Boolean; + } + + // float / decimal -> Float + if pyo3::ffi::PyFloat_CheckExact(object_ptr) == 1 + || object_type_ptr == crate::typeref::STD_DECIMAL_TYPE + { + return Self::Float; + } + + // int -> Integer + if pyo3::ffi::PyLong_CheckExact(object_ptr) == 1 { + return Self::Integer; + } + + // str/enum.StrEnum -> String + if pyo3::ffi::PyUnicode_Check(object_ptr) == 1 { + return Self::String; + } + + // bytes -> Bytes + if pyo3::ffi::PyBytes_CheckExact(object_ptr) == 1 { + return Self::Bytes; + } + + // date -> Date + if object_type_ptr == crate::typeref::STD_DATE_TYPE { + return Self::Date; + } + + // time -> Time + if object_type_ptr == crate::typeref::STD_TIME_TYPE { + return Self::Time; + } + + // datetime -> Datetime + if object_type_ptr == crate::typeref::STD_DATETIME_TYPE { + return Self::Datetime; + } + + // UUID -> Uuid + if object_type_ptr == crate::typeref::STD_UUID_TYPE { + return Self::Uuid; + } + + // list/dict -> Json + if (pyo3::ffi::PyList_CheckExact(object_ptr) == 1 + || pyo3::ffi::PyDict_CheckExact(object_ptr) == 1) + && modules::json_dumps(bound.clone(), |_| Ok(())).is_ok() + { + return Self::Json; + } + } + + // * -> Unspecified + Self::Unspecified + } +} + +impl SerializerType { + pub fn serialize( + &self, + dialect: &PySqlDialect, + object: &alias::BoundObject<'_>, + out: &mut String, + ) -> pyo3::PyResult<()> { + let py = object.py(); + let ptr = object.as_ptr(); + + unsafe { + match self { + Self::Integer => super::functions::write_integer(py, ptr, out), + Self::Float => super::functions::write_float(py, ptr, out), + Self::Boolean => super::functions::write_bool(py, ptr, &dialect.0, out), + Self::String => super::functions::write_str(py, ptr, &dialect.0, out), + Self::Uuid => super::functions::write_uuid(py, ptr, &dialect.0, out), + Self::Bytes => super::functions::write_bytes(py, ptr, &dialect.0, out), + Self::Bit => super::functions::write_bit(py, ptr, &dialect.0, out), + Self::Json => super::functions::write_json(py, ptr, &dialect.0, out), + Self::Date => super::functions::write_date(py, ptr, out), + Self::Time => super::functions::write_time(py, ptr, out), + Self::Datetime => super::functions::write_datetime(py, ptr, out), + Self::Inet => super::functions::write_inet(py, ptr, &dialect.0, out), + Self::MacAddress => super::functions::write_macaddr(py, ptr, &dialect.0, out), + Self::Array(element) => { + super::functions::write_array(py, ptr, dialect, element, out) + } + Self::Unspecified => { + let text = super::functions::pyobject_string(py, ptr)?; + out.push_str(&text); + Ok(()) + } + Self::Custom(s) => super::functions::write_custom(py, ptr, dialect, s, object, out), + } + } + } +} + +impl SerializerInner { + #[inline(always)] + pub fn new( + r#type: SerializerType, + prefix: Option, + suffix: Option, + ) -> Self { + Self { + r#type, + prefix, + suffix, + } + } + + /// Serializes the `object` into literal SQL value. + pub fn write_sql( + &self, + dialect: &PySqlDialect, + object: &alias::BoundObject<'_>, + out: &mut String, + ) -> pyo3::PyResult<()> { + if let Some(ref x) = self.prefix { + out.push_str(x.as_str()); + } + + self.r#type.serialize(dialect, object, out)?; + + if let Some(ref x) = self.suffix { + out.push_str(x.as_str()); + } + + Ok(()) + } +} + +impl From for SerializerInner { + fn from(value: SerializerType) -> Self { + Self { + r#type: value, + prefix: None, + suffix: None, + } + } +} diff --git a/src/data_type/serializer/mod.rs b/src/data_type/serializer/mod.rs index dda8e80..64e676f 100644 --- a/src/data_type/serializer/mod.rs +++ b/src/data_type/serializer/mod.rs @@ -1,126 +1,58 @@ -mod serialize; -mod serializer_enum; +//! Serializers -pub use serializer_enum::SerializerEnum; +mod functions; +mod inner; + +pub use inner::SerializerInner; +pub use inner::SerializerType; use crate::internal::alias; -pub(super) fn class_name(serializer: &SerializerEnum) -> &'static str { - match serializer { - SerializerEnum::Integer => "IntegerSerializer", - SerializerEnum::Float => "FloatSerializer", - SerializerEnum::Boolean => "BooleanSerializer", - SerializerEnum::String => "StringSerializer", - SerializerEnum::Uuid => "UuidSerializer", - SerializerEnum::Bytes => "BytesSerializer", - SerializerEnum::Bit => "BitSerializer", - SerializerEnum::Json => "JSONSerializer", - SerializerEnum::Date => "DateSerializer", - SerializerEnum::Time => "TimeSerializer", - SerializerEnum::Datetime => "DatetimeSerializer", - SerializerEnum::Inet => "InetSerializer", - SerializerEnum::MacAddress => "MacAddressSerializer", - SerializerEnum::Unspecified => "UnspecifiedSerializer", - SerializerEnum::Array(_) => "ArraySerializer", - SerializerEnum::Custom(_) => "CustomSerializer", - } -} +pub fn serializer_repr(serializer: &SerializerInner) -> String { + use std::fmt::Write; -#[inline] -fn wrap<'py, T>( - py: pyo3::Python<'py>, - base: PySerializer, -) -> pyo3::PyResult> -where - T: pyo3::PyClass + Default, -{ - pyo3::Bound::new( - py, - pyo3::PyClassInitializer::from(base).add_subclass(T::default()), - ) - .map(|x| x.into_any()) -} + let name = serializer_type_to_pyname(&serializer.r#type); + let mut buf = String::with_capacity(name.len() + 2); + buf.push_str(name); + buf.push('('); -pub fn serializer_repr(serializer: &SerializerEnum) -> String { - match serializer { - SerializerEnum::Array(element) => { - format!("ArraySerializer({})", serializer_repr(element)) - } - other => { - let name = class_name(other); - let mut buf = String::with_capacity(name.len() + 2); - buf.push_str(name); - buf.push_str("()"); - buf - } + let mut sep = ""; + + if let SerializerType::Array(element) = &serializer.r#type { + buf.push_str(&serializer_repr(element)); + sep = ", "; + } + if let Some(x) = &serializer.prefix { + let _ = write!(buf, "{sep}prefix={x:?}"); + sep = ", "; } + if let Some(x) = &serializer.suffix { + let _ = write!(buf, "{sep}suffix={x:?}"); + } + + buf.push(')'); + buf } /// Converts a Python object into a dialect-specific SQL literal string. -/// -/// Serializers produce a correctly escaped and formatted SQL value that can -/// be embedded in queries. Each concrete subclass handles a particular -/// category of values (integers, strings, dates, arrays, etc.). Use -/// `CustomSerializer` when the built-in ones are insufficient. -/// -/// This base class cannot be instantiated. Pass a dialect class or instance -/// to `serialize()`. Unit serializers compare equal by variant; -/// `ArraySerializer` by its element tree; `CustomSerializer` by function -/// identity. -#[rapidquery_macros::pyclass( - "Serializer", - subclass, - from_py_object, - module = "rapidquery._core.data_type", - generic -)] +#[rapidquery_macros::pyclass("rapidquery.data_type.Serializer", subclass, from_py_object, generic)] #[derive(Debug, Clone)] -pub struct PySerializer(pub SerializerEnum); +#[repr(transparent)] +pub struct PySerializer(pub SerializerInner); +#[pyo3::pymethods] impl PySerializer { - /// Wrap `self` as the matching Python subclass (`IntegerSerializer()`, …). - pub fn into_subclasses<'py>( - self, - py: pyo3::Python<'py>, - ) -> pyo3::PyResult> { - match &self.0 { - SerializerEnum::Integer => wrap::(py, self), - SerializerEnum::Float => wrap::(py, self), - SerializerEnum::Boolean => wrap::(py, self), - SerializerEnum::String => wrap::(py, self), - SerializerEnum::Uuid => wrap::(py, self), - SerializerEnum::Bytes => wrap::(py, self), - SerializerEnum::Bit => wrap::(py, self), - SerializerEnum::Json => wrap::(py, self), - SerializerEnum::Date => wrap::(py, self), - SerializerEnum::Time => wrap::(py, self), - SerializerEnum::Datetime => wrap::(py, self), - SerializerEnum::Inet => wrap::(py, self), - SerializerEnum::MacAddress => wrap::(py, self), - SerializerEnum::Unspecified => wrap::(py, self), - SerializerEnum::Array(_) => wrap::(py, self), - SerializerEnum::Custom(_) => wrap::(py, self), - } + #[getter] + fn get_prefix(&self) -> Option { + self.0.prefix.as_ref().map(arcstr::ArcStr::to_string) + } + + #[getter] + fn get_suffix(&self) -> Option { + self.0.suffix.as_ref().map(arcstr::ArcStr::to_string) } -} -#[pyo3::pymethods] -impl PySerializer { /// Renders `object` as a SQL literal for `dialect`. - /// - /// Args: - /// dialect: A dialect class (`PostgreSQL`) or instance (`PostgreSQL()`). - /// object: The Python value to convert. - /// - /// Returns: - /// A SQL fragment suitable for embedding in a statement. - /// - /// Raises: - /// TypeError: If `object` is not an accepted type for this serializer. - /// ValueError: If `object` is the right type but cannot be represented - /// (for example a non-finite float). - /// NotImplementedError: If `dialect` has no native literal for this - /// serializer (bit strings, `ARRAY[…]`, inet, macaddr). fn serialize( &self, dialect: crate::dialect::PySqlDialect, @@ -143,7 +75,7 @@ impl PySerializer { fn __hash__(&self) -> u64 { use std::hash::{Hash, Hasher}; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); + let mut hasher = std::hash::DefaultHasher::new(); self.0.hash(&mut hasher); hasher.finish() } @@ -158,24 +90,70 @@ macro_rules! impl_unit_serializers { ) => { $( $(#[$outer])* - #[rapidquery_macros::pyclass( - $pyname, - extends = PySerializer, - module = "rapidquery._core.data_type" - )] + #[rapidquery_macros::pyclass("rapidquery.data_type" + $pyname, extends = PySerializer)] #[derive(Debug, Default)] pub struct $name; #[pyo3::pymethods] impl $name { #[new] - #[inline] - fn __new__() -> pyo3::PyClassInitializer { - pyo3::PyClassInitializer::from(PySerializer(SerializerEnum::$variant)) - .add_subclass(Self) + #[pyo3(signature = (prefix=None, suffix=None))] + fn __new__(prefix: Option<&str>, suffix: Option<&str>) -> pyo3::PyClassInitializer { + let inner = SerializerInner::new( + SerializerType::$variant, + prefix.map(arcstr::ArcStr::from), + suffix.map(arcstr::ArcStr::from), + ); + pyo3::PyClassInitializer::from(PySerializer(inner)).add_subclass(Self) } } )+ + + pub(super) fn serializer_type_to_pyname(serializer_type: &SerializerType) -> &'static str { + match serializer_type { + $( + SerializerType::$variant {..} => $pyname, + )+ + + SerializerType::Array(_) => "ArraySerializer", + SerializerType::Custom(_) => "CustomSerializer", + } + } + + impl PySerializer { + /// Wrap `self` as the matching Python subclass. + pub fn into_subclasses<'py>( + self, + py: pyo3::Python<'py>, + ) -> pyo3::PyResult> { + match &self.0.r#type { + $( + SerializerType::$variant {..} => { + pyo3::Bound::new( + py, + pyo3::PyClassInitializer::from(self).add_subclass($name), + ) + .map(|x| x.into_any()) + } + )+ + + SerializerType::Array(_) => { + pyo3::Bound::new( + py, + pyo3::PyClassInitializer::from(self).add_subclass(PyArraySerializer), + ) + .map(|x| x.into_any()) + }, + SerializerType::Custom(_) => { + pyo3::Bound::new( + py, + pyo3::PyClassInitializer::from(self).add_subclass(PyCustomSerializer), + ) + .map(|x| x.into_any()) + } + } + } + } }; } @@ -305,11 +283,7 @@ impl_unit_serializers! { /// Raises: /// TypeError: If the value is not a `list` or `tuple`. /// NotImplementedError: If the dialect has no `ARRAY[…]` constructor. -#[rapidquery_macros::pyclass( - "ArraySerializer", - extends = PySerializer, - module = "rapidquery._core.data_type" -)] +#[rapidquery_macros::pyclass("rapidquery.data_type.ArraySerializer", extends = PySerializer)] #[derive(Debug, Default)] pub struct PyArraySerializer; @@ -322,8 +296,15 @@ impl PyArraySerializer { /// are expressed by passing another `ArraySerializer`. #[new] #[inline] - fn __new__(element: PySerializer) -> pyo3::PyClassInitializer { - let inner = SerializerEnum::Array(Box::new(element.0)); + #[pyo3(signature = (element, prefix=None, suffix=None))] + fn __new__( + element: PySerializer, + prefix: Option<&str>, + suffix: Option<&str>, + ) -> pyo3::PyClassInitializer { + let type_ = SerializerType::Array(Box::new(element.0)); + let inner = SerializerInner::new(type_, prefix.map(Into::into), suffix.map(Into::into)); + pyo3::PyClassInitializer::from(PySerializer(inner)).add_subclass(Self) } @@ -334,11 +315,11 @@ impl PyArraySerializer { #[getter] fn get_element(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { let base = slf.as_super(); - match &base.0 { - SerializerEnum::Array(element) => { + match &base.0.r#type { + SerializerType::Array(element) => { PySerializer((**element).clone()).into_subclasses(slf.py()) } - _ => unreachable!("ArraySerializer base is always Array"), + _ => unsafe { std::hint::unreachable_unchecked() }, } } } @@ -348,11 +329,7 @@ impl PyArraySerializer { /// `function` has the form `fn(dialect_name: str, obj) -> str`. The return /// value is inserted into SQL as-is (not re-quoted or escaped). It is the /// caller's responsibility to produce a safe fragment. -#[rapidquery_macros::pyclass( - "CustomSerializer", - extends = PySerializer, - module = "rapidquery._core.data_type" -)] +#[rapidquery_macros::pyclass("rapidquery.data_type.CustomSerializer", extends = PySerializer)] #[derive(Debug, Default)] pub struct PyCustomSerializer; @@ -365,7 +342,12 @@ impl PyCustomSerializer { /// The return value is inserted into SQL as-is (not re-quoted). /// Must return a `str`. #[new] - fn __new__(function: alias::PyObject) -> pyo3::PyResult> { + #[pyo3(signature = (function, prefix=None, suffix=None))] + fn __new__( + function: alias::PyObject, + prefix: Option<&str>, + suffix: Option<&str>, + ) -> pyo3::PyResult> { unsafe { if pyo3::ffi::PyCallable_Check(function.as_ptr()) != 1 { return rapidquery_macros::pyerr!( @@ -374,7 +356,9 @@ impl PyCustomSerializer { ); } } - let inner = SerializerEnum::Custom(function); + let type_ = SerializerType::Custom(function); + let inner = SerializerInner::new(type_, prefix.map(Into::into), suffix.map(Into::into)); + Ok(pyo3::PyClassInitializer::from(PySerializer(inner)).add_subclass(Self)) } @@ -384,9 +368,10 @@ impl PyCustomSerializer { /// The function given as `function` to `__new__`. #[getter] fn get_function(slf: pyo3::PyRef<'_, Self>) -> alias::PyObject { - match &slf.as_super().0 { - SerializerEnum::Custom(function) => function.clone(), - _ => unreachable!("CustomSerializer base is always Custom"), + let base = slf.as_super(); + match &base.0.r#type { + SerializerType::Custom(function) => function.clone(), + _ => unsafe { std::hint::unreachable_unchecked() }, } } } @@ -394,7 +379,7 @@ impl PyCustomSerializer { /// `Serializer` instance, or a `DataType` (its serializer only). /// /// Use [`PySerializer`] itself as an argument if you wanna accept ONLY `PySerializer` types. -pub struct SerializerExtractor(pub SerializerEnum); +pub struct SerializerExtractor(pub SerializerInner); impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for SerializerExtractor { type Error = pyo3::PyErr; diff --git a/src/data_type/serializer/serializer_enum.rs b/src/data_type/serializer/serializer_enum.rs deleted file mode 100644 index 87cc457..0000000 --- a/src/data_type/serializer/serializer_enum.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! Value-serialization tree. -//! -//! [`Serializer`] turns a Python object into a SQL literal. Unit variants are -//! inferred from [`DataTypeGroup`]. [`Serializer::Array`] and -//! [`Serializer::Custom`] carry state (`Custom` holds a Python callable). -//! -//! Python sees frozen subclasses wrapping this enum (`IntegerSerializer`, …). -//! The write path lives in [`super::serialize`]. - -use std::hash::{Hash, Hasher}; - -use crate::internal::alias; -use crate::internal::modules::json_dumps; - -/// How a Python object is written as a SQL literal. -#[derive(Debug, Clone)] -pub enum SerializerEnum { - Integer, - Float, - Boolean, - String, - Uuid, - Bytes, - Bit, - Json, - Date, - Time, - Datetime, - Inet, - MacAddress, - Unspecified, - Array(Box), - Custom(alias::PyObject), -} - -impl PartialEq for SerializerEnum { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (Self::Integer, Self::Integer) - | (Self::Float, Self::Float) - | (Self::Boolean, Self::Boolean) - | (Self::String, Self::String) - | (Self::Uuid, Self::Uuid) - | (Self::Bytes, Self::Bytes) - | (Self::Bit, Self::Bit) - | (Self::Json, Self::Json) - | (Self::Date, Self::Date) - | (Self::Time, Self::Time) - | (Self::Datetime, Self::Datetime) - | (Self::Inet, Self::Inet) - | (Self::MacAddress, Self::MacAddress) - | (Self::Unspecified, Self::Unspecified) => true, - (Self::Array(a), Self::Array(b)) => a == b, - (Self::Custom(a), Self::Custom(b)) => std::ptr::eq(a.as_ptr(), b.as_ptr()), - _ => false, - } - } -} - -impl Eq for SerializerEnum {} - -impl Hash for SerializerEnum { - fn hash(&self, state: &mut H) { - std::mem::discriminant(self).hash(state); - match self { - Self::Array(element) => element.hash(state), - Self::Custom(function) => (function.as_ptr() as usize).hash(state), - _ => {} - } - } -} - -impl SerializerEnum { - /// Built-in serializer for a [`DataTypeGroup`]. - /// - /// [`DataTypeGroup::Custom`] maps to [`Self::Unspecified`]. Json and Jsonb - /// share [`Self::Json`]. - #[inline] - pub fn from_group(kind: crate::data_type::DataTypeGroup) -> Self { - match kind { - crate::data_type::DataTypeGroup::Integer => Self::Integer, - crate::data_type::DataTypeGroup::Float => Self::Float, - crate::data_type::DataTypeGroup::Boolean => Self::Boolean, - crate::data_type::DataTypeGroup::String => Self::String, - crate::data_type::DataTypeGroup::Uuid => Self::Uuid, - crate::data_type::DataTypeGroup::Bytes => Self::Bytes, - crate::data_type::DataTypeGroup::Bit => Self::Bit, - crate::data_type::DataTypeGroup::Json | crate::data_type::DataTypeGroup::Jsonb => { - Self::Json - } - crate::data_type::DataTypeGroup::Date => Self::Date, - crate::data_type::DataTypeGroup::Time => Self::Time, - crate::data_type::DataTypeGroup::Datetime => Self::Datetime, - crate::data_type::DataTypeGroup::Inet => Self::Inet, - crate::data_type::DataTypeGroup::MacAddress => Self::MacAddress, - crate::data_type::DataTypeGroup::Custom => Self::Unspecified, - } - } - - pub fn infer_bound(bound: &alias::BoundObject) -> Self { - unsafe { - let object_ptr = bound.as_ptr(); - let object_type_ptr = pyo3::ffi::Py_TYPE(object_ptr); - - // None / bool -> Boolean - // - // Why `None`? - // Actually we don't use [`Serializer`] on `None` values. - // So it's not important what [`Serializer`] is. - // `None` values are handled by [`crate::sql::SqlWriter`] - if pyo3::ffi::Py_IsNone(object_ptr) == 1 || pyo3::ffi::PyBool_Check(object_ptr) == 1 { - return Self::Boolean; - } - - // float / decimal -> Float - if pyo3::ffi::PyFloat_CheckExact(object_ptr) == 1 - || object_type_ptr == crate::typeref::STD_DECIMAL_TYPE - { - return Self::Float; - } - - // int -> Integer - if pyo3::ffi::PyLong_CheckExact(object_ptr) == 1 { - return Self::Integer; - } - - // str/enum.StrEnum -> String - if pyo3::ffi::PyUnicode_Check(object_ptr) == 1 { - return Self::String; - } - - // bytes -> Bytes - if pyo3::ffi::PyBytes_CheckExact(object_ptr) == 1 { - return Self::Bytes; - } - - // date -> Date - if object_type_ptr == crate::typeref::STD_DATE_TYPE { - return Self::Date; - } - - // time -> Time - if object_type_ptr == crate::typeref::STD_TIME_TYPE { - return Self::Time; - } - - // datetime -> Datetime - if object_type_ptr == crate::typeref::STD_DATETIME_TYPE { - return Self::Datetime; - } - - // UUID -> Uuid - if object_type_ptr == crate::typeref::STD_UUID_TYPE { - return Self::Uuid; - } - - // list/dict -> Json - if (pyo3::ffi::PyList_CheckExact(object_ptr) == 1 - || pyo3::ffi::PyDict_CheckExact(object_ptr) == 1) - && json_dumps(bound.clone(), |_| Ok(())).is_ok() - { - return Self::Json; - } - } - - // * -> Unspecified - Self::Unspecified - } -} diff --git a/src/data_type/tests.rs b/src/data_type/tests.rs index 51ad2a0..ed53acf 100644 --- a/src/data_type/tests.rs +++ b/src/data_type/tests.rs @@ -4,43 +4,43 @@ fn assert_copy(_: T) {} #[test] fn data_type_group_is_copy() { - assert_copy(DataTypeGroup::Integer); + assert_copy(DataTypeKind::Integer); } #[test] fn known_names_are_canonicalized() { let cases = [ - ("int", "INT", DataTypeGroup::Integer), - ("INT", "INT", DataTypeGroup::Integer), - ("Integer", "INTEGER", DataTypeGroup::Integer), - ("integer", "INTEGER", DataTypeGroup::Integer), - ("varchar", "VARCHAR", DataTypeGroup::String), - ("text", "TEXT", DataTypeGroup::String), - ("uuid", "UUID", DataTypeGroup::Uuid), - ("bool", "BOOLEAN", DataTypeGroup::Boolean), - ("boolean", "BOOLEAN", DataTypeGroup::Boolean), + ("int", "INT", DataTypeKind::Integer), + ("INT", "INT", DataTypeKind::Integer), + ("Integer", "INTEGER", DataTypeKind::Integer), + ("integer", "INTEGER", DataTypeKind::Integer), + ("varchar", "VARCHAR", DataTypeKind::String), + ("text", "TEXT", DataTypeKind::String), + ("uuid", "UUID", DataTypeKind::Uuid), + ("bool", "BOOLEAN", DataTypeKind::Boolean), + ("boolean", "BOOLEAN", DataTypeKind::Boolean), ( "timestamptz", "TIMESTAMP WITH TIMEZONE", - DataTypeGroup::Datetime, + DataTypeKind::Datetime, ), ( "timestamp without timezone", "TIMESTAMP", - DataTypeGroup::Datetime, + DataTypeKind::Datetime, ), - ("macaddr", "MACADDRESS", DataTypeGroup::MacAddress), - ("jsonb", "JSONB", DataTypeGroup::Jsonb), + ("macaddr", "MACADDRESS", DataTypeKind::MacAddress), + ("jsonb", "JSONB", DataTypeKind::Jsonb), ( "double precision unsigned", "DOUBLE PRECISION UNSIGNED", - DataTypeGroup::Float, + DataTypeKind::Float, ), ]; for (input, canonical, group) in cases { let dt = DataTypeName::from(input); assert_eq!(dt.as_str(), canonical, "input {input:?}"); - assert_eq!(dt.group(), group, "input {input:?}"); + assert_eq!(dt.kind(), group, "input {input:?}"); } } @@ -50,7 +50,7 @@ fn character_varying_typo_and_correct_spelling() { let typo = DataTypeName::from("characater varying"); assert_eq!(correct.as_str(), "CHARACTER VARYING"); assert_eq!(typo.as_str(), "CHARACTER VARYING"); - assert_eq!(correct.group(), DataTypeGroup::String); + assert_eq!(correct.kind(), DataTypeKind::String); assert_eq!(correct, typo); } @@ -62,7 +62,7 @@ fn unsigned_bigint_typo_and_correct_spelling() { assert_eq!(correct.as_str(), "BIGINT UNSIGNED"); assert_eq!(typo.as_str(), "BIGINT UNSIGNED"); assert_eq!(alias.as_str(), "BIGINT UNSIGNED"); - assert_eq!(correct.group(), DataTypeGroup::Integer); + assert_eq!(correct.kind(), DataTypeKind::Integer); } #[test] @@ -70,7 +70,7 @@ fn parameterized_name_is_custom_and_preserved() { for input in ["varchar(255)", "VARCHAR(255)", "decimal(10, 2)"] { let dt = DataTypeName::from(input); assert_eq!(dt.as_str(), input); - assert_eq!(dt.group(), DataTypeGroup::Custom); + assert_eq!(dt.kind(), DataTypeKind::Custom); } } @@ -78,19 +78,19 @@ fn parameterized_name_is_custom_and_preserved() { fn unknown_name_is_custom_and_preserved() { let dt = DataTypeName::from("some_totally_unknown_type"); assert_eq!(dt.as_str(), "some_totally_unknown_type"); - assert_eq!(dt.group(), DataTypeGroup::Custom); + assert_eq!(dt.kind(), DataTypeKind::Custom); } #[test] fn from_string_reuses_unknown_allocation_path() { let dt = DataTypeName::from(String::from("not_a_real_type")); assert_eq!(dt.as_str(), "not_a_real_type"); - assert_eq!(dt.group(), DataTypeGroup::Custom); + assert_eq!(dt.kind(), DataTypeKind::Custom); } #[test] fn non_ascii_name_is_custom() { let dt = DataTypeName::from("тип"); assert_eq!(dt.as_str(), "тип"); - assert_eq!(dt.group(), DataTypeGroup::Custom); + assert_eq!(dt.kind(), DataTypeKind::Custom); } diff --git a/src/dialect/base.rs b/src/dialect/base.rs index 9a4c45d..72d4b6d 100644 --- a/src/dialect/base.rs +++ b/src/dialect/base.rs @@ -11,12 +11,7 @@ use super::inner::Dialect; /// This base class cannot be instantiated. /// /// Instances of the same dialect compare equal and hash together. -#[rapidquery_macros::pyclass( - "SqlDialect", - subclass, - skip_from_py_object, - module = "rapidquery._core.dialect" -)] +#[rapidquery_macros::pyclass("rapidquery.dialect.SqlDialect", subclass, skip_from_py_object)] #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct PySqlDialect(pub Dialect); diff --git a/src/dialect/builtin.rs b/src/dialect/builtin.rs index b42e12c..002e486 100644 --- a/src/dialect/builtin.rs +++ b/src/dialect/builtin.rs @@ -11,7 +11,7 @@ macro_rules! impl_builtin_dialects { ) => { $( $(#[$docs])* - #[rapidquery_macros::pyclass($pyname, extends = super::base::PySqlDialect, module = "rapidquery._core.dialect")] + #[rapidquery_macros::pyclass("rapidquery._core.dialect" + $pyname, extends = super::base::PySqlDialect)] pub struct $name; impl $name { @@ -38,8 +38,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Simple('?'), bit_format: None, array_format: Some(ArrayFormat::Constructor), - inet_format: None, - mac_format: None, + supports_inet: false, + supports_macaddr: false, null_treatment_syntax: Some(NullTreatmentSyntax::InsideParens), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -48,8 +48,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashHex, bytes_format: BytesFormat::PrefixBigBSlashX, - json_format: JsonFormat::JsonKeyword, - uuid_format: UuidFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::InsideWhere), ilike_expression_options: ILikeExpressionOptions::UseLikeLower, supports_similar_to: false, @@ -75,8 +73,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Simple('?'), bit_format: None, array_format: None, - inet_format: None, - mac_format: None, + supports_inet: false, + supports_macaddr: false, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::PatternList, bool_format: BoolFormat::Keyword, @@ -85,8 +83,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashUnicodeQuote, bytes_format: BytesFormat::PrefixBigX, - json_format: JsonFormat::Quoted, - uuid_format: UuidFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: false, @@ -112,8 +108,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Numbered('$'), bit_format: Some(BitFormat::PrefixBigB), array_format: Some(ArrayFormat::List), - inet_format: Some(InetFormat::Cast), - mac_format: None, + supports_inet: true, + supports_macaddr: false, null_treatment_syntax: Some(NullTreatmentSyntax::InsideParens), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -122,8 +118,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::SlashXBlob, - json_format: JsonFormat::Quoted, - uuid_format: UuidFormat::UuidKeyword, function_filter_syntax: Some(FunctionFilterSyntax::Filter), ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: true, @@ -149,8 +143,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Simple('?'), bit_format: None, array_format: Some(ArrayFormat::Function), - inet_format: None, - mac_format: None, + supports_inet: false, + supports_macaddr: false, null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -159,8 +153,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashUnicode, bytes_format: BytesFormat::Unhex, - json_format: JsonFormat::Quoted, - uuid_format: UuidFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLikeLower, supports_similar_to: false, @@ -186,8 +178,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Simple('?'), bit_format: None, array_format: None, - inet_format: None, - mac_format: None, + supports_inet: false, + supports_macaddr: false, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, @@ -196,8 +188,6 @@ impl_builtin_dialects!( string_format: StringFormat::PrefixBigN, string_escape: StringEscape::Quote, bytes_format: BytesFormat::PrefixZeroX, - json_format: JsonFormat::Quoted, - uuid_format: UuidFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLike, supports_similar_to: false, @@ -223,8 +213,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Simple('?'), bit_format: Some(BitFormat::PrefixSmallB), array_format: None, - inet_format: None, - mac_format: None, + supports_inet: false, + supports_macaddr: false, null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, @@ -233,8 +223,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashMysql, bytes_format: BytesFormat::PrefixBigX, - json_format: JsonFormat::Quoted, - uuid_format: UuidFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLike, supports_similar_to: false, @@ -260,8 +248,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Numbered(':'), bit_format: None, array_format: None, - inet_format: None, - mac_format: None, + supports_inet: false, + supports_macaddr: false, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, @@ -270,8 +258,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::HexToRaw, - json_format: JsonFormat::Quoted, - uuid_format: UuidFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::UseLikeLower, supports_similar_to: false, @@ -297,8 +283,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Numbered('$'), bit_format: Some(BitFormat::PrefixBigB), array_format: Some(ArrayFormat::Constructor), - inet_format: Some(InetFormat::Cast), - mac_format: Some(MacFormat::Cast), + supports_inet: true, + supports_macaddr: true, null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::ArrayConstructor, bool_format: BoolFormat::Keyword, @@ -307,8 +293,6 @@ impl_builtin_dialects!( string_format: StringFormat::PrefixBigE, string_escape: StringEscape::BackslashHex, bytes_format: BytesFormat::SlashXBytea, - json_format: JsonFormat::CastJsonb, - uuid_format: UuidFormat::CastUuid, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: true, @@ -334,8 +318,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Numbered('$'), bit_format: None, array_format: None, - inet_format: None, - mac_format: None, + supports_inet: false, + supports_macaddr: false, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -344,8 +328,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::ToVarbyte, - json_format: JsonFormat::Quoted, - uuid_format: UuidFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: true, @@ -371,8 +353,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Numbered(':'), bit_format: None, array_format: None, - inet_format: None, - mac_format: None, + supports_inet: false, + supports_macaddr: false, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::PatternList, bool_format: BoolFormat::Keyword, @@ -381,8 +363,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashHexZero, bytes_format: BytesFormat::ToBinary, - json_format: JsonFormat::ParseJson, - uuid_format: UuidFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: false, @@ -408,8 +388,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Simple('?'), bit_format: None, array_format: Some(ArrayFormat::Function), - inet_format: None, - mac_format: None, + supports_inet: false, + supports_macaddr: false, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::PatternList, bool_format: BoolFormat::Keyword, @@ -418,8 +398,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashUnicodeQuote, bytes_format: BytesFormat::PrefixBigX, - json_format: JsonFormat::Quoted, - uuid_format: UuidFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: false, @@ -445,8 +423,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Numbered('?'), bit_format: None, array_format: None, - inet_format: None, - mac_format: None, + supports_inet: false, + supports_macaddr: false, null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, @@ -455,8 +433,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::PrefixBigX, - json_format: JsonFormat::Quoted, - uuid_format: UuidFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::UseLike, supports_similar_to: false, @@ -482,8 +458,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Simple('?'), bit_format: Some(BitFormat::PostfixBigB), array_format: None, - inet_format: None, - mac_format: None, + supports_inet: false, + supports_macaddr: false, null_treatment_syntax: Some(NullTreatmentSyntax::InsideParens), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -492,8 +468,6 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::PostfixXbv, - json_format: JsonFormat::Quoted, - uuid_format: UuidFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLikeLower, supports_similar_to: false, diff --git a/src/dialect/inner.rs b/src/dialect/inner.rs index c24c25f..d88bd0a 100644 --- a/src/dialect/inner.rs +++ b/src/dialect/inner.rs @@ -436,6 +436,7 @@ pub enum FunctionFilterSyntax { InsideWhere, } +/// The way dialect accepts array values. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ArrayFormat { /// `ARRAY[…]` @@ -446,42 +447,6 @@ pub enum ArrayFormat { Function, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum InetFormat { - Quoted, - /// `'…'::inet` - Cast, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum MacFormat { - Quoted, - /// `'…'::macaddr` - Cast, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum JsonFormat { - Quoted, - /// `JSON '…'` - JsonKeyword, - /// `PARSE_JSON('…')` - ParseJson, - /// `'…'::json` - CastJson, - /// `'…'::jsonb` - CastJsonb, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum UuidFormat { - Quoted, - /// `UUID '…'` - UuidKeyword, - /// `'…'::uuid` - CastUuid, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Dialect { /// Positional parameter syntax. @@ -490,9 +455,12 @@ pub struct Dialect { /// /// Set [`Option::None`] if literal bit isn't supported. pub bit_format: Option, + /// How the dialect accepts array values if supported. pub array_format: Option, - pub inet_format: Option, - pub mac_format: Option, + /// Whether INET data type is supported + pub supports_inet: bool, + /// Whether MACADDR data type is supported + pub supports_macaddr: bool, /// How the dialect supports `{IGNORE|RESPECT} NULLS` expressions in `Function`s if supported. pub null_treatment_syntax: Option, /// How `LIKE ANY` / `ILIKE ANY` is rendered. @@ -509,8 +477,6 @@ pub struct Dialect { pub string_escape: StringEscape, /// How the dialect accepts literal bytes values. pub bytes_format: BytesFormat, - pub json_format: JsonFormat, - pub uuid_format: UuidFormat, /// How function calling `FILTER` keyword rendered if supported. pub function_filter_syntax: Option, /// How case-insensitive `LIKE` is spelled (`ILIKE`, `LIKE`, or `LOWER` + `LIKE`). @@ -893,8 +859,8 @@ mod tests { positional_parameter: PositionalParameter::Simple(':'), bit_format: None, array_format: None, - inet_format: None, - mac_format: None, + supports_inet: false, + supports_macaddr: false, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -903,8 +869,6 @@ mod tests { string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::PrefixBigX, - json_format: JsonFormat::Quoted, - uuid_format: UuidFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLike, supports_similar_to: false, diff --git a/src/expr/base.rs b/src/expr/base.rs index 82cfd70..0300142 100644 --- a/src/expr/base.rs +++ b/src/expr/base.rs @@ -17,12 +17,7 @@ use super::enum_::ExprInner; /// /// Concrete nodes are frozen subclasses (`Ident`, `Value`, `Func`, /// `Binary`, …). This base class cannot be instantiated. -#[rapidquery_macros::pyclass( - "Expr", - subclass, - skip_from_py_object, - module = "rapidquery._core.expr" -)] +#[rapidquery_macros::pyclass("rapidquery.expr.Expr", subclass, skip_from_py_object)] #[derive(Debug, Clone)] pub struct PyExpr(pub ExprInner); diff --git a/src/expr/enum_/value.rs b/src/expr/enum_/value.rs index c2a9fee..7a8c0d6 100644 --- a/src/expr/enum_/value.rs +++ b/src/expr/enum_/value.rs @@ -2,7 +2,7 @@ use pyo3::types::{PyAnyMethods, PyStringMethods}; -use crate::data_type::{SerializerEnum, serializer_repr}; +use crate::data_type::{SerializerInner, serializer_repr}; use crate::dialect::PySqlDialect; use crate::internal::alias; use crate::sql::{PrepareResult, SqlWriter, WriteSql}; @@ -17,19 +17,19 @@ use crate::sql::{PrepareResult, SqlWriter, WriteSql}; #[derive(Debug, Clone)] pub struct Value { pub object: alias::PyObject, - pub serializer: SerializerEnum, + pub serializer: SerializerInner, } impl Value { #[inline] - pub fn new(object: alias::PyObject, serializer: SerializerEnum) -> Self { + pub fn new(object: alias::PyObject, serializer: SerializerInner) -> Self { Self { object, serializer } } #[inline] pub fn infer_bound(bound: alias::BoundObject) -> Self { - let serializer = SerializerEnum::infer_bound(&bound); - Self::new(bound.unbind(), serializer) + let serializer = crate::data_type::SerializerType::infer_bound(&bound); + Self::new(bound.unbind(), serializer.into()) } pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { diff --git a/src/expr/extractors.rs b/src/expr/extractors.rs index c709d25..6df36e6 100644 --- a/src/expr/extractors.rs +++ b/src/expr/extractors.rs @@ -64,7 +64,7 @@ fn extract_expr( return Ok(ExprInner::Value(super::enum_::Value::new( obj.as_any().clone().unbind(), - crate::data_type::SerializerEnum::String, + crate::data_type::SerializerType::String.into(), ))); } diff --git a/src/expr/func.rs b/src/expr/func.rs index 832caa2..9e96adc 100644 --- a/src/expr/func.rs +++ b/src/expr/func.rs @@ -16,12 +16,7 @@ use super::extractors::{ConditionExtractor, IdentExtractor, LiteralExprExtractor /// /// Args: /// name: Function name (`str`, `Ident`, `Unquote`). -#[rapidquery_macros::pyclass( - "Function", - skip_from_py_object, - extends = super::base::PyExpr, - module = "rapidquery._core.expr" -)] +#[rapidquery_macros::pyclass("rapidquery.expr.Function", skip_from_py_object, extends = PyExpr)] #[derive(Debug, Default)] pub struct PyFunction; diff --git a/src/expr/ident.rs b/src/expr/ident.rs index 6d842a0..2a8df0e 100644 --- a/src/expr/ident.rs +++ b/src/expr/ident.rs @@ -41,18 +41,13 @@ fn ident_part_to_pyobject<'py>( /// ValueError: If there are no parts, a part is empty, `Asterisk` is /// not last, an `Unquote` body is not a simple ASCII name, or /// there are more than 10 parts. -#[rapidquery_macros::pyclass( - "Ident", - skip_from_py_object, - extends = super::base::PyExpr, - module = "rapidquery._core.expr" -)] +#[rapidquery_macros::pyclass("rapidquery.expr.Ident", skip_from_py_object, extends = super::base::PyExpr)] #[derive(Debug, Default)] pub struct PyIdent; impl PyIdent { #[inline(always)] - fn inner<'a>(slf: &'a pyo3::PyRef<'a, PyIdent>) -> &'a Ident { + fn inner<'a>(slf: &'a pyo3::PyRef<'a, Self>) -> &'a Ident { debug_assert!( matches!(&slf.as_super().0, super::enum_::ExprInner::Ident(..)), "PyIdent instance always holds ExprInner::Ident" @@ -170,13 +165,7 @@ impl PyIdent { /// Raises: /// ValueError: If `name` is empty or not a simple ASCII identifier /// (letters, digits, underscore; first character not a digit). -#[rapidquery_macros::pyclass( - "Unquote", - skip_from_py_object, - eq, - hash, - module = "rapidquery._core.expr" -)] +#[rapidquery_macros::pyclass("rapidquery.expr.Unquote", skip_from_py_object, eq, hash)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct PyUnquote(pub arcstr::ArcStr); @@ -210,13 +199,7 @@ impl PyUnquote { /// Pass the class (`Asterisk`) or an instance (`Asterisk()`) as an /// identifier part. It must be the last part of an `Ident` (`t.*`). /// `Ident("*")` is a quoted column named star, not this wildcard. -#[rapidquery_macros::pyclass( - "Asterisk", - skip_from_py_object, - eq, - hash, - module = "rapidquery._core.expr" -)] +#[rapidquery_macros::pyclass("rapidquery.expr.Asterisk", skip_from_py_object, eq, hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct PyAsterisk; diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index 7967cb5..17161d1 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -17,12 +17,7 @@ macro_rules! empty_node { ) => { $( $(#[$meta])* - #[rapidquery_macros::pyclass( - $pyname, - skip_from_py_object, - extends = PyExpr, - module = "rapidquery._core.expr" - )] + #[rapidquery_macros::pyclass("rapidquery.expr" + $pyname, skip_from_py_object, extends = PyExpr)] #[derive(Debug, Default)] pub struct $name; @@ -32,7 +27,6 @@ macro_rules! empty_node { debug_assert!( matches!(&slf.as_super().0, ExprInner::$variant(..)), concat!(stringify!($name), " instance always holds ExprInner::", stringify!($variant)) - ); match &slf.as_super().0 { diff --git a/src/expr/value.rs b/src/expr/value.rs index 3a936f7..dd164e4 100644 --- a/src/expr/value.rs +++ b/src/expr/value.rs @@ -15,13 +15,7 @@ use super::enum_::Value; /// Passing an existing `Value` keeps its object (and its serializer, unless /// a new one is given). A `DataType` as `serializer` contributes only its /// serializer; the data type is not stored. -#[rapidquery_macros::pyclass( - "Value", - skip_from_py_object, - extends = super::base::PyExpr, - module = "rapidquery._core.expr", - generic -)] +#[rapidquery_macros::pyclass("rapidquery.expr.Value", skip_from_py_object, extends = super::base::PyExpr, generic)] #[derive(Debug, Default)] pub struct PyValue; diff --git a/src/sql.rs b/src/sql.rs index 877f8d6..7c27fac 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -9,7 +9,7 @@ use std::fmt::Write; use pyo3::types::PyAnyMethods; -use crate::data_type::SerializerEnum; +use crate::data_type::SerializerInner; use crate::dialect::PySqlDialect; use crate::dialect::inner::{FormatError, PositionalParameter}; use crate::internal::alias; @@ -135,7 +135,7 @@ pub trait SqlWriter: std::fmt::Write { fn push_value( &mut self, dialect: &PySqlDialect, - serializer: &SerializerEnum, + serializer: &SerializerInner, object: &alias::PyObject, ) -> PrepareResult<()>; } @@ -195,7 +195,7 @@ impl<'py> SqlWriter for BufferedSql<'py> { fn push_value( &mut self, dialect: &PySqlDialect, - serializer: &SerializerEnum, + serializer: &SerializerInner, object: &alias::PyObject, ) -> PrepareResult<()> { let bound = object.bind(self.py); @@ -312,7 +312,7 @@ impl SqlWriter for ParameterizedSql<'_> { fn push_value( &mut self, dialect: &PySqlDialect, - _serializer: &SerializerEnum, + _serializer: &SerializerInner, object: &alias::PyObject, ) -> PrepareResult<()> { self.push_param(dialect, object.clone_ref(self.py)); From 352d6b71bacd769a728e1865a51d3ef66ddaea70 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Thu, 1 Oct 2026 12:43:14 +0330 Subject: [PATCH 59/87] Add rustfmt.toml; restore inet_format, macaddr_format, bytes_format, date_format, time_format, timestamp_format, uuid_format, json_format; remove prefix/suffix parameters from serializers and collapse SerializerInner into SerializerType --- rapidquery-macros/lib.rs | 3 +- rapidquery/data_type.pyi | 31 ++-- rustfmt.toml | 2 + src/data_type/mod.rs | 56 +++++-- src/data_type/name.rs | 4 +- src/data_type/serializer/functions.rs | 225 ++++++++++++++++++-------- src/data_type/serializer/inner.rs | 70 +------- src/data_type/serializer/mod.rs | 102 ++++-------- src/dialect/base.rs | 3 +- src/dialect/builtin.rs | 117 +++++++++++--- src/dialect/inner.rs | 107 ++++++++++-- src/dialect/mod.rs | 48 +++++- src/expr/base.rs | 3 +- src/expr/enum_/func.rs | 8 +- src/expr/enum_/ident.rs | 5 +- src/expr/enum_/like.rs | 13 +- src/expr/enum_/mod.rs | 8 +- src/expr/enum_/nodes.rs | 26 ++- src/expr/enum_/value.rs | 18 ++- src/expr/extractors.rs | 14 +- src/expr/func.rs | 10 +- src/expr/ident.rs | 11 +- src/expr/mod.rs | 40 ++++- src/expr/nodes.rs | 13 +- src/expr/value.rs | 11 +- src/internal/modules/json.rs | 4 +- src/internal/utils/mod.rs | 3 +- src/internal/utils/strings.rs | 3 +- src/internal/utils/submodule_ext.rs | 4 +- src/lib.rs | 2 +- src/sql.rs | 15 +- tests/test_serializers.py | 45 +++--- 32 files changed, 675 insertions(+), 349 deletions(-) create mode 100644 rustfmt.toml diff --git a/rapidquery-macros/lib.rs b/rapidquery-macros/lib.rs index 9262ff5..6949979 100644 --- a/rapidquery-macros/lib.rs +++ b/rapidquery-macros/lib.rs @@ -1,6 +1,7 @@ use proc_macro::TokenStream; use quote::quote; -use syn::parse::{Parse, ParseStream}; +use syn::parse::Parse; +use syn::parse::ParseStream; struct PyClassInput { name: String, diff --git a/rapidquery/data_type.pyi b/rapidquery/data_type.pyi index 5b9222c..32049b7 100644 --- a/rapidquery/data_type.pyi +++ b/rapidquery/data_type.pyi @@ -106,9 +106,9 @@ class StringSerializer(Serializer[str | enum.Enum]): @typing.final class UuidSerializer(Serializer[uuid.UUID | str]): """ - Serializes UUIDs as quoted SQL string literals. + Serializes UUIDs using the dialect's ``uuid_format``. - Accepts `uuid.UUID` or `str`. + Accepts `uuid.UUID` or `str`. A quoted literal, ``UUID '…'``, or ``'…'::uuid``. Raises: TypeError: If the value is not a `uuid.UUID` or `str`. @@ -149,9 +149,10 @@ class BitSerializer(Serializer[str]): @typing.final class JSONSerializer(Serializer[typing.Any]): """ - Serializes a JSON-encodable object as a quoted SQL string literal. + Serializes a JSON-encodable object using the dialect's ``json_format``. - Accepts any object that the JSON encoder can dump. Uses `orjson` when it + A quoted literal, ``JSON '…'``, or ``PARSE_JSON('…')``. Accepts any object + that the JSON encoder can dump. Uses `orjson` when it is installed, otherwise the stdlib `json` module. The JSON text is then wrapped with the dialect's string quoting. With `orjson`, datetime, UUID, enum.StrEnum, and dataclass are encoded natively; stdlib `json` @@ -166,7 +167,9 @@ class JSONSerializer(Serializer[typing.Any]): @typing.final class DateSerializer(Serializer[datetime.date]): """ - Serializes dates as quoted `YYYY-MM-DD` literals. + Serializes dates as `YYYY-MM-DD` literals. + + ``date_format`` chooses a quoted literal or ``DATE`` plus that literal. Accepts exact `datetime.date` (not `datetime.datetime`). @@ -179,7 +182,9 @@ class DateSerializer(Serializer[datetime.date]): @typing.final class TimeSerializer(Serializer[datetime.time]): """ - Serializes times as quoted `HH:MM:SS[.ffffff]` literals. + Serializes times as `HH:MM:SS[.ffffff]` literals. + + ``time_format`` chooses a quoted literal or ``TIME`` plus that literal. Accepts `datetime.time`. @@ -192,11 +197,13 @@ class TimeSerializer(Serializer[datetime.time]): @typing.final class DatetimeSerializer(Serializer[datetime.datetime]): """ - Serializes timestamps as quoted SQL timestamp literals. + Serializes timestamps as SQL timestamp literals. Accepts naive or aware `datetime.datetime`, or `int`/`float` UTC epoch seconds. Naive values keep wall time. Aware values keep their original - offset (they are not converted to UTC). + offset (they are not converted to UTC). When ``timestamp_format`` sets a + keyword, naive values use ``TIMESTAMP`` and aware values (including epoch + numbers) use ``TIMESTAMPTZ`` or ``TIMESTAMP WITH TIMEZONE``. Raises: TypeError: If the value is not a `datetime.datetime`, `int`, or `float`. @@ -208,7 +215,9 @@ class DatetimeSerializer(Serializer[datetime.datetime]): @typing.final class InetSerializer(Serializer[typing.Any]): """ - Serializes IP addresses and networks as quoted SQL literals. + Serializes IP addresses and networks using the dialect's ``inet_format``. + + A quoted literal or ``'…'::inet``. Accepts `str` or `ipaddress` objects (`IPv4Address`, `IPv6Address`, `IPv4Network`, `IPv6Network`, and related types whose `str()` is a valid @@ -224,7 +233,9 @@ class InetSerializer(Serializer[typing.Any]): @typing.final class MacAddressSerializer(Serializer[str]): """ - Serializes MAC addresses as quoted SQL literals. + Serializes MAC addresses using the dialect's ``macaddr_format``. + + A quoted literal or ``'…'::macaddr``. Accepts a `str` using `:` or `-` as the separator. Supported on PostgreSQL. diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..9c53693 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +imports_granularity = "Item" +single_line_let_else_max_width = 100 diff --git a/src/data_type/mod.rs b/src/data_type/mod.rs index 4c11d1b..7e798e4 100644 --- a/src/data_type/mod.rs +++ b/src/data_type/mod.rs @@ -12,10 +12,12 @@ mod serializer; #[cfg(test)] mod tests; -pub use name::{DataTypeKind, DataTypeName}; +pub use name::DataTypeKind; +pub use name::DataTypeName; pub use serializer::*; -use crate::internal::{alias, utils}; +use crate::internal::alias; +use crate::internal::utils; /// Represents a SQL data type. /// @@ -40,12 +42,12 @@ use crate::internal::{alias, utils}; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct PyDataType { pub name: DataTypeName, - pub serializer: SerializerInner, + pub serializer: SerializerType, } impl PyDataType { #[inline] - pub fn new(name: DataTypeName, serializer: SerializerInner) -> Self { + pub fn new(name: DataTypeName, serializer: SerializerType) -> Self { Self { name, serializer } } @@ -55,7 +57,7 @@ impl PyDataType { #[inline] pub fn from_name(name: impl Into) -> Self { let name = name.into(); - let serializer = SerializerInner::new(SerializerType::from_kind(name.kind()), None, None); + let serializer = SerializerType::from_kind(name.kind()); Self { name, serializer } } @@ -137,7 +139,7 @@ impl PyDataType { object: alias::BoundObject<'_>, ) -> pyo3::PyResult { let mut buf = String::new(); - self.serializer.write_sql(&dialect, &object, &mut buf)?; + self.serializer.serialize(&dialect, &object, &mut buf)?; Ok(buf) } @@ -186,11 +188,39 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for DataTypeNameExtractor { #[pyo3::pymodule(name = "data_type", gil_used = false)] pub mod data_type_mod { #[pymodule_export] - use super::{ - PyArraySerializer, PyBitSerializer, PyBooleanSerializer, PyBytesSerializer, - PyCustomSerializer, PyDataType, PyDateSerializer, PyDatetimeSerializer, PyFloatSerializer, - PyInetSerializer, PyIntegerSerializer, PyJSONSerializer, PyMacAddressSerializer, - PySerializer, PyStringSerializer, PyTimeSerializer, PyUnspecifiedSerializer, - PyUuidSerializer, - }; + use super::PyArraySerializer; + #[pymodule_export] + use super::PyBitSerializer; + #[pymodule_export] + use super::PyBooleanSerializer; + #[pymodule_export] + use super::PyBytesSerializer; + #[pymodule_export] + use super::PyCustomSerializer; + #[pymodule_export] + use super::PyDataType; + #[pymodule_export] + use super::PyDateSerializer; + #[pymodule_export] + use super::PyDatetimeSerializer; + #[pymodule_export] + use super::PyFloatSerializer; + #[pymodule_export] + use super::PyInetSerializer; + #[pymodule_export] + use super::PyIntegerSerializer; + #[pymodule_export] + use super::PyJSONSerializer; + #[pymodule_export] + use super::PyMacAddressSerializer; + #[pymodule_export] + use super::PySerializer; + #[pymodule_export] + use super::PyStringSerializer; + #[pymodule_export] + use super::PyTimeSerializer; + #[pymodule_export] + use super::PyUnspecifiedSerializer; + #[pymodule_export] + use super::PyUuidSerializer; } diff --git a/src/data_type/name.rs b/src/data_type/name.rs index ed3d701..c661c52 100644 --- a/src/data_type/name.rs +++ b/src/data_type/name.rs @@ -1,4 +1,6 @@ -use crate::sql::{PrepareResult, SqlWriter, WriteSql}; +use crate::sql::PrepareResult; +use crate::sql::SqlWriter; +use crate::sql::WriteSql; /// Category used to infer a built-in serializer from a type name. /// diff --git a/src/data_type/serializer/functions.rs b/src/data_type/serializer/functions.rs index 201290c..6b778f9 100644 --- a/src/data_type/serializer/functions.rs +++ b/src/data_type/serializer/functions.rs @@ -1,13 +1,21 @@ -use pyo3::types::{PyAnyMethods, PyTzInfoAccess}; - -use crate::{ - dialect::{ - PySqlDialect, - inner::{ArrayFormat, Dialect, FormatError, StringFormat}, - }, - internal::{alias, utils}, -}; -use std::{fmt::Write, str::FromStr}; +use pyo3::types::PyAnyMethods; +use pyo3::types::PyTzInfoAccess; + +use crate::dialect::PySqlDialect; +use crate::dialect::inner::ArrayFormat; +use crate::dialect::inner::DateFormat; +use crate::dialect::inner::Dialect; +use crate::dialect::inner::FormatError; +use crate::dialect::inner::InetFormat; +use crate::dialect::inner::JsonFormat; +use crate::dialect::inner::MacAddrFormat; +use crate::dialect::inner::TimeFormat; +use crate::dialect::inner::TimestampFormat; +use crate::dialect::inner::UuidFormat; +use crate::internal::alias; +use crate::internal::utils; +use std::fmt::Write; +use std::str::FromStr; /// `str(obj)` copied into a Rust `String`. /// @@ -27,23 +35,33 @@ pub(super) unsafe fn pyobject_string( out } -/// Open a string literal and write `body` inside it. -/// -/// # Note -/// `body` must be digits, `-`, `:`, `.`, `+`, and space only: no quote, backslash, NUL, or C0. -#[inline] -fn write_quoted_body( +fn write_quoted(dialect: &Dialect, out: &mut String, raw: &str) -> pyo3::PyResult<()> { + dialect.write_string_literal(out, raw).map_err(Into::into) +} + +fn write_with_keyword( dialect: &Dialect, out: &mut String, - body_fn: impl FnOnce(&mut String) -> std::fmt::Result, + keyword: Option<&str>, + raw: &str, ) -> pyo3::PyResult<()> { - match dialect.string_format { - StringFormat::PrefixBigN => out.push_str("N'"), - StringFormat::SingleQuote | StringFormat::PrefixBigE => out.push('\''), + if let Some(keyword) = keyword { + out.push_str(keyword); + out.push(' '); + } + write_quoted(dialect, out, raw) +} + +fn timestamp_keyword(format: TimestampFormat, aware: bool) -> Option<&'static str> { + match format { + TimestampFormat::Quoted => None, + TimestampFormat::Timestamptz => Some(if aware { "TIMESTAMPTZ" } else { "TIMESTAMP" }), + TimestampFormat::WithTimezone => Some(if aware { + "TIMESTAMP WITH TIMEZONE" + } else { + "TIMESTAMP" + }), } - body_fn(out).map_err(|_| rapidquery_macros::pyerr!(PyRuntimeError, "failed to write SQL"))?; - out.push('\''); - Ok(()) } #[inline] @@ -162,24 +180,34 @@ pub(super) unsafe fn write_uuid( dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { - if crate::typeref::check_exact(ptr, crate::typeref::STD_UUID_TYPE) { - let rendered = pyobject_string(py, ptr)?; - return write_quoted_body(dialect, out, |w| write!(w, "{}", rendered)); - } - - if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { + let text = if crate::typeref::check_exact(ptr, crate::typeref::STD_UUID_TYPE) { + pyobject_string(py, ptr)? + } else if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { let borrowed = utils::borrow_py_str_buffer(py, ptr)?; - let parsed = uuid::Uuid::try_parse(borrowed) - .map_err(|e| rapidquery_macros::pyerr!(PyValueError, "invalid uuid str: {}", e))?; + uuid::Uuid::try_parse(borrowed) + .map_err(|e| rapidquery_macros::pyerr!(PyValueError, "invalid uuid str: {}", e))? + .hyphenated() + .to_string() + } else { + return rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected uuid.UUID/str for UUID serialization, got {}", + utils::get_type_name(py, ptr) + ); + }; - return write_quoted_body(dialect, out, |w| write!(w, "{}", parsed.hyphenated())); + match dialect.uuid_format { + UuidFormat::Quoted => write_quoted(dialect, out, &text), + UuidFormat::UuidKeyword => { + out.push_str("UUID "); + write_quoted(dialect, out, &text) + } + UuidFormat::CastUuid => { + write_quoted(dialect, out, &text)?; + out.push_str("::uuid"); + Ok(()) + } } - - rapidquery_macros::pyerr!( - Err(PyTypeError), - "expected uuid.UUID/str for UUID serialization, got {}", - utils::get_type_name(py, ptr) - ) } #[inline] @@ -243,7 +271,19 @@ pub(super) unsafe fn write_json( ) -> pyo3::PyResult<()> { crate::internal::modules::json_dumps( unsafe { pyo3::Bound::from_borrowed_ptr(py, ptr) }, - |text| dialect.write_string_literal(out, text).map_err(Into::into), + |text| match dialect.json_format { + JsonFormat::Quoted => write_quoted(dialect, out, text), + JsonFormat::JsonKeyword => { + out.push_str("JSON "); + write_quoted(dialect, out, text) + } + JsonFormat::ParseJson => { + out.push_str("PARSE_JSON("); + write_quoted(dialect, out, text)?; + out.push(')'); + Ok(()) + } + }, ) } @@ -251,6 +291,7 @@ pub(super) unsafe fn write_json( pub(super) unsafe fn write_date( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, + dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { if crate::typeref::check_exact(ptr, crate::typeref::STD_DATE_TYPE) { @@ -258,9 +299,13 @@ pub(super) unsafe fn write_date( pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; let date: chrono::NaiveDate = val.extract()?; - - let _ = date.format("%Y-%m-%d").write_to(out); - Ok(()) + let mut body = String::new(); + let _ = date.format("%Y-%m-%d").write_to(&mut body); + let keyword = match dialect.date_format { + DateFormat::Quoted => None, + DateFormat::Keyword => Some("DATE"), + }; + write_with_keyword(dialect, out, keyword, &body) } else { rapidquery_macros::pyerr!( Err(PyTypeError), @@ -274,6 +319,7 @@ pub(super) unsafe fn write_date( pub(super) unsafe fn write_time( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, + dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { if crate::typeref::check_exact(ptr, crate::typeref::STD_TIME_TYPE) { @@ -281,8 +327,13 @@ pub(super) unsafe fn write_time( pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; let time: chrono::NaiveTime = val.extract()?; - let _ = time.format("%H:%M:%S%.f").write_to(out); - Ok(()) + let mut body = String::new(); + let _ = time.format("%H:%M:%S%.f").write_to(&mut body); + let keyword = match dialect.time_format { + TimeFormat::Quoted => None, + TimeFormat::Keyword => Some("TIME"), + }; + write_with_keyword(dialect, out, keyword, &body) } else { rapidquery_macros::pyerr!( Err(PyTypeError), @@ -296,6 +347,7 @@ pub(super) unsafe fn write_time( pub(super) unsafe fn write_datetime( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, + dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { use chrono::TimeZone; @@ -306,14 +358,27 @@ pub(super) unsafe fn write_datetime( if val.get_tzinfo().is_none() { let datetime: chrono::NaiveDateTime = val.extract()?; - - let _ = datetime.format("%Y-%m-%d %H:%M:%S%.f").write_to(out); - return Ok(()); + let mut body = String::new(); + let _ = datetime.format("%Y-%m-%d %H:%M:%S%.f").write_to(&mut body); + return write_with_keyword( + dialect, + out, + timestamp_keyword(dialect.timestamp_format, false), + &body, + ); } let datetime: chrono::DateTime = val.extract()?; - let _ = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").write_to(out); - return Ok(()); + let mut body = String::new(); + let _ = datetime + .format("%Y-%m-%d %H:%M:%S%.f%:z") + .write_to(&mut body); + return write_with_keyword( + dialect, + out, + timestamp_keyword(dialect.timestamp_format, true), + &body, + ); } if pyo3::ffi::PyLong_CheckExact(ptr) == 1 { @@ -325,8 +390,16 @@ pub(super) unsafe fn write_datetime( return match chrono::Utc.timestamp_opt(num, 0) { chrono::offset::LocalResult::Single(datetime) => { - let _ = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").write_to(out); - Ok(()) + let mut body = String::new(); + let _ = datetime + .format("%Y-%m-%d %H:%M:%S%.f%:z") + .write_to(&mut body); + write_with_keyword( + dialect, + out, + timestamp_keyword(dialect.timestamp_format, true), + &body, + ) } _ => rapidquery_macros::pyerr!( Err(PyOverflowError), @@ -342,8 +415,16 @@ pub(super) unsafe fn write_datetime( .timestamp_opt(num.trunc() as i64, (num.fract() * 1_000_000_000.0) as u32) { chrono::offset::LocalResult::Single(datetime) => { - let _ = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").write_to(out); - Ok(()) + let mut body = String::new(); + let _ = datetime + .format("%Y-%m-%d %H:%M:%S%.f%:z") + .write_to(&mut body); + write_with_keyword( + dialect, + out, + timestamp_keyword(dialect.timestamp_format, true), + &body, + ) } _ => rapidquery_macros::pyerr!( Err(PyOverflowError), @@ -367,15 +448,20 @@ pub(super) unsafe fn write_inet( dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { - if !dialect.supports_inet { - return Err(FormatError::Unsupported("INET data type").into()); - } - let text = pyobject_string(py, ptr)?; let net = ipnetwork::IpNetwork::from_str(&text) .map_err(|e| rapidquery_macros::pyerr!(PyValueError, e.to_string()))?; - - write_quoted_body(dialect, out, |w| write!(w, "{net}")) + let body = net.to_string(); + + match dialect.inet_format { + None => Err(FormatError::Unsupported("InetSerializer").into()), + Some(InetFormat::Quoted) => write_quoted(dialect, out, &body), + Some(InetFormat::Cast) => { + write_quoted(dialect, out, &body)?; + out.push_str("::inet"); + Ok(()) + } + } } #[inline] @@ -385,22 +471,27 @@ pub(super) unsafe fn write_macaddr( dialect: &Dialect, out: &mut String, ) -> pyo3::PyResult<()> { - if !dialect.supports_macaddr { - return Err(FormatError::Unsupported("MACADDR data type").into()); - } - let text = pyobject_string(py, ptr)?; let addr = mac_address::MacAddress::from_str(&text) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; - - write_quoted_body(dialect, out, |w| write!(w, "{addr}")) + let body = addr.to_string(); + + match dialect.macaddr_format { + None => Err(FormatError::Unsupported("MacAddressSerializer").into()), + Some(MacAddrFormat::Quoted) => write_quoted(dialect, out, &body), + Some(MacAddrFormat::Cast) => { + write_quoted(dialect, out, &body)?; + out.push_str("::macaddr"); + Ok(()) + } + } } pub(super) unsafe fn write_array( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, dialect: &PySqlDialect, - element: &super::inner::SerializerInner, + element: &super::inner::SerializerType, out: &mut String, ) -> pyo3::PyResult<()> { let (len, getter): ( @@ -449,7 +540,7 @@ pub(super) unsafe fn write_array( return Err(pyo3::PyErr::fetch(py)); } let borrowed = alias::BoundObject::from_borrowed_ptr(py, item); - element.write_sql(dialect, &borrowed, out)?; + element.serialize(dialect, &borrowed, out)?; } out.push(close); Ok(()) diff --git a/src/data_type/serializer/inner.rs b/src/data_type/serializer/inner.rs index 197d905..51de927 100644 --- a/src/data_type/serializer/inner.rs +++ b/src/data_type/serializer/inner.rs @@ -1,7 +1,6 @@ -use crate::{ - dialect::PySqlDialect, - internal::{alias, modules}, -}; +use crate::dialect::PySqlDialect; +use crate::internal::alias; +use crate::internal::modules; /// A thin enum which specifies the serializer type. /// @@ -22,20 +21,10 @@ pub enum SerializerType { Inet, MacAddress, Unspecified, - Array(Box), + Array(Box), Custom(alias::PyObject), } -/// How a Python object is written as a SQL literal. -/// -/// It wraps [`SerializerType`] with additional `prefix` and `suffix` fields. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct SerializerInner { - pub r#type: SerializerType, - pub prefix: Option, - pub suffix: Option, -} - impl PartialEq for SerializerType { fn eq(&self, other: &Self) -> bool { match (self, other) { @@ -197,9 +186,9 @@ impl SerializerType { Self::Bytes => super::functions::write_bytes(py, ptr, &dialect.0, out), Self::Bit => super::functions::write_bit(py, ptr, &dialect.0, out), Self::Json => super::functions::write_json(py, ptr, &dialect.0, out), - Self::Date => super::functions::write_date(py, ptr, out), - Self::Time => super::functions::write_time(py, ptr, out), - Self::Datetime => super::functions::write_datetime(py, ptr, out), + Self::Date => super::functions::write_date(py, ptr, &dialect.0, out), + Self::Time => super::functions::write_time(py, ptr, &dialect.0, out), + Self::Datetime => super::functions::write_datetime(py, ptr, &dialect.0, out), Self::Inet => super::functions::write_inet(py, ptr, &dialect.0, out), Self::MacAddress => super::functions::write_macaddr(py, ptr, &dialect.0, out), Self::Array(element) => { @@ -215,48 +204,3 @@ impl SerializerType { } } } - -impl SerializerInner { - #[inline(always)] - pub fn new( - r#type: SerializerType, - prefix: Option, - suffix: Option, - ) -> Self { - Self { - r#type, - prefix, - suffix, - } - } - - /// Serializes the `object` into literal SQL value. - pub fn write_sql( - &self, - dialect: &PySqlDialect, - object: &alias::BoundObject<'_>, - out: &mut String, - ) -> pyo3::PyResult<()> { - if let Some(ref x) = self.prefix { - out.push_str(x.as_str()); - } - - self.r#type.serialize(dialect, object, out)?; - - if let Some(ref x) = self.suffix { - out.push_str(x.as_str()); - } - - Ok(()) - } -} - -impl From for SerializerInner { - fn from(value: SerializerType) -> Self { - Self { - r#type: value, - prefix: None, - suffix: None, - } - } -} diff --git a/src/data_type/serializer/mod.rs b/src/data_type/serializer/mod.rs index 64e676f..fa120de 100644 --- a/src/data_type/serializer/mod.rs +++ b/src/data_type/serializer/mod.rs @@ -3,31 +3,18 @@ mod functions; mod inner; -pub use inner::SerializerInner; pub use inner::SerializerType; use crate::internal::alias; -pub fn serializer_repr(serializer: &SerializerInner) -> String { - use std::fmt::Write; - - let name = serializer_type_to_pyname(&serializer.r#type); +pub fn serializer_repr(serializer: &SerializerType) -> String { + let name = serializer_type_to_pyname(serializer); let mut buf = String::with_capacity(name.len() + 2); buf.push_str(name); buf.push('('); - let mut sep = ""; - - if let SerializerType::Array(element) = &serializer.r#type { + if let SerializerType::Array(element) = serializer { buf.push_str(&serializer_repr(element)); - sep = ", "; - } - if let Some(x) = &serializer.prefix { - let _ = write!(buf, "{sep}prefix={x:?}"); - sep = ", "; - } - if let Some(x) = &serializer.suffix { - let _ = write!(buf, "{sep}suffix={x:?}"); } buf.push(')'); @@ -38,20 +25,10 @@ pub fn serializer_repr(serializer: &SerializerInner) -> String { #[rapidquery_macros::pyclass("rapidquery.data_type.Serializer", subclass, from_py_object, generic)] #[derive(Debug, Clone)] #[repr(transparent)] -pub struct PySerializer(pub SerializerInner); +pub struct PySerializer(pub SerializerType); #[pyo3::pymethods] impl PySerializer { - #[getter] - fn get_prefix(&self) -> Option { - self.0.prefix.as_ref().map(arcstr::ArcStr::to_string) - } - - #[getter] - fn get_suffix(&self) -> Option { - self.0.suffix.as_ref().map(arcstr::ArcStr::to_string) - } - /// Renders `object` as a SQL literal for `dialect`. fn serialize( &self, @@ -59,7 +36,7 @@ impl PySerializer { object: alias::BoundObject<'_>, ) -> pyo3::PyResult { let mut buf = String::new(); - self.0.write_sql(&dialect, &object, &mut buf)?; + self.0.serialize(&dialect, &object, &mut buf)?; Ok(buf) } @@ -73,7 +50,8 @@ impl PySerializer { } fn __hash__(&self) -> u64 { - use std::hash::{Hash, Hasher}; + use std::hash::Hash; + use std::hash::Hasher; let mut hasher = std::hash::DefaultHasher::new(); self.0.hash(&mut hasher); @@ -97,14 +75,9 @@ macro_rules! impl_unit_serializers { #[pyo3::pymethods] impl $name { #[new] - #[pyo3(signature = (prefix=None, suffix=None))] - fn __new__(prefix: Option<&str>, suffix: Option<&str>) -> pyo3::PyClassInitializer { - let inner = SerializerInner::new( - SerializerType::$variant, - prefix.map(arcstr::ArcStr::from), - suffix.map(arcstr::ArcStr::from), - ); - pyo3::PyClassInitializer::from(PySerializer(inner)).add_subclass(Self) + fn __new__() -> pyo3::PyClassInitializer { + pyo3::PyClassInitializer::from(PySerializer(SerializerType::$variant)) + .add_subclass(Self) } } )+ @@ -126,7 +99,7 @@ macro_rules! impl_unit_serializers { self, py: pyo3::Python<'py>, ) -> pyo3::PyResult> { - match &self.0.r#type { + match &self.0 { $( SerializerType::$variant {..} => { pyo3::Bound::new( @@ -191,8 +164,8 @@ impl_unit_serializers! { /// TypeError: If the value is not a `str` or a string-valued enum member. String => PyStringSerializer => "StringSerializer", - /// Serializes UUIDs as quoted SQL string literals. - /// Accepts `uuid.UUID` or `str`. + /// Serializes UUIDs using the dialect's `uuid_format`. + /// Accepts `uuid.UUID` or `str`. A quoted literal, `UUID '…'`, or `'…'::uuid`. /// Raises: /// TypeError: If the value is not a `uuid.UUID` or `str`. Uuid => PyUuidSerializer => "UuidSerializer", @@ -214,7 +187,8 @@ impl_unit_serializers! { /// NotImplementedError: If the dialect has no native bit-string literal. Bit => PyBitSerializer => "BitSerializer", - /// Serializes a JSON-encodable object as a quoted SQL string literal. + /// Serializes a JSON-encodable object using the dialect's `json_format`. + /// A quoted literal, `JSON '…'`, or `PARSE_JSON('…')`. /// Accepts any object that the JSON encoder can dump. Uses `orjson` when it /// is installed, otherwise the stdlib `json` module. The JSON text is then /// wrapped with the dialect's string quoting. With `orjson`, datetime, @@ -225,31 +199,36 @@ impl_unit_serializers! { /// TypeError: If the value cannot be JSON-encoded. Json => PyJSONSerializer => "JSONSerializer", - /// Serializes dates as quoted `YYYY-MM-DD` literals. + /// Serializes dates as `YYYY-MM-DD` literals. + /// `date_format` chooses a quoted literal or `DATE` plus that literal. /// Accepts exact `datetime.date` (not `datetime.datetime`). /// /// Raises: /// TypeError: If the value is not a `datetime.date`. Date => PyDateSerializer => "DateSerializer", - /// Serializes times as quoted `HH:MM:SS[.ffffff]` literals. + /// Serializes times as `HH:MM:SS[.ffffff]` literals. + /// `time_format` chooses a quoted literal or `TIME` plus that literal. /// Accepts `datetime.time`. /// /// Raises: /// TypeError: If the value is not a `datetime.time`. Time => PyTimeSerializer => "TimeSerializer", - /// Serializes timestamps as quoted SQL timestamp literals. + /// Serializes timestamps as SQL timestamp literals. /// Accepts naive or aware `datetime.datetime`, or `int`/`float` UTC epoch /// seconds. Naive values keep wall time. Aware values keep their original - /// offset (they are not converted to UTC). + /// offset (they are not converted to UTC). When `timestamp_format` sets a + /// keyword, naive values use `TIMESTAMP` and aware values (including epoch + /// numbers) use `TIMESTAMPTZ` or `TIMESTAMP WITH TIMEZONE`. /// /// Raises: /// TypeError: If the value is not a `datetime.datetime`, `int`, or `float`. /// OverflowError: If an epoch value is out of range for a timestamp. Datetime => PyDatetimeSerializer => "DatetimeSerializer", - /// Serializes IP addresses and networks as quoted SQL literals. + /// Serializes IP addresses and networks using the dialect's `inet_format`. + /// A quoted literal or `'…'::inet`. /// Accepts `str` or `ipaddress` objects (`IPv4Address`, `IPv6Address`, /// `IPv4Network`, `IPv6Network`, and related types whose `str()` is a valid /// address or CIDR). Supported on PostgreSQL and DuckDB. @@ -259,7 +238,8 @@ impl_unit_serializers! { /// NotImplementedError: If the dialect has no native INET type. Inet => PyInetSerializer => "InetSerializer", - /// Serializes MAC addresses as quoted SQL literals. + /// Serializes MAC addresses using the dialect's `macaddr_format`. + /// A quoted literal or `'…'::macaddr`. /// Accepts a `str` using `:` or `-` as the separator. /// Supported on PostgreSQL. /// @@ -296,15 +276,8 @@ impl PyArraySerializer { /// are expressed by passing another `ArraySerializer`. #[new] #[inline] - #[pyo3(signature = (element, prefix=None, suffix=None))] - fn __new__( - element: PySerializer, - prefix: Option<&str>, - suffix: Option<&str>, - ) -> pyo3::PyClassInitializer { - let type_ = SerializerType::Array(Box::new(element.0)); - let inner = SerializerInner::new(type_, prefix.map(Into::into), suffix.map(Into::into)); - + fn __new__(element: PySerializer) -> pyo3::PyClassInitializer { + let inner = SerializerType::Array(Box::new(element.0)); pyo3::PyClassInitializer::from(PySerializer(inner)).add_subclass(Self) } @@ -315,7 +288,7 @@ impl PyArraySerializer { #[getter] fn get_element(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { let base = slf.as_super(); - match &base.0.r#type { + match &base.0 { SerializerType::Array(element) => { PySerializer((**element).clone()).into_subclasses(slf.py()) } @@ -342,12 +315,7 @@ impl PyCustomSerializer { /// The return value is inserted into SQL as-is (not re-quoted). /// Must return a `str`. #[new] - #[pyo3(signature = (function, prefix=None, suffix=None))] - fn __new__( - function: alias::PyObject, - prefix: Option<&str>, - suffix: Option<&str>, - ) -> pyo3::PyResult> { + fn __new__(function: alias::PyObject) -> pyo3::PyResult> { unsafe { if pyo3::ffi::PyCallable_Check(function.as_ptr()) != 1 { return rapidquery_macros::pyerr!( @@ -356,9 +324,7 @@ impl PyCustomSerializer { ); } } - let type_ = SerializerType::Custom(function); - let inner = SerializerInner::new(type_, prefix.map(Into::into), suffix.map(Into::into)); - + let inner = SerializerType::Custom(function); Ok(pyo3::PyClassInitializer::from(PySerializer(inner)).add_subclass(Self)) } @@ -369,7 +335,7 @@ impl PyCustomSerializer { #[getter] fn get_function(slf: pyo3::PyRef<'_, Self>) -> alias::PyObject { let base = slf.as_super(); - match &base.0.r#type { + match &base.0 { SerializerType::Custom(function) => function.clone(), _ => unsafe { std::hint::unreachable_unchecked() }, } @@ -379,7 +345,7 @@ impl PyCustomSerializer { /// `Serializer` instance, or a `DataType` (its serializer only). /// /// Use [`PySerializer`] itself as an argument if you wanna accept ONLY `PySerializer` types. -pub struct SerializerExtractor(pub SerializerInner); +pub struct SerializerExtractor(pub SerializerType); impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for SerializerExtractor { type Error = pyo3::PyErr; diff --git a/src/dialect/base.rs b/src/dialect/base.rs index 72d4b6d..0a362a7 100644 --- a/src/dialect/base.rs +++ b/src/dialect/base.rs @@ -1,4 +1,5 @@ -use std::hash::{Hash, Hasher}; +use std::hash::Hash; +use std::hash::Hasher; use crate::internal::utils; diff --git a/src/dialect/builtin.rs b/src/dialect/builtin.rs index 002e486..7e5490e 100644 --- a/src/dialect/builtin.rs +++ b/src/dialect/builtin.rs @@ -38,8 +38,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Simple('?'), bit_format: None, array_format: Some(ArrayFormat::Constructor), - supports_inet: false, - supports_macaddr: false, + inet_format: None, + macaddr_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::InsideParens), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -48,6 +48,11 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashHex, bytes_format: BytesFormat::PrefixBigBSlashX, + date_format: DateFormat::Keyword, + time_format: TimeFormat::Keyword, + timestamp_format: TimestampFormat::WithTimezone, + uuid_format: UuidFormat::Quoted, + json_format: JsonFormat::JsonKeyword, function_filter_syntax: Some(FunctionFilterSyntax::InsideWhere), ilike_expression_options: ILikeExpressionOptions::UseLikeLower, supports_similar_to: false, @@ -73,8 +78,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Simple('?'), bit_format: None, array_format: None, - supports_inet: false, - supports_macaddr: false, + inet_format: None, + macaddr_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::PatternList, bool_format: BoolFormat::Keyword, @@ -83,6 +88,11 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashUnicodeQuote, bytes_format: BytesFormat::PrefixBigX, + date_format: DateFormat::Keyword, + time_format: TimeFormat::Quoted, + timestamp_format: TimestampFormat::WithTimezone, + uuid_format: UuidFormat::Quoted, + json_format: JsonFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: false, @@ -108,8 +118,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Numbered('$'), bit_format: Some(BitFormat::PrefixBigB), array_format: Some(ArrayFormat::List), - supports_inet: true, - supports_macaddr: false, + inet_format: Some(InetFormat::Cast), + macaddr_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::InsideParens), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -118,6 +128,11 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::SlashXBlob, + date_format: DateFormat::Keyword, + time_format: TimeFormat::Keyword, + timestamp_format: TimestampFormat::Timestamptz, + uuid_format: UuidFormat::UuidKeyword, + json_format: JsonFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::Filter), ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: true, @@ -143,8 +158,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Simple('?'), bit_format: None, array_format: Some(ArrayFormat::Function), - supports_inet: false, - supports_macaddr: false, + inet_format: None, + macaddr_format: None, null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -153,6 +168,11 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashUnicode, bytes_format: BytesFormat::Unhex, + date_format: DateFormat::Keyword, + time_format: TimeFormat::Quoted, + timestamp_format: TimestampFormat::WithTimezone, + uuid_format: UuidFormat::Quoted, + json_format: JsonFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLikeLower, supports_similar_to: false, @@ -178,8 +198,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Simple('?'), bit_format: None, array_format: None, - supports_inet: false, - supports_macaddr: false, + inet_format: None, + macaddr_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, @@ -188,6 +208,11 @@ impl_builtin_dialects!( string_format: StringFormat::PrefixBigN, string_escape: StringEscape::Quote, bytes_format: BytesFormat::PrefixZeroX, + date_format: DateFormat::Quoted, + time_format: TimeFormat::Quoted, + timestamp_format: TimestampFormat::Quoted, + uuid_format: UuidFormat::Quoted, + json_format: JsonFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLike, supports_similar_to: false, @@ -213,8 +238,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Simple('?'), bit_format: Some(BitFormat::PrefixSmallB), array_format: None, - supports_inet: false, - supports_macaddr: false, + inet_format: None, + macaddr_format: None, null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, @@ -223,6 +248,11 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashMysql, bytes_format: BytesFormat::PrefixBigX, + date_format: DateFormat::Quoted, + time_format: TimeFormat::Quoted, + timestamp_format: TimestampFormat::Quoted, + uuid_format: UuidFormat::Quoted, + json_format: JsonFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLike, supports_similar_to: false, @@ -248,8 +278,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Numbered(':'), bit_format: None, array_format: None, - supports_inet: false, - supports_macaddr: false, + inet_format: None, + macaddr_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, @@ -258,6 +288,11 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::HexToRaw, + date_format: DateFormat::Keyword, + time_format: TimeFormat::Quoted, + timestamp_format: TimestampFormat::WithTimezone, + uuid_format: UuidFormat::Quoted, + json_format: JsonFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::UseLikeLower, supports_similar_to: false, @@ -283,8 +318,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Numbered('$'), bit_format: Some(BitFormat::PrefixBigB), array_format: Some(ArrayFormat::Constructor), - supports_inet: true, - supports_macaddr: true, + inet_format: Some(InetFormat::Cast), + macaddr_format: Some(MacAddrFormat::Cast), null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::ArrayConstructor, bool_format: BoolFormat::Keyword, @@ -293,6 +328,11 @@ impl_builtin_dialects!( string_format: StringFormat::PrefixBigE, string_escape: StringEscape::BackslashHex, bytes_format: BytesFormat::SlashXBytea, + date_format: DateFormat::Keyword, + time_format: TimeFormat::Keyword, + timestamp_format: TimestampFormat::Timestamptz, + uuid_format: UuidFormat::CastUuid, + json_format: JsonFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: true, @@ -318,8 +358,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Numbered('$'), bit_format: None, array_format: None, - supports_inet: false, - supports_macaddr: false, + inet_format: None, + macaddr_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -328,6 +368,11 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::ToVarbyte, + date_format: DateFormat::Quoted, + time_format: TimeFormat::Quoted, + timestamp_format: TimestampFormat::Quoted, + uuid_format: UuidFormat::Quoted, + json_format: JsonFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: true, @@ -353,8 +398,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Numbered(':'), bit_format: None, array_format: None, - supports_inet: false, - supports_macaddr: false, + inet_format: None, + macaddr_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::PatternList, bool_format: BoolFormat::Keyword, @@ -363,6 +408,11 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashHexZero, bytes_format: BytesFormat::ToBinary, + date_format: DateFormat::Keyword, + time_format: TimeFormat::Quoted, + timestamp_format: TimestampFormat::Timestamptz, + uuid_format: UuidFormat::Quoted, + json_format: JsonFormat::ParseJson, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: false, @@ -388,8 +438,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Simple('?'), bit_format: None, array_format: Some(ArrayFormat::Function), - supports_inet: false, - supports_macaddr: false, + inet_format: None, + macaddr_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::PatternList, bool_format: BoolFormat::Keyword, @@ -398,6 +448,11 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::BackslashUnicodeQuote, bytes_format: BytesFormat::PrefixBigX, + date_format: DateFormat::Keyword, + time_format: TimeFormat::Quoted, + timestamp_format: TimestampFormat::WithTimezone, + uuid_format: UuidFormat::Quoted, + json_format: JsonFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::Supported, supports_similar_to: false, @@ -423,8 +478,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Numbered('?'), bit_format: None, array_format: None, - supports_inet: false, - supports_macaddr: false, + inet_format: None, + macaddr_format: None, null_treatment_syntax: None, like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Int, @@ -433,6 +488,11 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::PrefixBigX, + date_format: DateFormat::Quoted, + time_format: TimeFormat::Quoted, + timestamp_format: TimestampFormat::Quoted, + uuid_format: UuidFormat::Quoted, + json_format: JsonFormat::Quoted, function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), ilike_expression_options: ILikeExpressionOptions::UseLike, supports_similar_to: false, @@ -458,8 +518,8 @@ impl_builtin_dialects!( positional_parameter: PositionalParameter::Simple('?'), bit_format: Some(BitFormat::PostfixBigB), array_format: None, - supports_inet: false, - supports_macaddr: false, + inet_format: None, + macaddr_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::InsideParens), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -468,6 +528,11 @@ impl_builtin_dialects!( string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::PostfixXbv, + date_format: DateFormat::Keyword, + time_format: TimeFormat::Quoted, + timestamp_format: TimestampFormat::WithTimezone, + uuid_format: UuidFormat::Quoted, + json_format: JsonFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLikeLower, supports_similar_to: false, diff --git a/src/dialect/inner.rs b/src/dialect/inner.rs index d88bd0a..3edebb4 100644 --- a/src/dialect/inner.rs +++ b/src/dialect/inner.rs @@ -1,4 +1,5 @@ -use std::fmt::{self, Write}; +use std::fmt::Write; +use std::fmt::{self}; use crate::internal::utils; @@ -447,6 +448,79 @@ pub enum ArrayFormat { Function, } +/// How a date literal is spelled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DateFormat { + /// Quoted string. MSSQL still gets `N'…'` via [`Dialect::string_format`]. + Quoted, + /// `DATE`, a space, then a quoted literal. + Keyword, +} + +/// How a time literal is spelled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TimeFormat { + /// Quoted string. + Quoted, + /// `TIME`, a space, then a quoted literal. + Keyword, +} + +/// How a timestamp literal is spelled. +/// +/// Naive values use `TIMESTAMP` when a keyword is configured. Aware values +/// (`datetime` with a tzinfo, or a UTC epoch number) use the keyword this +/// variant names. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TimestampFormat { + /// Both naive and aware values are a quoted string. No keyword. + Quoted, + /// Naive: `TIMESTAMP '…'`. Aware: `TIMESTAMPTZ '…'`. + Timestamptz, + /// Naive: `TIMESTAMP '…'`. Aware: `TIMESTAMP WITH TIMEZONE '…'`. + WithTimezone, +} + +/// How an inet literal is spelled. [`None`] on [`Dialect::inet_format`] means unsupported. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum InetFormat { + /// `'192.168.1.1/32'` + Quoted, + /// `'192.168.1.1/32'::inet` + Cast, +} + +/// How a MAC address literal is spelled. [`None`] on [`Dialect::macaddr_format`] means unsupported. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum MacAddrFormat { + /// `'01:23:45:67:89:AB'` + Quoted, + /// `'01:23:45:67:89:AB'::macaddr` + Cast, +} + +/// How a UUID literal is spelled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum UuidFormat { + /// Quoted hyphenated text. + Quoted, + /// `UUID '…'` + UuidKeyword, + /// `'…'::uuid` + CastUuid, +} + +/// How a JSON literal is spelled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JsonFormat { + /// Quoted JSON text. + Quoted, + /// `JSON '…'` + JsonKeyword, + /// `PARSE_JSON('…')` + ParseJson, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Dialect { /// Positional parameter syntax. @@ -457,10 +531,10 @@ pub struct Dialect { pub bit_format: Option, /// How the dialect accepts array values if supported. pub array_format: Option, - /// Whether INET data type is supported - pub supports_inet: bool, - /// Whether MACADDR data type is supported - pub supports_macaddr: bool, + /// How an inet literal is spelled. [`None`] means unsupported. + pub inet_format: Option, + /// How a MAC address literal is spelled. [`None`] means unsupported. + pub macaddr_format: Option, /// How the dialect supports `{IGNORE|RESPECT} NULLS` expressions in `Function`s if supported. pub null_treatment_syntax: Option, /// How `LIKE ANY` / `ILIKE ANY` is rendered. @@ -477,6 +551,16 @@ pub struct Dialect { pub string_escape: StringEscape, /// How the dialect accepts literal bytes values. pub bytes_format: BytesFormat, + /// How a date literal is spelled. + pub date_format: DateFormat, + /// How a time literal is spelled. + pub time_format: TimeFormat, + /// How a timestamp literal is spelled. + pub timestamp_format: TimestampFormat, + /// How a UUID literal is spelled. + pub uuid_format: UuidFormat, + /// How a JSON literal is spelled. + pub json_format: JsonFormat, /// How function calling `FILTER` keyword rendered if supported. pub function_filter_syntax: Option, /// How case-insensitive `LIKE` is spelled (`ILIKE`, `LIKE`, or `LOWER` + `LIKE`). @@ -859,8 +943,8 @@ mod tests { positional_parameter: PositionalParameter::Simple(':'), bit_format: None, array_format: None, - supports_inet: false, - supports_macaddr: false, + inet_format: None, + macaddr_format: None, null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), like_any_syntax: LikeAnySyntax::OrExpansion, bool_format: BoolFormat::Keyword, @@ -869,6 +953,11 @@ mod tests { string_format: StringFormat::SingleQuote, string_escape: StringEscape::Quote, bytes_format: BytesFormat::PrefixBigX, + date_format: DateFormat::Quoted, + time_format: TimeFormat::Quoted, + timestamp_format: TimestampFormat::Quoted, + uuid_format: UuidFormat::Quoted, + json_format: JsonFormat::Quoted, function_filter_syntax: None, ilike_expression_options: ILikeExpressionOptions::UseLike, supports_similar_to: false, @@ -1057,11 +1146,11 @@ mod tests { cfg.string_escape = StringEscape::BackslashHex; assert_eq!( collect(|buf| cfg.write_string_literal(buf, "hello")), - "'hello'" + "E'hello'" ); assert_eq!( collect(|buf| cfg.write_string_literal(buf, "O'Brien")), - "'O''Brien'" + "E'O\\'Brien'" ); assert_eq!( collect(|buf| cfg.write_string_literal(buf, "hello'world\n")), diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index f4e3194..6159aee 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -5,16 +5,48 @@ pub mod inner; // TODO: rewrite GenericDialect again pub use base::PySqlDialect; -pub use builtin::{ - PyBigQuery, PyDatabricks, PyDuckDb, PyHive, PyMsSql, PyMySql, PyOracle, PyPostgreSql, - PyRedshift, PySnowflake, PySpark, PySqlite, PyTeradata, -}; +pub use builtin::PyBigQuery; +pub use builtin::PyDatabricks; +pub use builtin::PyDuckDb; +pub use builtin::PyHive; +pub use builtin::PyMsSql; +pub use builtin::PyMySql; +pub use builtin::PyOracle; +pub use builtin::PyPostgreSql; +pub use builtin::PyRedshift; +pub use builtin::PySnowflake; +pub use builtin::PySpark; +pub use builtin::PySqlite; +pub use builtin::PyTeradata; #[pyo3::pymodule(name = "dialect", gil_used = false)] pub mod dialect_mod { #[pymodule_export] - use super::{ - PyBigQuery, PyDatabricks, PyDuckDb, PyHive, PyMsSql, PyMySql, PyOracle, PyPostgreSql, - PyRedshift, PySnowflake, PySpark, PySqlDialect, PySqlite, PyTeradata, - }; + use super::PyBigQuery; + #[pymodule_export] + use super::PyDatabricks; + #[pymodule_export] + use super::PyDuckDb; + #[pymodule_export] + use super::PyHive; + #[pymodule_export] + use super::PyMsSql; + #[pymodule_export] + use super::PyMySql; + #[pymodule_export] + use super::PyOracle; + #[pymodule_export] + use super::PyPostgreSql; + #[pymodule_export] + use super::PyRedshift; + #[pymodule_export] + use super::PySnowflake; + #[pymodule_export] + use super::PySpark; + #[pymodule_export] + use super::PySqlDialect; + #[pymodule_export] + use super::PySqlite; + #[pymodule_export] + use super::PyTeradata; } diff --git a/src/expr/base.rs b/src/expr/base.rs index 0300142..f53d01b 100644 --- a/src/expr/base.rs +++ b/src/expr/base.rs @@ -8,7 +8,8 @@ // `between` / `in_` / `is_null` / `is_distinct_from` / `cast`; `case`; // `custom`. Prefer named methods over v0 dunders that return `Expr`. -use crate::internal::{alias, utils}; +use crate::internal::alias; +use crate::internal::utils; use crate::sql; use super::enum_::ExprInner; diff --git a/src/expr/enum_/func.rs b/src/expr/enum_/func.rs index bd4dbc0..247c016 100644 --- a/src/expr/enum_/func.rs +++ b/src/expr/enum_/func.rs @@ -1,8 +1,12 @@ use super::ExprInner; use super::ident::Ident; -use crate::dialect::inner::{FunctionFilterSyntax, NullTreatmentSyntax}; -use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; +use crate::dialect::inner::FunctionFilterSyntax; +use crate::dialect::inner::NullTreatmentSyntax; +use crate::sql::PrepareResult; +use crate::sql::SqlWriter; +use crate::sql::WriteSql; +use crate::sql::write_separated; /// How `NULL`s are treated in some window/aggregate functions. /// diff --git a/src/expr/enum_/ident.rs b/src/expr/enum_/ident.rs index d166855..027e519 100644 --- a/src/expr/enum_/ident.rs +++ b/src/expr/enum_/ident.rs @@ -1,7 +1,10 @@ //! SQL identifiers. use crate::dialect::PySqlDialect; -use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; +use crate::sql::PrepareResult; +use crate::sql::SqlWriter; +use crate::sql::WriteSql; +use crate::sql::write_separated; /// One segment of a dotted identifier. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] diff --git a/src/expr/enum_/like.rs b/src/expr/enum_/like.rs index ca2a613..125206e 100644 --- a/src/expr/enum_/like.rs +++ b/src/expr/enum_/like.rs @@ -1,11 +1,14 @@ //! `LIKE` / `ILIKE` / `LIKE ANY` / `SIMILAR TO` / `RLIKE` AST nodes. use super::ExprInner; -use crate::dialect::{ - PySqlDialect, - inner::{LikeAnySyntax, LikeEscapeSyntax, RLikeExpressionOptions}, -}; -use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; +use crate::dialect::PySqlDialect; +use crate::dialect::inner::LikeAnySyntax; +use crate::dialect::inner::LikeEscapeSyntax; +use crate::dialect::inner::RLikeExpressionOptions; +use crate::sql::PrepareResult; +use crate::sql::SqlWriter; +use crate::sql::WriteSql; +use crate::sql::write_separated; use std::fmt::Write; pub(super) fn write_escape( diff --git a/src/expr/enum_/mod.rs b/src/expr/enum_/mod.rs index b337bed..a8bdc02 100644 --- a/src/expr/enum_/mod.rs +++ b/src/expr/enum_/mod.rs @@ -12,10 +12,10 @@ pub use nodes::*; pub use operator::*; pub use value::*; -use crate::{ - dialect::PySqlDialect, - sql::{PrepareResult, SqlWriter, WriteSql}, -}; +use crate::dialect::PySqlDialect; +use crate::sql::PrepareResult; +use crate::sql::SqlWriter; +use crate::sql::WriteSql; /// SQL expression tree. /// diff --git a/src/expr/enum_/nodes.rs b/src/expr/enum_/nodes.rs index 66c0224..5a3e2d2 100644 --- a/src/expr/enum_/nodes.rs +++ b/src/expr/enum_/nodes.rs @@ -1,10 +1,24 @@ -use super::{BinaryOp, CastKind, ExprInner, Ident, IsKind, TrimWhere, UnaryOp, UnarySpacing}; +use super::BinaryOp; +use super::CastKind; +use super::ExprInner; +use super::Ident; +use super::IsKind; +use super::TrimWhere; +use super::UnaryOp; +use super::UnarySpacing; use crate::dialect::PySqlDialect; -use crate::dialect::inner::{ - BitXorSyntax, BitwiseSyntax, CollateSyntax, IsDistinctFromSyntax, ModuloSyntax, - StringConcatSyntax, TrimSyntax, TupleSyntax, -}; -use crate::sql::{PrepareResult, SqlWriter, WriteSql, write_separated}; +use crate::dialect::inner::BitXorSyntax; +use crate::dialect::inner::BitwiseSyntax; +use crate::dialect::inner::CollateSyntax; +use crate::dialect::inner::IsDistinctFromSyntax; +use crate::dialect::inner::ModuloSyntax; +use crate::dialect::inner::StringConcatSyntax; +use crate::dialect::inner::TrimSyntax; +use crate::dialect::inner::TupleSyntax; +use crate::sql::PrepareResult; +use crate::sql::SqlWriter; +use crate::sql::WriteSql; +use crate::sql::write_separated; use std::fmt::Write; #[inline] diff --git a/src/expr/enum_/value.rs b/src/expr/enum_/value.rs index 7a8c0d6..3c327ab 100644 --- a/src/expr/enum_/value.rs +++ b/src/expr/enum_/value.rs @@ -1,35 +1,39 @@ //! A Python object plus the [`Serializer`] used to emit it as SQL. -use pyo3::types::{PyAnyMethods, PyStringMethods}; +use pyo3::types::PyAnyMethods; +use pyo3::types::PyStringMethods; -use crate::data_type::{SerializerInner, serializer_repr}; +use crate::data_type::SerializerType; +use crate::data_type::serializer_repr; use crate::dialect::PySqlDialect; use crate::internal::alias; -use crate::sql::{PrepareResult, SqlWriter, WriteSql}; +use crate::sql::PrepareResult; +use crate::sql::SqlWriter; +use crate::sql::WriteSql; /// A Python object plus the [`Serializer`] used to emit it as SQL. /// /// Parameterized SQL binds [`Self::object`] as a placeholder and ignores the -/// serializer. Raw SQL (`BufferedSql`) calls [`Serializer::write_sql`]; +/// serializer. Raw SQL (`BufferedSql`) calls [`SerializerType::serialize`]; /// Python `None` becomes `NULL`. /// /// Clone requires the GIL (`Py::clone`). #[derive(Debug, Clone)] pub struct Value { pub object: alias::PyObject, - pub serializer: SerializerInner, + pub serializer: SerializerType, } impl Value { #[inline] - pub fn new(object: alias::PyObject, serializer: SerializerInner) -> Self { + pub fn new(object: alias::PyObject, serializer: SerializerType) -> Self { Self { object, serializer } } #[inline] pub fn infer_bound(bound: alias::BoundObject) -> Self { let serializer = crate::data_type::SerializerType::infer_bound(&bound); - Self::new(bound.unbind(), serializer.into()) + Self::new(bound.unbind(), serializer) } pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { diff --git a/src/expr/extractors.rs b/src/expr/extractors.rs index 6df36e6..789708d 100644 --- a/src/expr/extractors.rs +++ b/src/expr/extractors.rs @@ -1,8 +1,14 @@ use std::str::FromStr; -use pyo3::types::{PyAnyMethods, PyListMethods, PyTupleMethods}; - -use super::enum_::{ExprInner, Ident, IdentPart, LikePattern, Tuple}; +use pyo3::types::PyAnyMethods; +use pyo3::types::PyListMethods; +use pyo3::types::PyTupleMethods; + +use super::enum_::ExprInner; +use super::enum_::Ident; +use super::enum_::IdentPart; +use super::enum_::LikePattern; +use super::enum_::Tuple; use crate::internal::utils; /// `true` when `obj` is `Asterisk` or `type[Asterisk]`. @@ -64,7 +70,7 @@ fn extract_expr( return Ok(ExprInner::Value(super::enum_::Value::new( obj.as_any().clone().unbind(), - crate::data_type::SerializerType::String.into(), + crate::data_type::SerializerType::String, ))); } diff --git a/src/expr/func.rs b/src/expr/func.rs index 9e96adc..42d240f 100644 --- a/src/expr/func.rs +++ b/src/expr/func.rs @@ -1,10 +1,14 @@ -use pyo3::types::{PyAnyMethods, PyTupleMethods}; +use pyo3::types::PyAnyMethods; +use pyo3::types::PyTupleMethods; -use crate::internal::{alias, utils}; +use crate::internal::alias; +use crate::internal::utils; use super::base::PyExpr; use super::enum_::NullTreatment; -use super::extractors::{ConditionExtractor, IdentExtractor, LiteralExprExtractor}; +use super::extractors::ConditionExtractor; +use super::extractors::IdentExtractor; +use super::extractors::LiteralExprExtractor; /// SQL function call. /// diff --git a/src/expr/ident.rs b/src/expr/ident.rs index 2a8df0e..faac437 100644 --- a/src/expr/ident.rs +++ b/src/expr/ident.rs @@ -1,7 +1,10 @@ use pyo3::types::PyTupleMethods; -use super::enum_::{Ident, IdentPart, ParseIdentError}; -use super::extractors::{ExtractedIdentArgument, extract_ident_argument}; +use super::enum_::Ident; +use super::enum_::IdentPart; +use super::enum_::ParseIdentError; +use super::extractors::ExtractedIdentArgument; +use super::extractors::extract_ident_argument; use crate::internal::alias; /// Convert a native part into the matching Python object (`str`, `Unquote`, @@ -128,7 +131,9 @@ impl PyIdent { } fn __hash__(slf: pyo3::PyRef<'_, Self>) -> u64 { - use std::hash::{DefaultHasher, Hash, Hasher}; + use std::hash::DefaultHasher; + use std::hash::Hash; + use std::hash::Hasher; let mut state = DefaultHasher::default(); let inner = Self::inner(&slf); diff --git a/src/expr/mod.rs b/src/expr/mod.rs index d712ec5..91c7e4a 100644 --- a/src/expr/mod.rs +++ b/src/expr/mod.rs @@ -15,15 +15,43 @@ pub mod expr_mod { use super::func::PyFunction; #[pymodule_export] - use super::ident::{PyAsterisk, PyIdent, PyUnquote}; + use super::ident::PyAsterisk; + #[pymodule_export] + use super::ident::PyIdent; + #[pymodule_export] + use super::ident::PyUnquote; #[pymodule_export] use super::value::PyValue; #[pymodule_export] - use super::nodes::{ - PyBetweenExpr, PyBinaryExpr, PyCaseExpr, PyCastExpr, PyCollateExpr, PyCustomExpr, - PyInListExpr, PyIsDistinctFromExpr, PyIsExpr, PyLikeExpr, PyRLikeExpr, PySimilarToExpr, - PyTrimExpr, PyTupleExpr, PyUnaryExpr, - }; + use super::nodes::PyBetweenExpr; + #[pymodule_export] + use super::nodes::PyBinaryExpr; + #[pymodule_export] + use super::nodes::PyCaseExpr; + #[pymodule_export] + use super::nodes::PyCastExpr; + #[pymodule_export] + use super::nodes::PyCollateExpr; + #[pymodule_export] + use super::nodes::PyCustomExpr; + #[pymodule_export] + use super::nodes::PyInListExpr; + #[pymodule_export] + use super::nodes::PyIsDistinctFromExpr; + #[pymodule_export] + use super::nodes::PyIsExpr; + #[pymodule_export] + use super::nodes::PyLikeExpr; + #[pymodule_export] + use super::nodes::PyRLikeExpr; + #[pymodule_export] + use super::nodes::PySimilarToExpr; + #[pymodule_export] + use super::nodes::PyTrimExpr; + #[pymodule_export] + use super::nodes::PyTupleExpr; + #[pymodule_export] + use super::nodes::PyUnaryExpr; } diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index 17161d1..ce0a75c 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -1,12 +1,15 @@ -use pyo3::types::{PyAnyMethods, PyTupleMethods}; +use pyo3::types::PyAnyMethods; +use pyo3::types::PyTupleMethods; use super::base::PyExpr; use super::enum_::ExprInner; -use super::extractors::{ - ConditionExtractor, IdentExtractor, LiteralExprExtractor, extract_like_pattern, -}; +use super::extractors::ConditionExtractor; +use super::extractors::IdentExtractor; +use super::extractors::LiteralExprExtractor; +use super::extractors::extract_like_pattern; use crate::data_type::DataTypeNameExtractor; -use crate::internal::{alias, utils}; +use crate::internal::alias; +use crate::internal::utils; macro_rules! empty_node { ( diff --git a/src/expr/value.rs b/src/expr/value.rs index dd164e4..217639f 100644 --- a/src/expr/value.rs +++ b/src/expr/value.rs @@ -1,7 +1,6 @@ -use crate::{ - data_type::{PySerializer, SerializerExtractor}, - internal::alias, -}; +use crate::data_type::PySerializer; +use crate::data_type::SerializerExtractor; +use crate::internal::alias; use super::enum_::Value; @@ -106,7 +105,9 @@ impl PyValue { fn __hash__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { use pyo3::types::PyAnyMethods; - use std::hash::{DefaultHasher, Hash, Hasher}; + use std::hash::DefaultHasher; + use std::hash::Hash; + use std::hash::Hasher; let inner = Self::inner(&slf); let hash_object = inner.object.bind(slf.py()).hash()?; diff --git a/src/internal/modules/json.rs b/src/internal/modules/json.rs index 7e58b87..0a7ef58 100644 --- a/src/internal/modules/json.rs +++ b/src/internal/modules/json.rs @@ -4,7 +4,9 @@ //! escape it without an intermediate `String`. use once_cell::sync::OnceCell; -use pyo3::types::{PyAnyMethods, PyModule, PyTuple}; +use pyo3::types::PyAnyMethods; +use pyo3::types::PyModule; +use pyo3::types::PyTuple; use crate::internal::utils::borrow_py_str_buffer; diff --git a/src/internal/utils/mod.rs b/src/internal/utils/mod.rs index f8597c3..8396ce2 100644 --- a/src/internal/utils/mod.rs +++ b/src/internal/utils/mod.rs @@ -40,7 +40,8 @@ pub unsafe fn borrow_py_str_buffer<'a>( /// pointer is fine). The GIL must be held (`py`). #[inline] pub unsafe fn get_type_name<'a>(py: pyo3::Python<'a>, obj: *mut pyo3::ffi::PyObject) -> String { - use pyo3::types::{PyStringMethods, PyTypeMethods}; + use pyo3::types::PyStringMethods; + use pyo3::types::PyTypeMethods; let type_ = pyo3::ffi::Py_TYPE(obj); diff --git a/src/internal/utils/strings.rs b/src/internal/utils/strings.rs index 8aa7a23..2c7f438 100644 --- a/src/internal/utils/strings.rs +++ b/src/internal/utils/strings.rs @@ -1,7 +1,8 @@ //! Allocation-conscious string replacement that writes directly to a [`fmt::Write`]. // Assisted-by: Grok -use std::fmt::{self, Write}; +use std::fmt::Write; +use std::fmt::{self}; /// A search needle that can be located inside a UTF-8 haystack. pub trait ReplacePattern: Copy { diff --git a/src/internal/utils/submodule_ext.rs b/src/internal/utils/submodule_ext.rs index 3f5d325..742cdf1 100644 --- a/src/internal/utils/submodule_ext.rs +++ b/src/internal/utils/submodule_ext.rs @@ -10,7 +10,9 @@ //! the dotted path (`rapidquery._core.dialect`) so `repr` and pickling //! match the import path. -use pyo3::types::{PyAnyMethods, PyModule, PyModuleMethods}; +use pyo3::types::PyAnyMethods; +use pyo3::types::PyModule; +use pyo3::types::PyModuleMethods; fn _inner_init(module: &pyo3::Bound<'_, PyModule>, path: &str) -> pyo3::PyResult<()> { let py = module.py(); diff --git a/src/lib.rs b/src/lib.rs index ef206d7..71e7d60 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,10 +10,10 @@ pub mod typeref; // Tasks until being stable (until this part) // - Clean code +// - Add fmt/lint/coding-style rules // - Restore GenericDialect ( very helpful for testing ) // - Test and coverage everything and edge cases // - Update docstrings / doccomments -// - Add fmt/lint/coding-style rules // Later tasks: // - Complete `Expr` methods diff --git a/src/sql.rs b/src/sql.rs index 7c27fac..299ee60 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -9,9 +9,10 @@ use std::fmt::Write; use pyo3::types::PyAnyMethods; -use crate::data_type::SerializerInner; +use crate::data_type::SerializerType; use crate::dialect::PySqlDialect; -use crate::dialect::inner::{FormatError, PositionalParameter}; +use crate::dialect::inner::FormatError; +use crate::dialect::inner::PositionalParameter; use crate::internal::alias; /// Error produced while building SQL. @@ -129,13 +130,13 @@ pub trait SqlWriter: std::fmt::Write { if cond { self.push_char(c) } else { self } } - /// Raw (`SqlBuf`): `None` → `NULL`, else `serializer.write_sql` into the + /// Raw (`SqlBuf`): `None` → `NULL`, else `serializer.serialize` into the /// SQL buffer. Prepared: bind `object` as a placeholder; `serializer` is /// unused. fn push_value( &mut self, dialect: &PySqlDialect, - serializer: &SerializerInner, + serializer: &SerializerType, object: &alias::PyObject, ) -> PrepareResult<()>; } @@ -195,7 +196,7 @@ impl<'py> SqlWriter for BufferedSql<'py> { fn push_value( &mut self, dialect: &PySqlDialect, - serializer: &SerializerInner, + serializer: &SerializerType, object: &alias::PyObject, ) -> PrepareResult<()> { let bound = object.bind(self.py); @@ -203,7 +204,7 @@ impl<'py> SqlWriter for BufferedSql<'py> { self.sql.push_str("NULL"); return Ok(()); } - serializer.write_sql(dialect, bound, &mut self.sql)?; + serializer.serialize(dialect, bound, &mut self.sql)?; Ok(()) } } @@ -312,7 +313,7 @@ impl SqlWriter for ParameterizedSql<'_> { fn push_value( &mut self, dialect: &PySqlDialect, - _serializer: &SerializerInner, + _serializer: &SerializerType, object: &alias::PyObject, ) -> PrepareResult<()> { self.push_param(dialect, object.clone_ref(self.py)); diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 69cbf15..e12d7f4 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -146,7 +146,7 @@ class TestUuidSerializer: def test_uuid_and_str(self): s = UuidSerializer() - expected = f"'{self._TEXT}'::uuid" + expected = f"E'{self._TEXT}'::uuid" assert s.serialize(PG, self._TEXT) == expected assert s.serialize(PG, uuid.UUID(self._TEXT)) == expected assert s.serialize(DuckDb(), self._TEXT) == f"UUID '{self._TEXT}'" @@ -202,8 +202,8 @@ def test_rejects_non_bit_string(self): class TestJSONSerializer: def test_dumps_then_json_format(self): s = JSONSerializer() - assert s.serialize(PG, 10) == "'10'::jsonb" - assert s.serialize(PG, "10") == "'\"10\"'::jsonb" + assert s.serialize(PG, 10) == "E'10'" + assert s.serialize(PG, "10") == "E'\"10\"'" assert s.serialize(BigQuery(), {"a": 1}) == "JSON '{\"a\":1}'" assert s.serialize(Snowflake(), {"a": 1}) == "PARSE_JSON('{\"a\":1}')" assert s.serialize(Sqlite(), {"a": 1}) == "'{\"a\":1}'" @@ -213,13 +213,13 @@ def test_orjson_only_values(self): extras = [ ( datetime.datetime(2024, 10, 1, tzinfo=datetime.timezone.utc), - "'\"2024-10-01T00:00:00+00:00\"'::jsonb", + "E'\"2024-10-01T00:00:00+00:00\"'", ), ( uuid.UUID("103596b1-db75-4ae0-907a-a4a9bc4dab85"), - "'\"103596b1-db75-4ae0-907a-a4a9bc4dab85\"'::jsonb", + "E'\"103596b1-db75-4ae0-907a-a4a9bc4dab85\"'", ), - (ExampleDataclass(x=1), "'{\"x\":1}'::jsonb"), + (ExampleDataclass(x=1), "E'{\"x\":1}'"), ] for obj, expected in extras: if USING_ORJSON: @@ -232,7 +232,7 @@ def test_orjson_only_values(self): class TestDateTimeSerializers: def test_date(self): value = datetime.date(2024, 1, 15) - assert DateSerializer().serialize(PG, value) == "DATE '2024-01-15'" + assert DateSerializer().serialize(PG, value) == "DATE E'2024-01-15'" assert DateSerializer().serialize(Oracle(), value) == "DATE '2024-01-15'" assert DateSerializer().serialize(Sqlite(), value) == "'2024-01-15'" @@ -244,9 +244,9 @@ def test_date_rejects_datetime_and_str(self): def test_time(self): s = TimeSerializer() - assert s.serialize(PG, datetime.time(13, 45, 30)) == "TIME '13:45:30'" + assert s.serialize(PG, datetime.time(13, 45, 30)) == "TIME E'13:45:30'" assert ( - s.serialize(PG, datetime.time(1, 2, 3, 456789)) == "TIME '01:02:03.456789'" + s.serialize(PG, datetime.time(1, 2, 3, 456789)) == "TIME E'01:02:03.456789'" ) assert s.serialize(Oracle(), datetime.time(13, 45, 30)) == "'13:45:30'" @@ -267,16 +267,21 @@ def test_datetime_naive_and_aware(self): 0, tzinfo=datetime.timezone(datetime.timedelta(hours=-5)), ) - assert s.serialize(PG, naive) == "TIMESTAMP '2024-01-15 10:30:00'" - assert s.serialize(PG, utc) == "TIMESTAMP '2024-01-15 10:30:00+00:00'" - assert s.serialize(PG, offset) == "TIMESTAMP '2024-01-15 10:30:00-05:00'" + assert s.serialize(PG, naive) == "TIMESTAMP E'2024-01-15 10:30:00'" + assert s.serialize(PG, utc) == "TIMESTAMPTZ E'2024-01-15 10:30:00+00:00'" + assert s.serialize(PG, offset) == "TIMESTAMPTZ E'2024-01-15 10:30:00-05:00'" assert s.serialize(Oracle(), naive) == "TIMESTAMP '2024-01-15 10:30:00'" + assert ( + s.serialize(Oracle(), offset) + == "TIMESTAMP WITH TIMEZONE '2024-01-15 10:30:00-05:00'" + ) assert s.serialize(Sqlite(), naive) == "'2024-01-15 10:30:00'" + assert s.serialize(Sqlite(), offset) == "'2024-01-15 10:30:00-05:00'" def test_datetime_unix_timestamp(self): s = DatetimeSerializer() - assert s.serialize(PG, 0) == "TIMESTAMP '1970-01-01 00:00:00+00:00'" - assert s.serialize(PG, 0.5) == "TIMESTAMP '1970-01-01 00:00:00.500+00:00'" + assert s.serialize(PG, 0) == "TIMESTAMPTZ E'1970-01-01 00:00:00+00:00'" + assert s.serialize(PG, 0.5) == "TIMESTAMPTZ E'1970-01-01 00:00:00.500+00:00'" def test_datetime_rejects_date_and_str(self): with pytest.raises(TypeError): @@ -307,7 +312,11 @@ def test_canonical_cidr(self, dialect_instance: SqlDialect): ("2001:db8::/32", "2001:db8::/32"), ] for obj, inner in cases: - assert s.serialize(dialect_instance, obj) == f"'{inner}'::inet" + if name == "PostgreSql": + expected = f"E'{inner}'::inet" + else: + expected = f"'{inner}'::inet" + assert s.serialize(dialect_instance, obj) == expected def test_invalid_address(self): with pytest.raises(ValueError): @@ -323,7 +332,7 @@ def test_canonical_form(self, dialect_instance: SqlDialect): MacAddressSerializer().serialize(dialect_instance, "01:23:45:67:89:ab") return s = MacAddressSerializer() - expected = "'01:23:45:67:89:AB'::macaddr" + expected = "E'01:23:45:67:89:AB'::macaddr" assert s.serialize(dialect_instance, "01:23:45:67:89:ab") == expected assert s.serialize(dialect_instance, "01-23-45-67-89-AB") == expected @@ -410,8 +419,8 @@ def test_unsupported_serializer_error_message(): class TestLiteralGoldens: - def test_pg_plain_string_skips_e_prefix(self): - assert StringSerializer().serialize(PG, "hello") == "'hello'" + def test_pg_string_uses_e_prefix(self): + assert StringSerializer().serialize(PG, "hello") == "E'hello'" assert StringSerializer().serialize(PG, "a\nb") == "E'a\\nb'" def test_mssql_nvarchar_prefix(self): From 087bd243a80aced31062a7b90c353d09bd95443f Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Thu, 1 Oct 2026 12:47:36 +0330 Subject: [PATCH 60/87] Add timestamptz/timestamp with time zone to interned data types --- src/data_type/name.rs | 4 +++- src/lib.rs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/data_type/name.rs b/src/data_type/name.rs index c661c52..ca0d391 100644 --- a/src/data_type/name.rs +++ b/src/data_type/name.rs @@ -51,7 +51,7 @@ impl DataTypeName { } } -/// Longest interned name is `"timestamp with timezone"` (23 bytes). +// Longest interned name is `"timestamp with time zone"` (24 bytes). const LOOKUP_BUF: usize = 32; fn interned(value: &str) -> Option { @@ -154,6 +154,8 @@ simple_data_types! { (DATETIME_DATA_TYPE => ("DATETIME", Datetime), "datetime"); (TIMESTAMP_DATA_TYPE => ("TIMESTAMP", Datetime), "timestamp"); (TIMESTAMP_WITH_TIMEZONE_DATA_TYPE => ("TIMESTAMP WITH TIMEZONE", Datetime), "timestamp with timezone"); + (TIMESTAMP_WITH_TIME_ZONE_DATA_TYPE => ("TIMESTAMP WITH TIME ZONE", Datetime), "timestamp with time zone"); + (TIMESTAMPTZ_DATA_TYPE => ("TIMESTAMPTZ", Datetime), "timestamptz"); // --- Json --- (JSON_DATA_TYPE => ("JSON", Json), "json"); diff --git a/src/lib.rs b/src/lib.rs index 71e7d60..2d6e9e1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,7 +10,7 @@ pub mod typeref; // Tasks until being stable (until this part) // - Clean code -// - Add fmt/lint/coding-style rules +// - Add lint/coding-style rules // - Restore GenericDialect ( very helpful for testing ) // - Test and coverage everything and edge cases // - Update docstrings / doccomments From f6c8d5224ba3cad066179ef351fe335c2f697c60 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Thu, 1 Oct 2026 12:58:02 +0330 Subject: [PATCH 61/87] Expand data type name tests --- rapidquery-macros/lib.rs | 26 +++--- src/data_type/name.rs | 4 +- src/data_type/tests.rs | 187 +++++++++++++++++++++++++++++++-------- 3 files changed, 166 insertions(+), 51 deletions(-) diff --git a/rapidquery-macros/lib.rs b/rapidquery-macros/lib.rs index 6949979..b3ff87c 100644 --- a/rapidquery-macros/lib.rs +++ b/rapidquery-macros/lib.rs @@ -64,6 +64,17 @@ impl Parse for PyClassInput { } } +/// Defines a pyo3 class with a required module path. +/// +/// ```ignore +/// #[pyclass("foo.Bar")] +/// struct Bar; +/// ``` +/// +/// ```ignore +/// #[pyclass("foo" + "Bar", subclass)] +/// struct Bar; +/// ``` #[proc_macro_attribute] pub fn pyclass(attr: TokenStream, item: TokenStream) -> TokenStream { let PyClassInput { @@ -148,18 +159,11 @@ impl Parse for PyErrInput { } } -/// Creates a new [`PyErr`] of the given exception type. -/// -/// Examples: -/// ```rust,no-test -/// pyerr!(PyTypeError) -/// // => PyTypeError::new_err(()) -/// -/// pyerr!(PyTypeError, "hello") -/// // => PyTypeError::new_err("hello") +/// Builds a [`PyErr`], or `Err(PyErr)` when the name is wrapped in `Err`. /// -/// pyerr!(PyTypeError, "hello {}", "world") -/// // => PyTypeError::new_err(format!("hello {}", "world")) +/// ```ignore +/// pyerr!(PyTypeError, "bad {}", x) +/// pyerr!(Err(PyValueError)) /// ``` #[proc_macro] pub fn pyerr(item: TokenStream) -> TokenStream { diff --git a/src/data_type/name.rs b/src/data_type/name.rs index ca0d391..ec3da3f 100644 --- a/src/data_type/name.rs +++ b/src/data_type/name.rs @@ -51,7 +51,7 @@ impl DataTypeName { } } -// Longest interned name is `"timestamp with time zone"` (24 bytes). +// Longest interned name is `"timestamp without timezone"` (26 bytes). const LOOKUP_BUF: usize = 32; fn interned(value: &str) -> Option { @@ -152,7 +152,7 @@ simple_data_types! { // --- Datetime --- (DATETIME_DATA_TYPE => ("DATETIME", Datetime), "datetime"); - (TIMESTAMP_DATA_TYPE => ("TIMESTAMP", Datetime), "timestamp"); + (TIMESTAMP_DATA_TYPE => ("TIMESTAMP", Datetime), "timestamp" | "timestamp without timezone"); (TIMESTAMP_WITH_TIMEZONE_DATA_TYPE => ("TIMESTAMP WITH TIMEZONE", Datetime), "timestamp with timezone"); (TIMESTAMP_WITH_TIME_ZONE_DATA_TYPE => ("TIMESTAMP WITH TIME ZONE", Datetime), "timestamp with time zone"); (TIMESTAMPTZ_DATA_TYPE => ("TIMESTAMPTZ", Datetime), "timestamptz"); diff --git a/src/data_type/tests.rs b/src/data_type/tests.rs index ed53acf..27b94cd 100644 --- a/src/data_type/tests.rs +++ b/src/data_type/tests.rs @@ -8,66 +8,150 @@ fn data_type_group_is_copy() { } #[test] -fn known_names_are_canonicalized() { +fn all_interned_names_canonicalize() { let cases = [ - ("int", "INT", DataTypeKind::Integer), - ("INT", "INT", DataTypeKind::Integer), - ("Integer", "INTEGER", DataTypeKind::Integer), - ("integer", "INTEGER", DataTypeKind::Integer), + // --- String --- + ("character", "CHARACTER", DataTypeKind::String), + ("char", "CHAR", DataTypeKind::String), + ( + "character varying", + "CHARACTER VARYING", + DataTypeKind::String, + ), + ("char varying", "CHAR VARYING", DataTypeKind::String), ("varchar", "VARCHAR", DataTypeKind::String), + ("nvarchar", "NVARCHAR", DataTypeKind::String), ("text", "TEXT", DataTypeKind::String), + // --- Uuid --- ("uuid", "UUID", DataTypeKind::Uuid), - ("bool", "BOOLEAN", DataTypeKind::Boolean), + // --- Bytes --- + ("binary", "BINARY", DataTypeKind::Bytes), + ("varbinary", "VARBINARY", DataTypeKind::Bytes), + ("blob", "BLOB", DataTypeKind::Bytes), + ("bytea", "BYTEA", DataTypeKind::Bytes), + // --- Bit --- + ("bit", "BIT", DataTypeKind::Bit), + ("bit varying", "BIT VARYING", DataTypeKind::Bit), + // --- Float (includes decimal/numeric) --- + ("numeric", "NUMERIC", DataTypeKind::Float), + ("decimal", "DECIMAL", DataTypeKind::Float), + ("dec", "DEC", DataTypeKind::Float), + ("float", "FLOAT", DataTypeKind::Float), + ("real", "REAL", DataTypeKind::Float), + ("double", "DOUBLE", DataTypeKind::Float), + ("double precision", "DOUBLE PRECISION", DataTypeKind::Float), + // --- Integer --- + ("tinyint", "TINYINT", DataTypeKind::Integer), + ("smallint", "SMALLINT", DataTypeKind::Integer), + ("int", "INT", DataTypeKind::Integer), + ("integer", "INTEGER", DataTypeKind::Integer), + ("bigint", "BIGINT", DataTypeKind::Integer), + // --- Boolean --- ("boolean", "BOOLEAN", DataTypeKind::Boolean), + // --- Date --- + ("date", "DATE", DataTypeKind::Date), + // --- Time --- + ("time", "TIME", DataTypeKind::Time), ( - "timestamptz", - "TIMESTAMP WITH TIMEZONE", - DataTypeKind::Datetime, + "time with timezone", + "TIME WITH TIMEZONE", + DataTypeKind::Time, ), + // --- Datetime --- + ("datetime", "DATETIME", DataTypeKind::Datetime), + ("timestamp", "TIMESTAMP", DataTypeKind::Datetime), ( "timestamp without timezone", "TIMESTAMP", DataTypeKind::Datetime, ), - ("macaddr", "MACADDRESS", DataTypeKind::MacAddress), - ("jsonb", "JSONB", DataTypeKind::Jsonb), ( - "double precision unsigned", - "DOUBLE PRECISION UNSIGNED", - DataTypeKind::Float, + "timestamp with timezone", + "TIMESTAMP WITH TIMEZONE", + DataTypeKind::Datetime, ), + ( + "timestamp with time zone", + "TIMESTAMP WITH TIME ZONE", + DataTypeKind::Datetime, + ), + ("timestamptz", "TIMESTAMPTZ", DataTypeKind::Datetime), + // --- Json / Jsonb stay distinct groups --- + ("json", "JSON", DataTypeKind::Json), + ("jsonb", "JSONB", DataTypeKind::Jsonb), + // --- Inet --- + ("inet", "INET", DataTypeKind::Inet), + // --- MacAddress (alias collapses onto the interned spelling) --- + ("macaddress", "MACADDRESS", DataTypeKind::MacAddress), + ("macaddr", "MACADDRESS", DataTypeKind::MacAddress), ]; - for (input, canonical, group) in cases { - let dt = DataTypeName::from(input); - assert_eq!(dt.as_str(), canonical, "input {input:?}"); - assert_eq!(dt.kind(), group, "input {input:?}"); + + for (input, canonical, kind) in cases { + let from_str = DataTypeName::from(input); + assert_eq!(from_str.as_str(), canonical, "input {input:?}"); + assert_eq!(from_str.kind(), kind, "input {input:?}"); + + let upper = input.to_ascii_uppercase(); + let from_upper = DataTypeName::from(upper.as_str()); + assert_eq!(from_upper.as_str(), canonical, "input {upper:?}"); + assert_eq!(from_upper.kind(), kind, "input {upper:?}"); + assert_eq!(from_str, from_upper, "case fold of {input:?}"); + + let from_string = DataTypeName::from(input.to_string()); + assert_eq!(from_string, from_str, "From of {input:?}"); + assert_eq!(from_string.kind(), kind); } } #[test] -fn character_varying_typo_and_correct_spelling() { - let correct = DataTypeName::from("character varying"); - let typo = DataTypeName::from("characater varying"); - assert_eq!(correct.as_str(), "CHARACTER VARYING"); - assert_eq!(typo.as_str(), "CHARACTER VARYING"); - assert_eq!(correct.kind(), DataTypeKind::String); - assert_eq!(correct, typo); +fn interned_synonyms_compare_equal() { + assert_eq!( + DataTypeName::from("timestamp"), + DataTypeName::from("timestamp without timezone"), + ); + assert_eq!( + DataTypeName::from("macaddress"), + DataTypeName::from("MACADDR"), + ); + // Distinct interned spellings stay distinct even when the kind matches. + assert_ne!( + DataTypeName::from("timestamp with timezone"), + DataTypeName::from("timestamp with time zone"), + ); + assert_ne!(DataTypeName::from("json"), DataTypeName::from("jsonb")); + assert_ne!(DataTypeName::from("int"), DataTypeName::from("integer")); } #[test] -fn unsigned_bigint_typo_and_correct_spelling() { - let correct = DataTypeName::from("unsigned bigint"); - let typo = DataTypeName::from("unsigned bitint"); - let alias = DataTypeName::from("ubigint"); - assert_eq!(correct.as_str(), "BIGINT UNSIGNED"); - assert_eq!(typo.as_str(), "BIGINT UNSIGNED"); - assert_eq!(alias.as_str(), "BIGINT UNSIGNED"); - assert_eq!(correct.kind(), DataTypeKind::Integer); +fn mixed_case_known_names_are_canonicalized() { + let cases = [ + ("Int", "INT", DataTypeKind::Integer), + ("InTeGeR", "INTEGER", DataTypeKind::Integer), + ("VarChar", "VARCHAR", DataTypeKind::String), + ("tExT", "TEXT", DataTypeKind::String), + ("UuId", "UUID", DataTypeKind::Uuid), + ("Boolean", "BOOLEAN", DataTypeKind::Boolean), + ("TimeStampTz", "TIMESTAMPTZ", DataTypeKind::Datetime), + ( + "Timestamp Without Timezone", + "TIMESTAMP", + DataTypeKind::Datetime, + ), + ("MacAddr", "MACADDRESS", DataTypeKind::MacAddress), + ("JsonB", "JSONB", DataTypeKind::Jsonb), + ("Double Precision", "DOUBLE PRECISION", DataTypeKind::Float), + ("ByteA", "BYTEA", DataTypeKind::Bytes), + ]; + for (input, canonical, kind) in cases { + let dt = DataTypeName::from(input); + assert_eq!(dt.as_str(), canonical, "input {input:?}"); + assert_eq!(dt.kind(), kind, "input {input:?}"); + } } #[test] fn parameterized_name_is_custom_and_preserved() { - for input in ["varchar(255)", "VARCHAR(255)", "decimal(10, 2)"] { + for input in ["varchar(255)", "VARCHAR(255)", "decimal(10, 2)", "int(11)"] { let dt = DataTypeName::from(input); assert_eq!(dt.as_str(), input); assert_eq!(dt.kind(), DataTypeKind::Custom); @@ -88,9 +172,36 @@ fn from_string_reuses_unknown_allocation_path() { assert_eq!(dt.kind(), DataTypeKind::Custom); } +/// Non-ASCII never hits the interned table: the lookup bails before matching, +/// and the caller's spelling (including unprintable scalars) is kept as Custom. #[test] -fn non_ascii_name_is_custom() { - let dt = DataTypeName::from("тип"); - assert_eq!(dt.as_str(), "тип"); - assert_eq!(dt.kind(), DataTypeKind::Custom); +fn unicode_and_unprintable_names_are_custom() { + let cases = [ + "тип", + "文字", + "intšŸ”„", + // Cyrillic 'е' (U+0435), so this is not the interned "integer". + "intеger", + "varchar\u{200b}", + "\u{0000}", + "int\u{0000}", + "\t", + "\n", + "\r\n", + "\u{0001}", + "\u{0007}", + "\u{001b}", + "\u{007f}", + "bit\u{0008}varying", + ]; + for input in cases { + let from_str = DataTypeName::from(input); + assert_eq!(from_str.as_str(), input, "input {input:?}"); + assert_eq!(from_str.kind(), DataTypeKind::Custom, "input {input:?}"); + + let from_string = DataTypeName::from(input.to_string()); + assert_eq!(from_string.as_str(), input, "From {input:?}"); + assert_eq!(from_string.kind(), DataTypeKind::Custom); + assert_eq!(from_str, from_string); + } } From 97d82a2220c79def065e4ccbf166ac91cc73d889 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Thu, 1 Oct 2026 13:09:29 +0330 Subject: [PATCH 62/87] Expand interned data type names --- src/data_type/name.rs | 14 +++++++----- tests/test_data_type.py | 49 +++-------------------------------------- 2 files changed, 12 insertions(+), 51 deletions(-) diff --git a/src/data_type/name.rs b/src/data_type/name.rs index ec3da3f..6871f6e 100644 --- a/src/data_type/name.rs +++ b/src/data_type/name.rs @@ -51,7 +51,7 @@ impl DataTypeName { } } -// Longest interned name is `"timestamp without timezone"` (26 bytes). +// Longest interned name is `"timestamp without time zone"` (27 bytes). const LOOKUP_BUF: usize = 32; fn interned(value: &str) -> Option { @@ -115,6 +115,7 @@ simple_data_types! { (UUID_DATA_TYPE => ("UUID", Uuid), "uuid"); // --- Bytes --- + (BYTES_DATA_TYPE => ("BYTES", Bytes), "bytes"); (BINARY_DATA_TYPE => ("BINARY", Bytes), "binary"); (VARBINARY_DATA_TYPE => ("VARBINARY", Bytes), "varbinary"); (BLOB_DATA_TYPE => ("BLOB", Bytes), "blob"); @@ -122,12 +123,14 @@ simple_data_types! { // --- Bit --- (BIT_DATA_TYPE => ("BIT", Bit), "bit"); + (VARBIT_DATA_TYPE => ("VARBIT", Bit), "varbit"); (BIT_VARYING_DATA_TYPE => ("BIT VARYING", Bit), "bit varying"); // --- Float (includes decimal/numeric) --- (NUMERIC_DATA_TYPE => ("NUMERIC", Float), "numeric"); + (BIGNUMERIC_DATA_TYPE => ("BIGNUMERIC", Float), "bignumeric"); (DECIMAL_DATA_TYPE => ("DECIMAL", Float), "decimal"); - (DEC_DATA_TYPE => ("DEC", Float), "dec"); + (BIGDECIMAL_DATA_TYPE => ("BIGDECIMAL", Float), "bigdecimal"); (FLOAT_DATA_TYPE => ("FLOAT", Float), "float"); (REAL_DATA_TYPE => ("REAL", Float), "real"); (DOUBLE_DATA_TYPE => ("DOUBLE", Float), "double"); @@ -141,18 +144,19 @@ simple_data_types! { (BIGINT_DATA_TYPE => ("BIGINT", Integer), "bigint"); // --- Boolean --- - (BOOLEAN_DATA_TYPE => ("BOOLEAN", Boolean), "boolean"); + (BOOLEAN_DATA_TYPE => ("BOOLEAN", Boolean), "boolean" | "bool"); // --- Date --- (DATE_DATA_TYPE => ("DATE", Date), "date"); // --- Time --- - (TIME_DATA_TYPE => ("TIME", Time), "time"); + (TIME_DATA_TYPE => ("TIME", Time), "time" | "time without timezone" | "time without time zone"); (TIME_WITH_TIMEZONE_DATA_TYPE => ("TIME WITH TIMEZONE", Time), "time with timezone"); + (TIME_WITH_TIME_ZONE_DATA_TYPE => ("TIME WITH TIME ZONE", Time), "time with time zone"); // --- Datetime --- (DATETIME_DATA_TYPE => ("DATETIME", Datetime), "datetime"); - (TIMESTAMP_DATA_TYPE => ("TIMESTAMP", Datetime), "timestamp" | "timestamp without timezone"); + (TIMESTAMP_DATA_TYPE => ("TIMESTAMP", Datetime), "timestamp" | "timestamp without timezone" | "timestamp without time zone"); (TIMESTAMP_WITH_TIMEZONE_DATA_TYPE => ("TIMESTAMP WITH TIMEZONE", Datetime), "timestamp with timezone"); (TIMESTAMP_WITH_TIME_ZONE_DATA_TYPE => ("TIMESTAMP WITH TIME ZONE", Datetime), "timestamp with time zone"); (TIMESTAMPTZ_DATA_TYPE => ("TIMESTAMPTZ", Datetime), "timestamptz"); diff --git a/tests/test_data_type.py b/tests/test_data_type.py index 6e2374e..1168f70 100644 --- a/tests/test_data_type.py +++ b/tests/test_data_type.py @@ -63,35 +63,10 @@ class TestDataTypeSerializerInference: "name", [ "tinyint", - "tinyint unsigned", - "utinyint", - "int2", - "int2 unsigned", "smallint", - "smallint unsigned", - "usmallint", - "mediumint", - "mediumint unsigned", "int", - "int4", - "int8", - "int64", "integer", - "unsigned integer", - "integer unsigned", - "int unsigned", - "int4 unsigned", - "hugeint", - "uhugeint", "bigint", - "bigint unsigned", - "unsigned bigint", - "unsigned bitint", - "ubigint", - "int8 unsigned", - "signed", - "signed integer", - "unsigned", ], ) def test_integer_family_infers_integer_serializer(self, name): @@ -104,17 +79,10 @@ def test_integer_family_infers_integer_serializer(self, name): "decimal", "bignumeric", "bigdecimal", - "dec", "float", - "float4", - "float8", - "float64", "real", - "real unsigned", "double", - "double unsigned", "double precision", - "double precision unsigned", ], ) def test_float_family_infers_float_serializer(self, name): @@ -124,21 +92,12 @@ def test_float_family_infers_float_serializer(self, name): "name", [ "character", - "char", "character varying", - "characater varying", + "char", "char varying", "varchar", "nvarchar", - "character large object", - "char large object", - "clob", - "regclass", "text", - "tinytext", - "mediumtext", - "longtext", - "string", ], ) def test_string_family_infers_string_serializer(self, name): @@ -153,9 +112,6 @@ def test_uuid_infers_uuid_serializer(self): "binary", "varbinary", "blob", - "tinyblob", - "mediumblob", - "longblob", "bytes", "bytea", ], @@ -182,9 +138,10 @@ def test_time_family_infers_time_serializer(self, name): "datetime", "timestamp", "timestamp without timezone", + "timestamp without time zone", "timestamp with timezone", + "timestamp with time zone", "timestamptz", - "timestampntz", ], ) def test_datetime_family_infers_datetime_serializer(self, name): From c1b48a90c560735193f31681f3d78a03f8852a55 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Fri, 2 Oct 2026 20:36:02 +0330 Subject: [PATCH 63/87] Rewrite dialects with packed bits to save memory --- Cargo.lock | 16 +- Cargo.toml | 60 +- rust-toolchain.toml | 2 + src/data_type/mod.rs | 4 +- src/data_type/name.rs | 2 +- src/data_type/serializer/functions.rs | 118 ++- src/data_type/serializer/inner.rs | 4 +- src/data_type/serializer/mod.rs | 2 +- src/dialect/base.rs | 27 +- src/dialect/builtin.rs | 1048 ++++++++++---------- src/dialect/errors.rs | 27 + src/dialect/flags.rs | 1025 ++++++++++++++++++++ src/dialect/inner.rs | 1266 ------------------------- src/dialect/mod.rs | 90 +- src/expr/base.rs | 4 +- src/expr/enum_/func.rs | 37 +- src/expr/enum_/ident.rs | 6 +- src/expr/enum_/like.rs | 83 +- src/expr/enum_/mod.rs | 6 +- src/expr/enum_/nodes.rs | 214 +++-- src/expr/enum_/value.rs | 4 +- src/lib.rs | 1 - src/sql.rs | 37 +- src/typeref.rs | 4 +- 24 files changed, 1938 insertions(+), 2149 deletions(-) create mode 100644 rust-toolchain.toml create mode 100644 src/dialect/errors.rs create mode 100644 src/dialect/flags.rs delete mode 100644 src/dialect/inner.rs diff --git a/Cargo.lock b/Cargo.lock index 62a2664..82809c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,11 +29,22 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "bitfield-struct" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ca6739863c590881f038d033a146c51ddae239186a4327014839fd864f44ed5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "bitflags" -version = "2.9.4" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "bumpalo" @@ -359,6 +370,7 @@ name = "rapidquery" version = "1.0.0-alpha1" dependencies = [ "arcstr", + "bitfield-struct", "chrono", "hashbrown", "ipnetwork", diff --git a/Cargo.toml b/Cargo.toml index 887d324..f037087 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,65 +1,67 @@ -[workspace] -members = ["rapidquery-macros"] - [package] name = "rapidquery" version = "1.0.0-alpha1" edition = "2024" description = "RapidQuery is the fastest, full-feature, and easy-to-use Python SQL query builder written in Rust." -license = "MIT" homepage = "https://github.com/awolverp/rapidquery" repository = "https://github.com/awolverp/rapidquery.git" -authors = ["Ali Pooralijan "] +license = "MIT" + +[workspace] +members = ["rapidquery-macros"] [lib] -name = "rapidquery" crate-type = ["cdylib", "rlib"] - -[profile.dev] -lto = true -panic = "unwind" -strip = false - -[profile.release] -codegen-units = 1 -debug = false -incremental = false -lto = true -panic = "abort" -strip = false -opt-level = 3 +name = "rapidquery" [dependencies] -rapidquery-macros = { path = "rapidquery-macros" } -pyo3 = { version = "0.29", default-features = false, features = [ "macros", "chrono", "py-clone", "hashbrown" ] } -parking_lot = { version = "0.12", default-features = false } -hashbrown = { version = "0.17.1", default-features = false, features = ["allocator-api2", "default-hasher", "inline-more"] } arcstr = { version = "1.2" } +bitfield-struct = { version = "0.13.0", default-features = false } chrono = { version = "0.4", default-features = false, features = ["clock"] } +hashbrown = { version = "0.17.1", default-features = false, features = ["default-hasher", "allocator-api2", "inline-more"] } ipnetwork = { version = "0.20", default-features = false } mac_address = { version = "1.1", default-features = false } once_cell = { version = "1.21.4", default-features = false } +parking_lot = { version = "0.12", default-features = false } +pyo3 = { version = "0.29", default-features = false, features = ["chrono", "hashbrown", "macros", "py-clone"] } +rapidquery-macros = { path = "rapidquery-macros" } smallvec = { version = "1.16.0", default-features = false } +uuid = { version = "1.26.1", default-features = false } -# ONLY for inspiring the source -sqlparser = { version = "0.62.0", default-features = false } # ONLY for inspiring the source sea-query = { version = "1.0.2", default-features = false } -uuid = { version = "1.26.1", default-features = false } +# ONLY for inspiring the source +sqlparser = { version = "0.62.0", default-features = false } [features] default = ["extension-module"] extension-module = ["pyo3/extension-module"] nightly = ["hashbrown/nightly"] + # Test harness only. `extension-module` does not link libpython, so Rust tests # that touch Python use `--no-default-features --features auto-initialize`. auto-initialize = ["pyo3/auto-initialize"] -# Testing features -only-stdlib-json = [] +# Testing feature only-orjson = [] +# Testing feature +only-stdlib-json = [] [lints.clippy] dbg_macro = "warn" print_stderr = "warn" print_stdout = "warn" + +[profile.dev] +strip = false +lto = true +panic = "unwind" + +[profile.release] +opt-level = 3 +debug = false +strip = false +lto = true +panic = "abort" +incremental = false +codegen-units = 1 diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..31578d3 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" \ No newline at end of file diff --git a/src/data_type/mod.rs b/src/data_type/mod.rs index 7e798e4..5a7c213 100644 --- a/src/data_type/mod.rs +++ b/src/data_type/mod.rs @@ -67,7 +67,7 @@ impl crate::sql::WriteSql for PyDataType { #[inline] fn write_sql( &self, - dialect: &crate::dialect::PySqlDialect, + dialect: &crate::dialect::PyDialect, out: &mut W, ) -> crate::sql::PrepareResult<()> { self.name.write_sql(dialect, out) @@ -135,7 +135,7 @@ impl PyDataType { /// type's serializer (bit strings, `ARRAY[…]`, inet, macaddr). fn serialize( &self, - dialect: crate::dialect::PySqlDialect, + dialect: crate::dialect::PyDialect, object: alias::BoundObject<'_>, ) -> pyo3::PyResult { let mut buf = String::new(); diff --git a/src/data_type/name.rs b/src/data_type/name.rs index 6871f6e..bcc9de1 100644 --- a/src/data_type/name.rs +++ b/src/data_type/name.rs @@ -221,7 +221,7 @@ impl std::fmt::Display for DataTypeName { impl WriteSql for DataTypeName { fn write_sql( &self, - _dialect: &crate::dialect::PySqlDialect, + _dialect: &crate::dialect::PyDialect, out: &mut W, ) -> PrepareResult<()> { out.push_str(self.as_str()); diff --git a/src/data_type/serializer/functions.rs b/src/data_type/serializer/functions.rs index 6b778f9..fd6079e 100644 --- a/src/data_type/serializer/functions.rs +++ b/src/data_type/serializer/functions.rs @@ -1,17 +1,9 @@ use pyo3::types::PyAnyMethods; use pyo3::types::PyTzInfoAccess; -use crate::dialect::PySqlDialect; -use crate::dialect::inner::ArrayFormat; -use crate::dialect::inner::DateFormat; -use crate::dialect::inner::Dialect; -use crate::dialect::inner::FormatError; -use crate::dialect::inner::InetFormat; -use crate::dialect::inner::JsonFormat; -use crate::dialect::inner::MacAddrFormat; -use crate::dialect::inner::TimeFormat; -use crate::dialect::inner::TimestampFormat; -use crate::dialect::inner::UuidFormat; +use crate::dialect::PyDialect; +use crate::dialect::errors::FormatError; +use crate::dialect::flags; use crate::internal::alias; use crate::internal::utils; use std::fmt::Write; @@ -35,12 +27,12 @@ pub(super) unsafe fn pyobject_string( out } -fn write_quoted(dialect: &Dialect, out: &mut String, raw: &str) -> pyo3::PyResult<()> { +fn write_quoted(dialect: &flags::DialectFlags, out: &mut String, raw: &str) -> pyo3::PyResult<()> { dialect.write_string_literal(out, raw).map_err(Into::into) } fn write_with_keyword( - dialect: &Dialect, + dialect: &flags::DialectFlags, out: &mut String, keyword: Option<&str>, raw: &str, @@ -52,11 +44,13 @@ fn write_with_keyword( write_quoted(dialect, out, raw) } -fn timestamp_keyword(format: TimestampFormat, aware: bool) -> Option<&'static str> { +fn timestamp_keyword(format: flags::TimestampFormat, aware: bool) -> Option<&'static str> { match format { - TimestampFormat::Quoted => None, - TimestampFormat::Timestamptz => Some(if aware { "TIMESTAMPTZ" } else { "TIMESTAMP" }), - TimestampFormat::WithTimezone => Some(if aware { + flags::TimestampFormat::Quoted => None, + flags::TimestampFormat::Timestamptz => { + Some(if aware { "TIMESTAMPTZ" } else { "TIMESTAMP" }) + } + flags::TimestampFormat::WithTimezone => Some(if aware { "TIMESTAMP WITH TIMEZONE" } else { "TIMESTAMP" @@ -137,7 +131,7 @@ pub(super) unsafe fn write_float( pub(super) unsafe fn write_bool( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &Dialect, + dialect: &flags::DialectFlags, out: &mut String, ) -> pyo3::PyResult<()> { if pyo3::ffi::PyBool_Check(ptr) == 1 { @@ -157,7 +151,7 @@ pub(super) unsafe fn write_bool( pub(super) unsafe fn write_str( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &Dialect, + dialect: &flags::DialectFlags, out: &mut String, ) -> pyo3::PyResult<()> { if pyo3::ffi::PyUnicode_Check(ptr) == 1 { @@ -177,7 +171,7 @@ pub(super) unsafe fn write_str( pub(super) unsafe fn write_uuid( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &Dialect, + dialect: &flags::DialectFlags, out: &mut String, ) -> pyo3::PyResult<()> { let text = if crate::typeref::check_exact(ptr, crate::typeref::STD_UUID_TYPE) { @@ -196,13 +190,13 @@ pub(super) unsafe fn write_uuid( ); }; - match dialect.uuid_format { - UuidFormat::Quoted => write_quoted(dialect, out, &text), - UuidFormat::UuidKeyword => { + match dialect.uuid_format() { + flags::UuidFormat::Quoted => write_quoted(dialect, out, &text), + flags::UuidFormat::UuidKeyword => { out.push_str("UUID "); write_quoted(dialect, out, &text) } - UuidFormat::CastUuid => { + flags::UuidFormat::CastUuid => { write_quoted(dialect, out, &text)?; out.push_str("::uuid"); Ok(()) @@ -214,7 +208,7 @@ pub(super) unsafe fn write_uuid( pub(super) unsafe fn write_bytes( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &Dialect, + dialect: &flags::DialectFlags, out: &mut String, ) -> pyo3::PyResult<()> { if pyo3::ffi::PyBytes_CheckExact(ptr) == 1 { @@ -238,7 +232,7 @@ pub(super) unsafe fn write_bytes( pub(super) unsafe fn write_bit( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &Dialect, + dialect: &flags::DialectFlags, out: &mut String, ) -> pyo3::PyResult<()> { if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { @@ -266,18 +260,18 @@ pub(super) unsafe fn write_bit( pub(super) unsafe fn write_json( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &Dialect, + dialect: &flags::DialectFlags, out: &mut String, ) -> pyo3::PyResult<()> { crate::internal::modules::json_dumps( unsafe { pyo3::Bound::from_borrowed_ptr(py, ptr) }, - |text| match dialect.json_format { - JsonFormat::Quoted => write_quoted(dialect, out, text), - JsonFormat::JsonKeyword => { + |text| match dialect.json_format() { + flags::JsonFormat::Quoted => write_quoted(dialect, out, text), + flags::JsonFormat::JsonKeyword => { out.push_str("JSON "); write_quoted(dialect, out, text) } - JsonFormat::ParseJson => { + flags::JsonFormat::ParseJson => { out.push_str("PARSE_JSON("); write_quoted(dialect, out, text)?; out.push(')'); @@ -291,7 +285,7 @@ pub(super) unsafe fn write_json( pub(super) unsafe fn write_date( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &Dialect, + dialect: &flags::DialectFlags, out: &mut String, ) -> pyo3::PyResult<()> { if crate::typeref::check_exact(ptr, crate::typeref::STD_DATE_TYPE) { @@ -301,9 +295,9 @@ pub(super) unsafe fn write_date( let date: chrono::NaiveDate = val.extract()?; let mut body = String::new(); let _ = date.format("%Y-%m-%d").write_to(&mut body); - let keyword = match dialect.date_format { - DateFormat::Quoted => None, - DateFormat::Keyword => Some("DATE"), + let keyword = match dialect.date_format() { + flags::DateFormat::Quoted => None, + flags::DateFormat::Keyword => Some("DATE"), }; write_with_keyword(dialect, out, keyword, &body) } else { @@ -319,7 +313,7 @@ pub(super) unsafe fn write_date( pub(super) unsafe fn write_time( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &Dialect, + dialect: &flags::DialectFlags, out: &mut String, ) -> pyo3::PyResult<()> { if crate::typeref::check_exact(ptr, crate::typeref::STD_TIME_TYPE) { @@ -329,9 +323,9 @@ pub(super) unsafe fn write_time( let time: chrono::NaiveTime = val.extract()?; let mut body = String::new(); let _ = time.format("%H:%M:%S%.f").write_to(&mut body); - let keyword = match dialect.time_format { - TimeFormat::Quoted => None, - TimeFormat::Keyword => Some("TIME"), + let keyword = match dialect.time_format() { + flags::TimeFormat::Quoted => None, + flags::TimeFormat::Keyword => Some("TIME"), }; write_with_keyword(dialect, out, keyword, &body) } else { @@ -347,7 +341,7 @@ pub(super) unsafe fn write_time( pub(super) unsafe fn write_datetime( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &Dialect, + dialect: &flags::DialectFlags, out: &mut String, ) -> pyo3::PyResult<()> { use chrono::TimeZone; @@ -363,7 +357,7 @@ pub(super) unsafe fn write_datetime( return write_with_keyword( dialect, out, - timestamp_keyword(dialect.timestamp_format, false), + timestamp_keyword(dialect.timestamp_format(), false), &body, ); } @@ -376,7 +370,7 @@ pub(super) unsafe fn write_datetime( return write_with_keyword( dialect, out, - timestamp_keyword(dialect.timestamp_format, true), + timestamp_keyword(dialect.timestamp_format(), true), &body, ); } @@ -397,7 +391,7 @@ pub(super) unsafe fn write_datetime( write_with_keyword( dialect, out, - timestamp_keyword(dialect.timestamp_format, true), + timestamp_keyword(dialect.timestamp_format(), true), &body, ) } @@ -422,7 +416,7 @@ pub(super) unsafe fn write_datetime( write_with_keyword( dialect, out, - timestamp_keyword(dialect.timestamp_format, true), + timestamp_keyword(dialect.timestamp_format(), true), &body, ) } @@ -445,7 +439,7 @@ pub(super) unsafe fn write_datetime( pub(super) unsafe fn write_inet( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &Dialect, + dialect: &flags::DialectFlags, out: &mut String, ) -> pyo3::PyResult<()> { let text = pyobject_string(py, ptr)?; @@ -453,10 +447,10 @@ pub(super) unsafe fn write_inet( .map_err(|e| rapidquery_macros::pyerr!(PyValueError, e.to_string()))?; let body = net.to_string(); - match dialect.inet_format { - None => Err(FormatError::Unsupported("InetSerializer").into()), - Some(InetFormat::Quoted) => write_quoted(dialect, out, &body), - Some(InetFormat::Cast) => { + match dialect.inet_format() { + flags::InetFormat::Unsupported => Err(FormatError::Unsupported("InetSerializer").into()), + flags::InetFormat::Quoted => write_quoted(dialect, out, &body), + flags::InetFormat::Cast => { write_quoted(dialect, out, &body)?; out.push_str("::inet"); Ok(()) @@ -468,7 +462,7 @@ pub(super) unsafe fn write_inet( pub(super) unsafe fn write_macaddr( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &Dialect, + dialect: &flags::DialectFlags, out: &mut String, ) -> pyo3::PyResult<()> { let text = pyobject_string(py, ptr)?; @@ -476,10 +470,12 @@ pub(super) unsafe fn write_macaddr( .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; let body = addr.to_string(); - match dialect.macaddr_format { - None => Err(FormatError::Unsupported("MacAddressSerializer").into()), - Some(MacAddrFormat::Quoted) => write_quoted(dialect, out, &body), - Some(MacAddrFormat::Cast) => { + match dialect.macaddr_format() { + flags::MacAddrFormat::Unsupported => { + Err(FormatError::Unsupported("MacAddressSerializer").into()) + } + flags::MacAddrFormat::Quoted => write_quoted(dialect, out, &body), + flags::MacAddrFormat::Cast => { write_quoted(dialect, out, &body)?; out.push_str("::macaddr"); Ok(()) @@ -490,7 +486,7 @@ pub(super) unsafe fn write_macaddr( pub(super) unsafe fn write_array( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &PySqlDialect, + dialect: &PyDialect, element: &super::inner::SerializerType, out: &mut String, ) -> pyo3::PyResult<()> { @@ -513,19 +509,19 @@ pub(super) unsafe fn write_array( ); }; - let close = match dialect.0.array_format { - None => { + let close = match dialect.0.array_format() { + flags::ArrayFormat::Unsupported => { return Err(FormatError::Unsupported("ArraySerializer").into()); } - Some(ArrayFormat::Constructor) => { + flags::ArrayFormat::Constructor => { out.push_str("ARRAY["); ']' } - Some(ArrayFormat::List) => { + flags::ArrayFormat::List => { out.push('['); ']' } - Some(ArrayFormat::Function) => { + flags::ArrayFormat::Function => { out.push_str("array("); ')' } @@ -550,7 +546,7 @@ pub(super) unsafe fn write_array( pub(super) unsafe fn write_custom( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, - dialect: &PySqlDialect, + dialect: &PyDialect, function: &pyo3::Py, object: &alias::BoundObject<'_>, out: &mut String, diff --git a/src/data_type/serializer/inner.rs b/src/data_type/serializer/inner.rs index 51de927..fdbeffc 100644 --- a/src/data_type/serializer/inner.rs +++ b/src/data_type/serializer/inner.rs @@ -1,4 +1,4 @@ -use crate::dialect::PySqlDialect; +use crate::dialect::PyDialect; use crate::internal::alias; use crate::internal::modules; @@ -169,7 +169,7 @@ impl SerializerType { impl SerializerType { pub fn serialize( &self, - dialect: &PySqlDialect, + dialect: &PyDialect, object: &alias::BoundObject<'_>, out: &mut String, ) -> pyo3::PyResult<()> { diff --git a/src/data_type/serializer/mod.rs b/src/data_type/serializer/mod.rs index fa120de..b90ddd7 100644 --- a/src/data_type/serializer/mod.rs +++ b/src/data_type/serializer/mod.rs @@ -32,7 +32,7 @@ impl PySerializer { /// Renders `object` as a SQL literal for `dialect`. fn serialize( &self, - dialect: crate::dialect::PySqlDialect, + dialect: crate::dialect::PyDialect, object: alias::BoundObject<'_>, ) -> pyo3::PyResult { let mut buf = String::new(); diff --git a/src/dialect/base.rs b/src/dialect/base.rs index 0a362a7..1f3f33e 100644 --- a/src/dialect/base.rs +++ b/src/dialect/base.rs @@ -1,22 +1,15 @@ -use std::hash::Hash; -use std::hash::Hasher; - use crate::internal::utils; -use super::inner::Dialect; +use super::flags; -/// Abstract SQL dialect. -/// -/// Concrete dialects are frozen subclasses (`PostgreSql`, `DuckDb`, ...). -/// Pass an instance wherever a dialect is required. -/// This base class cannot be instantiated. +/// SQL dialect. /// /// Instances of the same dialect compare equal and hash together. -#[rapidquery_macros::pyclass("rapidquery.dialect.SqlDialect", subclass, skip_from_py_object)] +#[rapidquery_macros::pyclass("rapidquery.dialect.Dialect", skip_from_py_object)] #[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct PySqlDialect(pub Dialect); +pub struct PyDialect(pub flags::DialectFlags); -impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PySqlDialect { +impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyDialect { type Error = pyo3::PyErr; fn extract( @@ -25,14 +18,14 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PySqlDialect { unsafe { // SAFETY: `obj` is a borrowed `&Bound`, so `as_ptr()` is a // live `PyObject*`. - if crate::typeref::check(obj.as_ptr(), crate::typeref::SQL_DIALECT_TYPE) { + if crate::typeref::check_exact(obj.as_ptr(), crate::typeref::DIALECT_TYPE) { let cast = obj.cast_unchecked::(); return Ok(Self(cast.get().0)); } rapidquery_macros::pyerr!( Err(PyTypeError), - "expected a SqlDialect, got {}", + "expected Dialect, got {}", utils::get_type_name(obj.py(), obj.as_ptr()) ) } @@ -40,14 +33,14 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PySqlDialect { } #[pyo3::pymethods] -impl PySqlDialect { - // Bound so only instances match; the class object is a type. - // PostgreSql() == PostgreSql is false; PostgreSql() == PostgreSql() is true. +impl PyDialect { fn __eq__(&self, other: &pyo3::Bound<'_, Self>) -> bool { self.0 == other.get().0 } fn __hash__(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::hash::DefaultHasher::default(); self.0.hash(&mut hasher); hasher.finish() diff --git a/src/dialect/builtin.rs b/src/dialect/builtin.rs index 7e5490e..072d8dd 100644 --- a/src/dialect/builtin.rs +++ b/src/dialect/builtin.rs @@ -1,554 +1,522 @@ -use super::inner::*; +use super::base; +use super::flags; -macro_rules! impl_builtin_dialects { - ( - $( - $(#[$docs:meta])* - $name:ident = $pyname:literal { - $inner:expr - } - )+ - ) => { - $( - $(#[$docs])* - #[rapidquery_macros::pyclass("rapidquery._core.dialect" + $pyname, extends = super::base::PySqlDialect)] - pub struct $name; - - impl $name { - pub const CONFIG: Dialect = $inner; - } - - #[pyo3::pymethods] - impl $name { - #[new] - #[inline] - fn __new__() -> pyo3::PyClassInitializer { - pyo3::PyClassInitializer::from(super::base::PySqlDialect(Self::CONFIG)) - .add_subclass(Self) - } - } - )+ - }; -} - -impl_builtin_dialects!( - /// BigQuery SQL dialect. - PyBigQuery = "BigQuery" { - Dialect { - positional_parameter: PositionalParameter::Simple('?'), - bit_format: None, - array_format: Some(ArrayFormat::Constructor), - inet_format: None, - macaddr_format: None, - null_treatment_syntax: Some(NullTreatmentSyntax::InsideParens), - like_any_syntax: LikeAnySyntax::OrExpansion, - bool_format: BoolFormat::Keyword, - identifier_format: IdentifierFormat::Backtick, - identifier_escape: IdentifierEscape::Backslash, - string_format: StringFormat::SingleQuote, - string_escape: StringEscape::BackslashHex, - bytes_format: BytesFormat::PrefixBigBSlashX, - date_format: DateFormat::Keyword, - time_format: TimeFormat::Keyword, - timestamp_format: TimestampFormat::WithTimezone, - uuid_format: UuidFormat::Quoted, - json_format: JsonFormat::JsonKeyword, - function_filter_syntax: Some(FunctionFilterSyntax::InsideWhere), - ilike_expression_options: ILikeExpressionOptions::UseLikeLower, - supports_similar_to: false, - rlike_expression_options: Some(RLikeExpressionOptions::RegexpContains), - string_concat_syntax: StringConcatSyntax::Pipes, - bit_xor_syntax: Some(BitXorSyntax::Caret), - modulo_syntax: ModuloSyntax::Percent, - is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), - tuple_syntax: None, - parenthesize_in_between: false, - try_cast_syntax: Some(TryCastSyntax::SafeCast), - supports_double_colon: false, - trim_syntax: TrimSyntax::Function, - collate_syntax: Some(CollateSyntax::FunctionString), - like_escape_syntax: LikeEscapeSyntax::None, - bitwise_syntax: BitwiseSyntax::Infix, - } - } +/// BigQuery SQL dialect. +pub const BIGQUERY_DIALECT: base::PyDialect = base::PyDialect( + flags::DialectFlags::new() + .with_placeholder_character('?' as u32) + .with_placeholder(flags::Placeholder::Simple) + .with_null_treatment(flags::NullTreatment::InsideParens) + .with_like_escape(flags::LikeEscape::Unsupported) + .with_like_any(flags::LikeAny::OrExpansion) + .with_ilike_expression(flags::ILikeExpression::UseLikeLower) + .with_rlike_expression(flags::RLikeExpression::RegexpContains) + .with_string_concat(flags::StringConcat::Pipes) + .with_bitxor(flags::BitXor::Caret) + .with_modulo(flags::Modulo::Percent) + .with_is_distinct_from(flags::IsDistinctFrom::Standard) + .with_try_cast(flags::TryCast::SafeCast) + .with_trim(flags::Trim::Function) + .with_collate(flags::Collate::FunctionString) + .with_bitwise(flags::Bitwise::Infix) + .with_tuple(flags::Tuple::Unsupported) + .with_bit_format(flags::BitFormat::Unsupported) + .with_bool_format(flags::BoolFormat::Keyword) + .with_identifier_format(flags::IdentifierFormat::Backtick) + .with_identifier_escape(flags::IdentifierEscape::Backslash) + .with_string_format(flags::StringFormat::SingleQuote) + .with_string_escape(flags::StringEscape::BackslashHex) + .with_bytes_format(flags::BytesFormat::PrefixBigBSlashX) + .with_function_filter(flags::FunctionFilter::InsideWhere) + .with_array_format(flags::ArrayFormat::Constructor) + .with_date_format(flags::DateFormat::Keyword) + .with_time_format(flags::TimeFormat::Keyword) + .with_timestamp_format(flags::TimestampFormat::WithTimezone) + .with_inet_format(flags::InetFormat::Unsupported) + .with_macaddr_format(flags::MacAddrFormat::Unsupported) + .with_uuid_format(flags::UuidFormat::Quoted) + .with_json_format(flags::JsonFormat::JsonKeyword) + .with_supports_similar_to(false) + .with_parenthesize_in_between(false) + .with_supports_double_colon(false), +); - /// Databricks SQL dialect. - PyDatabricks = "Databricks" { - Dialect { - positional_parameter: PositionalParameter::Simple('?'), - bit_format: None, - array_format: None, - inet_format: None, - macaddr_format: None, - null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), - like_any_syntax: LikeAnySyntax::PatternList, - bool_format: BoolFormat::Keyword, - identifier_format: IdentifierFormat::Backtick, - identifier_escape: IdentifierEscape::Double, - string_format: StringFormat::SingleQuote, - string_escape: StringEscape::BackslashUnicodeQuote, - bytes_format: BytesFormat::PrefixBigX, - date_format: DateFormat::Keyword, - time_format: TimeFormat::Quoted, - timestamp_format: TimestampFormat::WithTimezone, - uuid_format: UuidFormat::Quoted, - json_format: JsonFormat::Quoted, - function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), - ilike_expression_options: ILikeExpressionOptions::Supported, - supports_similar_to: false, - rlike_expression_options: Some(RLikeExpressionOptions::InfixRLike), - string_concat_syntax: StringConcatSyntax::Pipes, - bit_xor_syntax: Some(BitXorSyntax::Caret), - modulo_syntax: ModuloSyntax::Percent, - is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), - tuple_syntax: Some(TupleSyntax::Struct), - parenthesize_in_between: false, - try_cast_syntax: Some(TryCastSyntax::TryCast), - supports_double_colon: true, - trim_syntax: TrimSyntax::SqlFrom, - collate_syntax: Some(CollateSyntax::InfixIdent), - like_escape_syntax: LikeEscapeSyntax::Scalar, - bitwise_syntax: BitwiseSyntax::Infix, - } - } +/// Databricks SQL dialect. +pub const DATABRICKS_DIALECT: base::PyDialect = base::PyDialect( + flags::DialectFlags::new() + .with_placeholder_character('?' as u32) + .with_placeholder(flags::Placeholder::Simple) + .with_null_treatment(flags::NullTreatment::Postfix) + .with_like_escape(flags::LikeEscape::Scalar) + .with_like_any(flags::LikeAny::PatternList) + .with_ilike_expression(flags::ILikeExpression::Standard) + .with_rlike_expression(flags::RLikeExpression::Standard) + .with_string_concat(flags::StringConcat::Pipes) + .with_bitxor(flags::BitXor::Caret) + .with_modulo(flags::Modulo::Percent) + .with_is_distinct_from(flags::IsDistinctFrom::Standard) + .with_try_cast(flags::TryCast::TryCast) + .with_trim(flags::Trim::SqlFrom) + .with_collate(flags::Collate::InfixIdent) + .with_bitwise(flags::Bitwise::Infix) + .with_tuple(flags::Tuple::Struct) + .with_bit_format(flags::BitFormat::Unsupported) + .with_bool_format(flags::BoolFormat::Keyword) + .with_identifier_format(flags::IdentifierFormat::Backtick) + .with_identifier_escape(flags::IdentifierEscape::Double) + .with_string_format(flags::StringFormat::SingleQuote) + .with_string_escape(flags::StringEscape::BackslashUnicodeQuote) + .with_bytes_format(flags::BytesFormat::PrefixBigX) + .with_function_filter(flags::FunctionFilter::FilterWhere) + .with_array_format(flags::ArrayFormat::Unsupported) + .with_date_format(flags::DateFormat::Keyword) + .with_time_format(flags::TimeFormat::Quoted) + .with_timestamp_format(flags::TimestampFormat::WithTimezone) + .with_inet_format(flags::InetFormat::Unsupported) + .with_macaddr_format(flags::MacAddrFormat::Unsupported) + .with_uuid_format(flags::UuidFormat::Quoted) + .with_json_format(flags::JsonFormat::Quoted) + .with_supports_similar_to(false) + .with_parenthesize_in_between(false) + .with_supports_double_colon(true), +); - /// DuckDB SQL dialect. - PyDuckDb = "DuckDb" { - Dialect { - positional_parameter: PositionalParameter::Numbered('$'), - bit_format: Some(BitFormat::PrefixBigB), - array_format: Some(ArrayFormat::List), - inet_format: Some(InetFormat::Cast), - macaddr_format: None, - null_treatment_syntax: Some(NullTreatmentSyntax::InsideParens), - like_any_syntax: LikeAnySyntax::OrExpansion, - bool_format: BoolFormat::Keyword, - identifier_format: IdentifierFormat::DoubleQuote, - identifier_escape: IdentifierEscape::Double, - string_format: StringFormat::SingleQuote, - string_escape: StringEscape::Quote, - bytes_format: BytesFormat::SlashXBlob, - date_format: DateFormat::Keyword, - time_format: TimeFormat::Keyword, - timestamp_format: TimestampFormat::Timestamptz, - uuid_format: UuidFormat::UuidKeyword, - json_format: JsonFormat::Quoted, - function_filter_syntax: Some(FunctionFilterSyntax::Filter), - ilike_expression_options: ILikeExpressionOptions::Supported, - supports_similar_to: true, - rlike_expression_options: Some(RLikeExpressionOptions::RegexpMatches), - string_concat_syntax: StringConcatSyntax::Pipes, - bit_xor_syntax: Some(BitXorSyntax::XORFunction), - modulo_syntax: ModuloSyntax::Percent, - is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), - tuple_syntax: Some(TupleSyntax::Row), - parenthesize_in_between: false, - try_cast_syntax: Some(TryCastSyntax::TryCast), - supports_double_colon: true, - trim_syntax: TrimSyntax::Function, - collate_syntax: Some(CollateSyntax::InfixIdent), - like_escape_syntax: LikeEscapeSyntax::Scalar, - bitwise_syntax: BitwiseSyntax::Infix, - } - } +/// DuckDB SQL dialect. +pub const DUCKDB_DIALECT: base::PyDialect = base::PyDialect( + flags::DialectFlags::new() + .with_placeholder_character('$' as u32) + .with_placeholder(flags::Placeholder::Numbered) + .with_null_treatment(flags::NullTreatment::InsideParens) + .with_like_escape(flags::LikeEscape::Scalar) + .with_like_any(flags::LikeAny::OrExpansion) + .with_ilike_expression(flags::ILikeExpression::Standard) + .with_rlike_expression(flags::RLikeExpression::RegexpMatches) + .with_string_concat(flags::StringConcat::Pipes) + .with_bitxor(flags::BitXor::XORFunction) + .with_modulo(flags::Modulo::Percent) + .with_is_distinct_from(flags::IsDistinctFrom::Standard) + .with_try_cast(flags::TryCast::TryCast) + .with_trim(flags::Trim::Function) + .with_collate(flags::Collate::InfixIdent) + .with_bitwise(flags::Bitwise::Infix) + .with_tuple(flags::Tuple::Row) + .with_bit_format(flags::BitFormat::PrefixBigB) + .with_bool_format(flags::BoolFormat::Keyword) + .with_identifier_format(flags::IdentifierFormat::DoubleQuote) + .with_identifier_escape(flags::IdentifierEscape::Double) + .with_string_format(flags::StringFormat::SingleQuote) + .with_string_escape(flags::StringEscape::Quote) + .with_bytes_format(flags::BytesFormat::SlashXBlob) + .with_function_filter(flags::FunctionFilter::Filter) + .with_array_format(flags::ArrayFormat::List) + .with_date_format(flags::DateFormat::Keyword) + .with_time_format(flags::TimeFormat::Keyword) + .with_timestamp_format(flags::TimestampFormat::Timestamptz) + .with_inet_format(flags::InetFormat::Cast) + .with_macaddr_format(flags::MacAddrFormat::Unsupported) + .with_uuid_format(flags::UuidFormat::UuidKeyword) + .with_json_format(flags::JsonFormat::Quoted) + .with_supports_similar_to(true) + .with_parenthesize_in_between(false) + .with_supports_double_colon(true), +); - /// Hive SQL dialect. - PyHive = "Hive" { - Dialect { - positional_parameter: PositionalParameter::Simple('?'), - bit_format: None, - array_format: Some(ArrayFormat::Function), - inet_format: None, - macaddr_format: None, - null_treatment_syntax: None, - like_any_syntax: LikeAnySyntax::OrExpansion, - bool_format: BoolFormat::Keyword, - identifier_format: IdentifierFormat::Backtick, - identifier_escape: IdentifierEscape::Double, - string_format: StringFormat::SingleQuote, - string_escape: StringEscape::BackslashUnicode, - bytes_format: BytesFormat::Unhex, - date_format: DateFormat::Keyword, - time_format: TimeFormat::Quoted, - timestamp_format: TimestampFormat::WithTimezone, - uuid_format: UuidFormat::Quoted, - json_format: JsonFormat::Quoted, - function_filter_syntax: None, - ilike_expression_options: ILikeExpressionOptions::UseLikeLower, - supports_similar_to: false, - rlike_expression_options: Some(RLikeExpressionOptions::InfixRLike), - string_concat_syntax: StringConcatSyntax::Pipes, - bit_xor_syntax: Some(BitXorSyntax::Caret), - modulo_syntax: ModuloSyntax::Percent, - is_distinct_from_syntax: Some(IsDistinctFromSyntax::NullSafeEqual), - tuple_syntax: Some(TupleSyntax::Struct), - parenthesize_in_between: false, - try_cast_syntax: None, - supports_double_colon: false, - trim_syntax: TrimSyntax::SpacesOnly, - collate_syntax: None, - like_escape_syntax: LikeEscapeSyntax::None, - bitwise_syntax: BitwiseSyntax::Infix, - } - } +/// Hive SQL dialect. +pub const HIVE_DIALECT: base::PyDialect = base::PyDialect( + flags::DialectFlags::new() + .with_placeholder_character('?' as u32) + .with_placeholder(flags::Placeholder::Simple) + .with_null_treatment(flags::NullTreatment::Unsupported) + .with_like_escape(flags::LikeEscape::Unsupported) + .with_like_any(flags::LikeAny::OrExpansion) + .with_ilike_expression(flags::ILikeExpression::UseLikeLower) + .with_rlike_expression(flags::RLikeExpression::Standard) + .with_string_concat(flags::StringConcat::Pipes) + .with_bitxor(flags::BitXor::Caret) + .with_modulo(flags::Modulo::Percent) + .with_is_distinct_from(flags::IsDistinctFrom::NullSafeEqual) + .with_try_cast(flags::TryCast::Unsupported) + .with_trim(flags::Trim::SpacesOnly) + .with_collate(flags::Collate::Unsupported) + .with_bitwise(flags::Bitwise::Infix) + .with_tuple(flags::Tuple::Struct) + .with_bit_format(flags::BitFormat::Unsupported) + .with_bool_format(flags::BoolFormat::Keyword) + .with_identifier_format(flags::IdentifierFormat::Backtick) + .with_identifier_escape(flags::IdentifierEscape::Double) + .with_string_format(flags::StringFormat::SingleQuote) + .with_string_escape(flags::StringEscape::BackslashUnicode) + .with_bytes_format(flags::BytesFormat::Unhex) + .with_function_filter(flags::FunctionFilter::Unsupported) + .with_array_format(flags::ArrayFormat::Function) + .with_date_format(flags::DateFormat::Keyword) + .with_time_format(flags::TimeFormat::Quoted) + .with_timestamp_format(flags::TimestampFormat::WithTimezone) + .with_inet_format(flags::InetFormat::Unsupported) + .with_macaddr_format(flags::MacAddrFormat::Unsupported) + .with_uuid_format(flags::UuidFormat::Quoted) + .with_json_format(flags::JsonFormat::Quoted) + .with_supports_similar_to(false) + .with_parenthesize_in_between(false) + .with_supports_double_colon(false), +); - /// Microsoft SQL Server dialect. - PyMsSql = "MsSql" { - Dialect { - positional_parameter: PositionalParameter::Simple('?'), - bit_format: None, - array_format: None, - inet_format: None, - macaddr_format: None, - null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), - like_any_syntax: LikeAnySyntax::OrExpansion, - bool_format: BoolFormat::Int, - identifier_format: IdentifierFormat::SquareBracket, - identifier_escape: IdentifierEscape::Double, - string_format: StringFormat::PrefixBigN, - string_escape: StringEscape::Quote, - bytes_format: BytesFormat::PrefixZeroX, - date_format: DateFormat::Quoted, - time_format: TimeFormat::Quoted, - timestamp_format: TimestampFormat::Quoted, - uuid_format: UuidFormat::Quoted, - json_format: JsonFormat::Quoted, - function_filter_syntax: None, - ilike_expression_options: ILikeExpressionOptions::UseLike, - supports_similar_to: false, - rlike_expression_options: None, - string_concat_syntax: StringConcatSyntax::Plus, - bit_xor_syntax: Some(BitXorSyntax::Caret), - modulo_syntax: ModuloSyntax::Percent, - is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), - tuple_syntax: None, - parenthesize_in_between: true, - try_cast_syntax: Some(TryCastSyntax::TryCast), - supports_double_colon: false, - trim_syntax: TrimSyntax::SqlFrom, - collate_syntax: Some(CollateSyntax::InfixIdent), - like_escape_syntax: LikeEscapeSyntax::Scalar, - bitwise_syntax: BitwiseSyntax::InfixNoShift, - } - } +/// Microsoft SQL Server dialect. +pub const MSSQL_DIALECT: base::PyDialect = base::PyDialect( + flags::DialectFlags::new() + .with_placeholder_character('?' as u32) + .with_placeholder(flags::Placeholder::Simple) + .with_null_treatment(flags::NullTreatment::Postfix) + .with_like_escape(flags::LikeEscape::Scalar) + .with_like_any(flags::LikeAny::OrExpansion) + .with_ilike_expression(flags::ILikeExpression::UseLike) + .with_rlike_expression(flags::RLikeExpression::Unsupported) + .with_string_concat(flags::StringConcat::Plus) + .with_bitxor(flags::BitXor::Caret) + .with_modulo(flags::Modulo::Percent) + .with_is_distinct_from(flags::IsDistinctFrom::Standard) + .with_try_cast(flags::TryCast::TryCast) + .with_trim(flags::Trim::SqlFrom) + .with_collate(flags::Collate::InfixIdent) + .with_bitwise(flags::Bitwise::InfixNoShift) + .with_tuple(flags::Tuple::Unsupported) + .with_bit_format(flags::BitFormat::Unsupported) + .with_bool_format(flags::BoolFormat::Integer) + .with_identifier_format(flags::IdentifierFormat::SquareBracket) + .with_identifier_escape(flags::IdentifierEscape::Double) + .with_string_format(flags::StringFormat::PrefixBigN) + .with_string_escape(flags::StringEscape::Quote) + .with_bytes_format(flags::BytesFormat::PrefixZeroX) + .with_function_filter(flags::FunctionFilter::Unsupported) + .with_array_format(flags::ArrayFormat::Unsupported) + .with_date_format(flags::DateFormat::Quoted) + .with_time_format(flags::TimeFormat::Quoted) + .with_timestamp_format(flags::TimestampFormat::Quoted) + .with_inet_format(flags::InetFormat::Unsupported) + .with_macaddr_format(flags::MacAddrFormat::Unsupported) + .with_uuid_format(flags::UuidFormat::Quoted) + .with_json_format(flags::JsonFormat::Quoted) + .with_supports_similar_to(false) + .with_parenthesize_in_between(true) + .with_supports_double_colon(false), +); - /// MySQL dialect. - PyMySql = "MySql" { - Dialect { - positional_parameter: PositionalParameter::Simple('?'), - bit_format: Some(BitFormat::PrefixSmallB), - array_format: None, - inet_format: None, - macaddr_format: None, - null_treatment_syntax: None, - like_any_syntax: LikeAnySyntax::OrExpansion, - bool_format: BoolFormat::Int, - identifier_format: IdentifierFormat::Backtick, - identifier_escape: IdentifierEscape::Double, - string_format: StringFormat::SingleQuote, - string_escape: StringEscape::BackslashMysql, - bytes_format: BytesFormat::PrefixBigX, - date_format: DateFormat::Quoted, - time_format: TimeFormat::Quoted, - timestamp_format: TimestampFormat::Quoted, - uuid_format: UuidFormat::Quoted, - json_format: JsonFormat::Quoted, - function_filter_syntax: None, - ilike_expression_options: ILikeExpressionOptions::UseLike, - supports_similar_to: false, - rlike_expression_options: Some(RLikeExpressionOptions::InfixRLike), - string_concat_syntax: StringConcatSyntax::ConcatFunction, - bit_xor_syntax: Some(BitXorSyntax::Caret), - modulo_syntax: ModuloSyntax::Percent, - is_distinct_from_syntax: Some(IsDistinctFromSyntax::NullSafeEqual), - tuple_syntax: Some(TupleSyntax::RowRejectEmpty), - parenthesize_in_between: false, - try_cast_syntax: None, - supports_double_colon: false, - trim_syntax: TrimSyntax::SqlFrom, - collate_syntax: Some(CollateSyntax::InfixIdent), - like_escape_syntax: LikeEscapeSyntax::Scalar, - bitwise_syntax: BitwiseSyntax::Infix, - } - } +/// MySQL dialect. +pub const MYSQL_DIALECT: base::PyDialect = base::PyDialect( + flags::DialectFlags::new() + .with_placeholder_character('?' as u32) + .with_placeholder(flags::Placeholder::Simple) + .with_null_treatment(flags::NullTreatment::Unsupported) + .with_like_escape(flags::LikeEscape::Scalar) + .with_like_any(flags::LikeAny::OrExpansion) + .with_ilike_expression(flags::ILikeExpression::UseLike) + .with_rlike_expression(flags::RLikeExpression::Standard) + .with_string_concat(flags::StringConcat::ConcatFunction) + .with_bitxor(flags::BitXor::Caret) + .with_modulo(flags::Modulo::Percent) + .with_is_distinct_from(flags::IsDistinctFrom::NullSafeEqual) + .with_try_cast(flags::TryCast::Unsupported) + .with_trim(flags::Trim::SqlFrom) + .with_collate(flags::Collate::InfixIdent) + .with_bitwise(flags::Bitwise::Infix) + .with_tuple(flags::Tuple::RowRejectEmpty) + .with_bit_format(flags::BitFormat::PrefixSmallB) + .with_bool_format(flags::BoolFormat::Integer) + .with_identifier_format(flags::IdentifierFormat::Backtick) + .with_identifier_escape(flags::IdentifierEscape::Double) + .with_string_format(flags::StringFormat::SingleQuote) + .with_string_escape(flags::StringEscape::BackslashMysql) + .with_bytes_format(flags::BytesFormat::PrefixBigX) + .with_function_filter(flags::FunctionFilter::Unsupported) + .with_array_format(flags::ArrayFormat::Unsupported) + .with_date_format(flags::DateFormat::Quoted) + .with_time_format(flags::TimeFormat::Quoted) + .with_timestamp_format(flags::TimestampFormat::Quoted) + .with_inet_format(flags::InetFormat::Unsupported) + .with_macaddr_format(flags::MacAddrFormat::Unsupported) + .with_uuid_format(flags::UuidFormat::Quoted) + .with_json_format(flags::JsonFormat::Quoted) + .with_supports_similar_to(false) + .with_parenthesize_in_between(false) + .with_supports_double_colon(false), +); - /// Oracle SQL dialect. - PyOracle = "Oracle" { - Dialect { - positional_parameter: PositionalParameter::Numbered(':'), - bit_format: None, - array_format: None, - inet_format: None, - macaddr_format: None, - null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), - like_any_syntax: LikeAnySyntax::OrExpansion, - bool_format: BoolFormat::Int, - identifier_format: IdentifierFormat::DoubleQuote, - identifier_escape: IdentifierEscape::Double, - string_format: StringFormat::SingleQuote, - string_escape: StringEscape::Quote, - bytes_format: BytesFormat::HexToRaw, - date_format: DateFormat::Keyword, - time_format: TimeFormat::Quoted, - timestamp_format: TimestampFormat::WithTimezone, - uuid_format: UuidFormat::Quoted, - json_format: JsonFormat::Quoted, - function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), - ilike_expression_options: ILikeExpressionOptions::UseLikeLower, - supports_similar_to: false, - rlike_expression_options: Some(RLikeExpressionOptions::RegexpLike), - string_concat_syntax: StringConcatSyntax::Pipes, - bit_xor_syntax: None, - modulo_syntax: ModuloSyntax::Function, - is_distinct_from_syntax: None, - tuple_syntax: None, - parenthesize_in_between: false, - try_cast_syntax: None, - supports_double_colon: false, - trim_syntax: TrimSyntax::SqlFrom, - collate_syntax: Some(CollateSyntax::InfixIdent), - like_escape_syntax: LikeEscapeSyntax::Scalar, - bitwise_syntax: BitwiseSyntax::BitAndOnly, - } - } +/// Oracle SQL dialect. +pub const ORACLE_DIALECT: base::PyDialect = base::PyDialect( + flags::DialectFlags::new() + .with_placeholder_character(':' as u32) + .with_placeholder(flags::Placeholder::Numbered) + .with_null_treatment(flags::NullTreatment::Postfix) + .with_like_escape(flags::LikeEscape::Scalar) + .with_like_any(flags::LikeAny::OrExpansion) + .with_ilike_expression(flags::ILikeExpression::UseLikeLower) + .with_rlike_expression(flags::RLikeExpression::RegexpLike) + .with_string_concat(flags::StringConcat::Pipes) + .with_bitxor(flags::BitXor::Unsupported) + .with_modulo(flags::Modulo::Function) + .with_is_distinct_from(flags::IsDistinctFrom::Unsupported) + .with_try_cast(flags::TryCast::Unsupported) + .with_trim(flags::Trim::SqlFrom) + .with_collate(flags::Collate::InfixIdent) + .with_bitwise(flags::Bitwise::BitAndOnly) + .with_tuple(flags::Tuple::Unsupported) + .with_bit_format(flags::BitFormat::Unsupported) + .with_bool_format(flags::BoolFormat::Integer) + .with_identifier_format(flags::IdentifierFormat::DoubleQuote) + .with_identifier_escape(flags::IdentifierEscape::Double) + .with_string_format(flags::StringFormat::SingleQuote) + .with_string_escape(flags::StringEscape::Quote) + .with_bytes_format(flags::BytesFormat::HexToRaw) + .with_function_filter(flags::FunctionFilter::FilterWhere) + .with_array_format(flags::ArrayFormat::Unsupported) + .with_date_format(flags::DateFormat::Keyword) + .with_time_format(flags::TimeFormat::Quoted) + .with_timestamp_format(flags::TimestampFormat::WithTimezone) + .with_inet_format(flags::InetFormat::Unsupported) + .with_macaddr_format(flags::MacAddrFormat::Unsupported) + .with_uuid_format(flags::UuidFormat::Quoted) + .with_json_format(flags::JsonFormat::Quoted) + .with_supports_similar_to(false) + .with_parenthesize_in_between(false) + .with_supports_double_colon(false), +); - /// PostgreSQL dialect. - PyPostgreSql = "PostgreSql" { - Dialect { - positional_parameter: PositionalParameter::Numbered('$'), - bit_format: Some(BitFormat::PrefixBigB), - array_format: Some(ArrayFormat::Constructor), - inet_format: Some(InetFormat::Cast), - macaddr_format: Some(MacAddrFormat::Cast), - null_treatment_syntax: None, - like_any_syntax: LikeAnySyntax::ArrayConstructor, - bool_format: BoolFormat::Keyword, - identifier_format: IdentifierFormat::DoubleQuote, - identifier_escape: IdentifierEscape::Double, - string_format: StringFormat::PrefixBigE, - string_escape: StringEscape::BackslashHex, - bytes_format: BytesFormat::SlashXBytea, - date_format: DateFormat::Keyword, - time_format: TimeFormat::Keyword, - timestamp_format: TimestampFormat::Timestamptz, - uuid_format: UuidFormat::CastUuid, - json_format: JsonFormat::Quoted, - function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), - ilike_expression_options: ILikeExpressionOptions::Supported, - supports_similar_to: true, - rlike_expression_options: Some(RLikeExpressionOptions::Tilde), - string_concat_syntax: StringConcatSyntax::Pipes, - bit_xor_syntax: Some(BitXorSyntax::Hash), - modulo_syntax: ModuloSyntax::Percent, - is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), - tuple_syntax: Some(TupleSyntax::Row), - parenthesize_in_between: false, - try_cast_syntax: None, - supports_double_colon: true, - trim_syntax: TrimSyntax::SqlFrom, - collate_syntax: Some(CollateSyntax::InfixIdent), - like_escape_syntax: LikeEscapeSyntax::Scalar, - bitwise_syntax: BitwiseSyntax::Infix, - } - } +/// PostgreSQL dialect. +pub const POSTGRESQL_DIALECT: base::PyDialect = base::PyDialect( + flags::DialectFlags::new() + .with_placeholder_character('$' as u32) + .with_placeholder(flags::Placeholder::Numbered) + .with_null_treatment(flags::NullTreatment::Unsupported) + .with_like_escape(flags::LikeEscape::Scalar) + .with_like_any(flags::LikeAny::ArrayConstructor) + .with_ilike_expression(flags::ILikeExpression::Standard) + .with_rlike_expression(flags::RLikeExpression::Tilde) + .with_string_concat(flags::StringConcat::Pipes) + .with_bitxor(flags::BitXor::Hash) + .with_modulo(flags::Modulo::Percent) + .with_is_distinct_from(flags::IsDistinctFrom::Standard) + .with_try_cast(flags::TryCast::Unsupported) + .with_trim(flags::Trim::SqlFrom) + .with_collate(flags::Collate::InfixIdent) + .with_bitwise(flags::Bitwise::Infix) + .with_tuple(flags::Tuple::Row) + .with_bit_format(flags::BitFormat::PrefixBigB) + .with_bool_format(flags::BoolFormat::Keyword) + .with_identifier_format(flags::IdentifierFormat::DoubleQuote) + .with_identifier_escape(flags::IdentifierEscape::Double) + .with_string_format(flags::StringFormat::PrefixBigE) + .with_string_escape(flags::StringEscape::BackslashHex) + .with_bytes_format(flags::BytesFormat::SlashXBytea) + .with_function_filter(flags::FunctionFilter::FilterWhere) + .with_array_format(flags::ArrayFormat::Constructor) + .with_date_format(flags::DateFormat::Keyword) + .with_time_format(flags::TimeFormat::Keyword) + .with_timestamp_format(flags::TimestampFormat::Timestamptz) + .with_inet_format(flags::InetFormat::Cast) + .with_macaddr_format(flags::MacAddrFormat::Cast) + .with_uuid_format(flags::UuidFormat::CastUuid) + .with_json_format(flags::JsonFormat::Quoted) + .with_supports_similar_to(true) + .with_parenthesize_in_between(false) + .with_supports_double_colon(true), +); - /// Amazon Redshift dialect. - PyRedshift = "Redshift" { - Dialect { - positional_parameter: PositionalParameter::Numbered('$'), - bit_format: None, - array_format: None, - inet_format: None, - macaddr_format: None, - null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), - like_any_syntax: LikeAnySyntax::OrExpansion, - bool_format: BoolFormat::Keyword, - identifier_format: IdentifierFormat::DoubleQuote, - identifier_escape: IdentifierEscape::Double, - string_format: StringFormat::SingleQuote, - string_escape: StringEscape::Quote, - bytes_format: BytesFormat::ToVarbyte, - date_format: DateFormat::Quoted, - time_format: TimeFormat::Quoted, - timestamp_format: TimestampFormat::Quoted, - uuid_format: UuidFormat::Quoted, - json_format: JsonFormat::Quoted, - function_filter_syntax: None, - ilike_expression_options: ILikeExpressionOptions::Supported, - supports_similar_to: true, - rlike_expression_options: Some(RLikeExpressionOptions::Tilde), - string_concat_syntax: StringConcatSyntax::Pipes, - bit_xor_syntax: None, - modulo_syntax: ModuloSyntax::Percent, - is_distinct_from_syntax: None, - tuple_syntax: None, - parenthesize_in_between: false, - try_cast_syntax: Some(TryCastSyntax::TryCast), - supports_double_colon: true, - trim_syntax: TrimSyntax::SqlFrom, - collate_syntax: Some(CollateSyntax::FunctionString), - like_escape_syntax: LikeEscapeSyntax::Scalar, - bitwise_syntax: BitwiseSyntax::InfixAndShift, - } - } +/// Amazon Redshift dialect. +pub const REDSHIFT_DIALECT: base::PyDialect = base::PyDialect( + flags::DialectFlags::new() + .with_placeholder_character('$' as u32) + .with_placeholder(flags::Placeholder::Numbered) + .with_null_treatment(flags::NullTreatment::Postfix) + .with_like_escape(flags::LikeEscape::Scalar) + .with_like_any(flags::LikeAny::OrExpansion) + .with_ilike_expression(flags::ILikeExpression::Standard) + .with_rlike_expression(flags::RLikeExpression::Tilde) + .with_string_concat(flags::StringConcat::Pipes) + .with_bitxor(flags::BitXor::Unsupported) + .with_modulo(flags::Modulo::Percent) + .with_is_distinct_from(flags::IsDistinctFrom::Unsupported) + .with_try_cast(flags::TryCast::TryCast) + .with_trim(flags::Trim::SqlFrom) + .with_collate(flags::Collate::FunctionString) + .with_bitwise(flags::Bitwise::InfixAndShift) + .with_tuple(flags::Tuple::Unsupported) + .with_bit_format(flags::BitFormat::Unsupported) + .with_bool_format(flags::BoolFormat::Keyword) + .with_identifier_format(flags::IdentifierFormat::DoubleQuote) + .with_identifier_escape(flags::IdentifierEscape::Double) + .with_string_format(flags::StringFormat::SingleQuote) + .with_string_escape(flags::StringEscape::Quote) + .with_bytes_format(flags::BytesFormat::ToVarbyte) + .with_function_filter(flags::FunctionFilter::Unsupported) + .with_array_format(flags::ArrayFormat::Unsupported) + .with_date_format(flags::DateFormat::Quoted) + .with_time_format(flags::TimeFormat::Quoted) + .with_timestamp_format(flags::TimestampFormat::Quoted) + .with_inet_format(flags::InetFormat::Unsupported) + .with_macaddr_format(flags::MacAddrFormat::Unsupported) + .with_uuid_format(flags::UuidFormat::Quoted) + .with_json_format(flags::JsonFormat::Quoted) + .with_supports_similar_to(true) + .with_parenthesize_in_between(false) + .with_supports_double_colon(true), +); - /// Snowflake SQL dialect. - PySnowflake = "Snowflake" { - Dialect { - positional_parameter: PositionalParameter::Numbered(':'), - bit_format: None, - array_format: None, - inet_format: None, - macaddr_format: None, - null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), - like_any_syntax: LikeAnySyntax::PatternList, - bool_format: BoolFormat::Keyword, - identifier_format: IdentifierFormat::DoubleQuote, - identifier_escape: IdentifierEscape::Double, - string_format: StringFormat::SingleQuote, - string_escape: StringEscape::BackslashHexZero, - bytes_format: BytesFormat::ToBinary, - date_format: DateFormat::Keyword, - time_format: TimeFormat::Quoted, - timestamp_format: TimestampFormat::Timestamptz, - uuid_format: UuidFormat::Quoted, - json_format: JsonFormat::ParseJson, - function_filter_syntax: None, - ilike_expression_options: ILikeExpressionOptions::Supported, - supports_similar_to: false, - rlike_expression_options: Some(RLikeExpressionOptions::InfixRLike), - string_concat_syntax: StringConcatSyntax::Pipes, - bit_xor_syntax: Some(BitXorSyntax::BitXORFunction), - modulo_syntax: ModuloSyntax::Percent, - is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), - tuple_syntax: None, - parenthesize_in_between: false, - try_cast_syntax: Some(TryCastSyntax::TryCast), - supports_double_colon: true, - trim_syntax: TrimSyntax::Function, - collate_syntax: Some(CollateSyntax::FunctionString), - like_escape_syntax: LikeEscapeSyntax::IncludingAny, - bitwise_syntax: BitwiseSyntax::BitShiftFunctions, - } - } +/// Snowflake SQL dialect. +pub const SNOWFLAKE_DIALECT: base::PyDialect = base::PyDialect( + flags::DialectFlags::new() + .with_placeholder_character(':' as u32) + .with_placeholder(flags::Placeholder::Numbered) + .with_null_treatment(flags::NullTreatment::Postfix) + .with_like_escape(flags::LikeEscape::IncludingAny) + .with_like_any(flags::LikeAny::PatternList) + .with_ilike_expression(flags::ILikeExpression::Standard) + .with_rlike_expression(flags::RLikeExpression::Standard) + .with_string_concat(flags::StringConcat::Pipes) + .with_bitxor(flags::BitXor::BitXORFunction) + .with_modulo(flags::Modulo::Percent) + .with_is_distinct_from(flags::IsDistinctFrom::Standard) + .with_try_cast(flags::TryCast::TryCast) + .with_trim(flags::Trim::Function) + .with_collate(flags::Collate::FunctionString) + .with_bitwise(flags::Bitwise::BitShiftFunctions) + .with_tuple(flags::Tuple::Unsupported) + .with_bit_format(flags::BitFormat::Unsupported) + .with_bool_format(flags::BoolFormat::Keyword) + .with_identifier_format(flags::IdentifierFormat::DoubleQuote) + .with_identifier_escape(flags::IdentifierEscape::Double) + .with_string_format(flags::StringFormat::SingleQuote) + .with_string_escape(flags::StringEscape::BackslashHexZero) + .with_bytes_format(flags::BytesFormat::ToBinary) + .with_function_filter(flags::FunctionFilter::Unsupported) + .with_array_format(flags::ArrayFormat::Unsupported) + .with_date_format(flags::DateFormat::Keyword) + .with_time_format(flags::TimeFormat::Quoted) + .with_timestamp_format(flags::TimestampFormat::Timestamptz) + .with_inet_format(flags::InetFormat::Unsupported) + .with_macaddr_format(flags::MacAddrFormat::Unsupported) + .with_uuid_format(flags::UuidFormat::Quoted) + .with_json_format(flags::JsonFormat::ParseJson) + .with_supports_similar_to(false) + .with_parenthesize_in_between(false) + .with_supports_double_colon(true), +); - /// Apache Spark SQL dialect. - PySpark = "Spark" { - Dialect { - positional_parameter: PositionalParameter::Simple('?'), - bit_format: None, - array_format: Some(ArrayFormat::Function), - inet_format: None, - macaddr_format: None, - null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), - like_any_syntax: LikeAnySyntax::PatternList, - bool_format: BoolFormat::Keyword, - identifier_format: IdentifierFormat::Backtick, - identifier_escape: IdentifierEscape::Double, - string_format: StringFormat::SingleQuote, - string_escape: StringEscape::BackslashUnicodeQuote, - bytes_format: BytesFormat::PrefixBigX, - date_format: DateFormat::Keyword, - time_format: TimeFormat::Quoted, - timestamp_format: TimestampFormat::WithTimezone, - uuid_format: UuidFormat::Quoted, - json_format: JsonFormat::Quoted, - function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), - ilike_expression_options: ILikeExpressionOptions::Supported, - supports_similar_to: false, - rlike_expression_options: Some(RLikeExpressionOptions::InfixRLike), - string_concat_syntax: StringConcatSyntax::Pipes, - bit_xor_syntax: Some(BitXorSyntax::Caret), - modulo_syntax: ModuloSyntax::Percent, - is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), - tuple_syntax: Some(TupleSyntax::Struct), - parenthesize_in_between: false, - try_cast_syntax: Some(TryCastSyntax::TryCast), - supports_double_colon: true, - trim_syntax: TrimSyntax::SqlFrom, - collate_syntax: None, - like_escape_syntax: LikeEscapeSyntax::Scalar, - bitwise_syntax: BitwiseSyntax::Infix, - } - } +/// Apache Spark SQL dialect. +pub const SPARK_DIALECT: base::PyDialect = base::PyDialect( + flags::DialectFlags::new() + .with_placeholder_character('?' as u32) + .with_placeholder(flags::Placeholder::Simple) + .with_null_treatment(flags::NullTreatment::Postfix) + .with_like_escape(flags::LikeEscape::Scalar) + .with_like_any(flags::LikeAny::PatternList) + .with_ilike_expression(flags::ILikeExpression::Standard) + .with_rlike_expression(flags::RLikeExpression::Standard) + .with_string_concat(flags::StringConcat::Pipes) + .with_bitxor(flags::BitXor::Caret) + .with_modulo(flags::Modulo::Percent) + .with_is_distinct_from(flags::IsDistinctFrom::Standard) + .with_try_cast(flags::TryCast::TryCast) + .with_trim(flags::Trim::SqlFrom) + .with_collate(flags::Collate::Unsupported) + .with_bitwise(flags::Bitwise::Infix) + .with_tuple(flags::Tuple::Struct) + .with_bit_format(flags::BitFormat::Unsupported) + .with_bool_format(flags::BoolFormat::Keyword) + .with_identifier_format(flags::IdentifierFormat::Backtick) + .with_identifier_escape(flags::IdentifierEscape::Double) + .with_string_format(flags::StringFormat::SingleQuote) + .with_string_escape(flags::StringEscape::BackslashUnicodeQuote) + .with_bytes_format(flags::BytesFormat::PrefixBigX) + .with_function_filter(flags::FunctionFilter::FilterWhere) + .with_array_format(flags::ArrayFormat::Function) + .with_date_format(flags::DateFormat::Keyword) + .with_time_format(flags::TimeFormat::Quoted) + .with_timestamp_format(flags::TimestampFormat::WithTimezone) + .with_inet_format(flags::InetFormat::Unsupported) + .with_macaddr_format(flags::MacAddrFormat::Unsupported) + .with_uuid_format(flags::UuidFormat::Quoted) + .with_json_format(flags::JsonFormat::Quoted) + .with_supports_similar_to(false) + .with_parenthesize_in_between(false) + .with_supports_double_colon(true), +); - /// SQLite dialect. - PySqlite = "Sqlite" { - Dialect { - positional_parameter: PositionalParameter::Numbered('?'), - bit_format: None, - array_format: None, - inet_format: None, - macaddr_format: None, - null_treatment_syntax: None, - like_any_syntax: LikeAnySyntax::OrExpansion, - bool_format: BoolFormat::Int, - identifier_format: IdentifierFormat::DoubleQuote, - identifier_escape: IdentifierEscape::Double, - string_format: StringFormat::SingleQuote, - string_escape: StringEscape::Quote, - bytes_format: BytesFormat::PrefixBigX, - date_format: DateFormat::Quoted, - time_format: TimeFormat::Quoted, - timestamp_format: TimestampFormat::Quoted, - uuid_format: UuidFormat::Quoted, - json_format: JsonFormat::Quoted, - function_filter_syntax: Some(FunctionFilterSyntax::FilterWhere), - ilike_expression_options: ILikeExpressionOptions::UseLike, - supports_similar_to: false, - rlike_expression_options: None, - string_concat_syntax: StringConcatSyntax::Pipes, - bit_xor_syntax: None, - modulo_syntax: ModuloSyntax::Percent, - is_distinct_from_syntax: Some(IsDistinctFromSyntax::Standard), - tuple_syntax: Some(TupleSyntax::Parentheses), - parenthesize_in_between: false, - try_cast_syntax: None, - supports_double_colon: false, - trim_syntax: TrimSyntax::Function, - collate_syntax: Some(CollateSyntax::InfixIdent), - like_escape_syntax: LikeEscapeSyntax::Scalar, - bitwise_syntax: BitwiseSyntax::Infix, - } - } +/// SQLite dialect. +pub const SQLITE_DIALECT: base::PyDialect = base::PyDialect( + flags::DialectFlags::new() + .with_placeholder_character('?' as u32) + .with_placeholder(flags::Placeholder::Numbered) + .with_null_treatment(flags::NullTreatment::Unsupported) + .with_like_escape(flags::LikeEscape::Scalar) + .with_like_any(flags::LikeAny::OrExpansion) + .with_ilike_expression(flags::ILikeExpression::UseLike) + .with_rlike_expression(flags::RLikeExpression::Unsupported) + .with_string_concat(flags::StringConcat::Pipes) + .with_bitxor(flags::BitXor::Unsupported) + .with_modulo(flags::Modulo::Percent) + .with_is_distinct_from(flags::IsDistinctFrom::Standard) + .with_try_cast(flags::TryCast::Unsupported) + .with_trim(flags::Trim::Function) + .with_collate(flags::Collate::InfixIdent) + .with_bitwise(flags::Bitwise::Infix) + .with_tuple(flags::Tuple::Parentheses) + .with_bit_format(flags::BitFormat::Unsupported) + .with_bool_format(flags::BoolFormat::Integer) + .with_identifier_format(flags::IdentifierFormat::DoubleQuote) + .with_identifier_escape(flags::IdentifierEscape::Double) + .with_string_format(flags::StringFormat::SingleQuote) + .with_string_escape(flags::StringEscape::Quote) + .with_bytes_format(flags::BytesFormat::PrefixBigX) + .with_function_filter(flags::FunctionFilter::FilterWhere) + .with_array_format(flags::ArrayFormat::Unsupported) + .with_date_format(flags::DateFormat::Quoted) + .with_time_format(flags::TimeFormat::Quoted) + .with_timestamp_format(flags::TimestampFormat::Quoted) + .with_inet_format(flags::InetFormat::Unsupported) + .with_macaddr_format(flags::MacAddrFormat::Unsupported) + .with_uuid_format(flags::UuidFormat::Quoted) + .with_json_format(flags::JsonFormat::Quoted) + .with_supports_similar_to(false) + .with_parenthesize_in_between(false) + .with_supports_double_colon(false), +); - /// Teradata SQL dialect. - PyTeradata = "Teradata" { - Dialect { - positional_parameter: PositionalParameter::Simple('?'), - bit_format: Some(BitFormat::PostfixBigB), - array_format: None, - inet_format: None, - macaddr_format: None, - null_treatment_syntax: Some(NullTreatmentSyntax::InsideParens), - like_any_syntax: LikeAnySyntax::OrExpansion, - bool_format: BoolFormat::Keyword, - identifier_format: IdentifierFormat::DoubleQuote, - identifier_escape: IdentifierEscape::Double, - string_format: StringFormat::SingleQuote, - string_escape: StringEscape::Quote, - bytes_format: BytesFormat::PostfixXbv, - date_format: DateFormat::Keyword, - time_format: TimeFormat::Quoted, - timestamp_format: TimestampFormat::WithTimezone, - uuid_format: UuidFormat::Quoted, - json_format: JsonFormat::Quoted, - function_filter_syntax: None, - ilike_expression_options: ILikeExpressionOptions::UseLikeLower, - supports_similar_to: false, - rlike_expression_options: None, - string_concat_syntax: StringConcatSyntax::Pipes, - bit_xor_syntax: Some(BitXorSyntax::BitXORFunction), - modulo_syntax: ModuloSyntax::Infix, - is_distinct_from_syntax: None, - tuple_syntax: None, - parenthesize_in_between: false, - try_cast_syntax: Some(TryCastSyntax::TryCastWord), - supports_double_colon: false, - trim_syntax: TrimSyntax::SqlFrom, - collate_syntax: None, - like_escape_syntax: LikeEscapeSyntax::Scalar, - bitwise_syntax: BitwiseSyntax::ShiftFunctions, - } - } +/// Teradata SQL dialect. +pub const TERADATA_DIALECT: base::PyDialect = base::PyDialect( + flags::DialectFlags::new() + .with_placeholder_character('?' as u32) + .with_placeholder(flags::Placeholder::Simple) + .with_null_treatment(flags::NullTreatment::InsideParens) + .with_like_escape(flags::LikeEscape::Scalar) + .with_like_any(flags::LikeAny::OrExpansion) + .with_ilike_expression(flags::ILikeExpression::UseLikeLower) + .with_rlike_expression(flags::RLikeExpression::Unsupported) + .with_string_concat(flags::StringConcat::Pipes) + .with_bitxor(flags::BitXor::BitXORFunction) + .with_modulo(flags::Modulo::Infix) + .with_is_distinct_from(flags::IsDistinctFrom::Unsupported) + .with_try_cast(flags::TryCast::TryCastWord) + .with_trim(flags::Trim::SqlFrom) + .with_collate(flags::Collate::Unsupported) + .with_bitwise(flags::Bitwise::ShiftFunctions) + .with_tuple(flags::Tuple::Unsupported) + .with_bit_format(flags::BitFormat::PostfixBigB) + .with_bool_format(flags::BoolFormat::Keyword) + .with_identifier_format(flags::IdentifierFormat::DoubleQuote) + .with_identifier_escape(flags::IdentifierEscape::Double) + .with_string_format(flags::StringFormat::SingleQuote) + .with_string_escape(flags::StringEscape::Quote) + .with_bytes_format(flags::BytesFormat::PostfixXbv) + .with_function_filter(flags::FunctionFilter::Unsupported) + .with_array_format(flags::ArrayFormat::Unsupported) + .with_date_format(flags::DateFormat::Keyword) + .with_time_format(flags::TimeFormat::Quoted) + .with_timestamp_format(flags::TimestampFormat::WithTimezone) + .with_inet_format(flags::InetFormat::Unsupported) + .with_macaddr_format(flags::MacAddrFormat::Unsupported) + .with_uuid_format(flags::UuidFormat::Quoted) + .with_json_format(flags::JsonFormat::Quoted) + .with_supports_similar_to(false) + .with_parenthesize_in_between(false) + .with_supports_double_colon(false), ); diff --git a/src/dialect/errors.rs b/src/dialect/errors.rs new file mode 100644 index 0000000..48a7ce9 --- /dev/null +++ b/src/dialect/errors.rs @@ -0,0 +1,27 @@ +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FormatError { + Write, + NulInString, + Unsupported(&'static str), +} + +impl From for FormatError { + #[inline] + fn from(_: fmt::Error) -> Self { + Self::Write + } +} + +impl fmt::Display for FormatError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Write => f.write_str("failed to write SQL"), + Self::NulInString => f.write_str("NUL is not allowed in a string literal"), + Self::Unsupported(name) => write!(f, "this dialect does not support {name}"), + } + } +} + +impl std::error::Error for FormatError {} diff --git a/src/dialect/flags.rs b/src/dialect/flags.rs new file mode 100644 index 0000000..38cbaec --- /dev/null +++ b/src/dialect/flags.rs @@ -0,0 +1,1025 @@ +use bitfield_struct::bitenum; +use bitfield_struct::bitfield; + +use crate::internal::utils; + +use super::errors::FormatError; + +/// Positional parameter syntax +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Placeholder { + /// e.g. `'?'` + #[fallback] + Simple, + /// e.g. `'?1'` + Numbered, +} + +/// The way dialect supports `{IGNORE|RESPECT} NULLS` expressions in `Function`s. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum NullTreatment { + /// Is not supported + #[fallback] + Unsupported, + /// e.g. `FIRST_VALUE(x IGNORE NULLS)` + InsideParens, + /// e.g. `FIRST_VALUE(x) IGNORE NULLS` + Postfix, +} + +/// Where an inlined `LIKE` `ESCAPE` clause is legal. +/// +/// `escape: None` writes nothing. A set escape character on a form this +/// variant does not allow raises. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum LikeEscape { + /// No `ESCAPE` clause. + Unsupported, + /// Scalar `LIKE` and an `OR` expansion. Native `LIKE ANY` has no slot. + Scalar, + /// Scalar, `OR` expansion, and `LIKE ANY (p, …) ESCAPE`. + #[fallback] + IncludingAny, +} + +/// How `LIKE ANY` / `ILIKE ANY` is rendered. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum LikeAny { + /// `(x LIKE p1 OR x LIKE p2)`. + /// Useful when it isn't supported natively by dialect + #[fallback] + OrExpansion, + /// `LIKE ANY (ARRAY[p, …])`. + ArrayConstructor, + /// `LIKE ANY (p, …)`. + PatternList, +} + +/// Does dialect support `ILIKE` expression or we should fallback to another options? +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ILikeExpression { + #[fallback] + Standard, + /// Use `LIKE` expression and `LOWER()` function + UseLikeLower, + /// Fallback to `LIKE` expression + UseLike, +} + +/// How regex `RLike` is spelled. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RLikeExpression { + /// Is not supported. + #[fallback] + Unsupported, + /// `expr [NOT] RLIKE pattern` + Standard, + /// `expr ~ pattern` / `expr !~ pattern` + Tilde, + /// `REGEXP_LIKE(expr, pattern)` + RegexpLike, + /// `REGEXP_CONTAINS(expr, pattern)` + RegexpContains, + /// `regexp_matches(expr, pattern)` + RegexpMatches, +} + +/// How binary operation StringConcat (`||`) is spelled. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum StringConcat { + /// `left || right`. + #[fallback] + Pipes, + /// `left + right`. SQL Server `||` is 2025+; classic concat is `+`. + Plus, + /// `CONCAT(left, right)`. MySQL `||` is logical OR unless `PIPES_AS_CONCAT`. + ConcatFunction, +} + +/// How binary operation bit xor (`^`) is spelled. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BitXor { + /// Is not supported + Unsupported, + /// `left ^ right`. + #[fallback] + Caret, + /// `left # right`. PostgreSQL `^` is exponent. + Hash, + /// `xor(left, right)`. + XORFunction, + /// `BITXOR(left, right)`. + BitXORFunction, +} + +/// How binary operation mod (`%`) is spelled. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Modulo { + /// `left % right`. + #[fallback] + Percent, + /// `MOD(left, right)`. + Function, + /// `left MOD right`. + Infix, +} + +/// How `IS [NOT] DISTINCT FROM` is spelled. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum IsDistinctFrom { + /// Is not supported + Unsupported, + /// `left IS [NOT] DISTINCT FROM right`. + #[fallback] + Standard, + /// `<=>` is null-safe equal (`IS NOT DISTINCT FROM`). + /// `IS DISTINCT FROM` is `NOT (left <=> right)`. + NullSafeEqual, +} + +/// How `TRY_CAST` is spelled. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TryCast { + /// Is not supported + Unsupported, + /// `TRY_CAST(expr AS type)`. + #[fallback] + TryCast, + /// `TRYCAST(expr AS type)`. + TryCastWord, + /// `SAFE_CAST(expr AS type)`. BigQuery's spelling of a null-on-failure cast. + SafeCast, +} + +/// How `TRIM` is spelled. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Trim { + /// `TRIM([LEADING|TRAILING] [chars FROM] expr)`. + #[fallback] + SqlFrom, + /// `TRIM` / `LTRIM` / `RTRIM` `(expr[, chars])`. + Function, + /// `TRIM` / `LTRIM` / `RTRIM` `(expr)`. A character set raises. + SpacesOnly, +} + +/// How `COLLATE` is spelled. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Collate { + /// Is not supported + Unsupported, + /// `expr COLLATE ident`. + #[fallback] + InfixIdent, + /// `COLLATE(expr, 'body')`. The identifier body is a string literal. + FunctionString, +} + +/// How `&`, `|`, `~`, `<<`, and `>>` are spelled. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Bitwise { + /// All raises. + Unsupported, + /// `& | ~ << >>`. + #[fallback] + Infix, + /// `& << >>`. `|` and `~` raise. + /// + /// Redshift documents those three and not bitwise OR or NOT + /// (`^` is exponentiation and stays on [`BitXorSyntax`]). + InfixAndShift, + /// `& | ~`. Shifts raise (SQL Server). + InfixNoShift, + /// `BITAND` / `BITOR` / `BITNOT` / `BITSHIFTLEFT` / `BITSHIFTRIGHT`. + BitShiftFunctions, + /// `BITAND` / `BITOR` / `BITNOT` / `SHIFTLEFT` / `SHIFTRIGHT` (Teradata). + ShiftFunctions, + /// `BITAND` only. `|`, `~`, `<<`, and `>>` raise (Oracle). + BitAndOnly, +} + +/// How a tuple / row value is spelled. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Tuple { + /// Is not supported. + Unsupported, + /// `ROW(...)`. `empty_ok` allows arity 0 (`ROW()`). + Row, + /// `ROW(...)`. Rejects empty (`ROW()`). + RowRejectEmpty, + /// `struct(...)`. Arity 0 is unsupported. + Struct, + /// `(a, b, ...)`. Arity 0 and 1 are unsupported. + #[fallback] + Parentheses, +} + +/// The way dialect accepts literal bit values +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BitFormat { + /// Is not supported. + Unsupported, + /// e.g. `B'101'` + #[fallback] + PrefixBigB, + /// e.g. `b'101'` + PrefixSmallB, + /// e.g. `'101'B` + PostfixBigB, +} + +/// How a boolean literal is spelled. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BoolFormat { + /// `TRUE` / `FALSE` + #[fallback] + Keyword, + /// `1` / `0` + Integer, +} + +/// Delimiters wrapped around a quoted identifier. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum IdentifierFormat { + /// `"name"` + #[fallback] + DoubleQuote, + /// `` `name` `` + Backtick, + /// `[name]` + SquareBracket, +} + +/// How to escape identifiers. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum IdentifierEscape { + /// Escape identifier delimiters with doubling. + #[fallback] + Double, + /// Escape identifier delimiters with backslash. + Backslash, +} + +/// Delimiters wrapped around a literal string. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum StringFormat { + /// `'string'` + #[fallback] + SingleQuote, + /// `E'string'` + PrefixBigE, + /// `N'string'` + PrefixBigN, +} + +/// How to escape literal strings. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum StringEscape { + /// `'` → `''`. Other characters are unchanged. + #[fallback] + Quote, + /// `\\`, `\'`, `\n`, `\r`, `\t`; other C0/DEL as `\xNN`. + BackslashHex, + /// Also `\"` and `\Z` for 0x1A; tab and other controls pass through. + BackslashMysql, + /// Other C0/DEL as `\uNNNN`. No `\"`. + BackslashUnicode, + /// Other C0/DEL as `\xNN`. + BackslashHexZero, + /// [`Self::BackslashUnicode`] plus `\"`. + BackslashUnicodeQuote, +} + +/// The way dialect accepts literal bytes values. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BytesFormat { + /// `X'DEADBEEF'` + #[fallback] + PrefixBigX, + /// `0xDEADBEEF` + PrefixZeroX, + /// `'\xDEADBEEF'::bytea` + SlashXBytea, + /// `B'\xNN…'` (each byte as `\xNN`) + PrefixBigBSlashX, + /// `HEXTORAW('DEADBEEF')` + HexToRaw, + /// `UNHEX('DEADBEEF')` + Unhex, + /// `TO_VARBYTE('DEADBEEF', 'hex')` + ToVarbyte, + /// `'DEADBEEF'XBV` + PostfixXbv, + /// `TO_BINARY('DEADBEEF', 'HEX')` + ToBinary, + /// `'\xHH…'::BLOB`; empty is `''::BLOB` + SlashXBlob, +} + +/// Function `FILTER` syntax. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FunctionFilter { + /// Is not supported. + Unsupported, + /// e.g. FIRST_VALUE(x) FILTER (id > 10) + Filter, + /// e.g. FIRST_VALUE(x) FILTER (WHERE id > 10) + #[fallback] + FilterWhere, + /// e.g. FIRST_VALUE(x WHERE id > 10) + InsideWhere, +} + +/// The way dialect accepts array values. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ArrayFormat { + /// Is not supported. + #[fallback] + Unsupported, + /// `ARRAY[…]` + Constructor, + /// `[…]` + List, + /// `array(…)` + Function, +} + +/// How a date literal is spelled. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DateFormat { + /// Quoted string. + #[fallback] + Quoted, + /// `DATE`, a space, then a quoted literal. + Keyword, +} + +/// How a time literal is spelled. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TimeFormat { + /// Quoted string. + #[fallback] + Quoted, + /// `TIME`, a space, then a quoted literal. + Keyword, +} + +/// How a timestamp literal is spelled. +/// +/// Naive values use `TIMESTAMP` when a keyword is configured. Aware values +/// (`datetime` with a tzinfo, or a UTC epoch number) use the keyword this +/// variant names. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TimestampFormat { + /// Both naive and aware values are a quoted string. No keyword. + Quoted, + /// Naive: `TIMESTAMP '…'`. Aware: `TIMESTAMPTZ '…'`. + Timestamptz, + /// Naive: `TIMESTAMP '…'`. Aware: `TIMESTAMP WITH TIMEZONE '…'`. + #[fallback] + WithTimezone, +} + +/// How an inet literal is spelled. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum InetFormat { + /// Is not supported. + #[fallback] + Unsupported, + /// `'192.168.1.1/32'` + Quoted, + /// `'192.168.1.1/32'::inet` + Cast, +} + +/// How a MAC address literal is spelled. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MacAddrFormat { + /// Is not supported. + #[fallback] + Unsupported, + /// `'01:23:45:67:89:AB'` + Quoted, + /// `'01:23:45:67:89:AB'::macaddr` + Cast, +} + +/// How a UUID literal is spelled. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum UuidFormat { + /// Quoted hyphenated text. + #[fallback] + Quoted, + /// `UUID '…'` + UuidKeyword, + /// `'…'::uuid` + CastUuid, +} + +/// How a JSON literal is spelled. +#[bitenum] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JsonFormat { + /// Quoted JSON text. + #[fallback] + Quoted, + /// `JSON '…'` + JsonKeyword, + /// `PARSE_JSON('…')` + ParseJson, +} + +#[bitfield(u128)] +#[derive(PartialEq, Eq, Hash)] +pub struct DialectFlags { + /// Placeholder character as u32 + /// + /// Always is a valid character + #[bits(32)] + pub placeholder_character: u32, + /// Positional parameter syntax + #[bits(1)] + pub placeholder: Placeholder, + /// The way dialect supports `{IGNORE|RESPECT} NULLS` expressions in `Function`s. + #[bits(2)] + pub null_treatment: NullTreatment, + /// Where an inlined `LIKE` `ESCAPE` clause is legal. + #[bits(2)] + pub like_escape: LikeEscape, + /// How `LIKE ANY` / `ILIKE ANY` is rendered. + #[bits(2)] + pub like_any: LikeAny, + /// Does dialect support `ILIKE` expression or we should fallback to another options? + #[bits(2)] + pub ilike_expression: ILikeExpression, + /// How regex `RLike` is spelled. + #[bits(3)] + pub rlike_expression: RLikeExpression, + /// How binary operation StringConcat (`||`) is spelled. + #[bits(2)] + pub string_concat: StringConcat, + /// How binary operation bit xor (`^`) is spelled. + #[bits(3)] + pub bitxor: BitXor, + /// How binary operation mod (`%`) is spelled. + #[bits(2)] + pub modulo: Modulo, + /// How `IS [NOT] DISTINCT FROM` is spelled. + #[bits(2)] + pub is_distinct_from: IsDistinctFrom, + /// How `TRY_CAST` is spelled. + #[bits(2)] + pub try_cast: TryCast, + /// How TRIM is spelled. + #[bits(2)] + pub trim: Trim, + /// How `COLLATE` is spelled. + #[bits(2)] + pub collate: Collate, + /// How `&`, `|`, `~`, `<<`, and `>>` are spelled. + #[bits(3)] + pub bitwise: Bitwise, + /// How a tuple / row value is spelled. + #[bits(3)] + pub tuple: Tuple, + /// The way dialect accepts literal bit values. + #[bits(2)] + pub bit_format: BitFormat, + /// How a boolean literal is spelled. + #[bits(1)] + pub bool_format: BoolFormat, + /// Delimiters wrapped around a quoted identifier. + #[bits(2)] + pub identifier_format: IdentifierFormat, + /// How to escape identifiers. + #[bits(1)] + pub identifier_escape: IdentifierEscape, + /// Delimiters wrapped around a literal string. + #[bits(2)] + pub string_format: StringFormat, + /// How to escape literal strings. + #[bits(3)] + pub string_escape: StringEscape, + /// The way dialect accepts literal bytes values. + #[bits(4)] + pub bytes_format: BytesFormat, + /// Function `FILTER` syntax. + #[bits(3)] + pub function_filter: FunctionFilter, + /// The way dialect accepts array values. + #[bits(3)] + pub array_format: ArrayFormat, + /// How a date literal is spelled. + #[bits(3)] + pub date_format: DateFormat, + /// How a time literal is spelled. + #[bits(3)] + pub time_format: TimeFormat, + /// How a timestamp literal is spelled. + #[bits(2)] + pub timestamp_format: TimestampFormat, + /// How an inet literal is spelled. + #[bits(2)] + pub inet_format: InetFormat, + /// How a MAC address literal is spelled. + #[bits(2)] + pub macaddr_format: MacAddrFormat, + /// How a UUID literal is spelled. + #[bits(2)] + pub uuid_format: UuidFormat, + /// How a JSON literal is spelled. + #[bits(2)] + pub json_format: JsonFormat, + /// Whether `SIMILAR TO` is supported. + #[bits(1)] + pub supports_similar_to: bool, + /// Wrap `IN` / `BETWEEN` in parentheses. + /// + /// SQL Server ranks `AND` above `IN`/`BETWEEN`/`OR`, and those four share + /// a level evaluated left to right. The parentheses keep the predicate + /// from being parsed as part of the left operand. + #[bits(1)] + pub parenthesize_in_between: bool, + /// Whether `expr::type` is supported. + #[bits(1)] + pub supports_double_colon: bool, + + // Padding + #[bits(23)] + __: u32, +} + +impl DialectFlags { + /// Write a boolean literal (`TRUE`/`FALSE` or `1`/`0`). + #[inline] + pub fn format_boolean( + &self, + writer: &mut W, + value: bool, + ) -> Result<(), FormatError> { + match (self.bool_format(), value) { + (BoolFormat::Integer, false) => writer.write_str("0")?, + (BoolFormat::Integer, true) => writer.write_str("1")?, + (BoolFormat::Keyword, false) => writer.write_str("FALSE")?, + (BoolFormat::Keyword, true) => writer.write_str("TRUE")?, + } + Ok(()) + } + + /// Write `string` with special characters neutralized, without wrapping it. + #[inline] + pub fn escape_string( + &self, + writer: &mut W, + string: &str, + ) -> Result<(), FormatError> { + let result = match self.string_escape() { + StringEscape::Quote => utils::replace_to(string, '\'', "''", writer), + StringEscape::BackslashHex => write_backslash_hex(writer, string), + StringEscape::BackslashMysql => write_backslash_mysql(writer, string), + StringEscape::BackslashUnicode => write_backslash_unicode(writer, string, false), + StringEscape::BackslashHexZero => write_backslash_hex_zero(writer, string), + StringEscape::BackslashUnicodeQuote => write_backslash_unicode(writer, string, true), + }; + result.map_err(Into::into) + } + + /// Escape `ident` and wrap it in this dialect's identifier delimiters. + #[inline] + pub fn quote_identifier( + &self, + writer: &mut W, + ident: &str, + ) -> std::fmt::Result { + let (start, end) = match self.identifier_format() { + IdentifierFormat::DoubleQuote => ('"', '"'), + IdentifierFormat::Backtick => ('`', '`'), + IdentifierFormat::SquareBracket => ('[', ']'), + }; + writer.write_char(start)?; + + match self.identifier_escape() { + IdentifierEscape::Double => match self.identifier_format() { + IdentifierFormat::DoubleQuote => { + utils::replace_to(ident, '"', "\"\"", writer)?; + } + IdentifierFormat::Backtick => { + utils::replace_to(ident, '`', "``", writer)?; + } + IdentifierFormat::SquareBracket => { + utils::replace_to(ident, ']', "]]", writer)?; + } + }, + IdentifierEscape::Backslash => match self.identifier_format() { + IdentifierFormat::DoubleQuote => { + utils::replace_to(ident, '"', "\\\"", writer)?; + } + IdentifierFormat::Backtick => { + write_identifier_backtick_escape(writer, ident)?; + } + IdentifierFormat::SquareBracket => { + utils::replace_to(ident, ']', "\\]", writer)?; + } + }, + } + + writer.write_char(end) + } + + /// Escape `raw` and wrap it as a complete string literal. + #[inline] + pub fn write_string_literal( + &self, + writer: &mut W, + raw: &str, + ) -> Result<(), FormatError> { + if raw.contains('\0') { + return Err(FormatError::NulInString); + } + + match self.string_format() { + StringFormat::PrefixBigE => writer.write_str("E'")?, + StringFormat::PrefixBigN => writer.write_str("N'")?, + StringFormat::SingleQuote => writer.write_char('\'')?, + } + self.escape_string(writer, raw)?; + writer.write_char('\'')?; + Ok(()) + } + + /// Write a bit-string (`'0'`/`'1'` characters) as a native literal. + /// + /// Native formats embed `bits` as-is. Callers must pass only `'0'` and `'1'`; + /// that is checked with `debug_assert!`. + #[inline] + pub fn format_bits( + &self, + writer: &mut W, + bits: &str, + ) -> Result<(), FormatError> { + debug_assert!( + bits.chars().all(|c| c == '0' || c == '1'), + "format_bits: invalid input: {bits}" + ); + match self.bit_format() { + BitFormat::Unsupported => Err(FormatError::Unsupported("BitSerializer")), + BitFormat::PrefixBigB => write!(writer, "B'{bits}'").map_err(Into::into), + BitFormat::PrefixSmallB => write!(writer, "b'{bits}'").map_err(Into::into), + BitFormat::PostfixBigB => write!(writer, "'{bits}'B").map_err(Into::into), + } + } + + /// Write `bytes` as a native blob/bytea/varbyte literal. + #[inline] + pub fn format_bytes( + &self, + writer: &mut W, + bytes: &[u8], + ) -> Result<(), FormatError> { + let result = match self.bytes_format() { + BytesFormat::PrefixBigX => { + writer.write_str("X'")?; + bytes_to_hex(writer, bytes)?; + writer.write_char('\'') + } + BytesFormat::PrefixZeroX => { + writer.write_str("0x")?; + bytes_to_hex(writer, bytes) + } + BytesFormat::SlashXBytea => { + writer.write_str("'\\x")?; + bytes_to_hex(writer, bytes)?; + writer.write_str("'::bytea") + } + BytesFormat::PrefixBigBSlashX => { + writer.write_str("B'")?; + write_bytes_slash_x(writer, bytes)?; + writer.write_char('\'') + } + BytesFormat::HexToRaw => { + writer.write_str("HEXTORAW('")?; + bytes_to_hex(writer, bytes)?; + writer.write_str("')") + } + BytesFormat::Unhex => { + writer.write_str("UNHEX('")?; + bytes_to_hex(writer, bytes)?; + writer.write_str("')") + } + BytesFormat::ToVarbyte => { + writer.write_str("TO_VARBYTE('")?; + bytes_to_hex(writer, bytes)?; + writer.write_str("', 'hex')") + } + BytesFormat::PostfixXbv => { + writer.write_char('\'')?; + bytes_to_hex(writer, bytes)?; + writer.write_str("'XBV") + } + BytesFormat::ToBinary => { + writer.write_str("TO_BINARY('")?; + bytes_to_hex(writer, bytes)?; + writer.write_str("', 'HEX')") + } + BytesFormat::SlashXBlob => { + if bytes.is_empty() { + writer.write_str("''::BLOB") + } else { + writer.write_char('\'')?; + write_bytes_slash_x(writer, bytes)?; + writer.write_str("'::BLOB") + } + } + }; + result.map_err(Into::into) + } +} + +const HEX_DIGITS: &[u8; 16] = b"0123456789ABCDEF"; + +/// Write `byte` as two uppercase hex digits. +#[inline] +fn write_hex2(writer: &mut W, byte: u8) -> std::fmt::Result { + let buf = [ + HEX_DIGITS[(byte >> 4) as usize], + HEX_DIGITS[(byte & 0x0F) as usize], + ]; + // SAFETY: HEX_DIGITS is ASCII, so `buf` is valid UTF-8. + writer.write_str(unsafe { std::str::from_utf8_unchecked(&buf) }) +} + +/// Write `bytes` as a contiguous uppercase hex run (`DEADBEEF`). +/// +/// 64 raw bytes fill a 128-byte stack buffer and are written once. +#[inline] +fn bytes_to_hex(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { + let mut buf = [0u8; 128]; + for chunk in bytes.chunks(64) { + for (i, &byte) in chunk.iter().enumerate() { + let o = i * 2; + buf[o] = HEX_DIGITS[(byte >> 4) as usize]; + buf[o + 1] = HEX_DIGITS[(byte & 0x0F) as usize]; + } + let n = chunk.len() * 2; + // SAFETY: `buf[..n]` is filled only from `HEX_DIGITS` (ASCII). + writer.write_str(unsafe { std::str::from_utf8_unchecked(&buf[..n]) })?; + } + Ok(()) +} + +/// Write each byte as `\xNN` (uppercase hex). +/// +/// 32 raw bytes fill a 128-byte stack buffer and are written once. +#[inline] +fn write_bytes_slash_x(writer: &mut W, bytes: &[u8]) -> std::fmt::Result { + let mut buf = [0u8; 128]; + for chunk in bytes.chunks(32) { + for (i, &byte) in chunk.iter().enumerate() { + let o = i * 4; + buf[o] = b'\\'; + buf[o + 1] = b'x'; + buf[o + 2] = HEX_DIGITS[(byte >> 4) as usize]; + buf[o + 3] = HEX_DIGITS[(byte & 0x0F) as usize]; + } + let n = chunk.len() * 4; + // SAFETY: `buf[..n]` is ASCII (`\`, `x`, and hex digits). + writer.write_str(unsafe { std::str::from_utf8_unchecked(&buf[..n]) })?; + } + Ok(()) +} + +/// Write `\xNN` for a single byte (uppercase hex). +#[inline] +fn write_byte_hex_escape(writer: &mut W, byte: u8) -> std::fmt::Result { + writer.write_str("\\x")?; + write_hex2(writer, byte) +} + +/// Write `\uNNNN` for a codepoint that fits in 16 bits. +#[inline] +fn write_unicode4_escape(writer: &mut W, code: u32) -> std::fmt::Result { + debug_assert!(code <= 0xFFFF); + writer.write_str("\\u")?; + write_hex2(writer, (code >> 8) as u8)?; + write_hex2(writer, code as u8) +} + +/// Use the string escape table, plus `\``. +/// +/// `'` is left as itself. Only known escapes are emitted. +fn write_identifier_backtick_escape( + writer: &mut W, + ident: &str, +) -> std::fmt::Result { + write_span_escapes(writer, ident, |b| match b { + b'\\' => SpanEscape::Literal("\\\\"), + b'`' => SpanEscape::Literal("\\`"), + b'\n' => SpanEscape::Literal("\\n"), + b'\r' => SpanEscape::Literal("\\r"), + b'\t' => SpanEscape::Literal("\\t"), + b if b < 32 || b == 127 => SpanEscape::HexByte, + _ => SpanEscape::Keep, + }) +} + +/// Write `bytes[from..to]` as a string. +/// +/// # Safety invariant +/// `bytes` is the raw view of a `&str`. Callers split only on ASCII bytes, +/// which are char boundaries, or at `bytes.len()`. +#[inline] +fn write_byte_span( + writer: &mut W, + bytes: &[u8], + from: usize, + to: usize, +) -> std::fmt::Result { + if from == to { + return Ok(()); + } + // SAFETY: see invariant above. + let chunk = unsafe { bytes.get_unchecked(from..to) }; + writer.write_str(unsafe { std::str::from_utf8_unchecked(chunk) }) +} + +enum SpanEscape { + Keep, + Literal(&'static str), + HexByte, + Unicode4, +} + +/// Copy each plain span with one `write_str`. Special bytes are ASCII. +#[cfg_attr(feature = "nightly", optimize(speed))] +fn write_span_escapes( + writer: &mut W, + string: &str, + mut classify: impl FnMut(u8) -> SpanEscape, +) -> std::fmt::Result { + let bytes = string.as_bytes(); + let mut start = 0; + let mut i = 0; + while i < bytes.len() { + match classify(bytes[i]) { + SpanEscape::Keep => i += 1, + SpanEscape::Literal(repl) => { + write_byte_span(writer, bytes, start, i)?; + writer.write_str(repl)?; + i += 1; + start = i; + } + SpanEscape::HexByte => { + write_byte_span(writer, bytes, start, i)?; + write_byte_hex_escape(writer, bytes[i])?; + i += 1; + start = i; + } + SpanEscape::Unicode4 => { + write_byte_span(writer, bytes, start, i)?; + write_unicode4_escape(writer, bytes[i] as u32)?; + i += 1; + start = i; + } + } + } + write_byte_span(writer, bytes, start, bytes.len()) +} + +fn write_backslash_hex(writer: &mut W, string: &str) -> std::fmt::Result { + write_span_escapes(writer, string, |b| match b { + b'\\' => SpanEscape::Literal("\\\\"), + b'\'' => SpanEscape::Literal("\\'"), + b'\n' => SpanEscape::Literal("\\n"), + b'\r' => SpanEscape::Literal("\\r"), + b'\t' => SpanEscape::Literal("\\t"), + b if b < 32 || b == 127 => SpanEscape::HexByte, + _ => SpanEscape::Keep, + }) +} + +fn write_backslash_mysql(writer: &mut W, string: &str) -> std::fmt::Result { + write_span_escapes(writer, string, |b| match b { + b'\\' => SpanEscape::Literal("\\\\"), + b'\'' => SpanEscape::Literal("\\'"), + b'"' => SpanEscape::Literal("\\\""), + 0 => SpanEscape::Literal("\\0"), + b'\n' => SpanEscape::Literal("\\n"), + b'\r' => SpanEscape::Literal("\\r"), + 0x1a => SpanEscape::Literal("\\Z"), + _ => SpanEscape::Keep, + }) +} + +fn write_backslash_unicode( + writer: &mut W, + string: &str, + escape_double_quote: bool, +) -> std::fmt::Result { + write_span_escapes(writer, string, |b| match b { + b'\\' => SpanEscape::Literal("\\\\"), + b'\'' => SpanEscape::Literal("\\'"), + b'"' if escape_double_quote => SpanEscape::Literal("\\\""), + b'\n' => SpanEscape::Literal("\\n"), + b'\r' => SpanEscape::Literal("\\r"), + b'\t' => SpanEscape::Literal("\\t"), + 0 => SpanEscape::Literal("\\0"), + b if b < 32 || b == 127 => SpanEscape::Unicode4, + _ => SpanEscape::Keep, + }) +} + +fn write_backslash_hex_zero(writer: &mut W, string: &str) -> std::fmt::Result { + write_span_escapes(writer, string, |b| match b { + b'\\' => SpanEscape::Literal("\\\\"), + b'\'' => SpanEscape::Literal("\\'"), + b'\n' => SpanEscape::Literal("\\n"), + b'\r' => SpanEscape::Literal("\\r"), + b'\t' => SpanEscape::Literal("\\t"), + 0 => SpanEscape::Literal("\\0"), + b if b < 32 || b == 127 => SpanEscape::HexByte, + _ => SpanEscape::Keep, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn collect(write: impl FnOnce(&mut String) -> Result<(), FormatError>) -> String { + let mut buf = String::new(); + write(&mut buf).unwrap(); + buf + } + + #[test] + fn test_bool_format() { + let cfg = DialectFlags::new().with_bool_format(BoolFormat::Integer); + assert_eq!(collect(|x| cfg.format_boolean(x, true)), "1"); + assert_eq!(collect(|x| cfg.format_boolean(x, false)), "0"); + + let cfg = cfg.with_bool_format(BoolFormat::Keyword); + assert_eq!(collect(|x| cfg.format_boolean(x, true)), "TRUE"); + assert_eq!(collect(|x| cfg.format_boolean(x, false)), "FALSE"); + } +} diff --git a/src/dialect/inner.rs b/src/dialect/inner.rs deleted file mode 100644 index 3edebb4..0000000 --- a/src/dialect/inner.rs +++ /dev/null @@ -1,1266 +0,0 @@ -use std::fmt::Write; -use std::fmt::{self}; - -use crate::internal::utils; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FormatError { - Write, - NulInString, - Unsupported(&'static str), -} - -impl From for FormatError { - #[inline] - fn from(_: fmt::Error) -> Self { - Self::Write - } -} - -impl fmt::Display for FormatError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Write => f.write_str("failed to write SQL"), - Self::NulInString => f.write_str("NUL is not allowed in a string literal"), - Self::Unsupported(name) => write!(f, "this dialect does not support {name}"), - } - } -} - -impl std::error::Error for FormatError {} - -/// Positional parameter syntax -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum PositionalParameter { - /// e.g. `'?'` - Simple(char), - /// e.g. `'$'` -> `'$1'` - Numbered(char), -} - -/// The way dialect accepts literal bit values -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum BitFormat { - /// e.g. `B'101'` - PrefixBigB, - /// e.g. `b'101'` - PrefixSmallB, - /// e.g. `'101'B` - PostfixBigB, -} - -impl BitFormat { - /// Write `bits` as a native bit-string literal. - #[inline] - fn write(self, writer: &mut W, bits: &str) -> fmt::Result { - match self { - Self::PrefixBigB => write!(writer, "B'{bits}'"), - Self::PrefixSmallB => write!(writer, "b'{bits}'"), - Self::PostfixBigB => write!(writer, "'{bits}'B"), - } - } -} - -/// The way dialect supports `{IGNORE|RESPECT} NULLS` expressions in `Function`s. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum NullTreatmentSyntax { - /// e.g. `FIRST_VALUE(x IGNORE NULLS)` - InsideParens, - /// e.g. `FIRST_VALUE(x) IGNORE NULLS` - Postfix, -} - -/// How `LIKE ANY` / `ILIKE ANY` is rendered. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum LikeAnySyntax { - /// `LIKE ANY (ARRAY[p, …])`. - ArrayConstructor, - /// `LIKE ANY (p, …)`. - PatternList, - /// `(x LIKE p1 OR x LIKE p2)`. Useful when it isn't supported natively by dialect - OrExpansion, -} - -/// Does dialect support `ILIKE` expression or we should fallback to another options? -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ILikeExpressionOptions { - /// `ILIKE` is supported - Supported, - /// Use `LIKE` expression and `LOWER()` function - UseLikeLower, - /// Fallback to `LIKE` expression - UseLike, -} - -impl ILikeExpressionOptions { - /// `(use_ilike, wrap_lower)` when the LIKE is case-insensitive. - #[inline] - pub fn case_insensitive_sql(self) -> (bool, bool) { - match self { - Self::Supported => (true, false), - Self::UseLike => (false, false), - Self::UseLikeLower => (false, true), - } - } -} - -/// How regex `RLike` is spelled. -/// -/// [`None`] on [`Dialect::rlike_expression_options`] means unsupported. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum RLikeExpressionOptions { - /// `expr [NOT] RLIKE pattern` — MySQL, Hive, Spark, Databricks, Snowflake. - InfixRLike, - /// `expr ~ pattern` / `expr !~ pattern` — PostgreSQL, Redshift. - Tilde, - /// `REGEXP_LIKE(expr, pattern)` — Oracle. - RegexpLike, - /// `REGEXP_CONTAINS(expr, pattern)` — BigQuery. - RegexpContains, - /// `regexp_matches(expr, pattern)` — DuckDB (substring; DuckDB `~` is full match). - RegexpMatches, -} - -/// How [`crate::expr::enum_::BinaryOp::StringConcat`] (`||`) is spelled. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum StringConcatSyntax { - /// `left || right`. - Pipes, - /// `left + right`. SQL Server `||` is 2025+; classic concat is `+`. - Plus, - /// `CONCAT(left, right)`. MySQL `||` is logical OR unless `PIPES_AS_CONCAT`. - ConcatFunction, -} - -/// How [`crate::expr::enum_::BinaryOp::BitXor`] (`^`) is spelled. -/// -/// [`None`] on [`Dialect::bit_xor_syntax`] means unsupported. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum BitXorSyntax { - /// `left ^ right`. - Caret, - /// `left # right`. PostgreSQL `^` is exponent. - Hash, - /// `xor(left, right)`. - XORFunction, - /// `BITXOR(left, right)`. - BitXORFunction, -} - -/// How [`crate::expr::enum_::BinaryOp::Mod`] (`%`) is spelled. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ModuloSyntax { - /// `left % right`. - Percent, - /// `MOD(left, right)`. - Function, - /// `left MOD right`. Teradata's arithmetic operator; `MOD()` is an ODBC extension. - Infix, -} - -/// How `IS [NOT] DISTINCT FROM` is spelled. -/// -/// [`None`] on [`Dialect::is_distinct_from_syntax`] means unsupported. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum IsDistinctFromSyntax { - /// `left IS [NOT] DISTINCT FROM right`. - Standard, - /// `<=>` is null-safe equal (`IS NOT DISTINCT FROM`). - /// `IS DISTINCT FROM` is `NOT (left <=> right)`. - NullSafeEqual, -} - -/// How `TRY_CAST` is spelled. [`None`] on [`Dialect::try_cast_syntax`] means unsupported. -/// -/// Callers ask for `TRY_CAST`. BigQuery emits `SAFE_CAST`; Teradata emits `TRYCAST`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum TryCastSyntax { - /// `TRY_CAST(expr AS type)`. - TryCast, - /// `TRYCAST(expr AS type)`. - TryCastWord, - /// `SAFE_CAST(expr AS type)`. BigQuery's spelling of a null-on-failure cast. - SafeCast, -} - -impl TryCastSyntax { - #[inline] - pub fn as_sql(self) -> &'static str { - match self { - Self::TryCast => "TRY_CAST", - Self::TryCastWord => "TRYCAST", - Self::SafeCast => "SAFE_CAST", - } - } -} - -/// How `TRIM` is spelled. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum TrimSyntax { - /// `TRIM([LEADING|TRAILING] [chars FROM] expr)`. - SqlFrom, - /// `TRIM` / `LTRIM` / `RTRIM` `(expr[, chars])`. - Function, - /// `TRIM` / `LTRIM` / `RTRIM` `(expr)`. A character set raises. - SpacesOnly, -} - -/// How `COLLATE` is spelled. [`None`] on [`Dialect::collate_syntax`] means unsupported. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum CollateSyntax { - /// `expr COLLATE ident`. - InfixIdent, - /// `COLLATE(expr, 'body')`. The identifier body is a string literal. - FunctionString, -} - -/// Where an inlined `LIKE` `ESCAPE` clause is legal. -/// -/// `escape: None` writes nothing. A set escape character on a form this -/// variant does not allow raises. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum LikeEscapeSyntax { - /// No `ESCAPE` clause (BigQuery; Hive, whose operator is `A [NOT] LIKE B`). - None, - /// Scalar `LIKE` and an `OR` expansion. Native `LIKE ANY` has no slot. - Scalar, - /// Scalar, `OR` expansion, and `LIKE ANY (p, …) ESCAPE`. - IncludingAny, -} - -/// How `&`, `|`, `~`, `<<`, and `>>` are spelled. -/// -/// [`crate::expr::enum_::BinaryOp::BitXor`] stays on [`BitXorSyntax`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum BitwiseSyntax { - /// `& | ~ << >>`. - Infix, - /// `& << >>`. `|` and `~` raise. - /// - /// Redshift documents those three and not bitwise OR or NOT - /// (`^` is exponentiation and stays on [`BitXorSyntax`]). - InfixAndShift, - /// `& | ~`. Shifts raise (SQL Server). - InfixNoShift, - /// `BITAND` / `BITOR` / `BITNOT` / `BITSHIFTLEFT` / `BITSHIFTRIGHT`. - BitShiftFunctions, - /// `BITAND` / `BITOR` / `BITNOT` / `SHIFTLEFT` / `SHIFTRIGHT` (Teradata). - ShiftFunctions, - /// `BITAND` only. `|`, `~`, `<<`, and `>>` raise (Oracle). - BitAndOnly, - /// All five raise. - None, -} - -/// How a tuple / row value is spelled. -/// -/// [`None`] on [`Dialect::tuple_syntax`] means unsupported. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum TupleSyntax { - /// `ROW(...)`. `empty_ok` allows arity 0 (`ROW()`). - Row, - /// `ROW(...)`. Rejects empty (`ROW()`). - RowRejectEmpty, - /// `struct(...)`. Arity 0 is unsupported. - Struct, - /// `(a, b, ...)`. Arity 0 and 1 are unsupported. - Parentheses, -} - -/// How a boolean literal is spelled. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum BoolFormat { - /// `TRUE` / `FALSE` - Keyword, - /// `1` / `0` - Int, -} - -/// Delimiters wrapped around a quoted identifier. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum IdentifierFormat { - /// `"name"` - DoubleQuote, - /// `` `name` `` - Backtick, - /// `[name]` - SquareBracket, -} - -/// How to escape identifiers -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum IdentifierEscape { - /// Escape identifier delimiters with doubling. - Double, - /// Escape identifier delimiters with backslash. - Backslash, -} - -/// Delimiters wrapped around a literal string. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum StringFormat { - /// `'string'` - SingleQuote, - /// `E'string'` - PrefixBigE, - /// `N'string'` - PrefixBigN, -} - -/// How to escape literal strings. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum StringEscape { - /// `'` → `''`. Other characters are unchanged. - Quote, - /// `\\`, `\'`, `\n`, `\r`, `\t`; other C0/DEL as `\xNN`. - BackslashHex, - /// MySQL: also `\"` and `\Z` for 0x1A; tab and other controls pass through. - BackslashMysql, - /// Hive: other C0/DEL as `\uNNNN`. No `\"`. - BackslashUnicode, - /// Snowflake: other C0/DEL as `\xNN`. - BackslashHexZero, - /// Spark/Databricks: [`Self::BackslashUnicode`] plus `\"`. - BackslashUnicodeQuote, -} - -impl StringEscape { - /// Write `string` with special characters neutralized, without wrapping it. - #[inline] - fn write(self, writer: &mut W, string: &str) -> fmt::Result { - match self { - Self::Quote => utils::replace_to(string, '\'', "''", writer), - Self::BackslashHex => write_backslash_hex(writer, string), - Self::BackslashMysql => write_backslash_mysql(writer, string), - Self::BackslashUnicode => write_backslash_unicode(writer, string, false), - Self::BackslashHexZero => write_backslash_hex_zero(writer, string), - Self::BackslashUnicodeQuote => write_backslash_unicode(writer, string, true), - } - } -} - -/// The way dialect accepts literal bytes values. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum BytesFormat { - /// `X'DEADBEEF'` - PrefixBigX, - /// `0xDEADBEEF` - PrefixZeroX, - /// `'\xDEADBEEF'::bytea` - SlashXBytea, - /// `B'\xNN…'` (each byte as `\xNN`) - PrefixBigBSlashX, - /// `HEXTORAW('DEADBEEF')` - HexToRaw, - /// `UNHEX('DEADBEEF')` - Unhex, - /// `TO_VARBYTE('DEADBEEF', 'hex')` - ToVarbyte, - /// `'DEADBEEF'XBV` - PostfixXbv, - /// `TO_BINARY('DEADBEEF', 'HEX')` - ToBinary, - /// `'\xHH…'::BLOB`; empty is `''::BLOB` - SlashXBlob, -} - -impl BytesFormat { - /// Write `bytes` as a native blob/bytea/varbyte literal. - #[inline] - pub fn write(self, writer: &mut W, bytes: &[u8]) -> fmt::Result { - match self { - Self::PrefixBigX => { - writer.write_str("X'")?; - bytes_to_hex(writer, bytes)?; - writer.write_char('\'') - } - Self::PrefixZeroX => { - writer.write_str("0x")?; - bytes_to_hex(writer, bytes) - } - Self::SlashXBytea => { - writer.write_str("'\\x")?; - bytes_to_hex(writer, bytes)?; - writer.write_str("'::bytea") - } - Self::PrefixBigBSlashX => { - writer.write_str("B'")?; - write_bytes_slash_x(writer, bytes)?; - writer.write_char('\'') - } - Self::HexToRaw => { - writer.write_str("HEXTORAW('")?; - bytes_to_hex(writer, bytes)?; - writer.write_str("')") - } - Self::Unhex => { - writer.write_str("UNHEX('")?; - bytes_to_hex(writer, bytes)?; - writer.write_str("')") - } - Self::ToVarbyte => { - writer.write_str("TO_VARBYTE('")?; - bytes_to_hex(writer, bytes)?; - writer.write_str("', 'hex')") - } - Self::PostfixXbv => { - writer.write_char('\'')?; - bytes_to_hex(writer, bytes)?; - writer.write_str("'XBV") - } - Self::ToBinary => { - writer.write_str("TO_BINARY('")?; - bytes_to_hex(writer, bytes)?; - writer.write_str("', 'HEX')") - } - Self::SlashXBlob => { - if bytes.is_empty() { - writer.write_str("''::BLOB") - } else { - writer.write_char('\'')?; - write_bytes_slash_x(writer, bytes)?; - writer.write_str("'::BLOB") - } - } - } - } -} - -/// Function `FILTER` syntax -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum FunctionFilterSyntax { - /// e.g. FIRST_VALUE(x) FILTER (id > 10) - Filter, - /// e.g. FIRST_VALUE(x) FILTER (WHERE id > 10) - FilterWhere, - /// e.g. FIRST_VALUE(x WHERE id > 10) - InsideWhere, -} - -/// The way dialect accepts array values. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ArrayFormat { - /// `ARRAY[…]` - Constructor, - /// `[…]` - List, - /// `array(…)` - Function, -} - -/// How a date literal is spelled. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum DateFormat { - /// Quoted string. MSSQL still gets `N'…'` via [`Dialect::string_format`]. - Quoted, - /// `DATE`, a space, then a quoted literal. - Keyword, -} - -/// How a time literal is spelled. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum TimeFormat { - /// Quoted string. - Quoted, - /// `TIME`, a space, then a quoted literal. - Keyword, -} - -/// How a timestamp literal is spelled. -/// -/// Naive values use `TIMESTAMP` when a keyword is configured. Aware values -/// (`datetime` with a tzinfo, or a UTC epoch number) use the keyword this -/// variant names. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum TimestampFormat { - /// Both naive and aware values are a quoted string. No keyword. - Quoted, - /// Naive: `TIMESTAMP '…'`. Aware: `TIMESTAMPTZ '…'`. - Timestamptz, - /// Naive: `TIMESTAMP '…'`. Aware: `TIMESTAMP WITH TIMEZONE '…'`. - WithTimezone, -} - -/// How an inet literal is spelled. [`None`] on [`Dialect::inet_format`] means unsupported. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum InetFormat { - /// `'192.168.1.1/32'` - Quoted, - /// `'192.168.1.1/32'::inet` - Cast, -} - -/// How a MAC address literal is spelled. [`None`] on [`Dialect::macaddr_format`] means unsupported. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum MacAddrFormat { - /// `'01:23:45:67:89:AB'` - Quoted, - /// `'01:23:45:67:89:AB'::macaddr` - Cast, -} - -/// How a UUID literal is spelled. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum UuidFormat { - /// Quoted hyphenated text. - Quoted, - /// `UUID '…'` - UuidKeyword, - /// `'…'::uuid` - CastUuid, -} - -/// How a JSON literal is spelled. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum JsonFormat { - /// Quoted JSON text. - Quoted, - /// `JSON '…'` - JsonKeyword, - /// `PARSE_JSON('…')` - ParseJson, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Dialect { - /// Positional parameter syntax. - pub positional_parameter: PositionalParameter, - /// How the dialect accepts literal bit values if supported. - /// - /// Set [`Option::None`] if literal bit isn't supported. - pub bit_format: Option, - /// How the dialect accepts array values if supported. - pub array_format: Option, - /// How an inet literal is spelled. [`None`] means unsupported. - pub inet_format: Option, - /// How a MAC address literal is spelled. [`None`] means unsupported. - pub macaddr_format: Option, - /// How the dialect supports `{IGNORE|RESPECT} NULLS` expressions in `Function`s if supported. - pub null_treatment_syntax: Option, - /// How `LIKE ANY` / `ILIKE ANY` is rendered. - pub like_any_syntax: LikeAnySyntax, - /// How the dialect accepts literal boolean values. - pub bool_format: BoolFormat, - /// Specifies how delimiters should wrap around a quoted identifier. - pub identifier_format: IdentifierFormat, - /// Specifies how to escape identifiers. - pub identifier_escape: IdentifierEscape, - /// Specifies how delimiters should wrap around a literal string value. - pub string_format: StringFormat, - /// Specifies how to escape literal string value. - pub string_escape: StringEscape, - /// How the dialect accepts literal bytes values. - pub bytes_format: BytesFormat, - /// How a date literal is spelled. - pub date_format: DateFormat, - /// How a time literal is spelled. - pub time_format: TimeFormat, - /// How a timestamp literal is spelled. - pub timestamp_format: TimestampFormat, - /// How a UUID literal is spelled. - pub uuid_format: UuidFormat, - /// How a JSON literal is spelled. - pub json_format: JsonFormat, - /// How function calling `FILTER` keyword rendered if supported. - pub function_filter_syntax: Option, - /// How case-insensitive `LIKE` is spelled (`ILIKE`, `LIKE`, or `LOWER` + `LIKE`). - pub ilike_expression_options: ILikeExpressionOptions, - /// Whether `SIMILAR TO` is documented. - pub supports_similar_to: bool, - /// How regex `RLike` is spelled. [`None`] means unsupported. - pub rlike_expression_options: Option, - /// How `||` (string concat) is spelled. - pub string_concat_syntax: StringConcatSyntax, - /// How bitwise XOR (`^` in the AST) is spelled. [`None`] means unsupported. - pub bit_xor_syntax: Option, - /// How modulo (`%` in the AST) is spelled. - pub modulo_syntax: ModuloSyntax, - /// How `IS [NOT] DISTINCT FROM` is spelled. [`None`] means unsupported. - pub is_distinct_from_syntax: Option, - /// How a tuple value is spelled. [`None`] means unsupported. - pub tuple_syntax: Option, - /// Wrap `IN` / `BETWEEN` in parentheses. - /// - /// SQL Server ranks `AND` above `IN`/`BETWEEN`/`OR`, and those four share - /// a level evaluated left to right. The parentheses keep the predicate - /// from being parsed as part of the left operand. - pub parenthesize_in_between: bool, - /// How `TRY_CAST` is spelled. [`None`] means unsupported. - pub try_cast_syntax: Option, - /// Whether `expr::type` is documented. - pub supports_double_colon: bool, - /// How `TRIM` / `LTRIM` / `RTRIM` is spelled. - pub trim_syntax: TrimSyntax, - /// How `COLLATE` is spelled. [`None`] means unsupported. - pub collate_syntax: Option, - /// Where `LIKE` `ESCAPE` may be written. - pub like_escape_syntax: LikeEscapeSyntax, - /// How `&` `|` `~` `<<` `>>` are spelled. XOR stays on [`Self::bit_xor_syntax`]. - pub bitwise_syntax: BitwiseSyntax, -} - -impl Dialect { - /// Write a boolean literal (`TRUE`/`FALSE` or `1`/`0`). - #[inline] - pub fn format_boolean( - &self, - writer: &mut W, - value: bool, - ) -> Result<(), FormatError> { - match (self.bool_format, value) { - (BoolFormat::Int, false) => writer.write_str("0")?, - (BoolFormat::Int, true) => writer.write_str("1")?, - (BoolFormat::Keyword, false) => writer.write_str("FALSE")?, - (BoolFormat::Keyword, true) => writer.write_str("TRUE")?, - } - Ok(()) - } - - /// Write `string` with special characters neutralized, without wrapping it. - #[inline] - pub fn escape_string( - &self, - writer: &mut W, - string: &str, - ) -> Result<(), FormatError> { - self.string_escape.write(writer, string).map_err(Into::into) - } - - /// Escape `ident` and wrap it in this dialect's identifier delimiters. - #[inline] - pub fn quote_identifier(&self, writer: &mut W, ident: &str) -> fmt::Result { - let (start, end) = match self.identifier_format { - IdentifierFormat::DoubleQuote => ('"', '"'), - IdentifierFormat::Backtick => ('`', '`'), - IdentifierFormat::SquareBracket => ('[', ']'), - }; - writer.write_char(start)?; - - match self.identifier_escape { - IdentifierEscape::Double => match self.identifier_format { - IdentifierFormat::DoubleQuote => { - utils::replace_to(ident, '"', "\"\"", writer)?; - } - IdentifierFormat::Backtick => { - utils::replace_to(ident, '`', "``", writer)?; - } - IdentifierFormat::SquareBracket => { - utils::replace_to(ident, ']', "]]", writer)?; - } - }, - IdentifierEscape::Backslash => match self.identifier_format { - IdentifierFormat::DoubleQuote => { - utils::replace_to(ident, '"', "\\\"", writer)?; - } - IdentifierFormat::Backtick => { - write_bigquery_ident_body(writer, ident)?; - } - IdentifierFormat::SquareBracket => { - utils::replace_to(ident, ']', "\\]", writer)?; - } - }, - } - - writer.write_char(end) - } - - /// Escape `raw` and wrap it as a complete string literal. - #[inline] - pub fn write_string_literal( - &self, - writer: &mut W, - raw: &str, - ) -> Result<(), FormatError> { - if raw.contains('\0') { - return Err(FormatError::NulInString); - } - - match self.string_format { - StringFormat::PrefixBigE => writer.write_str("E'")?, - StringFormat::PrefixBigN => writer.write_str("N'")?, - StringFormat::SingleQuote => writer.write_char('\'')?, - } - self.escape_string(writer, raw)?; - writer.write_char('\'')?; - Ok(()) - } - - /// Write a bit-string (`'0'`/`'1'` characters) as a native literal. - /// - /// Native formats embed `bits` as-is. Callers must pass only `'0'` and `'1'`; - /// that is checked with `debug_assert!`. - #[inline] - pub fn format_bits( - &self, - writer: &mut W, - bits: &str, - ) -> Result<(), FormatError> { - debug_assert!( - bits.chars().all(|c| c == '0' || c == '1'), - "format_bits: invalid input: {bits}" - ); - match self.bit_format { - Some(format) => format.write(writer, bits).map_err(Into::into), - None => Err(FormatError::Unsupported("BitSerializer")), - } - } - - /// Write `bytes` as a native blob/bytea/varbyte literal. - #[inline] - pub fn format_bytes( - &self, - writer: &mut W, - bytes: &[u8], - ) -> Result<(), FormatError> { - self.bytes_format.write(writer, bytes).map_err(Into::into) - } -} - -const HEX_DIGITS: &[u8; 16] = b"0123456789ABCDEF"; - -/// Write `byte` as two uppercase hex digits. -#[inline] -fn write_hex2(writer: &mut W, byte: u8) -> fmt::Result { - let buf = [ - HEX_DIGITS[(byte >> 4) as usize], - HEX_DIGITS[(byte & 0x0F) as usize], - ]; - // SAFETY: HEX_DIGITS is ASCII, so `buf` is valid UTF-8. - writer.write_str(unsafe { std::str::from_utf8_unchecked(&buf) }) -} - -/// Write `bytes` as a contiguous uppercase hex run (`DEADBEEF`). -/// -/// 64 raw bytes fill a 128-byte stack buffer and are written once. -#[inline] -fn bytes_to_hex(writer: &mut W, bytes: &[u8]) -> fmt::Result { - let mut buf = [0u8; 128]; - for chunk in bytes.chunks(64) { - for (i, &byte) in chunk.iter().enumerate() { - let o = i * 2; - buf[o] = HEX_DIGITS[(byte >> 4) as usize]; - buf[o + 1] = HEX_DIGITS[(byte & 0x0F) as usize]; - } - let n = chunk.len() * 2; - // SAFETY: `buf[..n]` is filled only from `HEX_DIGITS` (ASCII). - writer.write_str(unsafe { std::str::from_utf8_unchecked(&buf[..n]) })?; - } - Ok(()) -} - -/// Write each byte as `\xNN` (uppercase hex). -/// -/// 32 raw bytes fill a 128-byte stack buffer and are written once. -#[inline] -fn write_bytes_slash_x(writer: &mut W, bytes: &[u8]) -> fmt::Result { - let mut buf = [0u8; 128]; - for chunk in bytes.chunks(32) { - for (i, &byte) in chunk.iter().enumerate() { - let o = i * 4; - buf[o] = b'\\'; - buf[o + 1] = b'x'; - buf[o + 2] = HEX_DIGITS[(byte >> 4) as usize]; - buf[o + 3] = HEX_DIGITS[(byte & 0x0F) as usize]; - } - let n = chunk.len() * 4; - // SAFETY: `buf[..n]` is ASCII (`\`, `x`, and hex digits). - writer.write_str(unsafe { std::str::from_utf8_unchecked(&buf[..n]) })?; - } - Ok(()) -} - -/// Write `\xNN` for a single byte (uppercase hex). -#[inline] -fn write_byte_hex_escape(writer: &mut W, byte: u8) -> fmt::Result { - writer.write_str("\\x")?; - write_hex2(writer, byte) -} - -/// Write `\uNNNN` for a codepoint that fits in 16 bits. -#[inline] -fn write_unicode4_escape(writer: &mut W, code: u32) -> fmt::Result { - debug_assert!(code <= 0xFFFF); - writer.write_str("\\u")?; - write_hex2(writer, (code >> 8) as u8)?; - write_hex2(writer, code as u8) -} - -/// Write `bytes[from..to]` as a string. -/// -/// # Safety invariant -/// `bytes` is the raw view of a `&str`. Callers split only on ASCII bytes, -/// which are char boundaries, or at `bytes.len()`. -#[inline] -fn write_byte_span(writer: &mut W, bytes: &[u8], from: usize, to: usize) -> fmt::Result { - if from == to { - return Ok(()); - } - // SAFETY: see invariant above. - let chunk = unsafe { bytes.get_unchecked(from..to) }; - writer.write_str(unsafe { std::str::from_utf8_unchecked(chunk) }) -} - -enum SpanEscape { - Keep, - Literal(&'static str), - HexByte, - Unicode4, -} - -/// Copy each plain span with one `write_str`. Special bytes are ASCII. -#[cfg_attr(feature = "nightly", optimize(speed))] -fn write_span_escapes( - writer: &mut W, - string: &str, - mut classify: impl FnMut(u8) -> SpanEscape, -) -> fmt::Result { - let bytes = string.as_bytes(); - let mut start = 0; - let mut i = 0; - while i < bytes.len() { - match classify(bytes[i]) { - SpanEscape::Keep => i += 1, - SpanEscape::Literal(repl) => { - write_byte_span(writer, bytes, start, i)?; - writer.write_str(repl)?; - i += 1; - start = i; - } - SpanEscape::HexByte => { - write_byte_span(writer, bytes, start, i)?; - write_byte_hex_escape(writer, bytes[i])?; - i += 1; - start = i; - } - SpanEscape::Unicode4 => { - write_byte_span(writer, bytes, start, i)?; - write_unicode4_escape(writer, bytes[i] as u32)?; - i += 1; - start = i; - } - } - } - write_byte_span(writer, bytes, start, bytes.len()) -} - -/// BigQuery quoted identifiers use the string escape table, plus `\``. -/// -/// `'` is left as itself. Only known escapes are emitted. -fn write_bigquery_ident_body(writer: &mut W, ident: &str) -> fmt::Result { - write_span_escapes(writer, ident, |b| match b { - b'\\' => SpanEscape::Literal("\\\\"), - b'`' => SpanEscape::Literal("\\`"), - b'\n' => SpanEscape::Literal("\\n"), - b'\r' => SpanEscape::Literal("\\r"), - b'\t' => SpanEscape::Literal("\\t"), - b if b < 32 || b == 127 => SpanEscape::HexByte, - _ => SpanEscape::Keep, - }) -} - -fn write_backslash_hex(writer: &mut W, string: &str) -> fmt::Result { - write_span_escapes(writer, string, |b| match b { - b'\\' => SpanEscape::Literal("\\\\"), - b'\'' => SpanEscape::Literal("\\'"), - b'\n' => SpanEscape::Literal("\\n"), - b'\r' => SpanEscape::Literal("\\r"), - b'\t' => SpanEscape::Literal("\\t"), - b if b < 32 || b == 127 => SpanEscape::HexByte, - _ => SpanEscape::Keep, - }) -} - -fn write_backslash_mysql(writer: &mut W, string: &str) -> fmt::Result { - write_span_escapes(writer, string, |b| match b { - b'\\' => SpanEscape::Literal("\\\\"), - b'\'' => SpanEscape::Literal("\\'"), - b'"' => SpanEscape::Literal("\\\""), - 0 => SpanEscape::Literal("\\0"), - b'\n' => SpanEscape::Literal("\\n"), - b'\r' => SpanEscape::Literal("\\r"), - 0x1a => SpanEscape::Literal("\\Z"), - _ => SpanEscape::Keep, - }) -} - -fn write_backslash_unicode( - writer: &mut W, - string: &str, - escape_double_quote: bool, -) -> fmt::Result { - write_span_escapes(writer, string, |b| match b { - b'\\' => SpanEscape::Literal("\\\\"), - b'\'' => SpanEscape::Literal("\\'"), - b'"' if escape_double_quote => SpanEscape::Literal("\\\""), - b'\n' => SpanEscape::Literal("\\n"), - b'\r' => SpanEscape::Literal("\\r"), - b'\t' => SpanEscape::Literal("\\t"), - 0 => SpanEscape::Literal("\\0"), - b if b < 32 || b == 127 => SpanEscape::Unicode4, - _ => SpanEscape::Keep, - }) -} - -fn write_backslash_hex_zero(writer: &mut W, string: &str) -> fmt::Result { - write_span_escapes(writer, string, |b| match b { - b'\\' => SpanEscape::Literal("\\\\"), - b'\'' => SpanEscape::Literal("\\'"), - b'\n' => SpanEscape::Literal("\\n"), - b'\r' => SpanEscape::Literal("\\r"), - b'\t' => SpanEscape::Literal("\\t"), - 0 => SpanEscape::Literal("\\0"), - b if b < 32 || b == 127 => SpanEscape::HexByte, - _ => SpanEscape::Keep, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn collect_fmt(write: impl FnOnce(&mut String) -> fmt::Result) -> String { - let mut buf = String::new(); - write(&mut buf).unwrap(); - buf - } - - fn collect(write: impl FnOnce(&mut String) -> Result<(), FormatError>) -> String { - let mut buf = String::new(); - write(&mut buf).unwrap(); - buf - } - - fn escape(kind: StringEscape, string: &str) -> String { - collect_fmt(|buf| kind.write(buf, string)) - } - - fn bytes(kind: BytesFormat, bytes: &[u8]) -> String { - collect_fmt(|buf| kind.write(buf, bytes)) - } - - fn base() -> Dialect { - Dialect { - positional_parameter: PositionalParameter::Simple(':'), - bit_format: None, - array_format: None, - inet_format: None, - macaddr_format: None, - null_treatment_syntax: Some(NullTreatmentSyntax::Postfix), - like_any_syntax: LikeAnySyntax::OrExpansion, - bool_format: BoolFormat::Keyword, - identifier_format: IdentifierFormat::DoubleQuote, - identifier_escape: IdentifierEscape::Backslash, - string_format: StringFormat::SingleQuote, - string_escape: StringEscape::Quote, - bytes_format: BytesFormat::PrefixBigX, - date_format: DateFormat::Quoted, - time_format: TimeFormat::Quoted, - timestamp_format: TimestampFormat::Quoted, - uuid_format: UuidFormat::Quoted, - json_format: JsonFormat::Quoted, - function_filter_syntax: None, - ilike_expression_options: ILikeExpressionOptions::UseLike, - supports_similar_to: false, - rlike_expression_options: None, - string_concat_syntax: StringConcatSyntax::Pipes, - bit_xor_syntax: None, - modulo_syntax: ModuloSyntax::Percent, - is_distinct_from_syntax: None, - tuple_syntax: None, - parenthesize_in_between: false, - try_cast_syntax: None, - supports_double_colon: false, - trim_syntax: TrimSyntax::SqlFrom, - collate_syntax: None, - like_escape_syntax: LikeEscapeSyntax::None, - bitwise_syntax: BitwiseSyntax::None, - } - } - - #[test] - fn quote_doubles_apostrophe() { - assert_eq!(escape(StringEscape::Quote, "O'Brien"), "O''Brien"); - assert_eq!(escape(StringEscape::Quote, "'"), "''"); - assert_eq!(escape(StringEscape::Quote, "a\0b"), "a\0b"); - } - - #[test] - fn backslash_variants() { - let cases: [(StringEscape, &str, &str); 40] = [ - (StringEscape::BackslashHex, "'", "\\'"), - (StringEscape::BackslashHex, "\\", "\\\\"), - (StringEscape::BackslashHex, "\n", "\\n"), - (StringEscape::BackslashHex, "\t", "\\t"), - (StringEscape::BackslashHex, "\0", "\\x00"), - (StringEscape::BackslashHex, "\"", "\""), - (StringEscape::BackslashHex, "\x1a", "\\x1A"), - (StringEscape::BackslashHex, "\x01", "\\x01"), - (StringEscape::BackslashMysql, "'", "\\'"), - (StringEscape::BackslashMysql, "\\", "\\\\"), - (StringEscape::BackslashMysql, "\n", "\\n"), - (StringEscape::BackslashMysql, "\t", "\t"), - (StringEscape::BackslashMysql, "\0", "\\0"), - (StringEscape::BackslashMysql, "\"", "\\\""), - (StringEscape::BackslashMysql, "\x1a", "\\Z"), - (StringEscape::BackslashMysql, "\x01", "\x01"), - (StringEscape::BackslashUnicode, "'", "\\'"), - (StringEscape::BackslashUnicode, "\\", "\\\\"), - (StringEscape::BackslashUnicode, "\n", "\\n"), - (StringEscape::BackslashUnicode, "\t", "\\t"), - (StringEscape::BackslashUnicode, "\0", "\\0"), - (StringEscape::BackslashUnicode, "\"", "\""), - (StringEscape::BackslashUnicode, "\x1a", "\\u001A"), - (StringEscape::BackslashUnicode, "\x01", "\\u0001"), - (StringEscape::BackslashHexZero, "'", "\\'"), - (StringEscape::BackslashHexZero, "\\", "\\\\"), - (StringEscape::BackslashHexZero, "\n", "\\n"), - (StringEscape::BackslashHexZero, "\t", "\\t"), - (StringEscape::BackslashHexZero, "\0", "\\0"), - (StringEscape::BackslashHexZero, "\"", "\""), - (StringEscape::BackslashHexZero, "\x1a", "\\x1A"), - (StringEscape::BackslashHexZero, "\x01", "\\x01"), - (StringEscape::BackslashUnicodeQuote, "'", "\\'"), - (StringEscape::BackslashUnicodeQuote, "\\", "\\\\"), - (StringEscape::BackslashUnicodeQuote, "\n", "\\n"), - (StringEscape::BackslashUnicodeQuote, "\t", "\\t"), - (StringEscape::BackslashUnicodeQuote, "\0", "\\0"), - (StringEscape::BackslashUnicodeQuote, "\"", "\\\""), - (StringEscape::BackslashUnicodeQuote, "\x1a", "\\u001A"), - (StringEscape::BackslashUnicodeQuote, "\x01", "\\u0001"), - ]; - for (kind, input, expected) in cases { - assert_eq!( - escape(kind, input), - expected, - "{kind:?} escaped {input:?} incorrectly" - ); - } - } - - #[test] - fn bytes_format_wrappers() { - let empty: &[u8] = &[]; - let zero: &[u8] = &[0x00]; - let dead: &[u8] = &[0xDE, 0xAD]; - - assert_eq!(bytes(BytesFormat::PrefixBigX, empty), "X''"); - assert_eq!(bytes(BytesFormat::PrefixBigX, zero), "X'00'"); - assert_eq!(bytes(BytesFormat::PrefixBigX, dead), "X'DEAD'"); - - assert_eq!(bytes(BytesFormat::PrefixZeroX, empty), "0x"); - assert_eq!(bytes(BytesFormat::PrefixZeroX, zero), "0x00"); - assert_eq!(bytes(BytesFormat::PrefixZeroX, dead), "0xDEAD"); - - assert_eq!(bytes(BytesFormat::SlashXBytea, empty), "'\\x'::bytea"); - assert_eq!(bytes(BytesFormat::SlashXBytea, zero), "'\\x00'::bytea"); - assert_eq!(bytes(BytesFormat::SlashXBytea, dead), "'\\xDEAD'::bytea"); - - assert_eq!(bytes(BytesFormat::PrefixBigBSlashX, empty), "B''"); - assert_eq!(bytes(BytesFormat::PrefixBigBSlashX, zero), "B'\\x00'"); - assert_eq!(bytes(BytesFormat::PrefixBigBSlashX, dead), "B'\\xDE\\xAD'"); - - assert_eq!(bytes(BytesFormat::HexToRaw, empty), "HEXTORAW('')"); - assert_eq!(bytes(BytesFormat::HexToRaw, zero), "HEXTORAW('00')"); - assert_eq!(bytes(BytesFormat::HexToRaw, dead), "HEXTORAW('DEAD')"); - - assert_eq!(bytes(BytesFormat::Unhex, empty), "UNHEX('')"); - assert_eq!(bytes(BytesFormat::Unhex, zero), "UNHEX('00')"); - assert_eq!(bytes(BytesFormat::Unhex, dead), "UNHEX('DEAD')"); - - assert_eq!( - bytes(BytesFormat::ToVarbyte, empty), - "TO_VARBYTE('', 'hex')" - ); - assert_eq!( - bytes(BytesFormat::ToVarbyte, zero), - "TO_VARBYTE('00', 'hex')" - ); - assert_eq!( - bytes(BytesFormat::ToVarbyte, dead), - "TO_VARBYTE('DEAD', 'hex')" - ); - - assert_eq!(bytes(BytesFormat::PostfixXbv, empty), "''XBV"); - assert_eq!(bytes(BytesFormat::PostfixXbv, zero), "'00'XBV"); - assert_eq!(bytes(BytesFormat::PostfixXbv, dead), "'DEAD'XBV"); - - assert_eq!(bytes(BytesFormat::ToBinary, empty), "TO_BINARY('', 'HEX')"); - assert_eq!(bytes(BytesFormat::ToBinary, zero), "TO_BINARY('00', 'HEX')"); - assert_eq!( - bytes(BytesFormat::ToBinary, dead), - "TO_BINARY('DEAD', 'HEX')" - ); - - assert_eq!(bytes(BytesFormat::SlashXBlob, empty), "''::BLOB"); - assert_eq!(bytes(BytesFormat::SlashXBlob, zero), "'\\x00'::BLOB"); - assert_eq!(bytes(BytesFormat::SlashXBlob, dead), "'\\xDE\\xAD'::BLOB"); - } - - #[test] - fn bit_format_literals() { - let bits = "10110101"; - assert_eq!( - collect_fmt(|buf| BitFormat::PrefixBigB.write(buf, bits)), - "B'10110101'" - ); - assert_eq!( - collect_fmt(|buf| BitFormat::PrefixSmallB.write(buf, bits)), - "b'10110101'" - ); - assert_eq!( - collect_fmt(|buf| BitFormat::PostfixBigB.write(buf, bits)), - "'10110101'B" - ); - - let mut cfg = base(); - cfg.bit_format = Some(BitFormat::PrefixBigB); - assert_eq!(collect(|buf| cfg.format_bits(buf, bits)), "B'10110101'"); - assert_eq!( - collect(|buf| cfg.format_bytes(buf, &[0xDE, 0xAD])), - "X'DEAD'" - ); - } - - #[test] - fn format_bits_errors_when_unsupported() { - let cfg = base(); - assert_eq!( - cfg.format_bits(&mut String::new(), "1"), - Err(FormatError::Unsupported("BitSerializer")) - ); - } - - #[test] - fn write_string_literal_rejects_nul() { - let cfg = base(); - assert_eq!( - cfg.write_string_literal(&mut String::new(), "a\0b"), - Err(FormatError::NulInString) - ); - } - - #[test] - fn write_string_literal_prefix_big_e_when_needed() { - let mut cfg = base(); - cfg.string_format = StringFormat::PrefixBigE; - cfg.string_escape = StringEscape::BackslashHex; - assert_eq!( - collect(|buf| cfg.write_string_literal(buf, "hello")), - "E'hello'" - ); - assert_eq!( - collect(|buf| cfg.write_string_literal(buf, "O'Brien")), - "E'O\\'Brien'" - ); - assert_eq!( - collect(|buf| cfg.write_string_literal(buf, "hello'world\n")), - "E'hello\\'world\\n'" - ); - } - - struct CountWrites { - buf: String, - strs: usize, - chars: usize, - } - - impl fmt::Write for CountWrites { - fn write_str(&mut self, s: &str) -> fmt::Result { - self.strs += 1; - self.buf.push_str(s); - Ok(()) - } - - fn write_char(&mut self, c: char) -> fmt::Result { - self.chars += 1; - self.buf.push(c); - Ok(()) - } - } - - #[test] - fn backslash_plain_string_is_one_write_str() { - for kind in [ - StringEscape::BackslashHex, - StringEscape::BackslashMysql, - StringEscape::BackslashUnicode, - StringEscape::BackslashHexZero, - StringEscape::BackslashUnicodeQuote, - ] { - let mut out = CountWrites { - buf: String::new(), - strs: 0, - chars: 0, - }; - kind.write(&mut out, "plain text").unwrap(); - assert_eq!(out.strs, 1, "{kind:?}"); - assert_eq!(out.chars, 0, "{kind:?}"); - assert_eq!(out.buf, "plain text"); - - let mut mixed = CountWrites { - buf: String::new(), - strs: 0, - chars: 0, - }; - kind.write(&mut mixed, "a'b\\c").unwrap(); - assert_eq!(mixed.chars, 0, "{kind:?} wrote a char at a time"); - assert_eq!(mixed.buf, escape(kind, "a'b\\c")); - } - } - - fn quote_bigquery(name: &str) -> String { - let mut cfg = base(); - cfg.identifier_format = IdentifierFormat::Backtick; - cfg.identifier_escape = IdentifierEscape::Backslash; - collect_fmt(|buf| cfg.quote_identifier(buf, name)) - } - - #[test] - fn bigquery_identifier_escapes_backslash() { - assert_eq!(quote_bigquery(r"a\b"), r"`a\\b`"); - assert_eq!(quote_bigquery("a\\`b"), r"`a\\\`b`"); - assert_eq!(quote_bigquery(r"a\"), r"`a\\`"); - assert_eq!(quote_bigquery("a\nb"), r"`a\nb`"); - assert_eq!(quote_bigquery("a\rb"), r"`a\rb`"); - assert_eq!(quote_bigquery("a\tb"), r"`a\tb`"); - assert_eq!(quote_bigquery("\u{1}"), r"`\x01`"); - assert_eq!(quote_bigquery("\0"), r"`\x00`"); - assert_eq!(quote_bigquery("\u{7f}"), r"`\x7F`"); - assert_eq!(quote_bigquery("a'b"), "`a'b`"); - assert_eq!(quote_bigquery("`"), r"`\``"); - - let mut out = CountWrites { - buf: String::new(), - strs: 0, - chars: 0, - }; - let mut cfg = base(); - cfg.identifier_format = IdentifierFormat::Backtick; - cfg.identifier_escape = IdentifierEscape::Backslash; - cfg.quote_identifier(&mut out, "plain").unwrap(); - assert_eq!(out.buf, "`plain`"); - assert_eq!(out.strs, 1); - assert_eq!(out.chars, 2); - } - - #[test] - fn bytes_longer_than_one_chunk() { - let long: Vec = (0..80).map(|i| (i * 3) as u8).collect(); - let hex: String = long.iter().map(|b| format!("{b:02X}")).collect(); - let xnn: String = long.iter().map(|b| format!("\\x{b:02X}")).collect(); - assert_eq!(bytes(BytesFormat::PrefixBigX, &long), format!("X'{hex}'")); - assert_eq!(bytes(BytesFormat::PrefixZeroX, &long), format!("0x{hex}")); - assert_eq!( - bytes(BytesFormat::PrefixBigBSlashX, &long), - format!("B'{xnn}'") - ); - assert_eq!( - bytes(BytesFormat::SlashXBlob, &long), - format!("'{xnn}'::BLOB") - ); - assert_eq!( - bytes(BytesFormat::SlashXBytea, &long), - format!("'\\x{hex}'::bytea") - ); - } -} diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 6159aee..9c682e9 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1,52 +1,78 @@ +pub mod errors; +pub mod flags; + mod base; mod builtin; -pub mod inner; - -// TODO: rewrite GenericDialect again - -pub use base::PySqlDialect; -pub use builtin::PyBigQuery; -pub use builtin::PyDatabricks; -pub use builtin::PyDuckDb; -pub use builtin::PyHive; -pub use builtin::PyMsSql; -pub use builtin::PyMySql; -pub use builtin::PyOracle; -pub use builtin::PyPostgreSql; -pub use builtin::PyRedshift; -pub use builtin::PySnowflake; -pub use builtin::PySpark; -pub use builtin::PySqlite; -pub use builtin::PyTeradata; + +pub use base::PyDialect; +pub use builtin::BIGQUERY_DIALECT; +pub use builtin::DATABRICKS_DIALECT; +pub use builtin::DUCKDB_DIALECT; +pub use builtin::HIVE_DIALECT; +pub use builtin::MSSQL_DIALECT; +pub use builtin::MYSQL_DIALECT; +pub use builtin::ORACLE_DIALECT; +pub use builtin::POSTGRESQL_DIALECT; +pub use builtin::REDSHIFT_DIALECT; +pub use builtin::SNOWFLAKE_DIALECT; +pub use builtin::SPARK_DIALECT; +pub use builtin::SQLITE_DIALECT; +pub use builtin::TERADATA_DIALECT; #[pyo3::pymodule(name = "dialect", gil_used = false)] pub mod dialect_mod { #[pymodule_export] - use super::PyBigQuery; + use super::PyDialect; + #[pymodule_export] - use super::PyDatabricks; + #[allow(non_upper_case_globals)] + const BigQuery: super::PyDialect = super::BIGQUERY_DIALECT; + #[pymodule_export] - use super::PyDuckDb; + #[allow(non_upper_case_globals)] + const Databricks: super::PyDialect = super::DATABRICKS_DIALECT; + #[pymodule_export] - use super::PyHive; + #[allow(non_upper_case_globals)] + const DuckDb: super::PyDialect = super::DUCKDB_DIALECT; + #[pymodule_export] - use super::PyMsSql; + #[allow(non_upper_case_globals)] + const Hive: super::PyDialect = super::HIVE_DIALECT; + #[pymodule_export] - use super::PyMySql; + #[allow(non_upper_case_globals)] + const MsSql: super::PyDialect = super::MSSQL_DIALECT; + #[pymodule_export] - use super::PyOracle; + #[allow(non_upper_case_globals)] + const MySql: super::PyDialect = super::MYSQL_DIALECT; + #[pymodule_export] - use super::PyPostgreSql; + #[allow(non_upper_case_globals)] + const Oracle: super::PyDialect = super::ORACLE_DIALECT; + #[pymodule_export] - use super::PyRedshift; + #[allow(non_upper_case_globals)] + const PostgreSql: super::PyDialect = super::POSTGRESQL_DIALECT; + #[pymodule_export] - use super::PySnowflake; + #[allow(non_upper_case_globals)] + const Redshift: super::PyDialect = super::REDSHIFT_DIALECT; + #[pymodule_export] - use super::PySpark; + #[allow(non_upper_case_globals)] + const Snowflake: super::PyDialect = super::SNOWFLAKE_DIALECT; + #[pymodule_export] - use super::PySqlDialect; + #[allow(non_upper_case_globals)] + const Spark: super::PyDialect = super::SPARK_DIALECT; + #[pymodule_export] - use super::PySqlite; + #[allow(non_upper_case_globals)] + const Sqlite: super::PyDialect = super::SQLITE_DIALECT; + #[pymodule_export] - use super::PyTeradata; + #[allow(non_upper_case_globals)] + const Teradata: super::PyDialect = super::TERADATA_DIALECT; } diff --git a/src/expr/base.rs b/src/expr/base.rs index f53d01b..e89a98f 100644 --- a/src/expr/base.rs +++ b/src/expr/base.rs @@ -81,7 +81,7 @@ impl PyExpr { fn prepare<'py>( slf: pyo3::PyRef<'py, Self>, py: pyo3::Python<'py>, - dialect: crate::dialect::PySqlDialect, + dialect: crate::dialect::PyDialect, ) -> pyo3::PyResult<(String, alias::BoundObject<'py>)> { let (sql, params) = sql::prepare(py, &slf.0, &dialect)?; @@ -99,7 +99,7 @@ impl PyExpr { fn to_sql( slf: pyo3::PyRef<'_, Self>, py: pyo3::Python<'_>, - dialect: crate::dialect::PySqlDialect, + dialect: crate::dialect::PyDialect, ) -> pyo3::PyResult { sql::write_sql(py, &slf.0, &dialect).map_err(Into::into) } diff --git a/src/expr/enum_/func.rs b/src/expr/enum_/func.rs index 247c016..6fe2851 100644 --- a/src/expr/enum_/func.rs +++ b/src/expr/enum_/func.rs @@ -1,8 +1,7 @@ use super::ExprInner; use super::ident::Ident; -use crate::dialect::inner::FunctionFilterSyntax; -use crate::dialect::inner::NullTreatmentSyntax; +use crate::dialect::flags; use crate::sql::PrepareResult; use crate::sql::SqlWriter; use crate::sql::WriteSql; @@ -80,7 +79,7 @@ impl Function { impl WriteSql for Function { fn write_sql( &self, - dialect: &crate::dialect::PySqlDialect, + dialect: &crate::dialect::PyDialect, out: &mut W, ) -> PrepareResult<()> { // "name"([DISTINCT] args [IGNORE|RESPECT NULLS] [WHERE expr]) @@ -94,14 +93,21 @@ impl WriteSql for Function { write_separated(dialect, out, &self.args, ", ")?; - if self.null_treatment.is_some() && dialect.0.null_treatment_syntax.is_none() { + let null_treatment_syntax = dialect.0.null_treatment(); + let function_filter_syntax = dialect.0.function_filter(); + + if self.null_treatment.is_some() + && matches!(null_treatment_syntax, flags::NullTreatment::Unsupported) + { return Err(rapidquery_macros::pyerr!( PyRuntimeError, "null_treatment is not supported by this dialect", ) .into()); } - if self.filter.is_some() && dialect.0.function_filter_syntax.is_none() { + if self.filter.is_some() + && matches!(function_filter_syntax, flags::FunctionFilter::Unsupported) + { return Err(rapidquery_macros::pyerr!( PyRuntimeError, "function filter is not supported by this dialect", @@ -111,10 +117,7 @@ impl WriteSql for Function { // Inside parens IGNORE/RESPECT NULLS if let Some(x) = self.null_treatment - && matches!( - dialect.0.null_treatment_syntax, - Some(NullTreatmentSyntax::InsideParens) - ) + && matches!(null_treatment_syntax, flags::NullTreatment::InsideParens) { out.push_char_if(args_available, ' '); out.push_str(x.as_sql()); @@ -122,10 +125,7 @@ impl WriteSql for Function { // Inside parens WHERE filter if let Some(x) = &self.filter - && matches!( - dialect.0.function_filter_syntax, - Some(FunctionFilterSyntax::InsideWhere) - ) + && matches!(function_filter_syntax, flags::FunctionFilter::InsideWhere) { out.push_char_if(args_available, ' '); out.push_str("WHERE "); @@ -136,14 +136,14 @@ impl WriteSql for Function { // FILTER, then postfix IGNORE/RESPECT NULLS (immediately before OVER). if let Some(x) = &self.filter { - match dialect.0.function_filter_syntax { - Some(FunctionFilterSyntax::FilterWhere) => { + match function_filter_syntax { + flags::FunctionFilter::FilterWhere => { out.push_char(' '); out.push_str("FILTER (WHERE "); x.write_sql(dialect, out)?; out.push_char(')'); } - Some(FunctionFilterSyntax::Filter) => { + flags::FunctionFilter::Filter => { out.push_char(' '); out.push_str("FILTER ("); x.write_sql(dialect, out)?; @@ -154,10 +154,7 @@ impl WriteSql for Function { } if let Some(x) = self.null_treatment - && matches!( - dialect.0.null_treatment_syntax, - Some(NullTreatmentSyntax::Postfix) - ) + && matches!(null_treatment_syntax, flags::NullTreatment::Postfix) { out.push_char(' '); out.push_str(x.as_sql()); diff --git a/src/expr/enum_/ident.rs b/src/expr/enum_/ident.rs index 027e519..b00adaf 100644 --- a/src/expr/enum_/ident.rs +++ b/src/expr/enum_/ident.rs @@ -1,6 +1,6 @@ //! SQL identifiers. -use crate::dialect::PySqlDialect; +use crate::dialect::PyDialect; use crate::sql::PrepareResult; use crate::sql::SqlWriter; use crate::sql::WriteSql; @@ -63,7 +63,7 @@ impl> From for IdentPart { impl WriteSql for IdentPart { #[inline] - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { match self { Self::Quote(name) => { dialect.0.quote_identifier(out, name.as_str())?; @@ -251,7 +251,7 @@ impl std::str::FromStr for Ident { impl WriteSql for Ident { #[inline] - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { write_separated(dialect, out, &self.0, ".") } } diff --git a/src/expr/enum_/like.rs b/src/expr/enum_/like.rs index 125206e..c99cae7 100644 --- a/src/expr/enum_/like.rs +++ b/src/expr/enum_/like.rs @@ -1,10 +1,8 @@ //! `LIKE` / `ILIKE` / `LIKE ANY` / `SIMILAR TO` / `RLIKE` AST nodes. use super::ExprInner; -use crate::dialect::PySqlDialect; -use crate::dialect::inner::LikeAnySyntax; -use crate::dialect::inner::LikeEscapeSyntax; -use crate::dialect::inner::RLikeExpressionOptions; +use crate::dialect::PyDialect; +use crate::dialect::flags; use crate::sql::PrepareResult; use crate::sql::SqlWriter; use crate::sql::WriteSql; @@ -12,7 +10,7 @@ use crate::sql::write_separated; use std::fmt::Write; pub(super) fn write_escape( - dialect: &PySqlDialect, + dialect: &PyDialect, out: &mut W, escape: Option, ) -> PrepareResult<()> { @@ -62,9 +60,9 @@ impl LikePattern { /// `case_sensitive == false` follows the dialect's `ilike_expression_options` /// (`ILIKE`, plain `LIKE`, or `LOWER(expr) LIKE LOWER(pattern)`). `ESCAPE` is /// a single inlined character (never a bind). A set escape character raises -/// when [`LikeEscapeSyntax`] does not allow it on that form. +/// when [`flags::LikeEscape`] does not allow it on that form. /// -/// LIKE ANY follows [`LikeAnySyntax`]: Postgres `ANY (ARRAY[…])` wraps +/// LIKE ANY follows [`flags::LikeAny`]: Postgres `ANY (ARRAY[…])` wraps /// negation as `NOT (x LIKE ANY …)` because `NOT LIKE ANY` is a quantified /// NOT LIKE; Snowflake/Spark/Databricks use `LIKE ANY (p, …)`; everyone else /// expands to parenthesized `OR` of scalar `LIKE`s (`AND` of `NOT LIKE` when @@ -104,7 +102,7 @@ impl Like { } fn write_like_operand( - dialect: &PySqlDialect, + dialect: &PyDialect, out: &mut W, expr: &ExprInner, wrap_lower: bool, @@ -134,7 +132,7 @@ fn write_like_infix(out: &mut W, negated: bool, ilike: bool) { } fn write_like_pattern_list( - dialect: &PySqlDialect, + dialect: &PyDialect, out: &mut W, patterns: &[ExprInner], wrap_lower: bool, @@ -155,17 +153,17 @@ fn write_like_pattern_list( /// `ESCAPE` is legal on this form. `native_any` is `LIKE ANY` that is not an /// `OR` expansion. #[inline] -fn like_escape_allowed(syntax: LikeEscapeSyntax, native_any: bool) -> bool { +fn like_escape_allowed(syntax: flags::LikeEscape, native_any: bool) -> bool { match syntax { - LikeEscapeSyntax::None => false, - LikeEscapeSyntax::Scalar => !native_any, - LikeEscapeSyntax::IncludingAny => true, + flags::LikeEscape::Unsupported => false, + flags::LikeEscape::Scalar => !native_any, + flags::LikeEscape::IncludingAny => true, } } #[inline] fn ensure_like_escape_is_supported( - syntax: LikeEscapeSyntax, + syntax: flags::LikeEscape, escape: Option, native_any: bool, ) -> PrepareResult<()> { @@ -186,7 +184,7 @@ fn ensure_like_escape_is_supported( /// `[NOT] (x LIKE|ILIKE ANY (ARRAY?[…]) [ESCAPE char])` fn write_like_any_native( - dialect: &PySqlDialect, + dialect: &PyDialect, out: &mut W, like: &Like, patterns: &[ExprInner], @@ -219,18 +217,22 @@ fn write_like_any_native( } impl WriteSql for Like { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { let (ilike, wrap_lower) = if self.case_sensitive { (false, false) } else { - dialect.0.ilike_expression_options.case_insensitive_sql() + match dialect.0.ilike_expression() { + flags::ILikeExpression::Standard => (true, false), + flags::ILikeExpression::UseLike => (false, false), + flags::ILikeExpression::UseLikeLower => (false, true), + } }; ensure_like_escape_is_supported( - dialect.0.like_escape_syntax, + dialect.0.like_escape(), self.escape, matches!(self.pattern, LikePattern::Any(_)) - && !matches!(dialect.0.like_any_syntax, LikeAnySyntax::OrExpansion), + && !matches!(dialect.0.like_any(), flags::LikeAny::OrExpansion), )?; match &self.pattern { @@ -240,14 +242,14 @@ impl WriteSql for Like { write_like_operand(dialect, out, pattern, wrap_lower, true)?; write_escape(dialect, out, self.escape) } - LikePattern::Any(patterns) => match dialect.0.like_any_syntax { - LikeAnySyntax::ArrayConstructor => { + LikePattern::Any(patterns) => match dialect.0.like_any() { + flags::LikeAny::ArrayConstructor => { write_like_any_native(dialect, out, self, patterns, ilike, wrap_lower, true) } - LikeAnySyntax::PatternList => { + flags::LikeAny::PatternList => { write_like_any_native(dialect, out, self, patterns, ilike, wrap_lower, false) } - LikeAnySyntax::OrExpansion => { + flags::LikeAny::OrExpansion => { let sep = if self.negated { " AND " } else { " OR " }; out.push_char('('); for (i, pattern) in patterns.iter().enumerate() { @@ -301,8 +303,8 @@ impl SimilarTo { } impl WriteSql for SimilarTo { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { - if !dialect.0.supports_similar_to { + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { + if !dialect.0.supports_similar_to() { return Err(rapidquery_macros::pyerr!( PyRuntimeError, "SIMILAR TO is not supported by this dialect", @@ -364,7 +366,7 @@ impl RLike { /// `NOT? name(expr, pattern)` — unquoted function rewrite. fn write_regex_call( - dialect: &PySqlDialect, + dialect: &PyDialect, out: &mut W, name: &str, negated: bool, @@ -384,17 +386,16 @@ fn write_regex_call( } impl WriteSql for RLike { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { - let Some(opt) = dialect.0.rlike_expression_options else { - return Err(rapidquery_macros::pyerr!( - PyRuntimeError, - "RLIKE is not supported by this dialect", - ) - .into()); - }; - - match opt { - RLikeExpressionOptions::InfixRLike => { + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { + match dialect.0.rlike_expression() { + flags::RLikeExpression::Unsupported => { + return Err(rapidquery_macros::pyerr!( + PyRuntimeError, + "RLIKE is not supported by this dialect", + ) + .into()); + } + flags::RLikeExpression::Standard => { self.expr .write_operand(dialect, out, Self::PRECEDENCE, false)?; if self.negated { @@ -405,14 +406,14 @@ impl WriteSql for RLike { self.pattern .write_operand(dialect, out, Self::PRECEDENCE, true) } - RLikeExpressionOptions::Tilde => { + flags::RLikeExpression::Tilde => { self.expr .write_operand(dialect, out, Self::PRECEDENCE, false)?; out.push_str(if self.negated { " !~ " } else { " ~ " }); self.pattern .write_operand(dialect, out, Self::PRECEDENCE, true) } - RLikeExpressionOptions::RegexpLike => write_regex_call( + flags::RLikeExpression::RegexpLike => write_regex_call( dialect, out, "REGEXP_LIKE", @@ -420,7 +421,7 @@ impl WriteSql for RLike { &self.expr, &self.pattern, ), - RLikeExpressionOptions::RegexpContains => write_regex_call( + flags::RLikeExpression::RegexpContains => write_regex_call( dialect, out, "REGEXP_CONTAINS", @@ -428,7 +429,7 @@ impl WriteSql for RLike { &self.expr, &self.pattern, ), - RLikeExpressionOptions::RegexpMatches => write_regex_call( + flags::RLikeExpression::RegexpMatches => write_regex_call( dialect, out, "regexp_matches", diff --git a/src/expr/enum_/mod.rs b/src/expr/enum_/mod.rs index a8bdc02..eaa5362 100644 --- a/src/expr/enum_/mod.rs +++ b/src/expr/enum_/mod.rs @@ -12,7 +12,7 @@ pub use nodes::*; pub use operator::*; pub use value::*; -use crate::dialect::PySqlDialect; +use crate::dialect::PyDialect; use crate::sql::PrepareResult; use crate::sql::SqlWriter; use crate::sql::WriteSql; @@ -130,7 +130,7 @@ impl ExprInner { #[inline] pub(crate) fn write_operand( &self, - dialect: &PySqlDialect, + dialect: &PyDialect, out: &mut W, parent_prec: u8, paren_if_equal: bool, @@ -174,7 +174,7 @@ impl ExprInner { impl WriteSql for ExprInner { #[cfg_attr(feature = "nightly", optimize(speed))] - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { match self { Self::Ident(x) => x.write_sql(dialect, out), Self::Value(x) => x.write_sql(dialect, out), diff --git a/src/expr/enum_/nodes.rs b/src/expr/enum_/nodes.rs index 5a3e2d2..41e1ad5 100644 --- a/src/expr/enum_/nodes.rs +++ b/src/expr/enum_/nodes.rs @@ -6,15 +6,8 @@ use super::IsKind; use super::TrimWhere; use super::UnaryOp; use super::UnarySpacing; -use crate::dialect::PySqlDialect; -use crate::dialect::inner::BitXorSyntax; -use crate::dialect::inner::BitwiseSyntax; -use crate::dialect::inner::CollateSyntax; -use crate::dialect::inner::IsDistinctFromSyntax; -use crate::dialect::inner::ModuloSyntax; -use crate::dialect::inner::StringConcatSyntax; -use crate::dialect::inner::TrimSyntax; -use crate::dialect::inner::TupleSyntax; +use crate::dialect::PyDialect; +use crate::dialect::flags; use crate::sql::PrepareResult; use crate::sql::SqlWriter; use crate::sql::WriteSql; @@ -54,31 +47,30 @@ impl Tuple { } impl WriteSql for Tuple { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { - let Some(syntax) = dialect.0.tuple_syntax else { - return Err(unsupported( - "tuple values are not supported by this dialect", - )); - }; - + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { let arity = self.items.len(); - match syntax { - TupleSyntax::Row => { + match dialect.0.tuple() { + flags::Tuple::Unsupported => { + return Err(unsupported( + "tuple values are not supported by this dialect", + )); + } + flags::Tuple::Row => { out.push_str("ROW("); } - TupleSyntax::RowRejectEmpty => { + flags::Tuple::RowRejectEmpty => { if arity == 0 { return Err(unsupported("empty tuple is not supported by this dialect")); } out.push_str("ROW("); } - TupleSyntax::Struct => { + flags::Tuple::Struct => { if arity == 0 { return Err(unsupported("empty tuple is not supported by this dialect")); } out.push_str("struct("); } - TupleSyntax::Parentheses => { + flags::Tuple::Parentheses => { if arity < 2 { return Err(unsupported( "a tuple needs at least two elements on this dialect", @@ -115,10 +107,10 @@ impl Unary { } impl WriteSql for Unary { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { if self.op == UnaryOp::BitNot { - match dialect.0.bitwise_syntax { - BitwiseSyntax::Infix | BitwiseSyntax::InfixNoShift => { + match dialect.0.bitwise() { + flags::Bitwise::Infix | flags::Bitwise::InfixNoShift => { out.push_char('~'); return self.expr.write_operand( dialect, @@ -127,13 +119,15 @@ impl WriteSql for Unary { true, ); } - BitwiseSyntax::BitShiftFunctions | BitwiseSyntax::ShiftFunctions => { + flags::Bitwise::BitShiftFunctions | flags::Bitwise::ShiftFunctions => { out.push_str("BITNOT("); self.expr.write_sql(dialect, out)?; out.push_char(')'); return Ok(()); } - BitwiseSyntax::BitAndOnly | BitwiseSyntax::None | BitwiseSyntax::InfixAndShift => { + flags::Bitwise::BitAndOnly + | flags::Bitwise::Unsupported + | flags::Bitwise::InfixAndShift => { return Err(unsupported("bitwise NOT is not supported by this dialect")); } } @@ -186,7 +180,7 @@ fn is_arithmetic_binary_op(op: &BinaryOp) -> bool { /// `name(left, right)` with an unquoted function name. #[inline] fn write_binary_call( - dialect: &PySqlDialect, + dialect: &PyDialect, out: &mut W, name: &str, left: &ExprInner, @@ -210,7 +204,7 @@ fn write_binary_call( #[inline] fn write_infix( bin: &Binary, - dialect: &PySqlDialect, + dialect: &PyDialect, out: &mut W, spelling: &str, paren_arithmetic: bool, @@ -225,7 +219,7 @@ fn write_infix( #[inline] fn write_infix_side( - dialect: &PySqlDialect, + dialect: &PyDialect, out: &mut W, expr: &ExprInner, prec: u8, @@ -242,88 +236,92 @@ fn write_infix_side( } impl WriteSql for Binary { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { match &self.op { - BinaryOp::StringConcat => match dialect.0.string_concat_syntax { - StringConcatSyntax::ConcatFunction => { + BinaryOp::StringConcat => match dialect.0.string_concat() { + flags::StringConcat::ConcatFunction => { write_binary_call(dialect, out, "CONCAT", &self.left, &self.right) } - StringConcatSyntax::Pipes => write_infix(self, dialect, out, "||", true), - StringConcatSyntax::Plus => write_infix(self, dialect, out, "+", true), + flags::StringConcat::Pipes => write_infix(self, dialect, out, "||", true), + flags::StringConcat::Plus => write_infix(self, dialect, out, "+", true), }, - BinaryOp::BitXor => match dialect.0.bit_xor_syntax { - None => Err(unsupported("bitwise XOR is not supported by this dialect")), - Some(BitXorSyntax::Caret) => write_infix(self, dialect, out, "^", false), - Some(BitXorSyntax::Hash) => write_infix(self, dialect, out, "#", false), - Some(BitXorSyntax::XORFunction) => { + BinaryOp::BitXor => match dialect.0.bitxor() { + flags::BitXor::Unsupported => { + Err(unsupported("bitwise XOR is not supported by this dialect")) + } + flags::BitXor::Caret => write_infix(self, dialect, out, "^", false), + flags::BitXor::Hash => write_infix(self, dialect, out, "#", false), + flags::BitXor::XORFunction => { write_binary_call(dialect, out, "xor", &self.left, &self.right) } - Some(BitXorSyntax::BitXORFunction) => { + flags::BitXor::BitXORFunction => { write_binary_call(dialect, out, "BITXOR", &self.left, &self.right) } }, - BinaryOp::Mod => match dialect.0.modulo_syntax { - ModuloSyntax::Percent => write_infix(self, dialect, out, "%", false), - ModuloSyntax::Function => { + BinaryOp::Mod => match dialect.0.modulo() { + flags::Modulo::Percent => write_infix(self, dialect, out, "%", false), + flags::Modulo::Function => { write_binary_call(dialect, out, "MOD", &self.left, &self.right) } - ModuloSyntax::Infix => write_infix(self, dialect, out, "MOD", false), + flags::Modulo::Infix => write_infix(self, dialect, out, "MOD", false), }, - BinaryOp::BitAnd => match dialect.0.bitwise_syntax { - BitwiseSyntax::Infix - | BitwiseSyntax::InfixNoShift - | BitwiseSyntax::InfixAndShift => write_infix(self, dialect, out, "&", false), - BitwiseSyntax::BitShiftFunctions - | BitwiseSyntax::ShiftFunctions - | BitwiseSyntax::BitAndOnly => { + BinaryOp::BitAnd => match dialect.0.bitwise() { + flags::Bitwise::Infix + | flags::Bitwise::InfixNoShift + | flags::Bitwise::InfixAndShift => write_infix(self, dialect, out, "&", false), + flags::Bitwise::BitShiftFunctions + | flags::Bitwise::ShiftFunctions + | flags::Bitwise::BitAndOnly => { write_binary_call(dialect, out, "BITAND", &self.left, &self.right) } - BitwiseSyntax::None => { + flags::Bitwise::Unsupported => { Err(unsupported("bitwise AND is not supported by this dialect")) } }, - BinaryOp::BitOr => match dialect.0.bitwise_syntax { - BitwiseSyntax::Infix | BitwiseSyntax::InfixNoShift => { + BinaryOp::BitOr => match dialect.0.bitwise() { + flags::Bitwise::Infix | flags::Bitwise::InfixNoShift => { write_infix(self, dialect, out, "|", false) } - BitwiseSyntax::BitShiftFunctions | BitwiseSyntax::ShiftFunctions => { + flags::Bitwise::BitShiftFunctions | flags::Bitwise::ShiftFunctions => { write_binary_call(dialect, out, "BITOR", &self.left, &self.right) } - BitwiseSyntax::BitAndOnly | BitwiseSyntax::None | BitwiseSyntax::InfixAndShift => { + flags::Bitwise::BitAndOnly + | flags::Bitwise::Unsupported + | flags::Bitwise::InfixAndShift => { Err(unsupported("bitwise OR is not supported by this dialect")) } }, - BinaryOp::LShift => match dialect.0.bitwise_syntax { - BitwiseSyntax::Infix | BitwiseSyntax::InfixAndShift => { + BinaryOp::LShift => match dialect.0.bitwise() { + flags::Bitwise::Infix | flags::Bitwise::InfixAndShift => { write_infix(self, dialect, out, "<<", false) } - BitwiseSyntax::BitShiftFunctions => { + flags::Bitwise::BitShiftFunctions => { write_binary_call(dialect, out, "BITSHIFTLEFT", &self.left, &self.right) } - BitwiseSyntax::ShiftFunctions => { + flags::Bitwise::ShiftFunctions => { write_binary_call(dialect, out, "SHIFTLEFT", &self.left, &self.right) } - BitwiseSyntax::InfixNoShift | BitwiseSyntax::BitAndOnly | BitwiseSyntax::None => { - Err(unsupported( - "bitwise shift is not supported by this dialect", - )) - } + flags::Bitwise::InfixNoShift + | flags::Bitwise::BitAndOnly + | flags::Bitwise::Unsupported => Err(unsupported( + "bitwise shift is not supported by this dialect", + )), }, - BinaryOp::RShift => match dialect.0.bitwise_syntax { - BitwiseSyntax::Infix | BitwiseSyntax::InfixAndShift => { + BinaryOp::RShift => match dialect.0.bitwise() { + flags::Bitwise::Infix | flags::Bitwise::InfixAndShift => { write_infix(self, dialect, out, ">>", false) } - BitwiseSyntax::BitShiftFunctions => { + flags::Bitwise::BitShiftFunctions => { write_binary_call(dialect, out, "BITSHIFTRIGHT", &self.left, &self.right) } - BitwiseSyntax::ShiftFunctions => { + flags::Bitwise::ShiftFunctions => { write_binary_call(dialect, out, "SHIFTRIGHT", &self.left, &self.right) } - BitwiseSyntax::InfixNoShift | BitwiseSyntax::BitAndOnly | BitwiseSyntax::None => { - Err(unsupported( - "bitwise shift is not supported by this dialect", - )) - } + flags::Bitwise::InfixNoShift + | flags::Bitwise::BitAndOnly + | flags::Bitwise::Unsupported => Err(unsupported( + "bitwise shift is not supported by this dialect", + )), }, _ => write_infix(self, dialect, out, self.op.as_sql(), false), } @@ -356,7 +354,7 @@ impl Is { } impl WriteSql for Is { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { self.expr .write_operand(dialect, out, Self::PRECEDENCE, false)?; if self.negated { @@ -395,12 +393,12 @@ impl IsDistinctFrom { } impl WriteSql for IsDistinctFrom { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { - match dialect.0.is_distinct_from_syntax { - None => Err(unsupported( + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { + match dialect.0.is_distinct_from() { + flags::IsDistinctFrom::Unsupported => Err(unsupported( "IS DISTINCT FROM is not supported by this dialect", )), - Some(IsDistinctFromSyntax::Standard) => { + flags::IsDistinctFrom::Standard => { self.left .write_operand(dialect, out, Self::PRECEDENCE, false)?; if self.negated { @@ -411,7 +409,7 @@ impl WriteSql for IsDistinctFrom { self.right .write_operand(dialect, out, Self::PRECEDENCE, true) } - Some(IsDistinctFromSyntax::NullSafeEqual) => { + flags::IsDistinctFrom::NullSafeEqual => { // `<=>` is null-safe equal, i.e. `IS NOT DISTINCT FROM`. if !self.negated { out.push_str("NOT ("); @@ -457,14 +455,14 @@ impl InList { } impl WriteSql for InList { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { if self.list.is_empty() { // `x IN ()` is false and `x NOT IN ()` is true, including when `x` is NULL. dialect.0.format_boolean(out, self.negated)?; return Ok(()); } - if dialect.0.parenthesize_in_between { + if dialect.0.parenthesize_in_between() { out.push_char('('); } self.expr @@ -476,7 +474,7 @@ impl WriteSql for InList { } write_separated(dialect, out, &self.list, ", ")?; out.push_char(')'); - if dialect.0.parenthesize_in_between { + if dialect.0.parenthesize_in_between() { out.push_char(')'); } Ok(()) @@ -513,8 +511,8 @@ impl Between { } impl WriteSql for Between { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { - if dialect.0.parenthesize_in_between { + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { + if dialect.0.parenthesize_in_between() { out.push_char('('); } self.expr @@ -529,7 +527,7 @@ impl WriteSql for Between { out.push_str(" AND "); self.high .write_operand(dialect, out, Self::PRECEDENCE, true)?; - if dialect.0.parenthesize_in_between { + if dialect.0.parenthesize_in_between() { out.push_char(')'); } Ok(()) @@ -565,7 +563,7 @@ impl Cast { fn write_call( &self, - dialect: &PySqlDialect, + dialect: &PyDialect, out: &mut W, name: &str, ) -> PrepareResult<()> { @@ -580,15 +578,19 @@ impl Cast { } impl WriteSql for Cast { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { match self.kind { CastKind::Cast => self.write_call(dialect, out, "CAST"), - CastKind::TryCast => match dialect.0.try_cast_syntax { - Some(syntax) => self.write_call(dialect, out, syntax.as_sql()), - None => Err(unsupported("try cast is not supported by this dialect")), + CastKind::TryCast => match dialect.0.try_cast() { + flags::TryCast::Unsupported => { + Err(unsupported("try cast is not supported by this dialect")) + } + flags::TryCast::TryCast => self.write_call(dialect, out, "TRY_CAST"), + flags::TryCast::TryCastWord => self.write_call(dialect, out, "TRYCAST"), + flags::TryCast::SafeCast => self.write_call(dialect, out, "SAFE_CAST"), }, CastKind::DoubleColon => { - if !dialect.0.supports_double_colon { + if !dialect.0.supports_double_colon() { return Err(unsupported( "double-colon cast is not supported by this dialect", )); @@ -634,7 +636,7 @@ impl Trim { #[inline] fn write_trim_function( trim: &Trim, - dialect: &PySqlDialect, + dialect: &PyDialect, out: &mut W, ) -> PrepareResult<()> { let name = match trim.where_ { @@ -654,9 +656,9 @@ fn write_trim_function( } impl WriteSql for Trim { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { - match dialect.0.trim_syntax { - TrimSyntax::SqlFrom => { + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { + match dialect.0.trim() { + flags::Trim::SqlFrom => { out.push_str("TRIM("); match (&self.where_, &self.characters) { (TrimWhere::Both, None) => self.expr.write_sql(dialect, out)?, @@ -681,8 +683,8 @@ impl WriteSql for Trim { out.push_char(')'); Ok(()) } - TrimSyntax::Function => write_trim_function(self, dialect, out), - TrimSyntax::SpacesOnly => { + flags::Trim::Function => write_trim_function(self, dialect, out), + flags::Trim::SpacesOnly => { if self.characters.is_some() { return Err(unsupported( "trim characters are not supported by this dialect", @@ -716,16 +718,18 @@ impl Collate { } impl WriteSql for Collate { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { - match dialect.0.collate_syntax { - None => Err(unsupported("COLLATE is not supported by this dialect")), - Some(CollateSyntax::InfixIdent) => { + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { + match dialect.0.collate() { + flags::Collate::Unsupported => { + Err(unsupported("COLLATE is not supported by this dialect")) + } + flags::Collate::InfixIdent => { self.expr .write_operand(dialect, out, Self::PRECEDENCE, false)?; out.push_str(" COLLATE "); self.collation.write_sql(dialect, out) } - Some(CollateSyntax::FunctionString) => { + flags::Collate::FunctionString => { let body = match self.collation.parts() { [super::IdentPart::Quote(body) | super::IdentPart::Unquote(body)] if !body.is_empty() => @@ -785,7 +789,7 @@ impl Case { } impl WriteSql for Case { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { out.push_str("CASE"); if let Some(operand) = &self.operand { out.push_char(' '); @@ -830,7 +834,7 @@ impl Custom { impl WriteSql for Custom { #[cfg_attr(feature = "nightly", optimize(speed))] - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { let bytes = self.sql.as_bytes(); let mut i = 0; let mut start = 0; diff --git a/src/expr/enum_/value.rs b/src/expr/enum_/value.rs index 3c327ab..a621a67 100644 --- a/src/expr/enum_/value.rs +++ b/src/expr/enum_/value.rs @@ -5,7 +5,7 @@ use pyo3::types::PyStringMethods; use crate::data_type::SerializerType; use crate::data_type::serializer_repr; -use crate::dialect::PySqlDialect; +use crate::dialect::PyDialect; use crate::internal::alias; use crate::sql::PrepareResult; use crate::sql::SqlWriter; @@ -47,7 +47,7 @@ impl Value { } impl WriteSql for Value { - fn write_sql(&self, dialect: &PySqlDialect, out: &mut W) -> PrepareResult<()> { + fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { out.push_value(dialect, &self.serializer, &self.object) } } diff --git a/src/lib.rs b/src/lib.rs index 2d6e9e1..77c3846 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,6 @@ pub mod sql; pub mod typeref; // Tasks until being stable (until this part) -// - Clean code // - Add lint/coding-style rules // - Restore GenericDialect ( very helpful for testing ) // - Test and coverage everything and edge cases diff --git a/src/sql.rs b/src/sql.rs index 299ee60..2e99ff5 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -10,9 +10,9 @@ use std::fmt::Write; use pyo3::types::PyAnyMethods; use crate::data_type::SerializerType; -use crate::dialect::PySqlDialect; -use crate::dialect::inner::FormatError; -use crate::dialect::inner::PositionalParameter; +use crate::dialect::PyDialect; +use crate::dialect::errors::FormatError; +use crate::dialect::flags::Placeholder; use crate::internal::alias; /// Error produced while building SQL. @@ -135,7 +135,7 @@ pub trait SqlWriter: std::fmt::Write { /// unused. fn push_value( &mut self, - dialect: &PySqlDialect, + dialect: &PyDialect, serializer: &SerializerType, object: &alias::PyObject, ) -> PrepareResult<()>; @@ -195,7 +195,7 @@ impl<'py> SqlWriter for BufferedSql<'py> { fn push_value( &mut self, - dialect: &PySqlDialect, + dialect: &PyDialect, serializer: &SerializerType, object: &alias::PyObject, ) -> PrepareResult<()> { @@ -279,13 +279,16 @@ impl<'py> ParameterizedSql<'py> { impl ParameterizedSql<'_> { /// Register `param` and append its placeholder to the SQL in one step. #[inline] - pub fn push_param(&mut self, dialect: &PySqlDialect, param: alias::PyObject) -> &mut Self { - match dialect.0.positional_parameter { - PositionalParameter::Simple(c) => { - self.sql.push(c); + pub fn push_param(&mut self, dialect: &PyDialect, param: alias::PyObject) -> &mut Self { + // SAFETY: Dialect::placeholder_character guarateed to store valid `char`. + let character = unsafe { char::from_u32_unchecked(dialect.0.placeholder_character()) }; + + match dialect.0.placeholder() { + Placeholder::Simple => { + self.sql.push(character); } - PositionalParameter::Numbered(c) => { - self.sql.push(c); + Placeholder::Numbered => { + self.sql.push(character); let _ = write!(self.sql, "{}", self.next_id); self.next_id += 1; } @@ -312,7 +315,7 @@ impl SqlWriter for ParameterizedSql<'_> { #[inline] fn push_value( &mut self, - dialect: &PySqlDialect, + dialect: &PyDialect, _serializer: &SerializerType, object: &alias::PyObject, ) -> PrepareResult<()> { @@ -340,10 +343,10 @@ pub trait WriteSql { fn write_sql( &self, // NOTE FOR ME: - // Why `PySqlDialect` instead of Rust `Dialect`? + // Why `PyDialect` instead of Rust `Dialect`? // because we accept custom serializers and custom serializer functions // need to get dialect objects. - dialect: &PySqlDialect, + dialect: &PyDialect, out: &mut W, ) -> PrepareResult<()>; } @@ -352,7 +355,7 @@ pub trait WriteSql { pub fn write_sql<'py, T: WriteSql>( py: pyo3::Python<'py>, stmt: &T, - dialect: &PySqlDialect, + dialect: &PyDialect, ) -> PrepareResult { let mut buf = BufferedSql::with_capacity(py, 256); stmt.write_sql(dialect, &mut buf)?; @@ -363,7 +366,7 @@ pub fn write_sql<'py, T: WriteSql>( pub fn prepare<'py, T: WriteSql>( py: pyo3::Python<'py>, stmt: &T, - dialect: &PySqlDialect, + dialect: &PyDialect, ) -> PrepareResult<(String, PrepareParams)> { let mut context = ParameterizedSql::with_capacity(py, 256); stmt.write_sql(dialect, &mut context)?; @@ -376,7 +379,7 @@ pub fn prepare<'py, T: WriteSql>( /// Use `","` / `", "` for lists and `" AND "` / `" OR "` for predicates. #[cfg_attr(feature = "nightly", optimize(speed))] pub fn write_separated<'a, T, W>( - dialect: &PySqlDialect, + dialect: &PyDialect, out: &mut W, items: impl IntoIterator, separator: &str, diff --git a/src/typeref.rs b/src/typeref.rs index 76afe5a..872b9fd 100644 --- a/src/typeref.rs +++ b/src/typeref.rs @@ -1,7 +1,7 @@ //! Interned `PyTypeObject*` cache. // Dialect types -pub(crate) static mut SQL_DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +pub(crate) static mut DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); // Data-type / serializer types pub(crate) static mut DATATYPE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); @@ -70,7 +70,7 @@ fn _initialize_typeref(py: pyo3::Python) { multiple_get_type_object_for!( py, // dialects - crate::dialect::PySqlDialect => SQL_DIALECT_TYPE, + crate::dialect::PyDialect => DIALECT_TYPE, // data types / serializers crate::data_type::PyDataType => DATATYPE_TYPE, crate::data_type::PySerializer => SERIALIZER_TYPE, From 32cf1d9bc418712c170732ec48a81c3707c349e2 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Sun, 4 Oct 2026 11:51:00 +0330 Subject: [PATCH 64/87] Remove `serializer` field from PyDataType Add a new utility for creating __repr__ outputs: ReprFormatter --- rapidquery/__init__.py | 2 + rapidquery/data_type.py | 44 +-- rapidquery/data_type.pyi | 370 +----------------- rapidquery/dialect.py | 4 +- rapidquery/dialect.pyi | 68 +--- rapidquery/expr.pyi | 26 +- rapidquery/serializers.py | 43 ++ rapidquery/serializers.pyi | 320 +++++++++++++++ src/data_type/mod.rs | 139 +------ src/data_type/name.rs | 131 +++---- src/data_type/tests.rs | 156 +++----- src/expr/base.rs | 2 +- src/expr/enum_/func.rs | 37 +- src/expr/enum_/ident.rs | 29 +- src/expr/enum_/like.rs | 83 ++-- src/expr/enum_/mod.rs | 3 +- src/expr/enum_/nodes.rs | 235 ++++++----- src/expr/enum_/value.rs | 20 +- src/expr/extractors.rs | 2 +- src/expr/ident.rs | 5 +- src/expr/value.rs | 12 +- src/internal/mod.rs | 1 + src/internal/repr.rs | 257 ++++++++++++ src/lib.rs | 4 + .../serializer => serializers}/functions.rs | 0 .../serializer => serializers}/inner.rs | 26 -- .../serializer => serializers}/mod.rs | 88 +++-- src/sql.rs | 2 +- src/typeref.rs | 2 +- tests/conftest.py | 8 +- tests/test_data_type.py | 170 +------- tests/test_dialect.py | 40 +- tests/test_expr.py | 4 +- tests/test_ident.py | 4 +- tests/test_serializers.py | 22 +- tests/test_value.py | 11 +- 36 files changed, 1099 insertions(+), 1271 deletions(-) create mode 100644 rapidquery/serializers.py create mode 100644 rapidquery/serializers.pyi create mode 100644 src/internal/repr.rs rename src/{data_type/serializer => serializers}/functions.rs (100%) rename src/{data_type/serializer => serializers}/inner.rs (82%) rename src/{data_type/serializer => serializers}/mod.rs (85%) diff --git a/rapidquery/__init__.py b/rapidquery/__init__.py index 70d5474..f9c7e50 100644 --- a/rapidquery/__init__.py +++ b/rapidquery/__init__.py @@ -3,6 +3,7 @@ from . import data_type as data_type from . import dialect as dialect from . import expr as expr +from . import serializers as serializers from .expr import Asterisk as Asterisk from .expr import Ident as Ident from .expr import Unquote as Unquote @@ -16,4 +17,5 @@ "data_type", "dialect", "expr", + "serializers", ] diff --git a/rapidquery/data_type.py b/rapidquery/data_type.py index 0a47676..0995e4d 100644 --- a/rapidquery/data_type.py +++ b/rapidquery/data_type.py @@ -1,45 +1,7 @@ """ -SQL data types and value serializers. +SQL data type names. """ -from ._core.data_type import ( - ArraySerializer, - BitSerializer, - BooleanSerializer, - BytesSerializer, - CustomSerializer, - DataType, - DateSerializer, - DatetimeSerializer, - FloatSerializer, - InetSerializer, - IntegerSerializer, - JSONSerializer, - MacAddressSerializer, - Serializer, - StringSerializer, - TimeSerializer, - UnspecifiedSerializer, - UuidSerializer, -) +from ._core.data_type import DataType -__all__ = [ - "ArraySerializer", - "BitSerializer", - "BooleanSerializer", - "BytesSerializer", - "CustomSerializer", - "DataType", - "DateSerializer", - "DatetimeSerializer", - "FloatSerializer", - "InetSerializer", - "IntegerSerializer", - "JSONSerializer", - "MacAddressSerializer", - "Serializer", - "StringSerializer", - "TimeSerializer", - "UnspecifiedSerializer", - "UuidSerializer", -] +__all__ = ["DataType"] diff --git a/rapidquery/data_type.pyi b/rapidquery/data_type.pyi index 32049b7..c685a65 100644 --- a/rapidquery/data_type.pyi +++ b/rapidquery/data_type.pyi @@ -1,345 +1,22 @@ -import datetime -import decimal -import enum -import typing -import uuid -from collections.abc import Callable - -from rapidquery.dialect import SqlDialect - -_T = typing.TypeVar("_T") - -class Serializer(typing.Generic[_T]): - """ - Converts a Python object into a dialect-specific SQL literal string. - - Serializers produce a correctly escaped and formatted SQL value that can - be embedded in queries. Each concrete subclass handles a particular - category of values (integers, strings, dates, arrays, etc.). Use - `CustomSerializer` when the built-in ones are insufficient. - - This base class cannot be instantiated. Pass a dialect class or instance - to `serialize()`. Unit serializers compare equal by variant; - `ArraySerializer` by its element tree; `CustomSerializer` by function - identity. - """ - - def serialize(self, dialect: SqlDialect, object: _T) -> str: - """ - Renders `object` as a SQL literal for `dialect`. - - Args: - dialect: A dialect class (`PostgreSQL`) or instance (`PostgreSQL()`). - object: The Python value to convert. - - Returns: - A SQL fragment suitable for embedding in a statement. - - Raises: - TypeError: If `object` is not an accepted type for this serializer. - ValueError: If `object` is the right type but cannot be represented - (for example a non-finite float). - NotImplementedError: If `dialect` has no native literal for this - serializer (bit strings, `ARRAY[…]`, inet, macaddr). - """ - - def __eq__(self, other: object) -> bool: ... - def __hash__(self) -> int: ... - -@typing.final -class IntegerSerializer(Serializer[int]): - """ - Serializes Python integers as bare SQL integer literals. - - Accepts Python `int` of any width (including values beyond 64-bit). - Rejects `bool`; `True` and `False` are not treated as integers. - - Raises: - TypeError: If the value is not an `int`. - """ - - def __new__(cls) -> typing.Self: ... - -@typing.final -class FloatSerializer(Serializer[int | float | decimal.Decimal]): - """ - Serializes numeric values as bare SQL numeric literals. - - Accepts `float`, `int`, and `decimal.Decimal`. Finite floats are formatted - so that `1.0` stays distinct from `1`. `str` is not accepted. - - Raises: - TypeError: If the value is not a `float`, `int`, or `decimal.Decimal`. - ValueError: If the value is a non-finite float (`nan`, `inf`, `-inf`). - """ - - def __new__(cls) -> typing.Self: ... - -@typing.final -class BooleanSerializer(Serializer[bool]): - """ - Serializes Python booleans as dialect boolean literals. - - Accepts Python `bool` only. The dialect chooses the spelling - (`TRUE`/`FALSE` or `1`/`0`). - - Raises: - TypeError: If the value is not a `bool`. - """ - - def __new__(cls) -> typing.Self: ... - -@typing.final -class StringSerializer(Serializer[str | enum.Enum]): - """ - Serializes text as a quoted, escaped SQL string literal. +"""SQL data type names.""" - Accepts `str`, or an `enum.StrEnum`. The - dialect supplies quoting and escaping. - - Raises: - TypeError: If the value is not a `str` or a string-valued enum member. - """ - - def __new__(cls) -> typing.Self: ... - -@typing.final -class UuidSerializer(Serializer[uuid.UUID | str]): - """ - Serializes UUIDs using the dialect's ``uuid_format``. - - Accepts `uuid.UUID` or `str`. A quoted literal, ``UUID '…'``, or ``'…'::uuid``. - - Raises: - TypeError: If the value is not a `uuid.UUID` or `str`. - ValueError: If the value is `str`, but it is not a UUID. - """ - - def __new__(cls) -> typing.Self: ... - -@typing.final -class BytesSerializer(Serializer[bytes]): - """ - Serializes binary data as a dialect blob/bytea literal. - - Accepts exact `bytes` (not `bytearray` or `memoryview`). - - Raises: - TypeError: If the value is not `bytes`. - """ - - def __new__(cls) -> typing.Self: ... - -@typing.final -class BitSerializer(Serializer[str]): - """ - Serializes bit strings as dialect bit-string literals. - - Accepts a `str` consisting only of `'0'` and `'1'` characters. - Supported on PostgreSQL, DuckDB, MySQL, and Teradata. - - Raises: - TypeError: If the value is not a `str`. - ValueError: If the string is empty or contains characters other than `'0'` and `'1'`. - NotImplementedError: If the dialect has no native bit-string literal. - """ - - def __new__(cls) -> typing.Self: ... - -@typing.final -class JSONSerializer(Serializer[typing.Any]): - """ - Serializes a JSON-encodable object using the dialect's ``json_format``. - - A quoted literal, ``JSON '…'``, or ``PARSE_JSON('…')``. Accepts any object - that the JSON encoder can dump. Uses `orjson` when it - is installed, otherwise the stdlib `json` module. The JSON text is then - wrapped with the dialect's string quoting. With `orjson`, datetime, UUID, - enum.StrEnum, and dataclass are encoded natively; stdlib `json` - rejects those. - - Raises: - TypeError: If the value cannot be JSON-encoded. - """ - - def __new__(cls) -> typing.Self: ... - -@typing.final -class DateSerializer(Serializer[datetime.date]): - """ - Serializes dates as `YYYY-MM-DD` literals. - - ``date_format`` chooses a quoted literal or ``DATE`` plus that literal. - - Accepts exact `datetime.date` (not `datetime.datetime`). - - Raises: - TypeError: If the value is not a `datetime.date`. - """ - - def __new__(cls) -> typing.Self: ... - -@typing.final -class TimeSerializer(Serializer[datetime.time]): - """ - Serializes times as `HH:MM:SS[.ffffff]` literals. - - ``time_format`` chooses a quoted literal or ``TIME`` plus that literal. - - Accepts `datetime.time`. - - Raises: - TypeError: If the value is not a `datetime.time`. - """ - - def __new__(cls) -> typing.Self: ... - -@typing.final -class DatetimeSerializer(Serializer[datetime.datetime]): - """ - Serializes timestamps as SQL timestamp literals. - - Accepts naive or aware `datetime.datetime`, or `int`/`float` UTC epoch - seconds. Naive values keep wall time. Aware values keep their original - offset (they are not converted to UTC). When ``timestamp_format`` sets a - keyword, naive values use ``TIMESTAMP`` and aware values (including epoch - numbers) use ``TIMESTAMPTZ`` or ``TIMESTAMP WITH TIMEZONE``. - - Raises: - TypeError: If the value is not a `datetime.datetime`, `int`, or `float`. - OverflowError: If an epoch value is out of range for a timestamp. - """ - - def __new__(cls) -> typing.Self: ... - -@typing.final -class InetSerializer(Serializer[typing.Any]): - """ - Serializes IP addresses and networks using the dialect's ``inet_format``. - - A quoted literal or ``'…'::inet``. - - Accepts `str` or `ipaddress` objects (`IPv4Address`, `IPv6Address`, - `IPv4Network`, `IPv6Network`, and related types whose `str()` is a valid - address or CIDR). Supported on PostgreSQL and DuckDB. - - Raises: - ValueError: If the value is not a valid IPv4/IPv6 address or network. - NotImplementedError: If the dialect has no native INET type. - """ - - def __new__(cls) -> typing.Self: ... - -@typing.final -class MacAddressSerializer(Serializer[str]): - """ - Serializes MAC addresses using the dialect's ``macaddr_format``. - - A quoted literal or ``'…'::macaddr``. - - Accepts a `str` using `:` or `-` as the separator. - Supported on PostgreSQL. - - Raises: - ValueError: If the value is not a valid MAC address. - NotImplementedError: If the dialect has no native MACADDR type. - """ - - def __new__(cls) -> typing.Self: ... - -@typing.final -class UnspecifiedSerializer(Serializer[typing.Any]): - """ - Inserts `str(obj)` into SQL with no quoting or type check. - - Used when no serializer could be inferred from the type name. The result - is not a safe SQL value; prefer an explicit serializer. - """ - - def __new__(cls) -> typing.Self: ... - -@typing.final -class ArraySerializer(Serializer[list[_T] | tuple[_T]]): - """ - Serializes sequences as `ARRAY[elem, ...]` literals. - - Each element is serialized independently with `element`. Accepts `list` - or `tuple`. Nested arrays are expressed by wrapping another - `ArraySerializer`. Supported on PostgreSQL, DuckDB, and BigQuery. - - Raises: - TypeError: If the value is not a `list` or `tuple`. - NotImplementedError: If the dialect has no `ARRAY[…]` constructor. - """ - - def __new__(cls, element: Serializer[_T]) -> typing.Self: - """ - Creates an array serializer. - - Args: - element: The serializer applied to every array item. Nested arrays - are expressed by passing another `ArraySerializer`. - """ - - @property - def element(self) -> Serializer[_T]: - """ - Returns the serializer used for each array element. - - Returns: - The `Serializer` passed to the constructor. - """ - -@typing.final -class CustomSerializer(Serializer[_T]): - """ - Serializes values by calling a user-supplied Python function. - - `function` has the form `fn(dialect_name: str, obj) -> str`. The return - value is inserted into SQL as-is (not re-quoted or escaped). It is the - caller's responsibility to produce a safe fragment. - """ - - def __new__(cls, function: Callable[[SqlDialect, _T], str]) -> typing.Self: - """ - Creates a serializer backed by a Python callable. - - Args: - function: A callable of the form `fn(dialect_name: str, obj) -> str`. - The return value is inserted into SQL as-is (not re-quoted). - Must return a `str`. - """ - - @property - def function(self) -> Callable[[str, _T], str]: - """ - Returns the callable passed to the constructor. - - Returns: - The function given as `function` to `__new__`. - """ +import typing @typing.final -class DataType(typing.Generic[_T]): +class DataType: """ Represents a SQL data type. - A data type is a SQL name plus the serializer used to turn Python objects - into SQL literals. The name can be any string; it is not limited to a set - of predefined values. The serializer is fully customizable. + A data type is a SQL type name. The name can be any string; it is not + limited to a set of predefined values. Known names are interned and canonicalized (`"int"` becomes `"INT"`). Unknown names and parameterized spellings (`VARCHAR(255)`) are kept as given. Interning applies to the **name string**, not to `DataType` instances: `DataType("int")` is not a singleton. - - If `serializer` is omitted at construction, one is inferred from the - name; unrecognized names get `UnspecifiedSerializer`. """ - def __new__( - cls, name: str, serializer: Serializer[_T] | None = None, / - ) -> typing.Self: + def __new__(cls, name: str, /) -> typing.Self: """ Creates a new SQL data type. @@ -348,12 +25,6 @@ class DataType(typing.Generic[_T]): Known names are interned and canonicalized (`"int"` becomes `"INT"`). Unknown names and parameterized spellings (`VARCHAR(255)`) are kept as given. - serializer: Used when a Python object is rendered as a SQL - literal (defaults, inserts, raw SQL). Built-in - serializers cover common types; `CustomSerializer` - covers the rest. If omitted, a serializer is inferred - from `name`; unrecognized names get - `UnspecifiedSerializer`. """ @property @@ -366,34 +37,5 @@ class DataType(typing.Generic[_T]): casing or spacing from the string passed to the constructor. """ - @property - def serializer(self) -> Serializer[_T]: - """ - Returns the serializer used to render Python values as SQL literals. - - Returns: - The `Serializer` associated with this data type. - """ - - def serialize(self, dialect: SqlDialect, object: _T) -> str: - """ - Renders `object` as a SQL literal for `dialect`. - - Shorthand for `self.serializer.serialize(dialect, object)`. - - Args: - dialect: A dialect class (`PostgreSQL`) or instance (`PostgreSQL()`). - object: The Python value to convert. - - Returns: - A SQL fragment produced by this type's serializer. - - Raises: - TypeError: If `object` is not an accepted type for this serializer. - ValueError: If `object` is the right type but cannot be represented. - NotImplementedError: If `dialect` has no native literal for this - type's serializer (bit strings, `ARRAY[…]`, inet, macaddr). - """ - def __eq__(self, other: object) -> bool: ... def __hash__(self) -> int: ... diff --git a/rapidquery/dialect.py b/rapidquery/dialect.py index 18ad990..35cfc36 100644 --- a/rapidquery/dialect.py +++ b/rapidquery/dialect.py @@ -5,6 +5,7 @@ from ._core.dialect import ( BigQuery, Databricks, + Dialect, DuckDb, Hive, MsSql, @@ -14,7 +15,6 @@ Redshift, Snowflake, Spark, - SqlDialect, Sqlite, Teradata, ) @@ -22,6 +22,7 @@ __all__ = [ "BigQuery", "Databricks", + "Dialect", "DuckDb", "Hive", "MsSql", @@ -31,7 +32,6 @@ "Redshift", "Snowflake", "Spark", - "SqlDialect", "Sqlite", "Teradata", ] diff --git a/rapidquery/dialect.pyi b/rapidquery/dialect.pyi index 4bcfb67..f6ad35d 100644 --- a/rapidquery/dialect.pyi +++ b/rapidquery/dialect.pyi @@ -1,6 +1,6 @@ -from typing import Self, final +import typing -class SqlDialect: +class Dialect: """ Abstract SQL dialect. @@ -13,54 +13,16 @@ class SqlDialect: def __eq__(self, other: object) -> bool: ... def __hash__(self) -> int: ... -@final -class BigQuery(SqlDialect): - def __new__(cls) -> Self: ... - -@final -class Databricks(SqlDialect): - def __new__(cls) -> Self: ... - -@final -class DuckDb(SqlDialect): - def __new__(cls) -> Self: ... - -@final -class Hive(SqlDialect): - def __new__(cls) -> Self: ... - -@final -class MsSql(SqlDialect): - def __new__(cls) -> Self: ... - -@final -class MySql(SqlDialect): - def __new__(cls) -> Self: ... - -@final -class Oracle(SqlDialect): - def __new__(cls) -> Self: ... - -@final -class PostgreSql(SqlDialect): - def __new__(cls) -> Self: ... - -@final -class Redshift(SqlDialect): - def __new__(cls) -> Self: ... - -@final -class Snowflake(SqlDialect): - def __new__(cls) -> Self: ... - -@final -class Spark(SqlDialect): - def __new__(cls) -> Self: ... - -@final -class Sqlite(SqlDialect): - def __new__(cls) -> Self: ... - -@final -class Teradata(SqlDialect): - def __new__(cls) -> Self: ... +BigQuery: typing.Final[Dialect] = ... +Databricks: typing.Final[Dialect] = ... +DuckDb: typing.Final[Dialect] = ... +Hive: typing.Final[Dialect] = ... +MsSql: typing.Final[Dialect] = ... +MySql: typing.Final[Dialect] = ... +Oracle: typing.Final[Dialect] = ... +PostgreSql: typing.Final[Dialect] = ... +Redshift: typing.Final[Dialect] = ... +Snowflake: typing.Final[Dialect] = ... +Spark: typing.Final[Dialect] = ... +Sqlite: typing.Final[Dialect] = ... +Teradata: typing.Final[Dialect] = ... diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index d907022..ab9858a 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -2,8 +2,9 @@ import typing from collections.abc import Iterable, Sequence from typing import Self, final, overload -from rapidquery.data_type import DataType, Serializer -from rapidquery.dialect import SqlDialect +from rapidquery.data_type import DataType +from rapidquery.serializers import Serializer +from rapidquery.dialect import Dialect class Expr: """ @@ -14,12 +15,12 @@ class Expr: instantiated. """ - def to_sql(self, dialect: SqlDialect) -> str: + def to_sql(self, dialect: Dialect) -> str: """ Render this expression as raw SQL (values inlined as literals). """ - def prepare(self, dialect: SqlDialect) -> tuple[str, tuple[typing.Any]]: + def prepare(self, dialect: Dialect) -> tuple[str, tuple[typing.Any]]: """ Render this expression as a parameterized statement. """ @@ -151,9 +152,7 @@ class Value(Expr, typing.Generic[_T]): If ``serializer`` is omitted, one is inferred from the object's type. Passing an existing ``Value`` keeps its object (and its - serializer, unless a new one is given). A ``DataType`` as - ``serializer`` contributes only its serializer; the data type is - not stored. + serializer, unless a new one is given). """ @overload @@ -162,7 +161,7 @@ class Value(Expr, typing.Generic[_T]): def __new__( cls, object: _T, - serializer: Serializer[_T] | DataType[_T] | None = None, + serializer: Serializer[_T] | None = None, /, ) -> typing.Self: """ @@ -171,18 +170,15 @@ class Value(Expr, typing.Generic[_T]): Args: object: The Python object to bind or serialize. If this is already a ``Value``, its object is reused. - serializer: How to render ``object`` as a SQL literal. A - ``DataType`` is accepted and only its serializer is - kept. If omitted, a serializer is inferred from - ``object`` (or kept, when ``object`` is already a - ``Value``). + serializer: How to render ``object`` as a SQL literal. If + omitted, a serializer is inferred from ``object`` (or + kept, when ``object`` is already a ``Value``). Returns: A new ``Value``. Raises: - TypeError: If ``serializer`` is not a ``Serializer`` or - ``DataType``. + TypeError: If ``serializer`` is not a ``Serializer``. """ def get_object(self) -> _T: diff --git a/rapidquery/serializers.py b/rapidquery/serializers.py new file mode 100644 index 0000000..f18af29 --- /dev/null +++ b/rapidquery/serializers.py @@ -0,0 +1,43 @@ +""" +Value serializers. +""" + +from ._core.serializers import ( + ArraySerializer, + BitSerializer, + BooleanSerializer, + BytesSerializer, + CustomSerializer, + DateSerializer, + DatetimeSerializer, + FloatSerializer, + InetSerializer, + IntegerSerializer, + JSONSerializer, + MacAddressSerializer, + Serializer, + StringSerializer, + TimeSerializer, + UnspecifiedSerializer, + UuidSerializer, +) + +__all__ = [ + "ArraySerializer", + "BitSerializer", + "BooleanSerializer", + "BytesSerializer", + "CustomSerializer", + "DateSerializer", + "DatetimeSerializer", + "FloatSerializer", + "InetSerializer", + "IntegerSerializer", + "JSONSerializer", + "MacAddressSerializer", + "Serializer", + "StringSerializer", + "TimeSerializer", + "UnspecifiedSerializer", + "UuidSerializer", +] diff --git a/rapidquery/serializers.pyi b/rapidquery/serializers.pyi new file mode 100644 index 0000000..f93fd26 --- /dev/null +++ b/rapidquery/serializers.pyi @@ -0,0 +1,320 @@ +import datetime +import decimal +import enum +import typing +import uuid +from collections.abc import Callable + +from rapidquery.dialect import Dialect + +_T = typing.TypeVar("_T") + +class Serializer(typing.Generic[_T]): + """ + Converts a Python object into a dialect-specific SQL literal string. + + Serializers produce a correctly escaped and formatted SQL value that can + be embedded in queries. Each concrete subclass handles a particular + category of values (integers, strings, dates, arrays, etc.). Use + `CustomSerializer` when the built-in ones are insufficient. + + This base class cannot be instantiated. Pass a dialect class or instance + to `serialize()`. Unit serializers compare equal by variant; + `ArraySerializer` by its element tree; `CustomSerializer` by function + identity. + """ + + def serialize(self, dialect: Dialect, object: _T) -> str: + """ + Renders `object` as a SQL literal for `dialect`. + + Args: + dialect: A dialect class (`PostgreSQL`) or instance (`PostgreSQL()`). + object: The Python value to convert. + + Returns: + A SQL fragment suitable for embedding in a statement. + + Raises: + TypeError: If `object` is not an accepted type for this serializer. + ValueError: If `object` is the right type but cannot be represented + (for example a non-finite float). + NotImplementedError: If `dialect` has no native literal for this + serializer (bit strings, `ARRAY[…]`, inet, macaddr). + """ + + def __eq__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... + +@typing.final +class IntegerSerializer(Serializer[int]): + """ + Serializes Python integers as bare SQL integer literals. + + Accepts Python `int` of any width (including values beyond 64-bit). + Rejects `bool`; `True` and `False` are not treated as integers. + + Raises: + TypeError: If the value is not an `int`. + """ + + def __new__(cls) -> typing.Self: ... + +@typing.final +class FloatSerializer(Serializer[int | float | decimal.Decimal]): + """ + Serializes numeric values as bare SQL numeric literals. + + Accepts `float`, `int`, and `decimal.Decimal`. Finite floats are formatted + so that `1.0` stays distinct from `1`. `str` is not accepted. + + Raises: + TypeError: If the value is not a `float`, `int`, or `decimal.Decimal`. + ValueError: If the value is a non-finite float (`nan`, `inf`, `-inf`). + """ + + def __new__(cls) -> typing.Self: ... + +@typing.final +class BooleanSerializer(Serializer[bool]): + """ + Serializes Python booleans as dialect boolean literals. + + Accepts Python `bool` only. The dialect chooses the spelling + (`TRUE`/`FALSE` or `1`/`0`). + + Raises: + TypeError: If the value is not a `bool`. + """ + + def __new__(cls) -> typing.Self: ... + +@typing.final +class StringSerializer(Serializer[str | enum.Enum]): + """ + Serializes text as a quoted, escaped SQL string literal. + + Accepts `str`, or an `enum.StrEnum`. The + dialect supplies quoting and escaping. + + Raises: + TypeError: If the value is not a `str` or a string-valued enum member. + """ + + def __new__(cls) -> typing.Self: ... + +@typing.final +class UuidSerializer(Serializer[uuid.UUID | str]): + """ + Serializes UUIDs using the dialect's ``uuid_format``. + + Accepts `uuid.UUID` or `str`. A quoted literal, ``UUID '…'``, or ``'…'::uuid``. + + Raises: + TypeError: If the value is not a `uuid.UUID` or `str`. + ValueError: If the value is `str`, but it is not a UUID. + """ + + def __new__(cls) -> typing.Self: ... + +@typing.final +class BytesSerializer(Serializer[bytes]): + """ + Serializes binary data as a dialect blob/bytea literal. + + Accepts exact `bytes` (not `bytearray` or `memoryview`). + + Raises: + TypeError: If the value is not `bytes`. + """ + + def __new__(cls) -> typing.Self: ... + +@typing.final +class BitSerializer(Serializer[str]): + """ + Serializes bit strings as dialect bit-string literals. + + Accepts a `str` consisting only of `'0'` and `'1'` characters. + Supported on PostgreSQL, DuckDB, MySQL, and Teradata. + + Raises: + TypeError: If the value is not a `str`. + ValueError: If the string is empty or contains characters other than `'0'` and `'1'`. + NotImplementedError: If the dialect has no native bit-string literal. + """ + + def __new__(cls) -> typing.Self: ... + +@typing.final +class JSONSerializer(Serializer[typing.Any]): + """ + Serializes a JSON-encodable object using the dialect's ``json_format``. + + A quoted literal, ``JSON '…'``, or ``PARSE_JSON('…')``. Accepts any object + that the JSON encoder can dump. Uses `orjson` when it + is installed, otherwise the stdlib `json` module. The JSON text is then + wrapped with the dialect's string quoting. With `orjson`, datetime, UUID, + enum.StrEnum, and dataclass are encoded natively; stdlib `json` + rejects those. + + Raises: + TypeError: If the value cannot be JSON-encoded. + """ + + def __new__(cls) -> typing.Self: ... + +@typing.final +class DateSerializer(Serializer[datetime.date]): + """ + Serializes dates as `YYYY-MM-DD` literals. + + ``date_format`` chooses a quoted literal or ``DATE`` plus that literal. + + Accepts exact `datetime.date` (not `datetime.datetime`). + + Raises: + TypeError: If the value is not a `datetime.date`. + """ + + def __new__(cls) -> typing.Self: ... + +@typing.final +class TimeSerializer(Serializer[datetime.time]): + """ + Serializes times as `HH:MM:SS[.ffffff]` literals. + + ``time_format`` chooses a quoted literal or ``TIME`` plus that literal. + + Accepts `datetime.time`. + + Raises: + TypeError: If the value is not a `datetime.time`. + """ + + def __new__(cls) -> typing.Self: ... + +@typing.final +class DatetimeSerializer(Serializer[datetime.datetime]): + """ + Serializes timestamps as SQL timestamp literals. + + Accepts naive or aware `datetime.datetime`, or `int`/`float` UTC epoch + seconds. Naive values keep wall time. Aware values keep their original + offset (they are not converted to UTC). When ``timestamp_format`` sets a + keyword, naive values use ``TIMESTAMP`` and aware values (including epoch + numbers) use ``TIMESTAMPTZ`` or ``TIMESTAMP WITH TIMEZONE``. + + Raises: + TypeError: If the value is not a `datetime.datetime`, `int`, or `float`. + OverflowError: If an epoch value is out of range for a timestamp. + """ + + def __new__(cls) -> typing.Self: ... + +@typing.final +class InetSerializer(Serializer[typing.Any]): + """ + Serializes IP addresses and networks using the dialect's ``inet_format``. + + A quoted literal or ``'…'::inet``. + + Accepts `str` or `ipaddress` objects (`IPv4Address`, `IPv6Address`, + `IPv4Network`, `IPv6Network`, and related types whose `str()` is a valid + address or CIDR). Supported on PostgreSQL and DuckDB. + + Raises: + ValueError: If the value is not a valid IPv4/IPv6 address or network. + NotImplementedError: If the dialect has no native INET type. + """ + + def __new__(cls) -> typing.Self: ... + +@typing.final +class MacAddressSerializer(Serializer[str]): + """ + Serializes MAC addresses using the dialect's ``macaddr_format``. + + A quoted literal or ``'…'::macaddr``. + + Accepts a `str` using `:` or `-` as the separator. + Supported on PostgreSQL. + + Raises: + ValueError: If the value is not a valid MAC address. + NotImplementedError: If the dialect has no native MACADDR type. + """ + + def __new__(cls) -> typing.Self: ... + +@typing.final +class UnspecifiedSerializer(Serializer[typing.Any]): + """ + Inserts `str(obj)` into SQL with no quoting or type check. + + Used when the Python object matches no built-in serializer. The result + is not a safe SQL value; prefer an explicit serializer. + """ + + def __new__(cls) -> typing.Self: ... + +@typing.final +class ArraySerializer(Serializer[list[_T] | tuple[_T]]): + """ + Serializes sequences as `ARRAY[elem, ...]` literals. + + Each element is serialized independently with `element`. Accepts `list` + or `tuple`. Nested arrays are expressed by wrapping another + `ArraySerializer`. Supported on PostgreSQL, DuckDB, and BigQuery. + + Raises: + TypeError: If the value is not a `list` or `tuple`. + NotImplementedError: If the dialect has no `ARRAY[…]` constructor. + """ + + def __new__(cls, element: Serializer[_T]) -> typing.Self: + """ + Creates an array serializer. + + Args: + element: The serializer applied to every array item. Nested arrays + are expressed by passing another `ArraySerializer`. + """ + + @property + def element(self) -> Serializer[_T]: + """ + Returns the serializer used for each array element. + + Returns: + The `Serializer` passed to the constructor. + """ + +@typing.final +class CustomSerializer(Serializer[_T]): + """ + Serializes values by calling a user-supplied Python function. + + `function` has the form `fn(dialect_name: str, obj) -> str`. The return + value is inserted into SQL as-is (not re-quoted or escaped). It is the + caller's responsibility to produce a safe fragment. + """ + + def __new__(cls, function: Callable[[Dialect, _T], str]) -> typing.Self: + """ + Creates a serializer backed by a Python callable. + + Args: + function: A callable of the form `fn(dialect_name: str, obj) -> str`. + The return value is inserted into SQL as-is (not re-quoted). + Must return a `str`. + """ + + @property + def function(self) -> Callable[[str, _T], str]: + """ + Returns the callable passed to the constructor. + + Returns: + The function given as `function` to `__new__`. + """ diff --git a/src/data_type/mod.rs b/src/data_type/mod.rs index 5a7c213..70e6b6b 100644 --- a/src/data_type/mod.rs +++ b/src/data_type/mod.rs @@ -1,65 +1,40 @@ -//! SQL data types: interned names, serializers, and the composed [`DataType`]. +//! SQL data types: interned names and [`DataType`]. //! -//! A [`DataTypeName`] is a canonical spelling plus a [`DataTypeGroup`]. The -//! group is the inference key for which [`Serializer`] [`DataType::from_name`] -//! constructs when none is passed. Groups are serializer-oriented: `NUMERIC` -//! and `DECIMAL` sit in [`DataTypeGroup::Float`] because they share a -//! serializer with IEEE floats. +//! A [`DataTypeName`] is a canonical SQL spelling. Known names are interned. +//! Serializing a Python value is [`crate::serializers`], not this module. mod name; -mod serializer; #[cfg(test)] mod tests; -pub use name::DataTypeKind; pub use name::DataTypeName; -pub use serializer::*; -use crate::internal::alias; +use crate::internal::repr::ReprFormatter; use crate::internal::utils; /// Represents a SQL data type. /// -/// A data type is a SQL name plus the serializer used to turn Python objects -/// into SQL literals. The name can be any string; it is not limited to a set -/// of predefined values. The serializer is fully customizable. +/// A data type is a SQL type name. The name can be any string; it is not +/// limited to a set of predefined values. /// /// Known names are interned and canonicalized (`"int"` becomes `"INT"`). /// Unknown names and parameterized spellings (`VARCHAR(255)`) are kept as /// given. Interning applies to the **name string**, not to `DataType` /// instances: `DataType("int")` is not a singleton. /// -/// If `serializer` is omitted at construction, one is inferred from the -/// name; unrecognized names get `UnspecifiedSerializer`. -/// -/// [`WriteSql`] emits the type name (CAST, column definitions). Literal -/// serialization of a Python object is [`Serializer::write_sql`], not this -/// type. -/// -/// Clone of a [`Serializer::Custom`] value requires the GIL. -#[rapidquery_macros::pyclass("rapidquery.data_type.DataType", from_py_object, eq, hash, generic)] +/// [`WriteSql`] emits the type name (CAST, column definitions). A Python +/// value is serialized by a `rapidquery.serializers` serializer on `Value`. +#[rapidquery_macros::pyclass("rapidquery.data_type.DataType", from_py_object, eq, hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct PyDataType { pub name: DataTypeName, - pub serializer: SerializerType, } impl PyDataType { - #[inline] - pub fn new(name: DataTypeName, serializer: SerializerType) -> Self { - Self { name, serializer } - } - - /// Infer the serializer from [`DataTypeName::group`]. - /// - /// Unrecognized and parameterized names use [`Serializer::Unspecified`]. #[inline] pub fn from_name(name: impl Into) -> Self { - let name = name.into(); - let serializer = SerializerType::from_kind(name.kind()); - - Self { name, serializer } + Self { name: name.into() } } } @@ -83,19 +58,10 @@ impl PyDataType { /// Known names are interned and canonicalized (`"int"` /// becomes `"INT"`). Unknown names and parameterized /// spellings (`VARCHAR(255)`) are kept as given. - /// serializer: Used when a Python object is rendered as a SQL - /// literal (defaults, inserts, raw SQL). Built-in - /// serializers cover common types; `CustomSerializer` - /// covers the rest. If omitted, a serializer is inferred - /// from `name`; unrecognized names get - /// `UnspecifiedSerializer`. #[new] - #[pyo3(signature = (name, serializer = None, /))] - fn __new__(name: String, serializer: Option) -> Self { - match serializer { - Some(PySerializer(s)) => PyDataType::new(DataTypeName::from(name), s), - None => PyDataType::from_name(name), - } + #[pyo3(signature = (name, /))] + fn __new__(name: String) -> Self { + PyDataType::from_name(name) } /// Returns the type name. @@ -108,47 +74,10 @@ impl PyDataType { self.name.as_str() } - /// Returns the serializer used to render Python values as SQL literals. - /// - /// Returns: - /// The `Serializer` associated with this data type. - #[getter] - fn get_serializer(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { - PySerializer(slf.serializer.clone()).into_subclasses(slf.py()) - } - - /// Renders `object` as a SQL literal for `dialect`. - /// - /// Shorthand for `self.serializer.serialize(dialect, object)`. - /// - /// Args: - /// dialect: A dialect class (`PostgreSQL`) or instance (`PostgreSQL()`). - /// object: The Python value to convert. - /// - /// Returns: - /// A SQL fragment produced by this type's serializer. - /// - /// Raises: - /// TypeError: If `object` is not an accepted type for this serializer. - /// ValueError: If `object` is the right type but cannot be represented. - /// NotImplementedError: If `dialect` has no native literal for this - /// type's serializer (bit strings, `ARRAY[…]`, inet, macaddr). - fn serialize( - &self, - dialect: crate::dialect::PyDialect, - object: alias::BoundObject<'_>, - ) -> pyo3::PyResult { - let mut buf = String::new(); - self.serializer.serialize(&dialect, &object, &mut buf)?; - Ok(buf) - } - fn __repr__(&self) -> String { - format!( - "DataType({:?}, {})", - self.name.as_str(), - serializer_repr(&self.serializer) - ) + let mut fmt = ReprFormatter::new_call("DataType"); + fmt.positional_debug(self.name.as_str()); + fmt.into_string() } } @@ -179,7 +108,7 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for DataTypeNameExtractor { rapidquery_macros::pyerr!( Err(PyTypeError), - "expected Serializer or DataType, got {}", + "expected str or DataType, got {}", unsafe { crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) } ) } @@ -187,40 +116,6 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for DataTypeNameExtractor { #[pyo3::pymodule(name = "data_type", gil_used = false)] pub mod data_type_mod { - #[pymodule_export] - use super::PyArraySerializer; - #[pymodule_export] - use super::PyBitSerializer; - #[pymodule_export] - use super::PyBooleanSerializer; - #[pymodule_export] - use super::PyBytesSerializer; - #[pymodule_export] - use super::PyCustomSerializer; #[pymodule_export] use super::PyDataType; - #[pymodule_export] - use super::PyDateSerializer; - #[pymodule_export] - use super::PyDatetimeSerializer; - #[pymodule_export] - use super::PyFloatSerializer; - #[pymodule_export] - use super::PyInetSerializer; - #[pymodule_export] - use super::PyIntegerSerializer; - #[pymodule_export] - use super::PyJSONSerializer; - #[pymodule_export] - use super::PyMacAddressSerializer; - #[pymodule_export] - use super::PySerializer; - #[pymodule_export] - use super::PyStringSerializer; - #[pymodule_export] - use super::PyTimeSerializer; - #[pymodule_export] - use super::PyUnspecifiedSerializer; - #[pymodule_export] - use super::PyUuidSerializer; } diff --git a/src/data_type/name.rs b/src/data_type/name.rs index bcc9de1..c00ba58 100644 --- a/src/data_type/name.rs +++ b/src/data_type/name.rs @@ -2,41 +2,15 @@ use crate::sql::PrepareResult; use crate::sql::SqlWriter; use crate::sql::WriteSql; -/// Category used to infer a built-in serializer from a type name. -/// -/// Distinct SQL spellings collapse onto one serializer (every integer width -/// is [`crate::data_type::Serializer::Integer`]). [`Json`] and [`Jsonb`] stay -/// distinct as groups so a later layer can treat storage differently; both -/// currently infer [`crate::data_type::Serializer::Json`]. -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord, Hash)] -pub enum DataTypeKind { - String, - Uuid, - Bytes, - Bit, - Integer, - Float, - Boolean, - Date, - Time, - Datetime, - Json, - Jsonb, - Inet, - MacAddress, - Custom, -} - -/// SQL type spelling plus its [`DataTypeGroup`]. +/// SQL type spelling. /// /// Known names are interned (`arcstr::literal!`) under that same spelling /// (case-folded to the traditional uppercase form). Unknown and parameterized -/// names keep the caller's spelling and are [`DataTypeGroup::Custom`]. -/// Synonyms are not rewritten to a different name. +/// names keep the caller's spelling. Synonyms are not rewritten to a different +/// name. #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] pub struct DataTypeName { name: arcstr::ArcStr, - kind: DataTypeKind, } impl DataTypeName { @@ -44,11 +18,6 @@ impl DataTypeName { pub fn as_str(&self) -> &str { &self.name } - - #[inline] - pub fn kind(&self) -> DataTypeKind { - self.kind - } } // Longest interned name is `"timestamp without time zone"` (27 bytes). @@ -68,10 +37,7 @@ fn interned(value: &str) -> Option { } fn custom_name>(value: T) -> DataTypeName { - DataTypeName { - name: value.into(), - kind: DataTypeKind::Custom, - } + DataTypeName { name: value.into() } } // Interned no-arg types. `arcstr::literal!` shares one allocation per spelling. @@ -79,14 +45,13 @@ macro_rules! simple_data_types { ( $( $(#[$docs:meta])* - ($upcase:ident => ($realname:literal, $kind:ident), $($possible_names:literal)|+); + ($upcase:ident => $realname:literal, $($possible_names:literal)|+); )+ ) => { $( $(#[$docs])* pub(super) const $upcase: DataTypeName = DataTypeName { name: arcstr::literal!($realname), - kind: DataTypeKind::$kind, }; )+ @@ -103,73 +68,73 @@ macro_rules! simple_data_types { simple_data_types! { // --- String --- - (CHARACTER_DATA_TYPE => ("CHARACTER", String), "character"); - (CHAR_DATA_TYPE => ("CHAR", String), "char"); - (CHARACTER_VARYING_DATA_TYPE => ("CHARACTER VARYING", String), "character varying"); - (CHAR_VARYING_DATA_TYPE => ("CHAR VARYING", String), "char varying"); - (VARCHAR_DATA_TYPE => ("VARCHAR", String), "varchar"); - (NVARCHAR_DATA_TYPE => ("NVARCHAR", String), "nvarchar"); - (TEXT_DATA_TYPE => ("TEXT", String), "text"); + (CHARACTER_DATA_TYPE => "CHARACTER", "character"); + (CHAR_DATA_TYPE => "CHAR", "char"); + (CHARACTER_VARYING_DATA_TYPE => "CHARACTER VARYING", "character varying"); + (CHAR_VARYING_DATA_TYPE => "CHAR VARYING", "char varying"); + (VARCHAR_DATA_TYPE => "VARCHAR", "varchar"); + (NVARCHAR_DATA_TYPE => "NVARCHAR", "nvarchar"); + (TEXT_DATA_TYPE => "TEXT", "text"); // --- Uuid --- - (UUID_DATA_TYPE => ("UUID", Uuid), "uuid"); + (UUID_DATA_TYPE => "UUID", "uuid"); // --- Bytes --- - (BYTES_DATA_TYPE => ("BYTES", Bytes), "bytes"); - (BINARY_DATA_TYPE => ("BINARY", Bytes), "binary"); - (VARBINARY_DATA_TYPE => ("VARBINARY", Bytes), "varbinary"); - (BLOB_DATA_TYPE => ("BLOB", Bytes), "blob"); - (BYTEA_DATA_TYPE => ("BYTEA", Bytes), "bytea"); + (BYTES_DATA_TYPE => "BYTES", "bytes"); + (BINARY_DATA_TYPE => "BINARY", "binary"); + (VARBINARY_DATA_TYPE => "VARBINARY", "varbinary"); + (BLOB_DATA_TYPE => "BLOB", "blob"); + (BYTEA_DATA_TYPE => "BYTEA", "bytea"); // --- Bit --- - (BIT_DATA_TYPE => ("BIT", Bit), "bit"); - (VARBIT_DATA_TYPE => ("VARBIT", Bit), "varbit"); - (BIT_VARYING_DATA_TYPE => ("BIT VARYING", Bit), "bit varying"); + (BIT_DATA_TYPE => "BIT", "bit"); + (VARBIT_DATA_TYPE => "VARBIT", "varbit"); + (BIT_VARYING_DATA_TYPE => "BIT VARYING", "bit varying"); // --- Float (includes decimal/numeric) --- - (NUMERIC_DATA_TYPE => ("NUMERIC", Float), "numeric"); - (BIGNUMERIC_DATA_TYPE => ("BIGNUMERIC", Float), "bignumeric"); - (DECIMAL_DATA_TYPE => ("DECIMAL", Float), "decimal"); - (BIGDECIMAL_DATA_TYPE => ("BIGDECIMAL", Float), "bigdecimal"); - (FLOAT_DATA_TYPE => ("FLOAT", Float), "float"); - (REAL_DATA_TYPE => ("REAL", Float), "real"); - (DOUBLE_DATA_TYPE => ("DOUBLE", Float), "double"); - (DOUBLE_PRECISION_DATA_TYPE => ("DOUBLE PRECISION", Float), "double precision"); + (NUMERIC_DATA_TYPE => "NUMERIC", "numeric"); + (BIGNUMERIC_DATA_TYPE => "BIGNUMERIC", "bignumeric"); + (DECIMAL_DATA_TYPE => "DECIMAL", "decimal"); + (BIGDECIMAL_DATA_TYPE => "BIGDECIMAL", "bigdecimal"); + (FLOAT_DATA_TYPE => "FLOAT", "float"); + (REAL_DATA_TYPE => "REAL", "real"); + (DOUBLE_DATA_TYPE => "DOUBLE", "double"); + (DOUBLE_PRECISION_DATA_TYPE => "DOUBLE PRECISION", "double precision"); // --- Integer --- - (TINYINT_DATA_TYPE => ("TINYINT", Integer), "tinyint"); - (SMALLINT_DATA_TYPE => ("SMALLINT", Integer), "smallint"); - (INT_DATA_TYPE => ("INT", Integer), "int"); - (INTEGER_DATA_TYPE => ("INTEGER", Integer), "integer"); - (BIGINT_DATA_TYPE => ("BIGINT", Integer), "bigint"); + (TINYINT_DATA_TYPE => "TINYINT", "tinyint"); + (SMALLINT_DATA_TYPE => "SMALLINT", "smallint"); + (INT_DATA_TYPE => "INT", "int"); + (INTEGER_DATA_TYPE => "INTEGER", "integer"); + (BIGINT_DATA_TYPE => "BIGINT", "bigint"); // --- Boolean --- - (BOOLEAN_DATA_TYPE => ("BOOLEAN", Boolean), "boolean" | "bool"); + (BOOLEAN_DATA_TYPE => "BOOLEAN", "boolean" | "bool"); // --- Date --- - (DATE_DATA_TYPE => ("DATE", Date), "date"); + (DATE_DATA_TYPE => "DATE", "date"); // --- Time --- - (TIME_DATA_TYPE => ("TIME", Time), "time" | "time without timezone" | "time without time zone"); - (TIME_WITH_TIMEZONE_DATA_TYPE => ("TIME WITH TIMEZONE", Time), "time with timezone"); - (TIME_WITH_TIME_ZONE_DATA_TYPE => ("TIME WITH TIME ZONE", Time), "time with time zone"); + (TIME_DATA_TYPE => "TIME", "time" | "time without timezone" | "time without time zone"); + (TIME_WITH_TIMEZONE_DATA_TYPE => "TIME WITH TIMEZONE", "time with timezone"); + (TIME_WITH_TIME_ZONE_DATA_TYPE => "TIME WITH TIME ZONE", "time with time zone"); // --- Datetime --- - (DATETIME_DATA_TYPE => ("DATETIME", Datetime), "datetime"); - (TIMESTAMP_DATA_TYPE => ("TIMESTAMP", Datetime), "timestamp" | "timestamp without timezone" | "timestamp without time zone"); - (TIMESTAMP_WITH_TIMEZONE_DATA_TYPE => ("TIMESTAMP WITH TIMEZONE", Datetime), "timestamp with timezone"); - (TIMESTAMP_WITH_TIME_ZONE_DATA_TYPE => ("TIMESTAMP WITH TIME ZONE", Datetime), "timestamp with time zone"); - (TIMESTAMPTZ_DATA_TYPE => ("TIMESTAMPTZ", Datetime), "timestamptz"); + (DATETIME_DATA_TYPE => "DATETIME", "datetime"); + (TIMESTAMP_DATA_TYPE => "TIMESTAMP", "timestamp" | "timestamp without timezone" | "timestamp without time zone"); + (TIMESTAMP_WITH_TIMEZONE_DATA_TYPE => "TIMESTAMP WITH TIMEZONE", "timestamp with timezone"); + (TIMESTAMP_WITH_TIME_ZONE_DATA_TYPE => "TIMESTAMP WITH TIME ZONE", "timestamp with time zone"); + (TIMESTAMPTZ_DATA_TYPE => "TIMESTAMPTZ", "timestamptz"); // --- Json --- - (JSON_DATA_TYPE => ("JSON", Json), "json"); - (JSONB_DATA_TYPE => ("JSONB", Jsonb), "jsonb"); + (JSON_DATA_TYPE => "JSON", "json"); + (JSONB_DATA_TYPE => "JSONB", "jsonb"); // --- Inet --- - (INET_DATA_TYPE => ("INET", Inet), "inet"); + (INET_DATA_TYPE => "INET", "inet"); // --- MacAddress --- - (MACADDRESS_DATA_TYPE => ("MACADDRESS", MacAddress), "macaddress" | "macaddr"); + (MACADDRESS_DATA_TYPE => "MACADDRESS", "macaddress" | "macaddr"); } fn is_parameterized(value: &str) -> bool { diff --git a/src/data_type/tests.rs b/src/data_type/tests.rs index 27b94cd..a488731 100644 --- a/src/data_type/tests.rs +++ b/src/data_type/tests.rs @@ -1,105 +1,75 @@ use super::*; -fn assert_copy(_: T) {} - -#[test] -fn data_type_group_is_copy() { - assert_copy(DataTypeKind::Integer); -} - #[test] fn all_interned_names_canonicalize() { let cases = [ // --- String --- - ("character", "CHARACTER", DataTypeKind::String), - ("char", "CHAR", DataTypeKind::String), - ( - "character varying", - "CHARACTER VARYING", - DataTypeKind::String, - ), - ("char varying", "CHAR VARYING", DataTypeKind::String), - ("varchar", "VARCHAR", DataTypeKind::String), - ("nvarchar", "NVARCHAR", DataTypeKind::String), - ("text", "TEXT", DataTypeKind::String), + ("character", "CHARACTER"), + ("char", "CHAR"), + ("character varying", "CHARACTER VARYING"), + ("char varying", "CHAR VARYING"), + ("varchar", "VARCHAR"), + ("nvarchar", "NVARCHAR"), + ("text", "TEXT"), // --- Uuid --- - ("uuid", "UUID", DataTypeKind::Uuid), + ("uuid", "UUID"), // --- Bytes --- - ("binary", "BINARY", DataTypeKind::Bytes), - ("varbinary", "VARBINARY", DataTypeKind::Bytes), - ("blob", "BLOB", DataTypeKind::Bytes), - ("bytea", "BYTEA", DataTypeKind::Bytes), + ("binary", "BINARY"), + ("varbinary", "VARBINARY"), + ("blob", "BLOB"), + ("bytea", "BYTEA"), // --- Bit --- - ("bit", "BIT", DataTypeKind::Bit), - ("bit varying", "BIT VARYING", DataTypeKind::Bit), + ("bit", "BIT"), + ("bit varying", "BIT VARYING"), // --- Float (includes decimal/numeric) --- - ("numeric", "NUMERIC", DataTypeKind::Float), - ("decimal", "DECIMAL", DataTypeKind::Float), - ("dec", "DEC", DataTypeKind::Float), - ("float", "FLOAT", DataTypeKind::Float), - ("real", "REAL", DataTypeKind::Float), - ("double", "DOUBLE", DataTypeKind::Float), - ("double precision", "DOUBLE PRECISION", DataTypeKind::Float), + ("numeric", "NUMERIC"), + ("decimal", "DECIMAL"), + ("dec", "DEC"), + ("float", "FLOAT"), + ("real", "REAL"), + ("double", "DOUBLE"), + ("double precision", "DOUBLE PRECISION"), // --- Integer --- - ("tinyint", "TINYINT", DataTypeKind::Integer), - ("smallint", "SMALLINT", DataTypeKind::Integer), - ("int", "INT", DataTypeKind::Integer), - ("integer", "INTEGER", DataTypeKind::Integer), - ("bigint", "BIGINT", DataTypeKind::Integer), + ("tinyint", "TINYINT"), + ("smallint", "SMALLINT"), + ("int", "INT"), + ("integer", "INTEGER"), + ("bigint", "BIGINT"), // --- Boolean --- - ("boolean", "BOOLEAN", DataTypeKind::Boolean), + ("boolean", "BOOLEAN"), // --- Date --- - ("date", "DATE", DataTypeKind::Date), + ("date", "DATE"), // --- Time --- - ("time", "TIME", DataTypeKind::Time), - ( - "time with timezone", - "TIME WITH TIMEZONE", - DataTypeKind::Time, - ), + ("time", "TIME"), + ("time with timezone", "TIME WITH TIMEZONE"), // --- Datetime --- - ("datetime", "DATETIME", DataTypeKind::Datetime), - ("timestamp", "TIMESTAMP", DataTypeKind::Datetime), - ( - "timestamp without timezone", - "TIMESTAMP", - DataTypeKind::Datetime, - ), - ( - "timestamp with timezone", - "TIMESTAMP WITH TIMEZONE", - DataTypeKind::Datetime, - ), - ( - "timestamp with time zone", - "TIMESTAMP WITH TIME ZONE", - DataTypeKind::Datetime, - ), - ("timestamptz", "TIMESTAMPTZ", DataTypeKind::Datetime), - // --- Json / Jsonb stay distinct groups --- - ("json", "JSON", DataTypeKind::Json), - ("jsonb", "JSONB", DataTypeKind::Jsonb), + ("datetime", "DATETIME"), + ("timestamp", "TIMESTAMP"), + ("timestamp without timezone", "TIMESTAMP"), + ("timestamp with timezone", "TIMESTAMP WITH TIMEZONE"), + ("timestamp with time zone", "TIMESTAMP WITH TIME ZONE"), + ("timestamptz", "TIMESTAMPTZ"), + // --- Json / Jsonb stay distinct spellings --- + ("json", "JSON"), + ("jsonb", "JSONB"), // --- Inet --- - ("inet", "INET", DataTypeKind::Inet), + ("inet", "INET"), // --- MacAddress (alias collapses onto the interned spelling) --- - ("macaddress", "MACADDRESS", DataTypeKind::MacAddress), - ("macaddr", "MACADDRESS", DataTypeKind::MacAddress), + ("macaddress", "MACADDRESS"), + ("macaddr", "MACADDRESS"), ]; - for (input, canonical, kind) in cases { + for (input, canonical) in cases { let from_str = DataTypeName::from(input); assert_eq!(from_str.as_str(), canonical, "input {input:?}"); - assert_eq!(from_str.kind(), kind, "input {input:?}"); let upper = input.to_ascii_uppercase(); let from_upper = DataTypeName::from(upper.as_str()); assert_eq!(from_upper.as_str(), canonical, "input {upper:?}"); - assert_eq!(from_upper.kind(), kind, "input {upper:?}"); assert_eq!(from_str, from_upper, "case fold of {input:?}"); let from_string = DataTypeName::from(input.to_string()); assert_eq!(from_string, from_str, "From of {input:?}"); - assert_eq!(from_string.kind(), kind); } } @@ -113,7 +83,7 @@ fn interned_synonyms_compare_equal() { DataTypeName::from("macaddress"), DataTypeName::from("MACADDR"), ); - // Distinct interned spellings stay distinct even when the kind matches. + // Distinct interned spellings stay distinct. assert_ne!( DataTypeName::from("timestamp with timezone"), DataTypeName::from("timestamp with time zone"), @@ -125,27 +95,22 @@ fn interned_synonyms_compare_equal() { #[test] fn mixed_case_known_names_are_canonicalized() { let cases = [ - ("Int", "INT", DataTypeKind::Integer), - ("InTeGeR", "INTEGER", DataTypeKind::Integer), - ("VarChar", "VARCHAR", DataTypeKind::String), - ("tExT", "TEXT", DataTypeKind::String), - ("UuId", "UUID", DataTypeKind::Uuid), - ("Boolean", "BOOLEAN", DataTypeKind::Boolean), - ("TimeStampTz", "TIMESTAMPTZ", DataTypeKind::Datetime), - ( - "Timestamp Without Timezone", - "TIMESTAMP", - DataTypeKind::Datetime, - ), - ("MacAddr", "MACADDRESS", DataTypeKind::MacAddress), - ("JsonB", "JSONB", DataTypeKind::Jsonb), - ("Double Precision", "DOUBLE PRECISION", DataTypeKind::Float), - ("ByteA", "BYTEA", DataTypeKind::Bytes), + ("Int", "INT"), + ("InTeGeR", "INTEGER"), + ("VarChar", "VARCHAR"), + ("tExT", "TEXT"), + ("UuId", "UUID"), + ("Boolean", "BOOLEAN"), + ("TimeStampTz", "TIMESTAMPTZ"), + ("Timestamp Without Timezone", "TIMESTAMP"), + ("MacAddr", "MACADDRESS"), + ("JsonB", "JSONB"), + ("Double Precision", "DOUBLE PRECISION"), + ("ByteA", "BYTEA"), ]; - for (input, canonical, kind) in cases { + for (input, canonical) in cases { let dt = DataTypeName::from(input); assert_eq!(dt.as_str(), canonical, "input {input:?}"); - assert_eq!(dt.kind(), kind, "input {input:?}"); } } @@ -154,7 +119,6 @@ fn parameterized_name_is_custom_and_preserved() { for input in ["varchar(255)", "VARCHAR(255)", "decimal(10, 2)", "int(11)"] { let dt = DataTypeName::from(input); assert_eq!(dt.as_str(), input); - assert_eq!(dt.kind(), DataTypeKind::Custom); } } @@ -162,18 +126,16 @@ fn parameterized_name_is_custom_and_preserved() { fn unknown_name_is_custom_and_preserved() { let dt = DataTypeName::from("some_totally_unknown_type"); assert_eq!(dt.as_str(), "some_totally_unknown_type"); - assert_eq!(dt.kind(), DataTypeKind::Custom); } #[test] fn from_string_reuses_unknown_allocation_path() { let dt = DataTypeName::from(String::from("not_a_real_type")); assert_eq!(dt.as_str(), "not_a_real_type"); - assert_eq!(dt.kind(), DataTypeKind::Custom); } /// Non-ASCII never hits the interned table: the lookup bails before matching, -/// and the caller's spelling (including unprintable scalars) is kept as Custom. +/// and the caller's spelling (including unprintable scalars) is kept. #[test] fn unicode_and_unprintable_names_are_custom() { let cases = [ @@ -197,11 +159,9 @@ fn unicode_and_unprintable_names_are_custom() { for input in cases { let from_str = DataTypeName::from(input); assert_eq!(from_str.as_str(), input, "input {input:?}"); - assert_eq!(from_str.kind(), DataTypeKind::Custom, "input {input:?}"); let from_string = DataTypeName::from(input.to_string()); assert_eq!(from_string.as_str(), input, "From {input:?}"); - assert_eq!(from_string.kind(), DataTypeKind::Custom); assert_eq!(from_str, from_string); } } diff --git a/src/expr/base.rs b/src/expr/base.rs index e89a98f..66cfaab 100644 --- a/src/expr/base.rs +++ b/src/expr/base.rs @@ -105,6 +105,6 @@ impl PyExpr { } fn __repr__(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - self.0.python_repr(py) + Ok(self.0.python_repr(py)?.into_string()) } } diff --git a/src/expr/enum_/func.rs b/src/expr/enum_/func.rs index 6fe2851..6f17542 100644 --- a/src/expr/enum_/func.rs +++ b/src/expr/enum_/func.rs @@ -2,6 +2,7 @@ use super::ExprInner; use super::ident::Ident; use crate::dialect::flags; +use crate::internal::repr::ReprFormatter; use crate::sql::PrepareResult; use crate::sql::SqlWriter; use crate::sql::WriteSql; @@ -46,33 +47,29 @@ pub struct Function { } impl Function { - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = format!("Function({}", self.name.python_repr()); - - for arg in self.args.iter() { - buf.push_str(", "); - buf.push_str(&arg.python_repr(py)?); - } - - if self.distinct { - if !self.args.is_empty() { - buf.push_str(", "); - } - buf.push_str("distinct=True"); + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = ReprFormatter::new_call("Function"); + fmt.positional_formatter(self.name.python_repr()); + for arg in &self.args { + fmt.positional_formatter(arg.python_repr(py)?); } - buf.push(')'); + fmt.keyword_if_true("distinct", self.distinct); + fmt.close(); match self.null_treatment { - Some(NullTreatment::IgnoreNulls) => buf.push_str(".ignore_nulls()"), - Some(NullTreatment::RespectNulls) => buf.push_str(".respect_nulls()"), + Some(NullTreatment::IgnoreNulls) => { + fmt.append_suffix(".ignore_nulls()"); + } + Some(NullTreatment::RespectNulls) => { + fmt.append_suffix(".respect_nulls()"); + } None => {} } if let Some(filter) = &self.filter { - buf.push_str(".filter("); - buf.push_str(&filter.python_repr(py)?); - buf.push(')'); + let argument = filter.python_repr(py)?; + fmt.append_call("filter", &argument); } - Ok(buf) + Ok(fmt) } } diff --git a/src/expr/enum_/ident.rs b/src/expr/enum_/ident.rs index b00adaf..9af22f6 100644 --- a/src/expr/enum_/ident.rs +++ b/src/expr/enum_/ident.rs @@ -1,6 +1,7 @@ //! SQL identifiers. use crate::dialect::PyDialect; +use crate::internal::repr::ReprFormatter; use crate::sql::PrepareResult; use crate::sql::SqlWriter; use crate::sql::WriteSql; @@ -159,22 +160,24 @@ impl Ident { self.0 } - pub fn python_repr(&self) -> String { - use std::fmt::Write; - - let mut result = String::from("Ident("); - let parts = self.parts(); - for (index, item) in parts.iter().enumerate() { + pub fn python_repr(&self) -> ReprFormatter { + let mut result = ReprFormatter::new_call("Ident"); + for item in self.parts() { match item { - IdentPart::Quote(x) => write!(result, "{x:?}").unwrap(), - IdentPart::Unquote(x) => write!(result, "Unquote({x:?})").unwrap(), - IdentPart::Asterisk => result.push_str("Asterisk"), - } - if index + 1 < parts.len() { - result.push_str(", "); + IdentPart::Quote(name) => { + result.positional_debug(name.as_str()); + } + IdentPart::Unquote(name) => { + let mut unquote = ReprFormatter::new_call("Unquote"); + unquote.positional_debug(name.as_str()); + result.positional_formatter(unquote); + } + IdentPart::Asterisk => { + result.positional_raw("Asterisk"); + } } } - result.push(')'); + result.close(); result } diff --git a/src/expr/enum_/like.rs b/src/expr/enum_/like.rs index c99cae7..bea02ae 100644 --- a/src/expr/enum_/like.rs +++ b/src/expr/enum_/like.rs @@ -3,11 +3,11 @@ use super::ExprInner; use crate::dialect::PyDialect; use crate::dialect::flags; +use crate::internal::repr::ReprFormatter; use crate::sql::PrepareResult; use crate::sql::SqlWriter; use crate::sql::WriteSql; use crate::sql::write_separated; -use std::fmt::Write; pub(super) fn write_escape( dialect: &PyDialect, @@ -37,19 +37,16 @@ pub enum LikePattern { } impl LikePattern { - fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { match self { - Self::One(p) => p.python_repr(py), - Self::Any(ps) => { - let mut buf = String::from("["); - for (i, item) in ps.iter().enumerate() { - if i > 0 { - buf.push_str(", "); - } - buf.push_str(&item.python_repr(py)?); + Self::One(pattern) => pattern.python_repr(py), + Self::Any(patterns) => { + let mut fmt = ReprFormatter::new_list(); + for item in patterns { + fmt.positional_formatter(item.python_repr(py)?); } - buf.push(']'); - Ok(buf) + fmt.close(); + Ok(fmt) } } } @@ -81,23 +78,15 @@ pub struct Like { impl Like { pub(crate) const PRECEDENCE: u8 = 5; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = format!( - "LikeExpr({}, {}", - self.expr.python_repr(py)?, - self.pattern.python_repr(py)? - ); - if !self.case_sensitive { - buf.push_str(", case_sensitive=False"); - } - if self.negated { - buf.push_str(", negated=True"); - } - if let Some(c) = self.escape { - write!(buf, ", escape={c:?}").unwrap(); - } - buf.push(')'); - Ok(buf) + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = ReprFormatter::new_call("LikeExpr"); + fmt.positional_formatter(self.expr.python_repr(py)?); + fmt.positional_formatter(self.pattern.python_repr(py)?); + fmt.keyword_if_false("case_sensitive", self.case_sensitive); + fmt.keyword_if_true("negated", self.negated); + fmt.keyword_optional_debug("escape", self.escape); + fmt.close(); + Ok(fmt) } } @@ -288,17 +277,13 @@ pub struct SimilarTo { impl SimilarTo { pub(crate) const PRECEDENCE: u8 = 5; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = format!( - "SimilarToExpr({}, {}", - self.expr.python_repr(py)?, - self.pattern.python_repr(py)? - ); - if self.negated { - buf.push_str(", negated=True"); - } - buf.push(')'); - Ok(buf) + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = ReprFormatter::new_call("SimilarToExpr"); + fmt.positional_formatter(self.expr.python_repr(py)?); + fmt.positional_formatter(self.pattern.python_repr(py)?); + fmt.keyword_if_true("negated", self.negated); + fmt.close(); + Ok(fmt) } } @@ -350,17 +335,13 @@ pub struct RLike { impl RLike { pub(crate) const PRECEDENCE: u8 = 5; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = format!( - "RLikeExpr({}, {}", - self.expr.python_repr(py)?, - self.pattern.python_repr(py)? - ); - if self.negated { - buf.push_str(", negated=True"); - } - buf.push(')'); - Ok(buf) + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = ReprFormatter::new_call("RLikeExpr"); + fmt.positional_formatter(self.expr.python_repr(py)?); + fmt.positional_formatter(self.pattern.python_repr(py)?); + fmt.keyword_if_true("negated", self.negated); + fmt.close(); + Ok(fmt) } } diff --git a/src/expr/enum_/mod.rs b/src/expr/enum_/mod.rs index eaa5362..7b5d259 100644 --- a/src/expr/enum_/mod.rs +++ b/src/expr/enum_/mod.rs @@ -13,6 +13,7 @@ pub use operator::*; pub use value::*; use crate::dialect::PyDialect; +use crate::internal::repr::ReprFormatter; use crate::sql::PrepareResult; use crate::sql::SqlWriter; use crate::sql::WriteSql; @@ -148,7 +149,7 @@ impl ExprInner { } #[inline] - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { match self { Self::Ident(x) => Ok(x.python_repr()), Self::Value(x) => x.python_repr(py), diff --git a/src/expr/enum_/nodes.rs b/src/expr/enum_/nodes.rs index 41e1ad5..35217c8 100644 --- a/src/expr/enum_/nodes.rs +++ b/src/expr/enum_/nodes.rs @@ -8,27 +8,17 @@ use super::UnaryOp; use super::UnarySpacing; use crate::dialect::PyDialect; use crate::dialect::flags; +use crate::internal::repr::ReprFormatter; use crate::sql::PrepareResult; use crate::sql::SqlWriter; use crate::sql::WriteSql; use crate::sql::write_separated; -use std::fmt::Write; #[inline] fn unsupported(message: &'static str) -> crate::sql::PrepareError { rapidquery_macros::pyerr!(PyRuntimeError, message).into() } -fn join_reprs(py: pyo3::Python<'_>, buf: &mut String, items: &[ExprInner]) -> pyo3::PyResult<()> { - for (i, item) in items.iter().enumerate() { - if i > 0 { - buf.push_str(", "); - } - buf.push_str(&item.python_repr(py)?); - } - Ok(()) -} - /// Row value. SQL spelling depends on [`TupleSyntax`]. #[derive(Clone, Debug, PartialEq)] pub struct Tuple { @@ -38,11 +28,13 @@ pub struct Tuple { impl Tuple { pub(crate) const PRECEDENCE: u8 = 0; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = String::from("TupleExpr("); - join_reprs(py, &mut buf, &self.items)?; - buf.push(')'); - Ok(buf) + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = ReprFormatter::new_call("TupleExpr"); + for item in &self.items { + fmt.positional_formatter(item.python_repr(py)?); + } + fmt.close(); + Ok(fmt) } } @@ -97,12 +89,12 @@ pub struct Unary { } impl Unary { - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - Ok(format!( - "UnaryExpr({:?}, {})", - self.op.as_sql(), - self.expr.python_repr(py)? - )) + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = ReprFormatter::new_call("UnaryExpr"); + fmt.positional_debug(self.op.as_sql()); + fmt.positional_formatter(self.expr.python_repr(py)?); + fmt.close(); + Ok(fmt) } } @@ -159,13 +151,13 @@ pub struct Binary { } impl Binary { - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - Ok(format!( - "BinaryExpr({}, {:?}, {})", - self.left.python_repr(py)?, - self.op.as_sql(), - self.right.python_repr(py)? - )) + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = ReprFormatter::new_call("BinaryExpr"); + fmt.positional_formatter(self.left.python_repr(py)?); + fmt.positional_debug(self.op.as_sql()); + fmt.positional_formatter(self.right.python_repr(py)?); + fmt.close(); + Ok(fmt) } } @@ -339,17 +331,13 @@ pub struct Is { impl Is { pub(crate) const PRECEDENCE: u8 = 5; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = format!( - "IsExpr({}, {:?}", - self.expr.python_repr(py)?, - self.kind.as_sql() - ); - if self.negated { - buf.push_str(", negated=True"); - } - buf.push(')'); - Ok(buf) + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = ReprFormatter::new_call("IsExpr"); + fmt.positional_formatter(self.expr.python_repr(py)?); + fmt.positional_debug(self.kind.as_sql()); + fmt.keyword_if_true("negated", self.negated); + fmt.close(); + Ok(fmt) } } @@ -378,17 +366,13 @@ pub struct IsDistinctFrom { impl IsDistinctFrom { pub(crate) const PRECEDENCE: u8 = 5; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = format!( - "IsDistinctFromExpr({}, {}", - self.left.python_repr(py)?, - self.right.python_repr(py)? - ); - if self.negated { - buf.push_str(", negated=True"); - } - buf.push(')'); - Ok(buf) + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = ReprFormatter::new_call("IsDistinctFromExpr"); + fmt.positional_formatter(self.left.python_repr(py)?); + fmt.positional_formatter(self.right.python_repr(py)?); + fmt.keyword_if_true("negated", self.negated); + fmt.close(); + Ok(fmt) } } @@ -442,15 +426,18 @@ pub struct InList { impl InList { pub(crate) const PRECEDENCE: u8 = 5; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = format!("InListExpr({}, (", self.expr.python_repr(py)?); - join_reprs(py, &mut buf, &self.list)?; - buf.push(')'); - if self.negated { - buf.push_str(", negated=True"); - } - buf.push(')'); - Ok(buf) + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = ReprFormatter::new_call("InListExpr"); + fmt.positional_formatter(self.expr.python_repr(py)?); + fmt.positional_tuple(|group| -> pyo3::PyResult<()> { + for item in &self.list { + group.positional_formatter(item.python_repr(py)?); + } + Ok(()) + })?; + fmt.keyword_if_true("negated", self.negated); + fmt.close(); + Ok(fmt) } } @@ -495,18 +482,14 @@ pub struct Between { impl Between { pub(crate) const PRECEDENCE: u8 = 5; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = format!( - "BetweenExpr({}, {}, {}", - self.expr.python_repr(py)?, - self.low.python_repr(py)?, - self.high.python_repr(py)? - ); - if self.negated { - buf.push_str(", negated=True"); - } - buf.push(')'); - Ok(buf) + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = ReprFormatter::new_call("BetweenExpr"); + fmt.positional_formatter(self.expr.python_repr(py)?); + fmt.positional_formatter(self.low.python_repr(py)?); + fmt.positional_formatter(self.high.python_repr(py)?); + fmt.keyword_if_true("negated", self.negated); + fmt.close(); + Ok(fmt) } } @@ -548,17 +531,15 @@ pub struct Cast { impl Cast { pub(crate) const PRECEDENCE: u8 = 0; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = format!( - "CastExpr({}, {:?}", - self.expr.python_repr(py)?, - self.data_type_name.as_str() - ); + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = ReprFormatter::new_call("CastExpr"); + fmt.positional_formatter(self.expr.python_repr(py)?); + fmt.positional_debug(self.data_type_name.as_str()); if self.kind != CastKind::Cast { - write!(buf, ", kind={:?}", self.kind.as_sql()).unwrap(); + fmt.keyword_debug("kind", self.kind.as_sql()); } - buf.push(')'); - Ok(buf) + fmt.close(); + Ok(fmt) } fn write_call( @@ -620,16 +601,20 @@ pub struct Trim { impl Trim { pub(crate) const PRECEDENCE: u8 = 0; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = format!("TrimExpr({}", self.expr.python_repr(py)?); + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let characters = self + .characters + .as_ref() + .map(|characters| characters.python_repr(py)) + .transpose()?; + let mut fmt = ReprFormatter::new_call("TrimExpr"); + fmt.positional_formatter(self.expr.python_repr(py)?); if self.where_ != TrimWhere::Both { - write!(buf, ", where={:?}", self.where_.as_sql()).unwrap(); - } - if let Some(characters) = &self.characters { - write!(buf, ", characters={}", characters.python_repr(py)?).unwrap(); + fmt.keyword_debug("where", self.where_.as_sql()); } - buf.push(')'); - Ok(buf) + fmt.keyword_optional_formatter("characters", characters); + fmt.close(); + Ok(fmt) } } @@ -708,12 +693,12 @@ pub struct Collate { impl Collate { pub(crate) const PRECEDENCE: u8 = 0; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - Ok(format!( - "CollateExpr({}, {})", - self.expr.python_repr(py)?, - self.collation.python_repr() - )) + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = ReprFormatter::new_call("CollateExpr"); + fmt.positional_formatter(self.expr.python_repr(py)?); + fmt.positional_formatter(self.collation.python_repr()); + fmt.close(); + Ok(fmt) } } @@ -762,29 +747,33 @@ pub struct Case { impl Case { pub(crate) const PRECEDENCE: u8 = 0; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = String::from("CaseExpr(["); - for (i, (when, then)) in self.when.iter().enumerate() { - if i > 0 { - buf.push_str(", "); + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = ReprFormatter::new_call("CaseExpr"); + fmt.positional_list(|list| -> pyo3::PyResult<()> { + for (when, then) in &self.when { + let when = when.python_repr(py)?; + let then = then.python_repr(py)?; + list.positional_tuple(|pair| { + pair.positional_formatter(when); + pair.positional_formatter(then); + }); } - write!( - buf, - "({}, {})", - when.python_repr(py)?, - then.python_repr(py)? - ) - .unwrap(); - } - buf.push(']'); - if let Some(operand) = &self.operand { - write!(buf, ", operand={}", operand.python_repr(py)?).unwrap(); - } - if let Some(else_result) = &self.else_result { - write!(buf, ", else_={}", else_result.python_repr(py)?).unwrap(); - } - buf.push(')'); - Ok(buf) + Ok(()) + })?; + let operand = self + .operand + .as_ref() + .map(|operand| operand.python_repr(py)) + .transpose()?; + let else_result = self + .else_result + .as_ref() + .map(|else_result| else_result.python_repr(py)) + .transpose()?; + fmt.keyword_optional_formatter("operand", operand); + fmt.keyword_optional_formatter("else_", else_result); + fmt.close(); + Ok(fmt) } } @@ -821,14 +810,14 @@ pub struct Custom { impl Custom { pub(crate) const PRECEDENCE: u8 = 0; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut buf = format!("CustomExpr({:?}", self.sql); + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = ReprFormatter::new_call("CustomExpr"); + fmt.positional_debug(self.sql.as_str()); for arg in &self.args { - buf.push_str(", "); - buf.push_str(&arg.python_repr(py)?); + fmt.positional_formatter(arg.python_repr(py)?); } - buf.push(')'); - Ok(buf) + fmt.close(); + Ok(fmt) } } diff --git a/src/expr/enum_/value.rs b/src/expr/enum_/value.rs index a621a67..bc667e2 100644 --- a/src/expr/enum_/value.rs +++ b/src/expr/enum_/value.rs @@ -3,10 +3,11 @@ use pyo3::types::PyAnyMethods; use pyo3::types::PyStringMethods; -use crate::data_type::SerializerType; -use crate::data_type::serializer_repr; use crate::dialect::PyDialect; use crate::internal::alias; +use crate::internal::repr::ReprFormatter; +use crate::serializers::SerializerType; +use crate::serializers::serializer_repr; use crate::sql::PrepareResult; use crate::sql::SqlWriter; use crate::sql::WriteSql; @@ -32,17 +33,18 @@ impl Value { #[inline] pub fn infer_bound(bound: alias::BoundObject) -> Self { - let serializer = crate::data_type::SerializerType::infer_bound(&bound); + let serializer = SerializerType::infer_bound(&bound); Self::new(bound.unbind(), serializer) } - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { let obj_repr = self.object.bind(py).repr()?; - Ok(format!( - "Value({}, {})", - obj_repr.to_cow()?, - serializer_repr(&self.serializer) - )) + let text = obj_repr.to_cow()?; + let mut fmt = ReprFormatter::new_call("Value"); + fmt.positional_raw(&text); + fmt.positional_formatter(serializer_repr(&self.serializer)); + fmt.close(); + Ok(fmt) } } diff --git a/src/expr/extractors.rs b/src/expr/extractors.rs index 789708d..6215a4b 100644 --- a/src/expr/extractors.rs +++ b/src/expr/extractors.rs @@ -70,7 +70,7 @@ fn extract_expr( return Ok(ExprInner::Value(super::enum_::Value::new( obj.as_any().clone().unbind(), - crate::data_type::SerializerType::String, + crate::serializers::SerializerType::String, ))); } diff --git a/src/expr/ident.rs b/src/expr/ident.rs index faac437..da46c0e 100644 --- a/src/expr/ident.rs +++ b/src/expr/ident.rs @@ -6,6 +6,7 @@ use super::enum_::ParseIdentError; use super::extractors::ExtractedIdentArgument; use super::extractors::extract_ident_argument; use crate::internal::alias; +use crate::internal::repr::ReprFormatter; /// Convert a native part into the matching Python object (`str`, `Unquote`, /// or `Asterisk`). @@ -195,7 +196,9 @@ impl PyUnquote { } fn __repr__(&self) -> String { - format!("Unquote({:?})", self.0.as_str()) + let mut fmt = ReprFormatter::new_call("Unquote"); + fmt.positional_debug(self.0.as_str()); + fmt.into_string() } } diff --git a/src/expr/value.rs b/src/expr/value.rs index 217639f..4f7d718 100644 --- a/src/expr/value.rs +++ b/src/expr/value.rs @@ -1,6 +1,6 @@ -use crate::data_type::PySerializer; -use crate::data_type::SerializerExtractor; use crate::internal::alias; +use crate::serializers::PySerializer; +use crate::serializers::SerializerExtractor; use super::enum_::Value; @@ -12,8 +12,7 @@ use super::enum_::Value; /// /// If `serializer` is omitted, one is inferred from the object's type. /// Passing an existing `Value` keeps its object (and its serializer, unless -/// a new one is given). A `DataType` as `serializer` contributes only its -/// serializer; the data type is not stored. +/// a new one is given). #[rapidquery_macros::pyclass("rapidquery.expr.Value", skip_from_py_object, extends = super::base::PyExpr, generic)] #[derive(Debug, Default)] pub struct PyValue; @@ -40,13 +39,12 @@ impl PyValue { /// Args: /// object: The Python object to bind or serialize. If this is already /// a `Value`, its object is reused. - /// serializer: How to render `object` as a SQL literal. A `DataType` - /// is accepted and only its serializer is kept. If omitted, + /// serializer: How to render `object` as a SQL literal. If omitted, /// a serializer is inferred from `object` (or kept, when /// `object` is already a `Value`). /// /// Raises: - /// TypeError: If `serializer` is not a `Serializer` or `DataType`. + /// TypeError: If `serializer` is not a `Serializer`. #[new] #[pyo3(signature = (object, serializer = None, /))] fn __new__( diff --git a/src/internal/mod.rs b/src/internal/mod.rs index 55c74b6..700fc1c 100644 --- a/src/internal/mod.rs +++ b/src/internal/mod.rs @@ -1,3 +1,4 @@ pub mod alias; pub mod modules; +pub mod repr; pub mod utils; diff --git a/src/internal/repr.rs b/src/internal/repr.rs new file mode 100644 index 0000000..0817a2f --- /dev/null +++ b/src/internal/repr.rs @@ -0,0 +1,257 @@ +/// Builds a Python `__repr__` of the form `Name(...)` or `[...]`. +/// +/// One buffer. Arguments are written in place. A nested node is another +/// formatter; the parent copies that buffer once. +pub struct ReprFormatter { + buf: String, + separated: bool, + closer: Closer, + closed: bool, +} + +#[derive(Clone, Copy)] +enum Closer { + Paren, + Bracket, +} + +impl ReprFormatter { + /// `name(`. + pub fn new_call(name: &str) -> Self { + let mut buf = String::with_capacity(name.len() + 32); + buf.push_str(name); + buf.push('('); + Self { + buf, + separated: false, + closer: Closer::Paren, + closed: false, + } + } + + /// `[`. + pub fn new_list() -> Self { + Self { + buf: String::from("["), + separated: false, + closer: Closer::Bracket, + closed: false, + } + } + + pub fn positional_debug(&mut self, value: impl std::fmt::Debug) -> &mut Self { + self.separate(); + self.push_debug(&value); + self + } + + /// Paste `value` with no extra quotes and no separator logic inside it. + pub fn positional_raw(&mut self, value: &str) -> &mut Self { + self.separate(); + self.buf.push_str(value); + self + } + + pub fn positional_formatter(&mut self, child: ReprFormatter) -> &mut Self { + self.separate(); + self.push_formatter(child); + self + } + + /// Positional `[...]`. Items are written with the same argument methods. + pub fn positional_list(&mut self, write_items: impl FnOnce(&mut Self) -> R) -> R { + self.group('[', ']', write_items) + } + + /// Positional `(...)`. + pub fn positional_tuple(&mut self, write_items: impl FnOnce(&mut Self) -> R) -> R { + self.group('(', ')', write_items) + } + + pub fn keyword_debug(&mut self, key: &str, value: impl std::fmt::Debug) -> &mut Self { + self.separate(); + self.push_key(key); + self.push_debug(&value); + self + } + + pub fn keyword_formatter(&mut self, key: &str, child: ReprFormatter) -> &mut Self { + self.separate(); + self.push_key(key); + self.push_formatter(child); + self + } + + pub fn keyword_optional_debug( + &mut self, + key: &str, + value: Option, + ) -> &mut Self { + if let Some(value) = value { + self.keyword_debug(key, value); + } + self + } + + pub fn keyword_optional_formatter( + &mut self, + key: &str, + value: Option, + ) -> &mut Self { + if let Some(child) = value { + self.keyword_formatter(key, child); + } + self + } + + /// `key=True` when `value` is set. Omitted when it is clear. + pub fn keyword_if_true(&mut self, key: &str, value: bool) -> &mut Self { + if value { + self.separate(); + self.push_key(key); + self.buf.push_str("True"); + } + self + } + + /// `key=False` when `value` is clear. Omitted when it is set. + pub fn keyword_if_false(&mut self, key: &str, value: bool) -> &mut Self { + if !value { + self.separate(); + self.push_key(key); + self.buf.push_str("False"); + } + self + } + + /// Write the closing `)` or `]`. Further calls leave the text unchanged. + pub fn close(&mut self) -> &mut Self { + if !self.closed { + self.buf.push(match self.closer { + Closer::Paren => ')', + Closer::Bracket => ']', + }); + self.closed = true; + } + self + } + + /// Append `suffix` after the closer. Closes first when still open. + pub fn append_suffix(&mut self, suffix: &str) -> &mut Self { + self.close(); + self.buf.push_str(suffix); + self + } + + /// Append `.method(argument)` after the closer. `argument` is already closed. + pub fn append_call(&mut self, method: &str, argument: &ReprFormatter) -> &mut Self { + debug_assert!(argument.closed); + self.close(); + self.buf.push('.'); + self.buf.push_str(method); + self.buf.push('('); + self.buf.reserve(argument.buf.len()); + self.buf.push_str(&argument.buf); + self.buf.push(')'); + self + } + + pub fn as_str(&self) -> &str { + &self.buf + } + + /// Move the buffer out. Closes first when still open. + pub fn into_string(mut self) -> String { + self.close(); + self.buf + } + + fn separate(&mut self) { + debug_assert!(!self.closed); + if self.separated { + self.buf.push_str(", "); + } else { + self.separated = true; + } + } + + fn push_key(&mut self, key: &str) { + self.buf.push_str(key); + self.buf.push('='); + } + + fn push_debug(&mut self, value: &impl std::fmt::Debug) { + use std::fmt::Write; + write!(self.buf, "{value:?}").unwrap(); + } + + fn push_formatter(&mut self, mut child: ReprFormatter) { + child.close(); + self.buf.reserve(child.buf.len()); + self.buf.push_str(&child.buf); + } + + fn group(&mut self, open: char, close: char, write_items: impl FnOnce(&mut Self) -> R) -> R { + self.separate(); + self.buf.push(open); + self.separated = false; + let result = write_items(self); + self.buf.push(close); + self.separated = true; + result + } +} + +#[cfg(test)] +mod tests { + use super::ReprFormatter; + + #[test] + fn call_debug_escape_and_bools() { + let mut fmt = ReprFormatter::new_call("LikeExpr"); + fmt.positional_debug("a\"b\\c"); + fmt.keyword_if_false("case_sensitive", false); + fmt.keyword_if_true("negated", true); + fmt.keyword_if_true("distinct", false); + fmt.keyword_optional_debug::<&str>("escape", None); + fmt.keyword_optional_debug("where", Some("LEADING")); + assert_eq!( + fmt.into_string(), + "LikeExpr(\"a\\\"b\\\\c\", case_sensitive=False, negated=True, where=\"LEADING\")" + ); + } + + #[test] + fn list_embeds_formatter_and_tuple() { + let mut ident = ReprFormatter::new_call("Ident"); + ident.positional_debug("id"); + + let mut fmt = ReprFormatter::new_list(); + fmt.positional_formatter(ident); + fmt.positional_tuple(|pair| { + pair.positional_debug("when"); + pair.positional_raw("Value(1, IntegerSerializer())"); + }); + fmt.keyword_optional_formatter("else_", None); + assert_eq!( + fmt.into_string(), + "[Ident(\"id\"), (\"when\", Value(1, IntegerSerializer()))]" + ); + } + + #[test] + fn append_call_after_close() { + let mut argument = ReprFormatter::new_call("Ident"); + argument.positional_debug("x"); + argument.close(); + + let mut fmt = ReprFormatter::new_call("Function"); + fmt.positional_debug("lag"); + fmt.append_suffix(".ignore_nulls()"); + fmt.append_call("filter", &argument); + assert_eq!( + fmt.into_string(), + "Function(\"lag\").ignore_nulls().filter(Ident(\"x\"))" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 77c3846..d664969 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod data_type; pub mod dialect; pub mod expr; pub mod internal; +pub mod serializers; pub mod sql; pub mod typeref; @@ -30,6 +31,9 @@ mod _core { #[pymodule_export] use super::dialect::dialect_mod; + #[pymodule_export] + use super::serializers::serializers_mod; + #[pymodule_export] use super::expr::expr_mod; diff --git a/src/data_type/serializer/functions.rs b/src/serializers/functions.rs similarity index 100% rename from src/data_type/serializer/functions.rs rename to src/serializers/functions.rs diff --git a/src/data_type/serializer/inner.rs b/src/serializers/inner.rs similarity index 82% rename from src/data_type/serializer/inner.rs rename to src/serializers/inner.rs index fdbeffc..2fb21aa 100644 --- a/src/data_type/serializer/inner.rs +++ b/src/serializers/inner.rs @@ -69,32 +69,6 @@ impl std::hash::Hash for SerializerType { } impl SerializerType { - /// Built-in serializer for a [`DataTypeKind`]. - /// - /// [`DataTypeKind::Custom`] maps to [`Self::Unspecified`]. Json and Jsonb - /// share [`Self::Json`]. - #[inline] - pub fn from_kind(kind: crate::data_type::DataTypeKind) -> Self { - match kind { - crate::data_type::DataTypeKind::Integer => Self::Integer, - crate::data_type::DataTypeKind::Float => Self::Float, - crate::data_type::DataTypeKind::Boolean => Self::Boolean, - crate::data_type::DataTypeKind::String => Self::String, - crate::data_type::DataTypeKind::Uuid => Self::Uuid, - crate::data_type::DataTypeKind::Bytes => Self::Bytes, - crate::data_type::DataTypeKind::Bit => Self::Bit, - crate::data_type::DataTypeKind::Json | crate::data_type::DataTypeKind::Jsonb => { - Self::Json - } - crate::data_type::DataTypeKind::Date => Self::Date, - crate::data_type::DataTypeKind::Time => Self::Time, - crate::data_type::DataTypeKind::Datetime => Self::Datetime, - crate::data_type::DataTypeKind::Inet => Self::Inet, - crate::data_type::DataTypeKind::MacAddress => Self::MacAddress, - crate::data_type::DataTypeKind::Custom => Self::Unspecified, - } - } - pub fn infer_bound(bound: &alias::BoundObject) -> Self { unsafe { let object_ptr = bound.as_ptr(); diff --git a/src/data_type/serializer/mod.rs b/src/serializers/mod.rs similarity index 85% rename from src/data_type/serializer/mod.rs rename to src/serializers/mod.rs index b90ddd7..accf25a 100644 --- a/src/data_type/serializer/mod.rs +++ b/src/serializers/mod.rs @@ -6,23 +6,24 @@ mod inner; pub use inner::SerializerType; use crate::internal::alias; +use crate::internal::repr::ReprFormatter; -pub fn serializer_repr(serializer: &SerializerType) -> String { - let name = serializer_type_to_pyname(serializer); - let mut buf = String::with_capacity(name.len() + 2); - buf.push_str(name); - buf.push('('); - +pub fn serializer_repr(serializer: &SerializerType) -> ReprFormatter { + let mut fmt = ReprFormatter::new_call(serializer_type_to_pyname(serializer)); if let SerializerType::Array(element) = serializer { - buf.push_str(&serializer_repr(element)); + fmt.positional_formatter(serializer_repr(element)); } - - buf.push(')'); - buf + fmt.close(); + fmt } /// Converts a Python object into a dialect-specific SQL literal string. -#[rapidquery_macros::pyclass("rapidquery.data_type.Serializer", subclass, from_py_object, generic)] +#[rapidquery_macros::pyclass( + "rapidquery.serializers.Serializer", + subclass, + from_py_object, + generic +)] #[derive(Debug, Clone)] #[repr(transparent)] pub struct PySerializer(pub SerializerType); @@ -41,7 +42,7 @@ impl PySerializer { } fn __repr__(&self) -> String { - serializer_repr(&self.0) + serializer_repr(&self.0).into_string() } // Bound so only instances match. @@ -68,7 +69,7 @@ macro_rules! impl_unit_serializers { ) => { $( $(#[$outer])* - #[rapidquery_macros::pyclass("rapidquery.data_type" + $pyname, extends = PySerializer)] + #[rapidquery_macros::pyclass("rapidquery.serializers" + $pyname, extends = PySerializer)] #[derive(Debug, Default)] pub struct $name; @@ -249,7 +250,7 @@ impl_unit_serializers! { MacAddress => PyMacAddressSerializer => "MacAddressSerializer", /// Inserts `str(obj)` into SQL with no quoting or type check. - /// Used when no serializer could be inferred from the type name. The result + /// Used when the Python object matches no built-in serializer. The result /// is not a safe SQL value; prefer an explicit serializer. Unspecified => PyUnspecifiedSerializer => "UnspecifiedSerializer", } @@ -263,7 +264,7 @@ impl_unit_serializers! { /// Raises: /// TypeError: If the value is not a `list` or `tuple`. /// NotImplementedError: If the dialect has no `ARRAY[…]` constructor. -#[rapidquery_macros::pyclass("rapidquery.data_type.ArraySerializer", extends = PySerializer)] +#[rapidquery_macros::pyclass("rapidquery.serializers.ArraySerializer", extends = PySerializer)] #[derive(Debug, Default)] pub struct PyArraySerializer; @@ -302,7 +303,7 @@ impl PyArraySerializer { /// `function` has the form `fn(dialect_name: str, obj) -> str`. The return /// value is inserted into SQL as-is (not re-quoted or escaped). It is the /// caller's responsibility to produce a safe fragment. -#[rapidquery_macros::pyclass("rapidquery.data_type.CustomSerializer", extends = PySerializer)] +#[rapidquery_macros::pyclass("rapidquery.serializers.CustomSerializer", extends = PySerializer)] #[derive(Debug, Default)] pub struct PyCustomSerializer; @@ -342,9 +343,9 @@ impl PyCustomSerializer { } } -/// `Serializer` instance, or a `DataType` (its serializer only). +/// A `Serializer` instance. /// -/// Use [`PySerializer`] itself as an argument if you wanna accept ONLY `PySerializer` types. +/// Use [`PySerializer`] itself as an argument to accept only `PySerializer` types. pub struct SerializerExtractor(pub SerializerType); impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for SerializerExtractor { @@ -362,17 +363,48 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for SerializerExtractor { let serializer = obj.cast_unchecked::(); return Ok(Self(serializer.get().0.clone())); } - - if crate::typeref::check_exact(ptr, crate::typeref::DATATYPE_TYPE) { - let data_type = obj.cast_unchecked::(); - return Ok(Self(data_type.get().serializer.clone())); - } } - rapidquery_macros::pyerr!( - Err(PyTypeError), - "expected Serializer or DataType, got {}", - unsafe { crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) } - ) + rapidquery_macros::pyerr!(Err(PyTypeError), "expected Serializer, got {}", unsafe { + crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) + }) } } + +#[pyo3::pymodule(name = "serializers", gil_used = false)] +pub mod serializers_mod { + #[pymodule_export] + use super::PyArraySerializer; + #[pymodule_export] + use super::PyBitSerializer; + #[pymodule_export] + use super::PyBooleanSerializer; + #[pymodule_export] + use super::PyBytesSerializer; + #[pymodule_export] + use super::PyCustomSerializer; + #[pymodule_export] + use super::PyDateSerializer; + #[pymodule_export] + use super::PyDatetimeSerializer; + #[pymodule_export] + use super::PyFloatSerializer; + #[pymodule_export] + use super::PyInetSerializer; + #[pymodule_export] + use super::PyIntegerSerializer; + #[pymodule_export] + use super::PyJSONSerializer; + #[pymodule_export] + use super::PyMacAddressSerializer; + #[pymodule_export] + use super::PySerializer; + #[pymodule_export] + use super::PyStringSerializer; + #[pymodule_export] + use super::PyTimeSerializer; + #[pymodule_export] + use super::PyUnspecifiedSerializer; + #[pymodule_export] + use super::PyUuidSerializer; +} diff --git a/src/sql.rs b/src/sql.rs index 2e99ff5..400bac0 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -9,11 +9,11 @@ use std::fmt::Write; use pyo3::types::PyAnyMethods; -use crate::data_type::SerializerType; use crate::dialect::PyDialect; use crate::dialect::errors::FormatError; use crate::dialect::flags::Placeholder; use crate::internal::alias; +use crate::serializers::SerializerType; /// Error produced while building SQL. #[derive(Debug)] diff --git a/src/typeref.rs b/src/typeref.rs index 872b9fd..ff3b7d4 100644 --- a/src/typeref.rs +++ b/src/typeref.rs @@ -73,7 +73,7 @@ fn _initialize_typeref(py: pyo3::Python) { crate::dialect::PyDialect => DIALECT_TYPE, // data types / serializers crate::data_type::PyDataType => DATATYPE_TYPE, - crate::data_type::PySerializer => SERIALIZER_TYPE, + crate::serializers::PySerializer => SERIALIZER_TYPE, crate::expr::base::PyExpr => EXPR_TYPE, crate::expr::ident::PyIdent => IDENT_TYPE, crate::expr::ident::PyUnquote => UNQUOTE_TYPE, diff --git a/tests/conftest.py b/tests/conftest.py index 24e2140..c398838 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,7 @@ Redshift, Snowflake, Spark, - SqlDialect, + Dialect, Sqlite, Teradata, ) @@ -28,7 +28,7 @@ # Builtin dialects only. GenericDialect needs every field as a keyword argument; # tests that want one call `generic_dialect()`. -ALL_DIALECT_CLASSES: list[type[SqlDialect]] = [ +ALL_DIALECT_CLASSES: list[type[Dialect]] = [ BigQuery, Databricks, DuckDb, @@ -48,12 +48,12 @@ @pytest.fixture(params=ALL_DIALECT_CLASSES, ids=ALL_DIALECT_NAMES) -def dialect_cls(request: pytest.FixtureRequest) -> type[SqlDialect]: +def dialect_cls(request: pytest.FixtureRequest) -> type[Dialect]: """Every builtin SqlDialect subclass (not GenericDialect).""" return request.param @pytest.fixture(params=ALL_DIALECT_CLASSES, ids=ALL_DIALECT_NAMES) -def dialect_instance(request: pytest.FixtureRequest) -> SqlDialect: +def dialect_instance(request: pytest.FixtureRequest) -> Dialect: """An instance of every builtin SqlDialect subclass.""" return request.param() diff --git a/tests/test_data_type.py b/tests/test_data_type.py index 1168f70..a1d48d6 100644 --- a/tests/test_data_type.py +++ b/tests/test_data_type.py @@ -1,23 +1,6 @@ import pytest -from rapidquery.data_type import ( - BitSerializer, - BooleanSerializer, - BytesSerializer, - DataType, - DateSerializer, - DatetimeSerializer, - FloatSerializer, - InetSerializer, - IntegerSerializer, - JSONSerializer, - MacAddressSerializer, - StringSerializer, - TimeSerializer, - UnspecifiedSerializer, - UuidSerializer, -) -from rapidquery.dialect import PostgreSql +from rapidquery.data_type import DataType class TestDataTypeNameNormalization: @@ -36,14 +19,8 @@ def test_known_names_are_uppercased_to_canonical_form(self, input_name, canonica assert dt.name == canonical def test_name_matching_is_case_insensitive(self): - assert ( - DataType("INTEGER").serializer.__class__ - == DataType("integer").serializer.__class__ - ) - assert ( - DataType("Integer").serializer.__class__ - == DataType("integer").serializer.__class__ - ) + assert DataType("INTEGER") == DataType("integer") + assert DataType("Integer") == DataType("integer") def test_unrecognized_name_is_preserved_as_is(self): dt = DataType("some_totally_unknown_type") @@ -58,142 +35,6 @@ def test_parameterized_name_is_not_alias_matched(self): assert dt.name == "varchar(255)" -class TestDataTypeSerializerInference: - @pytest.mark.parametrize( - "name", - [ - "tinyint", - "smallint", - "int", - "integer", - "bigint", - ], - ) - def test_integer_family_infers_integer_serializer(self, name): - assert isinstance(DataType(name).serializer, IntegerSerializer) - - @pytest.mark.parametrize( - "name", - [ - "numeric", - "decimal", - "bignumeric", - "bigdecimal", - "float", - "real", - "double", - "double precision", - ], - ) - def test_float_family_infers_float_serializer(self, name): - assert isinstance(DataType(name).serializer, FloatSerializer) - - @pytest.mark.parametrize( - "name", - [ - "character", - "character varying", - "char", - "char varying", - "varchar", - "nvarchar", - "text", - ], - ) - def test_string_family_infers_string_serializer(self, name): - assert isinstance(DataType(name).serializer, StringSerializer) - - def test_uuid_infers_uuid_serializer(self): - assert isinstance(DataType("uuid").serializer, UuidSerializer) - - @pytest.mark.parametrize( - "name", - [ - "binary", - "varbinary", - "blob", - "bytes", - "bytea", - ], - ) - def test_bytes_family_infers_bytes_serializer(self, name): - assert isinstance(DataType(name).serializer, BytesSerializer) - - @pytest.mark.parametrize("name", ["bit", "bit varying", "varbit"]) - def test_bit_family_infers_bit_serializer(self, name): - assert isinstance(DataType(name).serializer, BitSerializer) - - def test_date_infers_date_serializer(self): - assert isinstance(DataType("date").serializer, DateSerializer) - - @pytest.mark.parametrize( - "name", ["time", "time without timezone", "time with timezone"] - ) - def test_time_family_infers_time_serializer(self, name): - assert isinstance(DataType(name).serializer, TimeSerializer) - - @pytest.mark.parametrize( - "name", - [ - "datetime", - "timestamp", - "timestamp without timezone", - "timestamp without time zone", - "timestamp with timezone", - "timestamp with time zone", - "timestamptz", - ], - ) - def test_datetime_family_infers_datetime_serializer(self, name): - assert isinstance(DataType(name).serializer, DatetimeSerializer) - - @pytest.mark.parametrize("name", ["json", "jsonb"]) - def test_json_family_infers_json_serializer(self, name): - assert isinstance(DataType(name).serializer, JSONSerializer) - - def test_inet_infers_inet_serializer(self): - assert isinstance(DataType("inet").serializer, InetSerializer) - - @pytest.mark.parametrize("name", ["macaddress", "macaddr"]) - def test_macaddress_family_infers_macaddress_serializer(self, name): - assert isinstance(DataType(name).serializer, MacAddressSerializer) - - @pytest.mark.parametrize("name", ["any", "trigger", "unspecified"]) - def test_custom_group_names_infer_unspecified_serializer(self, name): - assert isinstance(DataType(name).serializer, UnspecifiedSerializer) - - def test_unrecognized_name_infers_unspecified_serializer(self): - assert isinstance(DataType("not_a_real_type").serializer, UnspecifiedSerializer) - - def test_parameterized_name_infers_unspecified_serializer(self): - assert isinstance(DataType("varchar(255)").serializer, UnspecifiedSerializer) - - @pytest.mark.parametrize("name", ["boolean", "bool"]) - def test_boolean_family_infers_boolean_serializer(self, name): - assert isinstance(DataType(name).serializer, BooleanSerializer) - - def test_boolean_serializer_correctly_rejects_non_bool_int(self): - dt = DataType("boolean") - with pytest.raises(TypeError): - dt.serialize(PostgreSql(), 1) - - -class TestDataTypeSerializeDelegation: - def test_serialize_delegates_to_serializer(self): - dt = DataType("integer") - assert dt.serialize(PostgreSql(), 42) == "42" - - def test_serialize_with_explicit_custom_serializer(self): - dt = DataType("weird_type", StringSerializer()) - result = dt.serialize(PostgreSql(), "hi") - assert "hi" in result - - def test_serialize_propagates_type_errors_from_serializer(self): - dt = DataType("integer") - with pytest.raises(TypeError): - dt.serialize(PostgreSql(), "not an int") - - class TestDataTypeEquality: def test_interned_spellings_of_the_same_name_are_equal(self): assert DataType("int") == DataType("INT") @@ -202,9 +43,6 @@ def test_interned_spellings_of_the_same_name_are_equal(self): def test_distinct_interned_names_are_not_equal(self): assert DataType("int") != DataType("integer") - def test_explicit_serializer_participates_in_equality(self): - assert DataType("int", StringSerializer()) != DataType("int") - def test_not_equal_to_unrelated_types(self): assert DataType("int") != "INT" - assert DataType("int") != IntegerSerializer() + assert DataType("int") != 1 diff --git a/tests/test_dialect.py b/tests/test_dialect.py index ee30aec..5382948 100644 --- a/tests/test_dialect.py +++ b/tests/test_dialect.py @@ -19,7 +19,7 @@ Redshift, Snowflake, Spark, - SqlDialect, + Dialect, Sqlite, Teradata, ) @@ -252,10 +252,10 @@ class TestSqlDialect: def test_base_cannot_be_instantiated(self): with pytest.raises(TypeError): - SqlDialect() + Dialect() def test_rejects_a_dialect_class_where_an_instance_is_required(self): - from rapidquery.data_type import IntegerSerializer + from rapidquery.serializers import IntegerSerializer with pytest.raises(TypeError, match="SqlDialect"): IntegerSerializer().serialize(PostgreSql, 1) @@ -270,41 +270,41 @@ def test_all_expected_dialects_are_exported(self): class TestBuiltinDialects: - def test_is_subclass_of_sql_dialect(self, dialect_cls: type[SqlDialect]): - assert issubclass(dialect_cls, SqlDialect) + def test_is_subclass_of_sql_dialect(self, dialect_cls: type[Dialect]): + assert issubclass(dialect_cls, Dialect) - def test_instance_is_sql_dialect(self, dialect_instance: SqlDialect): - assert isinstance(dialect_instance, SqlDialect) + def test_instance_is_sql_dialect(self, dialect_instance: Dialect): + assert isinstance(dialect_instance, Dialect) - def test_constructs_with_no_arguments(self, dialect_cls: type[SqlDialect]): + def test_constructs_with_no_arguments(self, dialect_cls: type[Dialect]): dialect_cls() - def test_rejects_unexpected_arguments(self, dialect_cls: type[SqlDialect]): + def test_rejects_unexpected_arguments(self, dialect_cls: type[Dialect]): with pytest.raises(TypeError): dialect_cls("unexpected") - def test_cannot_be_subclassed(self, dialect_cls: type[SqlDialect]): + def test_cannot_be_subclassed(self, dialect_cls: type[Dialect]): with pytest.raises(TypeError): type(f"Sub{dialect_cls.__name__}", (dialect_cls,), {}) - def test_frozen(self, dialect_instance: SqlDialect): + def test_frozen(self, dialect_instance: Dialect): with pytest.raises(AttributeError): dialect_instance.placeholder_sigil = "?" def test_builtin_config_covers_every_class(self): assert set(BUILTIN_CONFIG) == set(ALL_DIALECT_CLASSES) - def test_config_matches_builtin_table(self, dialect_cls: type[SqlDialect]): + def test_config_matches_builtin_table(self, dialect_cls: type[Dialect]): dialect = dialect_cls() for field, expected in BUILTIN_CONFIG[dialect_cls].items(): assert getattr(dialect, field) == expected, field - def test_same_dialect_instances_are_equal(self, dialect_cls: type[SqlDialect]): + def test_same_dialect_instances_are_equal(self, dialect_cls: type[Dialect]): assert dialect_cls() == dialect_cls() assert hash(dialect_cls()) == hash(dialect_cls()) assert len({dialect_cls(), dialect_cls()}) == 1 - def test_instance_is_not_equal_to_its_class(self, dialect_cls: type[SqlDialect]): + def test_instance_is_not_equal_to_its_class(self, dialect_cls: type[Dialect]): assert dialect_cls() != dialect_cls def test_distinct_builtins(self): @@ -312,22 +312,22 @@ def test_distinct_builtins(self): assert PostgreSql() != MySql() assert len({PostgreSql(), MySql()}) == 2 - def test_format_boolean_follows_bool_format(self, dialect_instance: SqlDialect): + def test_format_boolean_follows_bool_format(self, dialect_instance: Dialect): kind = dialect_instance.bool_format assert dialect_instance.format_boolean(True) == _BOOL_TRUE[kind] assert dialect_instance.format_boolean(False) == _BOOL_FALSE[kind] - def test_write_string_literal_wraps_plain_text(self, dialect_instance: SqlDialect): + def test_write_string_literal_wraps_plain_text(self, dialect_instance: Dialect): wrapped = _STRING_WRAP[dialect_instance.string_format].format("hello") assert dialect_instance.write_string_literal("hello") == wrapped - def test_format_bytes_follows_bytes_format(self, dialect_instance: SqlDialect): + def test_format_bytes_follows_bytes_format(self, dialect_instance: Dialect): assert ( dialect_instance.format_bytes(b"\xde\xad") == _BYTES_DEAD[dialect_instance.bytes_format] ) - def test_format_bits(self, dialect_instance: SqlDialect): + def test_format_bits(self, dialect_instance: Dialect): if dialect_instance.bit_format is None: with pytest.raises(ValueError, match="does not support literal bit"): dialect_instance.format_bits("10") @@ -396,9 +396,9 @@ def test_escape_string(self, escape: str, raw: str, escaped: str): class TestGenericDialect: def test_is_sql_dialect(self): dialect = generic_dialect() - assert isinstance(dialect, SqlDialect) + assert isinstance(dialect, Dialect) assert isinstance(dialect, GenericDialect) - assert issubclass(GenericDialect, SqlDialect) + assert issubclass(GenericDialect, Dialect) def test_cannot_be_subclassed(self): with pytest.raises(TypeError): diff --git a/tests/test_expr.py b/tests/test_expr.py index 165938a..ee613cb 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -11,7 +11,7 @@ Redshift, Snowflake, Spark, - SqlDialect, + Dialect, Sqlite, Teradata, ) @@ -70,7 +70,7 @@ def test_nodes_are_expr_subclasses(self): for node in nodes: assert isinstance(node, Expr) - def test_render_succeeds_on_every_dialect(self, dialect_instance: SqlDialect): + def test_render_succeeds_on_every_dialect(self, dialect_instance: Dialect): expr = BinaryExpr(Ident("id"), ">", 10) sql = expr.to_sql(dialect_instance) assert "id" in sql diff --git a/tests/test_ident.py b/tests/test_ident.py index 3b50c2f..5d94f67 100644 --- a/tests/test_ident.py +++ b/tests/test_ident.py @@ -1,7 +1,7 @@ import pytest from rapidquery import Asterisk, Ident, Unquote -from rapidquery.dialect import MySql, PostgreSql, SqlDialect +from rapidquery.dialect import MySql, PostgreSql, Dialect class TestIdentConstruction: @@ -136,7 +136,7 @@ def test_repr(self): class TestRender: - def test_to_sql_uses_dialect_quote_identifier(self, dialect_instance: SqlDialect): + def test_to_sql_uses_dialect_quote_identifier(self, dialect_instance: Dialect): ident = Ident("users", "id") sql = ident.to_sql(dialect_instance) assert sql == ( diff --git a/tests/test_serializers.py b/tests/test_serializers.py index e12d7f4..35ffa6d 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -10,7 +10,7 @@ import pytest -from rapidquery.data_type import ( +from rapidquery.serializers import ( ArraySerializer, BitSerializer, BooleanSerializer, @@ -36,7 +36,7 @@ Oracle, PostgreSql, Snowflake, - SqlDialect, + Dialect, Sqlite, ) @@ -68,12 +68,12 @@ class ExampleDataclass: x: int -def _custom_ok(dialect: SqlDialect, obj: typing.Any) -> str: - assert isinstance(dialect, SqlDialect) +def _custom_ok(dialect: Dialect, obj: typing.Any) -> str: + assert isinstance(dialect, Dialect) return f"CUSTOM:{obj}" -def _custom_not_str(dialect: SqlDialect, obj: typing.Any) -> int: +def _custom_not_str(dialect: Dialect, obj: typing.Any) -> int: return 12345 @@ -109,7 +109,7 @@ def test_rejects_non_finite(self, value: float): @pytest.mark.parametrize("dialect", ALL_DIALECTS, ids=ALL_DIALECT_NAMES) -def test_boolean_spelling(dialect: SqlDialect): +def test_boolean_spelling(dialect: Dialect): s = BooleanSerializer() if type(dialect).__name__ in BOOL_INT_NAMES: assert s.serialize(dialect, True) == "1" @@ -126,7 +126,7 @@ def test_boolean_rejects_non_bool(value: typing.Any): @pytest.mark.parametrize("dialect", ALL_DIALECTS, ids=ALL_DIALECT_NAMES) -def test_string_obrien_and_nul(dialect: SqlDialect): +def test_string_obrien_and_nul(dialect: Dialect): s = StringSerializer() sql = s.serialize(dialect, "O'Brien") assert "O'Brien" not in sql @@ -180,7 +180,7 @@ def test_bytes_rejects_non_exact_bytes(value: typing.Any): class TestBitSerializer: - def test_follows_bit_format_when_supported(self, dialect_instance: SqlDialect): + def test_follows_bit_format_when_supported(self, dialect_instance: Dialect): name = type(dialect_instance).__name__ if name not in BIT_SQL: with pytest.raises( @@ -295,7 +295,7 @@ def test_datetime_overflow(self): class TestInetSerializer: - def test_canonical_cidr(self, dialect_instance: SqlDialect): + def test_canonical_cidr(self, dialect_instance: Dialect): name = type(dialect_instance).__name__ if name not in {"PostgreSql", "DuckDb"}: with pytest.raises( @@ -324,7 +324,7 @@ def test_invalid_address(self): class TestMacAddressSerializer: - def test_canonical_form(self, dialect_instance: SqlDialect): + def test_canonical_form(self, dialect_instance: Dialect): if type(dialect_instance).__name__ != "PostgreSql": with pytest.raises( NotImplementedError, match="does not support MacAddressSerializer" @@ -350,7 +350,7 @@ def test_uses_str(self): class TestArraySerializer: - def test_renders_array_constructor(self, dialect_instance: SqlDialect): + def test_renders_array_constructor(self, dialect_instance: Dialect): s = ArraySerializer(IntegerSerializer()) name = type(dialect_instance).__name__ expected = ARRAY_SQL.get(name) diff --git a/tests/test_value.py b/tests/test_value.py index f18ed7b..bbda65c 100644 --- a/tests/test_value.py +++ b/tests/test_value.py @@ -5,12 +5,11 @@ import pytest -from rapidquery.data_type import ( +from rapidquery.serializers import ( ArraySerializer, BitSerializer, BooleanSerializer, BytesSerializer, - DataType, DateSerializer, DatetimeSerializer, FloatSerializer, @@ -105,9 +104,11 @@ def test_wrap_trick_to_change_serializer(self): assert isinstance(inner.get_serializer(), InetSerializer) assert isinstance(outer.get_serializer(), UnspecifiedSerializer) - def test_data_type_serializer_override(self): - value = Value("x", DataType("integer", StringSerializer())) - assert isinstance(value.get_serializer(), StringSerializer) + def test_data_type_is_not_a_serializer(self): + from rapidquery.data_type import DataType + + with pytest.raises(TypeError): + Value("x", DataType("integer")) class TestValueToSql: From 09ad395996182183b826fb382840ef1fd6d8ff7f Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Sun, 4 Oct 2026 11:57:49 +0330 Subject: [PATCH 65/87] Remove PyDataType --- rapidquery/data_type.py | 7 -- rapidquery/data_type.pyi | 41 ------- src/data_type/mod.rs | 258 +++++++++++++++++++++++++-------------- src/data_type/name.rs | 195 ----------------------------- src/expr/nodes.rs | 5 +- src/lib.rs | 3 - src/typeref.rs | 7 +- 7 files changed, 174 insertions(+), 342 deletions(-) delete mode 100644 rapidquery/data_type.py delete mode 100644 rapidquery/data_type.pyi delete mode 100644 src/data_type/name.rs diff --git a/rapidquery/data_type.py b/rapidquery/data_type.py deleted file mode 100644 index 0995e4d..0000000 --- a/rapidquery/data_type.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -SQL data type names. -""" - -from ._core.data_type import DataType - -__all__ = ["DataType"] diff --git a/rapidquery/data_type.pyi b/rapidquery/data_type.pyi deleted file mode 100644 index c685a65..0000000 --- a/rapidquery/data_type.pyi +++ /dev/null @@ -1,41 +0,0 @@ -"""SQL data type names.""" - -import typing - -@typing.final -class DataType: - """ - Represents a SQL data type. - - A data type is a SQL type name. The name can be any string; it is not - limited to a set of predefined values. - - Known names are interned and canonicalized (`"int"` becomes `"INT"`). - Unknown names and parameterized spellings (`VARCHAR(255)`) are kept as - given. Interning applies to the **name string**, not to `DataType` - instances: `DataType("int")` is not a singleton. - """ - - def __new__(cls, name: str, /) -> typing.Self: - """ - Creates a new SQL data type. - - Args: - name: The SQL type spelling. Not limited to a predefined set. - Known names are interned and canonicalized (`"int"` - becomes `"INT"`). Unknown names and parameterized - spellings (`VARCHAR(255)`) are kept as given. - """ - - @property - def name(self) -> str: - """ - Returns the type name. - - Returns: - The canonical type name. For interned names this may differ in - casing or spacing from the string passed to the constructor. - """ - - def __eq__(self, other: object) -> bool: ... - def __hash__(self) -> int: ... diff --git a/src/data_type/mod.rs b/src/data_type/mod.rs index 70e6b6b..1e77e13 100644 --- a/src/data_type/mod.rs +++ b/src/data_type/mod.rs @@ -3,119 +3,199 @@ //! A [`DataTypeName`] is a canonical SQL spelling. Known names are interned. //! Serializing a Python value is [`crate::serializers`], not this module. -mod name; +use crate::sql; #[cfg(test)] mod tests; -pub use name::DataTypeName; - -use crate::internal::repr::ReprFormatter; -use crate::internal::utils; - -/// Represents a SQL data type. -/// -/// A data type is a SQL type name. The name can be any string; it is not -/// limited to a set of predefined values. +/// SQL type spelling. /// -/// Known names are interned and canonicalized (`"int"` becomes `"INT"`). -/// Unknown names and parameterized spellings (`VARCHAR(255)`) are kept as -/// given. Interning applies to the **name string**, not to `DataType` -/// instances: `DataType("int")` is not a singleton. -/// -/// [`WriteSql`] emits the type name (CAST, column definitions). A Python -/// value is serialized by a `rapidquery.serializers` serializer on `Value`. -#[rapidquery_macros::pyclass("rapidquery.data_type.DataType", from_py_object, eq, hash)] -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct PyDataType { - pub name: DataTypeName, +/// Known names are interned (`arcstr::literal!`) under that same spelling +/// (case-folded to the traditional uppercase form). Unknown and parameterized +/// names keep the caller's spelling. Synonyms are not rewritten to a different +/// name. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +pub struct DataTypeName { + name: arcstr::ArcStr, } -impl PyDataType { +impl DataTypeName { #[inline] - pub fn from_name(name: impl Into) -> Self { - Self { name: name.into() } + pub fn as_str(&self) -> &str { + &self.name } } -impl crate::sql::WriteSql for PyDataType { - #[inline] - fn write_sql( - &self, - dialect: &crate::dialect::PyDialect, - out: &mut W, - ) -> crate::sql::PrepareResult<()> { - self.name.write_sql(dialect, out) - } -} +// Longest interned name is `"timestamp without time zone"` (27 bytes). +const LOOKUP_BUF: usize = 32; -#[pyo3::pymethods] -impl PyDataType { - /// Creates a new SQL data type. - /// - /// Args: - /// name: The SQL type spelling. Not limited to a predefined set. - /// Known names are interned and canonicalized (`"int"` - /// becomes `"INT"`). Unknown names and parameterized - /// spellings (`VARCHAR(255)`) are kept as given. - #[new] - #[pyo3(signature = (name, /))] - fn __new__(name: String) -> Self { - PyDataType::from_name(name) +fn interned(value: &str) -> Option { + if value.len() > LOOKUP_BUF || !value.is_ascii() { + return None; } - - /// Returns the type name. - /// - /// Returns: - /// The canonical type name. For interned names this may differ in - /// casing or spacing from the string passed to the constructor. - #[getter] - fn get_name(&self) -> &str { - self.name.as_str() + let mut buf = [0u8; LOOKUP_BUF]; + for (i, b) in value.bytes().enumerate() { + buf[i] = b.to_ascii_lowercase(); } + // SAFETY: `value` is ASCII, so the lowercased copy is valid UTF-8. + let lower = unsafe { std::str::from_utf8_unchecked(&buf[..value.len()]) }; + lookup_alias(lower) +} - fn __repr__(&self) -> String { - let mut fmt = ReprFormatter::new_call("DataType"); - fmt.positional_debug(self.name.as_str()); - fmt.into_string() - } +fn custom_name>(value: T) -> DataTypeName { + DataTypeName { name: value.into() } } -/// An extractor for DataTypeName, which accepts `str` and `DataType` -pub struct DataTypeNameExtractor(pub DataTypeName); +// Interned no-arg types. `arcstr::literal!` shares one allocation per spelling. +macro_rules! simple_data_types { + ( + $( + $(#[$docs:meta])* + ($upcase:ident => $realname:literal, $($possible_names:literal)|+); + )+ + ) => { + $( + $(#[$docs])* + pub(super) const $upcase: DataTypeName = DataTypeName { + name: arcstr::literal!($realname), + }; + )+ + + fn lookup_alias(lowercase: &str) -> Option { + match lowercase { + $( + $($possible_names)|+ => Some($upcase), + )+ + _ => None, + } + } + }; +} -impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for DataTypeNameExtractor { - type Error = pyo3::PyErr; +simple_data_types! { + // --- String --- + (CHARACTER_DATA_TYPE => "CHARACTER", "character"); + (CHAR_DATA_TYPE => "CHAR", "char"); + (CHARACTER_VARYING_DATA_TYPE => "CHARACTER VARYING", "character varying"); + (CHAR_VARYING_DATA_TYPE => "CHAR VARYING", "char varying"); + (VARCHAR_DATA_TYPE => "VARCHAR", "varchar"); + (NVARCHAR_DATA_TYPE => "NVARCHAR", "nvarchar"); + (TEXT_DATA_TYPE => "TEXT", "text"); + + // --- Uuid --- + (UUID_DATA_TYPE => "UUID", "uuid"); + + // --- Bytes --- + (BYTES_DATA_TYPE => "BYTES", "bytes"); + (BINARY_DATA_TYPE => "BINARY", "binary"); + (VARBINARY_DATA_TYPE => "VARBINARY", "varbinary"); + (BLOB_DATA_TYPE => "BLOB", "blob"); + (BYTEA_DATA_TYPE => "BYTEA", "bytea"); + + // --- Bit --- + (BIT_DATA_TYPE => "BIT", "bit"); + (VARBIT_DATA_TYPE => "VARBIT", "varbit"); + (BIT_VARYING_DATA_TYPE => "BIT VARYING", "bit varying"); + + // --- Float (includes decimal/numeric) --- + (NUMERIC_DATA_TYPE => "NUMERIC", "numeric"); + (BIGNUMERIC_DATA_TYPE => "BIGNUMERIC", "bignumeric"); + (DECIMAL_DATA_TYPE => "DECIMAL", "decimal"); + (BIGDECIMAL_DATA_TYPE => "BIGDECIMAL", "bigdecimal"); + (FLOAT_DATA_TYPE => "FLOAT", "float"); + (REAL_DATA_TYPE => "REAL", "real"); + (DOUBLE_DATA_TYPE => "DOUBLE", "double"); + (DOUBLE_PRECISION_DATA_TYPE => "DOUBLE PRECISION", "double precision"); + + // --- Integer --- + (TINYINT_DATA_TYPE => "TINYINT", "tinyint"); + (SMALLINT_DATA_TYPE => "SMALLINT", "smallint"); + (INT_DATA_TYPE => "INT", "int"); + (INTEGER_DATA_TYPE => "INTEGER", "integer"); + (BIGINT_DATA_TYPE => "BIGINT", "bigint"); + + // --- Boolean --- + (BOOLEAN_DATA_TYPE => "BOOLEAN", "boolean" | "bool"); + + // --- Date --- + (DATE_DATA_TYPE => "DATE", "date"); + + // --- Time --- + (TIME_DATA_TYPE => "TIME", "time" | "time without timezone" | "time without time zone"); + (TIME_WITH_TIMEZONE_DATA_TYPE => "TIME WITH TIMEZONE", "time with timezone"); + (TIME_WITH_TIME_ZONE_DATA_TYPE => "TIME WITH TIME ZONE", "time with time zone"); + + // --- Datetime --- + (DATETIME_DATA_TYPE => "DATETIME", "datetime"); + (TIMESTAMP_DATA_TYPE => "TIMESTAMP", "timestamp" | "timestamp without timezone" | "timestamp without time zone"); + (TIMESTAMP_WITH_TIMEZONE_DATA_TYPE => "TIMESTAMP WITH TIMEZONE", "timestamp with timezone"); + (TIMESTAMP_WITH_TIME_ZONE_DATA_TYPE => "TIMESTAMP WITH TIME ZONE", "timestamp with time zone"); + (TIMESTAMPTZ_DATA_TYPE => "TIMESTAMPTZ", "timestamptz"); + + // --- Json --- + (JSON_DATA_TYPE => "JSON", "json"); + (JSONB_DATA_TYPE => "JSONB", "jsonb"); + + // --- Inet --- + (INET_DATA_TYPE => "INET", "inet"); + + // --- MacAddress --- + (MACADDRESS_DATA_TYPE => "MACADDRESS", "macaddress" | "macaddr"); +} - fn extract( - obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, - ) -> Result { - let ptr = obj.as_ptr(); +fn is_parameterized(value: &str) -> bool { + value.as_bytes().contains(&b'(') +} - unsafe { - // SAFETY: `ptr` is a live borrowed `PyObject`. Type pointers are - // interned in `initialize_typeref` before any extractor runs. - if crate::typeref::check(ptr, crate::typeref::DATATYPE_TYPE) { - let dt = obj.cast_unchecked::(); - return Ok(Self(dt.get().name.clone())); - } +impl From<&str> for DataTypeName { + fn from(value: &str) -> Self { + if is_parameterized(value) { + return custom_name(value); + } + interned(value).unwrap_or_else(|| custom_name(value)) + } +} - if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { - let borrowed = utils::borrow_py_str_buffer(obj.py(), ptr)?; - return Ok(Self(DataTypeName::from(borrowed))); - } +impl From for DataTypeName { + fn from(value: String) -> Self { + if is_parameterized(&value) { + return custom_name(value); } + if let Some(dt) = interned(&value) { + return dt; + } + custom_name(value) + } +} + +impl AsRef for DataTypeName { + #[inline] + fn as_ref(&self) -> &str { + self.as_str() + } +} - rapidquery_macros::pyerr!( - Err(PyTypeError), - "expected str or DataType, got {}", - unsafe { crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) } - ) +impl From for String { + #[inline] + fn from(val: DataTypeName) -> Self { + val.name.to_string() } } -#[pyo3::pymodule(name = "data_type", gil_used = false)] -pub mod data_type_mod { - #[pymodule_export] - use super::PyDataType; +impl std::fmt::Display for DataTypeName { + #[inline] + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.name.fmt(f) + } +} + +impl sql::WriteSql for DataTypeName { + fn write_sql( + &self, + _dialect: &crate::dialect::PyDialect, + out: &mut W, + ) -> sql::PrepareResult<()> { + out.push_str(self.as_str()); + Ok(()) + } } diff --git a/src/data_type/name.rs b/src/data_type/name.rs deleted file mode 100644 index c00ba58..0000000 --- a/src/data_type/name.rs +++ /dev/null @@ -1,195 +0,0 @@ -use crate::sql::PrepareResult; -use crate::sql::SqlWriter; -use crate::sql::WriteSql; - -/// SQL type spelling. -/// -/// Known names are interned (`arcstr::literal!`) under that same spelling -/// (case-folded to the traditional uppercase form). Unknown and parameterized -/// names keep the caller's spelling. Synonyms are not rewritten to a different -/// name. -#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] -pub struct DataTypeName { - name: arcstr::ArcStr, -} - -impl DataTypeName { - #[inline] - pub fn as_str(&self) -> &str { - &self.name - } -} - -// Longest interned name is `"timestamp without time zone"` (27 bytes). -const LOOKUP_BUF: usize = 32; - -fn interned(value: &str) -> Option { - if value.len() > LOOKUP_BUF || !value.is_ascii() { - return None; - } - let mut buf = [0u8; LOOKUP_BUF]; - for (i, b) in value.bytes().enumerate() { - buf[i] = b.to_ascii_lowercase(); - } - // SAFETY: `value` is ASCII, so the lowercased copy is valid UTF-8. - let lower = unsafe { std::str::from_utf8_unchecked(&buf[..value.len()]) }; - lookup_alias(lower) -} - -fn custom_name>(value: T) -> DataTypeName { - DataTypeName { name: value.into() } -} - -// Interned no-arg types. `arcstr::literal!` shares one allocation per spelling. -macro_rules! simple_data_types { - ( - $( - $(#[$docs:meta])* - ($upcase:ident => $realname:literal, $($possible_names:literal)|+); - )+ - ) => { - $( - $(#[$docs])* - pub(super) const $upcase: DataTypeName = DataTypeName { - name: arcstr::literal!($realname), - }; - )+ - - fn lookup_alias(lowercase: &str) -> Option { - match lowercase { - $( - $($possible_names)|+ => Some($upcase), - )+ - _ => None, - } - } - }; -} - -simple_data_types! { - // --- String --- - (CHARACTER_DATA_TYPE => "CHARACTER", "character"); - (CHAR_DATA_TYPE => "CHAR", "char"); - (CHARACTER_VARYING_DATA_TYPE => "CHARACTER VARYING", "character varying"); - (CHAR_VARYING_DATA_TYPE => "CHAR VARYING", "char varying"); - (VARCHAR_DATA_TYPE => "VARCHAR", "varchar"); - (NVARCHAR_DATA_TYPE => "NVARCHAR", "nvarchar"); - (TEXT_DATA_TYPE => "TEXT", "text"); - - // --- Uuid --- - (UUID_DATA_TYPE => "UUID", "uuid"); - - // --- Bytes --- - (BYTES_DATA_TYPE => "BYTES", "bytes"); - (BINARY_DATA_TYPE => "BINARY", "binary"); - (VARBINARY_DATA_TYPE => "VARBINARY", "varbinary"); - (BLOB_DATA_TYPE => "BLOB", "blob"); - (BYTEA_DATA_TYPE => "BYTEA", "bytea"); - - // --- Bit --- - (BIT_DATA_TYPE => "BIT", "bit"); - (VARBIT_DATA_TYPE => "VARBIT", "varbit"); - (BIT_VARYING_DATA_TYPE => "BIT VARYING", "bit varying"); - - // --- Float (includes decimal/numeric) --- - (NUMERIC_DATA_TYPE => "NUMERIC", "numeric"); - (BIGNUMERIC_DATA_TYPE => "BIGNUMERIC", "bignumeric"); - (DECIMAL_DATA_TYPE => "DECIMAL", "decimal"); - (BIGDECIMAL_DATA_TYPE => "BIGDECIMAL", "bigdecimal"); - (FLOAT_DATA_TYPE => "FLOAT", "float"); - (REAL_DATA_TYPE => "REAL", "real"); - (DOUBLE_DATA_TYPE => "DOUBLE", "double"); - (DOUBLE_PRECISION_DATA_TYPE => "DOUBLE PRECISION", "double precision"); - - // --- Integer --- - (TINYINT_DATA_TYPE => "TINYINT", "tinyint"); - (SMALLINT_DATA_TYPE => "SMALLINT", "smallint"); - (INT_DATA_TYPE => "INT", "int"); - (INTEGER_DATA_TYPE => "INTEGER", "integer"); - (BIGINT_DATA_TYPE => "BIGINT", "bigint"); - - // --- Boolean --- - (BOOLEAN_DATA_TYPE => "BOOLEAN", "boolean" | "bool"); - - // --- Date --- - (DATE_DATA_TYPE => "DATE", "date"); - - // --- Time --- - (TIME_DATA_TYPE => "TIME", "time" | "time without timezone" | "time without time zone"); - (TIME_WITH_TIMEZONE_DATA_TYPE => "TIME WITH TIMEZONE", "time with timezone"); - (TIME_WITH_TIME_ZONE_DATA_TYPE => "TIME WITH TIME ZONE", "time with time zone"); - - // --- Datetime --- - (DATETIME_DATA_TYPE => "DATETIME", "datetime"); - (TIMESTAMP_DATA_TYPE => "TIMESTAMP", "timestamp" | "timestamp without timezone" | "timestamp without time zone"); - (TIMESTAMP_WITH_TIMEZONE_DATA_TYPE => "TIMESTAMP WITH TIMEZONE", "timestamp with timezone"); - (TIMESTAMP_WITH_TIME_ZONE_DATA_TYPE => "TIMESTAMP WITH TIME ZONE", "timestamp with time zone"); - (TIMESTAMPTZ_DATA_TYPE => "TIMESTAMPTZ", "timestamptz"); - - // --- Json --- - (JSON_DATA_TYPE => "JSON", "json"); - (JSONB_DATA_TYPE => "JSONB", "jsonb"); - - // --- Inet --- - (INET_DATA_TYPE => "INET", "inet"); - - // --- MacAddress --- - (MACADDRESS_DATA_TYPE => "MACADDRESS", "macaddress" | "macaddr"); -} - -fn is_parameterized(value: &str) -> bool { - value.as_bytes().contains(&b'(') -} - -impl From<&str> for DataTypeName { - fn from(value: &str) -> Self { - if is_parameterized(value) { - return custom_name(value); - } - interned(value).unwrap_or_else(|| custom_name(value)) - } -} - -impl From for DataTypeName { - fn from(value: String) -> Self { - if is_parameterized(&value) { - return custom_name(value); - } - if let Some(dt) = interned(&value) { - return dt; - } - custom_name(value) - } -} - -impl AsRef for DataTypeName { - #[inline] - fn as_ref(&self) -> &str { - self.as_str() - } -} - -impl From for String { - #[inline] - fn from(val: DataTypeName) -> Self { - val.name.to_string() - } -} - -impl std::fmt::Display for DataTypeName { - #[inline] - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.name.fmt(f) - } -} - -impl WriteSql for DataTypeName { - fn write_sql( - &self, - _dialect: &crate::dialect::PyDialect, - out: &mut W, - ) -> PrepareResult<()> { - out.push_str(self.as_str()); - Ok(()) - } -} diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index ce0a75c..4535894 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -7,7 +7,6 @@ use super::extractors::ConditionExtractor; use super::extractors::IdentExtractor; use super::extractors::LiteralExprExtractor; use super::extractors::extract_like_pattern; -use crate::data_type::DataTypeNameExtractor; use crate::internal::alias; use crate::internal::utils; @@ -484,7 +483,7 @@ impl PyCastExpr { #[pyo3(signature = (expr, data_type_name, /, *, kind = "CAST"))] fn __new__( expr: LiteralExprExtractor, - data_type_name: DataTypeNameExtractor, + data_type_name: &str, kind: &str, ) -> pyo3::PyResult> { let Some(kind) = super::enum_::CastKind::parse(kind) else { @@ -494,7 +493,7 @@ impl PyCastExpr { let inner = super::enum_::Cast { kind, expr: Box::new(expr.0), - data_type_name: data_type_name.0, + data_type_name: data_type_name.into(), }; Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) } diff --git a/src/lib.rs b/src/lib.rs index d664969..be608db 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,9 +25,6 @@ pub mod typeref; /// RapidQuery native extension. #[pyo3::pymodule(gil_used = false)] mod _core { - #[pymodule_export] - use super::data_type::data_type_mod; - #[pymodule_export] use super::dialect::dialect_mod; diff --git a/src/typeref.rs b/src/typeref.rs index ff3b7d4..dcaeb7b 100644 --- a/src/typeref.rs +++ b/src/typeref.rs @@ -3,8 +3,7 @@ // Dialect types pub(crate) static mut DIALECT_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -// Data-type / serializer types -pub(crate) static mut DATATYPE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); +// Serializer types pub(crate) static mut SERIALIZER_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); // Ident types @@ -71,9 +70,9 @@ fn _initialize_typeref(py: pyo3::Python) { py, // dialects crate::dialect::PyDialect => DIALECT_TYPE, - // data types / serializers - crate::data_type::PyDataType => DATATYPE_TYPE, + // serializers crate::serializers::PySerializer => SERIALIZER_TYPE, + // exprs crate::expr::base::PyExpr => EXPR_TYPE, crate::expr::ident::PyIdent => IDENT_TYPE, crate::expr::ident::PyUnquote => UNQUOTE_TYPE, From e55ae8efdfd801299090ea60c697ec55508b158f Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Sun, 4 Oct 2026 14:53:59 +0330 Subject: [PATCH 66/87] Add pre-commit, Makefile, lint/format rules --- .pre-commit-config.yaml | 27 + rustfmt.toml => .rustfmt.toml | 1 + AGENTS.md | 86 --- Makefile | 78 +++ README.md | 28 +- pyproject.toml | 114 ++-- rapidquery-macros/Cargo.toml | 16 +- rapidquery-macros/lib.rs | 7 +- rapidquery/__init__.py | 2 - rapidquery/dialect.pyi | 9 - rapidquery/expr.pyi | 1036 +++------------------------------ rapidquery/serializers.pyi | 249 +------- rust-toolchain.toml | 2 - src/data_type/tests.rs | 6 +- src/dialect/base.rs | 7 +- src/dialect/flags.rs | 53 +- src/expr/base.rs | 16 +- src/expr/enum_/func.rs | 30 +- src/expr/enum_/like.rs | 78 +-- src/expr/enum_/nodes.rs | 152 ++--- src/expr/enum_/operator.rs | 15 +- src/expr/extractors.rs | 41 +- src/expr/func.rs | 10 +- src/expr/ident.rs | 23 +- src/expr/nodes.rs | 35 +- src/expr/value.rs | 5 +- src/internal/modules/json.rs | 17 +- src/internal/repr.rs | 12 +- src/internal/utils/mod.rs | 10 +- src/internal/utils/strings.rs | 21 +- src/lib.rs | 1 - src/serializers/functions.rs | 116 ++-- src/serializers/inner.rs | 11 +- src/serializers/mod.rs | 26 +- src/sql.rs | 24 +- src/typeref.rs | 16 +- tests/conftest.py | 29 +- tests/test_data_type.py | 48 -- tests/test_dialect.py | 467 --------------- tests/test_expr.py | 738 ++++++++--------------- tests/test_ident.py | 66 ++- tests/test_serializers.py | 201 +++---- tests/test_value.py | 26 +- uv.lock | 606 +++++++++++++++++++ 44 files changed, 1488 insertions(+), 3073 deletions(-) create mode 100644 .pre-commit-config.yaml rename rustfmt.toml => .rustfmt.toml (80%) delete mode 100644 AGENTS.md create mode 100644 Makefile delete mode 100644 rust-toolchain.toml delete mode 100644 tests/test_data_type.py delete mode 100644 tests/test_dialect.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..bdb488a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +fail_fast: true + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + - id: check-yaml + - id: check-toml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-added-large-files + + - repo: local + hooks: + - id: lint-python + name: Lint Python + entry: make lint-python + types: [python] + language: system + pass_filenames: false + + - id: lint-rust + name: Lint Rust + entry: make lint-rust + types: [rust] + language: system + pass_filenames: false diff --git a/rustfmt.toml b/.rustfmt.toml similarity index 80% rename from rustfmt.toml rename to .rustfmt.toml index 9c53693..1017b98 100644 --- a/rustfmt.toml +++ b/.rustfmt.toml @@ -1,2 +1,3 @@ imports_granularity = "Item" single_line_let_else_max_width = 100 +max_width = 120 diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 763fce1..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,86 +0,0 @@ -# RapidQuery — agent notes - -High-performance Python SQL query builder. Public API is Python; the engine is Rust (PyO3 + maturin). Package: `rapidquery`. Native module: `rapidquery._core`. - -This branch (`refactor/v1`) restarts the closed draft PR **[#2](https://github.com/awolverp/rapidquery/pull/2)** (`origin/migrate` @ `77c6267`). That PR is the **architectural target**. It is not a SeaQuery port and it is not a vendored `sqlparser-rs` — it is a custom engine *inspired by* sqlparser-rs. - -Goals from the PR (keep these): -- No SeaQuery panics; production-safe SQL building. -- 13 dialects (BigQuery, Databricks, DuckDB, Hive, MSSQL, MySQL, Oracle, PostgreSQL, Redshift, Snowflake, Spark, SQLite, Teradata). -- Full SQL data-type coverage via interned `DataTypeName` + `DataTypeSerializer`. -- One module per concept under `src/`. Representation shims (`dialect/py.rs`, `data_type/py.rs`) only where the engine is a `Copy`/unit enum and Python is a frozen class tree. Do not resurrect a crate-wide `native/` vs `pybindings/` split. -- Dual SQL rendering via `WriteSql` + `SqlWriter`: raw (`BufferedSql`, literals) and parameterized (`ParameterizedSql`, placeholders). -- Ident / Asterisk with dialect quoting. -- Leave the door open for a SQL parser and an ORM later. -- Prefer backward compatibility with v0, but it is **not** guaranteed. - -Do **not** restore v0 (`_lib`, SeaQuery, three backends) unless the user asks. - -Keep these `refactor/v1` divergences from the PR (do not revert them): -- Module name `rapidquery._core` (PR used `_native`). -- Proc-macros `pyclass` / `pyerr` (PR used `pyclass_shortcut` + `new_pyerr!`). -- MIT license, Python `>=3.10`, crate version `1.0.0-alpha1`. -- Writer types `BufferedSql` / `ParameterizedSql` and error `PrepareError` (PR used `PrepareStatementContext` / `PrepareStatementError`). Module comments in `sql.rs` still say `SqlBuf` / `PrepareContext`; those names are not types. - -## Stack - -- Python `>=3.10` (local: 3.13). Rust edition **2024**. PyO3 **0.29**. Maturin **1.15.0**. uv for the venv. Local and CI rustc is **nightly**. -- Workspace crate `rapidquery-macros` (proc-macros: `pyclass`, `pyerr`). -- Current deps: `arcstr`, `once_cell`, `chrono`, `ipnetwork`, `mac_address`, `parking_lot`, `hashbrown`, `smallvec`. (`derive_more` is not on this branch.) -- Dialects (13), `DataType` + `Serializer`, `Ident`/`Unquote`/`Asterisk`, `Value`, `SqlWriter`/`WriteSql` exist on this branch. Still missing vs the PR’s next layers: query/schema AST (never in the PR either). -- `#[pyo3::pymodule(gil_used = false)]` — keep free-threaded Python in mind. -- Frozen/immutable PyO3 types: `rapidquery_macros::pyclass` already sets `module="rapidquery._core"`, `immutable_type`, `frozen`. -- Optional extra: `orjson`. Test-only crate features: `only-stdlib-json`, `only-orjson` (mutually exclusive). `nightly` enables hashbrown nightly + `optimize` attrs. - -## Layout - -``` -src/dialect/ SqlDialect enum + engines + tests; py.rs class tree -src/data_type/ DataType, interned names, Serializer enum; py.rs class tree -src/expr/ ExprInner + PyExpr ABC; ident.rs / value.rs / func / nodes -src/sql.rs BufferedSql, ParameterizedSql, WriteSql -src/internal/ aliases, JSON dumps helper, string utils, submodule helper -src/typeref.rs cached PyTypeObject pointers -rapidquery/ Python package: expr.py, data_type.py, dialect.py -rapidquery-macros/ Proc-macros -tests/ Python tests (ident, value, dialect, data_type, serializers) -backup/, backup-py/ gitignored v0 snapshots — read-only -target/, .venv/ Generated — do not commit or edit -``` - -Rust unit tests live next to the types (`data_type/tests.rs`, `dialect/tests.rs`, `expr/ident.rs`, `expr/tests.rs`, `internal/utils/strings.rs`). - -Reference the PR without checking it out as the working tree: `git show origin/migrate:`. Do not copy files wholesale if this branch already renamed or cleaned them. - -## Commands - -```bash -uv sync --dev -maturin develop # or: uv run maturin develop -cargo test --workspace --lib --bins --tests --no-default-features --features auto-initialize -cargo clippy --workspace --all-targets -- -D warnings # project lints are warn-level; CI does not run clippy -cargo fmt # no rustfmt.toml on this branch; CI does not run rustfmt -uv run pytest -vv # CI command; pytest is in [dependency-groups] dev -``` - -There is no Python linter/typechecker configured. Do not add ruff/mypy/a new test runner unless asked. - -After meaningful Rust changes: run `cargo test --workspace --lib --bins --tests --no-default-features --features auto-initialize`. After Python-facing changes: also `uv run pytest -vv`. - -`cargo test --workspace` (including doctests) currently fails: rustdoc builds `rapidquery-macros` without syn’s `full` feature. `pyerr` examples are marked `rust,no-test`. - -CI (`.github/workflows/test.yml`) runs `uv sync --all-extras --dev` then `uv run pytest -vv` on Python 3.10 / 3.14 / 3.14t / pypy3.11 and ubuntu/macos/windows. It still sets `HYPOTHESIS_PROFILE` though hypothesis is not a dependency. - -## How to edit - -- Small, reviewable diffs. Match existing patterns on **this** branch first; for missing layers, match `origin/migrate` conceptually (not its `native/` / `_native` folder names). One module per concept. `dialect/py.rs` and `data_type/py.rs` are the only enum-vs-class shims; serializers subclass a base `Serializer`; dialects subclass `SqlDialect` and are `@final`. Do not add `PyFoo(Foo)` for future AST nodes — put `#[pyclass]` on the node or keep a private rust type in the same file. Parser/ORM later: keep leaves cheap (`Ident`, `DataTypeName`, `SqlDialect`). -- Do not introduce SeaQuery, a sqlparser-rs crate dependency, SQLAlchemy, another Python package layout, or a new build backend. -- Do not touch `Cargo.lock` / `uv.lock` unless the change requires a dependency update. Do not touch generated artifacts (`target/`, `*.so`, wheels), secrets, or `.env`. -- `backup/` and `backup-py/` are gitignored snapshots of v0. Do not edit them; do not copy them wholesale into `src/`. `a.py` is local scratch (gitignored). -- SQL injection safety is a product requirement. Prefer parameterized `ParameterizedSql::push_param` over interpolating values. Dialect `escape_*` / `quote_*` must not let payloads close literals. -- Unsafe is allowed where the crate already uses it (`from_utf8_unchecked` after ASCII-boundary splits, PyO3 FFI). Keep the safety invariant next to the block. -- Clippy: no `dbg!`, `println!`, or `eprintln!` in library code (warn). Release profile uses `panic = "abort"`. - -## Docs drift - -`README.md` still describes v0.1.0 (SeaQuery, three backends, `_lib`). Treat `Cargo.toml` + `src/` as source of truth for what is implemented; treat PR #2 / `origin/migrate` as source of truth for what comes next. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7e4144a --- /dev/null +++ b/Makefile @@ -0,0 +1,78 @@ +# Customized version of https://github.com/pydantic/pydantic/raw/refs/heads/main/pydantic-core/Makefile + +.DEFAULT_GOAL := all + +uv-run-no-sync = uv run --no-sync + +# using pip install cargo (via maturin via pip) doesn't get the tty handle +# so doesn't render color without some help +export CARGO_TERM_COLOR=$(shell (test -t 0 && echo "always") || echo "auto") + +.PHONY: .uv ## Check that uv is installed +.uv: + @uv -V || echo 'Please install uv: https://docs.astral.sh/uv/getting-started/installation/' + +.PHONY: install ## Install the package, dependencies, and pre-commit for local development +install: .uv + uv sync --frozen --all-groups + uv run pre-commit install --install-hooks + +.PHONY: rebuild-lockfiles ## Rebuild lockfiles from scratch, updating all dependencies +rebuild-lockfiles: .uv + uv lock --upgrade + +.PHONY: build-dev ## Build the development version of the package +build-dev: + @rm -f rapidquery/*.so + $(uv-run-no-sync) maturin develop --uv + +.PHONY: build-prod ## Build the production version of the package +build-prod: + @rm -f rapidquery/*.so + $(uv-run-no-sync) maturin develop --uv --release + +.PHONY: format ## Auto-format rust and python source files +format: + uv run ruff check --fix . + uv run ruff format . + cargo +nightly fmt + +.PHONY: lint-python ## Lint python source files +lint-python: + uv run ruff check . + uv run ruff format --check . + +.PHONY: lint-rust ## Lint rust source files +lint-rust: + cargo +nightly fmt --version + cargo +nightly fmt --all -- --check + cargo clippy --version + cargo clippy --tests -- -D warnings + +.PHONY: lint ## Lint rust and python source files +lint: lint-python lint-rust + +.PHONY: test ## Run all tests +test: + uv run pytest + +.PHONY: all ## Run the standard set of checks performed in CI +all: format lint build-dev test + +.PHONY: clean ## Clear local caches and build artifacts +clean: + rm -rf `find . -name __pycache__` + rm -f `find . -type f -name '*.py[co]' ` + rm -f `find . -type f -name '*~' ` + rm -f `find . -type f -name '.*~' ` + rm -rf .pytest_cache + rm -rf .ruff_cache + rm -rf *.egg-info + rm -rf rapidquery/*.so + +.PHONY: help ## Display this message +help: + @grep -E \ + '^.PHONY: .*?## .*$$' $(MAKEFILE_LIST) | \ + sort | \ + awk 'BEGIN {FS = ".PHONY: |## "}; {printf "\033[36m%-19s\033[0m %s\n", $$2, $$3}' diff --git a/README.md b/README.md index 664a0a2..d73545e 100644 --- a/README.md +++ b/README.md @@ -128,18 +128,14 @@ rq.Expr.col("age") > 16 # -> "age" > 16 # Note that `rq.all` is different from built-in `all` rq.all( rq.Expr.col("age") >= 18, - rq.Expr.col( - "subscription" - ).is_null(), # same as rq.Expr.col("subscription").is_(Expr.null()) + rq.Expr.col("subscription").is_null(), # same as rq.Expr.col("subscription").is_(Expr.null()) rq.Expr.col("status").in_(["pending", "approved", "active"]), ) # -> "age" >= 18 AND "subscription" IS NULL AND "status" IN ('pending', 'approved', 'active') # Note that `rq.any` is different from built-in `any` rq.any( rq.Expr.col("is_admin").is_(True), - rq.Expr.col( - "is_moderator" - ).is_not_null(), # same as rq.Expr.col("subscription").is_not(Expr.null()) + rq.Expr.col("is_moderator").is_not_null(), # same as rq.Expr.col("subscription").is_not(Expr.null()) rq.Expr.col("price").between(10.00, 50.00), ) # -> "is_admin" IS TRUE OR "is_moderator" IS NOT NULL OR "price" BETWEEN 10.00 AND 50.00 ``` @@ -252,11 +248,7 @@ Use `rapidquery.InsertStatement` type to generate `INSERT` statements. ```python import rapidquery as rq -stmt = ( - rq.InsertStatement("glyph") - .values(aspect=3.14, image="A4") - .on_conflict(rq.OnConflict("id").do_update("image")) -) +stmt = rq.InsertStatement("glyph").values(aspect=3.14, image="A4").on_conflict(rq.OnConflict("id").do_update("image")) print(stmt.to_sql("postgres")) # INSERT INTO "glyph" ("aspect", "image") VALUES (3.14, 'A4') @@ -278,11 +270,7 @@ Use `rapidquery.UpdateStatement` type to generate `UPDATE` statements. ```python import rapidquery as rq -stmt = ( - rq.UpdateStatement("glyph") - .values(aspect=1.23, image="123") - .where(rq.Expr.col("id") == 1) -) +stmt = rq.UpdateStatement("glyph").values(aspect=1.23, image="123").where(rq.Expr.col("id") == 1) print(stmt.to_sql("postgres")) # UPDATE "glyph" SET "aspect" = 1.23, "image" = '123' WHERE "id" = 1 @@ -360,12 +348,8 @@ clause = ( ) ) -users_count_select = rq.SelectStatement(rq.Func.count(rq.Expr.asterisk())).from_table( - "users_count" -) -teams_count_select = rq.SelectStatement(rq.Func.count(rq.Expr.asterisk())).from_table( - "teams_count" -) +users_count_select = rq.SelectStatement(rq.Func.count(rq.Expr.asterisk())).from_table("users_count") +teams_count_select = rq.SelectStatement(rq.Func.count(rq.Expr.asterisk())).from_table("teams_count") query: WithQuery = clause.query( rq.SelectStatement( diff --git a/pyproject.toml b/pyproject.toml index c7ea927..18ace48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,66 +1,90 @@ -[build-system] -requires = ["maturin == 1.15.0"] -build-backend = "maturin" - -[tool.maturin] -features = ["pyo3/extension-module"] -module-name = "rapidquery._core" - [project] name = "rapidquery" requires-python = ">=3.10" +authors = [{ name = "Ali Pooralijan", email = "awolverp@gmail.com" }] keywords = [ - "SQL", - "dynamic-sql", - "sql-builder", - "rapidorm", - "pypika", - "orm", - "rapidquery", + "SQL", + "dynamic-sql", + "orm", + "pypika", + "rapidorm", + "rapidquery", + "sql-builder", ] -authors = [{ name = "Ali Pooralijan", email = "awolverp@gmail.com" }] classifiers = [ - "Programming Language :: Rust", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", - "Programming Language :: Python :: 3.15", - "Programming Language :: Python :: Implementation :: PyPy", - "Programming Language :: Python :: Implementation :: CPython", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: POSIX :: Linux", - "Operating System :: Microsoft :: Windows", - "Operating System :: MacOS", - "Typing :: Typed", - "Topic :: Software Development :: Libraries", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Programming Language :: Rust", + "Topic :: Software Development :: Libraries", + "Typing :: Typed", ] -dynamic = [ - "description", - "license", - "readme", - "version" -] +dynamic = ["description", "license", "readme", "version"] [project.urls] Homepage = "https://github.com/awolverp/rapidquery" -Repository = "https://github.com/awolverp/rapidquery" Issues = "https://github.com/awolverp/rapidquery/issues" +Repository = "https://github.com/awolverp/rapidquery" [project.optional-dependencies] orjson = [ - "orjson>=3.0.0", + "orjson>=3.0.0", ] [dependency-groups] dev = [ - "maturin == 1.15.0", - "pytest>=8.0.0", + { include-group = "build" }, + { include-group = "lint" }, + { include-group = "test" }, +] + +build = ["maturin == 1.15.0"] +lint = [ + "mypy>=2.3.1", + "pre-commit>=4.6.2", + "ruff>=0.16.9", +] +test = ["hypothesis>=6.0.0", "pytest>=8.0.0"] + +[build-system] +requires = ["maturin == 1.15.0"] +build-backend = "maturin" + +[tool.maturin] +features = ["pyo3/extension-module"] +module-name = "rapidquery._core" + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +ignore = ["DTZ00"] + +[tool.uv] +required-version = ">=0.10.0" +cache-keys = [ + { file = "pyproject.toml" }, + { file = "Cargo.toml" }, + { file = "Cargo.lock" }, + { dir = ".cargo" }, + { dir = "src" }, + { dir = "rapidquery" }, + { env = "MATURIN_PEP517_ARGS" }, + { env = "RUSTFLAGS" }, ] diff --git a/rapidquery-macros/Cargo.toml b/rapidquery-macros/Cargo.toml index b3c2d6d..574bc0b 100644 --- a/rapidquery-macros/Cargo.toml +++ b/rapidquery-macros/Cargo.toml @@ -3,21 +3,19 @@ name = "rapidquery-macros" version = "1.0.0-alpha1" edition = "2024" description = "Procedural macros for RapidQuery." -license = "MIT" homepage = "https://github.com/awolverp/rapidquery" repository = "https://github.com/awolverp/rapidquery.git" -authors = ["Ali Pooralijan "] +license = "MIT" [lib] path = "lib.rs" proc-macro = true [dependencies] -syn = { version = "2", default-features = false, features = [ - "parsing", - "printing", - "proc-macro", - "derive", -] } -quote = "1" proc-macro2 = "1" +quote = "1" + +[dependencies.syn] +version = "2" +default-features = false +features = ["derive", "parsing", "printing", "proc-macro"] diff --git a/rapidquery-macros/lib.rs b/rapidquery-macros/lib.rs index b3ff87c..8337422 100644 --- a/rapidquery-macros/lib.rs +++ b/rapidquery-macros/lib.rs @@ -85,10 +85,9 @@ pub fn pyclass(attr: TokenStream, item: TokenStream) -> TokenStream { let item = syn::parse_macro_input!(item as syn::ItemStruct); // Fresh tokens, no input span, so rustc cannot substitute the dotted literal. - let args: proc_macro2::TokenStream = - format!("module = \"{module}\", name = \"{name}\", immutable_type, frozen") - .parse() - .expect("pyclass args"); + let args: proc_macro2::TokenStream = format!("module = \"{module}\", name = \"{name}\", immutable_type, frozen") + .parse() + .expect("pyclass args"); let extra = if meta_args.is_empty() { quote! {} diff --git a/rapidquery/__init__.py b/rapidquery/__init__.py index f9c7e50..ef0ddc6 100644 --- a/rapidquery/__init__.py +++ b/rapidquery/__init__.py @@ -1,6 +1,5 @@ """Python SQL query builder with a Rust engine.""" -from . import data_type as data_type from . import dialect as dialect from . import expr as expr from . import serializers as serializers @@ -14,7 +13,6 @@ "Ident", "Unquote", "Value", - "data_type", "dialect", "expr", "serializers", diff --git a/rapidquery/dialect.pyi b/rapidquery/dialect.pyi index f6ad35d..dbc4f74 100644 --- a/rapidquery/dialect.pyi +++ b/rapidquery/dialect.pyi @@ -1,15 +1,6 @@ import typing class Dialect: - """ - Abstract SQL dialect. - - Concrete dialects are frozen subclasses (`PostgreSql`, `DuckDb`, ...). - Pass an instance wherever a dialect is required. - This base class cannot be instantiated. - - Instances of the same dialect compare equal and hash together. - """ def __eq__(self, other: object) -> bool: ... def __hash__(self) -> int: ... diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index ab9858a..bb4307d 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -1,383 +1,83 @@ import typing -from collections.abc import Iterable, Sequence +from collections.abc import Iterable from typing import Self, final, overload -from rapidquery.data_type import DataType -from rapidquery.serializers import Serializer from rapidquery.dialect import Dialect +from rapidquery.serializers import Serializer class Expr: - """ - Abstract SQL expression. - - Concrete nodes are frozen subclasses (``Ident``, ``Value``, - ``Function``, ``BinaryExpr``, …). This base class cannot be - instantiated. - """ - - def to_sql(self, dialect: Dialect) -> str: - """ - Render this expression as raw SQL (values inlined as literals). - """ - - def prepare(self, dialect: Dialect) -> tuple[str, tuple[typing.Any]]: - """ - Render this expression as a parameterized statement. - """ + def to_sql(self, dialect: Dialect) -> str: ... + def prepare(self, dialect: Dialect) -> tuple[str, tuple[typing.Any, ...]]: ... @final class Unquote: - """ - Marker for an unquoted identifier part. - - The body is emitted without dialect delimiters. It must be a simple - ASCII name (``[A-Za-z_][A-Za-z0-9_]*``). This is not a raw-SQL - fragment. - """ - - def __new__(cls, name: str, /) -> Self: - """ - Create an unquoted identifier part. - - Args: - name: The identifier body, unquoted. - - Returns: - A new ``Unquote``. - - Raises: - ValueError: If ``name`` is empty or not a simple ASCII - identifier (letters, digits, underscore; first character - not a digit). - """ - + def __new__(cls, name: str, /) -> Self: ... @property - def name(self) -> str: - """The unquoted identifier body. - - Returns: - The string passed to the constructor. - """ - + def name(self) -> str: ... def __eq__(self, other: object) -> bool: ... def __hash__(self) -> int: ... @final class Asterisk: - """ - SQL wildcard ``*``. - - Pass the class (``Asterisk``) or an instance (``Asterisk()``) as an - identifier part. It must be the last part of an ``Ident`` (``t.*``). - ``Ident("*")`` is a quoted column named star, not this wildcard. - """ - - def __new__(cls) -> Self: - """ - Create a wildcard part. - - Returns: - A new ``Asterisk``. - """ - + def __new__(cls) -> Self: ... def __eq__(self, other: object) -> bool: ... def __hash__(self) -> int: ... @final class Ident(Expr): - """ - SQL identifier (``schema.table.column``). - - An identifier is a sequence of parts. Each constructor argument is - one part: a quoted name (``str``), an ``Unquote`` name, an - ``Asterisk`` wildcard, or a nested ``Ident`` whose parts are - flattened. - - Never empty. At most 10 parts. An asterisk may appear only as the - last part. - """ - - def __new__(cls, *parts: str | Unquote | Ident | Asterisk | type[Asterisk]) -> Self: - """ - Create a SQL identifier. - - Args: - *parts: One argument per part. A ``str`` is always quoted, - including ``"*"`` (a column named star). Pass - ``Asterisk`` for the SQL wildcard. Dotted names are - ``Ident("schema", "table")``, not - ``Ident("schema.table")``. Sequences are not accepted. - - Returns: - A new ``Ident``. - - Raises: - TypeError: If an argument is not ``str``, ``Unquote``, - ``Asterisk``, or ``Ident``. - ValueError: If there are no parts, a part is empty, - ``Asterisk`` is not last, an ``Unquote`` body is not a - simple ASCII name, or there are more than 10 parts. - """ - - def __len__(self) -> int: - """ - Return the number of identifier parts. - - Returns: - Part count; always at least 1. - """ - + def __new__(cls, *parts: str | Unquote | Ident | Asterisk | type[Asterisk]) -> Self: ... + def __len__(self) -> int: ... def __getitem__(self, index: int) -> str | Unquote | Asterisk: ... def __hash__(self) -> int: ... def __eq__(self, value: object, /) -> bool: ... def __ne__(self, value: object, /) -> bool: ... - def to_list(self) -> Sequence[str | Unquote | Asterisk]: - """ - Return the parts as a list. - - Returns: - Each part as a ``str``, ``Unquote``, or ``Asterisk``. - """ + def to_list(self) -> list[str | Unquote | Asterisk]: ... _T = typing.TypeVar("_T") @final class Value(Expr, typing.Generic[_T]): - """ - A Python object plus the serializer used to emit it as SQL. - - ``Value`` does not convert the object until SQL is built. - Parameterized statements bind the object as a placeholder. Raw SQL - runs the serializer (``None`` becomes ``NULL``). - - If ``serializer`` is omitted, one is inferred from the object's - type. Passing an existing ``Value`` keeps its object (and its - serializer, unless a new one is given). - """ - @overload - def __new__(cls, object: _T, /) -> typing.Self: ... + def __new__(cls, object: _T, /) -> Self: ... @overload - def __new__( - cls, - object: _T, - serializer: Serializer[_T] | None = None, - /, - ) -> typing.Self: - """ - Create a SQL value. - - Args: - object: The Python object to bind or serialize. If this is - already a ``Value``, its object is reused. - serializer: How to render ``object`` as a SQL literal. If - omitted, a serializer is inferred from ``object`` (or - kept, when ``object`` is already a ``Value``). - - Returns: - A new ``Value``. - - Raises: - TypeError: If ``serializer`` is not a ``Serializer``. - """ - - def get_object(self) -> _T: - """ - Return the wrapped Python object. - - Returns: - The object passed to the constructor (or taken from an - existing ``Value``). - """ - - def get_serializer(self) -> Serializer[_T]: - """ - Return the serializer used for raw SQL literals. - - Returns: - The ``Serializer`` stored on this value. Not used when the - value is bound as a parameter. - """ - + def __new__(cls, object: _T, serializer: Serializer[_T] | None = None, /) -> Self: ... + def get_object(self) -> _T: ... + def get_serializer(self) -> Serializer[_T]: ... def __hash__(self) -> int: ... def __eq__(self, value: object, /) -> bool: ... def __ne__(self, value: object, /) -> bool: ... @final class Function(Expr): - """ - SQL function call. - - ``name`` is an identifier (``SUM``, ``schema.fn``). The constructor - emits a bare name such as ``CURRENT_TIMESTAMP`` (no parentheses). - Chain ``call``, ``filter``, ``ignore_nulls``, and ``respect_nulls``. - Each method returns a new frozen instance. - """ - def __new__( - cls, name: str | Ident | Unquote, /, *args: object, distinct: bool = False - ) -> Self: - """ - Create a function name with no argument list. - - Args: - name: Function name (``str``, ``Ident``, or ``Unquote``). - - Returns: - A new ``Function``. - - Raises: - TypeError: If ``name`` is not an identifier. - ValueError: If ``name`` is an empty or invalid identifier. - """ - - def filter(self, predicate: Expr, /) -> Self: - """ - Attach a ``FILTER (WHERE …)`` predicate. - - Args: - predicate: A condition expression (not ``Ident`` or ``Value``). - - Returns: - A new ``Function`` with the filter set. - - Raises: - TypeError: If ``predicate`` is not a condition expression. - """ - - def ignore_nulls(self) -> Self: - """ - Attach ``IGNORE NULLS``. - - BigQuery and DuckDB emit it inside the parentheses - (``LAG(x IGNORE NULLS)``). Other dialects emit it after the - call (``LAG(x) IGNORE NULLS``). - - Returns: - A new ``Function`` with this null treatment. - """ - - def respect_nulls(self) -> Self: - """ - Attach ``RESPECT NULLS``. - - Placement follows ``ignore_nulls`` (inside the parentheses on - BigQuery and DuckDB). - - Returns: - A new ``Function`` with this null treatment. - """ - - def get_name(self) -> Ident: - """ - Return the function name. - - Returns: - The function name as an ``Ident``. - """ - + cls, + name: str | Ident | Unquote | Asterisk | type[Asterisk], + /, + *args: object, + distinct: bool = False, + ) -> Self: ... + def filter(self, predicate: Expr, /) -> Self: ... + def ignore_nulls(self) -> Self: ... + def respect_nulls(self) -> Self: ... + def get_name(self) -> Ident: ... def get_distinct(self) -> bool: ... - def get_args(self) -> tuple[Expr, ...]: - """ - Return the positional arguments. - - Returns: - A tuple of argument expressions, or ``None`` when the call - has no parentheses. - """ - - def get_filter(self) -> Expr | None: - """ - Return the ``FILTER (WHERE …)`` predicate, if any. - - Returns: - The filter expression, or ``None``. - """ - - def get_null_treatment( - self, - ) -> typing.Literal["IGNORE NULLS", "RESPECT NULLS"] | None: - """ - Return the postfix null-treatment clause, if any. - - Returns: - ``IGNORE NULLS``, ``RESPECT NULLS``, or ``None``. - """ + def get_args(self) -> tuple[Expr, ...]: ... + def get_filter(self) -> Expr | None: ... + def get_null_treatment(self) -> typing.Literal["IGNORE NULLS", "RESPECT NULLS"] | None: ... @final class TupleExpr(Expr): - """Parenthesized row / tuple expression ``(a, b, …)``.""" - - def __new__(cls, *items: object) -> Self: - """ - Create a tuple expression. - - Args: - *items: Elements of the tuple. - - Returns: - A new ``TupleExpr``. - """ - - def get_items(self) -> tuple[Expr, ...]: - """ - Return the tuple elements. - - Returns: - The elements as a tuple of expressions. - """ + def __new__(cls, *items: object) -> Self: ... + def get_items(self) -> tuple[Expr, ...]: ... @final class UnaryExpr(Expr): - """Unary expression (``NOT x``, …).""" - - def __new__( - cls, - operator: typing.Literal["NOT", "+", "-", "~"] | str, - expr: object, - /, - ) -> Self: - """ - Create a unary expression. - - Args: - operator: A known unary spelling (``NOT``, ``+``, ``-``, ``~``). - expr: The operand. - - Returns: - A new ``UnaryExpr``. - - Raises: - ValueError: If ``operator`` is not a known unary operator. - """ - - def get_operator( - self, - ) -> typing.Literal["NOT", "+", "-", "~"]: - """ - Return the unary operator spelling. - - Returns: - The canonical SQL token. - """ - - def get_expr(self) -> Expr: - """ - Return the operand. - - Returns: - The operand expression. - """ + def __new__(cls, operator: typing.Literal["NOT", "+", "-", "~"] | str, expr: object, /) -> Self: ... + def get_operator(self) -> typing.Literal["NOT", "+", "-", "~"]: ... + def get_expr(self) -> Expr: ... @final class BinaryExpr(Expr): - """ - Binary expression (``a + b``, ``a AND b``, custom operators). - - Unknown operator spellings are stored as a custom - operator (for example ``->``). - """ - def __new__( cls, left: object, @@ -396,7 +96,6 @@ class BinaryExpr(Expr): ">", "AND", "OR", - "XOR", "&", "|", "^", @@ -407,49 +106,13 @@ class BinaryExpr(Expr): | str, right: object, /, - ) -> Self: - """ - Create a binary expression. - - Args: - left: Left operand. - operator: SQL operator spelling (``+``, ``AND``, ``=``, - ``->``, …). ``!=`` is stored as ``<>``. - right: Right operand. - - Returns: - A new ``BinaryExpr``. - """ - - def get_left(self) -> Expr: - """ - Return the left operand. - - Returns: - The left-hand expression. - """ - - def get_operator(self) -> str: - """ - Return the operator spelling. - - Returns: - The SQL operator (``+``, ``AND``, ``<>``, or the custom - string). - """ - - def get_right(self) -> Expr: - """ - Return the right operand. - - Returns: - The right-hand expression. - """ + ) -> Self: ... + def get_left(self) -> Expr: ... + def get_operator(self) -> str: ... + def get_right(self) -> Expr: ... @final class IsExpr(Expr): - """``expr IS [NOT] {NULL | TRUE | FALSE | UNKNOWN}``.""" - def __new__( cls, expr: object, @@ -457,192 +120,35 @@ class IsExpr(Expr): /, *, negated: bool = False, - ) -> Self: - """ - Create an ``IS`` predicate. - - Args: - expr: The tested expression. - kind: ``NULL``, ``TRUE``, ``FALSE``, or ``UNKNOWN``. - negated: If ``True``, emit ``IS NOT``. - - Returns: - A new ``IsExpr``. - - Raises: - ValueError: If ``kind`` is not one of the accepted spellings. - """ - - def get_expr(self) -> Expr: - """ - Return the tested expression. - - Returns: - The left-hand expression. - """ - - def get_kind(self) -> typing.Literal["NULL", "TRUE", "FALSE", "UNKNOWN"]: - """ - Return the ``IS`` kind. - - Returns: - ``NULL``, ``TRUE``, ``FALSE``, or ``UNKNOWN``. - """ - - def get_negated(self) -> bool: - """ - Return whether this is ``IS NOT``. - - Returns: - ``True`` if the predicate is negated. - """ + ) -> Self: ... + def get_expr(self) -> Expr: ... + def get_kind(self) -> typing.Literal["NULL", "TRUE", "FALSE", "UNKNOWN"]: ... + def get_negated(self) -> bool: ... @final class IsDistinctFromExpr(Expr): - """``a IS [NOT] DISTINCT FROM b``.""" - - def __new__(cls, left: object, right: object, /, *, negated: bool = False) -> Self: - """ - Create an ``IS DISTINCT FROM`` predicate. - - Args: - left: Left operand. - right: Right operand. - negated: If ``True``, emit ``IS NOT DISTINCT FROM``. - - Returns: - A new ``IsDistinctFromExpr``. - """ - - def get_left(self) -> Expr: - """ - Return the left operand. - - Returns: - The left-hand expression. - """ - - def get_right(self) -> Expr: - """ - Return the right operand. - - Returns: - The right-hand expression. - """ - - def get_negated(self) -> bool: - """ - Return whether this is ``IS NOT DISTINCT FROM``. - - Returns: - ``True`` if the predicate is negated. - """ + def __new__(cls, left: object, right: object, /, *, negated: bool = False) -> Self: ... + def get_left(self) -> Expr: ... + def get_right(self) -> Expr: ... + def get_negated(self) -> bool: ... @final class InListExpr(Expr): - """``expr [NOT] IN (…)``.""" - - def __new__( - cls, expr: object, list: Iterable[object], /, *, negated: bool = False - ) -> Self: - """ - Create an ``IN`` list predicate. - - Args: - expr: The tested expression. - list: Iterable of list elements. - negated: If ``True``, emit ``NOT IN``. - - Returns: - A new ``InListExpr``. - """ - - def get_expr(self) -> Expr: - """ - Return the tested expression. - - Returns: - The left-hand expression. - """ - - def get_list(self) -> tuple[Expr, ...]: - """ - Return the ``IN`` list. - - Returns: - The list elements as a tuple of expressions. - """ - - def get_negated(self) -> bool: - """ - Return whether this is ``NOT IN``. - - Returns: - ``True`` if the predicate is negated. - """ + def __new__(cls, expr: object, list: Iterable[object], /, *, negated: bool = False) -> Self: ... + def get_expr(self) -> Expr: ... + def get_list(self) -> tuple[Expr, ...]: ... + def get_negated(self) -> bool: ... @final class BetweenExpr(Expr): - """``expr [NOT] BETWEEN low AND high``.""" - - def __new__( - cls, - expr: object, - low: object, - high: object, - /, - *, - negated: bool = False, - ) -> Self: - """ - Create a ``BETWEEN`` predicate. - - Args: - expr: The tested expression. - low: Lower bound. - high: Upper bound. - negated: If ``True``, emit ``NOT BETWEEN``. - - Returns: - A new ``BetweenExpr``. - """ - - def get_expr(self) -> Expr: - """ - Return the tested expression. - - Returns: - The left-hand expression. - """ - - def get_low(self) -> Expr: - """ - Return the lower bound. - - Returns: - The low expression. - """ - - def get_high(self) -> Expr: - """ - Return the upper bound. - - Returns: - The high expression. - """ - - def get_negated(self) -> bool: - """ - Return whether this is ``NOT BETWEEN``. - - Returns: - ``True`` if the predicate is negated. - """ + def __new__(cls, expr: object, low: object, high: object, /, *, negated: bool = False) -> Self: ... + def get_expr(self) -> Expr: ... + def get_low(self) -> Expr: ... + def get_high(self) -> Expr: ... + def get_negated(self) -> bool: ... @final class LikeExpr(Expr): - """``expr [NOT] LIKE / ILIKE pattern`` or ``LIKE ANY`` from a sequence.""" - def __new__( cls, expr: object, @@ -652,434 +158,72 @@ class LikeExpr(Expr): negated: bool = False, case_sensitive: bool = True, escape: str | None = None, - ) -> Self: - """ - Create a ``LIKE``-family predicate. - - A ``list`` or ``tuple`` of patterns emits ``LIKE ANY`` (or an - equivalent ``OR`` expansion on dialects without native ANY). - A single expression (including a ``str``) is scalar ``LIKE``. - - Args: - expr: The tested expression. - pattern: One pattern, or a sequence of patterns. - negated: If ``True``, emit ``NOT`` / match-none. - case_sensitive: If ``False``, emit ``ILIKE`` instead of ``LIKE``. - escape: Optional single-character ``ESCAPE`` clause. - - Returns: - A new ``LikeExpr``. - - Raises: - ValueError: If ``pattern`` is an empty sequence. - """ - - def get_expr(self) -> Expr: - """ - Return the tested expression. - - Returns: - The left-hand expression. - """ - - def get_pattern(self) -> Expr | tuple[Expr, ...]: - """ - Return the pattern or the ``LIKE ANY`` patterns. - - Returns: - One expression, or a tuple when constructed from a sequence. - """ - - def get_negated(self) -> bool: - """ - Return whether this is a negated match. - - Returns: - ``True`` if the predicate is negated. - """ - + ) -> Self: ... + def get_expr(self) -> Expr: ... + def get_pattern(self) -> Expr | tuple[Expr, ...]: ... + def get_negated(self) -> bool: ... def get_case_sensitive(self) -> bool: ... - def get_escape(self) -> str | None: - """ - Return the ``ESCAPE`` character, if any. - - Returns: - A single character, or ``None``. - """ + def get_escape(self) -> str | None: ... @final class SimilarToExpr(Expr): - """``expr [NOT] SIMILAR TO pattern``.""" - - def __new__( - cls, - expr: object, - pattern: object, - /, - *, - negated: bool = False, - ) -> Self: - """ - Create a ``SIMILAR TO`` predicate. - - Args: - expr: The tested expression. - pattern: The pattern. - negated: If ``True``, emit ``NOT``. - - Returns: - A new ``SimilarToExpr``. - """ - - def get_expr(self) -> Expr: - """ - Return the tested expression. - - Returns: - The left-hand expression. - """ - - def get_pattern(self) -> Expr: - """ - Return the pattern expression. - - Returns: - Usually a ``Value`` when constructed from a string. - """ - - def get_negated(self) -> bool: - """ - Return whether this is a negated match. - - Returns: - ``True`` if the predicate is negated. - """ + def __new__(cls, expr: object, pattern: object, /, *, negated: bool = False) -> Self: ... + def get_expr(self) -> Expr: ... + def get_pattern(self) -> Expr: ... + def get_negated(self) -> bool: ... @final class RLikeExpr(Expr): - """Regex match (``RLIKE``, ``~``, ``REGEXP_LIKE``, …).""" - - def __new__( - cls, - expr: object, - pattern: object, - /, - *, - negated: bool = False, - ) -> Self: - """ - Create a regex-match predicate. - - Args: - expr: The tested expression. - pattern: The pattern. - negated: If ``True``, emit ``NOT`` / ``!~``. - - Returns: - A new ``RLikeExpr``. - """ - - def get_expr(self) -> Expr: - """ - Return the tested expression. - - Returns: - The left-hand expression. - """ - - def get_pattern(self) -> Expr: - """ - Return the pattern expression. - - Returns: - Usually a ``Value`` when constructed from a string. - """ - - def get_negated(self) -> bool: - """ - Return whether this is a negated match. - - Returns: - ``True`` if the predicate is negated. - """ + def __new__(cls, expr: object, pattern: object, /, *, negated: bool = False) -> Self: ... + def get_expr(self) -> Expr: ... + def get_pattern(self) -> Expr: ... + def get_negated(self) -> bool: ... @final class CastExpr(Expr): - """``CAST(expr AS type [FORMAT format [AT TIME ZONE tz]])``.""" - def __new__( cls, expr: object, - data_type_name: str | DataType, + data_type_name: str, /, *, - kind: typing.Literal["CAST", "TRY_CAST", "SAFE_CAST"] | str = "CAST", - format: object | None = None, - at_time_zone: object | None = None, - ) -> Self: - """ - Create a ``CAST`` expression. - - Args: - expr: The expression to cast. - data_type_name: SQL type name (``INT``, ``VARCHAR(255)``, - ``INT ARRAY``, …). Known names are canonicalized the - same way as ``DataType``. Array types are part of the - name (``INT ARRAY``), not a Cast flag. - kind: ``CAST``, ``TRY_CAST``, or ``SAFE_CAST``. - format: Optional BigQuery ``FORMAT`` expression. - at_time_zone: Optional ``AT TIME ZONE`` expression. Requires - ``format``. - - Returns: - A new ``CastExpr``. - - Raises: - ValueError: If ``kind`` is unknown, or ``at_time_zone`` is - set without ``format``. - """ - - def get_expr(self) -> Expr: - """ - Return the expression being cast. - - Returns: - The inner expression. - """ - - def get_data_type_name(self) -> str: - """ - Return the target SQL type name. - - Returns: - The canonical type spelling. - """ - - def get_kind(self) -> typing.Literal["CAST", "TRY_CAST", "SAFE_CAST"]: ... - def get_format(self) -> Expr | None: - """ - Return the ``FORMAT`` expression, if any. - - Returns: - The format expression, or ``None``. - """ - - def get_at_time_zone(self) -> Expr | None: - """ - Return the ``AT TIME ZONE`` expression, if any. - - Returns: - The time zone expression, or ``None``. - """ + kind: typing.Literal["CAST", "TRY_CAST", "::"] | str = "CAST", + ) -> Self: ... + def get_expr(self) -> Expr: ... + def get_data_type_name(self) -> str: ... + def get_kind(self) -> typing.Literal["CAST", "TRY_CAST", "::"]: ... @final class TrimExpr(Expr): - """``TRIM([BOTH|LEADING|TRAILING] [what FROM] expr[, chars…])``.""" - def __new__( cls, expr: object, /, *, - where: typing.Literal["BOTH", "LEADING", "TRAILING"] | str | None = None, - what: object | None = None, - characters: Sequence[object] | None = None, - ) -> Self: - """ - Create a ``TRIM`` expression. - - Args: - expr: The string to trim. - where: Optional ``BOTH``, ``LEADING``, or ``TRAILING``. - what: Characters to remove in ``TRIM(what FROM expr)`` form. - characters: Sequence of extra arguments for - ``TRIM(expr, c1, c2)``. A ``str`` is not a sequence - (use ``what``). - - Returns: - A new ``TrimExpr``. - - Raises: - ValueError: If ``where`` is unknown, ``characters`` is empty, - or both ``what`` and ``characters`` are set. - TypeError: If ``characters`` is not a list or tuple. - """ - - def get_expr(self) -> Expr: - """ - Return the trimmed expression. - - Returns: - The source expression. - """ - - def get_where(self) -> typing.Literal["BOTH", "LEADING", "TRAILING"] | None: - """ - Return the trim side, if any. - - Returns: - ``BOTH``, ``LEADING``, ``TRAILING``, or ``None``. - """ - - def get_what(self) -> Expr | None: - """ - Return the ``FROM`` characters expression, if any. - - Returns: - The ``what`` expression, or ``None``. - """ - - def get_characters(self) -> tuple[Expr, ...] | None: - """ - Return the comma-list characters, if any. - - Returns: - A tuple of expressions, or ``None``. - """ + where: typing.Literal["BOTH", "LEADING", "TRAILING"] | str = "BOTH", + characters: object | None = None, + ) -> Self: ... + def get_expr(self) -> Expr: ... + def get_where(self) -> typing.Literal["BOTH", "LEADING", "TRAILING"]: ... + def get_characters(self) -> Expr | None: ... @final class CollateExpr(Expr): - """``expr COLLATE collation``.""" - - def __new__(cls, expr: object, collation: str | Unquote | Ident, /) -> Self: - """ - Create a ``COLLATE`` expression. - - Args: - expr: The expression to collate. - collation: Collation name as an identifier (``str`` is - split on dots like ``Function`` names). - - Returns: - A new ``CollateExpr``. - """ - - def get_expr(self) -> Expr: - """ - Return the collated expression. - - Returns: - The inner expression. - """ - - def get_collation(self) -> Ident: - """ - Return the collation identifier. - - Returns: - The collation name. - """ + def __new__(cls, expr: object, collation: str | Unquote | Ident | Asterisk | type[Asterisk], /) -> Self: ... + def get_expr(self) -> Expr: ... + def get_collation(self) -> Ident: ... @final class CaseExpr(Expr): - """ - ``CASE [operand] WHEN … THEN … [ELSE …] END``. - - Build searched or simple ``CASE`` by chaining ``when`` and - ``else_``. Each call returns a new frozen instance. - - The ``when`` argument must already be a condition expression - (``BinaryExpr``, ``IsExpr``, …), not an ``Ident`` or ``Value``. - """ - - def __new__(cls, operand: object | None = None, /) -> Self: - """ - Create an empty ``CASE`` expression. - - Args: - operand: Optional simple-``CASE``. Omit for a searched ``CASE``. - - Returns: - A new ``CaseExpr`` with no ``WHEN`` clauses. - """ - - def when(self, when: Expr, then: object) -> Self: - """ - Append a ``WHEN … THEN …`` clause. - - Args: - when: A condition expression (not ``Ident`` or ``Value``). - then: Result expression. - - Returns: - A new ``CaseExpr`` with the clause appended. - - Raises: - TypeError: If ``when`` is not a condition expression. - """ - - def else_(self, result: object) -> Self: - """ - Set the ``ELSE`` result. - - Args: - result: The else expression. - - Returns: - A new ``CaseExpr`` with ``ELSE`` set. - """ - - def get_operand(self) -> Expr | None: - """ - Return the simple-``CASE`` operand, if any. - - Returns: - The operand expression, or ``None`` for a searched - ``CASE``. - """ - - def get_when_list(self) -> tuple[tuple[Expr, Expr], ...]: - """ - Return the ``WHEN`` / ``THEN`` pairs. - - Returns: - A tuple of ``(when, then)`` expression pairs. - """ - - def get_else(self) -> Expr | None: - """ - Return the ``ELSE`` expression, if any. - - Returns: - The else expression, or ``None``. - """ + def __new__(cls, operand: object | None = None, /) -> Self: ... + def when(self, when: Expr, then: object) -> Self: ... + def else_(self, result: object) -> Self: ... + def get_operand(self) -> Expr | None: ... + def get_when_list(self) -> tuple[tuple[Expr, Expr], ...]: ... + def get_else(self) -> Expr | None: ... @final class CustomExpr(Expr): - """ - Escape-hatch SQL template. - - ``{}`` slots are filled by rendering the corresponding argument - expression. ``{{`` / ``}}`` emit literal braces. Empty ``args`` - means a raw fragment. Argument count must match placeholder count - when SQL is written. - """ - - def __new__(cls, sql: str, *args: object) -> Self: - """ - Create a custom SQL fragment. - - Args: - sql: Template string. Each ``{}`` is replaced by one - argument. ``{{`` and ``}}`` emit ``{`` and ``}``. - *args: Expressions interpolated at ``{}`` slots. - - Returns: - A new ``CustomExpr``. - """ - - def get_sql(self) -> str: - """ - Return the template string. - - Returns: - The ``sql`` passed to the constructor. - """ - - def get_args(self) -> tuple[Expr, ...]: - """ - Return the interpolated arguments. - - Returns: - The argument expressions in slot order. - """ + def __new__(cls, sql: str, *args: object) -> Self: ... + def get_sql(self) -> str: ... + def get_args(self) -> tuple[Expr, ...]: ... diff --git a/rapidquery/serializers.pyi b/rapidquery/serializers.pyi index f93fd26..a2f7ff5 100644 --- a/rapidquery/serializers.pyi +++ b/rapidquery/serializers.pyi @@ -10,311 +10,74 @@ from rapidquery.dialect import Dialect _T = typing.TypeVar("_T") class Serializer(typing.Generic[_T]): - """ - Converts a Python object into a dialect-specific SQL literal string. - - Serializers produce a correctly escaped and formatted SQL value that can - be embedded in queries. Each concrete subclass handles a particular - category of values (integers, strings, dates, arrays, etc.). Use - `CustomSerializer` when the built-in ones are insufficient. - - This base class cannot be instantiated. Pass a dialect class or instance - to `serialize()`. Unit serializers compare equal by variant; - `ArraySerializer` by its element tree; `CustomSerializer` by function - identity. - """ - - def serialize(self, dialect: Dialect, object: _T) -> str: - """ - Renders `object` as a SQL literal for `dialect`. - - Args: - dialect: A dialect class (`PostgreSQL`) or instance (`PostgreSQL()`). - object: The Python value to convert. - - Returns: - A SQL fragment suitable for embedding in a statement. - - Raises: - TypeError: If `object` is not an accepted type for this serializer. - ValueError: If `object` is the right type but cannot be represented - (for example a non-finite float). - NotImplementedError: If `dialect` has no native literal for this - serializer (bit strings, `ARRAY[…]`, inet, macaddr). - """ - + def serialize(self, dialect: Dialect, object: _T) -> str: ... def __eq__(self, other: object) -> bool: ... def __hash__(self) -> int: ... @typing.final class IntegerSerializer(Serializer[int]): - """ - Serializes Python integers as bare SQL integer literals. - - Accepts Python `int` of any width (including values beyond 64-bit). - Rejects `bool`; `True` and `False` are not treated as integers. - - Raises: - TypeError: If the value is not an `int`. - """ - def __new__(cls) -> typing.Self: ... @typing.final class FloatSerializer(Serializer[int | float | decimal.Decimal]): - """ - Serializes numeric values as bare SQL numeric literals. - - Accepts `float`, `int`, and `decimal.Decimal`. Finite floats are formatted - so that `1.0` stays distinct from `1`. `str` is not accepted. - - Raises: - TypeError: If the value is not a `float`, `int`, or `decimal.Decimal`. - ValueError: If the value is a non-finite float (`nan`, `inf`, `-inf`). - """ - def __new__(cls) -> typing.Self: ... @typing.final class BooleanSerializer(Serializer[bool]): - """ - Serializes Python booleans as dialect boolean literals. - - Accepts Python `bool` only. The dialect chooses the spelling - (`TRUE`/`FALSE` or `1`/`0`). - - Raises: - TypeError: If the value is not a `bool`. - """ - def __new__(cls) -> typing.Self: ... @typing.final class StringSerializer(Serializer[str | enum.Enum]): - """ - Serializes text as a quoted, escaped SQL string literal. - - Accepts `str`, or an `enum.StrEnum`. The - dialect supplies quoting and escaping. - - Raises: - TypeError: If the value is not a `str` or a string-valued enum member. - """ - def __new__(cls) -> typing.Self: ... @typing.final class UuidSerializer(Serializer[uuid.UUID | str]): - """ - Serializes UUIDs using the dialect's ``uuid_format``. - - Accepts `uuid.UUID` or `str`. A quoted literal, ``UUID '…'``, or ``'…'::uuid``. - - Raises: - TypeError: If the value is not a `uuid.UUID` or `str`. - ValueError: If the value is `str`, but it is not a UUID. - """ - def __new__(cls) -> typing.Self: ... @typing.final class BytesSerializer(Serializer[bytes]): - """ - Serializes binary data as a dialect blob/bytea literal. - - Accepts exact `bytes` (not `bytearray` or `memoryview`). - - Raises: - TypeError: If the value is not `bytes`. - """ - def __new__(cls) -> typing.Self: ... @typing.final class BitSerializer(Serializer[str]): - """ - Serializes bit strings as dialect bit-string literals. - - Accepts a `str` consisting only of `'0'` and `'1'` characters. - Supported on PostgreSQL, DuckDB, MySQL, and Teradata. - - Raises: - TypeError: If the value is not a `str`. - ValueError: If the string is empty or contains characters other than `'0'` and `'1'`. - NotImplementedError: If the dialect has no native bit-string literal. - """ - def __new__(cls) -> typing.Self: ... @typing.final class JSONSerializer(Serializer[typing.Any]): - """ - Serializes a JSON-encodable object using the dialect's ``json_format``. - - A quoted literal, ``JSON '…'``, or ``PARSE_JSON('…')``. Accepts any object - that the JSON encoder can dump. Uses `orjson` when it - is installed, otherwise the stdlib `json` module. The JSON text is then - wrapped with the dialect's string quoting. With `orjson`, datetime, UUID, - enum.StrEnum, and dataclass are encoded natively; stdlib `json` - rejects those. - - Raises: - TypeError: If the value cannot be JSON-encoded. - """ - def __new__(cls) -> typing.Self: ... @typing.final class DateSerializer(Serializer[datetime.date]): - """ - Serializes dates as `YYYY-MM-DD` literals. - - ``date_format`` chooses a quoted literal or ``DATE`` plus that literal. - - Accepts exact `datetime.date` (not `datetime.datetime`). - - Raises: - TypeError: If the value is not a `datetime.date`. - """ - def __new__(cls) -> typing.Self: ... @typing.final class TimeSerializer(Serializer[datetime.time]): - """ - Serializes times as `HH:MM:SS[.ffffff]` literals. - - ``time_format`` chooses a quoted literal or ``TIME`` plus that literal. - - Accepts `datetime.time`. - - Raises: - TypeError: If the value is not a `datetime.time`. - """ - def __new__(cls) -> typing.Self: ... @typing.final -class DatetimeSerializer(Serializer[datetime.datetime]): - """ - Serializes timestamps as SQL timestamp literals. - - Accepts naive or aware `datetime.datetime`, or `int`/`float` UTC epoch - seconds. Naive values keep wall time. Aware values keep their original - offset (they are not converted to UTC). When ``timestamp_format`` sets a - keyword, naive values use ``TIMESTAMP`` and aware values (including epoch - numbers) use ``TIMESTAMPTZ`` or ``TIMESTAMP WITH TIMEZONE``. - - Raises: - TypeError: If the value is not a `datetime.datetime`, `int`, or `float`. - OverflowError: If an epoch value is out of range for a timestamp. - """ - +class DatetimeSerializer(Serializer[datetime.datetime | int | float]): def __new__(cls) -> typing.Self: ... @typing.final class InetSerializer(Serializer[typing.Any]): - """ - Serializes IP addresses and networks using the dialect's ``inet_format``. - - A quoted literal or ``'…'::inet``. - - Accepts `str` or `ipaddress` objects (`IPv4Address`, `IPv6Address`, - `IPv4Network`, `IPv6Network`, and related types whose `str()` is a valid - address or CIDR). Supported on PostgreSQL and DuckDB. - - Raises: - ValueError: If the value is not a valid IPv4/IPv6 address or network. - NotImplementedError: If the dialect has no native INET type. - """ - def __new__(cls) -> typing.Self: ... @typing.final class MacAddressSerializer(Serializer[str]): - """ - Serializes MAC addresses using the dialect's ``macaddr_format``. - - A quoted literal or ``'…'::macaddr``. - - Accepts a `str` using `:` or `-` as the separator. - Supported on PostgreSQL. - - Raises: - ValueError: If the value is not a valid MAC address. - NotImplementedError: If the dialect has no native MACADDR type. - """ - def __new__(cls) -> typing.Self: ... @typing.final class UnspecifiedSerializer(Serializer[typing.Any]): - """ - Inserts `str(obj)` into SQL with no quoting or type check. - - Used when the Python object matches no built-in serializer. The result - is not a safe SQL value; prefer an explicit serializer. - """ - def __new__(cls) -> typing.Self: ... @typing.final class ArraySerializer(Serializer[list[_T] | tuple[_T]]): - """ - Serializes sequences as `ARRAY[elem, ...]` literals. - - Each element is serialized independently with `element`. Accepts `list` - or `tuple`. Nested arrays are expressed by wrapping another - `ArraySerializer`. Supported on PostgreSQL, DuckDB, and BigQuery. - - Raises: - TypeError: If the value is not a `list` or `tuple`. - NotImplementedError: If the dialect has no `ARRAY[…]` constructor. - """ - - def __new__(cls, element: Serializer[_T]) -> typing.Self: - """ - Creates an array serializer. - - Args: - element: The serializer applied to every array item. Nested arrays - are expressed by passing another `ArraySerializer`. - """ - + def __new__(cls, element: Serializer[_T]) -> typing.Self: ... @property - def element(self) -> Serializer[_T]: - """ - Returns the serializer used for each array element. - - Returns: - The `Serializer` passed to the constructor. - """ + def element(self) -> Serializer[_T]: ... @typing.final class CustomSerializer(Serializer[_T]): - """ - Serializes values by calling a user-supplied Python function. - - `function` has the form `fn(dialect_name: str, obj) -> str`. The return - value is inserted into SQL as-is (not re-quoted or escaped). It is the - caller's responsibility to produce a safe fragment. - """ - - def __new__(cls, function: Callable[[Dialect, _T], str]) -> typing.Self: - """ - Creates a serializer backed by a Python callable. - - Args: - function: A callable of the form `fn(dialect_name: str, obj) -> str`. - The return value is inserted into SQL as-is (not re-quoted). - Must return a `str`. - """ - + def __new__(cls, function: Callable[[Dialect, _T], str]) -> typing.Self: ... @property - def function(self) -> Callable[[str, _T], str]: - """ - Returns the callable passed to the constructor. - - Returns: - The function given as `function` to `__new__`. - """ + def function(self) -> Callable[[Dialect, _T], str]: ... diff --git a/rust-toolchain.toml b/rust-toolchain.toml deleted file mode 100644 index 31578d3..0000000 --- a/rust-toolchain.toml +++ /dev/null @@ -1,2 +0,0 @@ -[toolchain] -channel = "stable" \ No newline at end of file diff --git a/src/data_type/tests.rs b/src/data_type/tests.rs index a488731..b7541f5 100644 --- a/src/data_type/tests.rs +++ b/src/data_type/tests.rs @@ -24,7 +24,6 @@ fn all_interned_names_canonicalize() { // --- Float (includes decimal/numeric) --- ("numeric", "NUMERIC"), ("decimal", "DECIMAL"), - ("dec", "DEC"), ("float", "FLOAT"), ("real", "REAL"), ("double", "DOUBLE"), @@ -79,10 +78,7 @@ fn interned_synonyms_compare_equal() { DataTypeName::from("timestamp"), DataTypeName::from("timestamp without timezone"), ); - assert_eq!( - DataTypeName::from("macaddress"), - DataTypeName::from("MACADDR"), - ); + assert_eq!(DataTypeName::from("macaddress"), DataTypeName::from("MACADDR"),); // Distinct interned spellings stay distinct. assert_ne!( DataTypeName::from("timestamp with timezone"), diff --git a/src/dialect/base.rs b/src/dialect/base.rs index 1f3f33e..6c3c7a0 100644 --- a/src/dialect/base.rs +++ b/src/dialect/base.rs @@ -12,9 +12,7 @@ pub struct PyDialect(pub flags::DialectFlags); impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyDialect { type Error = pyo3::PyErr; - fn extract( - obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, - ) -> Result { + fn extract(obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>) -> Result { unsafe { // SAFETY: `obj` is a borrowed `&Bound`, so `as_ptr()` is a // live `PyObject*`. @@ -39,7 +37,8 @@ impl PyDialect { } fn __hash__(&self) -> u64 { - use std::hash::{Hash, Hasher}; + use std::hash::Hash; + use std::hash::Hasher; let mut hasher = std::hash::DefaultHasher::default(); self.0.hash(&mut hasher); diff --git a/src/dialect/flags.rs b/src/dialect/flags.rs index 38cbaec..3a88b37 100644 --- a/src/dialect/flags.rs +++ b/src/dialect/flags.rs @@ -613,11 +613,7 @@ pub struct DialectFlags { impl DialectFlags { /// Write a boolean literal (`TRUE`/`FALSE` or `1`/`0`). #[inline] - pub fn format_boolean( - &self, - writer: &mut W, - value: bool, - ) -> Result<(), FormatError> { + pub fn format_boolean(&self, writer: &mut W, value: bool) -> Result<(), FormatError> { match (self.bool_format(), value) { (BoolFormat::Integer, false) => writer.write_str("0")?, (BoolFormat::Integer, true) => writer.write_str("1")?, @@ -629,11 +625,7 @@ impl DialectFlags { /// Write `string` with special characters neutralized, without wrapping it. #[inline] - pub fn escape_string( - &self, - writer: &mut W, - string: &str, - ) -> Result<(), FormatError> { + pub fn escape_string(&self, writer: &mut W, string: &str) -> Result<(), FormatError> { let result = match self.string_escape() { StringEscape::Quote => utils::replace_to(string, '\'', "''", writer), StringEscape::BackslashHex => write_backslash_hex(writer, string), @@ -647,11 +639,7 @@ impl DialectFlags { /// Escape `ident` and wrap it in this dialect's identifier delimiters. #[inline] - pub fn quote_identifier( - &self, - writer: &mut W, - ident: &str, - ) -> std::fmt::Result { + pub fn quote_identifier(&self, writer: &mut W, ident: &str) -> std::fmt::Result { let (start, end) = match self.identifier_format() { IdentifierFormat::DoubleQuote => ('"', '"'), IdentifierFormat::Backtick => ('`', '`'), @@ -689,11 +677,7 @@ impl DialectFlags { /// Escape `raw` and wrap it as a complete string literal. #[inline] - pub fn write_string_literal( - &self, - writer: &mut W, - raw: &str, - ) -> Result<(), FormatError> { + pub fn write_string_literal(&self, writer: &mut W, raw: &str) -> Result<(), FormatError> { if raw.contains('\0') { return Err(FormatError::NulInString); } @@ -713,11 +697,7 @@ impl DialectFlags { /// Native formats embed `bits` as-is. Callers must pass only `'0'` and `'1'`; /// that is checked with `debug_assert!`. #[inline] - pub fn format_bits( - &self, - writer: &mut W, - bits: &str, - ) -> Result<(), FormatError> { + pub fn format_bits(&self, writer: &mut W, bits: &str) -> Result<(), FormatError> { debug_assert!( bits.chars().all(|c| c == '0' || c == '1'), "format_bits: invalid input: {bits}" @@ -732,11 +712,7 @@ impl DialectFlags { /// Write `bytes` as a native blob/bytea/varbyte literal. #[inline] - pub fn format_bytes( - &self, - writer: &mut W, - bytes: &[u8], - ) -> Result<(), FormatError> { + pub fn format_bytes(&self, writer: &mut W, bytes: &[u8]) -> Result<(), FormatError> { let result = match self.bytes_format() { BytesFormat::PrefixBigX => { writer.write_str("X'")?; @@ -801,10 +777,7 @@ const HEX_DIGITS: &[u8; 16] = b"0123456789ABCDEF"; /// Write `byte` as two uppercase hex digits. #[inline] fn write_hex2(writer: &mut W, byte: u8) -> std::fmt::Result { - let buf = [ - HEX_DIGITS[(byte >> 4) as usize], - HEX_DIGITS[(byte & 0x0F) as usize], - ]; + let buf = [HEX_DIGITS[(byte >> 4) as usize], HEX_DIGITS[(byte & 0x0F) as usize]]; // SAFETY: HEX_DIGITS is ASCII, so `buf` is valid UTF-8. writer.write_str(unsafe { std::str::from_utf8_unchecked(&buf) }) } @@ -868,10 +841,7 @@ fn write_unicode4_escape(writer: &mut W, code: u32) -> std:: /// Use the string escape table, plus `\``. /// /// `'` is left as itself. Only known escapes are emitted. -fn write_identifier_backtick_escape( - writer: &mut W, - ident: &str, -) -> std::fmt::Result { +fn write_identifier_backtick_escape(writer: &mut W, ident: &str) -> std::fmt::Result { write_span_escapes(writer, ident, |b| match b { b'\\' => SpanEscape::Literal("\\\\"), b'`' => SpanEscape::Literal("\\`"), @@ -889,12 +859,7 @@ fn write_identifier_backtick_escape( /// `bytes` is the raw view of a `&str`. Callers split only on ASCII bytes, /// which are char boundaries, or at `bytes.len()`. #[inline] -fn write_byte_span( - writer: &mut W, - bytes: &[u8], - from: usize, - to: usize, -) -> std::fmt::Result { +fn write_byte_span(writer: &mut W, bytes: &[u8], from: usize, to: usize) -> std::fmt::Result { if from == to { return Ok(()); } diff --git a/src/expr/base.rs b/src/expr/base.rs index 66cfaab..2141a0b 100644 --- a/src/expr/base.rs +++ b/src/expr/base.rs @@ -27,31 +27,21 @@ fn wrap<'py, T>(py: pyo3::Python<'py>, base: PyExpr) -> pyo3::PyResult + Default, { - pyo3::Bound::new( - py, - pyo3::PyClassInitializer::from(base).add_subclass(T::default()), - ) - .map(|x| x.into_any()) + pyo3::Bound::new(py, pyo3::PyClassInitializer::from(base).add_subclass(T::default())).map(|x| x.into_any()) } /// Wrap `expr` as the matching Python subclass. /// /// Shorthand for `PyExpr(expr).into_subclasses(py)` #[inline] -pub(super) fn into_subclasses<'py>( - py: pyo3::Python<'py>, - expr: ExprInner, -) -> pyo3::PyResult> { +pub(super) fn into_subclasses<'py>(py: pyo3::Python<'py>, expr: ExprInner) -> pyo3::PyResult> { PyExpr(expr).into_subclasses(py) } impl PyExpr { /// Wrap `self` as the matching Python subclass. #[inline] - pub fn into_subclasses<'py>( - self, - py: pyo3::Python<'py>, - ) -> pyo3::PyResult> { + pub fn into_subclasses<'py>(self, py: pyo3::Python<'py>) -> pyo3::PyResult> { match &self.0 { ExprInner::Ident(_) => wrap::(py, self), ExprInner::Value(_) => wrap::(py, self), diff --git a/src/expr/enum_/func.rs b/src/expr/enum_/func.rs index 6f17542..771c16a 100644 --- a/src/expr/enum_/func.rs +++ b/src/expr/enum_/func.rs @@ -74,11 +74,7 @@ impl Function { } impl WriteSql for Function { - fn write_sql( - &self, - dialect: &crate::dialect::PyDialect, - out: &mut W, - ) -> PrepareResult<()> { + fn write_sql(&self, dialect: &crate::dialect::PyDialect, out: &mut W) -> PrepareResult<()> { // "name"([DISTINCT] args [IGNORE|RESPECT NULLS] [WHERE expr]) // [FILTER ([WHERE] expr)] [IGNORE|RESPECT NULLS] self.name.write_sql(dialect, out)?; @@ -93,23 +89,15 @@ impl WriteSql for Function { let null_treatment_syntax = dialect.0.null_treatment(); let function_filter_syntax = dialect.0.function_filter(); - if self.null_treatment.is_some() - && matches!(null_treatment_syntax, flags::NullTreatment::Unsupported) - { - return Err(rapidquery_macros::pyerr!( - PyRuntimeError, - "null_treatment is not supported by this dialect", - ) - .into()); + if self.null_treatment.is_some() && matches!(null_treatment_syntax, flags::NullTreatment::Unsupported) { + return Err( + rapidquery_macros::pyerr!(PyRuntimeError, "null_treatment is not supported by this dialect",).into(), + ); } - if self.filter.is_some() - && matches!(function_filter_syntax, flags::FunctionFilter::Unsupported) - { - return Err(rapidquery_macros::pyerr!( - PyRuntimeError, - "function filter is not supported by this dialect", - ) - .into()); + if self.filter.is_some() && matches!(function_filter_syntax, flags::FunctionFilter::Unsupported) { + return Err( + rapidquery_macros::pyerr!(PyRuntimeError, "function filter is not supported by this dialect",).into(), + ); } // Inside parens IGNORE/RESPECT NULLS diff --git a/src/expr/enum_/like.rs b/src/expr/enum_/like.rs index bea02ae..16f826a 100644 --- a/src/expr/enum_/like.rs +++ b/src/expr/enum_/like.rs @@ -9,20 +9,14 @@ use crate::sql::SqlWriter; use crate::sql::WriteSql; use crate::sql::write_separated; -pub(super) fn write_escape( - dialect: &PyDialect, - out: &mut W, - escape: Option, -) -> PrepareResult<()> { +pub(super) fn write_escape(dialect: &PyDialect, out: &mut W, escape: Option) -> PrepareResult<()> { let Some(c) = escape else { return Ok(()); }; out.push_str(" ESCAPE "); let mut buf = [0u8; 4]; - dialect - .0 - .write_string_literal(out, c.encode_utf8(&mut buf))?; + dialect.0.write_string_literal(out, c.encode_utf8(&mut buf))?; Ok(()) } @@ -220,8 +214,7 @@ impl WriteSql for Like { ensure_like_escape_is_supported( dialect.0.like_escape(), self.escape, - matches!(self.pattern, LikePattern::Any(_)) - && !matches!(dialect.0.like_any(), flags::LikeAny::OrExpansion), + matches!(self.pattern, LikePattern::Any(_)) && !matches!(dialect.0.like_any(), flags::LikeAny::OrExpansion), )?; match &self.pattern { @@ -290,23 +283,19 @@ impl SimilarTo { impl WriteSql for SimilarTo { fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { if !dialect.0.supports_similar_to() { - return Err(rapidquery_macros::pyerr!( - PyRuntimeError, - "SIMILAR TO is not supported by this dialect", - ) - .into()); + return Err( + rapidquery_macros::pyerr!(PyRuntimeError, "SIMILAR TO is not supported by this dialect",).into(), + ); } - self.expr - .write_operand(dialect, out, Self::PRECEDENCE, false)?; + self.expr.write_operand(dialect, out, Self::PRECEDENCE, false)?; out.push_char(' '); if self.negated { out.push_str("NOT SIMILAR TO "); } else { out.push_str("SIMILAR TO "); } - self.pattern - .write_operand(dialect, out, Self::PRECEDENCE, true) + self.pattern.write_operand(dialect, out, Self::PRECEDENCE, true) } } @@ -370,54 +359,31 @@ impl WriteSql for RLike { fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { match dialect.0.rlike_expression() { flags::RLikeExpression::Unsupported => { - return Err(rapidquery_macros::pyerr!( - PyRuntimeError, - "RLIKE is not supported by this dialect", - ) - .into()); + Err(rapidquery_macros::pyerr!(PyRuntimeError, "RLIKE is not supported by this dialect",).into()) } flags::RLikeExpression::Standard => { - self.expr - .write_operand(dialect, out, Self::PRECEDENCE, false)?; + self.expr.write_operand(dialect, out, Self::PRECEDENCE, false)?; if self.negated { out.push_str(" NOT RLIKE "); } else { out.push_str(" RLIKE "); } - self.pattern - .write_operand(dialect, out, Self::PRECEDENCE, true) + self.pattern.write_operand(dialect, out, Self::PRECEDENCE, true) } flags::RLikeExpression::Tilde => { - self.expr - .write_operand(dialect, out, Self::PRECEDENCE, false)?; + self.expr.write_operand(dialect, out, Self::PRECEDENCE, false)?; out.push_str(if self.negated { " !~ " } else { " ~ " }); - self.pattern - .write_operand(dialect, out, Self::PRECEDENCE, true) + self.pattern.write_operand(dialect, out, Self::PRECEDENCE, true) + } + flags::RLikeExpression::RegexpLike => { + write_regex_call(dialect, out, "REGEXP_LIKE", self.negated, &self.expr, &self.pattern) + } + flags::RLikeExpression::RegexpContains => { + write_regex_call(dialect, out, "REGEXP_CONTAINS", self.negated, &self.expr, &self.pattern) + } + flags::RLikeExpression::RegexpMatches => { + write_regex_call(dialect, out, "regexp_matches", self.negated, &self.expr, &self.pattern) } - flags::RLikeExpression::RegexpLike => write_regex_call( - dialect, - out, - "REGEXP_LIKE", - self.negated, - &self.expr, - &self.pattern, - ), - flags::RLikeExpression::RegexpContains => write_regex_call( - dialect, - out, - "REGEXP_CONTAINS", - self.negated, - &self.expr, - &self.pattern, - ), - flags::RLikeExpression::RegexpMatches => write_regex_call( - dialect, - out, - "regexp_matches", - self.negated, - &self.expr, - &self.pattern, - ), } } } diff --git a/src/expr/enum_/nodes.rs b/src/expr/enum_/nodes.rs index 35217c8..a3fccf9 100644 --- a/src/expr/enum_/nodes.rs +++ b/src/expr/enum_/nodes.rs @@ -43,9 +43,7 @@ impl WriteSql for Tuple { let arity = self.items.len(); match dialect.0.tuple() { flags::Tuple::Unsupported => { - return Err(unsupported( - "tuple values are not supported by this dialect", - )); + return Err(unsupported("tuple values are not supported by this dialect")); } flags::Tuple::Row => { out.push_str("ROW("); @@ -64,9 +62,7 @@ impl WriteSql for Tuple { } flags::Tuple::Parentheses => { if arity < 2 { - return Err(unsupported( - "a tuple needs at least two elements on this dialect", - )); + return Err(unsupported("a tuple needs at least two elements on this dialect")); } out.push_char('('); } @@ -104,12 +100,9 @@ impl WriteSql for Unary { match dialect.0.bitwise() { flags::Bitwise::Infix | flags::Bitwise::InfixNoShift => { out.push_char('~'); - return self.expr.write_operand( - dialect, - out, - UnaryOp::BitNot.precedence(), - true, - ); + return self + .expr + .write_operand(dialect, out, UnaryOp::BitNot.precedence(), true); } flags::Bitwise::BitShiftFunctions | flags::Bitwise::ShiftFunctions => { out.push_str("BITNOT("); @@ -117,9 +110,7 @@ impl WriteSql for Unary { out.push_char(')'); return Ok(()); } - flags::Bitwise::BitAndOnly - | flags::Bitwise::Unsupported - | flags::Bitwise::InfixAndShift => { + flags::Bitwise::BitAndOnly | flags::Bitwise::Unsupported | flags::Bitwise::InfixAndShift => { return Err(unsupported("bitwise NOT is not supported by this dialect")); } } @@ -238,82 +229,56 @@ impl WriteSql for Binary { flags::StringConcat::Plus => write_infix(self, dialect, out, "+", true), }, BinaryOp::BitXor => match dialect.0.bitxor() { - flags::BitXor::Unsupported => { - Err(unsupported("bitwise XOR is not supported by this dialect")) - } + flags::BitXor::Unsupported => Err(unsupported("bitwise XOR is not supported by this dialect")), flags::BitXor::Caret => write_infix(self, dialect, out, "^", false), flags::BitXor::Hash => write_infix(self, dialect, out, "#", false), - flags::BitXor::XORFunction => { - write_binary_call(dialect, out, "xor", &self.left, &self.right) - } - flags::BitXor::BitXORFunction => { - write_binary_call(dialect, out, "BITXOR", &self.left, &self.right) - } + flags::BitXor::XORFunction => write_binary_call(dialect, out, "xor", &self.left, &self.right), + flags::BitXor::BitXORFunction => write_binary_call(dialect, out, "BITXOR", &self.left, &self.right), }, BinaryOp::Mod => match dialect.0.modulo() { flags::Modulo::Percent => write_infix(self, dialect, out, "%", false), - flags::Modulo::Function => { - write_binary_call(dialect, out, "MOD", &self.left, &self.right) - } + flags::Modulo::Function => write_binary_call(dialect, out, "MOD", &self.left, &self.right), flags::Modulo::Infix => write_infix(self, dialect, out, "MOD", false), }, BinaryOp::BitAnd => match dialect.0.bitwise() { - flags::Bitwise::Infix - | flags::Bitwise::InfixNoShift - | flags::Bitwise::InfixAndShift => write_infix(self, dialect, out, "&", false), - flags::Bitwise::BitShiftFunctions - | flags::Bitwise::ShiftFunctions - | flags::Bitwise::BitAndOnly => { - write_binary_call(dialect, out, "BITAND", &self.left, &self.right) + flags::Bitwise::Infix | flags::Bitwise::InfixNoShift | flags::Bitwise::InfixAndShift => { + write_infix(self, dialect, out, "&", false) } - flags::Bitwise::Unsupported => { - Err(unsupported("bitwise AND is not supported by this dialect")) + flags::Bitwise::BitShiftFunctions | flags::Bitwise::ShiftFunctions | flags::Bitwise::BitAndOnly => { + write_binary_call(dialect, out, "BITAND", &self.left, &self.right) } + flags::Bitwise::Unsupported => Err(unsupported("bitwise AND is not supported by this dialect")), }, BinaryOp::BitOr => match dialect.0.bitwise() { - flags::Bitwise::Infix | flags::Bitwise::InfixNoShift => { - write_infix(self, dialect, out, "|", false) - } + flags::Bitwise::Infix | flags::Bitwise::InfixNoShift => write_infix(self, dialect, out, "|", false), flags::Bitwise::BitShiftFunctions | flags::Bitwise::ShiftFunctions => { write_binary_call(dialect, out, "BITOR", &self.left, &self.right) } - flags::Bitwise::BitAndOnly - | flags::Bitwise::Unsupported - | flags::Bitwise::InfixAndShift => { + flags::Bitwise::BitAndOnly | flags::Bitwise::Unsupported | flags::Bitwise::InfixAndShift => { Err(unsupported("bitwise OR is not supported by this dialect")) } }, BinaryOp::LShift => match dialect.0.bitwise() { - flags::Bitwise::Infix | flags::Bitwise::InfixAndShift => { - write_infix(self, dialect, out, "<<", false) - } + flags::Bitwise::Infix | flags::Bitwise::InfixAndShift => write_infix(self, dialect, out, "<<", false), flags::Bitwise::BitShiftFunctions => { write_binary_call(dialect, out, "BITSHIFTLEFT", &self.left, &self.right) } - flags::Bitwise::ShiftFunctions => { - write_binary_call(dialect, out, "SHIFTLEFT", &self.left, &self.right) + flags::Bitwise::ShiftFunctions => write_binary_call(dialect, out, "SHIFTLEFT", &self.left, &self.right), + flags::Bitwise::InfixNoShift | flags::Bitwise::BitAndOnly | flags::Bitwise::Unsupported => { + Err(unsupported("bitwise shift is not supported by this dialect")) } - flags::Bitwise::InfixNoShift - | flags::Bitwise::BitAndOnly - | flags::Bitwise::Unsupported => Err(unsupported( - "bitwise shift is not supported by this dialect", - )), }, BinaryOp::RShift => match dialect.0.bitwise() { - flags::Bitwise::Infix | flags::Bitwise::InfixAndShift => { - write_infix(self, dialect, out, ">>", false) - } + flags::Bitwise::Infix | flags::Bitwise::InfixAndShift => write_infix(self, dialect, out, ">>", false), flags::Bitwise::BitShiftFunctions => { write_binary_call(dialect, out, "BITSHIFTRIGHT", &self.left, &self.right) } flags::Bitwise::ShiftFunctions => { write_binary_call(dialect, out, "SHIFTRIGHT", &self.left, &self.right) } - flags::Bitwise::InfixNoShift - | flags::Bitwise::BitAndOnly - | flags::Bitwise::Unsupported => Err(unsupported( - "bitwise shift is not supported by this dialect", - )), + flags::Bitwise::InfixNoShift | flags::Bitwise::BitAndOnly | flags::Bitwise::Unsupported => { + Err(unsupported("bitwise shift is not supported by this dialect")) + } }, _ => write_infix(self, dialect, out, self.op.as_sql(), false), } @@ -343,8 +308,7 @@ impl Is { impl WriteSql for Is { fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { - self.expr - .write_operand(dialect, out, Self::PRECEDENCE, false)?; + self.expr.write_operand(dialect, out, Self::PRECEDENCE, false)?; if self.negated { out.push_str(" IS NOT "); } else { @@ -379,30 +343,24 @@ impl IsDistinctFrom { impl WriteSql for IsDistinctFrom { fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { match dialect.0.is_distinct_from() { - flags::IsDistinctFrom::Unsupported => Err(unsupported( - "IS DISTINCT FROM is not supported by this dialect", - )), + flags::IsDistinctFrom::Unsupported => Err(unsupported("IS DISTINCT FROM is not supported by this dialect")), flags::IsDistinctFrom::Standard => { - self.left - .write_operand(dialect, out, Self::PRECEDENCE, false)?; + self.left.write_operand(dialect, out, Self::PRECEDENCE, false)?; if self.negated { out.push_str(" IS NOT DISTINCT FROM "); } else { out.push_str(" IS DISTINCT FROM "); } - self.right - .write_operand(dialect, out, Self::PRECEDENCE, true) + self.right.write_operand(dialect, out, Self::PRECEDENCE, true) } flags::IsDistinctFrom::NullSafeEqual => { // `<=>` is null-safe equal, i.e. `IS NOT DISTINCT FROM`. if !self.negated { out.push_str("NOT ("); } - self.left - .write_operand(dialect, out, Self::PRECEDENCE, false)?; + self.left.write_operand(dialect, out, Self::PRECEDENCE, false)?; out.push_str(" <=> "); - self.right - .write_operand(dialect, out, Self::PRECEDENCE, true)?; + self.right.write_operand(dialect, out, Self::PRECEDENCE, true)?; if !self.negated { out.push_char(')'); } @@ -452,8 +410,7 @@ impl WriteSql for InList { if dialect.0.parenthesize_in_between() { out.push_char('('); } - self.expr - .write_operand(dialect, out, Self::PRECEDENCE, false)?; + self.expr.write_operand(dialect, out, Self::PRECEDENCE, false)?; if self.negated { out.push_str(" NOT IN ("); } else { @@ -498,18 +455,15 @@ impl WriteSql for Between { if dialect.0.parenthesize_in_between() { out.push_char('('); } - self.expr - .write_operand(dialect, out, Self::PRECEDENCE, false)?; + self.expr.write_operand(dialect, out, Self::PRECEDENCE, false)?; if self.negated { out.push_str(" NOT BETWEEN "); } else { out.push_str(" BETWEEN "); } - self.low - .write_operand(dialect, out, Self::PRECEDENCE, true)?; + self.low.write_operand(dialect, out, Self::PRECEDENCE, true)?; out.push_str(" AND "); - self.high - .write_operand(dialect, out, Self::PRECEDENCE, true)?; + self.high.write_operand(dialect, out, Self::PRECEDENCE, true)?; if dialect.0.parenthesize_in_between() { out.push_char(')'); } @@ -542,12 +496,7 @@ impl Cast { Ok(fmt) } - fn write_call( - &self, - dialect: &PyDialect, - out: &mut W, - name: &str, - ) -> PrepareResult<()> { + fn write_call(&self, dialect: &PyDialect, out: &mut W, name: &str) -> PrepareResult<()> { out.push_str(name); out.push_char('('); self.expr.write_sql(dialect, out)?; @@ -563,18 +512,14 @@ impl WriteSql for Cast { match self.kind { CastKind::Cast => self.write_call(dialect, out, "CAST"), CastKind::TryCast => match dialect.0.try_cast() { - flags::TryCast::Unsupported => { - Err(unsupported("try cast is not supported by this dialect")) - } + flags::TryCast::Unsupported => Err(unsupported("try cast is not supported by this dialect")), flags::TryCast::TryCast => self.write_call(dialect, out, "TRY_CAST"), flags::TryCast::TryCastWord => self.write_call(dialect, out, "TRYCAST"), flags::TryCast::SafeCast => self.write_call(dialect, out, "SAFE_CAST"), }, CastKind::DoubleColon => { if !dialect.0.supports_double_colon() { - return Err(unsupported( - "double-colon cast is not supported by this dialect", - )); + return Err(unsupported("double-colon cast is not supported by this dialect")); } out.push_char('('); self.expr.write_sql(dialect, out)?; @@ -619,11 +564,7 @@ impl Trim { } #[inline] -fn write_trim_function( - trim: &Trim, - dialect: &PyDialect, - out: &mut W, -) -> PrepareResult<()> { +fn write_trim_function(trim: &Trim, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { let name = match trim.where_ { TrimWhere::Both => "TRIM", TrimWhere::Leading => "LTRIM", @@ -671,9 +612,7 @@ impl WriteSql for Trim { flags::Trim::Function => write_trim_function(self, dialect, out), flags::Trim::SpacesOnly => { if self.characters.is_some() { - return Err(unsupported( - "trim characters are not supported by this dialect", - )); + return Err(unsupported("trim characters are not supported by this dialect")); } write_trim_function(self, dialect, out) } @@ -705,20 +644,15 @@ impl Collate { impl WriteSql for Collate { fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { match dialect.0.collate() { - flags::Collate::Unsupported => { - Err(unsupported("COLLATE is not supported by this dialect")) - } + flags::Collate::Unsupported => Err(unsupported("COLLATE is not supported by this dialect")), flags::Collate::InfixIdent => { - self.expr - .write_operand(dialect, out, Self::PRECEDENCE, false)?; + self.expr.write_operand(dialect, out, Self::PRECEDENCE, false)?; out.push_str(" COLLATE "); self.collation.write_sql(dialect, out) } flags::Collate::FunctionString => { let body = match self.collation.parts() { - [super::IdentPart::Quote(body) | super::IdentPart::Unquote(body)] - if !body.is_empty() => - { + [super::IdentPart::Quote(body) | super::IdentPart::Unquote(body)] if !body.is_empty() => { body.as_str() } _ => { diff --git a/src/expr/enum_/operator.rs b/src/expr/enum_/operator.rs index 671e997..17fee95 100644 --- a/src/expr/enum_/operator.rs +++ b/src/expr/enum_/operator.rs @@ -149,19 +149,8 @@ impl BinaryOp { match self { Self::Mul | Self::Div | Self::Mod => 2, Self::Add | Self::Sub => 3, - Self::BitAnd - | Self::BitOr - | Self::BitXor - | Self::LShift - | Self::RShift - | Self::StringConcat => 4, - Self::Eq - | Self::NotEq - | Self::Lt - | Self::LtEq - | Self::Gt - | Self::GtEq - | Self::Custom(_) => 5, + Self::BitAnd | Self::BitOr | Self::BitXor | Self::LShift | Self::RShift | Self::StringConcat => 4, + Self::Eq | Self::NotEq | Self::Lt | Self::LtEq | Self::Gt | Self::GtEq | Self::Custom(_) => 5, Self::And => 7, Self::Or => 9, } diff --git a/src/expr/extractors.rs b/src/expr/extractors.rs index 6215a4b..c7905bd 100644 --- a/src/expr/extractors.rs +++ b/src/expr/extractors.rs @@ -62,8 +62,7 @@ fn extract_expr( // a bare `str` becomes [`ExprInner::Ident`] if `str_as_ident` if str_as_ident { - let inner = Ident::from_str(s) - .map_err(|err| rapidquery_macros::pyerr!(PyValueError, "{}", err))?; + let inner = Ident::from_str(s).map_err(|err| rapidquery_macros::pyerr!(PyValueError, "{}", err))?; return Ok(ExprInner::Ident(inner)); } @@ -86,18 +85,14 @@ fn extract_expr( } } - Ok(ExprInner::Value(super::enum_::Value::infer_bound( - obj.as_any().clone(), - ))) + Ok(ExprInner::Value(super::enum_::Value::infer_bound(obj.as_any().clone()))) } /// Scalar `LIKE` pattern, or a list/tuple of patterns (`LIKE ANY`). /// /// A Python `str` is never a sequence. An already-built `TupleExpr` is a /// single pattern; a raw `tuple`/`list` is ANY. -pub fn extract_like_pattern( - obj: pyo3::prelude::Borrowed<'_, '_, pyo3::prelude::PyAny>, -) -> pyo3::PyResult { +pub fn extract_like_pattern(obj: pyo3::prelude::Borrowed<'_, '_, pyo3::prelude::PyAny>) -> pyo3::PyResult { let ptr = obj.as_ptr(); unsafe { if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { @@ -115,9 +110,7 @@ pub fn extract_like_pattern( Ok(LikePattern::One(Box::new(extract_expr(obj, false)?))) } -fn extract_like_any<'py>( - iter: impl IntoIterator>, -) -> pyo3::PyResult { +fn extract_like_any<'py>(iter: impl IntoIterator>) -> pyo3::PyResult { let mut items = Vec::new(); for item in iter { items.push(item.extract::()?.0); @@ -144,8 +137,7 @@ pub(super) fn extract_ident_argument( // interned in `initialize_typeref` before any extractor runs. if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { let s = utils::borrow_py_str_buffer(obj.py(), ptr)?; - let part = IdentPart::quoted(s) - .map_err(|e| rapidquery_macros::pyerr!(PyValueError, "{}", e))?; + let part = IdentPart::quoted(s).map_err(|e| rapidquery_macros::pyerr!(PyValueError, "{}", e))?; return Ok(ExtractedIdentArgument::Part(part)); } @@ -185,9 +177,7 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for LiteralExprExtractor { type Error = pyo3::PyErr; #[inline] - fn extract( - obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, - ) -> Result { + fn extract(obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>) -> Result { extract_expr(obj, false).map(Self) } } @@ -196,15 +186,12 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for IdentExtractor { type Error = pyo3::PyErr; #[inline] - fn extract( - obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, - ) -> Result { + fn extract(obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>) -> Result { unsafe { // SAFETY: `ptr` is a live borrowed `PyObject`. if pyo3::ffi::PyUnicode_CheckExact(obj.as_ptr()) == 1 { let s = utils::borrow_py_str_buffer(obj.py(), obj.as_ptr())?; - let inner = Ident::from_str(s) - .map_err(|e| rapidquery_macros::pyerr!(PyValueError, "{}", e))?; + let inner = Ident::from_str(s).map_err(|e| rapidquery_macros::pyerr!(PyValueError, "{}", e))?; return Ok(Self(inner)); } @@ -221,9 +208,7 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for ConditionExtractor { type Error = pyo3::PyErr; #[inline] - fn extract( - obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, - ) -> Result { + fn extract(obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>) -> Result { let ptr = obj.as_ptr(); unsafe { if crate::typeref::check(ptr, crate::typeref::EXPR_TYPE) { @@ -236,10 +221,8 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for ConditionExtractor { } } - rapidquery_macros::pyerr!( - Err(PyTypeError), - "expected a condition expression, got {}", - unsafe { crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) } - ) + rapidquery_macros::pyerr!(Err(PyTypeError), "expected a condition expression, got {}", unsafe { + crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) + }) } } diff --git a/src/expr/func.rs b/src/expr/func.rs index 42d240f..7417a94 100644 --- a/src/expr/func.rs +++ b/src/expr/func.rs @@ -38,10 +38,7 @@ impl PyFunction { } } - fn wrap( - py: pyo3::Python<'_>, - inner: super::enum_::Function, - ) -> pyo3::PyResult { + fn wrap(py: pyo3::Python<'_>, inner: super::enum_::Function) -> pyo3::PyResult { pyo3::Py::new( py, pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self), @@ -74,10 +71,7 @@ impl PyFunction { Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) } - fn filter( - slf: pyo3::PyRef<'_, Self>, - predicate: ConditionExtractor, - ) -> pyo3::PyResult { + fn filter(slf: pyo3::PyRef<'_, Self>, predicate: ConditionExtractor) -> pyo3::PyResult { let mut inner = Self::inner(&slf).clone(); inner.filter = Some(Box::new(predicate.0)); Self::wrap(slf.py(), inner) diff --git a/src/expr/ident.rs b/src/expr/ident.rs index da46c0e..125f917 100644 --- a/src/expr/ident.rs +++ b/src/expr/ident.rs @@ -17,9 +17,7 @@ fn ident_part_to_pyobject<'py>( ) -> pyo3::PyResult> { match ident_part { IdentPart::Quote(s) => Ok(pyo3::types::PyString::new(py, s.as_str()).into_any()), - IdentPart::Unquote(s) => { - Ok(pyo3::Bound::new(py, PyUnquote(arcstr::ArcStr::from(s.as_str())))?.into_any()) - } + IdentPart::Unquote(s) => Ok(pyo3::Bound::new(py, PyUnquote(arcstr::ArcStr::from(s.as_str())))?.into_any()), IdentPart::Asterisk => Ok(pyo3::Bound::new(py, PyAsterisk)?.into_any()), } } @@ -84,16 +82,13 @@ impl PyIdent { } } - Ident::from_parts(acc) - .map_err(|e| rapidquery_macros::pyerr!(PyValueError, "{}", e))? + Ident::from_parts(acc).map_err(|e| rapidquery_macros::pyerr!(PyValueError, "{}", e))? } }; Ok( - pyo3::PyClassInitializer::from(super::base::PyExpr(super::enum_::ExprInner::Ident( - ident, - ))) - .add_subclass(PyIdent), + pyo3::PyClassInitializer::from(super::base::PyExpr(super::enum_::ExprInner::Ident(ident))) + .add_subclass(PyIdent), ) } @@ -101,10 +96,7 @@ impl PyIdent { Self::inner(&slf).len() } - fn __getitem__<'py>( - slf: pyo3::PyRef<'py, Self>, - index: isize, - ) -> pyo3::PyResult> { + fn __getitem__<'py>(slf: pyo3::PyRef<'py, Self>, index: isize) -> pyo3::PyResult> { let inner = Self::inner(&slf); let x = if index < 0 { @@ -120,10 +112,7 @@ impl PyIdent { } /// Returns the parts as a list (`str`, `Unquote`, or `Asterisk`). - fn to_list( - slf: pyo3::PyRef<'_, Self>, - py: pyo3::Python<'_>, - ) -> pyo3::PyResult> { + fn to_list(slf: pyo3::PyRef<'_, Self>, py: pyo3::Python<'_>) -> pyo3::PyResult> { Self::inner(&slf) .parts() .iter() diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index 4535894..11f3cba 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -101,10 +101,7 @@ impl PyTupleExpr { exprs.push(item.extract::()?.0); } - Ok( - pyo3::PyClassInitializer::from(PyExpr(super::enum_::Tuple { items: exprs }.into())) - .add_subclass(Self), - ) + Ok(pyo3::PyClassInitializer::from(PyExpr(super::enum_::Tuple { items: exprs }.into())).add_subclass(Self)) } fn get_items(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { @@ -127,16 +124,9 @@ impl PyTupleExpr { impl PyUnaryExpr { #[new] #[pyo3(signature = (operator, expr, /))] - fn __new__( - operator: &str, - expr: LiteralExprExtractor, - ) -> pyo3::PyResult> { + fn __new__(operator: &str, expr: LiteralExprExtractor) -> pyo3::PyResult> { let Some(op) = super::enum_::UnaryOp::parse(operator) else { - return rapidquery_macros::pyerr!( - Err(PyValueError), - "unknown unary operator {:?}", - operator - ); + return rapidquery_macros::pyerr!(Err(PyValueError), "unknown unary operator {:?}", operator); }; let inner = super::enum_::Unary { @@ -562,10 +552,7 @@ impl PyTrimExpr { impl PyCollateExpr { #[new] #[pyo3(signature = (expr, collation, /))] - fn __new__( - expr: LiteralExprExtractor, - collation: IdentExtractor, - ) -> pyo3::PyClassInitializer { + fn __new__(expr: LiteralExprExtractor, collation: IdentExtractor) -> pyo3::PyClassInitializer { let inner = super::enum_::Collate { expr: Box::new(expr.0), collation: collation.0, @@ -587,9 +574,7 @@ impl PyCollateExpr { impl PyCaseExpr { #[new] #[pyo3(signature = (operand = None, /))] - fn __new__( - operand: Option, - ) -> pyo3::PyResult> { + fn __new__(operand: Option) -> pyo3::PyResult> { let inner = super::enum_::Case { operand: operand.map(|LiteralExprExtractor(e)| Box::new(e)), when: vec![], @@ -613,10 +598,7 @@ impl PyCaseExpr { .map(pyo3::Py::into_any) } - fn else_( - slf: pyo3::PyRef<'_, Self>, - result: LiteralExprExtractor, - ) -> pyo3::PyResult { + fn else_(slf: pyo3::PyRef<'_, Self>, result: LiteralExprExtractor) -> pyo3::PyResult { let mut inner = Self::inner(&slf).clone(); inner.else_result = Some(Box::new(result.0)); @@ -670,10 +652,7 @@ impl PyCaseExpr { impl PyCustomExpr { #[new] #[pyo3(signature = (sql, *args))] - fn __new__( - sql: String, - args: alias::ArgsType<'_>, - ) -> pyo3::PyResult> { + fn __new__(sql: String, args: alias::ArgsType<'_>) -> pyo3::PyResult> { let mut extracted = Vec::with_capacity(args.len()); for item in args.iter() { diff --git a/src/expr/value.rs b/src/expr/value.rs index 4f7d718..e954864 100644 --- a/src/expr/value.rs +++ b/src/expr/value.rs @@ -47,10 +47,7 @@ impl PyValue { /// TypeError: If `serializer` is not a `Serializer`. #[new] #[pyo3(signature = (object, serializer = None, /))] - fn __new__( - object: alias::BoundObject, - serializer: Option, - ) -> pyo3::PyClassInitializer { + fn __new__(object: alias::BoundObject, serializer: Option) -> pyo3::PyClassInitializer { // handle Value(Value(...), new_serializer) if unsafe { crate::typeref::check_exact(object.as_ptr(), crate::typeref::VALUE_TYPE) } { let expr = unsafe { object.cast_unchecked::() }; diff --git a/src/internal/modules/json.rs b/src/internal/modules/json.rs index 0a7ef58..ae5dcb3 100644 --- a/src/internal/modules/json.rs +++ b/src/internal/modules/json.rs @@ -11,9 +11,7 @@ use pyo3::types::PyTuple; use crate::internal::utils::borrow_py_str_buffer; #[cfg(all(feature = "only-stdlib-json", feature = "only-orjson"))] -compile_error!( - "Testing features `only-stdlib-json` and `only-orjson` cannot be used at a same time." -); +compile_error!("Testing features `only-stdlib-json` and `only-orjson` cannot be used at a same time."); enum JsonDumper { Orjson { @@ -118,9 +116,7 @@ pub unsafe fn dumps( let dumper = DUMPER.get_or_try_init(|| init_import(py))?; let result = match dumper { - JsonDumper::Orjson { dumps } => unsafe { - pyo3::ffi::PyObject_CallOneArg(dumps.as_ptr(), obj.as_ptr()) - }, + JsonDumper::Orjson { dumps } => unsafe { pyo3::ffi::PyObject_CallOneArg(dumps.as_ptr(), obj.as_ptr()) }, JsonDumper::Stdlib { dumps, kwnames, @@ -128,14 +124,7 @@ pub unsafe fn dumps( } => { let false_ = unsafe { pyo3::ffi::Py_False() }; let mut args = [obj.as_ptr(), false_, false_, separators.as_ptr()]; - unsafe { - pyo3::ffi::PyObject_Vectorcall( - dumps.as_ptr(), - args.as_mut_ptr(), - 1, - kwnames.as_ptr(), - ) - } + unsafe { pyo3::ffi::PyObject_Vectorcall(dumps.as_ptr(), args.as_mut_ptr(), 1, kwnames.as_ptr()) } } }; diff --git a/src/internal/repr.rs b/src/internal/repr.rs index 0817a2f..365a72b 100644 --- a/src/internal/repr.rs +++ b/src/internal/repr.rs @@ -82,22 +82,14 @@ impl ReprFormatter { self } - pub fn keyword_optional_debug( - &mut self, - key: &str, - value: Option, - ) -> &mut Self { + pub fn keyword_optional_debug(&mut self, key: &str, value: Option) -> &mut Self { if let Some(value) = value { self.keyword_debug(key, value); } self } - pub fn keyword_optional_formatter( - &mut self, - key: &str, - value: Option, - ) -> &mut Self { + pub fn keyword_optional_formatter(&mut self, key: &str, value: Option) -> &mut Self { if let Some(child) = value { self.keyword_formatter(key, child); } diff --git a/src/internal/utils/mod.rs b/src/internal/utils/mod.rs index 8396ce2..d87741d 100644 --- a/src/internal/utils/mod.rs +++ b/src/internal/utils/mod.rs @@ -12,10 +12,7 @@ pub use submodule_ext::*; /// returned slice is valid until that object is mutated or the GIL is /// released. The GIL must be held (`py`). #[inline] -pub unsafe fn borrow_py_str_buffer<'a>( - py: pyo3::Python<'_>, - ptr: *mut pyo3::ffi::PyObject, -) -> pyo3::PyResult<&'a str> { +pub unsafe fn borrow_py_str_buffer<'a>(py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject) -> pyo3::PyResult<&'a str> { let mut size: pyo3::ffi::Py_ssize_t = 0; let data = pyo3::ffi::PyUnicode_AsUTF8AndSize(ptr, &mut size); if data.is_null() || size < 0 { @@ -67,10 +64,7 @@ pub fn new_pynone<'py>(py: pyo3::Python<'py>) -> pyo3::Bound<'py, pyo3::PyAny> { /// # Safety /// The items returned by the iterator must be owned, not borrowed. #[inline] -pub unsafe fn iterator_to_tuple< - 'py, - I: Iterator>, ->( +pub unsafe fn iterator_to_tuple<'py, I: Iterator>>( py: pyo3::Python<'py>, len: isize, iter: I, diff --git a/src/internal/utils/strings.rs b/src/internal/utils/strings.rs index 2c7f438..515e32f 100644 --- a/src/internal/utils/strings.rs +++ b/src/internal/utils/strings.rs @@ -100,11 +100,7 @@ where /// /// # Complexity /// O(n + k) where n is the input length and k is the number of pairs. -pub fn replace_ascii_to( - haystack: &str, - replacements: &[(u8, &str)], - out: &mut W, -) -> fmt::Result { +pub fn replace_ascii_to(haystack: &str, replacements: &[(u8, &str)], out: &mut W) -> fmt::Result { if replacements.is_empty() { return out.write_str(haystack); } @@ -235,18 +231,12 @@ mod tests { #[test] fn single_replacement_matches_naive() { let input = "hello world"; - assert_eq!( - replace_ascii(input, &[(b'o', "0")]), - input.replace('o', "0") - ); + assert_eq!(replace_ascii(input, &[(b'o', "0")]), input.replace('o', "0")); } #[test] fn no_matches_ascii() { - assert_eq!( - replace_ascii("hello world", &[(b'z', "Z"), (b'x', "X")]), - "hello world" - ); + assert_eq!(replace_ascii("hello world", &[(b'z', "Z"), (b'x', "X")]), "hello world"); } #[test] @@ -286,10 +276,7 @@ mod tests { fn unicode_content_preserved() { let input = "cafĆ©\0naĆÆve'test"; let expected = input.replace('\'', "''").replace('\0', ""); - assert_eq!( - replace_ascii(input, &[(b'\'', "''"), (b'\0', "")]), - expected - ); + assert_eq!(replace_ascii(input, &[(b'\'', "''"), (b'\0', "")]), expected); } #[test] diff --git a/src/lib.rs b/src/lib.rs index be608db..f2b9cff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,7 +10,6 @@ pub mod sql; pub mod typeref; // Tasks until being stable (until this part) -// - Add lint/coding-style rules // - Restore GenericDialect ( very helpful for testing ) // - Test and coverage everything and edge cases // - Update docstrings / doccomments diff --git a/src/serializers/functions.rs b/src/serializers/functions.rs index fd6079e..66ea807 100644 --- a/src/serializers/functions.rs +++ b/src/serializers/functions.rs @@ -14,10 +14,7 @@ use std::str::FromStr; /// # Safety /// `ptr` must be *borrowed*, non-null, and a valid Python object. #[inline] -pub(super) unsafe fn pyobject_string( - py: pyo3::Python<'_>, - ptr: *mut pyo3::ffi::PyObject, -) -> pyo3::PyResult { +pub(super) unsafe fn pyobject_string(py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject) -> pyo3::PyResult { let s = pyo3::ffi::PyObject_Str(ptr); if s.is_null() { return Err(pyo3::PyErr::fetch(py)); @@ -47,14 +44,8 @@ fn write_with_keyword( fn timestamp_keyword(format: flags::TimestampFormat, aware: bool) -> Option<&'static str> { match format { flags::TimestampFormat::Quoted => None, - flags::TimestampFormat::Timestamptz => { - Some(if aware { "TIMESTAMPTZ" } else { "TIMESTAMP" }) - } - flags::TimestampFormat::WithTimezone => Some(if aware { - "TIMESTAMP WITH TIMEZONE" - } else { - "TIMESTAMP" - }), + flags::TimestampFormat::Timestamptz => Some(if aware { "TIMESTAMPTZ" } else { "TIMESTAMP" }), + flags::TimestampFormat::WithTimezone => Some(if aware { "TIMESTAMP WITH TIMEZONE" } else { "TIMESTAMP" }), } } @@ -111,9 +102,7 @@ pub(super) unsafe fn write_float( return Ok(()); } - if pyo3::ffi::PyLong_CheckExact(ptr) == 1 - || crate::typeref::check_exact(ptr, crate::typeref::STD_DECIMAL_TYPE) - { + if pyo3::ffi::PyLong_CheckExact(ptr) == 1 || crate::typeref::check_exact(ptr, crate::typeref::STD_DECIMAL_TYPE) { // Fallback to `str(obj)` for int/decimal.Decimal let text = pyobject_string(py, ptr)?; out.push_str(&text); @@ -263,22 +252,21 @@ pub(super) unsafe fn write_json( dialect: &flags::DialectFlags, out: &mut String, ) -> pyo3::PyResult<()> { - crate::internal::modules::json_dumps( - unsafe { pyo3::Bound::from_borrowed_ptr(py, ptr) }, - |text| match dialect.json_format() { - flags::JsonFormat::Quoted => write_quoted(dialect, out, text), - flags::JsonFormat::JsonKeyword => { - out.push_str("JSON "); - write_quoted(dialect, out, text) - } - flags::JsonFormat::ParseJson => { - out.push_str("PARSE_JSON("); - write_quoted(dialect, out, text)?; - out.push(')'); - Ok(()) - } - }, - ) + crate::internal::modules::json_dumps(unsafe { pyo3::Bound::from_borrowed_ptr(py, ptr) }, |text| match dialect + .json_format() + { + flags::JsonFormat::Quoted => write_quoted(dialect, out, text), + flags::JsonFormat::JsonKeyword => { + out.push_str("JSON "); + write_quoted(dialect, out, text) + } + flags::JsonFormat::ParseJson => { + out.push_str("PARSE_JSON("); + write_quoted(dialect, out, text)?; + out.push(')'); + Ok(()) + } + }) } #[inline] @@ -289,8 +277,7 @@ pub(super) unsafe fn write_date( out: &mut String, ) -> pyo3::PyResult<()> { if crate::typeref::check_exact(ptr, crate::typeref::STD_DATE_TYPE) { - let val: pyo3::Bound<'_, pyo3::types::PyDate> = - pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; + let val: pyo3::Bound<'_, pyo3::types::PyDate> = pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; let date: chrono::NaiveDate = val.extract()?; let mut body = String::new(); @@ -317,8 +304,7 @@ pub(super) unsafe fn write_time( out: &mut String, ) -> pyo3::PyResult<()> { if crate::typeref::check_exact(ptr, crate::typeref::STD_TIME_TYPE) { - let val: pyo3::Bound<'_, pyo3::types::PyTime> = - pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; + let val: pyo3::Bound<'_, pyo3::types::PyTime> = pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; let time: chrono::NaiveTime = val.extract()?; let mut body = String::new(); @@ -347,8 +333,7 @@ pub(super) unsafe fn write_datetime( use chrono::TimeZone; if crate::typeref::check_exact(ptr, crate::typeref::STD_DATETIME_TYPE) { - let val: pyo3::Bound<'_, pyo3::types::PyDateTime> = - pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; + let val: pyo3::Bound<'_, pyo3::types::PyDateTime> = pyo3::Bound::from_borrowed_ptr(py, ptr).cast_into()?; if val.get_tzinfo().is_none() { let datetime: chrono::NaiveDateTime = val.extract()?; @@ -364,15 +349,8 @@ pub(super) unsafe fn write_datetime( let datetime: chrono::DateTime = val.extract()?; let mut body = String::new(); - let _ = datetime - .format("%Y-%m-%d %H:%M:%S%.f%:z") - .write_to(&mut body); - return write_with_keyword( - dialect, - out, - timestamp_keyword(dialect.timestamp_format(), true), - &body, - ); + let _ = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").write_to(&mut body); + return write_with_keyword(dialect, out, timestamp_keyword(dialect.timestamp_format(), true), &body); } if pyo3::ffi::PyLong_CheckExact(ptr) == 1 { @@ -385,15 +363,8 @@ pub(super) unsafe fn write_datetime( return match chrono::Utc.timestamp_opt(num, 0) { chrono::offset::LocalResult::Single(datetime) => { let mut body = String::new(); - let _ = datetime - .format("%Y-%m-%d %H:%M:%S%.f%:z") - .write_to(&mut body); - write_with_keyword( - dialect, - out, - timestamp_keyword(dialect.timestamp_format(), true), - &body, - ) + let _ = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").write_to(&mut body); + write_with_keyword(dialect, out, timestamp_keyword(dialect.timestamp_format(), true), &body) } _ => rapidquery_macros::pyerr!( Err(PyOverflowError), @@ -405,20 +376,11 @@ pub(super) unsafe fn write_datetime( if pyo3::ffi::PyFloat_CheckExact(ptr) == 1 { let num = pyo3::ffi::PyFloat_AsDouble(ptr); - return match chrono::Utc - .timestamp_opt(num.trunc() as i64, (num.fract() * 1_000_000_000.0) as u32) - { + return match chrono::Utc.timestamp_opt(num.trunc() as i64, (num.fract() * 1_000_000_000.0) as u32) { chrono::offset::LocalResult::Single(datetime) => { let mut body = String::new(); - let _ = datetime - .format("%Y-%m-%d %H:%M:%S%.f%:z") - .write_to(&mut body); - write_with_keyword( - dialect, - out, - timestamp_keyword(dialect.timestamp_format(), true), - &body, - ) + let _ = datetime.format("%Y-%m-%d %H:%M:%S%.f%:z").write_to(&mut body); + write_with_keyword(dialect, out, timestamp_keyword(dialect.timestamp_format(), true), &body) } _ => rapidquery_macros::pyerr!( Err(PyOverflowError), @@ -443,8 +405,8 @@ pub(super) unsafe fn write_inet( out: &mut String, ) -> pyo3::PyResult<()> { let text = pyobject_string(py, ptr)?; - let net = ipnetwork::IpNetwork::from_str(&text) - .map_err(|e| rapidquery_macros::pyerr!(PyValueError, e.to_string()))?; + let net = + ipnetwork::IpNetwork::from_str(&text).map_err(|e| rapidquery_macros::pyerr!(PyValueError, e.to_string()))?; let body = net.to_string(); match dialect.inet_format() { @@ -466,14 +428,12 @@ pub(super) unsafe fn write_macaddr( out: &mut String, ) -> pyo3::PyResult<()> { let text = pyobject_string(py, ptr)?; - let addr = mac_address::MacAddress::from_str(&text) - .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let addr = + mac_address::MacAddress::from_str(&text).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; let body = addr.to_string(); match dialect.macaddr_format() { - flags::MacAddrFormat::Unsupported => { - Err(FormatError::Unsupported("MacAddressSerializer").into()) - } + flags::MacAddrFormat::Unsupported => Err(FormatError::Unsupported("MacAddressSerializer").into()), flags::MacAddrFormat::Quoted => write_quoted(dialect, out, &body), flags::MacAddrFormat::Cast => { write_quoted(dialect, out, &body)?; @@ -494,13 +454,9 @@ pub(super) unsafe fn write_array( isize, unsafe fn(*mut pyo3::ffi::PyObject, isize) -> *mut pyo3::ffi::PyObject, ) = if pyo3::ffi::PyList_CheckExact(ptr) == 1 { - (pyo3::ffi::PyList_Size(ptr), |p, i| { - pyo3::ffi::PyList_GetItem(p, i) - }) + (pyo3::ffi::PyList_Size(ptr), |p, i| pyo3::ffi::PyList_GetItem(p, i)) } else if pyo3::ffi::PyTuple_CheckExact(ptr) == 1 { - (pyo3::ffi::PyTuple_Size(ptr), |p, i| { - pyo3::ffi::PyTuple_GetItem(p, i) - }) + (pyo3::ffi::PyTuple_Size(ptr), |p, i| pyo3::ffi::PyTuple_GetItem(p, i)) } else { return rapidquery_macros::pyerr!( Err(PyTypeError), diff --git a/src/serializers/inner.rs b/src/serializers/inner.rs index 2fb21aa..75b6e5c 100644 --- a/src/serializers/inner.rs +++ b/src/serializers/inner.rs @@ -85,9 +85,7 @@ impl SerializerType { } // float / decimal -> Float - if pyo3::ffi::PyFloat_CheckExact(object_ptr) == 1 - || object_type_ptr == crate::typeref::STD_DECIMAL_TYPE - { + if pyo3::ffi::PyFloat_CheckExact(object_ptr) == 1 || object_type_ptr == crate::typeref::STD_DECIMAL_TYPE { return Self::Float; } @@ -127,8 +125,7 @@ impl SerializerType { } // list/dict -> Json - if (pyo3::ffi::PyList_CheckExact(object_ptr) == 1 - || pyo3::ffi::PyDict_CheckExact(object_ptr) == 1) + if (pyo3::ffi::PyList_CheckExact(object_ptr) == 1 || pyo3::ffi::PyDict_CheckExact(object_ptr) == 1) && modules::json_dumps(bound.clone(), |_| Ok(())).is_ok() { return Self::Json; @@ -165,9 +162,7 @@ impl SerializerType { Self::Datetime => super::functions::write_datetime(py, ptr, &dialect.0, out), Self::Inet => super::functions::write_inet(py, ptr, &dialect.0, out), Self::MacAddress => super::functions::write_macaddr(py, ptr, &dialect.0, out), - Self::Array(element) => { - super::functions::write_array(py, ptr, dialect, element, out) - } + Self::Array(element) => super::functions::write_array(py, ptr, dialect, element, out), Self::Unspecified => { let text = super::functions::pyobject_string(py, ptr)?; out.push_str(&text); diff --git a/src/serializers/mod.rs b/src/serializers/mod.rs index accf25a..310f2df 100644 --- a/src/serializers/mod.rs +++ b/src/serializers/mod.rs @@ -18,12 +18,7 @@ pub fn serializer_repr(serializer: &SerializerType) -> ReprFormatter { } /// Converts a Python object into a dialect-specific SQL literal string. -#[rapidquery_macros::pyclass( - "rapidquery.serializers.Serializer", - subclass, - from_py_object, - generic -)] +#[rapidquery_macros::pyclass("rapidquery.serializers.Serializer", subclass, from_py_object, generic)] #[derive(Debug, Clone)] #[repr(transparent)] pub struct PySerializer(pub SerializerType); @@ -31,11 +26,7 @@ pub struct PySerializer(pub SerializerType); #[pyo3::pymethods] impl PySerializer { /// Renders `object` as a SQL literal for `dialect`. - fn serialize( - &self, - dialect: crate::dialect::PyDialect, - object: alias::BoundObject<'_>, - ) -> pyo3::PyResult { + fn serialize(&self, dialect: crate::dialect::PyDialect, object: alias::BoundObject<'_>) -> pyo3::PyResult { let mut buf = String::new(); self.0.serialize(&dialect, &object, &mut buf)?; Ok(buf) @@ -290,9 +281,7 @@ impl PyArraySerializer { fn get_element(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { let base = slf.as_super(); match &base.0 { - SerializerType::Array(element) => { - PySerializer((**element).clone()).into_subclasses(slf.py()) - } + SerializerType::Array(element) => PySerializer((**element).clone()).into_subclasses(slf.py()), _ => unsafe { std::hint::unreachable_unchecked() }, } } @@ -319,10 +308,7 @@ impl PyCustomSerializer { fn __new__(function: alias::PyObject) -> pyo3::PyResult> { unsafe { if pyo3::ffi::PyCallable_Check(function.as_ptr()) != 1 { - return rapidquery_macros::pyerr!( - Err(PyTypeError), - "the 'function' parameter must be callable" - ); + return rapidquery_macros::pyerr!(Err(PyTypeError), "the 'function' parameter must be callable"); } } let inner = SerializerType::Custom(function); @@ -351,9 +337,7 @@ pub struct SerializerExtractor(pub SerializerType); impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for SerializerExtractor { type Error = pyo3::PyErr; - fn extract( - obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>, - ) -> Result { + fn extract(obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>) -> Result { let ptr = obj.as_ptr(); unsafe { diff --git a/src/sql.rs b/src/sql.rs index 400bac0..eccf043 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -31,10 +31,9 @@ impl std::fmt::Display for PrepareError { match self { Self::Write => f.write_str("failed to write SQL"), Self::Python(err) => write!(f, "{err}"), - Self::CustomPlaceholders { expected, got } => write!( - f, - "custom expression has {expected} placeholders but {got} arguments" - ), + Self::CustomPlaceholders { expected, got } => { + write!(f, "custom expression has {expected} placeholders but {got} arguments") + } } } } @@ -73,11 +72,7 @@ impl From for pyo3::PyErr { rapidquery_macros::pyerr!(PyValueError, "NUL is not allowed in a string literal") } FormatError::Unsupported(name) => { - rapidquery_macros::pyerr!( - PyNotImplementedError, - "this dialect does not support {}", - name - ) + rapidquery_macros::pyerr!(PyNotImplementedError, "this dialect does not support {}", name) } } } @@ -150,10 +145,7 @@ pub struct BufferedSql<'py> { impl<'py> BufferedSql<'py> { #[inline] pub fn new(py: pyo3::Python<'py>) -> Self { - Self { - py, - sql: String::new(), - } + Self { py, sql: String::new() } } #[inline] @@ -352,11 +344,7 @@ pub trait WriteSql { } /// Render `stmt` as raw SQL (values inlined as literals). -pub fn write_sql<'py, T: WriteSql>( - py: pyo3::Python<'py>, - stmt: &T, - dialect: &PyDialect, -) -> PrepareResult { +pub fn write_sql<'py, T: WriteSql>(py: pyo3::Python<'py>, stmt: &T, dialect: &PyDialect) -> PrepareResult { let mut buf = BufferedSql::with_capacity(py, 256); stmt.write_sql(dialect, &mut buf)?; Ok(buf.into_string()) diff --git a/src/typeref.rs b/src/typeref.rs index dcaeb7b..c45a99f 100644 --- a/src/typeref.rs +++ b/src/typeref.rs @@ -24,9 +24,7 @@ pub(crate) static mut STD_DATETIME_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr pub(crate) static mut STD_DATE_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); pub(crate) static mut STD_TIME_TYPE: *mut pyo3::ffi::PyTypeObject = std::ptr::null_mut(); -unsafe fn get_type_object_for( - py: pyo3::Python, -) -> *mut pyo3::ffi::PyTypeObject { +unsafe fn get_type_object_for(py: pyo3::Python) -> *mut pyo3::ffi::PyTypeObject { // Borrowed type object; lives as long as the `#[pyclass]` is registered // on `rapidquery._core`, which is the process lifetime of this extension. T::type_object_raw(py) @@ -45,8 +43,7 @@ unsafe fn look_up_type_object( ) -> *mut pyo3::ffi::PyTypeObject { let module = pyo3::ffi::PyImport_ImportModule(module_name.as_ptr()); let module_dict = pyo3::ffi::PyObject_GenericGetDict(module, std::ptr::null_mut()); - let ptr = pyo3::ffi::PyMapping_GetItemString(module_dict, member_name.as_ptr()) - .cast::(); + let ptr = pyo3::ffi::PyMapping_GetItemString(module_dict, member_name.as_ptr()).cast::(); pyo3::ffi::Py_DECREF(module_dict); pyo3::ffi::Py_DECREF(module); @@ -84,8 +81,8 @@ fn _initialize_typeref(py: pyo3::Python) { STD_UUID_TYPE = look_up_type_object(c"uuid", c"UUID"); pyo3::ffi::PyDateTime_IMPORT(); - let datetime_capsule = pyo3::ffi::PyCapsule_Import(c"datetime.datetime_CAPI".as_ptr(), 1) - .cast::(); + let datetime_capsule = + pyo3::ffi::PyCapsule_Import(c"datetime.datetime_CAPI".as_ptr(), 1).cast::(); STD_DATETIME_TYPE = (*datetime_capsule).DateTimeType; STD_DATE_TYPE = (*datetime_capsule).DateType; @@ -110,10 +107,7 @@ pub fn initialize_typeref(py: pyo3::Python) { /// `PyTypeObject*` (interned pointers from [`initialize_typeref`] are fine). /// The GIL must be held. #[inline(always)] -pub unsafe fn check_exact( - ptr: *mut pyo3::ffi::PyObject, - r#type: *mut pyo3::ffi::PyTypeObject, -) -> bool { +pub unsafe fn check_exact(ptr: *mut pyo3::ffi::PyObject, r#type: *mut pyo3::ffi::PyTypeObject) -> bool { pyo3::ffi::Py_TYPE(ptr) == r#type } diff --git a/tests/conftest.py b/tests/conftest.py index c398838..13b4163 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,12 @@ +import importlib.util + import pytest from rapidquery._core import _feature_only_stdlib_json from rapidquery.dialect import ( BigQuery, Databricks, + Dialect, DuckDb, Hive, MsSql, @@ -13,22 +16,14 @@ Redshift, Snowflake, Spark, - Dialect, Sqlite, Teradata, ) -try: - import orjson +USING_ORJSON = importlib.util.find_spec("orjson") is not None and not _feature_only_stdlib_json - USING_ORJSON = not _feature_only_stdlib_json -except ImportError: - USING_ORJSON = False - -# Builtin dialects only. GenericDialect needs every field as a keyword argument; -# tests that want one call `generic_dialect()`. -ALL_DIALECT_CLASSES: list[type[Dialect]] = [ +ALL_DIALECTS: list[Dialect] = [ BigQuery, Databricks, DuckDb, @@ -43,17 +38,9 @@ Sqlite, Teradata, ] -ALL_DIALECT_NAMES = [cls.__name__ for cls in ALL_DIALECT_CLASSES] -ALL_DIALECTS = tuple(cls() for cls in ALL_DIALECT_CLASSES) -@pytest.fixture(params=ALL_DIALECT_CLASSES, ids=ALL_DIALECT_NAMES) -def dialect_cls(request: pytest.FixtureRequest) -> type[Dialect]: - """Every builtin SqlDialect subclass (not GenericDialect).""" +@pytest.fixture(params=ALL_DIALECTS) +def dialect(request: pytest.FixtureRequest) -> Dialect: + """Every builtin Dialects.""" return request.param - - -@pytest.fixture(params=ALL_DIALECT_CLASSES, ids=ALL_DIALECT_NAMES) -def dialect_instance(request: pytest.FixtureRequest) -> Dialect: - """An instance of every builtin SqlDialect subclass.""" - return request.param() diff --git a/tests/test_data_type.py b/tests/test_data_type.py deleted file mode 100644 index a1d48d6..0000000 --- a/tests/test_data_type.py +++ /dev/null @@ -1,48 +0,0 @@ -import pytest - -from rapidquery.data_type import DataType - - -class TestDataTypeNameNormalization: - @pytest.mark.parametrize( - "input_name,canonical", - [ - ("int", "INT"), - ("integer", "INTEGER"), - ("varchar", "VARCHAR"), - ("text", "TEXT"), - ("uuid", "UUID"), - ], - ) - def test_known_names_are_uppercased_to_canonical_form(self, input_name, canonical): - dt = DataType(input_name) - assert dt.name == canonical - - def test_name_matching_is_case_insensitive(self): - assert DataType("INTEGER") == DataType("integer") - assert DataType("Integer") == DataType("integer") - - def test_unrecognized_name_is_preserved_as_is(self): - dt = DataType("some_totally_unknown_type") - assert dt.name == "some_totally_unknown_type" - - def test_parameterized_name_is_preserved_as_is(self): - dt = DataType("VARCHAR(255)") - assert dt.name == "VARCHAR(255)" - - def test_parameterized_name_is_not_alias_matched(self): - dt = DataType("varchar(255)") - assert dt.name == "varchar(255)" - - -class TestDataTypeEquality: - def test_interned_spellings_of_the_same_name_are_equal(self): - assert DataType("int") == DataType("INT") - assert hash(DataType("int")) == hash(DataType("INT")) - - def test_distinct_interned_names_are_not_equal(self): - assert DataType("int") != DataType("integer") - - def test_not_equal_to_unrelated_types(self): - assert DataType("int") != "INT" - assert DataType("int") != 1 diff --git a/tests/test_dialect.py b/tests/test_dialect.py deleted file mode 100644 index 5382948..0000000 --- a/tests/test_dialect.py +++ /dev/null @@ -1,467 +0,0 @@ -"""Python API for SqlDialect, the 13 builtins, and GenericDialect. - -Escape and format algorithms are tested in Rust (`src/dialect/inner.rs`). -These tests cover property wiring, Python string conversion, and a smoke -check of each public method. -""" - -import pytest - -from rapidquery.dialect import ( - BigQuery, - Databricks, - DuckDb, - Hive, - MsSql, - MySql, - Oracle, - PostgreSql, - Redshift, - Snowflake, - Spark, - Dialect, - Sqlite, - Teradata, -) - -from .conftest import ALL_DIALECT_CLASSES, ALL_DIALECT_NAMES - -# Expected DialectConfig for each builtin (src/dialect/builtin.rs). -BUILTIN_CONFIG = { - BigQuery: dict( - placeholder_sigil="@", - bit_format=None, - supports_array=True, - supports_inet=False, - supports_mac_address=False, - null_treatment_syntax="InsideParens", - like_any_syntax="OrExpansion", - bool_format="Keyword", - identifier_format="Backtick", - identifier_escape="SameAsString", - string_format="SingleQuote", - string_escape="BackslashHex", - bytes_format="PrefixBigBSlashX", - ), - Databricks: dict( - placeholder_sigil=":", - bit_format=None, - supports_array=False, - supports_inet=False, - supports_mac_address=False, - null_treatment_syntax="OutsideParens", - like_any_syntax="PatternList", - bool_format="Keyword", - identifier_format="Backtick", - identifier_escape="Delimiters", - string_format="SingleQuote", - string_escape="BackslashUnicodeQuote", - bytes_format="PrefixBigX", - ), - DuckDb: dict( - placeholder_sigil="$", - bit_format="PrefixBigB", - supports_array=True, - supports_inet=True, - supports_mac_address=False, - null_treatment_syntax="InsideParens", - like_any_syntax="OrExpansion", - bool_format="Keyword", - identifier_format="DoubleQuote", - identifier_escape="Delimiters", - string_format="SingleQuote", - string_escape="Quote", - bytes_format="PrefixBigX", - ), - Hive: dict( - placeholder_sigil=":", - bit_format=None, - supports_array=False, - supports_inet=False, - supports_mac_address=False, - null_treatment_syntax="OutsideParens", - like_any_syntax="OrExpansion", - bool_format="Keyword", - identifier_format="Backtick", - identifier_escape="Delimiters", - string_format="SingleQuote", - string_escape="BackslashUnicode", - bytes_format="Unhex", - ), - MsSql: dict( - placeholder_sigil="@", - bit_format=None, - supports_array=False, - supports_inet=False, - supports_mac_address=False, - null_treatment_syntax="OutsideParens", - like_any_syntax="OrExpansion", - bool_format="Int", - identifier_format="SquareBracket", - identifier_escape="Delimiters", - string_format="PrefixBigN", - string_escape="QuoteStripNul", - bytes_format="PrefixZeroX", - ), - MySql: dict( - placeholder_sigil=":", - bit_format="PrefixSmallB", - supports_array=False, - supports_inet=False, - supports_mac_address=False, - null_treatment_syntax="OutsideParens", - like_any_syntax="OrExpansion", - bool_format="Int", - identifier_format="Backtick", - identifier_escape="Delimiters", - string_format="SingleQuote", - string_escape="BackslashMysql", - bytes_format="PrefixBigX", - ), - Oracle: dict( - placeholder_sigil=":", - bit_format=None, - supports_array=False, - supports_inet=False, - supports_mac_address=False, - null_treatment_syntax="OutsideParens", - like_any_syntax="OrExpansion", - bool_format="Int", - identifier_format="DoubleQuote", - identifier_escape="Delimiters", - string_format="SingleQuote", - string_escape="QuoteStripNul", - bytes_format="HexToRaw", - ), - PostgreSql: dict( - placeholder_sigil=":", - bit_format="PrefixBigB", - supports_array=True, - supports_inet=True, - supports_mac_address=True, - null_treatment_syntax="OutsideParens", - like_any_syntax="ArrayConstructor", - bool_format="Keyword", - identifier_format="DoubleQuote", - identifier_escape="Delimiters", - string_format="PrefixBigE", - string_escape="BackslashHex", - bytes_format="SlashXBytea", - ), - Redshift: dict( - placeholder_sigil=":", - bit_format=None, - supports_array=False, - supports_inet=False, - supports_mac_address=False, - null_treatment_syntax="OutsideParens", - like_any_syntax="OrExpansion", - bool_format="Keyword", - identifier_format="DoubleQuote", - identifier_escape="Delimiters", - string_format="SingleQuote", - string_escape="QuoteStripNul", - bytes_format="ToVarbyte", - ), - Snowflake: dict( - placeholder_sigil=":", - bit_format=None, - supports_array=False, - supports_inet=False, - supports_mac_address=False, - null_treatment_syntax="OutsideParens", - like_any_syntax="PatternList", - bool_format="Keyword", - identifier_format="DoubleQuote", - identifier_escape="Delimiters", - string_format="SingleQuote", - string_escape="BackslashHexZero", - bytes_format="PrefixBigX", - ), - Spark: dict( - placeholder_sigil=":", - bit_format=None, - supports_array=False, - supports_inet=False, - supports_mac_address=False, - null_treatment_syntax="OutsideParens", - like_any_syntax="PatternList", - bool_format="Keyword", - identifier_format="Backtick", - identifier_escape="Delimiters", - string_format="SingleQuote", - string_escape="BackslashUnicodeQuote", - bytes_format="PrefixBigX", - ), - Sqlite: dict( - placeholder_sigil=":", - bit_format=None, - supports_array=False, - supports_inet=False, - supports_mac_address=False, - null_treatment_syntax="OutsideParens", - like_any_syntax="OrExpansion", - bool_format="Int", - identifier_format="DoubleQuote", - identifier_escape="Delimiters", - string_format="SingleQuote", - string_escape="QuoteStripNul", - bytes_format="PrefixBigX", - ), - Teradata: dict( - placeholder_sigil=":", - bit_format="PostfixBigB", - supports_array=False, - supports_inet=False, - supports_mac_address=False, - null_treatment_syntax="OutsideParens", - like_any_syntax="OrExpansion", - bool_format="Keyword", - identifier_format="SingleQuote", - identifier_escape="Delimiters", - string_format="SingleQuote", - string_escape="QuoteStripNul", - bytes_format="PostfixXbv", - ), -} - -_STRING_WRAP = { - "SingleQuote": "'{}'", - "PrefixBigE": "E'{}'", - "PrefixBigN": "N'{}'", -} -_BOOL_TRUE = {"Keyword": "TRUE", "Int": "1"} -_BOOL_FALSE = {"Keyword": "FALSE", "Int": "0"} -_BIT_WRAP = { - "PrefixBigB": "B'{}'", - "PrefixSmallB": "b'{}'", - "PostfixBigB": "'{}'B", -} -_BYTES_DEAD = { - "PrefixBigX": "X'DEAD'", - "PrefixZeroX": "0xDEAD", - "SlashXBytea": "'\\xDEAD'::bytea", - "PrefixBigBSlashX": "B'\\xDE\\xAD'", - "HexToRaw": "HEXTORAW('DEAD')", - "Unhex": "UNHEX('DEAD')", - "ToVarbyte": "TO_VARBYTE('DEAD', 'hex')", - "PostfixXbv": "'DEAD'XBV", -} - - -class TestSqlDialect: - def test_base_cannot_be_instantiated(self): - with pytest.raises(TypeError): - Dialect() - - def test_rejects_a_dialect_class_where_an_instance_is_required(self): - from rapidquery.serializers import IntegerSerializer - - with pytest.raises(TypeError, match="SqlDialect"): - IntegerSerializer().serialize(PostgreSql, 1) - - def test_all_expected_dialects_are_exported(self): - import rapidquery.dialect as dialect_module - - for name in ALL_DIALECT_NAMES: - assert hasattr(dialect_module, name) - assert hasattr(dialect_module, "GenericDialect") - assert hasattr(dialect_module, "SqlDialect") - - -class TestBuiltinDialects: - def test_is_subclass_of_sql_dialect(self, dialect_cls: type[Dialect]): - assert issubclass(dialect_cls, Dialect) - - def test_instance_is_sql_dialect(self, dialect_instance: Dialect): - assert isinstance(dialect_instance, Dialect) - - def test_constructs_with_no_arguments(self, dialect_cls: type[Dialect]): - dialect_cls() - - def test_rejects_unexpected_arguments(self, dialect_cls: type[Dialect]): - with pytest.raises(TypeError): - dialect_cls("unexpected") - - def test_cannot_be_subclassed(self, dialect_cls: type[Dialect]): - with pytest.raises(TypeError): - type(f"Sub{dialect_cls.__name__}", (dialect_cls,), {}) - - def test_frozen(self, dialect_instance: Dialect): - with pytest.raises(AttributeError): - dialect_instance.placeholder_sigil = "?" - - def test_builtin_config_covers_every_class(self): - assert set(BUILTIN_CONFIG) == set(ALL_DIALECT_CLASSES) - - def test_config_matches_builtin_table(self, dialect_cls: type[Dialect]): - dialect = dialect_cls() - for field, expected in BUILTIN_CONFIG[dialect_cls].items(): - assert getattr(dialect, field) == expected, field - - def test_same_dialect_instances_are_equal(self, dialect_cls: type[Dialect]): - assert dialect_cls() == dialect_cls() - assert hash(dialect_cls()) == hash(dialect_cls()) - assert len({dialect_cls(), dialect_cls()}) == 1 - - def test_instance_is_not_equal_to_its_class(self, dialect_cls: type[Dialect]): - assert dialect_cls() != dialect_cls - - def test_distinct_builtins(self): - assert len(set(ALL_DIALECT_CLASSES)) == len(ALL_DIALECT_CLASSES) - assert PostgreSql() != MySql() - assert len({PostgreSql(), MySql()}) == 2 - - def test_format_boolean_follows_bool_format(self, dialect_instance: Dialect): - kind = dialect_instance.bool_format - assert dialect_instance.format_boolean(True) == _BOOL_TRUE[kind] - assert dialect_instance.format_boolean(False) == _BOOL_FALSE[kind] - - def test_write_string_literal_wraps_plain_text(self, dialect_instance: Dialect): - wrapped = _STRING_WRAP[dialect_instance.string_format].format("hello") - assert dialect_instance.write_string_literal("hello") == wrapped - - def test_format_bytes_follows_bytes_format(self, dialect_instance: Dialect): - assert ( - dialect_instance.format_bytes(b"\xde\xad") - == _BYTES_DEAD[dialect_instance.bytes_format] - ) - - def test_format_bits(self, dialect_instance: Dialect): - if dialect_instance.bit_format is None: - with pytest.raises(ValueError, match="does not support literal bit"): - dialect_instance.format_bits("10") - return - expected = _BIT_WRAP[dialect_instance.bit_format].format("10") - assert dialect_instance.format_bits("10") == expected - - -class TestQuoteIdentifier: - def test_delimiter_escaping(self): - assert PostgreSql().quote_identifier('a"b') == '"a""b"' - assert MySql().quote_identifier("a`b") == "`a``b`" - assert MsSql().quote_identifier("a]b") == "[a]]b]" - assert Teradata().quote_identifier("a'b") == "'a''b'" - - def test_same_as_string_uses_string_escape(self): - # BigQuery: backticks + BackslashHex (same as string literals). - assert BigQuery().quote_identifier("foo'bar\\baz") == "`foo\\'bar\\\\baz`" - - def test_generic_delimiters_and_same_as_string(self): - doubled = generic_dialect( - identifier_format="DoubleQuote", identifier_escape="Delimiters" - ) - assert doubled.quote_identifier('a"b') == '"a""b"' - - same = generic_dialect( - identifier_format="Backtick", - identifier_escape="SameAsString", - string_escape="BackslashHex", - ) - assert same.quote_identifier("foo'bar") == "`foo\\'bar`" - - -class TestStringEscape: - # One representative input per StringEscape variant. Full matrices are in - # src/dialect/inner.rs. - @pytest.mark.parametrize( - "escape, raw, escaped", - [ - ("Quote", "O'Brien", "O''Brien"), - ("Quote", "a\0b", "a\0b"), - ("QuoteStripNul", "O'Brien", "O''Brien"), - ("QuoteStripNul", "a\0b", "ab"), - ("BackslashHex", "'", "\\'"), - ("BackslashHex", "\0", "\\x00"), - ("BackslashHex", "\x1a", "\\x1A"), - ("BackslashMysql", "\0", "\\0"), - ("BackslashMysql", '"', '\\"'), - ("BackslashMysql", "\x1a", "\\Z"), - ("BackslashMysql", "\t", "\t"), - ("BackslashUnicode", "\0", "\\0"), - ("BackslashUnicode", "\x01", "\\u0001"), - ("BackslashUnicode", '"', '"'), - ("BackslashHexZero", "\0", "\\0"), - ("BackslashHexZero", "\x1a", "\\x1A"), - ("BackslashUnicodeQuote", '"', '\\"'), - ("BackslashUnicodeQuote", "\x01", "\\u0001"), - ], - ) - def test_escape_string(self, escape: str, raw: str, escaped: str): - dialect = generic_dialect(string_escape=escape) - assert dialect.escape_string(raw) == escaped - assert dialect.write_string_literal(raw) == f"'{escaped}'" - - -class TestGenericDialect: - def test_is_sql_dialect(self): - dialect = generic_dialect() - assert isinstance(dialect, Dialect) - assert isinstance(dialect, GenericDialect) - assert issubclass(GenericDialect, Dialect) - - def test_cannot_be_subclassed(self): - with pytest.raises(TypeError): - type("SubGeneric", (GenericDialect,), {}) - - def test_all_arguments_are_required_and_keyword_only(self): - with pytest.raises(TypeError): - GenericDialect() - with pytest.raises(TypeError): - GenericDialect(":") # pyright: ignore[reportCallIssue] - - def test_unknown_format_name_is_value_error(self): - with pytest.raises(ValueError, match="string_escape"): - generic_dialect(string_escape="nope") - with pytest.raises(ValueError, match="bit_format"): - generic_dialect(bit_format="nope") - - def test_format_names_are_case_insensitive(self): - dialect = generic_dialect(bool_format="int", string_escape="BACKSLASHHEX") - assert dialect.bool_format == "Int" - assert dialect.string_escape == "BackslashHex" - - def test_placeholder_sigil_must_be_one_character(self): - with pytest.raises(ValueError, match="length 1"): - generic_dialect(placeholder_sigil="ab") - - def test_frozen(self): - dialect = generic_dialect() - with pytest.raises(AttributeError): - dialect.placeholder_sigil = "$" - - def test_equality_is_by_config(self): - a = generic_dialect(placeholder_sigil="$") - b = generic_dialect(placeholder_sigil="$") - c = generic_dialect(placeholder_sigil=":") - assert a == b - assert hash(a) == hash(b) - assert a != c - assert len({a, b, c}) == 2 - - def test_equal_to_builtin_with_the_same_config(self): - # Equality compares the inner Dialect, not the Python class. - assert generic_dialect(**BUILTIN_CONFIG[PostgreSql]) == PostgreSql() - assert generic_dialect() != PostgreSql() - - def test_repr_lists_every_field(self): - dialect = generic_dialect(placeholder_sigil="$", bit_format="PrefixBigB") - text = repr(dialect) - assert text.startswith("GenericDialect(") - assert "placeholder_sigil='$'" in text - assert 'bit_format="PrefixBigB"' in text - assert "supports_array=False" in text - assert 'string_escape="Quote"' in text - - def test_methods_honor_overrides(self): - dialect = generic_dialect( - bool_format="Int", - string_format="PrefixBigE", - string_escape="BackslashHex", - bit_format="PrefixSmallB", - bytes_format="PrefixZeroX", - ) - assert dialect.format_boolean(True) == "1" - assert dialect.write_string_literal("a'b\n") == "E'a\\'b\\n'" - assert dialect.format_bits("10") == "b'10'" - assert dialect.format_bytes(b"\x00") == "0x00" diff --git a/tests/test_expr.py b/tests/test_expr.py index ee613cb..672f101 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -1,8 +1,8 @@ import pytest -from rapidquery.data_type import DataType from rapidquery.dialect import ( BigQuery, + Dialect, DuckDb, MsSql, MySql, @@ -11,7 +11,6 @@ Redshift, Snowflake, Spark, - Dialect, Sqlite, Teradata, ) @@ -54,7 +53,6 @@ def test_nodes_are_expr_subclasses(self): UnaryExpr("-", 1), BinaryExpr(1, "+", 2), IsExpr(Ident("id"), "NULL"), - IsNormalizedExpr(Ident("s")), IsDistinctFromExpr(1, 2), InListExpr(Ident("id"), [1]), BetweenExpr(Ident("id"), 0, 1), @@ -70,9 +68,9 @@ def test_nodes_are_expr_subclasses(self): for node in nodes: assert isinstance(node, Expr) - def test_render_succeeds_on_every_dialect(self, dialect_instance: Dialect): + def test_render_succeeds_on_every_dialect(self, dialect: Dialect): expr = BinaryExpr(Ident("id"), ">", 10) - sql = expr.to_sql(dialect_instance) + sql = expr.to_sql(dialect) assert "id" in sql assert "10" in sql @@ -81,22 +79,18 @@ class TestFunction: def test_new(self): fn = Function("now") assert fn.get_name() == Ident("now") - assert fn.get_distinct() is None - assert fn.get_args() is None + assert fn.get_distinct() is False + assert fn.get_args() == () assert fn.get_filter() is None assert fn.get_null_treatment() is None fn = Function("example").filter(BinaryExpr(1, ">", 2)) assert fn.get_name() == Ident("example") - assert fn.get_distinct() is None - assert fn.get_args() is None + assert fn.get_distinct() is False + assert fn.get_args() == () assert isinstance(fn.get_filter(), BinaryExpr) - fn = ( - Function("count") - .call(Ident("id"), distinct=False) - .filter(BinaryExpr(1, ">", 2)) - ) + fn = Function("count", Ident("id"), distinct=False).filter(BinaryExpr(1, ">", 2)) assert fn.get_name() == Ident("count") assert fn.get_distinct() is False assert fn.get_args() == (Ident("id"),) @@ -109,94 +103,53 @@ def test_new(self): assert fn.get_name() == Ident("schema", "fn") bare = Function("now") - called = bare.call() - assert called is not bare - assert called.get_args() == () - assert bare.get_args() is None + with_arg = Function("now", 1) + assert with_arg is not bare + assert with_arg.get_args() == (Value(1),) + assert bare.get_args() == () def test_to_sql(self): - fn = Function("current_timestamp") - assert fn.to_sql(PostgreSql()) == '"current_timestamp"' + assert Function("current_timestamp").to_sql(PostgreSql) == '"current_timestamp"()' + assert Function(Unquote("CURRENT_TIMESTAMP")).to_sql(PostgreSql) == "CURRENT_TIMESTAMP()" + assert Function(Unquote("NOW")).to_sql(PostgreSql) == "NOW()" - fn = Function(Unquote("CURRENT_TIMESTAMP")) - assert fn.to_sql(PostgreSql()) == "CURRENT_TIMESTAMP" + fn = Function("count", Asterisk).filter(BinaryExpr(Ident("status"), "=", "completed")) + assert fn.to_sql(PostgreSql) == '"count"(*) FILTER (WHERE "status" = E\'completed\')' - fn = Function(Unquote("NOW")).call() - assert fn.to_sql(PostgreSql()) == "NOW()" + fn = Function("count", Ident("id"), distinct=False).filter(BinaryExpr(Ident("status"), "=", "completed")) + assert fn.to_sql(PostgreSql) == '"count"("id") FILTER (WHERE "status" = E\'completed\')' - fn = Function(Unquote("NOW")) - assert fn.to_sql(PostgreSql()) == "NOW" + fn = Function("count", Ident("id"), distinct=True).filter(BinaryExpr(Ident("status"), "=", "completed")) + assert fn.to_sql(PostgreSql) == '"count"(DISTINCT "id") FILTER (WHERE "status" = E\'completed\')' - fn = ( - Function("count") - .call(Asterisk) - .filter(BinaryExpr(Ident("status"), "=", "completed")) - ) - assert ( - fn.to_sql(PostgreSql()) - == '"count"(*) FILTER (WHERE "status" = E\'completed\')' + assert Function("now", distinct=True).to_sql(PostgreSql) == '"now"()' + assert Function("custom", 1, 2, Ident("id"), distinct=True).to_sql(PostgreSql) == ( + '"custom"(DISTINCT 1, 2, "id")' ) - fn = ( - Function("count") - .call(Ident("id"), distinct=False) - .filter(BinaryExpr(Ident("status"), "=", "completed")) - ) - assert ( - fn.to_sql(PostgreSql()) - == '"count"(ALL "id") FILTER (WHERE "status" = E\'completed\')' - ) - - fn = ( - Function("count") - .call(Ident("id"), distinct=True) - .filter(BinaryExpr(Ident("status"), "=", "completed")) - ) - assert ( - fn.to_sql(PostgreSql()) - == '"count"(DISTINCT "id") FILTER (WHERE "status" = E\'completed\')' - ) - - fn = Function("now").call(distinct=True) - assert fn.to_sql(PostgreSql()) == '"now"()' - - fn = Function("custom").call(1, 2, Ident("id"), distinct=True) - assert fn.to_sql(PostgreSql()) == '"custom"(DISTINCT 1, 2, "id")' - - fn = Function("lag").call(Ident("x")).ignore_nulls() - assert fn.to_sql(PostgreSql()) == '"lag"("x") IGNORE NULLS' - assert fn.to_sql(BigQuery()) == "`lag`(`x` IGNORE NULLS)" - assert fn.to_sql(DuckDb()) == '"lag"("x" IGNORE NULLS)' + fn = Function("lag", Ident("x")).ignore_nulls() + assert fn.to_sql(Oracle) == '"lag"("x") IGNORE NULLS' + assert fn.to_sql(BigQuery) == "`lag`(`x` IGNORE NULLS)" + assert fn.to_sql(DuckDb) == '"lag"("x" IGNORE NULLS)' assert fn.get_null_treatment() == "IGNORE NULLS" + with pytest.raises(RuntimeError, match="null_treatment"): + fn.to_sql(PostgreSql) - fn = Function("lag").call(Ident("x")).respect_nulls() - assert fn.to_sql(PostgreSql()) == '"lag"("x") RESPECT NULLS' - assert fn.to_sql(BigQuery()) == "`lag`(`x` RESPECT NULLS)" - assert fn.to_sql(DuckDb()) == '"lag"("x" RESPECT NULLS)' + fn = Function("lag", Ident("x")).respect_nulls() + assert fn.to_sql(Oracle) == '"lag"("x") RESPECT NULLS' + assert fn.to_sql(BigQuery) == "`lag`(`x` RESPECT NULLS)" + assert fn.to_sql(DuckDb) == '"lag"("x" RESPECT NULLS)' assert fn.get_null_treatment() == "RESPECT NULLS" - fn = Function("lag").call().ignore_nulls() - assert fn.to_sql(PostgreSql()) == '"lag"() IGNORE NULLS' - assert fn.to_sql(BigQuery()) == "`lag`(IGNORE NULLS)" - fn = Function("lag").ignore_nulls() - assert fn.to_sql(PostgreSql()) == '"lag" IGNORE NULLS' - assert fn.to_sql(BigQuery()) == "`lag` IGNORE NULLS" - - fn = ( - Function("count") - .call(Ident("id")) - .ignore_nulls() - .filter(BinaryExpr(Ident("status"), "=", "completed")) - ) - assert ( - fn.to_sql(PostgreSql()) - == '"count"("id") IGNORE NULLS FILTER (WHERE "status" = E\'completed\')' - ) - assert ( - fn.to_sql(BigQuery()) - == "`count`(`id` IGNORE NULLS) FILTER (WHERE `status` = 'completed')" - ) + assert fn.to_sql(Oracle) == '"lag"() IGNORE NULLS' + assert fn.to_sql(BigQuery) == "`lag`(IGNORE NULLS)" + + completed = BinaryExpr(Ident("status"), "=", "completed") + fn = Function("count", Ident("id")).ignore_nulls().filter(completed) + assert fn.to_sql(Oracle) == '"count"("id") FILTER (WHERE "status" = \'completed\') IGNORE NULLS' + assert fn.to_sql(BigQuery) == "`count`(`id` IGNORE NULLS WHERE `status` = 'completed')" + assert fn.to_sql(DuckDb) == '"count"("id" IGNORE NULLS) FILTER ("status" = \'completed\')' def test_edge_cases(self): with pytest.raises(TypeError): @@ -211,40 +164,37 @@ def test_edge_cases(self): with pytest.raises(TypeError, match="condition"): Function("now").filter(1) # pyright: ignore - fn = Function("f").call("id") + fn = Function("f", "id") assert fn.get_args() == (Value("id"),) - assert fn.to_sql(PostgreSql()) == "\"f\"('id')" + assert fn.to_sql(PostgreSql) == "\"f\"(E'id')" - fn = Function(Ident("schema.fn")).call() + fn = Function(Ident("schema.fn")) assert fn.get_name() == Ident("schema.fn") - assert fn.to_sql(PostgreSql()) == '"schema.fn"()' + assert fn.to_sql(PostgreSql) == '"schema.fn"()' - first = Function("f").call(1) - second = first.call(2, 3) + first = Function("f", 1) + second = Function("f", 2, 3) assert first.get_args() == (Value(1),) assert second.get_args() == (Value(2), Value(3)) filtered = Function("f").filter(BinaryExpr(1, ">", 0)) replaced = filtered.filter(BinaryExpr(2, ">", 0)) - assert filtered.to_sql(PostgreSql()) == '"f" FILTER (WHERE 1 > 0)' - assert replaced.to_sql(PostgreSql()) == '"f" FILTER (WHERE 2 > 0)' + assert filtered.to_sql(PostgreSql) == '"f"() FILTER (WHERE 1 > 0)' + assert replaced.to_sql(PostgreSql) == '"f"() FILTER (WHERE 2 > 0)' - ignored = Function("lag").call(Ident("x")).ignore_nulls() + ignored = Function("lag", Ident("x")).ignore_nulls() respected = ignored.respect_nulls() assert ignored.get_null_treatment() == "IGNORE NULLS" assert respected.get_null_treatment() == "RESPECT NULLS" - assert ignored.to_sql(PostgreSql()) == '"lag"("x") IGNORE NULLS' - assert respected.to_sql(PostgreSql()) == '"lag"("x") RESPECT NULLS' + assert ignored.to_sql(Oracle) == '"lag"("x") IGNORE NULLS' + assert respected.to_sql(Oracle) == '"lag"("x") RESPECT NULLS' - fn = Function("count").call(1).filter(BinaryExpr(Ident("x"), ">", 2)) - sql, params = fn.prepare(PostgreSql()) - assert sql == '"count"(:param_0) FILTER (WHERE "x" > :param_1)' - assert params == {"param_0": 1, "param_1": 2} + fn = Function("count", 1).filter(BinaryExpr(Ident("x"), ">", 2)) + sql, params = fn.prepare(PostgreSql) + assert sql == '"count"($1) FILTER (WHERE "x" > $2)' + assert params == (1, 2) - assert ( - repr(Function("lag").call(Ident("x")).ignore_nulls()) - == 'Function(Ident("lag")).call(Ident("x")).ignore_nulls()' - ) + assert repr(Function("lag", Ident("x")).ignore_nulls()) == ('Function(Ident("lag"), Ident("x")).ignore_nulls()') class TestTupleExpr: @@ -265,24 +215,22 @@ def test_new(self): def test_to_sql(self): tpl = TupleExpr(1, 2, "A") - assert tpl.to_sql(PostgreSql()) == "ROW(1, 2, 'A')" + assert tpl.to_sql(PostgreSql) == "ROW(1, 2, E'A')" - sql, params = tpl.prepare(PostgreSql()) - assert sql == "ROW(:param_0, :param_1, :param_2)" - assert params == {"param_0": 1, "param_1": 2, "param_2": "A"} + sql, params = tpl.prepare(PostgreSql) + assert sql == "ROW($1, $2, $3)" + assert params == (1, 2, "A") - assert TupleExpr().to_sql(PostgreSql()) == "ROW()" - assert TupleExpr(1).to_sql(PostgreSql()) == "ROW(1)" - assert ( - TupleExpr((1, 2), Ident("A")).to_sql(PostgreSql()) == 'ROW(ROW(1, 2), "A")' - ) + assert TupleExpr().to_sql(PostgreSql) == "ROW()" + assert TupleExpr(1).to_sql(PostgreSql) == "ROW(1)" + assert TupleExpr((1, 2), Ident("A")).to_sql(PostgreSql) == 'ROW(ROW(1, 2), "A")' - assert TupleExpr(1, 2).to_sql(Sqlite()) == "(1, 2)" + assert TupleExpr(1, 2).to_sql(Sqlite) == "(1, 2)" with pytest.raises(RuntimeError, match="at least two elements"): - TupleExpr(1).to_sql(Sqlite()) - assert TupleExpr(1, 2).to_sql(Spark()) == "struct(1, 2)" + TupleExpr(1).to_sql(Sqlite) + assert TupleExpr(1, 2).to_sql(Spark) == "struct(1, 2)" with pytest.raises(RuntimeError, match="tuple values are not supported"): - TupleExpr(1, 2).to_sql(BigQuery()) + TupleExpr(1, 2).to_sql(BigQuery) class TestUnaryExpr: @@ -315,48 +263,26 @@ def test_new(self): "|/", "?-", "?|", + "bang", ): - assert UnaryExpr(token, 1).get_operator() == token - - with pytest.raises(ValueError, match="unknown unary operator"): - UnaryExpr("bang", 1) + with pytest.raises(ValueError, match="unknown unary operator"): + UnaryExpr(token, 1) def test_to_sql(self): - un = UnaryExpr("-", Function("now").call()) - assert un.to_sql(PostgreSql()) == '-"now"()' - - assert UnaryExpr("NOT", 1).to_sql(PostgreSql()) == "NOT 1" - assert UnaryExpr("NOT", Ident("id")).to_sql(PostgreSql()) == 'NOT "id"' - assert UnaryExpr("+", 1).to_sql(PostgreSql()) == "+1" - assert UnaryExpr("~", Ident("id")).to_sql(PostgreSql()) == '~"id"' - assert UnaryExpr("-", UnaryExpr("-", 1)).to_sql(PostgreSql()) == "-(-1)" - assert ( - UnaryExpr("NOT", UnaryExpr("NOT", 1)).to_sql(PostgreSql()) == "NOT (NOT 1)" - ) - assert UnaryExpr("-", BinaryExpr(1, "+", 2)).to_sql(PostgreSql()) == "-(1 + 2)" - - sql, params = UnaryExpr("-", 1).prepare(PostgreSql()) - assert sql == "-:param_0" - assert params == {"param_0": 1} + un = UnaryExpr("-", Function("now")) + assert un.to_sql(PostgreSql) == '-"now"()' - assert UnaryExpr("|/", 9).to_sql(PostgreSql()) == "|/9" - assert UnaryExpr("||/", 27).to_sql(PostgreSql()) == "||/27" - assert UnaryExpr("!!", 1).to_sql(PostgreSql()) == "!!1" - assert UnaryExpr("@", 1).to_sql(PostgreSql()) == "@1" - assert UnaryExpr("!", 9).to_sql(PostgreSql()) == "9!" - assert UnaryExpr("#", Ident("path")).to_sql(PostgreSql()) == '# "path"' - assert UnaryExpr("@-@", Ident("path")).to_sql(PostgreSql()) == '@-@ "path"' - assert UnaryExpr("@@", Ident("path")).to_sql(PostgreSql()) == '@@ "path"' - assert UnaryExpr("?-", Ident("path")).to_sql(PostgreSql()) == '?- "path"' - assert UnaryExpr("?|", Ident("path")).to_sql(PostgreSql()) == '?| "path"' + assert UnaryExpr("NOT", 1).to_sql(PostgreSql) == "NOT 1" + assert UnaryExpr("NOT", Ident("id")).to_sql(PostgreSql) == 'NOT "id"' + assert UnaryExpr("+", 1).to_sql(PostgreSql) == "+1" + assert UnaryExpr("~", Ident("id")).to_sql(PostgreSql) == '~"id"' + assert UnaryExpr("-", UnaryExpr("-", 1)).to_sql(PostgreSql) == "-(-1)" + assert UnaryExpr("NOT", UnaryExpr("NOT", 1)).to_sql(PostgreSql) == "NOT (NOT 1)" + assert UnaryExpr("-", BinaryExpr(1, "+", 2)).to_sql(PostgreSql) == "-(1 + 2)" - assert UnaryExpr("!", UnaryExpr("-", 1)).to_sql(PostgreSql()) == "(-1)!" - assert UnaryExpr("-", UnaryExpr("!", 1)).to_sql(PostgreSql()) == "-(1!)" - assert UnaryExpr("!", UnaryExpr("!", 1)).to_sql(PostgreSql()) == "(1!)!" - - sql, params = UnaryExpr("|/", 9).prepare(PostgreSql()) - assert sql == "|/:param_0" - assert params == {"param_0": 9} + sql, params = UnaryExpr("-", 1).prepare(PostgreSql) + assert sql == "-$1" + assert params == (1,) class TestBinaryExpr: @@ -379,65 +305,53 @@ def test_new(self): bin = BinaryExpr(1, "Or", 2) assert bin.get_operator() == "OR" bin = BinaryExpr(1, "xor", 2) - assert bin.get_operator() == "XOR" + assert bin.get_operator() == "xor" def test_to_sql(self): bin = BinaryExpr(Ident("id"), ">", 10) - assert bin.to_sql(PostgreSql()) == '"id" > 10' + assert bin.to_sql(PostgreSql) == '"id" > 10' bin = BinaryExpr("str", "-custom-", 10) - assert bin.to_sql(PostgreSql()) == "'str' -custom- 10" + assert bin.to_sql(PostgreSql) == "E'str' -custom- 10" bin = BinaryExpr(Ident("id"), ">", BinaryExpr(456, "&", 10)) - assert bin.to_sql(PostgreSql()) == '"id" > 456 & 10' + assert bin.to_sql(PostgreSql) == '"id" > 456 & 10' bin = BinaryExpr(Ident("id"), ">", BinaryExpr(456, "CusTom", 10)) - assert bin.to_sql(PostgreSql()) == '"id" > (456 CusTom 10)' + assert bin.to_sql(PostgreSql) == '"id" > (456 CusTom 10)' bin = BinaryExpr(Ident("id"), ">", BinaryExpr(456, "<", 10)) - assert bin.to_sql(PostgreSql()) == '"id" > (456 < 10)' + assert bin.to_sql(PostgreSql) == '"id" > (456 < 10)' - assert BinaryExpr(1, "!=", 2).to_sql(PostgreSql()) == "1 <> 2" - assert BinaryExpr(1, "AND", 2).to_sql(PostgreSql()) == "1 AND 2" - assert ( - BinaryExpr(BinaryExpr(1, "+", 2), "AND", 3).to_sql(PostgreSql()) - == "1 + 2 AND 3" - ) - assert ( - BinaryExpr(1, "+", BinaryExpr(2, "AND", 3)).to_sql(PostgreSql()) - == "1 + (2 AND 3)" - ) + assert BinaryExpr(1, "!=", 2).to_sql(PostgreSql) == "1 <> 2" + assert BinaryExpr(1, "AND", 2).to_sql(PostgreSql) == "1 AND 2" + assert BinaryExpr(BinaryExpr(1, "+", 2), "AND", 3).to_sql(PostgreSql) == "1 + 2 AND 3" + assert BinaryExpr(1, "+", BinaryExpr(2, "AND", 3)).to_sql(PostgreSql) == "1 + (2 AND 3)" - sql, params = BinaryExpr(Ident("id"), ">", 10).prepare(PostgreSql()) - assert sql == '"id" > :param_0' - assert params == {"param_0": 10} + sql, params = BinaryExpr(Ident("id"), ">", 10).prepare(PostgreSql) + assert sql == '"id" > $1' + assert params == (10,) concat = BinaryExpr(1, "||", BinaryExpr(2, "+", 3)) - assert concat.to_sql(PostgreSql()) == "1 || (2 + 3)" - assert concat.to_sql(Sqlite()) == "1 || (2 + 3)" - assert concat.to_sql(MsSql()) == "1 + (2 + 3)" - assert concat.to_sql(MySql()) == "CONCAT(1, 2 + 3)" - assert ( - BinaryExpr(BinaryExpr(1, "||", 2), "||", 3).to_sql(MySql()) - == "CONCAT(CONCAT(1, 2), 3)" - ) - - assert BinaryExpr(1, "^", 2).to_sql(PostgreSql()) == "1 # 2" - assert BinaryExpr(1, "^", 2).to_sql(DuckDb()) == "xor(1, 2)" - assert BinaryExpr(1, "^", 2).to_sql(Snowflake()) == "BITXOR(1, 2)" - assert BinaryExpr(1, "^", 2).to_sql(MySql()) == "1 ^ 2" + assert concat.to_sql(PostgreSql) == "1 || (2 + 3)" + assert concat.to_sql(Sqlite) == "1 || (2 + 3)" + assert concat.to_sql(MsSql) == "1 + (2 + 3)" + assert concat.to_sql(MySql) == "CONCAT(1, 2 + 3)" + assert BinaryExpr(BinaryExpr(1, "||", 2), "||", 3).to_sql(MySql) == "CONCAT(CONCAT(1, 2), 3)" + + assert BinaryExpr(1, "^", 2).to_sql(PostgreSql) == "1 # 2" + assert BinaryExpr(1, "^", 2).to_sql(DuckDb) == "xor(1, 2)" + assert BinaryExpr(1, "^", 2).to_sql(Snowflake) == "BITXOR(1, 2)" + assert BinaryExpr(1, "^", 2).to_sql(MySql) == "1 ^ 2" with pytest.raises(RuntimeError, match="bitwise XOR is not supported"): - BinaryExpr(1, "^", 2).to_sql(Sqlite()) + BinaryExpr(1, "^", 2).to_sql(Sqlite) - assert BinaryExpr(5, "%", 2).to_sql(Oracle()) == "MOD(5, 2)" - assert BinaryExpr(5, "%", 2).to_sql(Teradata()) == "5 MOD 2" - assert BinaryExpr(5, "%", 2).to_sql(PostgreSql()) == "5 % 2" + assert BinaryExpr(5, "%", 2).to_sql(Oracle) == "MOD(5, 2)" + assert BinaryExpr(5, "%", 2).to_sql(Teradata) == "5 MOD 2" + assert BinaryExpr(5, "%", 2).to_sql(PostgreSql) == "5 % 2" def test_repr(self): - assert ( - repr(BinaryExpr(Ident("id"), ">", 10)) - == 'BinaryExpr(Ident("id"), ">", Value(10, IntegerSerializer()))' - ) + assert repr(BinaryExpr(Ident("id"), ">", 10)) == 'BinaryExpr(Ident("id"), ">", Value(10, IntegerSerializer()))' class TestIsExpr: @@ -457,31 +371,28 @@ def test_new(self): def test_to_sql(self): is_ = IsExpr(Ident("id"), "NUll") - assert is_.to_sql(PostgreSql()) == '"id" IS NULL' + assert is_.to_sql(PostgreSql) == '"id" IS NULL' is_ = IsExpr(Ident("id"), "FalSE") - assert is_.to_sql(PostgreSql()) == '"id" IS FALSE' + assert is_.to_sql(PostgreSql) == '"id" IS FALSE' is_ = IsExpr(Ident("id"), "tRuE") - assert is_.to_sql(PostgreSql()) == '"id" IS TRUE' + assert is_.to_sql(PostgreSql) == '"id" IS TRUE' is_ = IsExpr(Ident("id"), "unknown", negated=True) - assert is_.to_sql(PostgreSql()) == '"id" IS NOT UNKNOWN' + assert is_.to_sql(PostgreSql) == '"id" IS NOT UNKNOWN' - sql, params = IsExpr(Ident("id"), "NULL").prepare(PostgreSql()) + sql, params = IsExpr(Ident("id"), "NULL").prepare(PostgreSql) assert sql == '"id" IS NULL' - assert params == {} + assert params == () - sql, params = IsExpr(1, "TRUE").prepare(PostgreSql()) - assert sql == ":param_0 IS TRUE" - assert params == {"param_0": 1} + sql, params = IsExpr(1, "TRUE").prepare(PostgreSql) + assert sql == "$1 IS TRUE" + assert params == (1,) def test_repr(self): assert repr(IsExpr(Ident("id"), "NULL")) == 'IsExpr(Ident("id"), "NULL")' - assert ( - repr(IsExpr(Ident("id"), "NULL", negated=True)) - == 'IsExpr(Ident("id"), "NULL", negated=True)' - ) + assert repr(IsExpr(Ident("id"), "NULL", negated=True)) == 'IsExpr(Ident("id"), "NULL", negated=True)' class TestIsDistinctFromExpr: @@ -507,24 +418,21 @@ def test_new(self): def test_to_sql(self): idf = IsDistinctFromExpr(2, 3) - assert idf.to_sql(PostgreSql()) == "2 IS DISTINCT FROM 3" + assert idf.to_sql(PostgreSql) == "2 IS DISTINCT FROM 3" idf = IsDistinctFromExpr(Ident("id"), 10, negated=True) - assert idf.to_sql(PostgreSql()) == '"id" IS NOT DISTINCT FROM 10' + assert idf.to_sql(PostgreSql) == '"id" IS NOT DISTINCT FROM 10' - sql, params = idf.prepare(PostgreSql()) - assert sql == '"id" IS NOT DISTINCT FROM :param_0' - assert params == {"param_0": 10} + sql, params = idf.prepare(PostgreSql) + assert sql == '"id" IS NOT DISTINCT FROM $1' + assert params == (10,) - assert ( - IsDistinctFromExpr(Ident("a"), Ident("b")).to_sql(PostgreSql()) - == '"a" IS DISTINCT FROM "b"' - ) + assert IsDistinctFromExpr(Ident("a"), Ident("b")).to_sql(PostgreSql) == '"a" IS DISTINCT FROM "b"' - assert IsDistinctFromExpr(1, 2).to_sql(MySql()) == "NOT (1 <=> 2)" - assert IsDistinctFromExpr(1, 2, negated=True).to_sql(MySql()) == "1 <=> 2" + assert IsDistinctFromExpr(1, 2).to_sql(MySql) == "NOT (1 <=> 2)" + assert IsDistinctFromExpr(1, 2, negated=True).to_sql(MySql) == "1 <=> 2" with pytest.raises(RuntimeError, match="IS DISTINCT FROM is not supported"): - IsDistinctFromExpr(1, 2).to_sql(Oracle()) + IsDistinctFromExpr(1, 2).to_sql(Oracle) class TestInListExpr: @@ -547,25 +455,23 @@ def test_new(self): def test_to_sql(self): lst = InListExpr(Ident("id"), [1, 2, 3]) - assert lst.to_sql(PostgreSql()) == '"id" IN (1, 2, 3)' + assert lst.to_sql(PostgreSql) == '"id" IN (1, 2, 3)' - sql, params = lst.prepare(PostgreSql()) - assert sql == '"id" IN (:param_0, :param_1, :param_2)' - assert params == {"param_0": 1, "param_1": 2, "param_2": 3} + sql, params = lst.prepare(PostgreSql) + assert sql == '"id" IN ($1, $2, $3)' + assert params == (1, 2, 3) - assert InListExpr(Ident("id"), []).to_sql(PostgreSql()) == "FALSE" - assert InListExpr(Ident("id"), [], negated=True).to_sql(PostgreSql()) == "TRUE" - assert InListExpr(Ident("id"), [], negated=True).to_sql(MySql()) == "1" - assert InListExpr(Ident("id"), [1], negated=True).to_sql(PostgreSql()) == ( - '"id" NOT IN (1)' - ) - assert InListExpr(Ident("id"), [1, 2]).to_sql(MsSql()) == "([id] IN (1, 2))" + assert InListExpr(Ident("id"), []).to_sql(PostgreSql) == "FALSE" + assert InListExpr(Ident("id"), [], negated=True).to_sql(PostgreSql) == "TRUE" + assert InListExpr(Ident("id"), [], negated=True).to_sql(MySql) == "1" + assert InListExpr(Ident("id"), [1], negated=True).to_sql(PostgreSql) == ('"id" NOT IN (1)') + assert InListExpr(Ident("id"), [1, 2]).to_sql(MsSql) == "([id] IN (1, 2))" assert ( BinaryExpr( InListExpr(Ident("id"), [1]), "OR", InListExpr(Ident("id"), [2]), - ).to_sql(MsSql()) + ).to_sql(MsSql) == "([id] IN (1)) OR ([id] IN (2))" ) @@ -578,32 +484,26 @@ def test_new(self): assert bw.get_high() == Value(10) assert bw.get_negated() is False - bw = BetweenExpr( - Ident("id"), BinaryExpr(Ident("old"), "|", 0), 10, negated=True - ) + bw = BetweenExpr(Ident("id"), BinaryExpr(Ident("old"), "|", 0), 10, negated=True) assert bw.get_expr() == Ident("id") assert isinstance(bw.get_low(), BinaryExpr) assert bw.get_high() == Value(10) assert bw.get_negated() is True def test_to_sql(self): - bw = BetweenExpr( - Ident("id"), BinaryExpr(Ident("old"), "|", 0), 10, negated=True - ) - assert bw.to_sql(PostgreSql()) == '"id" NOT BETWEEN "old" | 0 AND 10' + bw = BetweenExpr(Ident("id"), BinaryExpr(Ident("old"), "|", 0), 10, negated=True) + assert bw.to_sql(PostgreSql) == '"id" NOT BETWEEN "old" | 0 AND 10' bw = BetweenExpr(Ident("id"), 1, 10) - assert BetweenExpr(Ident("id"), 1, 10, negated=True).to_sql(MsSql()) == ( - "([id] NOT BETWEEN 1 AND 10)" - ) - assert bw.to_sql(PostgreSql()) == '"id" BETWEEN 1 AND 10' + assert BetweenExpr(Ident("id"), 1, 10, negated=True).to_sql(MsSql) == ("([id] NOT BETWEEN 1 AND 10)") + assert bw.to_sql(PostgreSql) == '"id" BETWEEN 1 AND 10' bw = BetweenExpr(Ident("id"), BinaryExpr(1, "AND", 2), 10) - assert bw.to_sql(PostgreSql()) == '"id" BETWEEN (1 AND 2) AND 10' + assert bw.to_sql(PostgreSql) == '"id" BETWEEN (1 AND 2) AND 10' - sql, params = BetweenExpr(Ident("id"), 1, 10).prepare(PostgreSql()) - assert sql == '"id" BETWEEN :param_0 AND :param_1' - assert params == {"param_0": 1, "param_1": 10} + sql, params = BetweenExpr(Ident("id"), 1, 10).prepare(PostgreSql) + assert sql == '"id" BETWEEN $1 AND $2' + assert params == (1, 10) class TestLikeExpr: @@ -645,60 +545,39 @@ def test_new(self): def test_to_sql(self): like = LikeExpr(Ident("name"), "%a%") - assert like.to_sql(PostgreSql()) == "\"name\" LIKE '%a%'" + assert like.to_sql(PostgreSql) == "\"name\" LIKE E'%a%'" - assert ( - LikeExpr(Ident("name"), "%a%", case_sensitive=False).to_sql(PostgreSql()) - == "\"name\" ILIKE '%a%'" - ) - assert ( - LikeExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSql()) - == "\"name\" NOT LIKE '%a%'" - ) - assert ( - LikeExpr(Ident("name"), "%a%", escape="!").to_sql(PostgreSql()) - == "\"name\" LIKE '%a%' ESCAPE '!'" - ) + assert LikeExpr(Ident("name"), "%a%", case_sensitive=False).to_sql(PostgreSql) == "\"name\" ILIKE E'%a%'" + assert LikeExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSql) == "\"name\" NOT LIKE E'%a%'" + assert LikeExpr(Ident("name"), "%a%", escape="!").to_sql(PostgreSql) == "\"name\" LIKE E'%a%' ESCAPE E'!'" - sql, params = LikeExpr(Ident("name"), "%a%").prepare(PostgreSql()) - assert sql == '"name" LIKE :param_0' - assert params == {"param_0": "%a%"} + sql, params = LikeExpr(Ident("name"), "%a%").prepare(PostgreSql) + assert sql == '"name" LIKE $1' + assert params == ("%a%",) any_like = LikeExpr(Ident("name"), ["%a%", "%b%"]) - assert ( - any_like.to_sql(PostgreSql()) == "\"name\" LIKE ANY (ARRAY['%a%', '%b%'])" - ) - assert any_like.to_sql(Snowflake()) == "\"name\" LIKE ANY ('%a%', '%b%')" - assert any_like.to_sql(Sqlite()) == ( - "(\"name\" LIKE '%a%' OR \"name\" LIKE '%b%')" - ) + assert any_like.to_sql(PostgreSql) == "\"name\" LIKE ANY (ARRAY[E'%a%', E'%b%'])" + assert any_like.to_sql(Snowflake) == "\"name\" LIKE ANY ('%a%', '%b%')" + assert any_like.to_sql(Sqlite) == "(\"name\" LIKE '%a%' OR \"name\" LIKE '%b%')" - sql, params = any_like.prepare(PostgreSql()) - assert sql == '"name" LIKE ANY (ARRAY[:param_0, :param_1])' - assert params == {"param_0": "%a%", "param_1": "%b%"} + sql, params = any_like.prepare(PostgreSql) + assert sql == '"name" LIKE ANY (ARRAY[$1, $2])' + assert params == ("%a%", "%b%") negated = LikeExpr(Ident("name"), ["%a%", "%b%"], negated=True) - assert ( - negated.to_sql(PostgreSql()) - == "NOT (\"name\" LIKE ANY (ARRAY['%a%', '%b%']))" - ) - assert negated.to_sql(Snowflake()) == "\"name\" NOT LIKE ANY ('%a%', '%b%')" - assert negated.to_sql(Sqlite()) == ( - "(\"name\" NOT LIKE '%a%' AND \"name\" NOT LIKE '%b%')" - ) + assert negated.to_sql(PostgreSql) == "NOT (\"name\" LIKE ANY (ARRAY[E'%a%', E'%b%']))" + assert negated.to_sql(Snowflake) == "NOT (\"name\" LIKE ANY ('%a%', '%b%'))" + assert negated.to_sql(Sqlite) == "(\"name\" NOT LIKE '%a%' AND \"name\" NOT LIKE '%b%')" ilike_any = LikeExpr(Ident("name"), ["%a%", "%b%"], case_sensitive=False) - assert ( - ilike_any.to_sql(PostgreSql()) - == "\"name\" ILIKE ANY (ARRAY['%a%', '%b%'])" - ) + assert ilike_any.to_sql(PostgreSql) == "\"name\" ILIKE ANY (ARRAY[E'%a%', E'%b%'])" - escaped = LikeExpr(Ident("name"), ["%a%", "%b%"], escape="!") - assert ( - escaped.to_sql(PostgreSql()) - == "\"name\" LIKE ANY (ARRAY['%a%', '%b%']) ESCAPE '!'" + with pytest.raises(RuntimeError, match="LIKE ANY ESCAPE"): + LikeExpr(Ident("name"), ["%a%", "%b%"], escape="!").to_sql(PostgreSql) + assert LikeExpr(Ident("name"), ["%a%", "%b%"], escape="!").to_sql(Snowflake) == ( + "\"name\" LIKE ANY ('%a%', '%b%') ESCAPE '!'" ) - assert escaped.to_sql(Sqlite()) == ( + assert LikeExpr(Ident("name"), ["%a%", "%b%"], escape="!").to_sql(Sqlite) == ( "(\"name\" LIKE '%a%' ESCAPE '!' OR \"name\" LIKE '%b%' ESCAPE '!')" ) @@ -723,28 +602,24 @@ def test_new(self): def test_to_sql(self): sim = SimilarToExpr(Ident("name"), "%a%") - assert sim.to_sql(PostgreSql()) == "\"name\" SIMILAR TO '%a%'" - assert sim.to_sql(Redshift()) == "\"name\" SIMILAR TO '%a%'" - assert sim.to_sql(DuckDb()) == "\"name\" SIMILAR TO '%a%'" + assert sim.to_sql(PostgreSql) == "\"name\" SIMILAR TO E'%a%'" + assert sim.to_sql(Redshift) == "\"name\" SIMILAR TO '%a%'" + assert sim.to_sql(DuckDb) == "\"name\" SIMILAR TO '%a%'" - assert ( - SimilarToExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSql()) - == "\"name\" NOT SIMILAR TO '%a%'" + assert SimilarToExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSql) == ( + "\"name\" NOT SIMILAR TO E'%a%'" ) - sql, params = sim.prepare(PostgreSql()) - assert sql == '"name" SIMILAR TO :param_0' - assert params == {"param_0": "%a%"} + sql, params = sim.prepare(PostgreSql) + assert sql == '"name" SIMILAR TO $1' + assert params == ("%a%",) with pytest.raises(RuntimeError, match="SIMILAR TO is not supported"): - sim.to_sql(MySql()) + sim.to_sql(MySql) with pytest.raises(RuntimeError, match="SIMILAR TO is not supported"): - sim.to_sql(Sqlite()) + sim.to_sql(Sqlite) - assert ( - repr(sim) - == "SimilarToExpr(Ident(\"name\"), Value('%a%', StringSerializer()))" - ) + assert repr(sim) == "SimilarToExpr(Ident(\"name\"), Value('%a%', StringSerializer()))" assert ( repr(SimilarToExpr(Ident("name"), "%a%", negated=True)) == "SimilarToExpr(Ident(\"name\"), Value('%a%', StringSerializer()), " @@ -766,36 +641,27 @@ def test_new(self): def test_to_sql(self): rlike = RLikeExpr(Ident("name"), "%a%") - assert rlike.to_sql(PostgreSql()) == "\"name\" ~ '%a%'" - assert rlike.to_sql(MySql()) == "`name` RLIKE '%a%'" - assert rlike.to_sql(Spark()) == "`name` RLIKE '%a%'" - assert rlike.to_sql(Snowflake()) == "\"name\" RLIKE '%a%'" - assert rlike.to_sql(Oracle()) == "REGEXP_LIKE(\"name\", '%a%')" - assert rlike.to_sql(BigQuery()) == "REGEXP_CONTAINS(`name`, '%a%')" - assert rlike.to_sql(DuckDb()) == "regexp_matches(\"name\", '%a%')" + assert rlike.to_sql(PostgreSql) == "\"name\" ~ E'%a%'" + assert rlike.to_sql(MySql) == "`name` RLIKE '%a%'" + assert rlike.to_sql(Spark) == "`name` RLIKE '%a%'" + assert rlike.to_sql(Snowflake) == "\"name\" RLIKE '%a%'" + assert rlike.to_sql(Oracle) == "REGEXP_LIKE(\"name\", '%a%')" + assert rlike.to_sql(BigQuery) == "REGEXP_CONTAINS(`name`, '%a%')" + assert rlike.to_sql(DuckDb) == "regexp_matches(\"name\", '%a%')" - assert ( - RLikeExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSql()) - == "\"name\" !~ '%a%'" - ) - assert ( - RLikeExpr(Ident("name"), "%a%", negated=True).to_sql(Oracle()) - == "NOT REGEXP_LIKE(\"name\", '%a%')" - ) + assert RLikeExpr(Ident("name"), "%a%", negated=True).to_sql(PostgreSql) == "\"name\" !~ E'%a%'" + assert RLikeExpr(Ident("name"), "%a%", negated=True).to_sql(Oracle) == "NOT REGEXP_LIKE(\"name\", '%a%')" - sql, params = rlike.prepare(PostgreSql()) - assert sql == '"name" ~ :param_0' - assert params == {"param_0": "%a%"} + sql, params = rlike.prepare(PostgreSql) + assert sql == '"name" ~ $1' + assert params == ("%a%",) with pytest.raises(RuntimeError, match="RLIKE is not supported"): - rlike.to_sql(Sqlite()) + rlike.to_sql(Sqlite) with pytest.raises(RuntimeError, match="RLIKE is not supported"): - rlike.to_sql(MsSql()) + rlike.to_sql(MsSql) - assert ( - repr(rlike) - == "RLikeExpr(Ident(\"name\"), Value('%a%', StringSerializer()))" - ) + assert repr(rlike) == "RLikeExpr(Ident(\"name\"), Value('%a%', StringSerializer()))" assert ( repr(RLikeExpr(Ident("name"), "%a%", negated=True)) == "RLikeExpr(Ident(\"name\"), Value('%a%', StringSerializer()), " @@ -819,132 +685,68 @@ def test_new(self): assert cast.get_data_type_name() == "some_totally_unknown_type" assert cast.get_kind() == "CAST" - dt = DataType("integer") - cast = CastExpr(Ident("x"), dt, kind="SAFE_CAST") - assert cast.get_data_type_name() == dt.name - assert cast.get_kind() == "SAFE_CAST" - - formatted = CastExpr(Ident("s"), "DATE", format="%Y-%m-%d") - assert formatted.get_format() == Value("%Y-%m-%d") - assert formatted.get_at_time_zone() is None + cast = CastExpr(Ident("x"), "integer", kind="TRY_CAST") + assert cast.get_data_type_name() == "INTEGER" + assert cast.get_kind() == "TRY_CAST" - tz = CastExpr(Ident("s"), "TIMESTAMP", format="%Y-%m-%d", at_time_zone="UTC") - assert tz.get_format() == Value("%Y-%m-%d") - assert tz.get_at_time_zone() == Value("UTC") + cast = CastExpr(Ident("x"), "integer", kind="::") + assert cast.get_kind() == "::" - with pytest.raises(ValueError, match="AT TIME ZONE requires FORMAT"): - CastExpr(Ident("s"), "TIMESTAMP", at_time_zone="UTC") + with pytest.raises(ValueError, match="unknown cast kind"): + CastExpr(Ident("x"), "integer", kind="SAFE_CAST") def test_to_sql(self): - assert CastExpr(Ident("id"), "int").to_sql(PostgreSql()) == 'CAST("id" AS INT)' - assert ( - CastExpr(Ident("id"), "int", kind="SAFE_CAST").to_sql(PostgreSql()) - == 'SAFE_CAST("id" AS INT)' - ) - assert ( - CastExpr(Ident("id"), "int", kind="TRY_CAST").to_sql(PostgreSql()) - == 'TRY_CAST("id" AS INT)' - ) - assert CastExpr(Ident("id"), "int").to_sql(PostgreSql()) == 'CAST("id" AS INT)' - assert CastExpr(1, "varchar(255)").to_sql(PostgreSql()) == ( - "CAST(1 AS varchar(255))" - ) - assert CastExpr(Ident("id"), "INT ARRAY").to_sql(PostgreSql()) == ( - 'CAST("id" AS INT ARRAY)' - ) - assert ( - CastExpr(Ident("s"), "DATE", format="%Y-%m-%d").to_sql(PostgreSql()) - == "CAST(\"s\" AS DATE FORMAT '%Y-%m-%d')" - ) - assert ( - CastExpr( - Ident("s"), - "TIMESTAMP", - kind="SAFE_CAST", - format="%Y-%m-%d", - at_time_zone="UTC", - ).to_sql(PostgreSql()) - == "SAFE_CAST(\"s\" AS TIMESTAMP FORMAT '%Y-%m-%d' AT TIME ZONE 'UTC')" - ) + assert CastExpr(Ident("id"), "int").to_sql(PostgreSql) == 'CAST("id" AS INT)' + assert CastExpr(1, "varchar(255)").to_sql(PostgreSql) == "CAST(1 AS varchar(255))" + assert CastExpr(Ident("id"), "INT ARRAY").to_sql(PostgreSql) == 'CAST("id" AS INT ARRAY)' - sql, params = CastExpr(1, "INT").prepare(PostgreSql()) - assert sql == "CAST(:param_0 AS INT)" - assert params == {"param_0": 1} + with pytest.raises(RuntimeError, match="try cast"): + CastExpr(Ident("id"), "int", kind="TRY_CAST").to_sql(PostgreSql) + assert CastExpr(Ident("id"), "int", kind="TRY_CAST").to_sql(BigQuery) == "SAFE_CAST(`id` AS INT)" + assert CastExpr(Ident("id"), "int", kind="TRY_CAST").to_sql(Teradata) == 'TRYCAST("id" AS INT)' + assert CastExpr(Ident("id"), "int", kind="::").to_sql(DuckDb) == '("id")::INT' - sql, params = CastExpr("s", "DATE", format="%Y-%m-%d").prepare(PostgreSql()) - assert sql == "CAST(:param_0 AS DATE FORMAT :param_1)" - assert params == {"param_0": "s", "param_1": "%Y-%m-%d"} + sql, params = CastExpr(1, "INT").prepare(PostgreSql) + assert sql == "CAST($1 AS INT)" + assert params == (1,) assert repr(CastExpr(Ident("id"), "int")) == 'CastExpr(Ident("id"), "INT")' - assert ( - repr(CastExpr(Ident("id"), "int", kind="TRY_CAST")) - == 'CastExpr(Ident("id"), "INT", kind="TRY_CAST")' - ) - assert ( - repr(CastExpr(Ident("s"), "DATE", format="%Y-%m-%d")) - == 'CastExpr(Ident("s"), "DATE", ' - "format=Value('%Y-%m-%d', StringSerializer()))" - ) + assert repr(CastExpr(Ident("id"), "int", kind="TRY_CAST")) == 'CastExpr(Ident("id"), "INT", kind="TRY_CAST")' class TestTrimExpr: def test_new(self): trim = TrimExpr(Ident("s")) assert trim.get_expr() == Ident("s") - assert trim.get_where() is None - assert trim.get_what() is None + assert trim.get_where() == "BOTH" assert trim.get_characters() is None - trim = TrimExpr(Ident("s"), where="leading", what="x") + trim = TrimExpr(Ident("s"), where="leading", characters="x") assert trim.get_where() == "LEADING" - assert trim.get_what() == Value("x") - assert trim.get_characters() is None + assert trim.get_characters() == Value("x") - trim = TrimExpr(Ident("s"), characters=["a", Ident("b")]) - assert trim.get_characters() == (Value("a"), Ident("b")) + trim = TrimExpr(Ident("s"), characters=Ident("b")) + assert trim.get_characters() == Ident("b") with pytest.raises(ValueError, match="unknown TRIM where"): TrimExpr(Ident("s"), where="MIDDLE") - with pytest.raises(ValueError, match="empty"): - TrimExpr(Ident("s"), characters=[]) - with pytest.raises(ValueError, match="cannot both be set"): - TrimExpr(Ident("s"), what="x", characters=["y"]) - with pytest.raises(TypeError, match="list or tuple"): - TrimExpr(Ident("s"), characters="xy") # pyright: ignore + with pytest.raises(TypeError): + TrimExpr(Ident("s"), what="x") # pyright: ignore def test_to_sql(self): - assert TrimExpr(Ident("s")).to_sql(PostgreSql()) == 'TRIM("s")' - assert TrimExpr(Ident("s"), where="LEADING").to_sql(PostgreSql()) == ( - 'TRIM(LEADING FROM "s")' - ) - assert TrimExpr(Ident("s"), what="x").to_sql(PostgreSql()) == ( - "TRIM('x' FROM \"s\")" - ) - assert TrimExpr(Ident("s"), where="BOTH", what="x").to_sql(PostgreSql()) == ( - "TRIM(BOTH 'x' FROM \"s\")" - ) - assert TrimExpr(Ident("s"), characters=["a", "b"]).to_sql(PostgreSql()) == ( - "TRIM(\"s\", 'a', 'b')" - ) - - sql, params = TrimExpr(Ident("s"), what="x").prepare(PostgreSql()) - assert sql == 'TRIM(:param_0 FROM "s")' - assert params == {"param_0": "x"} + assert TrimExpr(Ident("s")).to_sql(PostgreSql) == 'TRIM("s")' + assert TrimExpr(Ident("s"), where="LEADING").to_sql(PostgreSql) == 'TRIM(LEADING FROM "s")' + assert TrimExpr(Ident("s"), characters="x").to_sql(PostgreSql) == "TRIM(E'x' FROM \"s\")" + assert TrimExpr(Ident("s"), where="BOTH", characters="x").to_sql(PostgreSql) == "TRIM(E'x' FROM \"s\")" + assert TrimExpr(Ident("s"), where="LEADING", characters="x").to_sql(Sqlite) == "LTRIM(\"s\", 'x')" - sql, params = TrimExpr(Ident("s"), characters=["a", "b"]).prepare(PostgreSql()) - assert sql == 'TRIM("s", :param_0, :param_1)' - assert params == {"param_0": "a", "param_1": "b"} + sql, params = TrimExpr(Ident("s"), characters="x").prepare(PostgreSql) + assert sql == 'TRIM($1 FROM "s")' + assert params == ("x",) assert repr(TrimExpr(Ident("s"))) == 'TrimExpr(Ident("s"))' - assert ( - repr(TrimExpr(Ident("s"), where="BOTH", what="x")) - == 'TrimExpr(Ident("s"), where="BOTH", ' - "what=Value('x', StringSerializer()))" - ) - assert ( - repr(TrimExpr(Ident("s"), characters=["a", "b"])) - == 'TrimExpr(Ident("s"), characters=[' - "Value('a', StringSerializer()), Value('b', StringSerializer())])" + assert repr(TrimExpr(Ident("s"), where="LEADING", characters="x")) == ( + 'TrimExpr(Ident("s"), where="LEADING", characters=Value(\'x\', StringSerializer()))' ) @@ -965,30 +767,16 @@ def test_new(self): assert col.get_collation() == Ident("pg_catalog", "default") def test_to_sql(self): - assert CollateExpr(Ident("name"), "en_US").to_sql(PostgreSql()) == ( - '"name" COLLATE "en_US"' - ) - assert CollateExpr(Ident("name"), Unquote("C")).to_sql(PostgreSql()) == ( - '"name" COLLATE C' - ) + assert CollateExpr(Ident("name"), "en_US").to_sql(PostgreSql) == ('"name" COLLATE "en_US"') + assert CollateExpr(Ident("name"), Unquote("C")).to_sql(PostgreSql) == ('"name" COLLATE C') assert ( - CollateExpr(Ident("name"), Ident("pg_catalog", "default")).to_sql( - PostgreSql() - ) + CollateExpr(Ident("name"), Ident("pg_catalog", "default")).to_sql(PostgreSql) == '"name" COLLATE "pg_catalog"."default"' ) - assert CollateExpr(BinaryExpr(1, "+", 2), "en_US").to_sql(PostgreSql()) == ( - '(1 + 2) COLLATE "en_US"' - ) - assert ( - BinaryExpr(CollateExpr(Ident("a"), "c"), "+", 1).to_sql(PostgreSql()) - == '"a" COLLATE "c" + 1' - ) + assert CollateExpr(BinaryExpr(1, "+", 2), "en_US").to_sql(PostgreSql) == ('(1 + 2) COLLATE "en_US"') + assert BinaryExpr(CollateExpr(Ident("a"), "c"), "+", 1).to_sql(PostgreSql) == '"a" COLLATE "c" + 1' - assert ( - repr(CollateExpr(Ident("name"), "en_US")) - == 'CollateExpr(Ident("name"), Ident("en_US"))' - ) + assert repr(CollateExpr(Ident("name"), "en_US")) == 'CollateExpr(Ident("name"), Ident("en_US"))' class TestCaseExpr: @@ -1026,27 +814,17 @@ def test_new(self): CaseExpr().when(1, 2) # pyright: ignore def test_to_sql(self): - assert CaseExpr().to_sql(PostgreSql()) == "CASE END" + assert CaseExpr().to_sql(PostgreSql) == "CASE END" searched = CaseExpr().when(BinaryExpr(Ident("id"), ">", 0), "yes").else_("no") - assert ( - searched.to_sql(PostgreSql()) - == "CASE WHEN \"id\" > 0 THEN 'yes' ELSE 'no' END" - ) + assert searched.to_sql(PostgreSql) == "CASE WHEN \"id\" > 0 THEN E'yes' ELSE E'no' END" - simple = ( - CaseExpr(Ident("status")) - .when(BinaryExpr(Ident("status"), "=", "open"), 1) - .else_(0) - ) - assert ( - simple.to_sql(PostgreSql()) - == 'CASE "status" WHEN "status" = E\'open\' THEN 1 ELSE 0 END' - ) + simple = CaseExpr(Ident("status")).when(BinaryExpr(Ident("status"), "=", "open"), 1).else_(0) + assert simple.to_sql(PostgreSql) == 'CASE "status" WHEN "status" = E\'open\' THEN 1 ELSE 0 END' - sql, params = searched.prepare(PostgreSql()) - assert sql == 'CASE WHEN "id" > :param_0 THEN :param_1 ELSE :param_2 END' - assert params == {"param_0": 0, "param_1": "yes", "param_2": "no"} + sql, params = searched.prepare(PostgreSql) + assert sql == 'CASE WHEN "id" > $1 THEN $2 ELSE $3 END' + assert params == (0, "yes", "no") class TestCustomExpr: @@ -1060,21 +838,21 @@ def test_new(self): assert custom.get_args() == (Ident("a"), Value(1)) def test_to_sql(self): - assert CustomExpr("1 = 1").to_sql(PostgreSql()) == "1 = 1" - assert CustomExpr("{} = {}", Ident("a"), 1).to_sql(PostgreSql()) == '"a" = 1' - assert CustomExpr("{{x}}").to_sql(PostgreSql()) == "{x}" - assert CustomExpr("a {{}} b").to_sql(PostgreSql()) == "a {} b" - assert CustomExpr("fn({})", Ident("id")).to_sql(PostgreSql()) == 'fn("id")' + assert CustomExpr("1 = 1").to_sql(PostgreSql) == "1 = 1" + assert CustomExpr("{} = {}", Ident("a"), 1).to_sql(PostgreSql) == '"a" = 1' + assert CustomExpr("{{x}}").to_sql(PostgreSql) == "{x}" + assert CustomExpr("a {{}} b").to_sql(PostgreSql) == "a {} b" + assert CustomExpr("fn({})", Ident("id")).to_sql(PostgreSql) == 'fn("id")' with pytest.raises(ValueError, match="placeholders"): - CustomExpr("a {} {}", 1).to_sql(PostgreSql()) + CustomExpr("a {} {}", 1).to_sql(PostgreSql) with pytest.raises(ValueError, match="placeholders"): - CustomExpr("a {}", 1, 2).to_sql(PostgreSql()) + CustomExpr("a {}", 1, 2).to_sql(PostgreSql) - sql, params = CustomExpr("x = {}", 1).prepare(PostgreSql()) - assert sql == "x = :param_0" - assert params == {"param_0": 1} + sql, params = CustomExpr("x = {}", 1).prepare(PostgreSql) + assert sql == "x = $1" + assert params == (1,) with pytest.raises(ValueError, match="placeholders"): - CustomExpr("{} {}", 1).prepare(PostgreSql()) + CustomExpr("{} {}", 1).prepare(PostgreSql) diff --git a/tests/test_ident.py b/tests/test_ident.py index 5d94f67..cba05ba 100644 --- a/tests/test_ident.py +++ b/tests/test_ident.py @@ -1,7 +1,38 @@ import pytest from rapidquery import Asterisk, Ident, Unquote -from rapidquery.dialect import MySql, PostgreSql, Dialect +from rapidquery.dialect import ( + BigQuery, + Databricks, + Dialect, + DuckDb, + Hive, + MsSql, + MySql, + Oracle, + PostgreSql, + Redshift, + Snowflake, + Spark, + Sqlite, + Teradata, +) + +_QUOTED_USERS_ID = { + BigQuery: "`users`.`id`", + Databricks: "`users`.`id`", + DuckDb: '"users"."id"', + Hive: "`users`.`id`", + MsSql: "[users].[id]", + MySql: "`users`.`id`", + Oracle: '"users"."id"', + PostgreSql: '"users"."id"', + Redshift: '"users"."id"', + Snowflake: '"users"."id"', + Spark: "`users`.`id`", + Sqlite: '"users"."id"', + Teradata: '"users"."id"', +} class TestIdentConstruction: @@ -16,7 +47,7 @@ def test_single_str_is_one_quoted_part_even_with_dots(self): assert len(ident) == 1 assert ident[0] == "schema.table" - assert ident.to_sql(PostgreSql()) == '"schema.table"' + assert ident.to_sql(PostgreSql) == '"schema.table"' def test_rejects_no_arguments(self): with pytest.raises(ValueError, match="empty"): @@ -34,7 +65,7 @@ def test_flattens_nested_ident(self): ident = Ident(Ident("t"), "id") assert ident.to_list() == ["t", "id"] - assert ident.to_sql(PostgreSql()) == '"t"."id"' + assert ident.to_sql(PostgreSql) == '"t"."id"' def test_too_many_parts(self): with pytest.raises(ValueError, match="too many"): @@ -50,22 +81,22 @@ def test_str_star_is_quoted_column(self): ident = Ident("*") assert len(ident) == 1 assert ident[0] == "*" - assert ident.to_sql(PostgreSql()) == '"*"' - assert ident.to_sql(MySql()) == "`*`" + assert ident.to_sql(PostgreSql) == '"*"' + assert ident.to_sql(MySql) == "`*`" def test_asterisk_class_is_wildcard(self): ident = Ident(Asterisk) assert ident[0] == Asterisk() - assert ident.to_sql(PostgreSql()) == "*" + assert ident.to_sql(PostgreSql) == "*" def test_asterisk_instance_is_wildcard(self): ident = Ident(Asterisk()) - assert ident.to_sql(MySql()) == "*" + assert ident.to_sql(MySql) == "*" def test_qualified_wildcard(self): ident = Ident("t", Asterisk) - assert ident.to_sql(PostgreSql()) == '"t".*' - assert ident.to_sql(MySql()) == "`t`.*" + assert ident.to_sql(PostgreSql) == '"t".*' + assert ident.to_sql(MySql) == "`t`.*" def test_asterisk_not_last_is_rejected(self): with pytest.raises(ValueError, match="asterisk"): @@ -76,11 +107,11 @@ class TestUnquote: def test_simple_name(self): part = Unquote("Users") assert part.name == "Users" - assert Ident(part).to_sql(PostgreSql()) == "Users" + assert Ident(part).to_sql(PostgreSql) == "Users" def test_mixed_with_quoted_parts(self): ident = Ident("public", Unquote("Users"), "id") - assert ident.to_sql(PostgreSql()) == '"public".Users."id"' + assert ident.to_sql(PostgreSql) == '"public".Users."id"' def test_equals_by_name(self): assert Unquote("id") == Unquote("id") @@ -136,15 +167,10 @@ def test_repr(self): class TestRender: - def test_to_sql_uses_dialect_quote_identifier(self, dialect_instance: Dialect): - ident = Ident("users", "id") - sql = ident.to_sql(dialect_instance) - assert sql == ( - f"{dialect_instance.quote_identifier('users')}." - f"{dialect_instance.quote_identifier('id')}" - ) + def test_to_sql_quotes_each_part(self, dialect: Dialect): + assert Ident("users", "id").to_sql(dialect) == _QUOTED_USERS_ID[dialect] def test_quoted_injection_stays_inside_delimiters(self): payload = "a; DROP TABLE t--" - sql = Ident(payload).to_sql(PostgreSql()) - assert sql == PostgreSql().quote_identifier(payload) + sql = Ident(payload).to_sql(PostgreSql) + assert sql == f'"{payload}"' diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 35ffa6d..9b70d7b 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -10,6 +10,20 @@ import pytest +from rapidquery.dialect import ( + BigQuery, + Dialect, + DuckDb, + Hive, + MsSql, + MySql, + Oracle, + PostgreSql, + Snowflake, + Spark, + Sqlite, + Teradata, +) from rapidquery.serializers import ( ArraySerializer, BitSerializer, @@ -28,34 +42,23 @@ UnspecifiedSerializer, UuidSerializer, ) -from rapidquery.dialect import ( - BigQuery, - DuckDb, - MsSql, - MySql, - Oracle, - PostgreSql, - Snowflake, - Dialect, - Sqlite, -) -from .conftest import ALL_DIALECT_NAMES, ALL_DIALECTS, USING_ORJSON +from .conftest import ALL_DIALECTS, USING_ORJSON -PG = PostgreSql() -BOOL_INT_NAMES = {"MsSql", "MySql", "Oracle", "Sqlite"} +PG = PostgreSql +BOOL_INT = (MsSql, MySql, Oracle, Sqlite) BIT_SQL = { - "PostgreSql": "B'10'", - "DuckDb": "B'10'", - "MySql": "b'10'", - "Teradata": "'10'B", + PostgreSql: "B'10'", + DuckDb: "B'10'", + MySql: "b'10'", + Teradata: "'10'B", } ARRAY_SQL = { - "PostgreSql": "ARRAY[1, 2, 3]", - "BigQuery": "ARRAY[1, 2, 3]", - "DuckDb": "[1, 2, 3]", - "Spark": "array(1, 2, 3)", - "Hive": "array(1, 2, 3)", + PostgreSql: "ARRAY[1, 2, 3]", + BigQuery: "ARRAY[1, 2, 3]", + DuckDb: "[1, 2, 3]", + Spark: "array(1, 2, 3)", + Hive: "array(1, 2, 3)", } @@ -108,10 +111,10 @@ def test_rejects_non_finite(self, value: float): FloatSerializer().serialize(PG, value) -@pytest.mark.parametrize("dialect", ALL_DIALECTS, ids=ALL_DIALECT_NAMES) +@pytest.mark.parametrize("dialect", ALL_DIALECTS) def test_boolean_spelling(dialect: Dialect): s = BooleanSerializer() - if type(dialect).__name__ in BOOL_INT_NAMES: + if dialect in BOOL_INT: assert s.serialize(dialect, True) == "1" assert s.serialize(dialect, False) == "0" else: @@ -125,7 +128,7 @@ def test_boolean_rejects_non_bool(value: typing.Any): BooleanSerializer().serialize(PG, value) -@pytest.mark.parametrize("dialect", ALL_DIALECTS, ids=ALL_DIALECT_NAMES) +@pytest.mark.parametrize("dialect", ALL_DIALECTS) def test_string_obrien_and_nul(dialect: Dialect): s = StringSerializer() sql = s.serialize(dialect, "O'Brien") @@ -149,8 +152,8 @@ def test_uuid_and_str(self): expected = f"E'{self._TEXT}'::uuid" assert s.serialize(PG, self._TEXT) == expected assert s.serialize(PG, uuid.UUID(self._TEXT)) == expected - assert s.serialize(DuckDb(), self._TEXT) == f"UUID '{self._TEXT}'" - assert s.serialize(Sqlite(), self._TEXT) == f"'{self._TEXT}'" + assert s.serialize(DuckDb, self._TEXT) == f"UUID '{self._TEXT}'" + assert s.serialize(Sqlite, self._TEXT) == f"'{self._TEXT}'" def test_invalid_str(self): with pytest.raises(ValueError): @@ -165,30 +168,26 @@ def test_bytes_pg_snowflake_duckdb(): dead = b"\xde\xad" assert BytesSerializer().serialize(PG, dead) == "'\\xDEAD'::bytea" assert BytesSerializer().serialize(PG, b"") == "'\\x'::bytea" - assert BytesSerializer().serialize(Snowflake(), dead) == "TO_BINARY('DEAD', 'HEX')" - assert BytesSerializer().serialize(Snowflake(), b"") == "TO_BINARY('', 'HEX')" - assert BytesSerializer().serialize(DuckDb(), dead) == "'\\xDE\\xAD'::BLOB" - assert BytesSerializer().serialize(DuckDb(), b"") == "''::BLOB" + assert BytesSerializer().serialize(Snowflake, dead) == "TO_BINARY('DEAD', 'HEX')" + assert BytesSerializer().serialize(Snowflake, b"") == "TO_BINARY('', 'HEX')" + assert BytesSerializer().serialize(DuckDb, dead) == "'\\xDE\\xAD'::BLOB" + assert BytesSerializer().serialize(DuckDb, b"") == "''::BLOB" -@pytest.mark.parametrize( - "value", [bytearray(b"abc"), memoryview(b"abc"), "abc", 1, None] -) +@pytest.mark.parametrize("value", [bytearray(b"abc"), memoryview(b"abc"), "abc", 1, None]) def test_bytes_rejects_non_exact_bytes(value: typing.Any): with pytest.raises(TypeError): BytesSerializer().serialize(PG, value) class TestBitSerializer: - def test_follows_bit_format_when_supported(self, dialect_instance: Dialect): - name = type(dialect_instance).__name__ - if name not in BIT_SQL: - with pytest.raises( - NotImplementedError, match="does not support BitSerializer" - ): - BitSerializer().serialize(dialect_instance, "10") + def test_follows_bit_format_when_supported(self, dialect: Dialect): + expected = BIT_SQL.get(dialect) + if expected is None: + with pytest.raises(NotImplementedError, match="does not support BitSerializer"): + BitSerializer().serialize(dialect, "10") return - assert BitSerializer().serialize(dialect_instance, "10") == BIT_SQL[name] + assert BitSerializer().serialize(dialect, "10") == expected def test_rejects_non_bit_string(self): with pytest.raises(ValueError): @@ -204,9 +203,9 @@ def test_dumps_then_json_format(self): s = JSONSerializer() assert s.serialize(PG, 10) == "E'10'" assert s.serialize(PG, "10") == "E'\"10\"'" - assert s.serialize(BigQuery(), {"a": 1}) == "JSON '{\"a\":1}'" - assert s.serialize(Snowflake(), {"a": 1}) == "PARSE_JSON('{\"a\":1}')" - assert s.serialize(Sqlite(), {"a": 1}) == "'{\"a\":1}'" + assert s.serialize(BigQuery, {"a": 1}) == "JSON '{\"a\":1}'" + assert s.serialize(Snowflake, {"a": 1}) == "PARSE_JSON('{\"a\":1}')" + assert s.serialize(Sqlite, {"a": 1}) == "'{\"a\":1}'" def test_orjson_only_values(self): s = JSONSerializer() @@ -233,8 +232,8 @@ class TestDateTimeSerializers: def test_date(self): value = datetime.date(2024, 1, 15) assert DateSerializer().serialize(PG, value) == "DATE E'2024-01-15'" - assert DateSerializer().serialize(Oracle(), value) == "DATE '2024-01-15'" - assert DateSerializer().serialize(Sqlite(), value) == "'2024-01-15'" + assert DateSerializer().serialize(Oracle, value) == "DATE '2024-01-15'" + assert DateSerializer().serialize(Sqlite, value) == "'2024-01-15'" def test_date_rejects_datetime_and_str(self): with pytest.raises(TypeError): @@ -245,10 +244,8 @@ def test_date_rejects_datetime_and_str(self): def test_time(self): s = TimeSerializer() assert s.serialize(PG, datetime.time(13, 45, 30)) == "TIME E'13:45:30'" - assert ( - s.serialize(PG, datetime.time(1, 2, 3, 456789)) == "TIME E'01:02:03.456789'" - ) - assert s.serialize(Oracle(), datetime.time(13, 45, 30)) == "'13:45:30'" + assert s.serialize(PG, datetime.time(1, 2, 3, 456789)) == "TIME E'01:02:03.456789'" + assert s.serialize(Oracle, datetime.time(13, 45, 30)) == "'13:45:30'" def test_time_rejects_date(self): with pytest.raises(TypeError): @@ -270,13 +267,10 @@ def test_datetime_naive_and_aware(self): assert s.serialize(PG, naive) == "TIMESTAMP E'2024-01-15 10:30:00'" assert s.serialize(PG, utc) == "TIMESTAMPTZ E'2024-01-15 10:30:00+00:00'" assert s.serialize(PG, offset) == "TIMESTAMPTZ E'2024-01-15 10:30:00-05:00'" - assert s.serialize(Oracle(), naive) == "TIMESTAMP '2024-01-15 10:30:00'" - assert ( - s.serialize(Oracle(), offset) - == "TIMESTAMP WITH TIMEZONE '2024-01-15 10:30:00-05:00'" - ) - assert s.serialize(Sqlite(), naive) == "'2024-01-15 10:30:00'" - assert s.serialize(Sqlite(), offset) == "'2024-01-15 10:30:00-05:00'" + assert s.serialize(Oracle, naive) == "TIMESTAMP '2024-01-15 10:30:00'" + assert s.serialize(Oracle, offset) == "TIMESTAMP WITH TIMEZONE '2024-01-15 10:30:00-05:00'" + assert s.serialize(Sqlite, naive) == "'2024-01-15 10:30:00'" + assert s.serialize(Sqlite, offset) == "'2024-01-15 10:30:00-05:00'" def test_datetime_unix_timestamp(self): s = DatetimeSerializer() @@ -295,13 +289,10 @@ def test_datetime_overflow(self): class TestInetSerializer: - def test_canonical_cidr(self, dialect_instance: Dialect): - name = type(dialect_instance).__name__ - if name not in {"PostgreSql", "DuckDb"}: - with pytest.raises( - NotImplementedError, match="does not support InetSerializer" - ): - InetSerializer().serialize(dialect_instance, "192.168.1.1") + def test_canonical_cidr(self, dialect: Dialect): + if dialect not in (PostgreSql, DuckDb): + with pytest.raises(NotImplementedError, match="does not support InetSerializer"): + InetSerializer().serialize(dialect, "192.168.1.1") return s = InetSerializer() cases = [ @@ -312,11 +303,11 @@ def test_canonical_cidr(self, dialect_instance: Dialect): ("2001:db8::/32", "2001:db8::/32"), ] for obj, inner in cases: - if name == "PostgreSql": + if dialect == PostgreSql: expected = f"E'{inner}'::inet" else: expected = f"'{inner}'::inet" - assert s.serialize(dialect_instance, obj) == expected + assert s.serialize(dialect, obj) == expected def test_invalid_address(self): with pytest.raises(ValueError): @@ -324,17 +315,15 @@ def test_invalid_address(self): class TestMacAddressSerializer: - def test_canonical_form(self, dialect_instance: Dialect): - if type(dialect_instance).__name__ != "PostgreSql": - with pytest.raises( - NotImplementedError, match="does not support MacAddressSerializer" - ): - MacAddressSerializer().serialize(dialect_instance, "01:23:45:67:89:ab") + def test_canonical_form(self, dialect: Dialect): + if dialect != PostgreSql: + with pytest.raises(NotImplementedError, match="does not support MacAddressSerializer"): + MacAddressSerializer().serialize(dialect, "01:23:45:67:89:ab") return s = MacAddressSerializer() expected = "E'01:23:45:67:89:AB'::macaddr" - assert s.serialize(dialect_instance, "01:23:45:67:89:ab") == expected - assert s.serialize(dialect_instance, "01-23-45-67-89-AB") == expected + assert s.serialize(dialect, "01:23:45:67:89:ab") == expected + assert s.serialize(dialect, "01-23-45-67-89-AB") == expected def test_invalid_address(self): with pytest.raises(ValueError): @@ -350,38 +339,26 @@ def test_uses_str(self): class TestArraySerializer: - def test_renders_array_constructor(self, dialect_instance: Dialect): + def test_renders_array_constructor(self, dialect: Dialect): s = ArraySerializer(IntegerSerializer()) - name = type(dialect_instance).__name__ - expected = ARRAY_SQL.get(name) + expected = ARRAY_SQL.get(dialect) if expected is None: - with pytest.raises( - NotImplementedError, match="does not support ArraySerializer" - ): - s.serialize(dialect_instance, [1, 2, 3]) + with pytest.raises(NotImplementedError, match="does not support ArraySerializer"): + s.serialize(dialect, [1, 2, 3]) return - assert s.serialize(dialect_instance, [1, 2, 3]) == expected - assert s.serialize(dialect_instance, (1, 2, 3)) == expected - empty = ( - expected.replace("1, 2, 3", "") - if "array(" in expected - else expected.replace("1, 2, 3", "") - ) - assert s.serialize(dialect_instance, []) == empty + assert s.serialize(dialect, [1, 2, 3]) == expected + assert s.serialize(dialect, (1, 2, 3)) == expected + empty = expected.replace("1, 2, 3", "") + assert s.serialize(dialect, []) == empty nested = ArraySerializer(ArraySerializer(IntegerSerializer())) - inner = expected - if name in {"PostgreSql", "BigQuery"}: - assert nested.serialize(dialect_instance, [[1, 2], [3]]) == ( - "ARRAY[ARRAY[1, 2], ARRAY[3]]" - ) - elif name == "DuckDb": - assert nested.serialize(dialect_instance, [[1, 2], [3]]) == "[[1, 2], [3]]" - elif name in {"Spark", "Hive"}: - assert nested.serialize(dialect_instance, [[1, 2], [3]]) == ( - "array(array(1, 2), array(3))" - ) + if dialect in (PostgreSql, BigQuery): + assert nested.serialize(dialect, [[1, 2], [3]]) == "ARRAY[ARRAY[1, 2], ARRAY[3]]" + elif dialect == DuckDb: + assert nested.serialize(dialect, [[1, 2], [3]]) == "[[1, 2], [3]]" + elif dialect in (Spark, Hive): + assert nested.serialize(dialect, [[1, 2], [3]]) == "array(array(1, 2), array(3))" else: - raise AssertionError(inner) + raise AssertionError(expected) def test_rejects_non_sequence_and_bad_element(self): s = ArraySerializer(IntegerSerializer()) @@ -392,12 +369,12 @@ def test_rejects_non_sequence_and_bad_element(self): def test_nested_unsupported_element(self): with pytest.raises(NotImplementedError, match="does not support BitSerializer"): - ArraySerializer(BitSerializer()).serialize(BigQuery(), ["10"]) + ArraySerializer(BitSerializer()).serialize(BigQuery, ["10"]) class TestCustomSerializer: def test_receives_dialect_instance(self): - dialect = DuckDb() + dialect = DuckDb assert CustomSerializer(_custom_ok).serialize(dialect, 1) == "CUSTOM:1" def test_must_return_str(self): @@ -407,15 +384,13 @@ def test_must_return_str(self): def test_unsupported_serializer_error_message(): with pytest.raises(NotImplementedError, match="does not support BitSerializer"): - BitSerializer().serialize(Sqlite(), "10") + BitSerializer().serialize(Sqlite, "10") with pytest.raises(NotImplementedError, match="does not support ArraySerializer"): - ArraySerializer(IntegerSerializer()).serialize(MySql(), [1, 2]) + ArraySerializer(IntegerSerializer()).serialize(MySql, [1, 2]) with pytest.raises(NotImplementedError, match="does not support InetSerializer"): - InetSerializer().serialize(Sqlite(), "192.168.1.1") - with pytest.raises( - NotImplementedError, match="does not support MacAddressSerializer" - ): - MacAddressSerializer().serialize(DuckDb(), "01:23:45:67:89:ab") + InetSerializer().serialize(Sqlite, "192.168.1.1") + with pytest.raises(NotImplementedError, match="does not support MacAddressSerializer"): + MacAddressSerializer().serialize(DuckDb, "01:23:45:67:89:ab") class TestLiteralGoldens: @@ -424,7 +399,7 @@ def test_pg_string_uses_e_prefix(self): assert StringSerializer().serialize(PG, "a\nb") == "E'a\\nb'" def test_mssql_nvarchar_prefix(self): - assert StringSerializer().serialize(MsSql(), "hi") == "N'hi'" + assert StringSerializer().serialize(MsSql, "hi") == "N'hi'" def test_pg_e_needed_for_backslash(self): assert StringSerializer().serialize(PG, "a\\b") == "E'a\\\\b'" diff --git a/tests/test_value.py b/tests/test_value.py index bbda65c..5239062 100644 --- a/tests/test_value.py +++ b/tests/test_value.py @@ -5,6 +5,8 @@ import pytest +from rapidquery.dialect import PostgreSql +from rapidquery.expr import Expr, Value from rapidquery.serializers import ( ArraySerializer, BitSerializer, @@ -23,8 +25,6 @@ UnspecifiedSerializer, UuidSerializer, ) -from rapidquery.dialect import PostgreSql -from rapidquery.expr import Expr, Value from tests.conftest import USING_ORJSON @@ -45,8 +45,8 @@ class InvalidEnum(enum.Enum): BytesSerializer: [b"bytes"], BitSerializer: [], JSONSerializer: [ - list(), # ruff: ignore - dict(), # ruff: ignore + [], + {}, {"OK": 1}, ], DateSerializer: [datetime.datetime.now(tz=datetime.UTC).date()], @@ -59,7 +59,7 @@ class InvalidEnum(enum.Enum): } if USING_ORJSON: - INFER_TEST_CASES[JSONSerializer].append({"OK": datetime.datetime.now()}) # ruff: ignore + INFER_TEST_CASES[JSONSerializer].append({"OK": datetime.datetime.now()}) INFER_TEST_CASES[JSONSerializer].append({"OK": uuid.uuid4()}) @@ -104,28 +104,20 @@ def test_wrap_trick_to_change_serializer(self): assert isinstance(inner.get_serializer(), InetSerializer) assert isinstance(outer.get_serializer(), UnspecifiedSerializer) - def test_data_type_is_not_a_serializer(self): - from rapidquery.data_type import DataType - - with pytest.raises(TypeError): - Value("x", DataType("integer")) - class TestValueToSql: def test_integer_literal(self): - assert Value(42).to_sql(PostgreSql()) == "42" + assert Value(42).to_sql(PostgreSql) == "42" def test_string_literal_matches_string_serializer(self): - assert Value("hi").to_sql(PostgreSql()) == StringSerializer().serialize( - PostgreSql(), "hi" - ) + assert Value("hi").to_sql(PostgreSql) == StringSerializer().serialize(PostgreSql, "hi") def test_none_is_null(self): - assert Value(None).to_sql(PostgreSql()) == "NULL" + assert Value(None).to_sql(PostgreSql) == "NULL" def test_propagates_serializer_type_error(self): with pytest.raises(TypeError): - Value("not an int", IntegerSerializer()).to_sql(PostgreSql()) + Value("not an int", IntegerSerializer()).to_sql(PostgreSql) class TestValueEquality: diff --git a/uv.lock b/uv.lock index f631b91..7eaeb51 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,83 @@ version = 1 revision = 3 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "ast-serialize" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/1c/7257e6ec9382843915ce475558ce4492ccb5ed39122c256bb369c27e2ebf/ast_serialize-0.12.1.tar.gz", hash = "sha256:5285a390caf1c44368ae270f037f797b91427d138b7d43cad0f1fda4c83518d9", size = 954408, upload-time = "2026-10-03T12:25:00.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/f7/e976169da322c009bb083a52d21e88fbfe5f071e1806e8c8361ab4ac477a/ast_serialize-0.12.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:e73255c9227fd74eac8a9b55c4049e8ad7b66d1f690bf827c98a86b2e594def7", size = 897232, upload-time = "2026-10-03T12:23:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/5545f6f4d38dd41b4e2a20050967ccd722508bc90fab0dfba463d8c8b994/ast_serialize-0.12.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4655ef993e69e01bb47d2d99647de9bbb74af03938438656832cd010d95de348", size = 1235397, upload-time = "2026-10-03T12:23:23.835Z" }, + { url = "https://files.pythonhosted.org/packages/22/19/e9b839ef9b57626e15e20dd7cf764a9a6b50f9750f86d0a49bc3a971fb72/ast_serialize-0.12.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bac5a99a2c91dd823be9b8c44645694fccbb0750773cc5b27889a9f6f22fce89", size = 1216331, upload-time = "2026-10-03T12:23:25.557Z" }, + { url = "https://files.pythonhosted.org/packages/26/2a/d054d4ff8ba42472a22e3da6eb6dee0e69a32c477b5077eefdbada99f554/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6485e681625ed7a094221f16a7ff2ef154946112266a05cf83bde50c959ef345", size = 1281194, upload-time = "2026-10-03T12:23:27.349Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/c73eddfa180a7a4c1613c0f3d3ef020b05dca9b922ac08212463c33ad11f/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a513bc6f60980d01767f7cbe39b17ce0373e722824a74ce28d6cea49ee3c8460", size = 1287170, upload-time = "2026-10-03T12:23:29.333Z" }, + { url = "https://files.pythonhosted.org/packages/94/77/39dc75d8b718844859b64a9067c9df0cfce218ca45ea215fb24a1fda3cf7/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:82866f3523d53ffca8d2a69a750bec52908b69f728012e40959bebce2620453c", size = 1531238, upload-time = "2026-10-03T12:23:30.954Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c0/6a6a6f94f45a288c4bac2eb8379a3d9654574a0f9249380ce3b07f6d64bb/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:243054a05a5190f5d087b5c8b16423e7f1cefa4991bac26e9c8ace18074b75b6", size = 1307154, upload-time = "2026-10-03T12:23:32.645Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/80f843892bd0f7c0d95ec5422ba3dc315c1ce011e6f08b06d5f71bd82c25/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9fbe5a3e8acddfc2fddff3dbbc7ea0e9798b3df3428f851b8abc52a3806f31", size = 1301506, upload-time = "2026-10-03T12:23:34.63Z" }, + { url = "https://files.pythonhosted.org/packages/df/cc/49a5fe852706f545e3e005584c5be89456bc637a8c9179aeaa8b9f26e8e4/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d3d516da3463071d27e64caf54d88cba25cf4ad4afcc807e0bcf67743719f03e", size = 1297616, upload-time = "2026-10-03T12:23:36.377Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/1208c91d6e00cc43cc276bd6233c40c9b4ec3ef8537c83281dd5372cbdb8/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d6711adf11136c77e3a35517de9488a5081d1012874fae99c2876b64f4daace", size = 1354884, upload-time = "2026-10-03T12:23:38.035Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/2b5fc912ff0be64d8d61ff5dc7dc405c6311297a0e2039b848b7d14333f2/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cbe239bee4bd609186daf60b95b7b0f47146c7f7f55f6da83807d747d6fe753f", size = 1457911, upload-time = "2026-10-03T12:23:39.679Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f8/d720429bf8933efbd0cc2038c0a50b6267a585d503500845c44bc6c8ff66/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5bbf582286c9dc6b4c544ef645dc99e4b3aa09db28892bc60344141f6926641f", size = 1563023, upload-time = "2026-10-03T12:23:41.585Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0a/99e6cc92bdbae5db60f84a14a0fb1ae77b6087e451d77808d87558162c9a/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99e33c93efb5254a70c525b46038212371dfe5693d48eb2d0d5f17d936a263d7", size = 1556601, upload-time = "2026-10-03T12:23:43.361Z" }, + { url = "https://files.pythonhosted.org/packages/7a/05/59de9e16a2e333da534f30776d0f5e426034b64c67c17843425e3cc827d1/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:aa6c17a2b7f07e81fa8cfcc4aa7c832b3e57733853aebea113ab502f9b0963db", size = 1665039, upload-time = "2026-10-03T12:23:45.257Z" }, + { url = "https://files.pythonhosted.org/packages/33/83/35ed67a127167b484b42a071df440f84b14c0d20ea8f69dbed5cc96bfd98/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f896fa38e0af38821e1ab1425c5dee89e359623e165765bdeae7d0eb6909e76d", size = 1472263, upload-time = "2026-10-03T12:23:46.811Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b0/3ab8613bbb690297f1bb687d780a248c486df0f4131b6a82044fcb49e438/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:af699e81fd7ce80b8b03945826d8ea23dd36d072f10d4613402da597ba4ee9c6", size = 1499242, upload-time = "2026-10-03T12:23:48.531Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a0/a28894d3b06f8775cea8989f371bd9f72ce562c32bc807770e7fb920ce17/ast_serialize-0.12.1-cp314-cp314t-win32.whl", hash = "sha256:10b59afc108eb285146acb23d1b5ec0fc58bb3c09cb2ab8876402df06c373c3b", size = 1119488, upload-time = "2026-10-03T12:23:50.419Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b2/0c44952f4ba4e14bb7f60a5858e2960dfefb9884e6ff007aaf64337dba5c/ast_serialize-0.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:72e871f6995a066c1b19104f8a6b5832b1163adb9a8267c2aa4711fbb0f4d1f3", size = 1157434, upload-time = "2026-10-03T12:23:52.383Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1e/cb594c63f46a01d53629af1c4f9e42cd02afcea1c2fe483e12f22743ebac/ast_serialize-0.12.1-cp314-cp314t-win_arm64.whl", hash = "sha256:3398e458047d21c9bc1b323fe5aab77c608dc9ddb65b2d44deebaff503a1f1eb", size = 1129094, upload-time = "2026-10-03T12:23:54.133Z" }, + { url = "https://files.pythonhosted.org/packages/16/05/ca16884f9498386f3646bb18be59f0e31d44e992d252d7d6f5e4f8ae1ee2/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:410233de149ab8414cb27c6fc73e9d2baa35d6f971672d540d752060d980ffb4", size = 1235686, upload-time = "2026-10-03T12:23:55.863Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/34e87ed30e292cf365523712c4bcfef1967d9c3c2749de21b1f93b1fe0f3/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:b9a2310845302f1a6bd45ae8a67d5760211103a8d66410b854bfa440d107e093", size = 1215598, upload-time = "2026-10-03T12:23:57.48Z" }, + { url = "https://files.pythonhosted.org/packages/f8/dc/c498f41c957b6ff31b97ed8ceccf3a84f85af7debca1125183cab95bb58b/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff65f40f49d5e1a1a043ba366081d59a4e26a9f5c1b07eb1170e172115da7ca", size = 1281911, upload-time = "2026-10-03T12:23:58.954Z" }, + { url = "https://files.pythonhosted.org/packages/dc/60/70ccefae9d88058c4c234bf0aed93f54aca36eb74087736e76e9515aee96/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:536d783c4d91331f094e0a892221e619be5ffbe6fb6640885f14d7f226ec90ca", size = 1287296, upload-time = "2026-10-03T12:24:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/08/e9/4fc697879c7128e29f9dab2ed19a9b586a56b621e5ea4aee2ae28c18e116/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c42d2d65f388d1960c5796231eb9bf5a988c46228633eb489605c4549ad16c52", size = 1532339, upload-time = "2026-10-03T12:24:02.053Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/9e2bd489731602a94dbd0c576ebe1cc487a2d0f6127be743f44711166f0d/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5628a12acc875fe7a167910f18d101dd101c2a7b1e6c2b6f7289ffaff25805c", size = 1308988, upload-time = "2026-10-03T12:24:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/3f/69/e9cae837bd766a66db6953ffb5fc7f04b1945e02b0a9e4c6a0b6acb08f17/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e855adfa5bb982b2e6fe09056b2d584f6dd4fce085d91a07d1155683751b6b5", size = 1303412, upload-time = "2026-10-03T12:24:05.62Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1e/5ef8c62d5031d93187ed0d8dade5d942de9920c3fbd678c7652362b9a7a2/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:fafe1471e8aca6c87b4913b7b54ff97197adf702fbe28692284b929dfa62ff96", size = 1298466, upload-time = "2026-10-03T12:24:07.242Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/25ded60844a1a437edc840e597b6f81daf91dc4a26035416e14298d3a091/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a5cac246474d2a703147d1605a6ac5ba0fa9e0a443cf1cf42513adf4df02686f", size = 1355617, upload-time = "2026-10-03T12:24:09.125Z" }, + { url = "https://files.pythonhosted.org/packages/05/68/a0d3cc8d8042208a2cbef7b26483f4941b44dd5dd717bb19f20e4a4c0d66/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6e25cd319fb0d7b39fcac666784ec86708ccbc78d07a698b1400cf5ed40c045b", size = 1458949, upload-time = "2026-10-03T12:24:10.772Z" }, + { url = "https://files.pythonhosted.org/packages/df/a0/5e4d355c48a9f125b8bec7b1b98d4d2dcd8324ff1d4dfcb03678a03c1414/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:657a7354ea16ed4d29f8127ed477c6fee3915c111d135f020ca835a991438e90", size = 1562328, upload-time = "2026-10-03T12:24:12.404Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/3d40f6a7908200f2f0ed9ce1bad130d00e06d0405baa7918d4a4299b25dd/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:9eb9de7e59621acdb3e66984f374272b33d56d15a04763e2fd0604211e1c8303", size = 1557751, upload-time = "2026-10-03T12:24:14.455Z" }, + { url = "https://files.pythonhosted.org/packages/3f/13/d53e5a7e299d6dbaeab23a424eea2c98c821b46ed7b05abbe14743beeb63/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:09cc4d3103c1fc97f6845ba307af1db9cde5226bef47f8843220dde83f2276ba", size = 1666238, upload-time = "2026-10-03T12:24:16.171Z" }, + { url = "https://files.pythonhosted.org/packages/37/5b/7638ee3ae35a64e4467160a37dc7565cddfe2a87f06cef2fd07c93cfd503/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:c9e2a592706fd791c2271ce9c8f4e38c98d3ea0b4a86b511e09a4fe3ac44ab37", size = 1472620, upload-time = "2026-10-03T12:24:18.053Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e7/6e9e621e0e4a3be5a9a5f8b6961982964d2367013dbe64feb5adaa43e56d/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ac042e95a575432c1730ca4cb9183066a2074886a599f46c1f1b0955fb8198", size = 1500141, upload-time = "2026-10-03T12:24:20.036Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/3217da5b671711c09cc6be580095839cad539983662a6599405bd75c1a19/ast_serialize-0.12.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:b3cd105995942cc6163a229674a86161ba1305f646493efd59bd6723a357ee14", size = 1118382, upload-time = "2026-10-03T12:24:21.599Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/6074cf29dca8ceff27d50e845c88e7a2eaaa7f0b6f3972909e878d844737/ast_serialize-0.12.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:a9cd24a26126088693ca054547ea0a391398a29cf1a3a2bec1009b4b6acc8b82", size = 1158034, upload-time = "2026-10-03T12:24:23.316Z" }, + { url = "https://files.pythonhosted.org/packages/92/a0/81ce428f9f3f1ca45f8b62c9711c30452bf8190476e8685cea0f72d8d008/ast_serialize-0.12.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:9649cd903db0dc047906c6dd740784a2ba665d54f7e43ba31457edbce76c9493", size = 1129437, upload-time = "2026-10-03T12:24:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d9/1c08adb90728607d0d07d188df4558ae863d688b4458087efe9fafeca458/ast_serialize-0.12.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:5ef62601db3ce5c23445132262a193075e211fb2fc87b46b7550dd351fac0976", size = 897419, upload-time = "2026-10-03T12:24:26.699Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/1eabd2c0673283054468b1c6cb539aeb877636d6c84b280279f2d7a177a9/ast_serialize-0.12.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:98d91cd3a6cb76a39512ee090a539d1e3206b732ad8150eb38918cffa1ddf515", size = 1241515, upload-time = "2026-10-03T12:24:28.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d7/c56955934a431a0fa3e4e9aa7af4a53ceab2a61241005427545208945eb4/ast_serialize-0.12.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8a32f184ce3e4b1d0b06d642a1243281cf55b99e0323680b1b8f904029fd7700", size = 1228048, upload-time = "2026-10-03T12:24:30.556Z" }, + { url = "https://files.pythonhosted.org/packages/1e/4e/2b2ca4602baf92f842316ea617423402089df4fbd2ea42571ba28725ba46/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e62126ac2be2d9340ac1b3ee7a0466a883ecbed634cff0929c88ca0b671483b7", size = 1292087, upload-time = "2026-10-03T12:24:32.45Z" }, + { url = "https://files.pythonhosted.org/packages/d9/49/9ebd05218a87ca31f4f855d5e3df14239bba3c58f2aed9d02c7cba5d94f5/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0f93a70fa9826c04ea9f2c3a880f87f4cca09a828144ed5084682abd28110980", size = 1295355, upload-time = "2026-10-03T12:24:34.423Z" }, + { url = "https://files.pythonhosted.org/packages/dc/09/6db7c4327e7a56aba805f7190d377a159fc0bf6bdefb410dc7860624dfa3/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1858887be56a64a2aea899423dfe43787c34c75c18b0d7497de8e618d54b2790", size = 1541231, upload-time = "2026-10-03T12:24:36.05Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/7ab6097e5fe23cd4657b0e5a2fabb4f789f91e441a3ee40b3ca8b79be238/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89a2bc39a820bc7785b60c53a5742b4e8dd4c1a599294e2dd68fae545883d44a", size = 1317324, upload-time = "2026-10-03T12:24:37.737Z" }, + { url = "https://files.pythonhosted.org/packages/c0/60/58961e7fd129e226ce36788fe328d20034f3105f5d3380df690050517737/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50a9eaedf1db4857dad7cc47dd757ed70bfcc40b89d44d516c4a2f0d5033bd76", size = 1311809, upload-time = "2026-10-03T12:24:39.306Z" }, + { url = "https://files.pythonhosted.org/packages/15/c7/09d973db87d4575cba470fd80a3fa489322f7d6882546e43a4b21012468e/ast_serialize-0.12.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:c30b609e8fea426b310543126de876592236a25aa8ebd59f1e2b323dd52a4085", size = 1306678, upload-time = "2026-10-03T12:24:40.891Z" }, + { url = "https://files.pythonhosted.org/packages/84/27/84f69c22bcdaa5256b4fe43ff972fc117668fff8e68495807d5792eadcce/ast_serialize-0.12.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b1ad06513022cfa1337744959255af0ef16119d2beb1e547b67f37ad9433d4a", size = 1362009, upload-time = "2026-10-03T12:24:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/43/46/76ee342ef22cd6d82ccd6089d5e2f7163246de73d1816ccb4b6ec0550db6/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6add54b495e37ae3cf3a1f0d5eaba364814eb72e93026adc41b7791e4b0d45d3", size = 1467959, upload-time = "2026-10-03T12:24:44.496Z" }, + { url = "https://files.pythonhosted.org/packages/31/4d/18e48154bbf6058eed8d9b54fcebb2e130f8a380db1a2a202b4faac48626/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5fc136cd08001b817ad0b3e7426f50a7d2b8982dc7c6491f0af78af4c3dd8672", size = 1571157, upload-time = "2026-10-03T12:24:46.156Z" }, + { url = "https://files.pythonhosted.org/packages/34/76/6b16ddf0510e713613a5f5441b13407c1bde6158df04946b9f1fdc65add3/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:45a9e6b700bbd973d49942668a5cdbeffa693f2e250b8a5409abd1fa9d351854", size = 1566873, upload-time = "2026-10-03T12:24:48.542Z" }, + { url = "https://files.pythonhosted.org/packages/75/33/9f6169ae7f60c2da4baec03450073d3f1edb39e95ab538be0d25a7d2f72e/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:a1d8267f83c613ea0a31f2518df074bd62e98a4b3a4892f6a529d74e08e02dba", size = 1673030, upload-time = "2026-10-03T12:24:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/11/51/0d78755bd61d6cf8980f0cfdc7fa8ede38df46a5423c9f7a3da0cff587ec/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a0cbab9796e6ce841197feeba4008faa96b4cc7741129542fd81c882d7a4f01", size = 1480052, upload-time = "2026-10-03T12:24:52.277Z" }, + { url = "https://files.pythonhosted.org/packages/01/ae/ad4c0e5129991f2761f388420c5ded37cb134ec5882e3e59043d33c1ad87/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b4282695f1d51a3c6ef76560351bad5af880eff7d755aefea325bffb9bf68c25", size = 1508940, upload-time = "2026-10-03T12:24:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/3299182794d38815ae6e9c7ede9bb8f2e4aa93c3578bed201c1ea746643a/ast_serialize-0.12.1-cp39-abi3-win32.whl", hash = "sha256:119d1b0cadaba4a6e475f9bbe79eecc79351e373142eabe7c353f0250d70aebb", size = 1125618, upload-time = "2026-10-03T12:24:55.405Z" }, + { url = "https://files.pythonhosted.org/packages/86/14/5d4fb733c18a1d69e237c067b183842f3a7ea1c999a51ddc87093a281c88/ast_serialize-0.12.1-cp39-abi3-win_amd64.whl", hash = "sha256:3d6ed63d4fc1ec867b8cb522d58c36df0e8f05e487bea0ffd102043a37636d72", size = 1165798, upload-time = "2026-10-03T12:24:57.052Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f4/b54123680025c0b7253117418f023d1b2487f1102552acbdd9d8ee96b622/ast_serialize-0.12.1-cp39-abi3-win_arm64.whl", hash = "sha256:610a41351de68199de9a1434499083b4256c0df7658ec1cfc0a0a7b20b08d317", size = 1136560, upload-time = "2026-10-03T12:24:58.689Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] [[package]] name = "colorama" @@ -11,6 +88,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -23,6 +109,120 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "filelock" +version = "4.0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/51/a182494d1d8dde1240bff84dda57d48165d982e59582ce8f167e8e3d7628/filelock-4.0.10.tar.gz", hash = "sha256:00d6a81f976a6332551c2c10f39e12b4abb7e01c64d4c497b81a615bc9186f1f", size = 252487, upload-time = "2026-10-03T23:40:48.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/2a/4b1ea3f2180be251a5cff1e70c8a5b7994ffa5c8eab8937901f8a9db58fe/filelock-4.0.10-py3-none-any.whl", hash = "sha256:698048d158dc3c9e18cc5e88b3efdd4bd0e77d76686b22adb4511cc4b2c2f0db", size = 111193, upload-time = "2026-10-03T23:40:47.334Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.168.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/b7/13118bbc45d6d8b9d04e2de779e2a4ff23145ea39efa692b994fb874ca72/hypothesis-6.168.3.tar.gz", hash = "sha256:a43388f9067678fef6e13bdff325b6cfa6961a590498bb37f7ff31589c83bc75", size = 511022, upload-time = "2026-09-28T05:20:58.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/cd/746a2fc1e5e5ba34f07ea6ee1ad07525a3dde5ff4836db3a7980af49a891/hypothesis-6.168.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d20972ca134e652e9928ecd200966a8a2857adc2812c1d74b12a872e5cd503eb", size = 791536, upload-time = "2026-09-28T05:18:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0f/7a158e377b69556c8e12c25c8fd811d0103e54c24a12dab1f3b9819f202e/hypothesis-6.168.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:eafbec09d3e87d13d8242411d1f5868b5e879f1bd6d95e233e9ff80c527bea1c", size = 787313, upload-time = "2026-09-28T05:20:18.122Z" }, + { url = "https://files.pythonhosted.org/packages/45/c6/7df9104ee359e8fcd77791781c47e70794964ca69673665e0de2a6590f4c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73c5627497968cc62d140e9fde1ea21e12cac7b64dc419fea8286cd34ff1ad4e", size = 1124036, upload-time = "2026-09-28T05:20:04.372Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/8a61715404a73b9a82cb1f976803428d603cdea976a9c30266c72ad6a7ce/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dc56e6dc6eeb0aeb08ac463f847279bde2336c4786faf7ee0af4b93cd031a7", size = 1147875, upload-time = "2026-09-28T05:19:15.495Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3f/2b16e95cc3b9a069afa23830a012aefe377ce4a457a227e5a5026eb827ff/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:753bb501f8560d2e321ed3b62596b4496c56f15668b0a0669231ccc3e6c4e80d", size = 1149489, upload-time = "2026-09-28T05:19:18.886Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e7/d7cf6dc068bb02732b2a6e38a35a0dcb7f2137608198fc79d740f69d197c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71ab606c472449cb872ef2a7acaec679ee4f651b7f0a477a04ebc85d8933ef8c", size = 1191992, upload-time = "2026-09-28T05:19:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/5f/26/f1e5b25dec15998e8375d722825ec3dae1075cbe7cb1f74221b2312a2e33/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26928956c54748e4dfa587333741ab750246123b93484955646ff316cca27eef", size = 1169911, upload-time = "2026-09-28T05:19:27.996Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/901234a49147e6bf8a5b9e54b775706a223c2b52befea1106ea2aaf15d48/hypothesis-6.168.3-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:0bdfc53c041b61c854fa3761735736b991bc2bddafee45a361cfe4d43027c1ef", size = 1129406, upload-time = "2026-09-28T05:18:42.632Z" }, + { url = "https://files.pythonhosted.org/packages/55/c1/5fc9ff91ec9fba6bb527833386c66812524b5cc75fe7d4a4f9122ee1c420/hypothesis-6.168.3-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:650528e2b1e2a45e95c2df624d4b9364b8ac5a027ba84e1eea2bc5398dd01dcd", size = 1160399, upload-time = "2026-09-28T05:20:23.911Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d2/49ad1ef5c55547c8abfd0fc571b3493806dda530fd5cb3d8263dbad86a72/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:209dc54cdb1b4d6d7020ad8d09e44c49756361b7183adacea4d6f5705085b595", size = 1299906, upload-time = "2026-09-28T05:18:58.417Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5f/f98a26094c4f462c4215807bed0f9a6b5508b27a3c329bcba1bc8adc7ac7/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:af8ca98cd11dc7f9427bb90483abcd9688d4df2e8e63893a30a2423b027ebb11", size = 1425538, upload-time = "2026-09-28T05:20:49.074Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b8/c5b4406e3a6591e51a42f85469351d1d824e0a54b84167aec9b2353759fe/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f8122cfdd0bc0ba843063effa22ac310bdebdb3a1319bf1e63f14baa119c72ab", size = 1377084, upload-time = "2026-09-28T05:20:06.618Z" }, + { url = "https://files.pythonhosted.org/packages/e0/01/0d84a8ea469d024c15f602799c0b8410fbe03d99fdb0370449b33c3c916f/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4bedbb379eab34f792af7ee9a05aae04e9c08bbb52e0f90f5a2110d4fe4b2fbd", size = 1281162, upload-time = "2026-09-28T05:19:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/22/b5/ea6435038de1a795f005cb603531e8ccc053febc50180f3ab55583c9c60e/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:5a953115b9f5c95133ab2d04efffeec96e5658c3207923dca7285f7db3e6bbef", size = 1300433, upload-time = "2026-09-28T05:18:55.634Z" }, + { url = "https://files.pythonhosted.org/packages/6c/78/fa6c77d9f64b6d592e69e582d83cbce7d7e56897faa4762885a2123b0166/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6173558e676ad25ed1e20507fa4024a0d816dd90f715f77006e5a28f19109026", size = 1336273, upload-time = "2026-09-28T05:19:08.273Z" }, + { url = "https://files.pythonhosted.org/packages/f1/56/4acd0d778bab818c9ea336fcba768bf43bc70db7f84cc373579298df4641/hypothesis-6.168.3-cp310-abi3-win32.whl", hash = "sha256:dc66390fb12d80585aa9222bf538ce8b7aa22cf5d118250647355a1c9e8f62f4", size = 678211, upload-time = "2026-09-28T05:19:23.355Z" }, + { url = "https://files.pythonhosted.org/packages/b0/db/0a02b146ad1c30f9716362f2e1a379dfd6b0f7be03ac1e22c9da24c9e2a9/hypothesis-6.168.3-cp310-abi3-win_amd64.whl", hash = "sha256:92325b276360fe86c5bf71a568c0d53a6d140b0de36dcf029f9164a17803bb24", size = 684906, upload-time = "2026-09-28T05:18:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c0/958deaf726848f96f52250740bf39f13f476b068e22f73e2b358eaa07532/hypothesis-6.168.3-cp310-abi3-win_arm64.whl", hash = "sha256:3cf6f1eeaf41cd8d60cf1f88fde905ca1dd77c906929a507d6ac7f66f2ccba2a", size = 683292, upload-time = "2026-09-28T05:19:57.404Z" }, + { url = "https://files.pythonhosted.org/packages/bd/80/4efe245037b2d2ff64e220fecb4656f41a3d5e296a65daa6d6e4245e1400/hypothesis-6.168.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:26084ef31653108da9e5eb171278a1d930e90d8631604391f828219f5c1f6cb2", size = 792323, upload-time = "2026-09-28T05:19:31.294Z" }, + { url = "https://files.pythonhosted.org/packages/49/cd/0f96010943e93d07fe0c064a6c1812b33d305f29fa43606a8b863ba806af/hypothesis-6.168.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aac5889ea7b0b86cf2bf5709250b7576c44859dd249d1be23c009a7d6fb9f742", size = 788335, upload-time = "2026-09-28T05:20:29.748Z" }, + { url = "https://files.pythonhosted.org/packages/12/a9/95ebd471169fef0ccb7c9194ed643797eab67703b927efb6e76c046a78ce/hypothesis-6.168.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7311d8ff296806ea41513d52edc832fcf43a1dc8d5c5f308cc386fc31b253a05", size = 1124433, upload-time = "2026-09-28T05:19:01.479Z" }, + { url = "https://files.pythonhosted.org/packages/9c/3a/153bf04b7ce7f14d563b943af5f05347d5f91948f66f59655b9913b97531/hypothesis-6.168.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1872d2dc3f3758dfbf17cc39838b8e6b6a5c39a37811c067245e87b12d50f74", size = 1170640, upload-time = "2026-09-28T05:18:40.699Z" }, + { url = "https://files.pythonhosted.org/packages/d9/da/3d5ca014d00897dc5f69fa45374d86014fde89938e381303d597275b4ad3/hypothesis-6.168.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:140fe3cc7ba98a4ffeeb6492c318b43c326110a4d7b4f56bb759d5c264e2dc1c", size = 1300447, upload-time = "2026-09-28T05:18:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/18/76/38fb90efa47cab7d93b2794624a8177533bc748ee4666681a0ece1e3c23b/hypothesis-6.168.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:558625ff28e415f2f770cad618c2c9e05a045a6586949397f178d537bec2f083", size = 1336918, upload-time = "2026-09-28T05:18:45.505Z" }, + { url = "https://files.pythonhosted.org/packages/78/44/92b10c9df3029379cc67ad2d3af17cd1b35a72516b0ef37d5b55a125caec/hypothesis-6.168.3-cp310-cp310-win_amd64.whl", hash = "sha256:836eda971f25ddfce274107ed113297e8acede8d9b267775742b8ba8a404d0f0", size = 684844, upload-time = "2026-09-28T05:19:39.335Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ab/443208c2988a24eaafbfbd6791b897ad9a8a1dd7f870d5b455d7d15a28ad/hypothesis-6.168.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9029775dc2e25e3e8e596c4306926f0079158353631c7c52c285bbc5c8073c2b", size = 792239, upload-time = "2026-09-28T05:19:59.144Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7b/619c1a76c52de2d3cfd68a2f9876c09464d63097db5bf86a28b62dc37bb1/hypothesis-6.168.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e1d91530cefdb9e0b6467c203eb509c61a11d70480a37db59cb53a2f9913102", size = 788113, upload-time = "2026-09-28T05:19:17.145Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/33841e8833105f850214a486d83740ba52041892ca94e687a05829c0ce83/hypothesis-6.168.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0df00cbe8133aa220308d11fa28e3add996cf5102a39b279c1d711015fd9114b", size = 1124241, upload-time = "2026-09-28T05:18:37.589Z" }, + { url = "https://files.pythonhosted.org/packages/4b/43/c84a7900feced6a420e824ab61d316e4f59a6887d991bc143ecc294a09ed/hypothesis-6.168.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7f75a2e23288ee82ee965a952473d9c5447c2cbc1afc94be09d2402201774e", size = 1170406, upload-time = "2026-09-28T05:19:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/47/5d/6e2a32b9c3020af7a06b00fe446a2c5155ae76ac468c0b68d7188e524002/hypothesis-6.168.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc2b1c37631f2231dd0d077225aa5908fc2bd34a4c3faf475053b0bc2eb24c95", size = 1300339, upload-time = "2026-09-28T05:20:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f9/78/19f3c9eb9a5c215b9fbde57be18ef7c1c78afa509b460d4e8f86026d741a/hypothesis-6.168.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:73960a58f6efc8cbc57d8ad0647955f7c5b25f3d562e3eaa57afa1c69894f7aa", size = 1336484, upload-time = "2026-09-28T05:19:02.841Z" }, + { url = "https://files.pythonhosted.org/packages/88/bb/d46c4519e989e022667705d8a56e83a09ad4ea50eae07ca4bb0f4708dd23/hypothesis-6.168.3-cp311-cp311-win_amd64.whl", hash = "sha256:3f1122223759acc0fe5301c1ae505d762ee61af91db7b191a791ccdbcd965462", size = 684646, upload-time = "2026-09-28T05:20:00.842Z" }, + { url = "https://files.pythonhosted.org/packages/7a/56/44a17266bcf1add5fba7c3df5c3920396a5e924d917fb0534a6f9e2b9b9a/hypothesis-6.168.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bff12e036b67abc5ded682f724f4d7afaff5223190270a0763695f218c079068", size = 793313, upload-time = "2026-09-28T05:20:22.045Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/1a25b11e9133cb54c02ee6cf99a61296ad461aabc7aae3399e1d0ff1f095/hypothesis-6.168.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c73c6188056e6dc110260e39d141c69c3d02f23e3ba1d8610c7a8b6f2510ec96", size = 784855, upload-time = "2026-09-28T05:19:34.496Z" }, + { url = "https://files.pythonhosted.org/packages/8b/da/728d3210f0b0089337f37206a100d31ef546d1f99839e709f0a9ca443f8e/hypothesis-6.168.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb1063cb794765097b8c0add598bd34a904f44dc15db81275dd1f0ed1ac567ed", size = 1123043, upload-time = "2026-09-28T05:19:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/53/86/bfb9ec344a8b3ef2fa6f59bb3d30c5b3a7d86d594107670539c34ee53d1b/hypothesis-6.168.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b2d47f5d9060b049039bef0b267e88480f6468f95ee394e514e84a81ccdc5f5", size = 1169121, upload-time = "2026-09-28T05:20:40.512Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e0/5a76dbafbcaf8fff075f09377cddefa17289ef05dbf51878dde88385b966/hypothesis-6.168.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8367f623a98cbf90f33fd2a43b100671b152b5975bc343a578290945f52f7018", size = 1298749, upload-time = "2026-09-28T05:19:32.883Z" }, + { url = "https://files.pythonhosted.org/packages/28/15/0e65af96b353f2c4bdc283c53ed0980e5ac5009afe97ef3b23c77b364664/hypothesis-6.168.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd81241f4cd76fb18d481e87b9688b30a0cb5c30841730513323ba1c7aba1837", size = 1335271, upload-time = "2026-09-28T05:20:54.005Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c5/37eab2155664aa544df321f6d3940b89c6702d79a72fdc98d7b5a2286f8c/hypothesis-6.168.3-cp312-cp312-win_amd64.whl", hash = "sha256:377438de53afb94347d9845b7d90db5905c6d2a8d612108e9938e0f80503bb90", size = 682211, upload-time = "2026-09-28T05:18:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a2/6787da846d929e52fc3344d803299c45782dbeac287528af08380e984bf8/hypothesis-6.168.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b230a850de63334c16654a34a2d547e0179d36b9071d4439b3e7237f6d077e7", size = 793254, upload-time = "2026-09-28T05:19:36.294Z" }, + { url = "https://files.pythonhosted.org/packages/89/ba/2893f5ca42501f4d562cba3229c8994c3a3e5fac66ef60c1e0e0b5220a5d/hypothesis-6.168.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d63b0226cd3e0d8bdd97c3384b22a21934ed4d53246c1c93575dada616672499", size = 784699, upload-time = "2026-09-28T05:18:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/b7/aa/7d7349daf75b71f6f35876f8de115e974c5c04d600e86df1b2779b0883ab/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0369f5df055f96e117ab12e5f249668ff144731ab5280bc7a205fdf81f990b89", size = 1123016, upload-time = "2026-09-28T05:20:36.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/f0/7774e1ea072708ea46cb25c4aeee5f9978b6c271764809069e8a6789858e/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f076bcd0f77fdcdb7797826c879099573d03a02e228ebe649ea917081b962ac0", size = 1168989, upload-time = "2026-09-28T05:20:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7d/113992abed9efbd7944e3a496a382da58152dce126fddf6419ff31a0a57d/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b823ba1fcec8da730f29316d010b06d3f7e0c3828dcf91020e24c55e7d24652a", size = 1298626, upload-time = "2026-09-28T05:19:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/d5/65/3659fa5e733027e5b37a27486e57f4053535e40853d23501c422bb275043/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dd2849c269d674e4618590f3b48d443bd4c06b5aef3d8d869086c2e6d213d248", size = 1335184, upload-time = "2026-09-28T05:20:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/b4/6c/35aab2221b5ea65125340f236e1a765a9e9f3b28d89a389ea0033fed29f7/hypothesis-6.168.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ef7d26f5789e691401d5f87eafed9bd2763f0dbe47af6d6b66012a509404766", size = 682173, upload-time = "2026-09-28T05:19:12.762Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/27b0cb56ff5d2bc92458bf6fcebdb1f0dc01f57f043a178e9f9d74498169/hypothesis-6.168.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e2d4c68729a13df9af4998d2652cfb5d541c5609b88c306880a5dfb284938ec2", size = 793287, upload-time = "2026-09-28T05:20:38.522Z" }, + { url = "https://files.pythonhosted.org/packages/e8/bd/5342f95c3bc36586777ef8cb8eba87ac7acf0184210327fb0e6d8654e295/hypothesis-6.168.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b345f818083ec99966a43ca4f7b38feb62c6920ce28572bc2bde4948a8b7eaba", size = 784857, upload-time = "2026-09-28T05:20:42.609Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5f/fc774241518a0588d362680a3084275058aab86d6bc02a2c61e1073142d0/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0608c610fc002978fc5de0f471770d8817e9455cb8649a47983c9f8bccfc1897", size = 1123330, upload-time = "2026-09-28T05:19:29.735Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/131f16775a3dca5f4fe27f0d6ad9a9851600098a54a872d163f6ebf0b664/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dcf6448b1ecc37f2b23f2d1b3ddfc9ff6b6910614c15a642dc82f419b96037b", size = 1169178, upload-time = "2026-09-28T05:19:53.436Z" }, + { url = "https://files.pythonhosted.org/packages/06/61/c9f5bff8b73321c9fd12f2d69666c6e4861b97b60f5f24fdd6ac293007fd/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f9f094041dcce5119ebd7bab71062743ab02b6e654d056b370beda78c19e2", size = 1299127, upload-time = "2026-09-28T05:20:14.486Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/d4618f8ab12dd76c4d58ea052252759aa2bf0c4e71f4b502ba5d577e0b9f/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d479985fe73af97badfb72dc6d20c6a353e486a36f6d065f600026e0cf954a86", size = 1335390, upload-time = "2026-09-28T05:20:10.326Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/727161e17cc297df1aeea945de05c532033b66751625bec192bc5b5ff707/hypothesis-6.168.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:785e2c45f8c08e274b4bf1ccae97f1a4e09407e9a68e80790cfab1d17a4a45fa", size = 624291, upload-time = "2026-09-28T05:18:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/77/23/2f9b506392a0b8712440bcb781abc5713be9ffac1303b6d10f3669050882/hypothesis-6.168.3-cp314-cp314-win_amd64.whl", hash = "sha256:320920b1e3dae8611eee8a03d063cf2187446f2a17c38cfb8a7fc1466f71eee2", size = 682064, upload-time = "2026-09-28T05:20:16.336Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/efabfd95eb2b69c0c9fa1e1c83c8290aa2715d46baeb5a7764faf09c133a/hypothesis-6.168.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:35380baa981108a7f60c4eab71e46acd8d8f58440520346a1a6aba06dca7e074", size = 791875, upload-time = "2026-09-28T05:19:37.789Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fc/2a0ada1623a9a33048bbf9f00a7c92513398c6b8865cba5efb854262ce30/hypothesis-6.168.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f0aaaed00438fa6673d856aaee12a4a8afbde82a9f3c5ff487cacfb064239afe", size = 783412, upload-time = "2026-09-28T05:18:48.266Z" }, + { url = "https://files.pythonhosted.org/packages/41/31/73c615d37eeb12da0209555612c32ced628d860267c23cfd2691c6207aea/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27e6df1576bf838e7f484d4ae5cba92114497ca30afb917056bf1ea33e95675", size = 1121662, upload-time = "2026-09-28T05:19:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/92/54/1893344a3b8bbf83fa7c9bb7e96f6836580213854f46671cb749f7b4706c/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bc85014577982ec6d2e266edc5cd6e7a7d3674c648791ca983c67a52f89a0c4", size = 1167761, upload-time = "2026-09-28T05:18:49.85Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/a61d82968febd25a0f08ffbbfabfaeead66f68fc5fa73f3c9ece1c36fc8f/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ccfc29505aa1cdcc254cb9cd701fd0811fef5480addd97d4127a00df023410f7", size = 1297309, upload-time = "2026-09-28T05:19:51.711Z" }, + { url = "https://files.pythonhosted.org/packages/bc/01/fe8e4cf6d39d02f4efadaa351427fa13722086cfc54a2da3579741c5177a/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32d0699566aaa93f9e97a44705d7164386f1e91de78d277f53bada35e78bcc96", size = 1334260, upload-time = "2026-09-28T05:19:00.121Z" }, + { url = "https://files.pythonhosted.org/packages/39/6a/09177ebc62f4778f94379ecade6a51299d3d8c4f4b75ba636bf3a0202364/hypothesis-6.168.3-cp314-cp314t-win_amd64.whl", hash = "sha256:28d88fa174ecbd4ecbd7bb290f06d0db084a3971c3f511ae2830b65b5e25500f", size = 681992, upload-time = "2026-09-28T05:19:48.043Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f6/890bf33d608cd63348d3146a3ca83362fbf09e589c5e5230a00c403070f4/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:61f5782d807b1e6aa5c9beef1054cb2037e7d1add48a64972cd6ee447778781f", size = 791231, upload-time = "2026-09-28T05:18:43.998Z" }, + { url = "https://files.pythonhosted.org/packages/03/dd/fc36b204f8aa7437c676576d9437f0422de46aa8a729e6cd5bbb0224e759/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:7aacf3cf40c7ce8f9e4924d5b57bc0b068beafdfed2347bcf160a28b4cfbba7b", size = 783171, upload-time = "2026-09-28T05:19:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d2/3cd087d577db67c404991d7723e64d7cd75570cdc82a0f7329aed6f2086c/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01768a03a30dc54df7fe457c0b34016c84d598fa00d5965ededab93ba4eb3408", size = 1121152, upload-time = "2026-09-28T05:18:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/42/6f/a05a68cc66e7a22701c50bf94fb9ddeb36f4f5d0de3cc130066709eda621/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1a8a4ffc6c6e6e577f2bfbcfebf7cffbb310283ba2532c729570a0a752cebaaa", size = 1144069, upload-time = "2026-09-28T05:20:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f4/fdd7a093fb2fb3a0ce24256c92ad5bf67936f4669bd06d5ceae4298150ec/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b987d73eba95183a7e59cca6d1925c588aa4307922d9852cc2b4d282a2ae4128", size = 1146692, upload-time = "2026-09-28T05:20:25.83Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/6dbd4377e935505ae4fc8ee4c7b18c69994c4bbaca015447c470218bdbee/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4569c39bd97d9573e946429ed676f3b55a7c8ed80920addd67d384a47d59b38", size = 1189480, upload-time = "2026-09-28T05:20:46.734Z" }, + { url = "https://files.pythonhosted.org/packages/ba/bf/ff288b496b690000d2686dcfa7f67855e4c5a6dddb464ed8e4880be83bb2/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6042b8707a4b25bbbfe20b110258fb7549a68951e5525608a8ce06091039e9fc", size = 1167109, upload-time = "2026-09-28T05:19:24.758Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d1/3fee2bc29fc747fabbb27e174515592e390809ca465d3bbbabbcfa3235a8/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:f413629de94d38a7a2ad259697a6752526143cb49c2e2d7bdba19381c80693f6", size = 1126823, upload-time = "2026-09-28T05:18:52.636Z" }, + { url = "https://files.pythonhosted.org/packages/58/6a/585294392fa6a6d9719446a042a777ba98ecca265d9e48cbedc10c88df0f/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f071737e4e775bebba07e319eb645a880d1e0186b4d24bad23255e849d86d483", size = 1155773, upload-time = "2026-09-28T05:20:51.268Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4d/fa283ff79996debf1ff08593e01f8b773eb8211b19b3d6d5d491317a65bf/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:f59a3912858d0609c26054aa1c474847c797937f5e0e1c77f0d3f08032e50f09", size = 1296671, upload-time = "2026-09-28T05:18:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/42/05/98f9c2f628da5afabb6c3e8df5d2d299d2d50bf306b14a6e26d3954c4951/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:f09c05a23a8025dd5cad07c2ed299a46778e72d49ff0dd5bf353bcc0f7dc1580", size = 1422001, upload-time = "2026-09-28T05:19:04.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c9/f2109ade29a7ec284d55bca328d784743e8b13e321576fd34ee7e65f99af/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:44ace770bda3a0301739fc5a413c764de790c739df1f1a7218dd049f8594d9f5", size = 1374182, upload-time = "2026-09-28T05:20:27.764Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6d/e05d5f72441564a3bebc71fa155deafd0fd3b015d6014ec8a00edf6e42bb/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:03b131043608f94a2578a2a896a1a72092acb7079815eef5b8513b08447e0e62", size = 1278402, upload-time = "2026-09-28T05:20:56.332Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/6988a7f1f69c5c530687dce03a32c157e2d878e3a2e32af9269ce016b78a/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:e9784aca26eddfe99b03a0292320db742cc8a74200ef864fdce949f526f973cd", size = 1297770, upload-time = "2026-09-28T05:19:11.387Z" }, + { url = "https://files.pythonhosted.org/packages/a2/28/5bb82b60b836bd2329e1fe01ad94efc2b14dfa806f8e50ed14b795d78770/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2b52ac363096232bebc2add117e9178d91f4248f4cdb919fd1026b5f86a4bb16", size = 1333977, upload-time = "2026-09-28T05:20:02.483Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7d/d419841b8f65481ea1a50c4ba36f2670d48b8c7a51e4e8586089564cf7f6/hypothesis-6.168.3-cp315-abi3.abi3t-win32.whl", hash = "sha256:d28e3a6b511a74ce37df5274b51f36c2b274fea7365e00b16f0c81e22acd5957", size = 675394, upload-time = "2026-09-28T05:19:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/1de6a2ad100e44817f2e9a8e8ba3eeaf4769621f4f87f2d4966c4971fcc4/hypothesis-6.168.3-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:7b9638789548361a57d984f56619ac694a914c328181d911271618409261ff4a", size = 681699, upload-time = "2026-09-28T05:19:09.957Z" }, + { url = "https://files.pythonhosted.org/packages/c0/79/be3370fd02734d6b1d950183ae9224580d19fa8d356b95348d60f1e3eeb7/hypothesis-6.168.3-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:65d78e4357ec48ed2c67825f06740ee3599be4cfe770a6092bed07108d679ac5", size = 679849, upload-time = "2026-09-28T05:19:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0e/27960db1d45a94497e1681fb9d7b9140b44da4e655ba20f0d82f95d10230/hypothesis-6.168.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:101531b4ccf7fa12965a6b295d10b64c2f8a8fab61e1f9b18c55335d8fb02575", size = 793135, upload-time = "2026-09-28T05:19:14.056Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cb/be08379286c1925fcd651b92ed835f470a83b859b5c484b3431da9d851f5/hypothesis-6.168.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:41e0120814de5c3a6b58d8cb27cb11b8873ab33fba130cd09855a27f5a12a1ca", size = 788995, upload-time = "2026-09-28T05:20:34.164Z" }, + { url = "https://files.pythonhosted.org/packages/38/7b/6d885d139b2ed76e2f4908cf10eae56acc73bf05cbef286357c4559c9a04/hypothesis-6.168.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d120009d145697909f2f0c532f2d568558ddd6990707f8cba6576bfb9770696", size = 1124974, upload-time = "2026-09-28T05:19:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/05/cc/b302ae809b48254830cae57801d0af0b66783df515b5dd56b9f21c8dfa86/hypothesis-6.168.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:818b3d09bba60ef90463e47a4bc87275dba1e8aeb184d3102888440ca7c7837d", size = 1171967, upload-time = "2026-09-28T05:20:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ab/51031080a6600f5b07adf5f9dfa117345c2adda07a5c24bda523e70bd1e9/hypothesis-6.168.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9e3c36eba80e089ecfa28ec08049723223cb41d3cfe4d9988169cf98440e402b", size = 685641, upload-time = "2026-09-28T05:19:44.609Z" }, +] + +[[package]] +name = "identify" +version = "2.6.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/35/d70c0006c7cee65999ea94a6273e60b2094f600a3d8b71b04318253fc643/identify-2.6.20.tar.gz", hash = "sha256:ad729860a923858d26917c2f4fb0a1d83d27a75b1e090c06440c573f048f3285", size = 99681, upload-time = "2026-09-26T20:29:07.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl", hash = "sha256:6a16b69b93187244e0548cbfd25b3e4a6f9a7a2ad784625c3bec2b8d27b81aaa", size = 99506, upload-time = "2026-09-26T20:29:06.054Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -32,6 +232,136 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "librt" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/77/900294b3c9a81415323d81b3f3f271a7f5d7ed9d01e824fc38ffa49470b6/librt-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:96f576f2711f8519152ec76d0e599243555c1f07679fa73606ca8c8c868c0be6", size = 148668, upload-time = "2026-09-29T00:44:20.282Z" }, + { url = "https://files.pythonhosted.org/packages/55/3b/c301e8cc47758e4518b93e926a0111080155cc79fa03651d202ac2025065/librt-0.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2bec3818c7da7c96ceae0ef5915a3d16c52dd08f3ea913bf1fe8568c447c7978", size = 153562, upload-time = "2026-09-29T00:44:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/62/b1/4d5b98114a58bf17e3832cd33400e40f2582f68c2fb7ce25b4468cb1afd3/librt-0.16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c79520414a3fdfc6aabd7593e6169afa14d5f8d9908d4b498db068868b08dd", size = 494337, upload-time = "2026-09-29T00:44:23.096Z" }, + { url = "https://files.pythonhosted.org/packages/88/36/14223c9b0dc38982eb4f399732c8cb27b19c7e027ae0faef5ca51d08d19f/librt-0.16.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f81b5b19ce748ef68d4746656b7929762eb2fe99269b266e4be07e2ee4de7144", size = 485440, upload-time = "2026-09-29T00:44:24.555Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/d7155e6800d0c33e297e529b217649f7361d706491ebf9d2fc0fc627309e/librt-0.16.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c71d1b76210a36729fedfc5115069b50a3d8619054745f8758fe5d6f19e86671", size = 498363, upload-time = "2026-09-29T00:44:26.03Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ce/d5f1fb9f913ac0979d6fc5db9fb9c0bf28cc3470988528957922f15ed40c/librt-0.16.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:473eebc7866bb0a0c8849a292b5e7157c1aba5d14d0f0f610c52158d6d964262", size = 515453, upload-time = "2026-09-29T00:44:27.566Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/ca0fca1a27c26080d1a25b2b31f271e532078a5719878c3daca0c3d4c539/librt-0.16.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4eb1313a19847089ee81e88742abedf285c60538816640b99742d8534b81d26a", size = 509537, upload-time = "2026-09-29T00:44:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/ee/c0/26807bf2221a53a2c66f2e23a5c4d57f1c0eaa4afed7cb81d841cf3d1903/librt-0.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ebefd60b42e2a82b32d136bb5f7c94eadfcd29f772b547df6f3291d1ed855a1c", size = 532541, upload-time = "2026-09-29T00:44:30.745Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/c329d509907e1bc061abeec3796f94d255d43ba25d28c3854d2138ed5ab2/librt-0.16.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c5da27e8056439f927ea896735da60e616c477a8293feaa3233d4e7781a6726", size = 537059, upload-time = "2026-09-29T00:44:32.102Z" }, + { url = "https://files.pythonhosted.org/packages/84/d6/4571b9c9e65d98e5503734166dce2fd3e216f526dc3c59a3773faa8a60e3/librt-0.16.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:67e718c7a43f8db325abbbf1404e2d535f12f8f7a1a82259568385cc5274b82a", size = 536614, upload-time = "2026-09-29T00:44:33.653Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d3/a2ed8a292e4ab7889792a9c3f4ad5d1caf6557afce7f6b452e77f8ed20b7/librt-0.16.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:314e703f0c19320dc8094e7a784b9cf29e1b67402515abf580069a6363c0b4f1", size = 517199, upload-time = "2026-09-29T00:44:35.115Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/276e5218b4b7adb6258a42b7403cb1b7cef25616238d0c2152288a9d2f53/librt-0.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:943c6bbecbdf7fa575a4f2952fcfd848c88ef95507c3fca411e89d4ac3ff8143", size = 558746, upload-time = "2026-09-29T00:44:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5c/9156df81a1726e1efc9567379def2b8777a6d56f464d2f1b760bf951b8af/librt-0.16.0-cp310-cp310-win32.whl", hash = "sha256:7cc365f006891afb006b52d5ee5ee74c09306ffa20e2f8705a32a4450af2f3ba", size = 104424, upload-time = "2026-09-29T00:44:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/0b/62/4fd172cb3f49ab2cf5ef17de519a93cfb935202c1270ba9d44c6a64178c3/librt-0.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:0314058469f4d2fd279ce7c62ac274ac82c3918ef7db62ef0697c4c359370155", size = 124267, upload-time = "2026-09-29T00:44:39.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0e/b04fd2d76b6a78a02286938251b2c119fb36ddece92c76cb4886ef7d44b8/librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18", size = 148058, upload-time = "2026-09-29T00:44:40.903Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fc/e1ed24aadb32d38603c0c07f579d739eb4d90e64e90d525b024e4a999b4d/librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421", size = 153073, upload-time = "2026-09-29T00:44:42.165Z" }, + { url = "https://files.pythonhosted.org/packages/be/38/133ab655409420e0c34a9a9836a7f4e5ae75fba758d7fe177099130d58ba/librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f", size = 493052, upload-time = "2026-09-29T00:44:44.043Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/aaa663bc9421f5bf7bbbe9d3316d7219749698ec7688ac3b519770a89c04/librt-0.16.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ef46c1a29ffb8c72e882e22618ec618778eacd0578fb22c6e7cf9c11d15f357b", size = 485506, upload-time = "2026-09-29T00:44:45.479Z" }, + { url = "https://files.pythonhosted.org/packages/7b/59/83a6eb5087ba8b45b083859e2085256963e06d4c2531944cdd962670bfb5/librt-0.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3c94211ee0c4f8d649ec06b7c115c0ec4eadb873a0e3154ca15cef3f814b071", size = 498708, upload-time = "2026-09-29T00:44:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/15a8d62b2bc12935080ec7277e2e0b9b233047781eb784a51d3e25af669a/librt-0.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94aed6a8308818b91677957d1bd03188869cd7aeb23c5dba7912a6c0402f7602", size = 515981, upload-time = "2026-09-29T00:44:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/4f/287cc282ac5dd815204c0779e79401bb0a568c3a1335fb319bf0838fb566/librt-0.16.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:349c0bcb87ebd07481b6ff781e25cdc699723dbe2212e57dabb27f7a13b7b87d", size = 508670, upload-time = "2026-09-29T00:44:49.726Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5b/ab7292add898ade0d2b5b75d32a923aae315861671aa5c39f5491309f7ae/librt-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:001bfd59a7d45b17e3e75f2a8c6405280b35e7b84471792778e718c4f368950e", size = 530393, upload-time = "2026-09-29T00:44:51.356Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/8e6bcf027d4bcbc16a4e864b3d158a1d3e5f55ffa53af0238020cad89271/librt-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:28e038895b998d7a0c7798922ce8a1dc157675df5cf1c9ef0aca809ed804b7a1", size = 534302, upload-time = "2026-09-29T00:44:52.893Z" }, + { url = "https://files.pythonhosted.org/packages/96/a4/8c5ab4f3b3a8e905ff8305d76976a2e666f52fe049edd398aed2402beb36/librt-0.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0dbe4096a7ecc00fa835d24510ad8545a4efef738dac96e0e63516783ccde905", size = 536150, upload-time = "2026-09-29T00:44:54.421Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5e/7c4bc267e3cd4b8bd288de9f62d61fff203829c9cc649ea2aa12e9218cf0/librt-0.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5cd5b092441053364af968ea12084692cb9d4a22f3ce9524e377880bf028761e", size = 514316, upload-time = "2026-09-29T00:44:55.998Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/c8bc6b50f43c73819334233f5a3259c81216742cef56d2140c3dd377c3f0/librt-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ddeb3c9dedb461bb457c6c7d9aa7fbf35329da313d1a7543d00c8d0f3473c96", size = 557731, upload-time = "2026-09-29T00:44:57.581Z" }, + { url = "https://files.pythonhosted.org/packages/93/fb/e241f91bc8de6c7ac1c6a20ea6dd381bc52312b44d711ffcd97140db1e57/librt-0.16.0-cp311-cp311-win32.whl", hash = "sha256:e05108e0849966f53a8d2d3112a7af881d0efaa479bc735bba91108f9f2350a7", size = 104922, upload-time = "2026-09-29T00:44:59.078Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/c892383d2bb1a6a80b2ec8ede84970a265a792c56614a06282bf3008517c/librt-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5f49cff01bd608ef7d97104cb035c75455e79c2d70bf4a506cf773338ac1860d", size = 125093, upload-time = "2026-09-29T00:49:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/8bbde1ed0b21480163617648f638fdaec9374cf5e9c846ebcee34ec02a42/librt-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:1d28ae980ae2218f9c5b95d191e947296f918c9bf0b400d467a9430275bbe678", size = 115910, upload-time = "2026-09-29T00:52:40.131Z" }, + { url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" }, + { url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036, upload-time = "2026-09-29T00:53:06.284Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211, upload-time = "2026-09-29T00:53:07.72Z" }, + { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587, upload-time = "2026-09-29T00:53:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146, upload-time = "2026-09-29T00:53:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336, upload-time = "2026-09-29T00:53:12.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689, upload-time = "2026-09-29T00:53:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537, upload-time = "2026-09-29T00:53:16.064Z" }, + { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231, upload-time = "2026-09-29T00:53:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499, upload-time = "2026-09-29T00:53:19.666Z" }, + { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261, upload-time = "2026-09-29T00:53:21.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029, upload-time = "2026-09-29T00:53:23.07Z" }, + { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748, upload-time = "2026-09-29T00:53:24.696Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861, upload-time = "2026-09-29T00:53:26.089Z" }, + { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236, upload-time = "2026-09-29T00:53:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180, upload-time = "2026-09-29T00:53:28.7Z" }, + { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557, upload-time = "2026-09-29T00:53:30.012Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" }, + { url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" }, + { url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" }, + { url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, +] + [[package]] name = "maturin" version = "1.15.0" @@ -56,6 +386,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/10/15d4314edf130955edf2dc237aa393a8a7c10f2b9b57b89fa2f61f915659/maturin-1.15.0-py3-none-win_arm64.whl", hash = "sha256:c7dc0c66c78d3debdd9c5aa807e861fbcbf07f3505d34b125df74c03986b0f48", size = 9713795, upload-time = "2026-08-24T12:11:20.83Z" }, ] +[[package]] +name = "mypy" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/4e/64300736cf0a0373a27b94a91b664ee7382e36f77b0621bae6381da3e180/mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e", size = 4064425, upload-time = "2026-10-01T20:40:39.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/78c9ac93933e2ecd51d807021f78c03ffe98640f33951a8be159f5fd55bc/mypy-2.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ffda5244fd1ad71a1e54405e35f50d09b80c3978efa120f58bd1252ae32c62d2", size = 14408618, upload-time = "2026-10-01T20:39:46.766Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4f/5f5f4429fd5a4218ce6268164a66afeed75c7e2ce5522a4d163faaa5fe85/mypy-2.4.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:615b03922d40e186fd1df73156473db0ba525c639bb4fd1cf28e1694879c9b23", size = 14552749, upload-time = "2026-10-01T20:40:08.325Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/fa1c319cd51db7a6027b46117460d5a361c6bba0c0f0f0670a2dab8ce2a5/mypy-2.4.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13fa24f439c0e48a290a3922fa14ccd22f2762bae99d2142931b3e40a9055080", size = 15505846, upload-time = "2026-10-01T20:39:15.475Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5a/14c02690776fb04e9fa60b81173cb9ae6315e0745fad3813b303737f2f0c/mypy-2.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3adef556a19eb3b630bf86a79c29d7da3d61e541472d0d897ca01b171c0abf8f", size = 15823059, upload-time = "2026-10-01T20:39:35.429Z" }, + { url = "https://files.pythonhosted.org/packages/76/d8/d29afa819f8cb765018d6e2773e390a41ea4592f75abe417135969646857/mypy-2.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:1dc0f64b0a92ae27a49d2175f0bacfa56e15bdc5b420cf92e0c1f79292219cbb", size = 11312927, upload-time = "2026-10-01T20:39:28.509Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c8/966dec4d80303855d824e6bf67fe1da812d88adfe4d75e24d8545dab4826/mypy-2.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a378fc15fb33e321f04652c166ce73eeb8833a97c3d218132844e938cd93220", size = 10879252, upload-time = "2026-10-01T20:38:41.545Z" }, + { url = "https://files.pythonhosted.org/packages/26/67/e53965e67d23d48e78dd603aa7525a8d77e7818c23954cc217ccde5cd5c0/mypy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d20e6c7c35fcbf2a0ebdd0eaeacfbc243009dfd33ab7822d54e213912e6dbbd", size = 14315745, upload-time = "2026-10-01T20:39:25.03Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5d/a588ccd57625822d8484d7aff4e9c70f88d042a91c2ec84324a2bc14b3f3/mypy-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3011537be6cf1de4511c0255a324362a812b58184bbe61e15f59c8b31033bd74", size = 14465652, upload-time = "2026-10-01T20:38:57.19Z" }, + { url = "https://files.pythonhosted.org/packages/05/af/b4978a5566cb829ca563dc08e3ad8d944f116078786ad54ba3dff2bcb64d/mypy-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a96b07a49b7b1d025ce59c1b3acbcf24bead9a83da4523c4a6bde1bb94e7a0e1", size = 15419180, upload-time = "2026-10-01T20:40:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8c/52c71f31c54d0a749d832cc73b933c6e38c52d9b270d5503e031c73723dc/mypy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7c4f8f8d1d1c0e2832d8ee7113dd08f6df6c7aad9e863fcbed9f25832be0b8c4", size = 15669212, upload-time = "2026-10-01T20:39:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/e8/55/ba63121494404fd7d3787ec176ccd02820d6ec125ac4e7c4c0b79f2cc6c7/mypy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:ba05652540bf12828e52abae807b024b09ca144ff4f75e2450a81d69c376425b", size = 11299572, upload-time = "2026-10-01T20:40:03.627Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9c/385c81b5c0d3a2917f077c4da660d0493f9521ccfd4b058e33b860f72d02/mypy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:6306086b87cf7f8a29aa618d9fd9bffb56c59247166b9660fdb54d86d7714ecd", size = 10869751, upload-time = "2026-10-01T20:40:25.483Z" }, + { url = "https://files.pythonhosted.org/packages/68/ed/e5d7cf4017e74a1c1e1c4058ce8f614fc1e3e7606564f47166e22bfc9f95/mypy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e05ff2925d8b37ad26c80c1b9dc43ae5d455da2df1e23c24c095a6425917c57e", size = 14378593, upload-time = "2026-10-01T20:38:48.222Z" }, + { url = "https://files.pythonhosted.org/packages/30/7d/12d994886a922f0f1997becc9c6625198d61eeb5962558a886af7dd38d54/mypy-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29243242cf72582b65f9582ad9e56e8cb281566ed3519f4cd70bb8b9f2977e90", size = 14464697, upload-time = "2026-10-01T20:39:11.059Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9e/bcc9af755425ad17790bf11d73c2ea7592914cf309ee1340997670f9d57a/mypy-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29eb0b9427a6b11b992e452f6cceb8af724f4dceb47e779d0b35e405e996ea5e", size = 15552257, upload-time = "2026-10-01T20:39:56.269Z" }, + { url = "https://files.pythonhosted.org/packages/af/0c/3343fc4525d6f00d75ad17a93a9f052d5163641cb8911840d4a12cb59ff2/mypy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3ebe2f72a2a1156065a9851570ffbf50c0a93cdccadef9c6e05c508a4fd10b1", size = 15800443, upload-time = "2026-10-01T20:38:54.815Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0e/69aac6b8159da7c53e6115a2be1bf48e85503402cadbf506dd708e8b2ad7/mypy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:236e0d68f6941992b0811128e652590f590db444ab29ad8f1324765b9298b946", size = 11381574, upload-time = "2026-10-01T20:39:49.406Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/d32ce4feb5993eec09d2024bef16cedc9b93701b1446b86092f08b24491b/mypy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:82d0f94c8587ccb472622ee7795280aaa38a06640d5f45b3f16909d6dd86a989", size = 10944729, upload-time = "2026-10-01T20:40:15.203Z" }, + { url = "https://files.pythonhosted.org/packages/44/f2/eb15183c97c69d7cbfac990a6efd33a19ecfd97dab9e714c742fa78a784f/mypy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7da85fbcff6dac1abcc636707bed38b45598131fb7a605d9719c70b5cc733af8", size = 14213395, upload-time = "2026-10-01T20:40:21.178Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b5/ba91b6ff65e4d6b6ff53b2b3b3ac5f1babf0c7c27d0b43a0196b1c967926/mypy-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4209da39d85cf240f762af622d8180fcdfcb4727d021f44ade62d613a1a43324", size = 14373009, upload-time = "2026-10-01T20:39:58.86Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c4/484275efc935c0003e55e4e8a33e4b8e99528ee956c12256ece4708f903f/mypy-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be721bd4bd57576193653b75b4af3461c9d0bf7dd8b528f782e9be210dc75bb", size = 15465557, upload-time = "2026-10-01T20:39:01.794Z" }, + { url = "https://files.pythonhosted.org/packages/50/30/66eb6fdd0875e3c9025a02f0bb0ea2e524b74274b658c37fde0068c4939d/mypy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1fde197ae65be856a034a91b70ed747a16562ca69577785f06c661548424bf1", size = 15728673, upload-time = "2026-10-01T20:38:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/58/bc/2aa98fd7f49c42dba8e9c065886fadffdd00f3c654cff3f2a7103a797ac8/mypy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:295ecf2e57542cd836ca537486951289678c8c7d1ee6ad74ebe29b2168a003cf", size = 11389803, upload-time = "2026-10-01T20:39:37.547Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/17b60df2d946792ef6af43b89351f5c9ddabc69053206b2304945572a744/mypy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc378bdad4e9f12b5bd96466083d1e71acf00594ec9c7b2bdb5e02816f77f303", size = 10951060, upload-time = "2026-10-01T20:39:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/e0/66/924be0b653372ed31ad5c48e26044cc00840e591943a56e61621cf05b60e/mypy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:058165f564ccf559c68c70fec2091fca5891110480210c22594635e3f6683437", size = 14234230, upload-time = "2026-10-01T20:39:44.535Z" }, + { url = "https://files.pythonhosted.org/packages/56/39/c4f176880a4177123576de6cec6309feea8f42fca2bf2f6584e88054f656/mypy-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fa247e02b505a45a2775f69df38d360d197e3790bc60f717595db9eda358b6e", size = 14392944, upload-time = "2026-10-01T20:40:05.78Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1c/26e16977e25ef2494a74f8ffc872a76ec2c3aa43c3156057cbdf88f352c5/mypy-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20e9a5cd875837520c43db98dea0b6d0c2197833d95c30127d8f570fb9b1f00b", size = 15441463, upload-time = "2026-10-01T20:40:32.412Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/9e4b049f0ecefbfd6817ca2a16eea55dd74a07950268edc6cffd29ccfe98/mypy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f03a7828cca2b0adcd6662aee8f2711ff8830e1027641fdea3ab0b787483566", size = 15705406, upload-time = "2026-10-01T20:40:34.844Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5e/e861b5f6c5ef9ee6cd24683aed1edecf82a26dbd536049f9b7850b586267/mypy-2.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9279488933040b638c0ab739084c0ca100efeea6db581bf5d7628d8e89de53fe", size = 7897523, upload-time = "2026-10-01T20:39:39.861Z" }, + { url = "https://files.pythonhosted.org/packages/3a/87/61ffee58b25a956532a6006fae84918a2de849ed25f397492b2e815fe7b3/mypy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:2106b55105ba5ea9be4f53a24517fc5fa927ff1585edc9bc1a975abb72caef89", size = 11576345, upload-time = "2026-10-01T20:39:41.553Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/a331c20698971c2ce8d1c30f317fd61b5be13a85b1f053e12dfa22ac6568/mypy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:528c8744b8b5e3ecb8774f86af38d2376216816e9908317ad055f3c9c2d74799", size = 11172062, upload-time = "2026-10-01T20:38:59.534Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/4f7daa73270dced8e86c6f4a911c68082d03a84e6c1ec9bd916028d66131/mypy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0bb95cf34899e4619c61ab0a8667804e139e580b30d5df12af2102dfe44d0c97", size = 15711208, upload-time = "2026-10-01T20:39:30.597Z" }, + { url = "https://files.pythonhosted.org/packages/2f/05/f1afa303c678be24cf7a266d38fb24b3de4599a024c2f9ba0d5905a3efa3/mypy-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d616fe84c6eab8026f8c50ab5bcb90db780d2ccd233d971e34e92bede9b359", size = 15935442, upload-time = "2026-10-01T20:39:22.781Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d6/6a1a45459b63716e0d035f4892a926d3054f0a8cbfa02551d228a9946083/mypy-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3f86fd1313dd69d013e265f1fdcd12ea7a9d606f9875b2a3db946cd334555f3", size = 17240044, upload-time = "2026-10-01T20:39:32.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/ae33bee66c13f98d421964d87bf0888be941063bc75c1204a4cf142cf1cc/mypy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:720434d48542ecfe84d32d287b727569d3fc8f5769acd39051130e490a5c295c", size = 17545856, upload-time = "2026-10-01T20:39:08.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/de/eeff209b65c3e267818d79333bb9f058da6e4e73761461bd94748a2eb628/mypy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a6e851b82c0661f69f1630fc16172c68787a6a9cf0991e7c6437d60976cdcd76", size = 12369342, upload-time = "2026-10-01T20:40:10.516Z" }, + { url = "https://files.pythonhosted.org/packages/2b/43/e62d8d5c1dd737aa248968302ff7d6eabf997c3301773ed8bcb64932ca77/mypy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3bd0e340f0ebe65c548210f53be3fd8192e83964760caf0c28bef368e68b0d37", size = 11849099, upload-time = "2026-10-01T20:39:51.834Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5f/335b8980055118dc131355155883fb676bb2161a0d97e08658e479c776fb/mypy-2.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:afa89837d9be67e0cadfa33bca3bb7efdda98c3b07e74dc3b635ebfb1c8a926a", size = 14241302, upload-time = "2026-10-01T20:39:13.227Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/1482fdc49332b145828912b15f16eee0c4ca70a8bce0f6514ece79b14680/mypy-2.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb443e81057896132d3642d6be219e6efd158691ac7883e3ba8fcb469865f05d", size = 14408200, upload-time = "2026-10-01T20:38:52.576Z" }, + { url = "https://files.pythonhosted.org/packages/04/09/dce2e8f6c1b31053c430ef6963f6f7a38ccb49b90c5e01e37ed0129b7d5a/mypy-2.4.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f38f57d344f8b6accb40e01c3d83cfc590498231724d16c07ffb7940f157818", size = 15444409, upload-time = "2026-10-01T20:39:18.1Z" }, + { url = "https://files.pythonhosted.org/packages/43/c5/91b68306da4cd280cb15be35dbb5ffc343cabd67c4b121b6625bf2d13177/mypy-2.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e76172710bd4e5eeae061abfd68347e5264632e02778be61784671ae3a2132f5", size = 15731186, upload-time = "2026-10-01T20:40:23.426Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/2d0340182a8f27352fb1e25531355951108b506097c433937e9eec452ffd/mypy-2.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f83353e47ab520bf6fd4df8f5897d9fe081211f2fbc4b7d37736a3e3c166cbcf", size = 7897838, upload-time = "2026-10-01T20:38:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/3e/08/32703c117e134c02efa2a82705bb40cee91eaedea10b6530e20eba651b1f/mypy-2.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:970b221ed5842213d98e3c480c08f795ace4b1f81fb21e1b126bd0476bce1c34", size = 11575700, upload-time = "2026-10-01T20:39:53.982Z" }, + { url = "https://files.pythonhosted.org/packages/ab/08/08bb269feafdaad031046ee2d771528a64467e6a180f962afc28c4a3ccd8/mypy-2.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:502b94b0b331f7dafe32fd6b151797ddbb4f32385b362e722c783a025e5954a3", size = 11172908, upload-time = "2026-10-01T20:40:12.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3f/c5c92626006ca92c7686adfbece47fc6a0daa0e54753952a9ad13ce0561c/mypy-2.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c9de622fd397495695d0598ddc789222bfcfec9d7c9ec3a1e385c855e3bc5e01", size = 15705970, upload-time = "2026-10-01T20:40:17.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/f3/863365f7997a76a5a1dd42d7902ab05afa4124e8419089cbca1ce2554db1/mypy-2.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f459f0b4f0596d9d51fe7716b404b35287b99e77da98a7af90a65dd5fd61141", size = 15942234, upload-time = "2026-10-01T20:40:37.243Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/2f45b1f425a2c95dbe1a3f4d076bfd42b76e9615dba5906230703b14e4eb/mypy-2.4.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9b028548b3af480e2b1ed8df14ccaac86f99c9f600d1580770ab7ba3dcd40f0", size = 17218364, upload-time = "2026-10-01T20:39:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/5b2bbfc69e84800b78fa2dba16d995b003f93b573558e412c5490d6e6c37/mypy-2.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:cb734b2668c1f40d07ce093bbeb4407e9527c67901627b0e1679825be3f09975", size = 17557934, upload-time = "2026-10-01T20:38:46.077Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/1a5dc601c22041a7bbfe1ff71cf097fbbd4fb49930867c6789baf5102106/mypy-2.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:172e30b8fea631fe310f0c665477f52d9ea40bb4e99e0c81dc30118563b13710", size = 12350955, upload-time = "2026-10-01T20:40:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/697e9e26fc2a86c094ad67ee1e419971b7f01ded66ee66231b09e9f3e12e/mypy-2.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:5786ef987b3767e51aaa53f20aec104c0252b42ecda7aef8e8b4cbae279b05c5", size = 11842738, upload-time = "2026-10-01T20:40:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/81/12/46ae8670c98a3cd0286ca5645c2f918f8f6be65edfed81b916010619f668/mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093", size = 2800734, upload-time = "2026-10-01T20:39:26.837Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/105de02c1322cfada6d9710d9146ef8026419d433c9d08359a2d35805811/nodeenv-1.11.0.tar.gz", hash = "sha256:3ce8fe5b71d16e8af7039ca65257354100bc772965d6bc549070649e53b1b146", size = 99301, upload-time = "2026-09-26T11:29:21.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" }, +] + [[package]] name = "orjson" version = "3.12.0" @@ -137,6 +552,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.12.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/23/4a86fc741c38c5b69792a4ef954b281afa69bea9f083f881de1b0d23bc07/platformdirs-4.12.3.tar.gz", hash = "sha256:427fc0bb321ae0c5b037fa03238ca74820437be162e78b4848c4d4055b9b766c", size = 59538, upload-time = "2026-10-03T23:40:46.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/9b/6ce1ead737fe496611bda600c263b9a11ae7bd8f41bb13f9bd7e0a2c37a4/platformdirs-4.12.3-py3-none-any.whl", hash = "sha256:080f3b39423b5abfca9a23d84c4e9795f54d395cd8459867a8ded44084fcd5f8", size = 32493, upload-time = "2026-10-03T23:40:45.104Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -146,6 +579,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -173,6 +622,82 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "python-discovery" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/57/250bd238b966cece44328235eb85290045d059265fdaf7527a3a958123db/python_discovery-1.6.1.tar.gz", hash = "sha256:cf87d3627dfb4412437fdd5b13eae402607722998d21567993aedbc59b23c15e", size = 84338, upload-time = "2026-09-18T01:31:53.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl", hash = "sha256:d43fcdef879fe795352bd13ccf8d185ba5a9f86f36cfcd00529f596e737442b3", size = 38664, upload-time = "2026-09-18T01:31:52.448Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "rapidquery" source = { editable = "." } @@ -183,8 +708,24 @@ orjson = [ ] [package.dev-dependencies] +build = [ + { name = "maturin" }, +] dev = [ + { name = "hypothesis" }, { name = "maturin" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "ruff" }, +] +lint = [ + { name = "mypy" }, + { name = "pre-commit" }, + { name = "ruff" }, +] +test = [ + { name = "hypothesis" }, { name = "pytest" }, ] @@ -193,11 +734,59 @@ requires-dist = [{ name = "orjson", marker = "extra == 'orjson'", specifier = "> provides-extras = ["orjson"] [package.metadata.requires-dev] +build = [{ name = "maturin", specifier = "==1.15.0" }] dev = [ + { name = "hypothesis", specifier = ">=6.0.0" }, { name = "maturin", specifier = "==1.15.0" }, + { name = "mypy", specifier = ">=2.3.1" }, + { name = "pre-commit", specifier = ">=4.6.2" }, + { name = "pytest", specifier = ">=8.0.0" }, + { name = "ruff", specifier = ">=0.16.9" }, +] +lint = [ + { name = "mypy", specifier = ">=2.3.1" }, + { name = "pre-commit", specifier = ">=4.6.2" }, + { name = "ruff", specifier = ">=0.16.9" }, +] +test = [ + { name = "hypothesis", specifier = ">=6.0.0" }, { name = "pytest", specifier = ">=8.0.0" }, ] +[[package]] +name = "ruff" +version = "0.16.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" }, + { url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" }, + { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "tomli" version = "2.4.0" @@ -260,3 +849,20 @@ sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3 wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] + +[[package]] +name = "virtualenv" +version = "21.14.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/f9/f323b3b6058cff3853b31cf6a49c0425ed797bf9611572ec10d065e08bac/virtualenv-21.14.5.tar.gz", hash = "sha256:c4cb6c13e46b57225a999c7e22a09b163393878facc7ac4c059a57f46faa1647", size = 5467127, upload-time = "2026-10-02T17:42:25.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/93/084f10ebbecdedf7902ed68fbecc903f0e9c5742155bfffb05eccbc071da/virtualenv-21.14.5-py3-none-any.whl", hash = "sha256:b0651e0174982bba17cc6f0aaa2ef496730cb85a3f35eb5d1119d1c904f7d1da", size = 5487482, upload-time = "2026-10-02T17:42:23.38Z" }, +] From b1a9985ca037cc69d6e9898639a55e07f69c4bef Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Sun, 4 Oct 2026 17:03:04 +0330 Subject: [PATCH 67/87] Port dialect options to Python --- Cargo.lock | 10 ++ Cargo.toml | 2 +- pyproject.toml | 1 + rapidquery-macros/dialect_options.rs | 189 ++++++++++++++++++++++ rapidquery-macros/lib.rs | 14 ++ rapidquery/dialect.py | 228 +++++++++++++++++++++++++++ rapidquery/dialect.pyi | 223 ++++++++++++++++++++++++++ src/dialect/base.rs | 216 +++++++++++++++++++++++++ src/dialect/mod.rs | 5 + src/lib.rs | 1 - tests/test_dialect_options.py | 38 +++++ 11 files changed, 925 insertions(+), 2 deletions(-) create mode 100644 rapidquery-macros/dialect_options.rs create mode 100644 tests/test_dialect_options.py diff --git a/Cargo.lock b/Cargo.lock index 82809c4..236a477 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,6 +167,15 @@ dependencies = [ "cc", ] +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + [[package]] name = "ipnetwork" version = "0.20.0" @@ -305,6 +314,7 @@ checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b" dependencies = [ "chrono", "hashbrown", + "inventory", "libc", "once_cell", "portable-atomic", diff --git a/Cargo.toml b/Cargo.toml index f037087..369982c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ ipnetwork = { version = "0.20", default-features = false } mac_address = { version = "1.1", default-features = false } once_cell = { version = "1.21.4", default-features = false } parking_lot = { version = "0.12", default-features = false } -pyo3 = { version = "0.29", default-features = false, features = ["chrono", "hashbrown", "macros", "py-clone"] } +pyo3 = { version = "0.29", default-features = false, features = ["chrono", "hashbrown", "macros", "multiple-pymethods", "py-clone"] } rapidquery-macros = { path = "rapidquery-macros" } smallvec = { version = "1.16.0", default-features = false } uuid = { version = "1.26.1", default-features = false } diff --git a/pyproject.toml b/pyproject.toml index 18ace48..e14edd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,7 @@ target-version = "py310" [tool.ruff.lint] ignore = ["DTZ00"] +select = ["RUF022"] [tool.uv] required-version = ">=0.10.0" diff --git a/rapidquery-macros/dialect_options.rs b/rapidquery-macros/dialect_options.rs new file mode 100644 index 0000000..d8e4dd2 --- /dev/null +++ b/rapidquery-macros/dialect_options.rs @@ -0,0 +1,189 @@ +use proc_macro2::TokenStream; +use quote::format_ident; +use quote::quote; +use syn::Token; +use syn::parse::Parse; +use syn::parse::ParseStream; +use syn::punctuated::Punctuated; + +struct Options { + flags: Vec, +} + +enum Flag { + Char { field: syn::Ident, default: syn::LitChar }, + Enum(EnumFlag), + Bool { field: syn::Ident, default: syn::Expr }, +} + +struct EnumFlag { + ty: syn::Ident, + field: syn::Ident, + variants: Vec, +} + +struct Variant { + name: syn::Ident, + opt: syn::Ident, +} + +impl Parse for Variant { + fn parse(input: ParseStream) -> syn::Result { + let name: syn::Ident = input.parse()?; + input.parse::]>()?; + let opt: syn::Ident = input.parse()?; + Ok(Self { name, opt }) + } +} + +impl Parse for EnumFlag { + fn parse(input: ParseStream) -> syn::Result { + let ty: syn::Ident = input.parse()?; + let field: syn::Ident = input.parse()?; + let body; + syn::braced!(body in input); + let variants: Punctuated = body.parse_terminated(Variant::parse, Token![,])?; + if variants.is_empty() { + return Err(syn::Error::new(field.span(), "flag enum needs at least one variant")); + } + Ok(Self { + ty, + field, + variants: variants.into_iter().collect(), + }) + } +} + +impl Parse for Options { + fn parse(input: ParseStream) -> syn::Result { + let mut flags = Vec::new(); + while !input.is_empty() { + if input.peek(Token![enum]) { + input.parse::()?; + flags.push(Flag::Enum(input.parse()?)); + continue; + } + + let kind: syn::Ident = input.parse()?; + if kind == "char" { + let field: syn::Ident = input.parse()?; + input.parse::()?; + let default: syn::LitChar = input.parse()?; + input.parse::()?; + flags.push(Flag::Char { field, default }); + } else if kind == "bool" { + let field: syn::Ident = input.parse()?; + input.parse::()?; + let default: syn::Expr = input.parse()?; + input.parse::()?; + flags.push(Flag::Bool { field, default }); + } else { + return Err(syn::Error::new(kind.span(), "expected `char`, `enum`, or `bool`")); + } + } + Ok(Self { flags }) + } +} + +fn setter(field: &syn::Ident) -> syn::Ident { + syn::Ident::new(&format!("with_{field}"), field.span()) +} + +pub fn generate(input: TokenStream) -> syn::Result { + let options: Options = syn::parse2(input)?; + + let mut consts = TokenStream::new(); + let mut exports = TokenStream::new(); + let mut params = TokenStream::new(); + let mut args = TokenStream::new(); + let mut checks = TokenStream::new(); + let mut setters = TokenStream::new(); + let mut getters = TokenStream::new(); + + for flag in &options.flags { + match flag { + Flag::Char { field, default } => { + let set = setter(field); + let get = format_ident!("get_{field}"); + params.extend(quote! { #field = #default, }); + args.extend(quote! { #field: char, }); + setters.extend(quote! { .#set(#field as u32) }); + getters.extend(quote! { + #[getter] + fn #get(&self) -> char { + // SAFETY: the constructor stores a `char` cast to `u32`. + unsafe { char::from_u32_unchecked(self.0.#field()) } + } + }); + } + Flag::Enum(flag) => { + let ty = &flag.ty; + let field = &flag.field; + let set = setter(field); + let get = format_ident!("get_{field}"); + params.extend(quote! { #field = 0, }); + args.extend(quote! { #field: u8, }); + checks.extend(quote! { let #field = flags::#ty::from_bits(#field); }); + setters.extend(quote! { .#set(#field) }); + getters.extend(quote! { + #[getter] + fn #get(&self) -> u8 { + self.0.#field().into_bits() + } + }); + for variant in &flag.variants { + let name = &variant.name; + let opt = &variant.opt; + consts.extend(quote! { + pub(super) const #opt: u8 = flags::#ty::#name.into_bits(); + }); + exports.extend(quote! { + module.add(stringify!(#opt), #opt)?; + }); + } + } + Flag::Bool { field, default } => { + let set = setter(field); + let get = format_ident!("get_{field}"); + params.extend(quote! { #field = #default, }); + args.extend(quote! { #field: bool, }); + setters.extend(quote! { .#set(#field) }); + getters.extend(quote! { + #[getter] + fn #get(&self) -> bool { + self.0.#field() + } + }); + } + } + } + + Ok(quote! { + #consts + + pub(super) fn export_options( + module: &pyo3::Bound<'_, pyo3::types::PyModule>, + ) -> pyo3::PyResult<()> { + use pyo3::types::PyModuleMethods; + #exports + Ok(()) + } + + #[pyo3::pymethods] + impl PyDialect { + #[new] + #[pyo3(signature = (*, #params))] + #[allow( + clippy::too_many_arguments, + clippy::fn_params_excessive_bools, + clippy::cognitive_complexity + )] + fn __new__(#args) -> pyo3::PyResult { + #checks + Ok(Self(flags::DialectFlags::new() #setters)) + } + + #getters + } + }) +} diff --git a/rapidquery-macros/lib.rs b/rapidquery-macros/lib.rs index 8337422..2eba512 100644 --- a/rapidquery-macros/lib.rs +++ b/rapidquery-macros/lib.rs @@ -3,6 +3,8 @@ use quote::quote; use syn::parse::Parse; use syn::parse::ParseStream; +mod dialect_options; + struct PyClassInput { name: String, module: String, @@ -189,3 +191,15 @@ pub fn pyerr(item: TokenStream) -> TokenStream { constructed.into() } } + +/// Constructor arguments, getters, and `OPT_*` constants for dialect flags. +/// +/// Invoked from `src/dialect/base.rs`. Each item is `char name = '?';`, +/// `enum Type name { Variant => OPT_..., }`, or `bool name = false;`. +#[proc_macro] +pub fn dialect_options(input: TokenStream) -> TokenStream { + match dialect_options::generate(input.into()) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} diff --git a/rapidquery/dialect.py b/rapidquery/dialect.py index 35cfc36..2b814c9 100644 --- a/rapidquery/dialect.py +++ b/rapidquery/dialect.py @@ -3,6 +3,121 @@ """ from ._core.dialect import ( + # Dialect options + OPT_ARRAY_FORMAT_CONSTRUCTOR, + OPT_ARRAY_FORMAT_FUNCTION, + OPT_ARRAY_FORMAT_LIST, + OPT_ARRAY_FORMAT_UNSUPPORTED, + OPT_BIT_FORMAT_POSTFIX_BIG_B, + OPT_BIT_FORMAT_PREFIX_BIG_B, + OPT_BIT_FORMAT_PREFIX_SMALL_B, + OPT_BIT_FORMAT_UNSUPPORTED, + OPT_BITWISE_BIT_AND_ONLY, + OPT_BITWISE_BIT_SHIFT_FUNCTIONS, + OPT_BITWISE_INFIX, + OPT_BITWISE_INFIX_AND_SHIFT, + OPT_BITWISE_INFIX_NO_SHIFT, + OPT_BITWISE_SHIFT_FUNCTIONS, + OPT_BITWISE_UNSUPPORTED, + OPT_BITXOR_BIT_XOR_FUNCTION, + OPT_BITXOR_CARET, + OPT_BITXOR_HASH, + OPT_BITXOR_UNSUPPORTED, + OPT_BITXOR_XOR_FUNCTION, + OPT_BOOL_FORMAT_INTEGER, + OPT_BOOL_FORMAT_KEYWORD, + OPT_BYTES_FORMAT_HEX_TO_RAW, + OPT_BYTES_FORMAT_POSTFIX_XBV, + OPT_BYTES_FORMAT_PREFIX_BIG_B_SLASH_X, + OPT_BYTES_FORMAT_PREFIX_BIG_X, + OPT_BYTES_FORMAT_PREFIX_ZERO_X, + OPT_BYTES_FORMAT_SLASH_X_BLOB, + OPT_BYTES_FORMAT_SLASH_X_BYTEA, + OPT_BYTES_FORMAT_TO_BINARY, + OPT_BYTES_FORMAT_TO_VARBYTE, + OPT_BYTES_FORMAT_UNHEX, + OPT_COLLATE_FUNCTION_STRING, + OPT_COLLATE_INFIX_IDENT, + OPT_COLLATE_UNSUPPORTED, + OPT_DATE_FORMAT_KEYWORD, + OPT_DATE_FORMAT_QUOTED, + OPT_FUNCTION_FILTER_FILTER, + OPT_FUNCTION_FILTER_FILTER_WHERE, + OPT_FUNCTION_FILTER_INSIDE_WHERE, + OPT_FUNCTION_FILTER_UNSUPPORTED, + OPT_IDENTIFIER_ESCAPE_BACKSLASH, + OPT_IDENTIFIER_ESCAPE_DOUBLE, + OPT_IDENTIFIER_FORMAT_BACKTICK, + OPT_IDENTIFIER_FORMAT_DOUBLE_QUOTE, + OPT_IDENTIFIER_FORMAT_SQUARE_BRACKET, + OPT_ILIKE_EXPRESSION_STANDARD, + OPT_ILIKE_EXPRESSION_USE_LIKE, + OPT_ILIKE_EXPRESSION_USE_LIKE_LOWER, + OPT_INET_FORMAT_CAST, + OPT_INET_FORMAT_QUOTED, + OPT_INET_FORMAT_UNSUPPORTED, + OPT_IS_DISTINCT_FROM_NULL_SAFE_EQUAL, + OPT_IS_DISTINCT_FROM_STANDARD, + OPT_IS_DISTINCT_FROM_UNSUPPORTED, + OPT_JSON_FORMAT_JSON_KEYWORD, + OPT_JSON_FORMAT_PARSE_JSON, + OPT_JSON_FORMAT_QUOTED, + OPT_LIKE_ANY_ARRAY_CONSTRUCTOR, + OPT_LIKE_ANY_OR_EXPANSION, + OPT_LIKE_ANY_PATTERN_LIST, + OPT_LIKE_ESCAPE_INCLUDING_ANY, + OPT_LIKE_ESCAPE_SCALAR, + OPT_LIKE_ESCAPE_UNSUPPORTED, + OPT_MACADDR_FORMAT_CAST, + OPT_MACADDR_FORMAT_QUOTED, + OPT_MACADDR_FORMAT_UNSUPPORTED, + OPT_MODULO_FUNCTION, + OPT_MODULO_INFIX, + OPT_MODULO_PERCENT, + OPT_NULL_TREATMENT_INSIDE_PARENS, + OPT_NULL_TREATMENT_POSTFIX, + OPT_NULL_TREATMENT_UNSUPPORTED, + OPT_PLACEHOLDER_NUMBERED, + OPT_PLACEHOLDER_SIMPLE, + OPT_RLIKE_EXPRESSION_REGEXP_CONTAINS, + OPT_RLIKE_EXPRESSION_REGEXP_LIKE, + OPT_RLIKE_EXPRESSION_REGEXP_MATCHES, + OPT_RLIKE_EXPRESSION_STANDARD, + OPT_RLIKE_EXPRESSION_TILDE, + OPT_RLIKE_EXPRESSION_UNSUPPORTED, + OPT_STRING_CONCAT_CONCAT_FUNCTION, + OPT_STRING_CONCAT_PIPES, + OPT_STRING_CONCAT_PLUS, + OPT_STRING_ESCAPE_BACKSLASH_HEX, + OPT_STRING_ESCAPE_BACKSLASH_HEX_ZERO, + OPT_STRING_ESCAPE_BACKSLASH_MYSQL, + OPT_STRING_ESCAPE_BACKSLASH_UNICODE, + OPT_STRING_ESCAPE_BACKSLASH_UNICODE_QUOTE, + OPT_STRING_ESCAPE_QUOTE, + OPT_STRING_FORMAT_PREFIX_BIG_E, + OPT_STRING_FORMAT_PREFIX_BIG_N, + OPT_STRING_FORMAT_SINGLE_QUOTE, + OPT_TIME_FORMAT_KEYWORD, + OPT_TIME_FORMAT_QUOTED, + OPT_TIMESTAMP_FORMAT_QUOTED, + OPT_TIMESTAMP_FORMAT_TIMESTAMPTZ, + OPT_TIMESTAMP_FORMAT_WITH_TIMEZONE, + OPT_TRIM_FUNCTION, + OPT_TRIM_SPACES_ONLY, + OPT_TRIM_SQL_FROM, + OPT_TRY_CAST_SAFE_CAST, + OPT_TRY_CAST_TRY_CAST, + OPT_TRY_CAST_TRY_CAST_WORD, + OPT_TRY_CAST_UNSUPPORTED, + OPT_TUPLE_PARENTHESES, + OPT_TUPLE_ROW, + OPT_TUPLE_ROW_REJECT_EMPTY, + OPT_TUPLE_STRUCT, + OPT_TUPLE_UNSUPPORTED, + OPT_UUID_FORMAT_CAST_UUID, + OPT_UUID_FORMAT_QUOTED, + OPT_UUID_FORMAT_UUID_KEYWORD, + # Dialects BigQuery, Databricks, Dialect, @@ -20,6 +135,119 @@ ) __all__ = [ + "OPT_ARRAY_FORMAT_CONSTRUCTOR", + "OPT_ARRAY_FORMAT_FUNCTION", + "OPT_ARRAY_FORMAT_LIST", + "OPT_ARRAY_FORMAT_UNSUPPORTED", + "OPT_BITWISE_BIT_AND_ONLY", + "OPT_BITWISE_BIT_SHIFT_FUNCTIONS", + "OPT_BITWISE_INFIX", + "OPT_BITWISE_INFIX_AND_SHIFT", + "OPT_BITWISE_INFIX_NO_SHIFT", + "OPT_BITWISE_SHIFT_FUNCTIONS", + "OPT_BITWISE_UNSUPPORTED", + "OPT_BITXOR_BIT_XOR_FUNCTION", + "OPT_BITXOR_CARET", + "OPT_BITXOR_HASH", + "OPT_BITXOR_UNSUPPORTED", + "OPT_BITXOR_XOR_FUNCTION", + "OPT_BIT_FORMAT_POSTFIX_BIG_B", + "OPT_BIT_FORMAT_PREFIX_BIG_B", + "OPT_BIT_FORMAT_PREFIX_SMALL_B", + "OPT_BIT_FORMAT_UNSUPPORTED", + "OPT_BOOL_FORMAT_INTEGER", + "OPT_BOOL_FORMAT_KEYWORD", + "OPT_BYTES_FORMAT_HEX_TO_RAW", + "OPT_BYTES_FORMAT_POSTFIX_XBV", + "OPT_BYTES_FORMAT_PREFIX_BIG_B_SLASH_X", + "OPT_BYTES_FORMAT_PREFIX_BIG_X", + "OPT_BYTES_FORMAT_PREFIX_ZERO_X", + "OPT_BYTES_FORMAT_SLASH_X_BLOB", + "OPT_BYTES_FORMAT_SLASH_X_BYTEA", + "OPT_BYTES_FORMAT_TO_BINARY", + "OPT_BYTES_FORMAT_TO_VARBYTE", + "OPT_BYTES_FORMAT_UNHEX", + "OPT_COLLATE_FUNCTION_STRING", + "OPT_COLLATE_INFIX_IDENT", + "OPT_COLLATE_UNSUPPORTED", + "OPT_DATE_FORMAT_KEYWORD", + "OPT_DATE_FORMAT_QUOTED", + "OPT_FUNCTION_FILTER_FILTER", + "OPT_FUNCTION_FILTER_FILTER_WHERE", + "OPT_FUNCTION_FILTER_INSIDE_WHERE", + "OPT_FUNCTION_FILTER_UNSUPPORTED", + "OPT_IDENTIFIER_ESCAPE_BACKSLASH", + "OPT_IDENTIFIER_ESCAPE_DOUBLE", + "OPT_IDENTIFIER_FORMAT_BACKTICK", + "OPT_IDENTIFIER_FORMAT_DOUBLE_QUOTE", + "OPT_IDENTIFIER_FORMAT_SQUARE_BRACKET", + "OPT_ILIKE_EXPRESSION_STANDARD", + "OPT_ILIKE_EXPRESSION_USE_LIKE", + "OPT_ILIKE_EXPRESSION_USE_LIKE_LOWER", + "OPT_INET_FORMAT_CAST", + "OPT_INET_FORMAT_QUOTED", + "OPT_INET_FORMAT_UNSUPPORTED", + "OPT_IS_DISTINCT_FROM_NULL_SAFE_EQUAL", + "OPT_IS_DISTINCT_FROM_STANDARD", + "OPT_IS_DISTINCT_FROM_UNSUPPORTED", + "OPT_JSON_FORMAT_JSON_KEYWORD", + "OPT_JSON_FORMAT_PARSE_JSON", + "OPT_JSON_FORMAT_QUOTED", + "OPT_LIKE_ANY_ARRAY_CONSTRUCTOR", + "OPT_LIKE_ANY_OR_EXPANSION", + "OPT_LIKE_ANY_PATTERN_LIST", + "OPT_LIKE_ESCAPE_INCLUDING_ANY", + "OPT_LIKE_ESCAPE_SCALAR", + "OPT_LIKE_ESCAPE_UNSUPPORTED", + "OPT_MACADDR_FORMAT_CAST", + "OPT_MACADDR_FORMAT_QUOTED", + "OPT_MACADDR_FORMAT_UNSUPPORTED", + "OPT_MODULO_FUNCTION", + "OPT_MODULO_INFIX", + "OPT_MODULO_PERCENT", + "OPT_NULL_TREATMENT_INSIDE_PARENS", + "OPT_NULL_TREATMENT_POSTFIX", + "OPT_NULL_TREATMENT_UNSUPPORTED", + "OPT_PLACEHOLDER_NUMBERED", + "OPT_PLACEHOLDER_SIMPLE", + "OPT_RLIKE_EXPRESSION_REGEXP_CONTAINS", + "OPT_RLIKE_EXPRESSION_REGEXP_LIKE", + "OPT_RLIKE_EXPRESSION_REGEXP_MATCHES", + "OPT_RLIKE_EXPRESSION_STANDARD", + "OPT_RLIKE_EXPRESSION_TILDE", + "OPT_RLIKE_EXPRESSION_UNSUPPORTED", + "OPT_STRING_CONCAT_CONCAT_FUNCTION", + "OPT_STRING_CONCAT_PIPES", + "OPT_STRING_CONCAT_PLUS", + "OPT_STRING_ESCAPE_BACKSLASH_HEX", + "OPT_STRING_ESCAPE_BACKSLASH_HEX_ZERO", + "OPT_STRING_ESCAPE_BACKSLASH_MYSQL", + "OPT_STRING_ESCAPE_BACKSLASH_UNICODE", + "OPT_STRING_ESCAPE_BACKSLASH_UNICODE_QUOTE", + "OPT_STRING_ESCAPE_QUOTE", + "OPT_STRING_FORMAT_PREFIX_BIG_E", + "OPT_STRING_FORMAT_PREFIX_BIG_N", + "OPT_STRING_FORMAT_SINGLE_QUOTE", + "OPT_TIMESTAMP_FORMAT_QUOTED", + "OPT_TIMESTAMP_FORMAT_TIMESTAMPTZ", + "OPT_TIMESTAMP_FORMAT_WITH_TIMEZONE", + "OPT_TIME_FORMAT_KEYWORD", + "OPT_TIME_FORMAT_QUOTED", + "OPT_TRIM_FUNCTION", + "OPT_TRIM_SPACES_ONLY", + "OPT_TRIM_SQL_FROM", + "OPT_TRY_CAST_SAFE_CAST", + "OPT_TRY_CAST_TRY_CAST", + "OPT_TRY_CAST_TRY_CAST_WORD", + "OPT_TRY_CAST_UNSUPPORTED", + "OPT_TUPLE_PARENTHESES", + "OPT_TUPLE_ROW", + "OPT_TUPLE_ROW_REJECT_EMPTY", + "OPT_TUPLE_STRUCT", + "OPT_TUPLE_UNSUPPORTED", + "OPT_UUID_FORMAT_CAST_UUID", + "OPT_UUID_FORMAT_QUOTED", + "OPT_UUID_FORMAT_UUID_KEYWORD", "BigQuery", "Databricks", "Dialect", diff --git a/rapidquery/dialect.pyi b/rapidquery/dialect.pyi index dbc4f74..f95a444 100644 --- a/rapidquery/dialect.pyi +++ b/rapidquery/dialect.pyi @@ -1,6 +1,229 @@ import typing +OPT_PLACEHOLDER_SIMPLE: typing.Final[int] +OPT_PLACEHOLDER_NUMBERED: typing.Final[int] +OPT_NULL_TREATMENT_UNSUPPORTED: typing.Final[int] +OPT_NULL_TREATMENT_INSIDE_PARENS: typing.Final[int] +OPT_NULL_TREATMENT_POSTFIX: typing.Final[int] +OPT_LIKE_ESCAPE_UNSUPPORTED: typing.Final[int] +OPT_LIKE_ESCAPE_SCALAR: typing.Final[int] +OPT_LIKE_ESCAPE_INCLUDING_ANY: typing.Final[int] +OPT_LIKE_ANY_OR_EXPANSION: typing.Final[int] +OPT_LIKE_ANY_ARRAY_CONSTRUCTOR: typing.Final[int] +OPT_LIKE_ANY_PATTERN_LIST: typing.Final[int] +OPT_ILIKE_EXPRESSION_STANDARD: typing.Final[int] +OPT_ILIKE_EXPRESSION_USE_LIKE_LOWER: typing.Final[int] +OPT_ILIKE_EXPRESSION_USE_LIKE: typing.Final[int] +OPT_RLIKE_EXPRESSION_UNSUPPORTED: typing.Final[int] +OPT_RLIKE_EXPRESSION_STANDARD: typing.Final[int] +OPT_RLIKE_EXPRESSION_TILDE: typing.Final[int] +OPT_RLIKE_EXPRESSION_REGEXP_LIKE: typing.Final[int] +OPT_RLIKE_EXPRESSION_REGEXP_CONTAINS: typing.Final[int] +OPT_RLIKE_EXPRESSION_REGEXP_MATCHES: typing.Final[int] +OPT_STRING_CONCAT_PIPES: typing.Final[int] +OPT_STRING_CONCAT_PLUS: typing.Final[int] +OPT_STRING_CONCAT_CONCAT_FUNCTION: typing.Final[int] +OPT_BITXOR_UNSUPPORTED: typing.Final[int] +OPT_BITXOR_CARET: typing.Final[int] +OPT_BITXOR_HASH: typing.Final[int] +OPT_BITXOR_XOR_FUNCTION: typing.Final[int] +OPT_BITXOR_BIT_XOR_FUNCTION: typing.Final[int] +OPT_MODULO_PERCENT: typing.Final[int] +OPT_MODULO_FUNCTION: typing.Final[int] +OPT_MODULO_INFIX: typing.Final[int] +OPT_IS_DISTINCT_FROM_UNSUPPORTED: typing.Final[int] +OPT_IS_DISTINCT_FROM_STANDARD: typing.Final[int] +OPT_IS_DISTINCT_FROM_NULL_SAFE_EQUAL: typing.Final[int] +OPT_TRY_CAST_UNSUPPORTED: typing.Final[int] +OPT_TRY_CAST_TRY_CAST: typing.Final[int] +OPT_TRY_CAST_TRY_CAST_WORD: typing.Final[int] +OPT_TRY_CAST_SAFE_CAST: typing.Final[int] +OPT_TRIM_SQL_FROM: typing.Final[int] +OPT_TRIM_FUNCTION: typing.Final[int] +OPT_TRIM_SPACES_ONLY: typing.Final[int] +OPT_COLLATE_UNSUPPORTED: typing.Final[int] +OPT_COLLATE_INFIX_IDENT: typing.Final[int] +OPT_COLLATE_FUNCTION_STRING: typing.Final[int] +OPT_BITWISE_UNSUPPORTED: typing.Final[int] +OPT_BITWISE_INFIX: typing.Final[int] +OPT_BITWISE_INFIX_AND_SHIFT: typing.Final[int] +OPT_BITWISE_INFIX_NO_SHIFT: typing.Final[int] +OPT_BITWISE_BIT_SHIFT_FUNCTIONS: typing.Final[int] +OPT_BITWISE_SHIFT_FUNCTIONS: typing.Final[int] +OPT_BITWISE_BIT_AND_ONLY: typing.Final[int] +OPT_TUPLE_UNSUPPORTED: typing.Final[int] +OPT_TUPLE_ROW: typing.Final[int] +OPT_TUPLE_ROW_REJECT_EMPTY: typing.Final[int] +OPT_TUPLE_STRUCT: typing.Final[int] +OPT_TUPLE_PARENTHESES: typing.Final[int] +OPT_BIT_FORMAT_UNSUPPORTED: typing.Final[int] +OPT_BIT_FORMAT_PREFIX_BIG_B: typing.Final[int] +OPT_BIT_FORMAT_PREFIX_SMALL_B: typing.Final[int] +OPT_BIT_FORMAT_POSTFIX_BIG_B: typing.Final[int] +OPT_BOOL_FORMAT_KEYWORD: typing.Final[int] +OPT_BOOL_FORMAT_INTEGER: typing.Final[int] +OPT_IDENTIFIER_FORMAT_DOUBLE_QUOTE: typing.Final[int] +OPT_IDENTIFIER_FORMAT_BACKTICK: typing.Final[int] +OPT_IDENTIFIER_FORMAT_SQUARE_BRACKET: typing.Final[int] +OPT_IDENTIFIER_ESCAPE_DOUBLE: typing.Final[int] +OPT_IDENTIFIER_ESCAPE_BACKSLASH: typing.Final[int] +OPT_STRING_FORMAT_SINGLE_QUOTE: typing.Final[int] +OPT_STRING_FORMAT_PREFIX_BIG_E: typing.Final[int] +OPT_STRING_FORMAT_PREFIX_BIG_N: typing.Final[int] +OPT_STRING_ESCAPE_QUOTE: typing.Final[int] +OPT_STRING_ESCAPE_BACKSLASH_HEX: typing.Final[int] +OPT_STRING_ESCAPE_BACKSLASH_MYSQL: typing.Final[int] +OPT_STRING_ESCAPE_BACKSLASH_UNICODE: typing.Final[int] +OPT_STRING_ESCAPE_BACKSLASH_HEX_ZERO: typing.Final[int] +OPT_STRING_ESCAPE_BACKSLASH_UNICODE_QUOTE: typing.Final[int] +OPT_BYTES_FORMAT_PREFIX_BIG_X: typing.Final[int] +OPT_BYTES_FORMAT_PREFIX_ZERO_X: typing.Final[int] +OPT_BYTES_FORMAT_SLASH_X_BYTEA: typing.Final[int] +OPT_BYTES_FORMAT_PREFIX_BIG_B_SLASH_X: typing.Final[int] +OPT_BYTES_FORMAT_HEX_TO_RAW: typing.Final[int] +OPT_BYTES_FORMAT_UNHEX: typing.Final[int] +OPT_BYTES_FORMAT_TO_VARBYTE: typing.Final[int] +OPT_BYTES_FORMAT_POSTFIX_XBV: typing.Final[int] +OPT_BYTES_FORMAT_TO_BINARY: typing.Final[int] +OPT_BYTES_FORMAT_SLASH_X_BLOB: typing.Final[int] +OPT_FUNCTION_FILTER_UNSUPPORTED: typing.Final[int] +OPT_FUNCTION_FILTER_FILTER: typing.Final[int] +OPT_FUNCTION_FILTER_FILTER_WHERE: typing.Final[int] +OPT_FUNCTION_FILTER_INSIDE_WHERE: typing.Final[int] +OPT_ARRAY_FORMAT_UNSUPPORTED: typing.Final[int] +OPT_ARRAY_FORMAT_CONSTRUCTOR: typing.Final[int] +OPT_ARRAY_FORMAT_LIST: typing.Final[int] +OPT_ARRAY_FORMAT_FUNCTION: typing.Final[int] +OPT_DATE_FORMAT_QUOTED: typing.Final[int] +OPT_DATE_FORMAT_KEYWORD: typing.Final[int] +OPT_TIME_FORMAT_QUOTED: typing.Final[int] +OPT_TIME_FORMAT_KEYWORD: typing.Final[int] +OPT_TIMESTAMP_FORMAT_QUOTED: typing.Final[int] +OPT_TIMESTAMP_FORMAT_TIMESTAMPTZ: typing.Final[int] +OPT_TIMESTAMP_FORMAT_WITH_TIMEZONE: typing.Final[int] +OPT_INET_FORMAT_UNSUPPORTED: typing.Final[int] +OPT_INET_FORMAT_QUOTED: typing.Final[int] +OPT_INET_FORMAT_CAST: typing.Final[int] +OPT_MACADDR_FORMAT_UNSUPPORTED: typing.Final[int] +OPT_MACADDR_FORMAT_QUOTED: typing.Final[int] +OPT_MACADDR_FORMAT_CAST: typing.Final[int] +OPT_UUID_FORMAT_QUOTED: typing.Final[int] +OPT_UUID_FORMAT_UUID_KEYWORD: typing.Final[int] +OPT_UUID_FORMAT_CAST_UUID: typing.Final[int] +OPT_JSON_FORMAT_QUOTED: typing.Final[int] +OPT_JSON_FORMAT_JSON_KEYWORD: typing.Final[int] +OPT_JSON_FORMAT_PARSE_JSON: typing.Final[int] + class Dialect: + def __new__( + cls, + *, + placeholder_character: str = "?", + placeholder: int = 0, + null_treatment: int = 0, + like_escape: int = 0, + like_any: int = 0, + ilike_expression: int = 0, + rlike_expression: int = 0, + string_concat: int = 0, + bitxor: int = 0, + modulo: int = 0, + is_distinct_from: int = 0, + try_cast: int = 0, + trim: int = 0, + collate: int = 0, + bitwise: int = 0, + tuple: int = 0, + bit_format: int = 0, + bool_format: int = 0, + identifier_format: int = 0, + identifier_escape: int = 0, + string_format: int = 0, + string_escape: int = 0, + bytes_format: int = 0, + function_filter: int = 0, + array_format: int = 0, + date_format: int = 0, + time_format: int = 0, + timestamp_format: int = 0, + inet_format: int = 0, + macaddr_format: int = 0, + uuid_format: int = 0, + json_format: int = 0, + supports_similar_to: bool = False, + parenthesize_in_between: bool = False, + supports_double_colon: bool = False, + ) -> typing.Self: ... + @property + def placeholder_character(self) -> str: ... + @property + def placeholder(self) -> int: ... + @property + def null_treatment(self) -> int: ... + @property + def like_escape(self) -> int: ... + @property + def like_any(self) -> int: ... + @property + def ilike_expression(self) -> int: ... + @property + def rlike_expression(self) -> int: ... + @property + def string_concat(self) -> int: ... + @property + def bitxor(self) -> int: ... + @property + def modulo(self) -> int: ... + @property + def is_distinct_from(self) -> int: ... + @property + def try_cast(self) -> int: ... + @property + def trim(self) -> int: ... + @property + def collate(self) -> int: ... + @property + def bitwise(self) -> int: ... + @property + def tuple(self) -> int: ... + @property + def bit_format(self) -> int: ... + @property + def bool_format(self) -> int: ... + @property + def identifier_format(self) -> int: ... + @property + def identifier_escape(self) -> int: ... + @property + def string_format(self) -> int: ... + @property + def string_escape(self) -> int: ... + @property + def bytes_format(self) -> int: ... + @property + def function_filter(self) -> int: ... + @property + def array_format(self) -> int: ... + @property + def date_format(self) -> int: ... + @property + def time_format(self) -> int: ... + @property + def timestamp_format(self) -> int: ... + @property + def inet_format(self) -> int: ... + @property + def macaddr_format(self) -> int: ... + @property + def uuid_format(self) -> int: ... + @property + def json_format(self) -> int: ... + @property + def supports_similar_to(self) -> bool: ... + @property + def parenthesize_in_between(self) -> bool: ... + @property + def supports_double_colon(self) -> bool: ... def __eq__(self, other: object) -> bool: ... def __hash__(self) -> int: ... diff --git a/src/dialect/base.rs b/src/dialect/base.rs index 6c3c7a0..ca51562 100644 --- a/src/dialect/base.rs +++ b/src/dialect/base.rs @@ -30,6 +30,222 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for PyDialect { } } +// One item is one constructor argument and one getter. +// Enum items also export `OPT_*` constants. +// Adding a flag is a new item here, plus the enum and the bitfield field. +rapidquery_macros::dialect_options! { + char placeholder_character = '?'; + + enum Placeholder placeholder { + Simple => OPT_PLACEHOLDER_SIMPLE, + Numbered => OPT_PLACEHOLDER_NUMBERED, + } + + enum NullTreatment null_treatment { + Unsupported => OPT_NULL_TREATMENT_UNSUPPORTED, + InsideParens => OPT_NULL_TREATMENT_INSIDE_PARENS, + Postfix => OPT_NULL_TREATMENT_POSTFIX, + } + + enum LikeEscape like_escape { + Unsupported => OPT_LIKE_ESCAPE_UNSUPPORTED, + Scalar => OPT_LIKE_ESCAPE_SCALAR, + IncludingAny => OPT_LIKE_ESCAPE_INCLUDING_ANY, + } + + enum LikeAny like_any { + OrExpansion => OPT_LIKE_ANY_OR_EXPANSION, + ArrayConstructor => OPT_LIKE_ANY_ARRAY_CONSTRUCTOR, + PatternList => OPT_LIKE_ANY_PATTERN_LIST, + } + + enum ILikeExpression ilike_expression { + Standard => OPT_ILIKE_EXPRESSION_STANDARD, + UseLikeLower => OPT_ILIKE_EXPRESSION_USE_LIKE_LOWER, + UseLike => OPT_ILIKE_EXPRESSION_USE_LIKE, + } + + enum RLikeExpression rlike_expression { + Unsupported => OPT_RLIKE_EXPRESSION_UNSUPPORTED, + Standard => OPT_RLIKE_EXPRESSION_STANDARD, + Tilde => OPT_RLIKE_EXPRESSION_TILDE, + RegexpLike => OPT_RLIKE_EXPRESSION_REGEXP_LIKE, + RegexpContains => OPT_RLIKE_EXPRESSION_REGEXP_CONTAINS, + RegexpMatches => OPT_RLIKE_EXPRESSION_REGEXP_MATCHES, + } + + enum StringConcat string_concat { + Pipes => OPT_STRING_CONCAT_PIPES, + Plus => OPT_STRING_CONCAT_PLUS, + ConcatFunction => OPT_STRING_CONCAT_CONCAT_FUNCTION, + } + + enum BitXor bitxor { + Unsupported => OPT_BITXOR_UNSUPPORTED, + Caret => OPT_BITXOR_CARET, + Hash => OPT_BITXOR_HASH, + XORFunction => OPT_BITXOR_XOR_FUNCTION, + BitXORFunction => OPT_BITXOR_BIT_XOR_FUNCTION, + } + + enum Modulo modulo { + Percent => OPT_MODULO_PERCENT, + Function => OPT_MODULO_FUNCTION, + Infix => OPT_MODULO_INFIX, + } + + enum IsDistinctFrom is_distinct_from { + Unsupported => OPT_IS_DISTINCT_FROM_UNSUPPORTED, + Standard => OPT_IS_DISTINCT_FROM_STANDARD, + NullSafeEqual => OPT_IS_DISTINCT_FROM_NULL_SAFE_EQUAL, + } + + enum TryCast try_cast { + Unsupported => OPT_TRY_CAST_UNSUPPORTED, + TryCast => OPT_TRY_CAST_TRY_CAST, + TryCastWord => OPT_TRY_CAST_TRY_CAST_WORD, + SafeCast => OPT_TRY_CAST_SAFE_CAST, + } + + enum Trim trim { + SqlFrom => OPT_TRIM_SQL_FROM, + Function => OPT_TRIM_FUNCTION, + SpacesOnly => OPT_TRIM_SPACES_ONLY, + } + + enum Collate collate { + Unsupported => OPT_COLLATE_UNSUPPORTED, + InfixIdent => OPT_COLLATE_INFIX_IDENT, + FunctionString => OPT_COLLATE_FUNCTION_STRING, + } + + enum Bitwise bitwise { + Unsupported => OPT_BITWISE_UNSUPPORTED, + Infix => OPT_BITWISE_INFIX, + InfixAndShift => OPT_BITWISE_INFIX_AND_SHIFT, + InfixNoShift => OPT_BITWISE_INFIX_NO_SHIFT, + BitShiftFunctions => OPT_BITWISE_BIT_SHIFT_FUNCTIONS, + ShiftFunctions => OPT_BITWISE_SHIFT_FUNCTIONS, + BitAndOnly => OPT_BITWISE_BIT_AND_ONLY, + } + + enum Tuple tuple { + Unsupported => OPT_TUPLE_UNSUPPORTED, + Row => OPT_TUPLE_ROW, + RowRejectEmpty => OPT_TUPLE_ROW_REJECT_EMPTY, + Struct => OPT_TUPLE_STRUCT, + Parentheses => OPT_TUPLE_PARENTHESES, + } + + enum BitFormat bit_format { + Unsupported => OPT_BIT_FORMAT_UNSUPPORTED, + PrefixBigB => OPT_BIT_FORMAT_PREFIX_BIG_B, + PrefixSmallB => OPT_BIT_FORMAT_PREFIX_SMALL_B, + PostfixBigB => OPT_BIT_FORMAT_POSTFIX_BIG_B, + } + + enum BoolFormat bool_format { + Keyword => OPT_BOOL_FORMAT_KEYWORD, + Integer => OPT_BOOL_FORMAT_INTEGER, + } + + enum IdentifierFormat identifier_format { + DoubleQuote => OPT_IDENTIFIER_FORMAT_DOUBLE_QUOTE, + Backtick => OPT_IDENTIFIER_FORMAT_BACKTICK, + SquareBracket => OPT_IDENTIFIER_FORMAT_SQUARE_BRACKET, + } + + enum IdentifierEscape identifier_escape { + Double => OPT_IDENTIFIER_ESCAPE_DOUBLE, + Backslash => OPT_IDENTIFIER_ESCAPE_BACKSLASH, + } + + enum StringFormat string_format { + SingleQuote => OPT_STRING_FORMAT_SINGLE_QUOTE, + PrefixBigE => OPT_STRING_FORMAT_PREFIX_BIG_E, + PrefixBigN => OPT_STRING_FORMAT_PREFIX_BIG_N, + } + + enum StringEscape string_escape { + Quote => OPT_STRING_ESCAPE_QUOTE, + BackslashHex => OPT_STRING_ESCAPE_BACKSLASH_HEX, + BackslashMysql => OPT_STRING_ESCAPE_BACKSLASH_MYSQL, + BackslashUnicode => OPT_STRING_ESCAPE_BACKSLASH_UNICODE, + BackslashHexZero => OPT_STRING_ESCAPE_BACKSLASH_HEX_ZERO, + BackslashUnicodeQuote => OPT_STRING_ESCAPE_BACKSLASH_UNICODE_QUOTE, + } + + enum BytesFormat bytes_format { + PrefixBigX => OPT_BYTES_FORMAT_PREFIX_BIG_X, + PrefixZeroX => OPT_BYTES_FORMAT_PREFIX_ZERO_X, + SlashXBytea => OPT_BYTES_FORMAT_SLASH_X_BYTEA, + PrefixBigBSlashX => OPT_BYTES_FORMAT_PREFIX_BIG_B_SLASH_X, + HexToRaw => OPT_BYTES_FORMAT_HEX_TO_RAW, + Unhex => OPT_BYTES_FORMAT_UNHEX, + ToVarbyte => OPT_BYTES_FORMAT_TO_VARBYTE, + PostfixXbv => OPT_BYTES_FORMAT_POSTFIX_XBV, + ToBinary => OPT_BYTES_FORMAT_TO_BINARY, + SlashXBlob => OPT_BYTES_FORMAT_SLASH_X_BLOB, + } + + enum FunctionFilter function_filter { + Unsupported => OPT_FUNCTION_FILTER_UNSUPPORTED, + Filter => OPT_FUNCTION_FILTER_FILTER, + FilterWhere => OPT_FUNCTION_FILTER_FILTER_WHERE, + InsideWhere => OPT_FUNCTION_FILTER_INSIDE_WHERE, + } + + enum ArrayFormat array_format { + Unsupported => OPT_ARRAY_FORMAT_UNSUPPORTED, + Constructor => OPT_ARRAY_FORMAT_CONSTRUCTOR, + List => OPT_ARRAY_FORMAT_LIST, + Function => OPT_ARRAY_FORMAT_FUNCTION, + } + + enum DateFormat date_format { + Quoted => OPT_DATE_FORMAT_QUOTED, + Keyword => OPT_DATE_FORMAT_KEYWORD, + } + + enum TimeFormat time_format { + Quoted => OPT_TIME_FORMAT_QUOTED, + Keyword => OPT_TIME_FORMAT_KEYWORD, + } + + enum TimestampFormat timestamp_format { + Quoted => OPT_TIMESTAMP_FORMAT_QUOTED, + Timestamptz => OPT_TIMESTAMP_FORMAT_TIMESTAMPTZ, + WithTimezone => OPT_TIMESTAMP_FORMAT_WITH_TIMEZONE, + } + + enum InetFormat inet_format { + Unsupported => OPT_INET_FORMAT_UNSUPPORTED, + Quoted => OPT_INET_FORMAT_QUOTED, + Cast => OPT_INET_FORMAT_CAST, + } + + enum MacAddrFormat macaddr_format { + Unsupported => OPT_MACADDR_FORMAT_UNSUPPORTED, + Quoted => OPT_MACADDR_FORMAT_QUOTED, + Cast => OPT_MACADDR_FORMAT_CAST, + } + + enum UuidFormat uuid_format { + Quoted => OPT_UUID_FORMAT_QUOTED, + UuidKeyword => OPT_UUID_FORMAT_UUID_KEYWORD, + CastUuid => OPT_UUID_FORMAT_CAST_UUID, + } + + enum JsonFormat json_format { + Quoted => OPT_JSON_FORMAT_QUOTED, + JsonKeyword => OPT_JSON_FORMAT_JSON_KEYWORD, + ParseJson => OPT_JSON_FORMAT_PARSE_JSON, + } + + bool supports_similar_to = false; + bool parenthesize_in_between = false; + bool supports_double_colon = false; +} #[pyo3::pymethods] impl PyDialect { fn __eq__(&self, other: &pyo3::Bound<'_, Self>) -> bool { diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 9c682e9..caaa69b 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -75,4 +75,9 @@ pub mod dialect_mod { #[pymodule_export] #[allow(non_upper_case_globals)] const Teradata: super::PyDialect = super::TERADATA_DIALECT; + + #[pymodule_init] + fn init(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> { + super::base::export_options(m) + } } diff --git a/src/lib.rs b/src/lib.rs index f2b9cff..4b0d63c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,7 +10,6 @@ pub mod sql; pub mod typeref; // Tasks until being stable (until this part) -// - Restore GenericDialect ( very helpful for testing ) // - Test and coverage everything and edge cases // - Update docstrings / doccomments diff --git a/tests/test_dialect_options.py b/tests/test_dialect_options.py new file mode 100644 index 0000000..dbcef16 --- /dev/null +++ b/tests/test_dialect_options.py @@ -0,0 +1,38 @@ +from rapidquery.dialect import ( + OPT_BOOL_FORMAT_INTEGER, + OPT_LIKE_ESCAPE_INCLUDING_ANY, + OPT_LIKE_ESCAPE_SCALAR, + OPT_PLACEHOLDER_NUMBERED, + OPT_PLACEHOLDER_SIMPLE, + Dialect, + PostgreSql, +) + + +def test_getters_round_trip(): + dialect = Dialect( + placeholder_character="$", + placeholder=OPT_PLACEHOLDER_NUMBERED, + like_escape=OPT_LIKE_ESCAPE_INCLUDING_ANY, + bool_format=OPT_BOOL_FORMAT_INTEGER, + supports_double_colon=True, + ) + assert dialect.placeholder_character == "$" + assert dialect.placeholder == OPT_PLACEHOLDER_NUMBERED + assert dialect.like_escape == OPT_LIKE_ESCAPE_INCLUDING_ANY + assert dialect.bool_format == OPT_BOOL_FORMAT_INTEGER + assert dialect.supports_double_colon is True + assert dialect.supports_similar_to is False + assert dialect.parenthesize_in_between is False + + default = Dialect() + assert default.placeholder_character == "?" + assert default.placeholder == OPT_PLACEHOLDER_SIMPLE + assert default.supports_double_colon is False + + +def test_builtin_getters(): + assert PostgreSql.placeholder_character == "$" + assert PostgreSql.placeholder == OPT_PLACEHOLDER_NUMBERED + assert PostgreSql.supports_double_colon is True + assert PostgreSql.like_escape == OPT_LIKE_ESCAPE_SCALAR From 93e189ea7a164caa9a0cd1be869d3a3f741cc158 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Sun, 4 Oct 2026 19:11:40 +0330 Subject: [PATCH 68/87] Accept anything instead of only conditions in Function.filter and CaseExpr.when --- rapidquery/expr.pyi | 4 ++-- src/expr/enum_/mod.rs | 7 ------- src/expr/extractors.rs | 26 -------------------------- src/expr/func.rs | 3 +-- src/expr/nodes.rs | 3 +-- src/lib.rs | 4 ++-- tests/test_expr.py | 18 +++++------------- 7 files changed, 11 insertions(+), 54 deletions(-) diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index bb4307d..32ceea4 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -56,7 +56,7 @@ class Function(Expr): *args: object, distinct: bool = False, ) -> Self: ... - def filter(self, predicate: Expr, /) -> Self: ... + def filter(self, predicate: object, /) -> Self: ... def ignore_nulls(self) -> Self: ... def respect_nulls(self) -> Self: ... def get_name(self) -> Ident: ... @@ -216,7 +216,7 @@ class CollateExpr(Expr): @final class CaseExpr(Expr): def __new__(cls, operand: object | None = None, /) -> Self: ... - def when(self, when: Expr, then: object) -> Self: ... + def when(self, when: object, then: object) -> Self: ... def else_(self, result: object) -> Self: ... def get_operand(self) -> Expr | None: ... def get_when_list(self) -> tuple[tuple[Expr, Expr], ...]: ... diff --git a/src/expr/enum_/mod.rs b/src/expr/enum_/mod.rs index 7b5d259..5b632d8 100644 --- a/src/expr/enum_/mod.rs +++ b/src/expr/enum_/mod.rs @@ -93,13 +93,6 @@ impl_from_expr_inner! { } impl ExprInner { - /// `true` when this node can appear as a SQL condition (`WHERE`, - /// `WHEN`, `FILTER`). Identifiers and bound values are not conditions. - #[inline] - pub(crate) fn is_condition(&self) -> bool { - !matches!(self, Self::Ident(_) | Self::Value(_)) - } - /// Lower is tighter binding. #[inline] fn precedence(&self) -> u8 { diff --git a/src/expr/extractors.rs b/src/expr/extractors.rs index c7905bd..fb8e6a4 100644 --- a/src/expr/extractors.rs +++ b/src/expr/extractors.rs @@ -31,9 +31,6 @@ pub struct LiteralExprExtractor(pub ExprInner); /// `Asterisk`, and `type[Asterisk]`. Sequences are rejected. pub struct IdentExtractor(pub Ident); -/// Accepts `PyExpr` only when it is a condition, not a value or ident. -pub struct ConditionExtractor(pub ExprInner); - fn extract_expr( obj: pyo3::prelude::Borrowed<'_, '_, pyo3::prelude::PyAny>, str_as_ident: bool, @@ -203,26 +200,3 @@ impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for IdentExtractor { } } } - -impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for ConditionExtractor { - type Error = pyo3::PyErr; - - #[inline] - fn extract(obj: pyo3::prelude::Borrowed<'a, 'py, pyo3::prelude::PyAny>) -> Result { - let ptr = obj.as_ptr(); - unsafe { - if crate::typeref::check(ptr, crate::typeref::EXPR_TYPE) { - let expr = obj.cast_unchecked::(); - let inner = &expr.get().0; - - if inner.is_condition() { - return Ok(Self(inner.clone())); - } - } - } - - rapidquery_macros::pyerr!(Err(PyTypeError), "expected a condition expression, got {}", unsafe { - crate::internal::utils::get_type_name(obj.py(), obj.as_ptr()) - }) - } -} diff --git a/src/expr/func.rs b/src/expr/func.rs index 7417a94..97fdfee 100644 --- a/src/expr/func.rs +++ b/src/expr/func.rs @@ -6,7 +6,6 @@ use crate::internal::utils; use super::base::PyExpr; use super::enum_::NullTreatment; -use super::extractors::ConditionExtractor; use super::extractors::IdentExtractor; use super::extractors::LiteralExprExtractor; @@ -71,7 +70,7 @@ impl PyFunction { Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) } - fn filter(slf: pyo3::PyRef<'_, Self>, predicate: ConditionExtractor) -> pyo3::PyResult { + fn filter(slf: pyo3::PyRef<'_, Self>, predicate: LiteralExprExtractor) -> pyo3::PyResult { let mut inner = Self::inner(&slf).clone(); inner.filter = Some(Box::new(predicate.0)); Self::wrap(slf.py(), inner) diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index 11f3cba..f05271b 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -3,7 +3,6 @@ use pyo3::types::PyTupleMethods; use super::base::PyExpr; use super::enum_::ExprInner; -use super::extractors::ConditionExtractor; use super::extractors::IdentExtractor; use super::extractors::LiteralExprExtractor; use super::extractors::extract_like_pattern; @@ -585,7 +584,7 @@ impl PyCaseExpr { fn when( slf: pyo3::PyRef<'_, Self>, - when: ConditionExtractor, + when: LiteralExprExtractor, then: LiteralExprExtractor, ) -> pyo3::PyResult { let mut inner = Self::inner(&slf).clone(); diff --git a/src/lib.rs b/src/lib.rs index 4b0d63c..9ba475b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,10 +14,10 @@ pub mod typeref; // - Update docstrings / doccomments // Later tasks: -// - Complete `Expr` methods -// - Test `Expr` methods // - Benchmark and compare with old version // - Open PR +// - Complete `Expr` methods +// - Support customization protocols (e.g. __serializer__, __expr__, __ident__) // - Start writing query ASTs. /// RapidQuery native extension. diff --git a/tests/test_expr.py b/tests/test_expr.py index 672f101..56a56c9 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -158,11 +158,8 @@ def test_edge_cases(self): with pytest.raises(ValueError, match="empty"): Function("") - with pytest.raises(TypeError, match="condition"): - Function("now").filter(Ident("x")) # pyright: ignore - - with pytest.raises(TypeError, match="condition"): - Function("now").filter(1) # pyright: ignore + Function("now").filter(Ident("x")) + Function("now").filter(1) fn = Function("f", "id") assert fn.get_args() == (Value("id"),) @@ -804,14 +801,9 @@ def test_new(self): simple = CaseExpr(Ident("status")) assert simple.get_operand() == Ident("status") - with pytest.raises(TypeError, match="condition"): - CaseExpr().when(Ident("x"), 1) # pyright: ignore - - with pytest.raises(TypeError, match="condition"): - CaseExpr().when(Value(1), 1) # pyright: ignore - - with pytest.raises(TypeError, match="condition"): - CaseExpr().when(1, 2) # pyright: ignore + CaseExpr().when(Ident("x"), 1) + CaseExpr().when(Value(1), 1) + CaseExpr().when(1, 2) def test_to_sql(self): assert CaseExpr().to_sql(PostgreSql) == "CASE END" From cef6f8aec534ad277996c1f006a7bb5f13c1bf07 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 5 Oct 2026 13:20:16 +0330 Subject: [PATCH 69/87] Cover DialectFlags edge cases --- Makefile | 1 + src/dialect/base.rs | 1 + src/dialect/flags.rs | 22 -- src/dialect/mod.rs | 3 + src/dialect/tests.rs | 733 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 738 insertions(+), 22 deletions(-) create mode 100644 src/dialect/tests.rs diff --git a/Makefile b/Makefile index 7e4144a..8e63963 100644 --- a/Makefile +++ b/Makefile @@ -54,6 +54,7 @@ lint: lint-python lint-rust .PHONY: test ## Run all tests test: + cargo test --workspace --all --no-default-features --features auto-initialize uv run pytest .PHONY: all ## Run the standard set of checks performed in CI diff --git a/src/dialect/base.rs b/src/dialect/base.rs index ca51562..40918b7 100644 --- a/src/dialect/base.rs +++ b/src/dialect/base.rs @@ -246,6 +246,7 @@ rapidquery_macros::dialect_options! { bool parenthesize_in_between = false; bool supports_double_colon = false; } + #[pyo3::pymethods] impl PyDialect { fn __eq__(&self, other: &pyo3::Bound<'_, Self>) -> bool { diff --git a/src/dialect/flags.rs b/src/dialect/flags.rs index 3a88b37..0245677 100644 --- a/src/dialect/flags.rs +++ b/src/dialect/flags.rs @@ -966,25 +966,3 @@ fn write_backslash_hex_zero(writer: &mut W, string: &str) -> _ => SpanEscape::Keep, }) } - -#[cfg(test)] -mod tests { - use super::*; - - fn collect(write: impl FnOnce(&mut String) -> Result<(), FormatError>) -> String { - let mut buf = String::new(); - write(&mut buf).unwrap(); - buf - } - - #[test] - fn test_bool_format() { - let cfg = DialectFlags::new().with_bool_format(BoolFormat::Integer); - assert_eq!(collect(|x| cfg.format_boolean(x, true)), "1"); - assert_eq!(collect(|x| cfg.format_boolean(x, false)), "0"); - - let cfg = cfg.with_bool_format(BoolFormat::Keyword); - assert_eq!(collect(|x| cfg.format_boolean(x, true)), "TRUE"); - assert_eq!(collect(|x| cfg.format_boolean(x, false)), "FALSE"); - } -} diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index caaa69b..0c12063 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -4,6 +4,9 @@ pub mod flags; mod base; mod builtin; +#[cfg(test)] +mod tests; + pub use base::PyDialect; pub use builtin::BIGQUERY_DIALECT; pub use builtin::DATABRICKS_DIALECT; diff --git a/src/dialect/tests.rs b/src/dialect/tests.rs new file mode 100644 index 0000000..ced8207 --- /dev/null +++ b/src/dialect/tests.rs @@ -0,0 +1,733 @@ +use super::*; + +#[inline] +fn bool_flags(format: flags::BoolFormat) -> flags::DialectFlags { + flags::DialectFlags::new().with_bool_format(format) +} + +#[inline] +fn string_flags(format: flags::StringFormat, escape: flags::StringEscape) -> flags::DialectFlags { + flags::DialectFlags::new() + .with_string_format(format) + .with_string_escape(escape) +} + +#[inline] +fn ident_flags(format: flags::IdentifierFormat, escape: flags::IdentifierEscape) -> flags::DialectFlags { + flags::DialectFlags::new() + .with_identifier_format(format) + .with_identifier_escape(escape) +} + +fn write_str(flags: &flags::DialectFlags, raw: &str) -> Result { + let mut out = String::new(); + flags.write_string_literal(&mut out, raw)?; + Ok(out) +} + +fn escape(flags: &flags::DialectFlags, raw: &str) -> String { + let mut out = String::new(); + flags.escape_string(&mut out, raw).unwrap(); + out +} + +fn quote_ident(flags: &flags::DialectFlags, ident: &str) -> String { + let mut out = String::new(); + flags.quote_identifier(&mut out, ident).unwrap(); + out +} + +/// A writer that fails on the Nth `write_str` / `write_char` (1-based). +struct FailAfter { + remaining: usize, +} + +impl std::fmt::Write for FailAfter { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + if s.is_empty() { + return Ok(()); + } + if self.remaining == 0 { + return Err(std::fmt::Error); + } + self.remaining -= 1; + Ok(()) + } +} + +/// Standard-SQL string lexer: quotes doubled, no backslash escapes. +/// Returns the unescaped body if `sql` is exactly one literal. +fn parse_quote_doubled(sql: &str) -> Option { + let rest = sql.strip_prefix('\'')?; + let mut body = String::new(); + let mut chars = rest.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\'' { + if chars.peek() == Some(&'\'') { + chars.next(); + body.push('\''); + } else { + return chars.next().is_none().then_some(body); + } + } else { + body.push(ch); + } + } + None +} + +/// MySQL-style backslash lexer (NO_BACKSLASH_ESCAPES off). Unknown escapes +/// keep the escaped byte. Returns the body if the literal closes exactly once. +fn parse_backslash(sql: &str) -> Option { + let rest = sql.strip_prefix('\'')?; + let bytes = rest.as_bytes(); + let mut i = 0; + let mut body = Vec::new(); + while i < bytes.len() { + match bytes[i] { + b'\'' => { + i += 1; + return (i == bytes.len()).then(|| String::from_utf8(body).ok()).flatten(); + } + b'\\' => { + if i + 1 >= bytes.len() { + return None; + } + body.push(bytes[i + 1]); + i += 2; + } + b => { + body.push(b); + i += 1; + } + } + } + None +} + +fn assert_no_early_close_quote(escaped: &str) { + let mut chars = escaped.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\'' { + assert_eq!( + chars.next(), + Some('\''), + "odd quote in quote-doubled escape: {escaped:?}" + ); + } + } +} + +const BREAKOUTS: &[&str] = &[ + "'", + "''", + "' OR '1'='1", + "' OR 1=1 --", + "'; DROP TABLE users; --", + "\\", + "\\'", + "\\\\", + "'''", + "admin'--", + "1' AND SLEEP(1)--", + "\" OR \"1\"=\"1", + "') OR ('1'='1", + "\n' OR 1=1 --", + "\r\n", + "foo\tbar", + "%' OR 1=1 --", + "_", + "/*", + "*/", + "--", + "#", + "\u{1a}", + "\u{7f}", + "\u{01}", + "cafĆ©", + "I ā¤ļø SQL", + "\u{202e}1=1 RO", + "’", // U+2019, not a SQL quote + "'", // U+FF0C fullwidth is not the quote; fullwidth apostrophe is U+FF07 + "'", + "\u{ff07}", +]; + +#[test] +fn format_boolean_keyword_and_integer() { + let keyword = bool_flags(flags::BoolFormat::Keyword); + let integer = bool_flags(flags::BoolFormat::Integer); + + let mut out = String::new(); + keyword.format_boolean(&mut out, true).unwrap(); + keyword.format_boolean(&mut out, false).unwrap(); + assert_eq!(out, "TRUEFALSE"); + + out.clear(); + integer.format_boolean(&mut out, true).unwrap(); + integer.format_boolean(&mut out, false).unwrap(); + assert_eq!(out, "10"); +} + +#[test] +fn format_boolean_does_not_emit_quotes_or_lowercase() { + // MySQL may accept the integers; neither spelling is a string, so a + // caller that interpolates them into a quoted context would be wrong. + for value in [false, true] { + let mut keyword = String::new(); + let mut integer = String::new(); + bool_flags(flags::BoolFormat::Keyword) + .format_boolean(&mut keyword, value) + .unwrap(); + + bool_flags(flags::BoolFormat::Integer) + .format_boolean(&mut integer, value) + .unwrap(); + + assert!(!keyword.contains('\'')); + assert!(!integer.contains('\'')); + assert_eq!(keyword, keyword.to_ascii_uppercase()); + assert!(integer == "0" || integer == "1"); + } +} + +#[test] +fn format_boolean_propagates_writer_errors() { + let mut writer = FailAfter { remaining: 0 }; + let err = bool_flags(flags::BoolFormat::Keyword) + .format_boolean(&mut writer, true) + .unwrap_err(); + + assert_eq!(err, errors::FormatError::Write); +} + +#[test] +fn escape_string_quote_doubles_and_leaves_the_rest() { + let flags = string_flags(flags::StringFormat::SingleQuote, flags::StringEscape::Quote); + assert_eq!(escape(&flags, ""), ""); + assert_eq!(escape(&flags, "plain"), "plain"); + assert_eq!(escape(&flags, "'"), "''"); + assert_eq!(escape(&flags, "''"), "''''"); + assert_eq!(escape(&flags, "a'b'c"), "a''b''c"); + // Backslash is data under standard SQL. Leaving it alone is correct for + // Postgres and wrong for MySQL with backslash escapes enabled. + assert_eq!(escape(&flags, "\\'"), "\\''"); + assert_eq!(escape(&flags, "\0"), "\0"); + assert_eq!(escape(&flags, "\n\r\t"), "\n\r\t"); + assert_eq!(escape(&flags, "100%_"), "100%_"); + assert_eq!(escape(&flags, "雪's"), "雪''s"); +} + +#[test] +fn escape_string_backslash_hex_covers_c0_and_del() { + let flags = string_flags(flags::StringFormat::SingleQuote, flags::StringEscape::BackslashHex); + assert_eq!(escape(&flags, "\\"), "\\\\"); + assert_eq!(escape(&flags, "'"), "\\'"); + assert_eq!(escape(&flags, "\n\r\t"), "\\n\\r\\t"); + assert_eq!(escape(&flags, "\0"), "\\x00"); + assert_eq!(escape(&flags, "\u{1}"), "\\x01"); + assert_eq!(escape(&flags, "\u{1a}"), "\\x1A"); + assert_eq!(escape(&flags, "\u{1f}"), "\\x1F"); + assert_eq!(escape(&flags, "\u{7f}"), "\\x7F"); + // Space, DEL-adjacent printable, and non-ASCII stay raw. Splitting a + // multibyte char here would corrupt the literal (and the unsafe span + // writer assumes ASCII split points). + assert_eq!(escape(&flags, " ~"), " ~"); + assert_eq!(escape(&flags, "雪's\\"), "雪\\'s\\\\"); + assert_eq!(escape(&flags, "\"quote"), "\"quote"); +} + +#[test] +fn escape_string_mysql_keeps_tab_and_uses_z_for_sub() { + let flags = string_flags(flags::StringFormat::SingleQuote, flags::StringEscape::BackslashMysql); + assert_eq!(escape(&flags, "\\'\""), "\\\\\\'\\\""); + assert_eq!(escape(&flags, "\0"), "\\0"); + assert_eq!(escape(&flags, "\n\r"), "\\n\\r"); + assert_eq!(escape(&flags, "\t"), "\t"); + assert_eq!(escape(&flags, "\u{1a}"), "\\Z"); + // Other C0 bytes pass through. A strict ANSI client may reject them; + // MySQL's NO_BACKSLASH_ESCAPES-off lexer does not treat them as quotes. + assert_eq!(escape(&flags, "\u{01}"), "\u{01}"); + assert_eq!(escape(&flags, "\u{7f}"), "\u{7f}"); + assert_eq!(escape(&flags, "it\'s"), "it\\'s"); +} + +#[test] +fn escape_string_unicode_and_hex_zero_disagree_on_nul_and_quotes() { + let unicode = string_flags(flags::StringFormat::SingleQuote, flags::StringEscape::BackslashUnicode); + let unicode_quote = string_flags( + flags::StringFormat::SingleQuote, + flags::StringEscape::BackslashUnicodeQuote, + ); + let hex_zero = string_flags(flags::StringFormat::SingleQuote, flags::StringEscape::BackslashHexZero); + + assert_eq!(escape(&unicode, "\0"), "\\0"); + assert_eq!(escape(&unicode, "\u{01}"), "\\u0001"); + assert_eq!(escape(&unicode, "\u{1a}"), "\\u001A"); + assert_eq!(escape(&unicode, "\u{7f}"), "\\u007F"); + assert_eq!(escape(&unicode, "\"'"), "\"\\'"); + assert_eq!(escape(&unicode_quote, "\"'"), "\\\"\\'"); + assert_eq!(escape(&hex_zero, "\0"), "\\0"); + assert_eq!(escape(&hex_zero, "\u{01}"), "\\x01"); + assert_eq!(escape(&hex_zero, "\u{7f}"), "\\x7F"); + assert_eq!(escape(&hex_zero, "\t\n"), "\\t\\n"); +} + +#[test] +fn escape_string_does_not_wrap_or_reject_nul() { + // NUL rejection belongs to `write_string_literal`. `escape_string` is the + // inner transform and must stay usable for already-open literals. + let flags = string_flags(flags::StringFormat::SingleQuote, flags::StringEscape::Quote); + let escaped = escape(&flags, "a\0b"); + assert_eq!(escaped, "a\0b"); + assert!(!escaped.starts_with('\'')); +} + +#[test] +fn write_string_literal_prefixes_and_empty() { + let cases = [ + (flags::StringFormat::SingleQuote, "''"), + (flags::StringFormat::PrefixBigE, "E''"), + (flags::StringFormat::PrefixBigN, "N''"), + ]; + for (format, expected) in cases { + let flags = string_flags(format, flags::StringEscape::Quote); + assert_eq!(write_str(&flags, "").unwrap(), expected); + assert_eq!(write_str(&flags, "a").unwrap(), expected.replace("''", "'a'")); + } +} + +#[test] +fn write_string_literal_rejects_nul_before_writing() { + let mut out = String::from("kept"); + let flags = string_flags(flags::StringFormat::PrefixBigE, flags::StringEscape::BackslashHex); + let err = flags.write_string_literal(&mut out, "a\0b").unwrap_err(); + assert_eq!(err, errors::FormatError::NulInString); + // C drivers (historical libmysql) truncate at NUL. Refusing before any + // write avoids a half-emitted literal that a caller might still send. + assert_eq!(out, "kept"); + assert!(flags.write_string_literal(&mut String::new(), "\0").is_err()); +} + +#[test] +fn write_string_literal_quote_mode_survives_classic_payloads() { + let flags = string_flags(flags::StringFormat::SingleQuote, flags::StringEscape::Quote); + for payload in BREAKOUTS { + if payload.contains('\0') { + continue; + } + let sql = write_str(&flags, payload).unwrap(); + let body = parse_quote_doubled(&sql).unwrap_or_else(|| panic!("quote breakout for {payload:?} -> {sql:?}")); + assert_eq!(body, *payload); + assert_no_early_close_quote(&sql[1..sql.len() - 1]); + } + assert_eq!(write_str(&flags, "' OR '1'='1").unwrap(), "''' OR ''1''=''1'"); + assert_eq!( + write_str(&flags, "'; DROP TABLE users; --").unwrap(), + "'''; DROP TABLE users; --'" + ); +} + +#[test] +fn write_string_literal_backslash_modes_do_not_end_on_backslash_quote() { + // The MySQL bug: escaping only `'` as `\'` (or not escaping `\`) lets + // `\'` close the string. Every backslash mode must escape `\` first. + let modes = [ + flags::StringEscape::BackslashHex, + flags::StringEscape::BackslashMysql, + flags::StringEscape::BackslashUnicode, + flags::StringEscape::BackslashHexZero, + flags::StringEscape::BackslashUnicodeQuote, + ]; + for mode in modes { + let flags = string_flags(flags::StringFormat::SingleQuote, mode); + for payload in ["\\'", "\\\\'", "'", "\\", "a\\'b", "\n' OR 1=1 --"] { + let sql = write_str(&flags, payload).unwrap(); + assert!( + parse_backslash(&sql).is_some(), + "{mode:?} breakout for {payload:?} -> {sql:?}" + ); + // `\'` must not be a closer. The backslash is escaped, then the quote. + if payload.contains('\\') { + assert!(sql.contains("\\\\"), "{mode:?} left a raw backslash in {sql:?}"); + } + } + } +} + +#[test] +fn write_string_literal_quote_mode_is_not_safe_for_mysql_backslash() { + // Documents the mismatch. Under a MySQL lexer, quote-doubling alone does + // not neutralize a leading backslash: `\'` becomes `\''`, which is an + // escaped quote plus a closer, i.e. an empty string and trailing junk. + let flags = string_flags(flags::StringFormat::SingleQuote, flags::StringEscape::Quote); + let sql = write_str(&flags, "\\' OR 1=1 --").unwrap(); + assert_eq!(sql, "'\\'' OR 1=1 --'"); + assert!(parse_backslash(&sql).is_none()); +} + +#[test] +fn write_string_literal_national_and_escape_prefixes_still_close() { + let e = string_flags(flags::StringFormat::PrefixBigE, flags::StringEscape::BackslashHex); + let n = string_flags(flags::StringFormat::PrefixBigN, flags::StringEscape::Quote); + assert_eq!(write_str(&e, "a'b\\").unwrap(), "E'a\\'b\\\\'"); + assert_eq!(write_str(&n, "a'b").unwrap(), "N'a''b'"); + // Prefix is outside the quotes. A payload must not be able to eat it. + assert!(write_str(&e, "x").unwrap().starts_with("E'")); + assert!(write_str(&n, "x").unwrap().starts_with("N'")); +} + +#[test] +fn write_string_literal_passes_unicode_quotes_and_like_wildcards() { + let flags = string_flags(flags::StringFormat::SingleQuote, flags::StringEscape::Quote); + // U+2019 / U+FF07 are not the ASCII delimiter. Treating them as quotes + // would corrupt data; not treating ASCII `'` as a quote would be SQLi. + assert_eq!(write_str(&flags, "it’s").unwrap(), "'it’s'"); + assert_eq!(write_str(&flags, "\u{ff07}").unwrap(), "'\u{ff07}'"); + assert_eq!(write_str(&flags, "100%_").unwrap(), "'100%_'"); + // U+202E (right-to-left override) can spoof a log line. It is still data. + let spoof = write_str(&flags, "\u{202e}--").unwrap(); + assert_eq!(spoof, "'\u{202e}--'"); +} + +#[test] +fn quote_identifier_doubles_only_its_own_closer() { + let dq = ident_flags(flags::IdentifierFormat::DoubleQuote, flags::IdentifierEscape::Double); + let bt = ident_flags(flags::IdentifierFormat::Backtick, flags::IdentifierEscape::Double); + let br = ident_flags(flags::IdentifierFormat::SquareBracket, flags::IdentifierEscape::Double); + + assert_eq!(quote_ident(&dq, ""), "\"\""); + assert_eq!(quote_ident(&dq, "users"), "\"users\""); + assert_eq!(quote_ident(&dq, "a\"b"), "\"a\"\"b\""); + assert_eq!(quote_ident(&dq, "\""), "\"\"\"\""); + // The other delimiters are data. This is the Postgres / ANSI contract. + assert_eq!(quote_ident(&dq, "a`b]c'"), "\"a`b]c'\""); + + assert_eq!(quote_ident(&bt, "a`b"), "`a``b`"); + assert_eq!(quote_ident(&bt, "`"), "````"); + assert_eq!(quote_ident(&bt, "a\"b"), "`a\"b`"); + + assert_eq!(quote_ident(&br, "a]b"), "[a]]b]"); + assert_eq!(quote_ident(&br, "]"), "[]]]"); + assert_eq!(quote_ident(&br, "a[b"), "[a[b]"); + // SQL Server does not treat `[` as special inside brackets. Escaping it + // would change the identifier. + assert_eq!(quote_ident(&br, "dbo.users"), "[dbo.users]"); +} + +#[test] +fn quote_identifier_rejects_delimiter_breakout() { + let samples = [ + ( + ident_flags(flags::IdentifierFormat::DoubleQuote, flags::IdentifierEscape::Double), + "\"", + "\"", + '"', + ), + ( + ident_flags(flags::IdentifierFormat::Backtick, flags::IdentifierEscape::Double), + "`", + "`", + '`', + ), + ( + ident_flags(flags::IdentifierFormat::SquareBracket, flags::IdentifierEscape::Double), + "]", + "[", + ']', + ), + ]; + for (flags, closer, start, end) in samples { + for payload in [ + closer.to_string(), + format!("{closer}{closer}"), + format!("x{closer} OR 1=1 --"), + format!("{closer}; DROP TABLE users; --"), + ] { + let sql = quote_ident(&flags, &payload); + assert!( + sql.starts_with(start) && sql.ends_with(end), + "delimiters lost in {sql:?}" + ); + // Doubled closer never leaves a raw closer at an odd position. + let inner = &sql[start.len()..sql.len() - end.len_utf8()]; + let mut rest = inner; + let needle = closer.to_string(); + while let Some(at) = rest.find(&needle) { + assert!( + rest[at + needle.len()..].starts_with(&needle), + "undoubled closer in {sql:?}" + ); + rest = &rest[at + needle.len() * 2..]; + } + } + } +} + +#[test] +fn quote_identifier_backslash_modes() { + let dq = ident_flags(flags::IdentifierFormat::DoubleQuote, flags::IdentifierEscape::Backslash); + let bt = ident_flags(flags::IdentifierFormat::Backtick, flags::IdentifierEscape::Backslash); + let br = ident_flags( + flags::IdentifierFormat::SquareBracket, + flags::IdentifierEscape::Backslash, + ); + + assert_eq!(quote_ident(&dq, "a\"b"), "\"a\\\"b\""); + assert_eq!(quote_ident(&dq, "\\"), "\"\\\""); + // Standard SQL does not treat `\` as an identifier escape. `\"` still + // contains a raw closer, so this mode is only safe on engines that + // consume the backslash inside quoted identifiers (MySQL ANSI_QUOTES). + assert_eq!(quote_ident(&dq, "\""), "\"\\\"\""); + + assert_eq!(quote_ident(&bt, "a`b"), "`a\\`b`"); + assert_eq!(quote_ident(&bt, "\\"), "`\\\\`"); + assert_eq!(quote_ident(&bt, "\n\r\t"), "`\\n\\r\\t`"); + assert_eq!(quote_ident(&bt, "\u{01}"), "`\\x01`"); + assert_eq!(quote_ident(&bt, "\u{7f}"), "`\\x7F`"); + // Apostrophe is not the closer. Escaping it would change the name. + assert_eq!(quote_ident(&bt, "it's"), "`it's`"); + + assert_eq!(quote_ident(&br, "a]b"), "[a\\]b]"); + assert_eq!(quote_ident(&br, "]"), "[\\]]"); +} + +#[test] +fn quote_identifier_trailing_backslash_can_eat_a_backslash_closer() { + // If the engine treats `\` as an escape, a trailing backslash escapes the + // closing delimiter and the next source token is absorbed into the name. + // Doubling mode does not have this hole. Callers must not pair + // `flags::IdentifierEscape::Backslash` with an engine that ignores backslashes, + // and must reject or double a trailing `\` on engines that honor it. + let bt = ident_flags(flags::IdentifierFormat::Backtick, flags::IdentifierEscape::Backslash); + let br = ident_flags( + flags::IdentifierFormat::SquareBracket, + flags::IdentifierEscape::Backslash, + ); + assert_eq!(quote_ident(&bt, "foo\\"), "`foo\\\\`"); + assert_eq!(quote_ident(&br, "foo\\"), "[foo\\]"); + assert!(quote_ident(&br, "foo\\").ends_with("\\]")); +} + +#[test] +fn quote_identifier_comments_and_dots_stay_inside() { + let dq = ident_flags(flags::IdentifierFormat::DoubleQuote, flags::IdentifierEscape::Double); + // A newline inside a quoted identifier is legal in some engines and a + // line-comment introducer in others if the closer is lost. Doubling keeps + // the closer, so `--` cannot comment out the rest of the statement. + assert_eq!(quote_ident(&dq, "a\n--\nb"), "\"a\n--\nb\""); + assert_eq!(quote_ident(&dq, "a.b"), "\"a.b\""); + assert_eq!(quote_ident(&dq, "users"), "\"users\""); +} + +#[test] +fn format_bits_spellings_and_unsupported() { + let mut out = String::new(); + let err = flags::DialectFlags::new() + .with_bit_format(flags::BitFormat::Unsupported) + .format_bits(&mut out, "101") + .unwrap_err(); + + assert_eq!(err, errors::FormatError::Unsupported("BitSerializer")); + assert!(out.is_empty(), "unsupported must not write a partial literal"); + + let cases = [ + (flags::BitFormat::PrefixBigB, "B'101'"), + (flags::BitFormat::PrefixSmallB, "b'101'"), + (flags::BitFormat::PostfixBigB, "'101'B"), + ]; + for (format, expected) in cases { + out.clear(); + flags::DialectFlags::new() + .with_bit_format(format) + .format_bits(&mut out, "101") + .unwrap(); + assert_eq!(out, expected); + out.clear(); + flags::DialectFlags::new() + .with_bit_format(format) + .format_bits(&mut out, "") + .unwrap(); + assert_eq!(out, expected.replace("101", "")); + } +} + +#[test] +fn format_bits_embeds_input_unchanged() { + // Release builds do not re-check the alphabet. A `'` in `bits` would + // close the literal (`B'''; DROP`), so the debug assert is the guard. + let mut out = String::new(); + flags::DialectFlags::new() + .with_bit_format(flags::BitFormat::PrefixBigB) + .format_bits(&mut out, "0") + .unwrap(); + assert_eq!(out, "B'0'"); + out.clear(); + flags::DialectFlags::new() + .with_bit_format(flags::BitFormat::PostfixBigB) + .format_bits(&mut out, "1") + .unwrap(); + assert_eq!(out, "'1'B"); +} + +#[cfg(debug_assertions)] +#[test] +#[should_panic(expected = "format_bits: invalid input")] +fn format_bits_panics_on_quote_in_debug() { + let mut out = String::new(); + let _ = flags::DialectFlags::new() + .with_bit_format(flags::BitFormat::PrefixBigB) + .format_bits(&mut out, "1'; DROP TABLE t; --"); +} + +#[test] +fn format_bytes_every_spelling_including_empty_and_quote_byte() { + // 0x27 is ASCII `'`. Hex spellings must emit `27`, never a raw quote. + let quote_byte = [0x27]; + let dead = [0xDE, 0xAD, 0xBE, 0xEF]; + let cases: [(flags::BytesFormat, &str, &str); 10] = [ + (flags::BytesFormat::PrefixBigX, "X''", "X'27'"), + (flags::BytesFormat::PrefixZeroX, "0x", "0x27"), + (flags::BytesFormat::SlashXBytea, "'\\x'::bytea", "'\\x27'::bytea"), + (flags::BytesFormat::PrefixBigBSlashX, "B''", "B'\\x27'"), + (flags::BytesFormat::HexToRaw, "HEXTORAW('')", "HEXTORAW('27')"), + (flags::BytesFormat::Unhex, "UNHEX('')", "UNHEX('27')"), + ( + flags::BytesFormat::ToVarbyte, + "TO_VARBYTE('', 'hex')", + "TO_VARBYTE('27', 'hex')", + ), + (flags::BytesFormat::PostfixXbv, "''XBV", "'27'XBV"), + ( + flags::BytesFormat::ToBinary, + "TO_BINARY('', 'HEX')", + "TO_BINARY('27', 'HEX')", + ), + (flags::BytesFormat::SlashXBlob, "''::BLOB", "'\\x27'::BLOB"), + ]; + for (format, empty, quoted) in cases { + let flags = flags::DialectFlags::new().with_bytes_format(format); + let mut out = String::new(); + flags.format_bytes(&mut out, &[]).unwrap(); + assert_eq!(out, empty, "{format:?} empty"); + out.clear(); + flags.format_bytes(&mut out, "e_byte).unwrap(); + assert_eq!(out, quoted, "{format:?} quote byte"); + assert!( + !out.contains("''") || format == flags::BytesFormat::SlashXBlob || out.starts_with("''"), + "raw quote leaked into {out}" + ); + out.clear(); + flags.format_bytes(&mut out, &dead).unwrap(); + assert!( + out.contains("DEADBEEF") || out.contains("\\xDE\\xAD\\xBE\\xEF"), + "{out}" + ); + assert!(!out.contains("deadbeef"), "hex must be uppercase: {out}"); + } +} + +#[test] +fn format_bytes_slash_x_blob_empty_is_not_a_slash_escape() { + // Empty `'\x'::BLOB` would be a one-byte escape, not an empty blob. + let flags = flags::DialectFlags::new().with_bytes_format(flags::BytesFormat::SlashXBlob); + let mut out = String::new(); + flags.format_bytes(&mut out, &[]).unwrap(); + assert_eq!(out, "''::BLOB"); + out.clear(); + flags.format_bytes(&mut out, &[0x00]).unwrap(); + assert_eq!(out, "'\\x00'::BLOB"); +} + +#[test] +fn format_bytes_crosses_hex_and_slash_chunk_boundaries() { + let sixty_five = vec![0xABu8; 65]; + let thirty_three = vec![0xFFu8; 33]; + + let mut hex = String::new(); + flags::DialectFlags::new() + .with_bytes_format(flags::BytesFormat::PrefixBigX) + .format_bytes(&mut hex, &sixty_five) + .unwrap(); + + assert_eq!(hex.len(), 2 + 65 * 2 + 1); + assert!(hex.starts_with("X'") && hex.ends_with('\'')); + assert_eq!(&hex[2..hex.len() - 1], &"AB".repeat(65)); + + let mut slash = String::new(); + flags::DialectFlags::new() + .with_bytes_format(flags::BytesFormat::PrefixBigBSlashX) + .format_bytes(&mut slash, &thirty_three) + .unwrap(); + + assert_eq!(slash, format!("B'{}'", "\\xFF".repeat(33))); + + let mut blob = String::new(); + flags::DialectFlags::new() + .with_bytes_format(flags::BytesFormat::SlashXBlob) + .format_bytes(&mut blob, &thirty_three) + .unwrap(); + assert_eq!(blob, format!("'{}'::BLOB", "\\xFF".repeat(33))); +} + +#[test] +fn format_bytes_zero_prefix_empty_is_not_a_closed_literal() { + // `0x` with no digits is rejected by most engines. Locked so a caller + // that needs an empty blob picks another spelling. + let mut out = String::new(); + flags::DialectFlags::new() + .with_bytes_format(flags::BytesFormat::PrefixZeroX) + .format_bytes(&mut out, &[]) + .unwrap(); + + assert_eq!(out, "0x"); +} + +#[test] +fn format_bytes_function_wrappers_cannot_be_closed_by_payload() { + // The hex alphabet is generated, not copied, so a byte cannot inject a + // quote into HEXTORAW / UNHEX / TO_BINARY / TO_VARBYTE. + let nasty = [0x27, 0x5C, 0x00, 0x22, 0x3B]; + for format in [ + flags::BytesFormat::HexToRaw, + flags::BytesFormat::Unhex, + flags::BytesFormat::ToVarbyte, + flags::BytesFormat::ToBinary, + ] { + let mut out = String::new(); + flags::DialectFlags::new() + .with_bytes_format(format) + .format_bytes(&mut out, &nasty) + .unwrap(); + + assert!(out.contains("275C00223B"), "{format:?} -> {out}"); + let hex = out + .split('\'') + .nth(1) + .unwrap_or_else(|| panic!("missing hex literal in {out}")); + + assert!( + hex.chars().all(|c| c.is_ascii_hexdigit()), + "{format:?} leaked non-hex into {out}" + ); + assert_eq!(hex, "275C00223B"); + } +} + +#[test] +fn writer_errors_surface_as_format_error() { + let flags = string_flags(flags::StringFormat::PrefixBigN, flags::StringEscape::Quote); + let mut writer = FailAfter { remaining: 0 }; + assert_eq!( + flags.write_string_literal(&mut writer, "x").unwrap_err(), + errors::FormatError::Write + ); + + let mut writer = FailAfter { remaining: 0 }; + assert!( + flags + .with_bit_format(flags::BitFormat::PrefixBigB) + .format_bits(&mut writer, "1") + .is_err() + ); +} From c5423dd1db6b5c027768669f2dc429be3658fdd4 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 5 Oct 2026 15:42:43 +0330 Subject: [PATCH 70/87] Complete tests --- rapidquery/dialect.pyi | 6 +++ rapidquery/expr.pyi | 6 +-- src/dialect/base.rs | 40 +++++++++++++++++++ src/lib.rs | 1 - ...est_dialect_options.py => test_dialect.py} | 37 +++++++++++++++++ 5 files changed, 86 insertions(+), 4 deletions(-) rename tests/{test_dialect_options.py => test_dialect.py} (51%) diff --git a/rapidquery/dialect.pyi b/rapidquery/dialect.pyi index f95a444..1afe808 100644 --- a/rapidquery/dialect.pyi +++ b/rapidquery/dialect.pyi @@ -154,6 +154,12 @@ class Dialect: parenthesize_in_between: bool = False, supports_double_colon: bool = False, ) -> typing.Self: ... + def format_boolean(self, value: bool) -> str: ... + def escape_string(self, value: str) -> str: ... + def quote_identifier(self, value: str) -> str: ... + def write_string_literal(self, value: str) -> str: ... + def format_bits(self, value: str) -> str: ... + def format_bytes(self, value: bytes) -> str: ... @property def placeholder_character(self) -> str: ... @property diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index 32ceea4..16d9e82 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -38,10 +38,10 @@ _T = typing.TypeVar("_T") @final class Value(Expr, typing.Generic[_T]): @overload - def __new__(cls, object: _T, /) -> Self: ... + def __new__(cls, object: _T | None, /) -> Self: ... @overload - def __new__(cls, object: _T, serializer: Serializer[_T] | None = None, /) -> Self: ... - def get_object(self) -> _T: ... + def __new__(cls, object: _T | None, serializer: Serializer[_T] | None, /) -> Self: ... + def get_object(self) -> _T | None: ... def get_serializer(self) -> Serializer[_T]: ... def __hash__(self) -> int: ... def __eq__(self, value: object, /) -> bool: ... diff --git a/src/dialect/base.rs b/src/dialect/base.rs index 40918b7..ad832c9 100644 --- a/src/dialect/base.rs +++ b/src/dialect/base.rs @@ -249,6 +249,46 @@ rapidquery_macros::dialect_options! { #[pyo3::pymethods] impl PyDialect { + fn format_boolean(&self, value: bool) -> pyo3::PyResult { + let mut buf = String::new(); + self.0.format_boolean(&mut buf, value)?; + Ok(buf) + } + + fn escape_string(&self, value: &str) -> pyo3::PyResult { + let mut buf = String::new(); + self.0.escape_string(&mut buf, value)?; + Ok(buf) + } + + fn quote_identifier(&self, value: &str) -> pyo3::PyResult { + let mut buf = String::new(); + let _ = self.0.quote_identifier(&mut buf, value); + Ok(buf) + } + + fn write_string_literal(&self, value: &str) -> pyo3::PyResult { + let mut buf = String::new(); + self.0.write_string_literal(&mut buf, value)?; + Ok(buf) + } + + fn format_bits(&self, value: &str) -> pyo3::PyResult { + if value.chars().any(|c| c != '0' && c != '1') { + return rapidquery_macros::pyerr!(Err(PyValueError), "invalid input: {}", value); + } + + let mut buf = String::new(); + self.0.format_bits(&mut buf, value)?; + Ok(buf) + } + + fn format_bytes(&self, value: &[u8]) -> pyo3::PyResult { + let mut buf = String::new(); + self.0.format_bytes(&mut buf, value)?; + Ok(buf) + } + fn __eq__(&self, other: &pyo3::Bound<'_, Self>) -> bool { self.0 == other.get().0 } diff --git a/src/lib.rs b/src/lib.rs index 9ba475b..5d2e0a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,7 +10,6 @@ pub mod sql; pub mod typeref; // Tasks until being stable (until this part) -// - Test and coverage everything and edge cases // - Update docstrings / doccomments // Later tasks: diff --git a/tests/test_dialect_options.py b/tests/test_dialect.py similarity index 51% rename from tests/test_dialect_options.py rename to tests/test_dialect.py index dbcef16..dcae992 100644 --- a/tests/test_dialect_options.py +++ b/tests/test_dialect.py @@ -1,9 +1,17 @@ +import pytest + from rapidquery.dialect import ( + OPT_BIT_FORMAT_POSTFIX_BIG_B, OPT_BOOL_FORMAT_INTEGER, + OPT_BYTES_FORMAT_SLASH_X_BYTEA, + OPT_IDENTIFIER_ESCAPE_BACKSLASH, + OPT_IDENTIFIER_FORMAT_BACKTICK, OPT_LIKE_ESCAPE_INCLUDING_ANY, OPT_LIKE_ESCAPE_SCALAR, OPT_PLACEHOLDER_NUMBERED, OPT_PLACEHOLDER_SIMPLE, + OPT_STRING_ESCAPE_BACKSLASH_HEX, + OPT_STRING_FORMAT_PREFIX_BIG_E, Dialect, PostgreSql, ) @@ -36,3 +44,32 @@ def test_builtin_getters(): assert PostgreSql.placeholder == OPT_PLACEHOLDER_NUMBERED assert PostgreSql.supports_double_colon is True assert PostgreSql.like_escape == OPT_LIKE_ESCAPE_SCALAR + + +# Edge cases are tested in Rust +def test_dialect_methods(): + default = Dialect( + bool_format=OPT_BOOL_FORMAT_INTEGER, + string_format=OPT_STRING_FORMAT_PREFIX_BIG_E, + string_escape=OPT_STRING_ESCAPE_BACKSLASH_HEX, + identifier_format=OPT_IDENTIFIER_FORMAT_BACKTICK, + identifier_escape=OPT_IDENTIFIER_ESCAPE_BACKSLASH, + bit_format=OPT_BIT_FORMAT_POSTFIX_BIG_B, + bytes_format=OPT_BYTES_FORMAT_SLASH_X_BYTEA, + ) + assert default.format_boolean(True) == "1" + + assert default.write_string_literal("love") == "E'love'" + + assert default.escape_string("\r") == "\\r" + assert default.escape_string("\t") == "\\t" + assert default.escape_string("\\") == "\\\\" + + assert default.quote_identifier("lov`e") == "`lov\\`e`" + + assert default.format_bits("10101") == "'10101'B" + + with pytest.raises(ValueError): + _ = default.format_bits("123") + + assert default.format_bytes(b"love") == "'\\x6C6F7665'::bytea" From 14ecd249b7094e6b5beee3bca2c10fd595198156 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 5 Oct 2026 15:51:13 +0330 Subject: [PATCH 71/87] Rename dialect format_* methods to write_* --- rapidquery/dialect.pyi | 8 ++-- src/data_type/mod.rs | 22 +++------- src/dialect/base.rs | 16 +++---- src/dialect/flags.rs | 10 ++--- src/dialect/tests.rs | 84 ++++++++++++++++++------------------ src/expr/enum_/ident.rs | 2 +- src/expr/enum_/nodes.rs | 2 +- src/serializers/functions.rs | 6 +-- tests/test_dialect.py | 10 ++--- 9 files changed, 76 insertions(+), 84 deletions(-) diff --git a/rapidquery/dialect.pyi b/rapidquery/dialect.pyi index 1afe808..1c38335 100644 --- a/rapidquery/dialect.pyi +++ b/rapidquery/dialect.pyi @@ -154,12 +154,12 @@ class Dialect: parenthesize_in_between: bool = False, supports_double_colon: bool = False, ) -> typing.Self: ... - def format_boolean(self, value: bool) -> str: ... + def write_boolean(self, value: bool) -> str: ... def escape_string(self, value: str) -> str: ... - def quote_identifier(self, value: str) -> str: ... + def write_identifier(self, value: str) -> str: ... def write_string_literal(self, value: str) -> str: ... - def format_bits(self, value: str) -> str: ... - def format_bytes(self, value: bytes) -> str: ... + def write_bits(self, value: str) -> str: ... + def write_bytes(self, value: bytes) -> str: ... @property def placeholder_character(self) -> str: ... @property diff --git a/src/data_type/mod.rs b/src/data_type/mod.rs index 1e77e13..893578b 100644 --- a/src/data_type/mod.rs +++ b/src/data_type/mod.rs @@ -1,8 +1,3 @@ -//! SQL data types: interned names and [`DataType`]. -//! -//! A [`DataTypeName`] is a canonical SQL spelling. Known names are interned. -//! Serializing a Python value is [`crate::serializers`], not this module. - use crate::sql; #[cfg(test)] @@ -15,14 +10,13 @@ mod tests; /// names keep the caller's spelling. Synonyms are not rewritten to a different /// name. #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] -pub struct DataTypeName { - name: arcstr::ArcStr, -} +#[repr(transparent)] +pub struct DataTypeName(arcstr::ArcStr); impl DataTypeName { #[inline] pub fn as_str(&self) -> &str { - &self.name + &self.0 } } @@ -43,7 +37,7 @@ fn interned(value: &str) -> Option { } fn custom_name>(value: T) -> DataTypeName { - DataTypeName { name: value.into() } + DataTypeName(value.into()) } // Interned no-arg types. `arcstr::literal!` shares one allocation per spelling. @@ -56,9 +50,7 @@ macro_rules! simple_data_types { ) => { $( $(#[$docs])* - pub(super) const $upcase: DataTypeName = DataTypeName { - name: arcstr::literal!($realname), - }; + pub(super) const $upcase: DataTypeName = DataTypeName(arcstr::literal!($realname)); )+ fn lookup_alias(lowercase: &str) -> Option { @@ -178,14 +170,14 @@ impl AsRef for DataTypeName { impl From for String { #[inline] fn from(val: DataTypeName) -> Self { - val.name.to_string() + val.0.to_string() } } impl std::fmt::Display for DataTypeName { #[inline] fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.name.fmt(f) + self.0.fmt(f) } } diff --git a/src/dialect/base.rs b/src/dialect/base.rs index ad832c9..3ce9f21 100644 --- a/src/dialect/base.rs +++ b/src/dialect/base.rs @@ -249,9 +249,9 @@ rapidquery_macros::dialect_options! { #[pyo3::pymethods] impl PyDialect { - fn format_boolean(&self, value: bool) -> pyo3::PyResult { + fn write_boolean(&self, value: bool) -> pyo3::PyResult { let mut buf = String::new(); - self.0.format_boolean(&mut buf, value)?; + self.0.write_boolean(&mut buf, value)?; Ok(buf) } @@ -261,9 +261,9 @@ impl PyDialect { Ok(buf) } - fn quote_identifier(&self, value: &str) -> pyo3::PyResult { + fn write_identifier(&self, value: &str) -> pyo3::PyResult { let mut buf = String::new(); - let _ = self.0.quote_identifier(&mut buf, value); + let _ = self.0.write_identifier(&mut buf, value); Ok(buf) } @@ -273,19 +273,19 @@ impl PyDialect { Ok(buf) } - fn format_bits(&self, value: &str) -> pyo3::PyResult { + fn write_bits(&self, value: &str) -> pyo3::PyResult { if value.chars().any(|c| c != '0' && c != '1') { return rapidquery_macros::pyerr!(Err(PyValueError), "invalid input: {}", value); } let mut buf = String::new(); - self.0.format_bits(&mut buf, value)?; + self.0.write_bits(&mut buf, value)?; Ok(buf) } - fn format_bytes(&self, value: &[u8]) -> pyo3::PyResult { + fn write_bytes(&self, value: &[u8]) -> pyo3::PyResult { let mut buf = String::new(); - self.0.format_bytes(&mut buf, value)?; + self.0.write_bytes(&mut buf, value)?; Ok(buf) } diff --git a/src/dialect/flags.rs b/src/dialect/flags.rs index 0245677..809974c 100644 --- a/src/dialect/flags.rs +++ b/src/dialect/flags.rs @@ -613,7 +613,7 @@ pub struct DialectFlags { impl DialectFlags { /// Write a boolean literal (`TRUE`/`FALSE` or `1`/`0`). #[inline] - pub fn format_boolean(&self, writer: &mut W, value: bool) -> Result<(), FormatError> { + pub fn write_boolean(&self, writer: &mut W, value: bool) -> Result<(), FormatError> { match (self.bool_format(), value) { (BoolFormat::Integer, false) => writer.write_str("0")?, (BoolFormat::Integer, true) => writer.write_str("1")?, @@ -639,7 +639,7 @@ impl DialectFlags { /// Escape `ident` and wrap it in this dialect's identifier delimiters. #[inline] - pub fn quote_identifier(&self, writer: &mut W, ident: &str) -> std::fmt::Result { + pub fn write_identifier(&self, writer: &mut W, ident: &str) -> std::fmt::Result { let (start, end) = match self.identifier_format() { IdentifierFormat::DoubleQuote => ('"', '"'), IdentifierFormat::Backtick => ('`', '`'), @@ -697,10 +697,10 @@ impl DialectFlags { /// Native formats embed `bits` as-is. Callers must pass only `'0'` and `'1'`; /// that is checked with `debug_assert!`. #[inline] - pub fn format_bits(&self, writer: &mut W, bits: &str) -> Result<(), FormatError> { + pub fn write_bits(&self, writer: &mut W, bits: &str) -> Result<(), FormatError> { debug_assert!( bits.chars().all(|c| c == '0' || c == '1'), - "format_bits: invalid input: {bits}" + "write_bits: invalid input: {bits}" ); match self.bit_format() { BitFormat::Unsupported => Err(FormatError::Unsupported("BitSerializer")), @@ -712,7 +712,7 @@ impl DialectFlags { /// Write `bytes` as a native blob/bytea/varbyte literal. #[inline] - pub fn format_bytes(&self, writer: &mut W, bytes: &[u8]) -> Result<(), FormatError> { + pub fn write_bytes(&self, writer: &mut W, bytes: &[u8]) -> Result<(), FormatError> { let result = match self.bytes_format() { BytesFormat::PrefixBigX => { writer.write_str("X'")?; diff --git a/src/dialect/tests.rs b/src/dialect/tests.rs index ced8207..28b71f5 100644 --- a/src/dialect/tests.rs +++ b/src/dialect/tests.rs @@ -33,7 +33,7 @@ fn escape(flags: &flags::DialectFlags, raw: &str) -> String { fn quote_ident(flags: &flags::DialectFlags, ident: &str) -> String { let mut out = String::new(); - flags.quote_identifier(&mut out, ident).unwrap(); + flags.write_identifier(&mut out, ident).unwrap(); out } @@ -154,34 +154,34 @@ const BREAKOUTS: &[&str] = &[ ]; #[test] -fn format_boolean_keyword_and_integer() { +fn write_boolean_keyword_and_integer() { let keyword = bool_flags(flags::BoolFormat::Keyword); let integer = bool_flags(flags::BoolFormat::Integer); let mut out = String::new(); - keyword.format_boolean(&mut out, true).unwrap(); - keyword.format_boolean(&mut out, false).unwrap(); + keyword.write_boolean(&mut out, true).unwrap(); + keyword.write_boolean(&mut out, false).unwrap(); assert_eq!(out, "TRUEFALSE"); out.clear(); - integer.format_boolean(&mut out, true).unwrap(); - integer.format_boolean(&mut out, false).unwrap(); + integer.write_boolean(&mut out, true).unwrap(); + integer.write_boolean(&mut out, false).unwrap(); assert_eq!(out, "10"); } #[test] -fn format_boolean_does_not_emit_quotes_or_lowercase() { +fn write_boolean_does_not_emit_quotes_or_lowercase() { // MySQL may accept the integers; neither spelling is a string, so a // caller that interpolates them into a quoted context would be wrong. for value in [false, true] { let mut keyword = String::new(); let mut integer = String::new(); bool_flags(flags::BoolFormat::Keyword) - .format_boolean(&mut keyword, value) + .write_boolean(&mut keyword, value) .unwrap(); bool_flags(flags::BoolFormat::Integer) - .format_boolean(&mut integer, value) + .write_boolean(&mut integer, value) .unwrap(); assert!(!keyword.contains('\'')); @@ -192,10 +192,10 @@ fn format_boolean_does_not_emit_quotes_or_lowercase() { } #[test] -fn format_boolean_propagates_writer_errors() { +fn write_boolean_propagates_writer_errors() { let mut writer = FailAfter { remaining: 0 }; let err = bool_flags(flags::BoolFormat::Keyword) - .format_boolean(&mut writer, true) + .write_boolean(&mut writer, true) .unwrap_err(); assert_eq!(err, errors::FormatError::Write); @@ -391,7 +391,7 @@ fn write_string_literal_passes_unicode_quotes_and_like_wildcards() { } #[test] -fn quote_identifier_doubles_only_its_own_closer() { +fn write_identifier_doubles_only_its_own_closer() { let dq = ident_flags(flags::IdentifierFormat::DoubleQuote, flags::IdentifierEscape::Double); let bt = ident_flags(flags::IdentifierFormat::Backtick, flags::IdentifierEscape::Double); let br = ident_flags(flags::IdentifierFormat::SquareBracket, flags::IdentifierEscape::Double); @@ -416,7 +416,7 @@ fn quote_identifier_doubles_only_its_own_closer() { } #[test] -fn quote_identifier_rejects_delimiter_breakout() { +fn write_identifier_rejects_delimiter_breakout() { let samples = [ ( ident_flags(flags::IdentifierFormat::DoubleQuote, flags::IdentifierEscape::Double), @@ -465,7 +465,7 @@ fn quote_identifier_rejects_delimiter_breakout() { } #[test] -fn quote_identifier_backslash_modes() { +fn write_identifier_backslash_modes() { let dq = ident_flags(flags::IdentifierFormat::DoubleQuote, flags::IdentifierEscape::Backslash); let bt = ident_flags(flags::IdentifierFormat::Backtick, flags::IdentifierEscape::Backslash); let br = ident_flags( @@ -493,7 +493,7 @@ fn quote_identifier_backslash_modes() { } #[test] -fn quote_identifier_trailing_backslash_can_eat_a_backslash_closer() { +fn write_identifier_trailing_backslash_can_eat_a_backslash_closer() { // If the engine treats `\` as an escape, a trailing backslash escapes the // closing delimiter and the next source token is absorbed into the name. // Doubling mode does not have this hole. Callers must not pair @@ -510,7 +510,7 @@ fn quote_identifier_trailing_backslash_can_eat_a_backslash_closer() { } #[test] -fn quote_identifier_comments_and_dots_stay_inside() { +fn write_identifier_comments_and_dots_stay_inside() { let dq = ident_flags(flags::IdentifierFormat::DoubleQuote, flags::IdentifierEscape::Double); // A newline inside a quoted identifier is legal in some engines and a // line-comment introducer in others if the closer is lost. Doubling keeps @@ -521,11 +521,11 @@ fn quote_identifier_comments_and_dots_stay_inside() { } #[test] -fn format_bits_spellings_and_unsupported() { +fn write_bits_spellings_and_unsupported() { let mut out = String::new(); let err = flags::DialectFlags::new() .with_bit_format(flags::BitFormat::Unsupported) - .format_bits(&mut out, "101") + .write_bits(&mut out, "101") .unwrap_err(); assert_eq!(err, errors::FormatError::Unsupported("BitSerializer")); @@ -540,48 +540,48 @@ fn format_bits_spellings_and_unsupported() { out.clear(); flags::DialectFlags::new() .with_bit_format(format) - .format_bits(&mut out, "101") + .write_bits(&mut out, "101") .unwrap(); assert_eq!(out, expected); out.clear(); flags::DialectFlags::new() .with_bit_format(format) - .format_bits(&mut out, "") + .write_bits(&mut out, "") .unwrap(); assert_eq!(out, expected.replace("101", "")); } } #[test] -fn format_bits_embeds_input_unchanged() { +fn write_bits_embeds_input_unchanged() { // Release builds do not re-check the alphabet. A `'` in `bits` would // close the literal (`B'''; DROP`), so the debug assert is the guard. let mut out = String::new(); flags::DialectFlags::new() .with_bit_format(flags::BitFormat::PrefixBigB) - .format_bits(&mut out, "0") + .write_bits(&mut out, "0") .unwrap(); assert_eq!(out, "B'0'"); out.clear(); flags::DialectFlags::new() .with_bit_format(flags::BitFormat::PostfixBigB) - .format_bits(&mut out, "1") + .write_bits(&mut out, "1") .unwrap(); assert_eq!(out, "'1'B"); } #[cfg(debug_assertions)] #[test] -#[should_panic(expected = "format_bits: invalid input")] -fn format_bits_panics_on_quote_in_debug() { +#[should_panic(expected = "write_bits: invalid input")] +fn write_bits_panics_on_quote_in_debug() { let mut out = String::new(); let _ = flags::DialectFlags::new() .with_bit_format(flags::BitFormat::PrefixBigB) - .format_bits(&mut out, "1'; DROP TABLE t; --"); + .write_bits(&mut out, "1'; DROP TABLE t; --"); } #[test] -fn format_bytes_every_spelling_including_empty_and_quote_byte() { +fn write_bytes_every_spelling_including_empty_and_quote_byte() { // 0x27 is ASCII `'`. Hex spellings must emit `27`, never a raw quote. let quote_byte = [0x27]; let dead = [0xDE, 0xAD, 0xBE, 0xEF]; @@ -608,17 +608,17 @@ fn format_bytes_every_spelling_including_empty_and_quote_byte() { for (format, empty, quoted) in cases { let flags = flags::DialectFlags::new().with_bytes_format(format); let mut out = String::new(); - flags.format_bytes(&mut out, &[]).unwrap(); + flags.write_bytes(&mut out, &[]).unwrap(); assert_eq!(out, empty, "{format:?} empty"); out.clear(); - flags.format_bytes(&mut out, "e_byte).unwrap(); + flags.write_bytes(&mut out, "e_byte).unwrap(); assert_eq!(out, quoted, "{format:?} quote byte"); assert!( !out.contains("''") || format == flags::BytesFormat::SlashXBlob || out.starts_with("''"), "raw quote leaked into {out}" ); out.clear(); - flags.format_bytes(&mut out, &dead).unwrap(); + flags.write_bytes(&mut out, &dead).unwrap(); assert!( out.contains("DEADBEEF") || out.contains("\\xDE\\xAD\\xBE\\xEF"), "{out}" @@ -628,26 +628,26 @@ fn format_bytes_every_spelling_including_empty_and_quote_byte() { } #[test] -fn format_bytes_slash_x_blob_empty_is_not_a_slash_escape() { +fn write_bytes_slash_x_blob_empty_is_not_a_slash_escape() { // Empty `'\x'::BLOB` would be a one-byte escape, not an empty blob. let flags = flags::DialectFlags::new().with_bytes_format(flags::BytesFormat::SlashXBlob); let mut out = String::new(); - flags.format_bytes(&mut out, &[]).unwrap(); + flags.write_bytes(&mut out, &[]).unwrap(); assert_eq!(out, "''::BLOB"); out.clear(); - flags.format_bytes(&mut out, &[0x00]).unwrap(); + flags.write_bytes(&mut out, &[0x00]).unwrap(); assert_eq!(out, "'\\x00'::BLOB"); } #[test] -fn format_bytes_crosses_hex_and_slash_chunk_boundaries() { +fn write_bytes_crosses_hex_and_slash_chunk_boundaries() { let sixty_five = vec![0xABu8; 65]; let thirty_three = vec![0xFFu8; 33]; let mut hex = String::new(); flags::DialectFlags::new() .with_bytes_format(flags::BytesFormat::PrefixBigX) - .format_bytes(&mut hex, &sixty_five) + .write_bytes(&mut hex, &sixty_five) .unwrap(); assert_eq!(hex.len(), 2 + 65 * 2 + 1); @@ -657,7 +657,7 @@ fn format_bytes_crosses_hex_and_slash_chunk_boundaries() { let mut slash = String::new(); flags::DialectFlags::new() .with_bytes_format(flags::BytesFormat::PrefixBigBSlashX) - .format_bytes(&mut slash, &thirty_three) + .write_bytes(&mut slash, &thirty_three) .unwrap(); assert_eq!(slash, format!("B'{}'", "\\xFF".repeat(33))); @@ -665,26 +665,26 @@ fn format_bytes_crosses_hex_and_slash_chunk_boundaries() { let mut blob = String::new(); flags::DialectFlags::new() .with_bytes_format(flags::BytesFormat::SlashXBlob) - .format_bytes(&mut blob, &thirty_three) + .write_bytes(&mut blob, &thirty_three) .unwrap(); assert_eq!(blob, format!("'{}'::BLOB", "\\xFF".repeat(33))); } #[test] -fn format_bytes_zero_prefix_empty_is_not_a_closed_literal() { +fn write_bytes_zero_prefix_empty_is_not_a_closed_literal() { // `0x` with no digits is rejected by most engines. Locked so a caller // that needs an empty blob picks another spelling. let mut out = String::new(); flags::DialectFlags::new() .with_bytes_format(flags::BytesFormat::PrefixZeroX) - .format_bytes(&mut out, &[]) + .write_bytes(&mut out, &[]) .unwrap(); assert_eq!(out, "0x"); } #[test] -fn format_bytes_function_wrappers_cannot_be_closed_by_payload() { +fn write_bytes_function_wrappers_cannot_be_closed_by_payload() { // The hex alphabet is generated, not copied, so a byte cannot inject a // quote into HEXTORAW / UNHEX / TO_BINARY / TO_VARBYTE. let nasty = [0x27, 0x5C, 0x00, 0x22, 0x3B]; @@ -697,7 +697,7 @@ fn format_bytes_function_wrappers_cannot_be_closed_by_payload() { let mut out = String::new(); flags::DialectFlags::new() .with_bytes_format(format) - .format_bytes(&mut out, &nasty) + .write_bytes(&mut out, &nasty) .unwrap(); assert!(out.contains("275C00223B"), "{format:?} -> {out}"); @@ -727,7 +727,7 @@ fn writer_errors_surface_as_format_error() { assert!( flags .with_bit_format(flags::BitFormat::PrefixBigB) - .format_bits(&mut writer, "1") + .write_bits(&mut writer, "1") .is_err() ); } diff --git a/src/expr/enum_/ident.rs b/src/expr/enum_/ident.rs index 9af22f6..b7ee3d8 100644 --- a/src/expr/enum_/ident.rs +++ b/src/expr/enum_/ident.rs @@ -67,7 +67,7 @@ impl WriteSql for IdentPart { fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { match self { Self::Quote(name) => { - dialect.0.quote_identifier(out, name.as_str())?; + dialect.0.write_identifier(out, name.as_str())?; } Self::Unquote(name) => { out.push_str(name.as_str()); diff --git a/src/expr/enum_/nodes.rs b/src/expr/enum_/nodes.rs index a3fccf9..6eb15d2 100644 --- a/src/expr/enum_/nodes.rs +++ b/src/expr/enum_/nodes.rs @@ -403,7 +403,7 @@ impl WriteSql for InList { fn write_sql(&self, dialect: &PyDialect, out: &mut W) -> PrepareResult<()> { if self.list.is_empty() { // `x IN ()` is false and `x NOT IN ()` is true, including when `x` is NULL. - dialect.0.format_boolean(out, self.negated)?; + dialect.0.write_boolean(out, self.negated)?; return Ok(()); } diff --git a/src/serializers/functions.rs b/src/serializers/functions.rs index 66ea807..143bd48 100644 --- a/src/serializers/functions.rs +++ b/src/serializers/functions.rs @@ -125,7 +125,7 @@ pub(super) unsafe fn write_bool( ) -> pyo3::PyResult<()> { if pyo3::ffi::PyBool_Check(ptr) == 1 { let value = pyo3::ffi::Py_True() == ptr; - dialect.format_boolean(out, value)?; + dialect.write_boolean(out, value)?; Ok(()) } else { rapidquery_macros::pyerr!( @@ -206,7 +206,7 @@ pub(super) unsafe fn write_bytes( debug_assert!(!buf.is_null()); dialect - .format_bytes(out, std::slice::from_raw_parts(buf, len)) + .write_bytes(out, std::slice::from_raw_parts(buf, len)) .map_err(Into::into) } else { rapidquery_macros::pyerr!( @@ -235,7 +235,7 @@ pub(super) unsafe fn write_bit( ); } - dialect.format_bits(out, raw).map_err(Into::into) + dialect.write_bits(out, raw).map_err(Into::into) } else { rapidquery_macros::pyerr!( Err(PyTypeError), diff --git a/tests/test_dialect.py b/tests/test_dialect.py index dcae992..8e4da6b 100644 --- a/tests/test_dialect.py +++ b/tests/test_dialect.py @@ -57,7 +57,7 @@ def test_dialect_methods(): bit_format=OPT_BIT_FORMAT_POSTFIX_BIG_B, bytes_format=OPT_BYTES_FORMAT_SLASH_X_BYTEA, ) - assert default.format_boolean(True) == "1" + assert default.write_boolean(True) == "1" assert default.write_string_literal("love") == "E'love'" @@ -65,11 +65,11 @@ def test_dialect_methods(): assert default.escape_string("\t") == "\\t" assert default.escape_string("\\") == "\\\\" - assert default.quote_identifier("lov`e") == "`lov\\`e`" + assert default.write_identifier("lov`e") == "`lov\\`e`" - assert default.format_bits("10101") == "'10101'B" + assert default.write_bits("10101") == "'10101'B" with pytest.raises(ValueError): - _ = default.format_bits("123") + _ = default.write_bits("123") - assert default.format_bytes(b"love") == "'\\x6C6F7665'::bytea" + assert default.write_bytes(b"love") == "'\\x6C6F7665'::bytea" From 2f1151a37dd5a46e344e80a4e32baf26d8a76567 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Tue, 6 Oct 2026 14:02:11 +0330 Subject: [PATCH 72/87] Update docstrings and doccomments --- README.md | 784 +--------------------------------- rapidquery/__init__.py | 2 - rapidquery/dialect.py | 4 - rapidquery/dialect.pyi | 560 +++++++++++++++++++++++-- rapidquery/expr.py | 4 - rapidquery/expr.pyi | 831 +++++++++++++++++++++++++++++++++---- rapidquery/serializers.py | 17 +- rapidquery/serializers.pyi | 321 +++++++++++++- src/data_type/mod.rs | 15 +- src/dialect/base.rs | 15 +- src/dialect/errors.rs | 6 +- src/dialect/flags.rs | 18 +- src/expr/base.rs | 5 +- src/expr/enum_/func.rs | 20 + src/expr/enum_/ident.rs | 24 +- src/expr/enum_/like.rs | 81 ++-- src/expr/enum_/nodes.rs | 234 +++++++++-- src/expr/enum_/value.rs | 15 +- src/expr/extractors.rs | 32 +- src/expr/func.rs | 19 +- src/expr/ident.rs | 48 +-- src/expr/nodes.rs | 5 +- src/expr/value.rs | 33 +- src/internal/repr.rs | 15 +- src/lib.rs | 3 - src/serializers/mod.rs | 157 ++----- src/sql.rs | 40 +- tests/test_dialect.py | 4 - 28 files changed, 2043 insertions(+), 1269 deletions(-) diff --git a/README.md b/README.md index d73545e..dfeca15 100644 --- a/README.md +++ b/README.md @@ -5,786 +5,4 @@ RapidQuery is a powerful SQL query builder library designed for Python, combining the simplicity of Python with the raw speed of **Rust**. Build complex SQL queries effortlessly and efficiently, with a library that prioritizes both performance and ease of use. > [!WARNING]\ -> This is the old documentation for v0.1.0. -> This branch is for v1.0.0 and is still in development. - -- [**Installation**](#install) -- [**Why RapidQuery?**](#why-rapidquery) -- [**Supported Backends**](#backends) -- [**Usage**](#usage) -- [**Performance**](#performance) -- [**Known Issues**](#known-issues) -- [**Supported Platforms**](#supported-platforms) -- [**License**](#license) - -## Install - -You can use **PIP**: - -```bash -pip3 install rapidquery -``` - -Or you can use **UV** (recommended): - -```bash -uv add rapidquery -``` - -## Why RapidQuery? - -**Features**: - -- **🧶 Thread safe**: It's completely thread-safe and uses locks in internal to prevent concurrency problems. -- **⚔ Blazing High Performance**: Leveraging the power of Rust under the hood, RapidQuery ensures your query building process is as fast as possible. -- šŸ›”ļø **SQL Injection Protection**: Built-in security measures to prevent SQL injection attacks by default. -- šŸ“ **Intuitive Pythonic API**: Write clean, readable code with an API that feels natural to Python developers. -- šŸ”„ **Built on Solid Foundations**: RapidQuery is built with **Rust** and powered by the robust **SeaQuery** crate, bringing enterprise-grade reliability and performance to your Python applications. - -> **Why RapidQuery Was Created** \ -> In a landscape filled with SQL libraries, we noticed a critical gap: **performance was often an afterthought**. That's why we built RapidQuery with speed as our primary and enduring focus. - -## Backends - -RapidQuery supports `PostgreSQL`, `MySQL`, and `SQLite`. These are referred to as `backend`s. When building SQL statements, you must specify your target backend. - -## Usage - -1. Core Concepts - 1. [**Value**](#value) - 2. [**Expr**](#expr) - 3. [**Statement Builders**](#statement-builders) -2. Query Statements - 1. [**Query Select**](#query-select) - 2. [**Query Insert**](#query-insert) - 3. [**Query Update**](#query-update) - 4. [**Query Delete**](#query-delete) - 5. [**Query With**](#query-with) -3. More About Queries - 1. [**Custom Function**](#custom-functions) -4. Schema Statements - 1. [**Table Create**](#table-create) - 2. [**Table Alter**](#table-alter) - 3. [**Table Drop**](#table-drop) - 4. [**Table Rename**](#table-rename) - 5. [**Table Truncate**](#table-truncate) - 6. [**Index Create**](#index-create) - 7. [**Index Drop**](#index-drop) -5. Advanced Usage - 1. [**More About Column References**](#more-about-column-references) - 2. [**More About TableName**](#more-about-tablename) - 3. [**More About Expr**](#more-about-expr) - -### Core Concepts - -#### Value - -Bridges Python types, Rust types, and SQL types for seamless data conversion. - -This class handles validation, adaptation, and conversion between different -type systems used in the application stack. - -> [!NOTE]\ -> this class is immutable and frozen. - -> [!TIP]\ -> **Important**: `Value` is lazy. This means it keeps your value and never converts it to Rust and then SQL until needed. - -#### Expr - -Represents a SQL expression that can be built into SQL code. - -This class provides a fluent interface for constructing complex SQL expressions -in a database-agnostic way. It supports arithmetic operations, comparisons, -logical operations, and database-specific functions. - -The class automatically handles SQL injection protection and proper quoting -when building the final SQL statement. - -> [!NOTE]\ -> `Expr` is immutable, so by calling each method you will give a new instance -> of it which includes new change(s). - -**Basic** - -```python -import rapidquery as rp - -rp.Expr(25) # -> 25 (literal value) -rp.Expr("Hello") # -> 'Hello' (literal value) -rp.Expr(rq.Value("World")) # -> 'World' (literal value) - -rp.Expr.col("id") # -> "id" (column reference) -rp.Expr.col("users.name") # -> "users"."name" (column reference) -rp.Expr(rq.ColumnRef("name", table="users")) # -> "users"."name" (column reference) -``` - -**Comparisons** - -```python -rq.Expr.col("status") == "active" # -> "status" == 'active' -rq.Expr.col("age") > 16 # -> "age" > 16 - -# Note that `rq.all` is different from built-in `all` -rq.all( - rq.Expr.col("age") >= 18, - rq.Expr.col("subscription").is_null(), # same as rq.Expr.col("subscription").is_(Expr.null()) - rq.Expr.col("status").in_(["pending", "approved", "active"]), -) # -> "age" >= 18 AND "subscription" IS NULL AND "status" IN ('pending', 'approved', 'active') - -# Note that `rq.any` is different from built-in `any` -rq.any( - rq.Expr.col("is_admin").is_(True), - rq.Expr.col("is_moderator").is_not_null(), # same as rq.Expr.col("subscription").is_not(Expr.null()) - rq.Expr.col("price").between(10.00, 50.00), -) # -> "is_admin" IS TRUE OR "is_moderator" IS NOT NULL OR "price" BETWEEN 10.00 AND 50.00 -``` - -**Best Practices** - -- Always use `Expr.col()` for column references: This ensures proper quoting for your target database - -```python -# Column reference (properly quoted identifier) -rq.Expr.col("user_name") # → "user_name" - -# String literal (value) -rq.Expr("user_name") # → 'user_name' -``` - -- Use `rapidquery.all()` and `rapidquery.any()` for logical combinations: More readable than chaining `&` and `|` operators - -```python -# Good -all(condition1, condition2, condition3) - -# Less readable -condition1 & condition2 & condition3 -``` - -- Be careful with `Expr.custom()`: It bypasses all safety checks - -```python -# Dangerous - vulnerable to SQL injection -user_input = "'; DROP TABLE users; --" -Expr.custom(f"name = '{user_input}'") - -# Safe -Expr.col("name") == user_input -``` - -#### Statement Builders - -Statements are divided into 2 categories: `QueryStatement`, and `SchemaStatement`. - -Some statements like `Select`, `Update`, `Delete`, `Insert`, ... are `QueryStatement`. -Other statements like `Table`, `AlterTable`, `Index`, ... are `SchemaStatement`. - -`QueryStatement` class interface is: - -```python -class QueryStatement: - """Subclass of query statements.""" - - def build(self, backend: _BackendName, /) -> tuple[str, tuple[Value, ...]]: - """Build the SQL statement with parameter values.""" - ... - - def to_sql(self, backend: _BackendName, /) -> str: - """ - Build a SQL string representation. - - **This method is unsafe and can cause SQL injection.** use `.build()` method instead. - """ - ... -``` - -`SchemaStatement` class interface is: - -```python -class SchemaStatement: - """Subclass of schema statements.""" - - def to_sql(self, backend: _BackendName, /) -> str: - """Build a SQL string representation.""" - ... -``` - -### Query Select - -Use `rapidquery.SelectStatement` type to generate `SELECT` statements. - -```python -import rapidquery as rq - -stmt = ( - rq.SelectStatement() - .columns("character", "fonts.name") - .from_table("characters") - .join("fonts", rq.Expr.col("characters.font_id") == rq.Expr.col("fonts.id"), "LEFT") - .where(rq.Expr.col("size_w").in_([3, 4])) - .where(rq.Expr.col("characters").like("A%")) -) -print(stmt.to_sql("postgres")) -# SELECT "character" AS "character", "fonts"."name" AS "name" FROM "characters" -# LEFT JOIN "fonts" ON "characters"."font_id" = "fonts"."id" -# WHERE "size_w" IN (3, 4) AND "characters" LIKE 'A%' - -print(stmt.to_sql("mysql")) -# SELECT `character` AS `character`, `fonts`.`name` AS `name` FROM `characters` -# LEFT JOIN `fonts` ON `characters`.`font_id` = `fonts`.`id` -# WHERE `size_w` IN (3, 4) AND `characters` LIKE 'A%' - -print(stmt.to_sql("sqlite")) -# SELECT "character" AS "character", "fonts"."name" AS "name" FROM "characters" -# LEFT JOIN "fonts" ON "characters"."font_id" = "fonts"."id" -# WHERE "size_w" IN (3, 4) AND "characters" LIKE 'A%' -``` - -### Query Insert - -Use `rapidquery.InsertStatement` type to generate `INSERT` statements. - -```python -import rapidquery as rq - -stmt = rq.InsertStatement("glyph").values(aspect=3.14, image="A4").on_conflict(rq.OnConflict("id").do_update("image")) - -print(stmt.to_sql("postgres")) -# INSERT INTO "glyph" ("aspect", "image") VALUES (3.14, 'A4') -# ON CONFLICT ("id") DO UPDATE SET "image" = "excluded"."image" -# -print(stmt.to_sql("mysql")) -# INSERT INTO `glyph` (`aspect`, `image`) VALUES (3.14, 'A4') -# ON DUPLICATE KEY UPDATE `image` = VALUES(`image`) - -print(stmt.to_sql("sqlite")) -# INSERT INTO "glyph" ("aspect", "image") VALUES (3.14, 'A4') -# ON CONFLICT ("id") DO UPDATE SET "image" = "excluded"."image" -``` - -### Query Update - -Use `rapidquery.UpdateStatement` type to generate `UPDATE` statements. - -```python -import rapidquery as rq - -stmt = rq.UpdateStatement("glyph").values(aspect=1.23, image="123").where(rq.Expr.col("id") == 1) - -print(stmt.to_sql("postgres")) -# UPDATE "glyph" SET "aspect" = 1.23, "image" = '123' WHERE "id" = 1 - -print(stmt.to_sql("mysql")) -# UPDATE `glyph` SET `aspect` = 1.23, `image` = '123' WHERE `id` = 1 - -print(stmt.to_sql("sqlite")) -# UPDATE "glyph" SET "aspect" = 1.23, "image" = '123' WHERE "id" = 1 -``` - -### Query Delete - -Use `rapidquery.DeleteStatement` type to generate `DELETE` statements. - -```python -import rapidquery as rq - -stmt = rq.DeleteStatement("glyph").where( - rq.any( - rq.Expr.col("id") < 1, - rq.Expr.col("id") > 10, - ) -) - -print(stmt.to_sql("postgres")) -# DELETE FROM "glyph" WHERE "id" < 1 OR "id" > 10 - -print(stmt.to_sql("mysql")) -# DELETE FROM `glyph` WHERE `id` < 1 OR `id` > 10 - -print(stmt.to_sql("sqlite")) -# DELETE FROM "glyph" WHERE "id" < 1 OR "id" > 10 -``` - -### Query With - -We have two types here: `rapidquery.WithClause` and `rapidquery.WithQuery`. - -```txt - WithQuery - | -|------------------------| -WITH [... CTEs ...] QUERY -|------------------| - | - WithClause -``` - -As you can see, `rapidquery.WithClause` includes common table expressions (CTEs), -and `rapidquery.WithQuery` includes `rapidquery.WithClause` and the final query. - -```python -import rapidquery as rq - -clause = ( - rq.WithClause() - .cte( - "users_count", - ( - rq.UpdateStatement("users") - .values(amount=rq.Expr.col("amount") + 10) - .where(rq.Expr.col("id") > 50) - .returning(rq.Returning(rq.Expr.val(1))) - ), - ) - .cte( - "teams_count", - ( - rq.UpdateStatement("teams") - .values(amount=rq.Expr.col("amount") + 10) - .where(rq.Expr.col("id") > 50) - .returning(rq.Returning(rq.Expr.val(1))) - ), - ) -) - -users_count_select = rq.SelectStatement(rq.Func.count(rq.Expr.asterisk())).from_table("users_count") -teams_count_select = rq.SelectStatement(rq.Func.count(rq.Expr.asterisk())).from_table("teams_count") - -query: WithQuery = clause.query( - rq.SelectStatement( - users_count_select.label("users"), - teams_count_select.label("teams"), - ) -) -query.to_sql("postgres") -# WITH -# "users_count" AS ( -# UPDATE "users" SET "amount" = "amount" + 10 WHERE "id" > 50 RETURNING 1 -# ) , -# "teams_count" AS ( -# UPDATE "teams" SET "amount" = "amount" + 10 WHERE "id" > 50 RETURNING 1 -# ) -# SELECT -# (SELECT COUNT(*) FROM "users_count") AS "users", -# (SELECT COUNT(*) FROM "teams_count") AS "teams" -``` - -### Custom Functions - -For working with functions in RapidQuery, you have to use `Func` class. -A lot of functions such as `SUM`, `AVG`, `MD5`, ... is ready to use. For example: - -```python -stmt = rq.SelectStatement(rq.Func.sum(rq.Expr.col("amount"))) -stmt.to_sql("postgres") -# SELECT SUM("amount") -``` - -But for functions not provided by the library, you can define custom functions. -Custom functions can be defined using the `Func` constructor: - -```python -stmt = rq.SelectStatement(rq.Func("CUSTOM", 1, "hello")) -stmt.to_sql("postgres") -# SELECT CUSTOM(1, 'hello') -``` - -### Table Create - -`rapidquery.Table` represents a complete database table definition. Use it to generate `CREATE TABLE` statements. - -```python -import rapidquery as rq - -characters = rq.Table( - "characters", - rq.Column("id", rq.sqltypes.Integer(), primary_key=True, auto_increment=True), - rq.Column("font_size", rq.sqltypes.Integer(), nullable=False), - rq.Column("character", rq.sqltypes.String(), nullable=False), - rq.Column("size_w", rq.sqltypes.Integer(), nullable=False), - rq.Column("size_h", rq.sqltypes.Integer(), nullable=False), - rq.Column("font_id", rq.sqltypes.Integer(), default=None), - rq.ForeignKey(["font_id"], ["fonts.id"], on_delete="CASCADE", on_update="CASCADE"), - rq.Index("idx_character", ["character"]), - if_not_exists=True, -) - -print(characters.to_sql("postgresql")) -# CREATE TABLE IF NOT EXISTS "characters" ( -# "id" serial PRIMARY KEY, -# "font_size" integer NOT NULL, -# "character" varchar NOT NULL, -# "size_w" integer NOT NULL, -# "size_h" integer NOT NULL, -# "font_id" integer DEFAULT NULL, -# FOREIGN KEY ("font_id") REFERENCES "fonts" ("id") ON DELETE CASCADE ON UPDATE CASCADE -# ); -# CREATE INDEX IF NOT EXISTS "idx_character" ON "characters" ("character") -``` - -### Table Alter - -Use `rapidquery.AlterTable` type to generate `ALTER TABLE` statements. - -```python -import rapidquery as rq - -stmt = rq.AlterTable( - "fonts", - [ - rq.AlterTableAddColumnOption( - rq.Column( - "new_col", - rq.sqltypes.Integer(), - nullable=False, - default=100, - ) - ), - rq.AlterTableRenameColumnOption("hello", "world"), - ], -) -print(stmt.to_sql("mysql")) -# ALTER TABLE `fonts` ADD COLUMN `new_col` int NOT NULL DEFAULT 100, -# RENAME COLUMN `hello` TO `world` -``` - -### Table Drop - -Use `rapidquery.DropTable` type to generate `DROP TABLE` statements. - -```python -import rapidquery as rq - -stmt = rq.DropTable("glyph", if_exists=True) -print(stmt.to_sql("mysql")) -# DROP TABLE IF EXISTS `glyph` -``` - -### Table Rename - -Use `rapidquery.RenameTable` type to generate `RENAME TABLE` statements. - -```python -import rapidquery as rq - -stmt = rq.RenameTable("old", "new") - -print(stmt.to_sql("sqlite")) -# ALTER TABLE "old" RENAME TO "new" - -print(stmt.to_sql("mysql")) -# RENAME TABLE `old` TO `new` -``` - -### Table Truncate - -Use `rapidquery.TruncateTable` type to generate `TRUNCATE TABLE` statements. - -```python -import rapidquery as rq - -stmt = rq.TruncateTable("old") - -print(stmt.to_sql("mysql")) -# TRUNCATE TABLE `old` -``` - -### Index Create - -```python -import rapidquery as rq - -idx = rq.Index("idx_glyph_aspect", ["aspect"], "glyph") - -print(idx.to_sql("postgres")) -# CREATE INDEX "idx_glyph_aspect" ON "glyph" ("aspect") -``` - -### Index Drop - -Use `rapidquery.DropIndex` type to generate `DROP INDEX` statements. - -```python -import rapidquery as rq - -idx = rq.DropIndex("idx_glyph_aspect", "glyph") - -print(idx.to_sql("postgres")) -# DROP INDEX "idx_glyph_aspect" -``` - -### More About Column Reference - -Let's learn some tricks about column references. - -In RapidQuery, we have something called `ColumnRef`, which represents a reference to a database column with optional table and schema qualification. - -> This type is a final type, which means you cannot use it as subclass. - -In generating statements, we have a lot of situations that you need to work with column references. - -**ā— The Trick** \ -For the parameters which accept column references, you have 4 ways: - -1. Use `ColumnRef`: - -```python -col_ref = rq.ColumnRef("id", "characters") -# OR -col_ref = rq.ColumnRef.parse("characters.id") - -stmt = rq.SelectStatement().columns(col_ref) -# SELECT "characters"."id" AS "id" -``` - -2. Use `str`: The easiest way - -```python -stmt = rq.SelectStatement().columns("characters.id") -# SELECT "characters"."id" AS "id" -``` - -3. Use `__column_ref__` property: developer-friendly and expandable way. - -```python -class IdColumnProperty: - @property - def __column_ref__(self): - # Can return ColumnRef or str - return "characters.id" - - -class IdColumnClassVar: - __column_ref__ = "characters.id" - - -stmt = rq.SelectStatement().columns(IdColumnProperty()) -# SELECT "characters"."id" AS "id" - -stmt = rq.SelectStatement().columns(IdColumnClassVar) -# SELECT "characters"."id" AS "id" -``` - -4. Use `Column`: It's possible because `Column` has `__column_ref__` property. - -```python -id = Column("id", rq.Integer()) - -stmt = rq.SelectStatement().columns(id) -# SELECT "id" AS "id" -``` - -### More About TableName - -Let's learn some tricks about table name. - -In RapidQuery, we have something called `TableName`, which represents a table name reference with optional schema, database, and alias. - -> This type is a final type, which means you cannot use it as subclass. - -In generating statements, we have situations that you need to specify table name. - -**ā— The Trick** \ -For the parameters which accept table name, you have 4 ways: - -1. Use `TableName`: - -```python -tbl = rq.TableName("users", schema="archive") -# OR -tbl = rq.TableName.parse("archive.users") - -stmt = rq.DeleteStatement(tbl) -# DELETE FROM "archive"."users" -``` - -2. Use `str`: The easiest way - -```python -stmt = rq.DeleteStatement("archive.users") -# DELETE FROM "archive"."users" -``` - -3. Use `__table_name__` property: developer-friendly and expandable way. - -```python -class UsersProperty: - @property - def __table_name__(self): - # Can return TableName or str - return "archive.users" - - -class UsersClassVar: - __table_name__ = "archive.users" - - -stmt = rq.DeleteStatement(UsersProperty()) -# DELETE FROM "archive"."users" - -stmt = rq.DeleteStatement(UsersClassVar) -# DELETE FROM "archive"."users" -``` - -4. Use `Table`: It's possible because `Table` has `__table_name__` property. - -```python -users = Table("archive.users", ...) - -stmt = rq.DeleteStatement(users) -# DELETE FROM "archive"."users" -``` - -### More About Expr - -You learned [here](#expr) about `Expr` type. - -**ā— The Trick** \ -This 2 tricks are notable and very good to know. - -1. First, like `ColumnRef` and `TableName`, `Expr` supports `__expr__` property, which should always return `Expr`. - -```python -class TextClause: - def __init__(self, expr: str) -> None: - self.expr = expr - - @property - def __expr__(self) -> rq.Expr: - return rq.Expr.custom(self.expr) - - -stmt = rq.SelectStatement(TextClause("WOW!")) -# SELECT WOW! -``` - -2. Second, same as `ColumnRef`, `Expr` also supports `__column_ref__` property. - -## Performance - -### Benchmarks - -Benchmarks run on _Linux 6.18.12-1-MANJARO x86_64_ with CPython 3.14. Your results may vary. - -Iterations per test: 100,000 \ -Python version: 3.14.3 - -``` -šŸ“Š SELECT Query Benchmark ----------------------------------------------------------------------- -Library Time (ms) vs Fastest Status ----------------------------------------------------------------------- -RapidQuery 245.44 1.00x (FASTEST) šŸ† -PyPika 4327.18 17.63x slower -SQLAlchemy 8818.33 35.93x slower ----------------------------------------------------------------------- - -šŸ“Š INSERT Query Benchmark ----------------------------------------------------------------------- -Library Time (ms) vs Fastest Status ----------------------------------------------------------------------- -RapidQuery 640.63 1.00x (FASTEST) šŸ† -PyPika 4655.51 7.27x slower -SQLAlchemy 7085.74 11.06x slower ----------------------------------------------------------------------- - -šŸ“Š UPDATE Query Benchmark ----------------------------------------------------------------------- -Library Time (ms) vs Fastest Status ----------------------------------------------------------------------- -RapidQuery 557.21 1.00x (FASTEST) šŸ† -PyPika 4488.96 8.06x slower -SQLAlchemy 11839.85 21.25x slower ----------------------------------------------------------------------- - -šŸ“Š DELETE Query Benchmark ----------------------------------------------------------------------- -Library Time (ms) vs Fastest Status ----------------------------------------------------------------------- -RapidQuery 441.38 1.00x (FASTEST) šŸ† -PyPika 4517.16 10.23x slower -SQLAlchemy 7924.52 17.95x slower ----------------------------------------------------------------------- -``` - -## Known Issues - -### Unmanaged Rust panic output in building SQL - -The library may encounter errors during SQL query construction, which are correctly raised as _RuntimeError_ exceptions. For instance, this occurs when using a function that isn't supported by your target database. **While this error-raising behavior is intentional and logical, the issue is that unmanaged Rust panic information is also printed to stderr**. Currently, there is no way to suppress or manage this panic output. We are working to resolve this problem as much as possible in future updates. - -```python ->>> import rapidquery as rq ->>> stmt = rq.TruncateTable("users") ->>> print(stmt.to_sql("sqlite")) - -thread '' (14206) panicked at sea-query-0.32.7/src/backend/sqlite/table.rs:58:9: -Sqlite doesn't support TRUNCATE statement -Traceback (most recent call last): - File "", line 1, in - print(stmt.to_sql("sqlite")) - ~~~~~~~~~~~^^^^^^^^^^ -RuntimeError: build failed -``` - -### Missing `__init__` Calls - -If a RapidQuery object is instantiated without calling its `__init__` method (e.g., via certain serialization tricks or `__new__` alone), the internal Rust pointer will be null. Accessing methods on such objects will cause an unmanaged Rust panic. Always use the provided constructors. - -### Join conditions are not optional - -Currently, `JOIN` operations require an explicit ON or USING condition. - -## Supported Platforms - -Pre-built wheels are available for the following platforms and Python interpreters: - -### Linux (manylinux) - -| Architecture | Python Versions | -| ------------ | ------------------------------------------------ | -| x86_64 | 3.10 – 3.14, 3.14t, PyPy 3.11, GraalPy 3.11/3.12 | -| i686 | 3.10 – 3.14, 3.14t, PyPy 3.11 | -| aarch64 | 3.10 – 3.14, 3.14t, PyPy 3.11, GraalPy 3.11/3.12 | -| armv7 | 3.10 – 3.14, 3.14t | -| s390x | 3.10 – 3.14, 3.14t | -| ppc64le | 3.10 – 3.14, 3.14t | -| riscv64 | 3.10 – 3.14, 3.14t | - -### Linux (musllinux 1.1) - -| Architecture | Python Versions | -| ------------ | ----------------------------- | -| x86_64 | 3.10 – 3.14, 3.14t, PyPy 3.11 | -| aarch64 | 3.10 – 3.14, 3.14t, PyPy 3.11 | -| armv7 | 3.10 – 3.14, 3.14t, PyPy 3.11 | - -### Windows - -| Architecture | Python Versions | -| ------------ | ------------------ | -| x64 | 3.10 – 3.14, 3.14t | -| x86 | 3.10 – 3.14, 3.14t | - -### macOS - -| Architecture | Python Versions | -| ----------------------- | ------------------------------------ | -| x86_64 | 3.10 – 3.14, 3.13t, 3.14t, PyPy 3.11 | -| aarch64 (Apple Silicon) | 3.10 – 3.14, 3.13t, 3.14t, PyPy 3.11 | - -A source distribution (sdist) is also published for platforms not listed above, which requires a Rust nightly toolchain to build from source. - -## TODO - -- [x] Write tests -- [x] Update & automate workflows -- [x] Write CTE -- [x] Complete README.md -- [x] Bump version to 0.1.0 -- [ ] Complete backend-only functions - -## License - -This repository is licensed under the [GNU GPLv3 License](LICENSE) +> This branch is under development and incomplete. diff --git a/rapidquery/__init__.py b/rapidquery/__init__.py index ef0ddc6..8206e08 100644 --- a/rapidquery/__init__.py +++ b/rapidquery/__init__.py @@ -1,5 +1,3 @@ -"""Python SQL query builder with a Rust engine.""" - from . import dialect as dialect from . import expr as expr from . import serializers as serializers diff --git a/rapidquery/dialect.py b/rapidquery/dialect.py index 2b814c9..7eb945d 100644 --- a/rapidquery/dialect.py +++ b/rapidquery/dialect.py @@ -1,7 +1,3 @@ -""" -SQL dialect markers. -""" - from ._core.dialect import ( # Dialect options OPT_ARRAY_FORMAT_CONSTRUCTOR, diff --git a/rapidquery/dialect.pyi b/rapidquery/dialect.pyi index 1c38335..fdbddb1 100644 --- a/rapidquery/dialect.pyi +++ b/rapidquery/dialect.pyi @@ -1,120 +1,395 @@ +""" +SQL dialects and the integer flags that configure one. + +Use a builtin (`PostgreSql`, `Sqlite`, `MySql`, and the others) when rendering +an expression. Build a `Dialect` only when you need a different combination of +`OPT_*` flags. Each flag selects a spelling; it does not invent syntax the +dialect lacks. + +Examples: + Quote an identifier and write a boolean the way MySQL does: + + >>> from rapidquery.dialect import ( + ... OPT_BOOL_FORMAT_INTEGER, + ... OPT_IDENTIFIER_FORMAT_BACKTICK, + ... Dialect, + ... ) + >>> dialect = Dialect( + ... bool_format=OPT_BOOL_FORMAT_INTEGER, + ... identifier_format=OPT_IDENTIFIER_FORMAT_BACKTICK, + ... ) + >>> dialect.write_boolean(True) + '1' + >>> dialect.write_identifier("id") + '`id`' +""" + import typing OPT_PLACEHOLDER_SIMPLE: typing.Final[int] +"""Placeholders are a bare `?`.""" + OPT_PLACEHOLDER_NUMBERED: typing.Final[int] +"""Placeholders are numbered, such as `$1`.""" + OPT_NULL_TREATMENT_UNSUPPORTED: typing.Final[int] +"""`IGNORE NULLS` and `RESPECT NULLS` are rejected.""" + OPT_NULL_TREATMENT_INSIDE_PARENS: typing.Final[int] +"""Null treatment is written inside the call parentheses.""" + OPT_NULL_TREATMENT_POSTFIX: typing.Final[int] +"""Null treatment is written after the call.""" + OPT_LIKE_ESCAPE_UNSUPPORTED: typing.Final[int] +"""`LIKE` has no `ESCAPE` clause.""" + OPT_LIKE_ESCAPE_SCALAR: typing.Final[int] +"""`ESCAPE` is legal on a single pattern, not on native `LIKE ANY`.""" + OPT_LIKE_ESCAPE_INCLUDING_ANY: typing.Final[int] +"""`ESCAPE` is legal on a single pattern and on `LIKE ANY`.""" + OPT_LIKE_ANY_OR_EXPANSION: typing.Final[int] +"""`LIKE ANY` is expanded to `OR` of scalar `LIKE` predicates.""" + OPT_LIKE_ANY_ARRAY_CONSTRUCTOR: typing.Final[int] +"""`LIKE ANY` uses `ANY (ARRAY[…])`.""" + OPT_LIKE_ANY_PATTERN_LIST: typing.Final[int] +"""`LIKE ANY` uses a parenthesized pattern list.""" + OPT_ILIKE_EXPRESSION_STANDARD: typing.Final[int] +"""Case-insensitive match is the `ILIKE` operator.""" + OPT_ILIKE_EXPRESSION_USE_LIKE_LOWER: typing.Final[int] +"""Case-insensitive match lowers both sides and uses `LIKE`.""" + OPT_ILIKE_EXPRESSION_USE_LIKE: typing.Final[int] +"""Case-insensitive match falls back to `LIKE`.""" + OPT_RLIKE_EXPRESSION_UNSUPPORTED: typing.Final[int] +"""Regex predicates are rejected.""" + OPT_RLIKE_EXPRESSION_STANDARD: typing.Final[int] +"""Regex match is the `RLIKE` operator.""" + OPT_RLIKE_EXPRESSION_TILDE: typing.Final[int] +"""Regex match is `~`.""" + OPT_RLIKE_EXPRESSION_REGEXP_LIKE: typing.Final[int] +"""Regex match is `REGEXP_LIKE`.""" + OPT_RLIKE_EXPRESSION_REGEXP_CONTAINS: typing.Final[int] +"""Regex match is `REGEXP_CONTAINS`.""" + OPT_RLIKE_EXPRESSION_REGEXP_MATCHES: typing.Final[int] +"""Regex match is `regexp_matches`.""" + OPT_STRING_CONCAT_PIPES: typing.Final[int] +"""String concatenation is `||`.""" + OPT_STRING_CONCAT_PLUS: typing.Final[int] +"""String concatenation is `+`.""" + OPT_STRING_CONCAT_CONCAT_FUNCTION: typing.Final[int] +"""String concatenation is `CONCAT`.""" + OPT_BITXOR_UNSUPPORTED: typing.Final[int] +"""Bitwise XOR is rejected.""" + OPT_BITXOR_CARET: typing.Final[int] +"""Bitwise XOR is `^`.""" + OPT_BITXOR_HASH: typing.Final[int] +"""Bitwise XOR is `#`.""" + OPT_BITXOR_XOR_FUNCTION: typing.Final[int] +"""Bitwise XOR is `XOR`.""" + OPT_BITXOR_BIT_XOR_FUNCTION: typing.Final[int] +"""Bitwise XOR is `BITXOR`.""" + OPT_MODULO_PERCENT: typing.Final[int] +"""Modulo is `%`.""" + OPT_MODULO_FUNCTION: typing.Final[int] +"""Modulo is a function call.""" + OPT_MODULO_INFIX: typing.Final[int] +"""Modulo is the dialect's infix operator.""" + OPT_IS_DISTINCT_FROM_UNSUPPORTED: typing.Final[int] +"""`IS DISTINCT FROM` is rejected.""" + OPT_IS_DISTINCT_FROM_STANDARD: typing.Final[int] +"""`IS DISTINCT FROM` uses the SQL standard spelling.""" + OPT_IS_DISTINCT_FROM_NULL_SAFE_EQUAL: typing.Final[int] +"""`IS DISTINCT FROM` uses the dialect's null-safe equality operator.""" + OPT_TRY_CAST_UNSUPPORTED: typing.Final[int] +"""`TRY_CAST` is rejected.""" + OPT_TRY_CAST_TRY_CAST: typing.Final[int] +"""`TRY_CAST` is written `TRY_CAST`.""" + OPT_TRY_CAST_TRY_CAST_WORD: typing.Final[int] +"""`TRY_CAST` is written as one word, `TRYCAST`.""" + OPT_TRY_CAST_SAFE_CAST: typing.Final[int] +"""`TRY_CAST` is written `SAFE_CAST`.""" + OPT_TRIM_SQL_FROM: typing.Final[int] +"""`TRIM` uses the SQL `TRIM([side] chars FROM expr)` form.""" + OPT_TRIM_FUNCTION: typing.Final[int] +"""`TRIM` is a function, including `LTRIM` and `RTRIM`.""" + OPT_TRIM_SPACES_ONLY: typing.Final[int] +"""`TRIM` can remove spaces only.""" + OPT_COLLATE_UNSUPPORTED: typing.Final[int] +"""`COLLATE` is rejected.""" + OPT_COLLATE_INFIX_IDENT: typing.Final[int] +"""`COLLATE` is an infix identifier.""" + OPT_COLLATE_FUNCTION_STRING: typing.Final[int] +"""`COLLATE` is a function and the name is a string.""" + OPT_BITWISE_UNSUPPORTED: typing.Final[int] +"""Bitwise operators are rejected.""" + OPT_BITWISE_INFIX: typing.Final[int] +"""Bitwise AND, OR, NOT, and shifts are infix operators.""" + OPT_BITWISE_INFIX_AND_SHIFT: typing.Final[int] +"""Bitwise operators and shifts are both infix.""" + OPT_BITWISE_INFIX_NO_SHIFT: typing.Final[int] +"""Bitwise AND, OR, and NOT are infix. Shifts are rejected.""" + OPT_BITWISE_BIT_SHIFT_FUNCTIONS: typing.Final[int] +"""Shifts are `BITSHIFTLEFT` and `BITSHIFTRIGHT`.""" + OPT_BITWISE_SHIFT_FUNCTIONS: typing.Final[int] +"""Shifts are `SHIFTLEFT` and `SHIFTRIGHT`.""" + OPT_BITWISE_BIT_AND_ONLY: typing.Final[int] +"""Only bitwise AND is available, as a function.""" + OPT_TUPLE_UNSUPPORTED: typing.Final[int] +"""Tuple values are rejected.""" + OPT_TUPLE_ROW: typing.Final[int] +"""Tuples are `ROW(…)`.""" + OPT_TUPLE_ROW_REJECT_EMPTY: typing.Final[int] +"""Tuples are `ROW(…)`. An empty tuple is rejected.""" + OPT_TUPLE_STRUCT: typing.Final[int] +"""Tuples are `struct(…)`.""" + OPT_TUPLE_PARENTHESES: typing.Final[int] +"""Tuples are `(…)`. Fewer than two elements is rejected.""" + OPT_BIT_FORMAT_UNSUPPORTED: typing.Final[int] +"""Bit-string literals are rejected.""" + OPT_BIT_FORMAT_PREFIX_BIG_B: typing.Final[int] +"""Bit strings are `B'…'`.""" + OPT_BIT_FORMAT_PREFIX_SMALL_B: typing.Final[int] +"""Bit strings are `b'…'`.""" + OPT_BIT_FORMAT_POSTFIX_BIG_B: typing.Final[int] +"""Bit strings are `'…'B`.""" + OPT_BOOL_FORMAT_KEYWORD: typing.Final[int] +"""Booleans are `TRUE` and `FALSE`.""" + OPT_BOOL_FORMAT_INTEGER: typing.Final[int] +"""Booleans are `1` and `0`.""" + OPT_IDENTIFIER_FORMAT_DOUBLE_QUOTE: typing.Final[int] +"""Quoted identifiers use double quotes.""" + OPT_IDENTIFIER_FORMAT_BACKTICK: typing.Final[int] +"""Quoted identifiers use backticks.""" + OPT_IDENTIFIER_FORMAT_SQUARE_BRACKET: typing.Final[int] +"""Quoted identifiers use square brackets.""" + OPT_IDENTIFIER_ESCAPE_DOUBLE: typing.Final[int] +"""A quote inside an identifier is written twice.""" + OPT_IDENTIFIER_ESCAPE_BACKSLASH: typing.Final[int] +"""A quote inside an identifier is escaped with a backslash.""" + OPT_STRING_FORMAT_SINGLE_QUOTE: typing.Final[int] +"""String literals use single quotes.""" + OPT_STRING_FORMAT_PREFIX_BIG_E: typing.Final[int] +"""String literals use the `E'…'` escape prefix.""" + OPT_STRING_FORMAT_PREFIX_BIG_N: typing.Final[int] +"""String literals use the national `N'…'` prefix.""" + OPT_STRING_ESCAPE_QUOTE: typing.Final[int] +"""A quote inside a string is written twice.""" + OPT_STRING_ESCAPE_BACKSLASH_HEX: typing.Final[int] +"""String escapes use backslash, with non-ASCII bytes in hex.""" + OPT_STRING_ESCAPE_BACKSLASH_MYSQL: typing.Final[int] +"""String escapes use the MySQL backslash table.""" + OPT_STRING_ESCAPE_BACKSLASH_UNICODE: typing.Final[int] +"""String escapes use backslash Unicode sequences.""" + OPT_STRING_ESCAPE_BACKSLASH_HEX_ZERO: typing.Final[int] +"""String escapes use backslash hex, and NUL is rejected.""" + OPT_STRING_ESCAPE_BACKSLASH_UNICODE_QUOTE: typing.Final[int] +"""String escapes use backslash Unicode, and quotes are escaped too.""" + OPT_BYTES_FORMAT_PREFIX_BIG_X: typing.Final[int] +"""Bytes are `X'…'`.""" + OPT_BYTES_FORMAT_PREFIX_ZERO_X: typing.Final[int] +"""Bytes are `0x…`.""" + OPT_BYTES_FORMAT_SLASH_X_BYTEA: typing.Final[int] +"""Bytes are `'\\x…'::bytea`.""" + OPT_BYTES_FORMAT_PREFIX_BIG_B_SLASH_X: typing.Final[int] +"""Bytes are `b'\\x…'`.""" + OPT_BYTES_FORMAT_HEX_TO_RAW: typing.Final[int] +"""Bytes are `HEXTORAW('…')`.""" + OPT_BYTES_FORMAT_UNHEX: typing.Final[int] +"""Bytes are `UNHEX('…')`.""" + OPT_BYTES_FORMAT_TO_VARBYTE: typing.Final[int] +"""Bytes are `TO_VARBYTE(…)`.""" + OPT_BYTES_FORMAT_POSTFIX_XBV: typing.Final[int] +"""Bytes use the dialect's postfix binary literal.""" + OPT_BYTES_FORMAT_TO_BINARY: typing.Final[int] +"""Bytes are `TO_BINARY('…')`.""" + OPT_BYTES_FORMAT_SLASH_X_BLOB: typing.Final[int] +"""Bytes are `'\\x…'::BLOB`.""" + OPT_FUNCTION_FILTER_UNSUPPORTED: typing.Final[int] +"""Aggregate `FILTER` is rejected.""" + OPT_FUNCTION_FILTER_FILTER: typing.Final[int] +"""`FILTER` omits the `WHERE` keyword.""" + OPT_FUNCTION_FILTER_FILTER_WHERE: typing.Final[int] +"""`FILTER` is `FILTER (WHERE …)`.""" + OPT_FUNCTION_FILTER_INSIDE_WHERE: typing.Final[int] +"""The filter is a `WHERE` clause inside the call.""" + OPT_ARRAY_FORMAT_UNSUPPORTED: typing.Final[int] +"""Array literals are rejected.""" + OPT_ARRAY_FORMAT_CONSTRUCTOR: typing.Final[int] +"""Arrays are `ARRAY[…]`.""" + OPT_ARRAY_FORMAT_LIST: typing.Final[int] +"""Arrays are a bracket list.""" + OPT_ARRAY_FORMAT_FUNCTION: typing.Final[int] +"""Arrays are a function call.""" + OPT_DATE_FORMAT_QUOTED: typing.Final[int] +"""Dates are quoted `YYYY-MM-DD` strings.""" + OPT_DATE_FORMAT_KEYWORD: typing.Final[int] +"""Dates are `DATE 'YYYY-MM-DD'`.""" + OPT_TIME_FORMAT_QUOTED: typing.Final[int] +"""Times are quoted strings.""" + OPT_TIME_FORMAT_KEYWORD: typing.Final[int] +"""Times are `TIME '…'`.""" + OPT_TIMESTAMP_FORMAT_QUOTED: typing.Final[int] +"""Timestamps are quoted strings.""" + OPT_TIMESTAMP_FORMAT_TIMESTAMPTZ: typing.Final[int] +"""Aware timestamps use the `TIMESTAMPTZ` keyword.""" + OPT_TIMESTAMP_FORMAT_WITH_TIMEZONE: typing.Final[int] +"""Aware timestamps use `TIMESTAMP WITH TIMEZONE`.""" + OPT_INET_FORMAT_UNSUPPORTED: typing.Final[int] +"""Inet literals are rejected.""" + OPT_INET_FORMAT_QUOTED: typing.Final[int] +"""Inet values are quoted strings.""" + OPT_INET_FORMAT_CAST: typing.Final[int] +"""Inet values are `'…'::inet`.""" + OPT_MACADDR_FORMAT_UNSUPPORTED: typing.Final[int] +"""MAC address literals are rejected.""" + OPT_MACADDR_FORMAT_QUOTED: typing.Final[int] +"""MAC addresses are quoted strings.""" + OPT_MACADDR_FORMAT_CAST: typing.Final[int] +"""MAC addresses are `'…'::macaddr`.""" + OPT_UUID_FORMAT_QUOTED: typing.Final[int] +"""UUIDs are quoted strings.""" + OPT_UUID_FORMAT_UUID_KEYWORD: typing.Final[int] +"""UUIDs are `UUID '…'`.""" + OPT_UUID_FORMAT_CAST_UUID: typing.Final[int] +"""UUIDs are `'…'::uuid`.""" + OPT_JSON_FORMAT_QUOTED: typing.Final[int] +"""JSON is a quoted string.""" + OPT_JSON_FORMAT_JSON_KEYWORD: typing.Final[int] +"""JSON is `JSON '…'`.""" + OPT_JSON_FORMAT_PARSE_JSON: typing.Final[int] +"""JSON is `PARSE_JSON('…')`.""" class Dialect: + """Quoting, literal spelling, and which SQL constructs are legal. + + Use a builtin dialect when rendering an expression. Construct `Dialect` + when you need a different mix of `OPT_*` flags. Flags select a spelling + the renderer already knows. They do not add syntax that has no writer. + + Examples: + Write MySQL-style booleans and identifiers: + + >>> from rapidquery.dialect import ( + ... OPT_BOOL_FORMAT_INTEGER, + ... OPT_IDENTIFIER_FORMAT_BACKTICK, + ... Dialect, + ... ) + >>> dialect = Dialect( + ... bool_format=OPT_BOOL_FORMAT_INTEGER, + ... identifier_format=OPT_IDENTIFIER_FORMAT_BACKTICK, + ... ) + >>> dialect.write_boolean(True) + '1' + >>> dialect.write_identifier("id") + '`id`' + """ + def __new__( cls, *, @@ -153,96 +428,297 @@ class Dialect: supports_similar_to: bool = False, parenthesize_in_between: bool = False, supports_double_colon: bool = False, - ) -> typing.Self: ... - def write_boolean(self, value: bool) -> str: ... - def escape_string(self, value: str) -> str: ... - def write_identifier(self, value: str) -> str: ... - def write_string_literal(self, value: str) -> str: ... - def write_bits(self, value: str) -> str: ... - def write_bytes(self, value: bytes) -> str: ... + ) -> typing.Self: + """ + Build a dialect from flags. + + Each integer argument is one `OPT_*` constant. Omitted flags use the + zero value, which is that option's fallback spelling. + + Args: + placeholder_character: One character written for a parameter. + placeholder: `OPT_PLACEHOLDER_SIMPLE` or `OPT_PLACEHOLDER_NUMBERED`. + null_treatment: Where `{IGNORE|RESPECT} NULLS` is written. + like_escape: Where `LIKE … ESCAPE` is legal. + like_any: How `LIKE ANY` is spelled. + ilike_expression: How a case-insensitive `LIKE` is spelled. + rlike_expression: How a regex predicate is spelled. + string_concat: How `||` is spelled. + bitxor: How `^` is spelled. + modulo: How `%` is spelled. + is_distinct_from: How `IS DISTINCT FROM` is spelled. + try_cast: How `TRY_CAST` is spelled. + trim: How `TRIM` is spelled. + collate: How `COLLATE` is spelled. + bitwise: How `&`, `|`, `~`, and shifts are spelled. + tuple: How a row value is spelled. + bit_format: How a bit-string literal is spelled. + bool_format: `TRUE`/`FALSE` or `1`/`0`. + identifier_format: Delimiter around a quoted identifier. + identifier_escape: How an identifier quotes its delimiter. + string_format: Prefix and quotes of a string literal. + string_escape: How special characters in a string are escaped. + bytes_format: How a byte literal is spelled. + function_filter: How aggregate `FILTER` is spelled. + array_format: How an array literal is spelled. + date_format: How a date literal is spelled. + time_format: How a time literal is spelled. + timestamp_format: How a timestamp literal is spelled. + inet_format: How an inet literal is spelled. + macaddr_format: How a MAC address literal is spelled. + uuid_format: How a UUID literal is spelled. + json_format: How a JSON literal is spelled. + supports_similar_to: Whether `SIMILAR TO` is legal. + parenthesize_in_between: Wrap `IN` and `BETWEEN` in parentheses. + supports_double_colon: Whether `expr::type` is legal. + + Raises: + TypeError: `placeholder_character` is not one character, or a flag + is not an `int`. + """ + + def write_boolean(self, value: bool) -> str: + """ + Write `value` as this dialect's boolean literal. + + Args: + value: Python boolean. + + Returns: + `TRUE`, `FALSE`, `1`, or `0`. + """ + + def write_identifier(self, value: str) -> str: + """Quote `value` with this dialect's identifier delimiters. + + Args: + value: Raw identifier body. + + Returns: + The delimited identifier. + """ + + def write_string_literal(self, value: str) -> str: + """ + Escape `value` and wrap it as a complete string literal. + + Args: + value: Text to embed. A NUL character is rejected. + + Returns: + The SQL string literal. + + Raises: + ValueError: `value` contains a NUL character. + """ + + def write_bits(self, value: str) -> str: + """ + Write a bit string of `0` and `1` characters. + + Args: + value: Bit characters. Empty is legal. Any other character is not. + + Returns: + The dialect's bit literal. + + Raises: + ValueError: `value` contains a character other than `0` or `1`. + NotImplementedError: This dialect has no bit-string literal. + """ + + def write_bytes(self, value: bytes) -> str: + """Write `value` as this dialect's blob or bytea literal. + + Args: + value: Raw bytes. + + Returns: + The SQL literal. + + Raises: + NotImplementedError: This dialect has no byte literal of the + configured form. + """ + @property - def placeholder_character(self) -> str: ... + def placeholder_character(self) -> str: + """The one character used in placeholders.""" + @property - def placeholder(self) -> int: ... + def placeholder(self) -> int: + """`OPT_PLACEHOLDER_SIMPLE` or `OPT_PLACEHOLDER_NUMBERED`.""" + @property - def null_treatment(self) -> int: ... + def null_treatment(self) -> int: + """Where `{IGNORE|RESPECT} NULLS` is written.""" + @property - def like_escape(self) -> int: ... + def like_escape(self) -> int: + """Where `LIKE … ESCAPE` is legal.""" + @property - def like_any(self) -> int: ... + def like_any(self) -> int: + """How `LIKE ANY` is spelled.""" + @property - def ilike_expression(self) -> int: ... + def ilike_expression(self) -> int: + """How a case-insensitive match is spelled.""" + @property - def rlike_expression(self) -> int: ... + def rlike_expression(self) -> int: + """How a regex predicate is spelled.""" + @property - def string_concat(self) -> int: ... + def string_concat(self) -> int: + """How string concatenation is spelled.""" + @property - def bitxor(self) -> int: ... + def bitxor(self) -> int: + """How bitwise XOR is spelled.""" + @property - def modulo(self) -> int: ... + def modulo(self) -> int: + """How modulo is spelled.""" + @property - def is_distinct_from(self) -> int: ... + def is_distinct_from(self) -> int: + """How `IS DISTINCT FROM` is spelled.""" + @property - def try_cast(self) -> int: ... + def try_cast(self) -> int: + """How `TRY_CAST` is spelled.""" + @property - def trim(self) -> int: ... + def trim(self) -> int: + """How `TRIM` is spelled.""" + @property - def collate(self) -> int: ... + def collate(self) -> int: + """How `COLLATE` is spelled.""" + @property - def bitwise(self) -> int: ... + def bitwise(self) -> int: + """How bitwise operators are spelled.""" + @property - def tuple(self) -> int: ... + def tuple(self) -> int: + """How a row value is spelled.""" + @property - def bit_format(self) -> int: ... + def bit_format(self) -> int: + """How a bit-string literal is spelled.""" + @property - def bool_format(self) -> int: ... + def bool_format(self) -> int: + """How a boolean literal is spelled.""" + @property - def identifier_format(self) -> int: ... + def identifier_format(self) -> int: + """Which delimiter wraps a quoted identifier.""" + @property - def identifier_escape(self) -> int: ... + def identifier_escape(self) -> int: + """How an identifier quotes its own delimiter.""" + @property - def string_format(self) -> int: ... + def string_format(self) -> int: + """Which prefix and quotes a string literal uses.""" + @property - def string_escape(self) -> int: ... + def string_escape(self) -> int: + """How special characters in a string are escaped.""" + @property - def bytes_format(self) -> int: ... + def bytes_format(self) -> int: + """How a byte literal is spelled.""" + @property - def function_filter(self) -> int: ... + def function_filter(self) -> int: + """How aggregate `FILTER` is spelled.""" + @property - def array_format(self) -> int: ... + def array_format(self) -> int: + """How an array literal is spelled.""" + @property - def date_format(self) -> int: ... + def date_format(self) -> int: + """How a date literal is spelled.""" + @property - def time_format(self) -> int: ... + def time_format(self) -> int: + """How a time literal is spelled.""" + @property - def timestamp_format(self) -> int: ... + def timestamp_format(self) -> int: + """How a timestamp literal is spelled.""" + @property - def inet_format(self) -> int: ... + def inet_format(self) -> int: + """How an inet literal is spelled.""" + @property - def macaddr_format(self) -> int: ... + def macaddr_format(self) -> int: + """How a MAC address literal is spelled.""" + @property - def uuid_format(self) -> int: ... + def uuid_format(self) -> int: + """How a UUID literal is spelled.""" + @property - def json_format(self) -> int: ... + def json_format(self) -> int: + """How a JSON literal is spelled.""" + @property - def supports_similar_to(self) -> bool: ... + def supports_similar_to(self) -> bool: + """Whether `SIMILAR TO` can be rendered.""" + @property - def parenthesize_in_between(self) -> bool: ... + def parenthesize_in_between(self) -> bool: + """Whether `IN` and `BETWEEN` are wrapped in parentheses.""" + @property - def supports_double_colon(self) -> bool: ... + def supports_double_colon(self) -> bool: + """Whether `expr::type` can be rendered.""" + def __eq__(self, other: object) -> bool: ... def __hash__(self) -> int: ... BigQuery: typing.Final[Dialect] = ... +"""BigQuery standard SQL.""" + Databricks: typing.Final[Dialect] = ... +"""Databricks SQL.""" + DuckDb: typing.Final[Dialect] = ... +"""DuckDB SQL.""" + Hive: typing.Final[Dialect] = ... +"""HiveQL.""" + MsSql: typing.Final[Dialect] = ... +"""Microsoft SQL Server.""" + MySql: typing.Final[Dialect] = ... +"""MySQL.""" + Oracle: typing.Final[Dialect] = ... +"""Oracle SQL.""" + PostgreSql: typing.Final[Dialect] = ... +"""PostgreSQL.""" + Redshift: typing.Final[Dialect] = ... +"""Amazon Redshift.""" + Snowflake: typing.Final[Dialect] = ... +"""Snowflake SQL.""" + Spark: typing.Final[Dialect] = ... +"""Spark SQL.""" + Sqlite: typing.Final[Dialect] = ... +"""SQLite.""" + Teradata: typing.Final[Dialect] = ... +"""Teradata SQL.""" diff --git a/rapidquery/expr.py b/rapidquery/expr.py index a877e81..9b35dc7 100644 --- a/rapidquery/expr.py +++ b/rapidquery/expr.py @@ -1,7 +1,3 @@ -""" -SQL expressions. -""" - from ._core.expr import ( Asterisk, BetweenExpr, diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index 16d9e82..2b11c77 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -6,78 +6,372 @@ from rapidquery.dialect import Dialect from rapidquery.serializers import Serializer class Expr: - def to_sql(self, dialect: Dialect) -> str: ... - def prepare(self, dialect: Dialect) -> tuple[str, tuple[typing.Any, ...]]: ... + """ + Base of every SQL expression node. + + Do not construct `Expr` itself. Build a subclass (`Ident`, `Value`, + `BinaryExpr`, and the rest), then render it. `to_sql` inlines literals. + `prepare` binds them and returns the SQL plus the bound objects. + + Examples: + >>> from rapidquery.dialect import Sqlite + >>> from rapidquery.expr import BinaryExpr, Ident + >>> BinaryExpr(Ident("age"), ">", 18).to_sql(Sqlite) + '"age" > 18' + """ + + def to_sql(self, dialect: Dialect) -> str: + """ + Render this node with values written as SQL literals. + + Use this to inspect SQL. Prefer `prepare` when a driver will bind + the parameters. + + Args: + dialect: Dialect that chooses quoting and literal spelling. + + Returns: + The SQL text. + + Raises: + RuntimeError: The dialect rejects this construct. + NotImplementedError: The dialect has no spelling for this construct. + ValueError: A `CustomExpr` template has a different number of `{}` + slots than arguments. + """ + + def prepare(self, dialect: Dialect) -> tuple[str, tuple[typing.Any, ...]]: + """ + Render this node and bind each `Value` as a placeholder. + + Identifiers and keywords are still written as SQL. Only values become + parameters, in left-to-right order. The placeholder character comes + from the dialect (`?` or `$1`). + + Args: + dialect: Dialect that chooses quoting and placeholders. + + Returns: + The SQL text and the bound Python objects, in placeholder order. + + Raises: + RuntimeError: The dialect rejects this construct. + NotImplementedError: The dialect has no spelling for this construct. + ValueError: A `CustomExpr` template has a different number of `{}` + slots than arguments. + + Examples: + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.expr import Value + >>> Value(1).prepare(PostgreSql) + ('$1', (1,)) + """ @final class Unquote: - def __new__(cls, name: str, /) -> Self: ... + """ + Identifier body that is written without quote characters. + + Use this for a keyword-like name that must stay bare, such as + `CURRENT_TIMESTAMP`. The name must match `[A-Za-z_][A-Za-z0-9_]*`. + This is not a raw SQL fragment. + + Examples: + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.expr import Ident, Unquote + >>> Ident(Unquote("COUNT")).to_sql(PostgreSql) + 'COUNT' + """ + + def __new__(cls, name: str, /) -> Self: + """ + Store `name` as an unquoted identifier part. + + Args: + name: ASCII identifier body. A digit cannot be first. + + Raises: + ValueError: `name` is empty or is not a simple ASCII identifier. + """ + @property - def name(self) -> str: ... + def name(self) -> str: + """The identifier body, without quotes.""" + def __eq__(self, other: object) -> bool: ... def __hash__(self) -> int: ... @final class Asterisk: - def __new__(cls) -> Self: ... + """ + The SQL wildcard `*`. + + Pass the class or an instance as the last part of an `Ident` (`t.*`). + `Ident("*")` is a quoted column whose name is a star, not this wildcard. + + Examples: + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.expr import Asterisk, Ident + >>> Ident("t", Asterisk).to_sql(PostgreSql) + '"t".*' + """ + + def __new__(cls) -> Self: + """Return the wildcard marker.""" + def __eq__(self, other: object) -> bool: ... def __hash__(self) -> int: ... @final class Ident(Expr): - def __new__(cls, *parts: str | Unquote | Ident | Asterisk | type[Asterisk]) -> Self: ... - def __len__(self) -> int: ... - def __getitem__(self, index: int) -> str | Unquote | Asterisk: ... + """ + Dotted SQL name: schema, table, column, or a final wildcard. + + Use `Ident` wherever a name must be quoted for the dialect. Pass one + argument per part. A `str` is always quoted, including `"*"`. Flatten a + nested `Ident` by passing it as one argument. At most 10 parts. + + Examples: + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.expr import Ident + >>> Ident("users", "id").to_sql(PostgreSql) + '"users"."id"' + """ + + def __new__(cls, *parts: str | Unquote | Ident | Asterisk | type[Asterisk]) -> Self: + """ + Build an identifier from parts. + + Args: + parts: One part each. A `str` is quoted. `Unquote` stays bare. + `Asterisk` is legal only as the last part. + + Raises: + TypeError: A part is not a string, `Unquote`, `Asterisk`, or `Ident`. + ValueError: There are no parts, a part is empty, `Asterisk` is not + last, or there are more than 10 parts. + """ + + def __len__(self) -> int: + """Number of parts. At least one.""" + + def __getitem__(self, index: int) -> str | Unquote | Asterisk: + """ + Return the part at `index`. + + A quoted part comes back as `str`. Negative indexes count from the end. + + Raises: + IndexError: `index` is outside the parts. + """ + def __hash__(self) -> int: ... def __eq__(self, value: object, /) -> bool: ... def __ne__(self, value: object, /) -> bool: ... - def to_list(self) -> list[str | Unquote | Asterisk]: ... + def to_list(self) -> list[str | Unquote | Asterisk]: + """ + Copy the parts into a list. + + Quoted parts are `str`. Unquoted parts are `Unquote`. A wildcard is + `Asterisk`. + """ _T = typing.TypeVar("_T") @final class Value(Expr, typing.Generic[_T]): + """ + A Python object kept until SQL is rendered. + + Use `Value` for literals and for driver parameters. `to_sql` runs the + serializer (`None` becomes `NULL`). `prepare` binds the object and does + not call the serializer. Omit the serializer to infer one from the + object's type. Passing an existing `Value` reuses its object. + + Examples: + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.expr import Value + >>> Value("hi").to_sql(PostgreSql) + "E'hi'" + """ + @overload - def __new__(cls, object: _T | None, /) -> Self: ... + def __new__(cls, object: _T | None, /) -> Self: + """Wrap `object` and infer a serializer from its type.""" + @overload - def __new__(cls, object: _T | None, serializer: Serializer[_T] | None, /) -> Self: ... - def get_object(self) -> _T | None: ... - def get_serializer(self) -> Serializer[_T]: ... + def __new__(cls, object: _T | None, serializer: Serializer[_T] | None, /) -> Self: + """ + Wrap `object` with an explicit serializer. + + Args: + object: Value to bind or serialize. An existing `Value` contributes + its object. + serializer: How `to_sql` spells `object`. `None` infers one, or + keeps the serializer when `object` is already a `Value`. + + Raises: + TypeError: `serializer` is not a `Serializer`. + """ + + def get_object(self) -> _T | None: + """The wrapped Python object. `None` renders as `NULL`.""" + + def get_serializer(self) -> Serializer[_T]: + """Serializer used by `to_sql`. `prepare` does not call it.""" + def __hash__(self) -> int: ... def __eq__(self, value: object, /) -> bool: ... def __ne__(self, value: object, /) -> bool: ... @final class Function(Expr): + """ + SQL function call, including a bare name with no arguments. + + Use this for `count`, `sum`, and dialect functions that are not their own + node. The name is an identifier: a string is quoted, so a niladic keyword + such as `CURRENT_TIMESTAMP` must be an `Unquote`. `filter` adds + `FILTER (WHERE …)` where the dialect allows it. Null treatment is a + separate clause, placed where that dialect requires it. + + Examples: + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.expr import Function, Ident + >>> Function("count", Ident("id")).to_sql(PostgreSql) + '"count"("id")' + """ + def __new__( cls, name: str | Ident | Unquote | Asterisk | type[Asterisk], /, *args: object, distinct: bool = False, - ) -> Self: ... - def filter(self, predicate: object, /) -> Self: ... - def ignore_nulls(self) -> Self: ... - def respect_nulls(self) -> Self: ... - def get_name(self) -> Ident: ... - def get_distinct(self) -> bool: ... - def get_args(self) -> tuple[Expr, ...]: ... - def get_filter(self) -> Expr | None: ... - def get_null_treatment(self) -> typing.Literal["IGNORE NULLS", "RESPECT NULLS"] | None: ... + ) -> Self: + """ + Build a call. + + Args: + name: Function name. A `str` is a quoted identifier, and a dotted + string is split into parts. + args: Arguments. A `str` becomes a `Value`, not an identifier. + distinct: When true, write `DISTINCT` before the arguments. + + Raises: + TypeError: `name` is not an identifier. + ValueError: `name` is not a legal identifier. + """ + + def filter(self, predicate: object, /) -> Self: + """ + Return a copy with `FILTER (WHERE predicate)`. + + The predicate is any expression. A string becomes a literal. Rendering + raises when the dialect has no `FILTER` syntax. + + Args: + predicate: Boolean expression placed in the filter. + """ + + def ignore_nulls(self) -> Self: + """Return a copy that writes `IGNORE NULLS`.""" + + def respect_nulls(self) -> Self: + """Return a copy that writes `RESPECT NULLS`.""" + + def get_name(self) -> Ident: + """The function name.""" + + def get_distinct(self) -> bool: + """Whether the call writes `DISTINCT`.""" + + def get_args(self) -> tuple[Expr, ...]: + """The argument expressions, in order.""" + + def get_filter(self) -> Expr | None: + """The `FILTER` predicate, or `None`.""" + + def get_null_treatment(self) -> typing.Literal["IGNORE NULLS", "RESPECT NULLS"] | None: + """`IGNORE NULLS`, `RESPECT NULLS`, or `None`.""" @final class TupleExpr(Expr): - def __new__(cls, *items: object) -> Self: ... - def get_items(self) -> tuple[Expr, ...]: ... + """ + Parenthesized row value. + + Use this for `ROW(a, b)` and the other tuple spellings a dialect allows. + A string argument is a literal, not a column name. Rendering raises when + the dialect rejects tuples or rejects this arity. + + Examples: + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.expr import TupleExpr + >>> TupleExpr(1, 2).to_sql(PostgreSql) + 'ROW(1, 2)' + """ + + def __new__(cls, *items: object) -> Self: + """ + Build a row from expressions. + + Args: + items: One expression per element. Strings become literals. + """ + + def get_items(self) -> tuple[Expr, ...]: + """The element expressions, in order.""" @final class UnaryExpr(Expr): - def __new__(cls, operator: typing.Literal["NOT", "+", "-", "~"] | str, expr: object, /) -> Self: ... - def get_operator(self) -> typing.Literal["NOT", "+", "-", "~"]: ... - def get_expr(self) -> Expr: ... + """Prefix operator: `NOT`, `+`, `-`, or `~`. + + Use this for negation and bitwise complement. `NOT` is written with a + space. The others are written against the operand. `~` becomes a function + on dialects that have no prefix complement. + + Examples: + >>> from rapidquery.dialect import Sqlite + >>> from rapidquery.expr import Ident, UnaryExpr + >>> UnaryExpr("NOT", Ident("active")).to_sql(Sqlite) + 'NOT "active"' + """ + + def __new__(cls, operator: typing.Literal["NOT", "+", "-", "~"] | str, expr: object, /) -> Self: + """ + Build a prefix operation. + + Args: + operator: `NOT`, `+`, `-`, or `~`. Case is ignored for `NOT`. + expr: The operand. A string becomes a literal. + + Raises: + ValueError: `operator` is not one of those four spellings. + """ + + def get_operator(self) -> typing.Literal["NOT", "+", "-", "~"]: + """The operator spelling.""" + + def get_expr(self) -> Expr: + """The operand.""" @final class BinaryExpr(Expr): + """ + Infix operator between two expressions. + + Use this for arithmetic, comparison, `AND` / `OR`, concatenation, and + bitwise operators. `!=` is stored and written as `<>`. A string on either + side is a literal. Dialects may rewrite `%`, `^`, `||`, and shifts into + function calls. + + Examples: + >>> from rapidquery.dialect import Sqlite + >>> from rapidquery.expr import BinaryExpr, Ident + >>> BinaryExpr(Ident("age"), ">", 18).to_sql(Sqlite) + '"age" > 18' + """ + def __new__( cls, left: object, @@ -106,13 +400,40 @@ class BinaryExpr(Expr): | str, right: object, /, - ) -> Self: ... - def get_left(self) -> Expr: ... - def get_operator(self) -> str: ... - def get_right(self) -> Expr: ... + ) -> Self: + """ + Build an infix operation. + + Args: + left: Left operand. + operator: One of the spellings listed on this method. `!=` is stored + as `<>`. Any other spelling is kept as a custom operator. + right: Right operand. + """ + + def get_left(self) -> Expr: + """The left operand.""" + + def get_operator(self) -> str: + """The operator spelling. `!=` is returned as `<>`.""" + + def get_right(self) -> Expr: + """The right operand.""" @final class IsExpr(Expr): + """`expr IS [NOT] NULL`, `TRUE`, `FALSE`, or `UNKNOWN`. + + Use this for null tests and boolean state tests. Pass the kind as a + string. Set `negated` for `IS NOT`. + + Examples: + >>> from rapidquery.dialect import Sqlite + >>> from rapidquery.expr import Ident, IsExpr + >>> IsExpr(Ident("name"), "NULL").to_sql(Sqlite) + '"name" IS NULL' + """ + def __new__( cls, expr: object, @@ -120,35 +441,148 @@ class IsExpr(Expr): /, *, negated: bool = False, - ) -> Self: ... - def get_expr(self) -> Expr: ... - def get_kind(self) -> typing.Literal["NULL", "TRUE", "FALSE", "UNKNOWN"]: ... - def get_negated(self) -> bool: ... + ) -> Self: + """ + Build an `IS` test. + + Args: + expr: Tested expression. + kind: `NULL`, `TRUE`, `FALSE`, or `UNKNOWN`. + negated: Write `IS NOT` instead of `IS`. + + Raises: + ValueError: `kind` is not one of those four words. + """ + + def get_expr(self) -> Expr: + """The tested expression.""" + + def get_kind(self) -> typing.Literal["NULL", "TRUE", "FALSE", "UNKNOWN"]: + """`NULL`, `TRUE`, `FALSE`, or `UNKNOWN`.""" + + def get_negated(self) -> bool: + """Whether the test is `IS NOT`.""" @final class IsDistinctFromExpr(Expr): - def __new__(cls, left: object, right: object, /, *, negated: bool = False) -> Self: ... - def get_left(self) -> Expr: ... - def get_right(self) -> Expr: ... - def get_negated(self) -> bool: ... + """`left IS [NOT] DISTINCT FROM right`. + + Use this when `NULL` on either side must not make the comparison unknown. + Rendering follows the dialect: standard syntax, a null-safe operator, or + an error when the dialect has neither. + + Examples: + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.expr import Ident, IsDistinctFromExpr + >>> IsDistinctFromExpr(Ident("a"), Ident("b")).to_sql(PostgreSql) + '"a" IS DISTINCT FROM "b"' + """ + + def __new__(cls, left: object, right: object, /, *, negated: bool = False) -> Self: + """ + Build an `IS DISTINCT FROM` comparison. + + Args: + left: Left expression. + right: Right expression. + negated: Write `IS NOT DISTINCT FROM`. + """ + + def get_left(self) -> Expr: + """The left expression.""" + + def get_right(self) -> Expr: + """The right expression.""" + + def get_negated(self) -> bool: + """Whether the predicate is negated.""" @final class InListExpr(Expr): - def __new__(cls, expr: object, list: Iterable[object], /, *, negated: bool = False) -> Self: ... - def get_expr(self) -> Expr: ... - def get_list(self) -> tuple[Expr, ...]: ... - def get_negated(self) -> bool: ... + """`expr [NOT] IN (item, …)`. + + Use this for a fixed list of expressions. A string item is a literal. + An empty list renders as a boolean literal and does not write `expr`. + + Examples: + >>> from rapidquery.dialect import Sqlite + >>> from rapidquery.expr import Ident, InListExpr + >>> InListExpr(Ident("id"), [1, 2]).to_sql(Sqlite) + '"id" IN (1, 2)' + """ + + def __new__(cls, expr: object, list: Iterable[object], /, *, negated: bool = False) -> Self: + """ + Build an `IN` list. + + Args: + expr: Tested expression. + list: Membership expressions. + negated: Write `NOT IN`. + """ + + def get_expr(self) -> Expr: + """The tested expression.""" + + def get_list(self) -> tuple[Expr, ...]: + """The membership expressions.""" + + def get_negated(self) -> bool: + """Whether the predicate is `NOT IN`.""" @final class BetweenExpr(Expr): - def __new__(cls, expr: object, low: object, high: object, /, *, negated: bool = False) -> Self: ... - def get_expr(self) -> Expr: ... - def get_low(self) -> Expr: ... - def get_high(self) -> Expr: ... - def get_negated(self) -> bool: ... + """`expr [NOT] BETWEEN low AND high`. + + Use this for an inclusive range. Both bounds are expressions. There is + no `SYMMETRIC` clause. Some dialects parenthesize the whole predicate. + + Examples: + >>> from rapidquery.dialect import Sqlite + >>> from rapidquery.expr import BetweenExpr, Ident + >>> BetweenExpr(Ident("n"), 1, 3).to_sql(Sqlite) + '"n" BETWEEN 1 AND 3' + """ + + def __new__(cls, expr: object, low: object, high: object, /, *, negated: bool = False) -> Self: + """ + Build a `BETWEEN` predicate. + + Args: + expr: Tested expression. + low: Inclusive lower bound. + high: Inclusive upper bound. + negated: Write `NOT BETWEEN`. + """ + + def get_expr(self) -> Expr: + """The tested expression.""" + + def get_low(self) -> Expr: + """The lower bound.""" + + def get_high(self) -> Expr: + """The upper bound.""" + + def get_negated(self) -> bool: + """Whether the predicate is `NOT BETWEEN`.""" @final class LikeExpr(Expr): + """`LIKE`, `ILIKE`, or `LIKE ANY`, with an optional `ESCAPE` character. + + Use this for pattern predicates. A string pattern is one pattern. A list + or tuple of patterns is `LIKE ANY`, spelled the way the dialect allows. + `case_sensitive=False` selects `ILIKE` or the dialect's fallback. `escape` + must be one character and is inlined, never bound. + + Examples: + >>> from rapidquery.dialect import Sqlite + >>> from rapidquery.expr import Ident, LikeExpr + >>> LikeExpr(Ident("name"), "A%").to_sql(Sqlite) + '"name" LIKE \\'A%\\'' + """ + def __new__( cls, expr: object, @@ -158,29 +592,121 @@ class LikeExpr(Expr): negated: bool = False, case_sensitive: bool = True, escape: str | None = None, - ) -> Self: ... - def get_expr(self) -> Expr: ... - def get_pattern(self) -> Expr | tuple[Expr, ...]: ... - def get_negated(self) -> bool: ... - def get_case_sensitive(self) -> bool: ... - def get_escape(self) -> str | None: ... + ) -> Self: + """ + Build a pattern predicate. + + Args: + expr: Tested expression. + pattern: One pattern, or a list or tuple of patterns for `ANY`. + negated: Negate the match. + case_sensitive: When false, render the dialect's case-insensitive form. + escape: One escape character, or `None` to omit `ESCAPE`. + + Raises: + TypeError: `escape` is not one character, or `pattern` is not an + expression, list, or tuple. + """ + + def get_expr(self) -> Expr: + """The tested expression.""" + + def get_pattern(self) -> Expr | tuple[Expr, ...]: + """One pattern, or a tuple of patterns when this is `LIKE ANY`.""" + + def get_negated(self) -> bool: + """Whether the match is negated.""" + + def get_case_sensitive(self) -> bool: + """Whether the match keeps case.""" + + def get_escape(self) -> str | None: + """The escape character, or `None`.""" @final class SimilarToExpr(Expr): - def __new__(cls, expr: object, pattern: object, /, *, negated: bool = False) -> Self: ... - def get_expr(self) -> Expr: ... - def get_pattern(self) -> Expr: ... - def get_negated(self) -> bool: ... + """`expr [NOT] SIMILAR TO pattern`. + + Use this only for dialects that implement SQL `SIMILAR TO` (PostgreSQL, + Redshift, DuckDB). Other dialects raise at render time. There is no + `ESCAPE` clause. + + Examples: + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.expr import Ident, SimilarToExpr + >>> SimilarToExpr(Ident("name"), "a%").to_sql(PostgreSql) + '"name" SIMILAR TO E\\'a%\\'' + """ + + def __new__(cls, expr: object, pattern: object, /, *, negated: bool = False) -> Self: + """ + Build a `SIMILAR TO` predicate. + + Args: + expr: Tested expression. + pattern: SQL regular pattern. + negated: Write `NOT SIMILAR TO`. + """ + + def get_expr(self) -> Expr: + """The tested expression.""" + + def get_pattern(self) -> Expr: + """The pattern.""" + + def get_negated(self) -> bool: + """Whether the predicate is negated.""" @final class RLikeExpr(Expr): - def __new__(cls, expr: object, pattern: object, /, *, negated: bool = False) -> Self: ... - def get_expr(self) -> Expr: ... - def get_pattern(self) -> Expr: ... - def get_negated(self) -> bool: ... + """Regex match, spelled the way the dialect spells it. + + Use this instead of writing `RLIKE`, `~`, or `REGEXP_LIKE` yourself. + PostgreSQL and Redshift use `~`. Other dialects use `RLIKE` or a function, + or raise when they have no regex predicate. There is no `ESCAPE` clause. + + Examples: + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.expr import Ident, RLikeExpr + >>> RLikeExpr(Ident("name"), "a.*").to_sql(PostgreSql) + '"name" ~ E\\'a.*\\'' + """ + + def __new__(cls, expr: object, pattern: object, /, *, negated: bool = False) -> Self: + """ + Build a regex predicate. + + Args: + expr: Tested expression. + pattern: Regular expression. + negated: Negate the match. + """ + + def get_expr(self) -> Expr: + """The tested expression.""" + + def get_pattern(self) -> Expr: + """The regular expression.""" + + def get_negated(self) -> bool: + """Whether the match is negated.""" @final class CastExpr(Expr): + """`CAST`, `TRY_CAST`, or PostgreSQL-style `::`. + + Use this to name the result type. The type string is a SQL type name, not + a quoted identifier. `TRY_CAST` is rewritten to the dialect's spelling, + such as `SAFE_CAST`, or rejected when the dialect has no try-cast. + `kind="::"` renders only when the dialect allows that operator. + + Examples: + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.expr import CastExpr, Ident + >>> CastExpr(Ident("id"), "int").to_sql(PostgreSql) + 'CAST("id" AS INT)' + """ + def __new__( cls, expr: object, @@ -188,13 +714,43 @@ class CastExpr(Expr): /, *, kind: typing.Literal["CAST", "TRY_CAST", "::"] | str = "CAST", - ) -> Self: ... - def get_expr(self) -> Expr: ... - def get_data_type_name(self) -> str: ... - def get_kind(self) -> typing.Literal["CAST", "TRY_CAST", "::"]: ... + ) -> Self: + """ + Build a cast. + + Args: + expr: Input expression. + data_type_name: SQL type name, such as `"int"` or `"varchar(16)"`. + kind: `CAST`, `TRY_CAST`, or `::`. + + Raises: + ValueError: `kind` is not one of those three spellings. + """ + + def get_expr(self) -> Expr: + """The input expression.""" + + def get_data_type_name(self) -> str: + """The SQL type name, in the spelling that will be written.""" + + def get_kind(self) -> typing.Literal["CAST", "TRY_CAST", "::"]: + """`CAST`, `TRY_CAST`, or `::`.""" @final class TrimExpr(Expr): + """`TRIM` of an expression, from one side, of an optional character set. + + Use this for `TRIM`, `LTRIM`, and `RTRIM`. `where` selects both ends, + the leading end, or the trailing end. `characters` is the set to strip. + Omit it to strip spaces. The written function depends on the dialect. + + Examples: + >>> from rapidquery.dialect import Sqlite + >>> from rapidquery.expr import Ident, TrimExpr + >>> TrimExpr(Ident("s")).to_sql(Sqlite) + 'TRIM("s")' + """ + def __new__( cls, expr: object, @@ -202,28 +758,139 @@ class TrimExpr(Expr): *, where: typing.Literal["BOTH", "LEADING", "TRAILING"] | str = "BOTH", characters: object | None = None, - ) -> Self: ... - def get_expr(self) -> Expr: ... - def get_where(self) -> typing.Literal["BOTH", "LEADING", "TRAILING"]: ... - def get_characters(self) -> Expr | None: ... + ) -> Self: + """ + Build a trim call. + + Args: + expr: Input expression. + where: `BOTH`, `LEADING`, or `TRAILING`. + characters: Characters to strip, or `None` for spaces. + + Raises: + ValueError: `where` is not one of those three words. + """ + + def get_expr(self) -> Expr: + """The input expression.""" + + def get_where(self) -> typing.Literal["BOTH", "LEADING", "TRAILING"]: + """Which side is trimmed.""" + + def get_characters(self) -> Expr | None: + """The character set, or `None` when trimming spaces.""" @final class CollateExpr(Expr): - def __new__(cls, expr: object, collation: str | Unquote | Ident | Asterisk | type[Asterisk], /) -> Self: ... - def get_expr(self) -> Expr: ... - def get_collation(self) -> Ident: ... + """ + `expr COLLATE collation`. + + Use this to attach a collation name. The collation is an identifier, not + a string literal, except on dialects that spell collation as a function + and then quote the name as text. Rendering raises when the dialect has + no collation syntax. + + Examples: + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.expr import CollateExpr, Ident + >>> CollateExpr(Ident("s"), "en_US").to_sql(PostgreSql) + '"s" COLLATE "en_US"' + """ + + def __new__(cls, expr: object, collation: str | Unquote | Ident | Asterisk | type[Asterisk], /) -> Self: + """ + Attach a collation. + + Args: + expr: Input expression. + collation: Collation name, as an identifier. + """ + + def get_expr(self) -> Expr: + """The input expression.""" + + def get_collation(self) -> Ident: + """The collation name.""" @final class CaseExpr(Expr): - def __new__(cls, operand: object | None = None, /) -> Self: ... - def when(self, when: object, then: object) -> Self: ... - def else_(self, result: object) -> Self: ... - def get_operand(self) -> Expr | None: ... - def get_when_list(self) -> tuple[tuple[Expr, Expr], ...]: ... - def get_else(self) -> Expr | None: ... + """`CASE` expression, searched or with an operand. + + Use this for conditional results. Omit the operand for `CASE WHEN`. + Pass an operand for `CASE operand WHEN`. `when` and `else_` return a new + node. A `CASE` with no branches is still constructible; render it only + after adding a branch. + + Examples: + >>> from rapidquery.dialect import Sqlite + >>> from rapidquery.expr import CaseExpr, Ident + >>> CaseExpr().when(Ident("a"), 1).else_(0).to_sql(Sqlite) + 'CASE WHEN "a" THEN 1 ELSE 0 END' + """ + + def __new__(cls, operand: object | None = None, /) -> Self: + """ + Start a `CASE`. + + Args: + operand: The `CASE operand` expression, or `None` for a searched + `CASE`. + """ + + def when(self, when: object, then: object) -> Self: + """Return a copy with one more `WHEN` / `THEN` pair. + + Args: + when: Comparison value, or a condition when there is no operand. + then: Result when this branch matches. + """ + + def else_(self, result: object) -> Self: + """ + Return a copy whose `ELSE` result is `result`. + + Args: + result: Value written after `ELSE`. + """ + + def get_operand(self) -> Expr | None: + """The `CASE` operand, or `None` for a searched `CASE`.""" + + def get_when_list(self) -> tuple[tuple[Expr, Expr], ...]: + """`(WHEN, THEN)` pairs, in order.""" + + def get_else(self) -> Expr | None: + """The `ELSE` expression, or `None`.""" @final class CustomExpr(Expr): - def __new__(cls, sql: str, *args: object) -> Self: ... - def get_sql(self) -> str: ... - def get_args(self) -> tuple[Expr, ...]: ... + """SQL template with `{}` slots filled by expressions. + + Use this when no node spells the fragment yet. Each `{}` consumes one + argument, rendered with the same dialect. `{{` and `}}` write a literal + brace. The template is not escaped. Keep it to keywords and punctuation + you trust, and put data in the arguments. + + Examples: + >>> from rapidquery.dialect import Sqlite + >>> from rapidquery.expr import CustomExpr, Ident + >>> CustomExpr("COALESCE({}, 0)", Ident("n")).to_sql(Sqlite) + 'COALESCE("n", 0)' + """ + + def __new__(cls, sql: str, *args: object) -> Self: + """ + Build a template. + + Args: + sql: SQL text. `{}` marks an argument. `{{` and `}}` are braces. + args: One expression per `{}` slot. + + The slot count is checked when the expression is rendered, not here. + """ + + def get_sql(self) -> str: + """The template, including `{}` slots.""" + + def get_args(self) -> tuple[Expr, ...]: + """The expressions that fill `{}` slots, in order.""" diff --git a/rapidquery/serializers.py b/rapidquery/serializers.py index f18af29..73825ac 100644 --- a/rapidquery/serializers.py +++ b/rapidquery/serializers.py @@ -1,5 +1,18 @@ -""" -Value serializers. +"""Serializers that turn a Python object into a SQL literal. + +Pass one to `Value` when inference is wrong, or call `serialize` to render a +single literal. Each builtin covers one Python type. `ArraySerializer` and +`CustomSerializer` cover lists and caller-defined spellings. The dialect +still chooses quotes, keywords, and whether the form exists. + +Examples: + Force a string serializer instead of inferring one from the object: + + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.expr import Value + >>> from rapidquery.serializers import StringSerializer + >>> Value("hi", StringSerializer()).to_sql(PostgreSql) + "E'hi'" """ from ._core.serializers import ( diff --git a/rapidquery/serializers.pyi b/rapidquery/serializers.pyi index a2f7ff5..e2fbc6a 100644 --- a/rapidquery/serializers.pyi +++ b/rapidquery/serializers.pyi @@ -10,74 +10,357 @@ from rapidquery.dialect import Dialect _T = typing.TypeVar("_T") class Serializer(typing.Generic[_T]): - def serialize(self, dialect: Dialect, object: _T) -> str: ... + """ + Turns one Python object into a SQL literal for a dialect. + + Use a subclass when `Value` should not infer the spelling, or call + `serialize` to render a literal without building an expression. Subclasses + are frozen singletons except `ArraySerializer` and `CustomSerializer`. + + Examples: + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.serializers import StringSerializer + >>> StringSerializer().serialize(PostgreSql, "hi") + "E'hi'" + """ + + def serialize(self, dialect: Dialect, object: _T) -> str: + """ + Render `object` as a SQL literal. + + Args: + dialect: Dialect that chooses quotes, keywords, and hex form. + object: Value this serializer accepts. + + Returns: + The SQL literal, with no trailing separator. + + Raises: + TypeError: `object` is not a type this serializer accepts. + ValueError: `object` is the right type but not a legal value. + NotImplementedError: The dialect has no literal of this kind. + """ + def __eq__(self, other: object) -> bool: ... def __hash__(self) -> int: ... @typing.final class IntegerSerializer(Serializer[int]): - def __new__(cls) -> typing.Self: ... + """ + Bare SQL integer for a Python `int`. + + Use this for integers of any width, including values outside 64-bit. + `bool` is rejected so `True` is not written as `1` by this serializer. + + Examples: + >>> from rapidquery.dialect import Sqlite + >>> from rapidquery.serializers import IntegerSerializer + >>> IntegerSerializer().serialize(Sqlite, 7) + '7' + """ + + def __new__(cls) -> typing.Self: + """Return the integer serializer.""" @typing.final class FloatSerializer(Serializer[int | float | decimal.Decimal]): - def __new__(cls) -> typing.Self: ... + """ + Bare SQL number for `float`, `int`, or `decimal.Decimal`. + + Use this when a whole number must stay a decimal (`1.0`, not `1`). + Non-finite floats are rejected. Strings are not accepted. + + Examples: + >>> from rapidquery.dialect import Sqlite + >>> from rapidquery.serializers import FloatSerializer + >>> FloatSerializer().serialize(Sqlite, 1.0) + '1.0' + """ + + def __new__(cls) -> typing.Self: + """Return the float serializer.""" @typing.final class BooleanSerializer(Serializer[bool]): - def __new__(cls) -> typing.Self: ... + """ + Dialect boolean literal for a Python `bool`. + + Use this for `TRUE` / `FALSE` or `1` / `0`, whichever the dialect writes. + Only `bool` is accepted. + + Examples: + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.serializers import BooleanSerializer + >>> BooleanSerializer().serialize(PostgreSql, True) + 'TRUE' + """ + + def __new__(cls) -> typing.Self: + """Return the boolean serializer.""" @typing.final class StringSerializer(Serializer[str | enum.Enum]): - def __new__(cls) -> typing.Self: ... + """ + Quoted, escaped SQL string. + + Use this for text. An `enum.Enum` member is accepted when its value is a + `str`. The dialect chooses the quotes and the escape rules. + + Examples: + >>> from rapidquery.dialect import Sqlite + >>> from rapidquery.serializers import StringSerializer + >>> StringSerializer().serialize(Sqlite, "hi") + "'hi'" + """ + + def __new__(cls) -> typing.Self: + """Return the string serializer.""" @typing.final class UuidSerializer(Serializer[uuid.UUID | str]): - def __new__(cls) -> typing.Self: ... + """ + UUID literal, spelled with the dialect's `uuid_format`. + + Use this for `uuid.UUID` or a UUID string. The result is a quoted literal, + `UUID '…'`, or `'…'::uuid`. + + Examples: + >>> import uuid + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.serializers import UuidSerializer + >>> UuidSerializer().serialize(PostgreSql, uuid.UUID(int=0)) + "E'00000000-0000-0000-0000-000000000000'::uuid" + """ + + def __new__(cls) -> typing.Self: + """Return the UUID serializer.""" @typing.final class BytesSerializer(Serializer[bytes]): - def __new__(cls) -> typing.Self: ... + """ + Dialect blob or bytea literal for exact `bytes`. + + Use this for binary payloads. `bytearray` and `memoryview` are rejected. + The dialect chooses `X'…'`, `\\x…::bytea`, or a conversion function. + + Examples: + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.serializers import BytesSerializer + >>> BytesSerializer().serialize(PostgreSql, b"A") + "'\\\\x41'::bytea" + """ + + def __new__(cls) -> typing.Self: + """Return the bytes serializer.""" @typing.final class BitSerializer(Serializer[str]): - def __new__(cls) -> typing.Self: ... + """ + Bit-string literal for a string of `0` and `1` characters. + + Use this on PostgreSQL, DuckDB, MySQL, and Teradata. Other dialects raise. + The string must be non-empty. + + Examples: + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.serializers import BitSerializer + >>> BitSerializer().serialize(PostgreSql, "101") + "B'101'" + """ + + def __new__(cls) -> typing.Self: + """Return the bit-string serializer.""" @typing.final class JSONSerializer(Serializer[typing.Any]): - def __new__(cls) -> typing.Self: ... + """ + JSON literal, spelled with the dialect's `json_format`. + + Use this for objects the JSON encoder can dump. `orjson` is used when it + is installed; otherwise the stdlib `json` module is used. The JSON text + is then wrapped as a quoted literal, `JSON '…'`, or `PARSE_JSON('…')`. + + Examples: + >>> from rapidquery.dialect import Sqlite + >>> from rapidquery.serializers import JSONSerializer + >>> JSONSerializer().serialize(Sqlite, {"a": 1}) + '\\'{"a":1}\\'' + """ + + def __new__(cls) -> typing.Self: + """Return the JSON serializer.""" @typing.final class DateSerializer(Serializer[datetime.date]): - def __new__(cls) -> typing.Self: ... + """ + Date literal `YYYY-MM-DD`, quoted or after the `DATE` keyword. + + Use this for `datetime.date`. A `datetime.datetime` is rejected so a + timestamp is not silently truncated. + + Examples: + >>> import datetime + >>> from rapidquery.dialect import Sqlite + >>> from rapidquery.serializers import DateSerializer + >>> DateSerializer().serialize(Sqlite, datetime.date(2020, 1, 2)) + "'2020-01-02'" + """ + + def __new__(cls) -> typing.Self: + """Return the date serializer.""" @typing.final class TimeSerializer(Serializer[datetime.time]): - def __new__(cls) -> typing.Self: ... + """ + Time literal `HH:MM:SS[.ffffff]`, quoted or after the `TIME` keyword. + + Use this for `datetime.time`. + + Examples: + >>> import datetime + >>> from rapidquery.dialect import Sqlite + >>> from rapidquery.serializers import TimeSerializer + >>> TimeSerializer().serialize(Sqlite, datetime.time(1, 2, 3)) + "'01:02:03'" + """ + + def __new__(cls) -> typing.Self: + """Return the time serializer.""" @typing.final class DatetimeSerializer(Serializer[datetime.datetime | int | float]): - def __new__(cls) -> typing.Self: ... + """ + Timestamp literal for a `datetime` or a UTC epoch. + + Use this for naive or aware datetimes, or for `int` / `float` seconds + since the Unix epoch. Naive values keep their wall time. Aware values + keep their offset. Epoch numbers are treated as UTC. The dialect chooses + a quoted literal or a `TIMESTAMP` / `TIMESTAMPTZ` keyword. + + Examples: + >>> import datetime + >>> from rapidquery.dialect import Sqlite + >>> from rapidquery.serializers import DatetimeSerializer + >>> value = datetime.datetime(2020, 1, 2, 3, 4, 5) + >>> DatetimeSerializer().serialize(Sqlite, value) + "'2020-01-02 03:04:05'" + """ + + def __new__(cls) -> typing.Self: + """Return the timestamp serializer.""" @typing.final class InetSerializer(Serializer[typing.Any]): - def __new__(cls) -> typing.Self: ... + """ + IP address or network literal for PostgreSQL and DuckDB. + + Use this for `ipaddress` objects or for a string that is an address or + CIDR. Other dialects raise. The result is a quoted literal or `'…'::inet`. + + Examples: + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.serializers import InetSerializer + >>> InetSerializer().serialize(PostgreSql, "127.0.0.1") + "E'127.0.0.1/32'::inet" + """ + + def __new__(cls) -> typing.Self: + """Return the inet serializer.""" @typing.final class MacAddressSerializer(Serializer[str]): - def __new__(cls) -> typing.Self: ... + """ + MAC address literal. + + Use this for a `str` that uses `:` or `-` as the separator. PostgreSQL + writes a native MAC literal. Other dialects raise. + + Examples: + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.serializers import MacAddressSerializer + >>> MacAddressSerializer().serialize(PostgreSql, "00:11:22:33:44:55") + "E'00:11:22:33:44:55'::macaddr" + """ + + def __new__(cls) -> typing.Self: + """Return the MAC address serializer.""" @typing.final class UnspecifiedSerializer(Serializer[typing.Any]): - def __new__(cls) -> typing.Self: ... + """ + Inserts `str(object)` with no quotes and no type check. + + Use this only when no builtin serializer matches and you already hold SQL + text. The result is not escaped. Prefer an explicit serializer for values + that come from outside the program. + + Examples: + >>> from rapidquery.dialect import Sqlite + >>> from rapidquery.serializers import UnspecifiedSerializer + >>> UnspecifiedSerializer().serialize(Sqlite, 1) + '1' + """ + + def __new__(cls) -> typing.Self: + """Return the unspecified serializer.""" @typing.final class ArraySerializer(Serializer[list[_T] | tuple[_T]]): - def __new__(cls, element: Serializer[_T]) -> typing.Self: ... + """ + `ARRAY[elem, …]` built by serializing each item. + + Use this for a list or tuple on PostgreSQL, DuckDB, and BigQuery. Pass + another `ArraySerializer` as `element` for a nested array. Other dialects + raise. + + Examples: + >>> from rapidquery.dialect import PostgreSql + >>> from rapidquery.serializers import ArraySerializer, IntegerSerializer + >>> ArraySerializer(IntegerSerializer()).serialize(PostgreSql, [1, 2]) + 'ARRAY[1, 2]' + """ + + def __new__(cls, element: Serializer[_T]) -> typing.Self: + """ + Build an array serializer. + + Args: + element: Serializer applied to every item. + """ + @property - def element(self) -> Serializer[_T]: ... + def element(self) -> Serializer[_T]: + """Serializer applied to every item.""" @typing.final class CustomSerializer(Serializer[_T]): - def __new__(cls, function: Callable[[Dialect, _T], str]) -> typing.Self: ... + """ + Inserts the text returned by a Python callable. + + Use this for a literal spelling the builtins do not cover. The callable + receives the dialect name and the object, and must return SQL text. That + text is inserted as-is, with no extra quoting. + + Examples: + >>> from rapidquery.dialect import Sqlite + >>> from rapidquery.serializers import CustomSerializer + >>> serializer = CustomSerializer(lambda _dialect, value: str(value)) + >>> serializer.serialize(Sqlite, 3) + '3' + """ + + def __new__(cls, function: Callable[[Dialect, _T], str]) -> typing.Self: + """ + Build a serializer around `function`. + + Args: + function: `fn(dialect, object) -> str`. `dialect` is the `Dialect` + value, not its name. + + Raises: + TypeError: `function` is not callable. + """ + @property - def function(self) -> Callable[[Dialect, _T], str]: ... + def function(self) -> Callable[[Dialect, _T], str]: + """The callable passed to the constructor.""" diff --git a/src/data_type/mod.rs b/src/data_type/mod.rs index 893578b..f882ddd 100644 --- a/src/data_type/mod.rs +++ b/src/data_type/mod.rs @@ -3,12 +3,17 @@ use crate::sql; #[cfg(test)] mod tests; -/// SQL type spelling. +/// SQL type name, interned when it is one of the known spellings. /// -/// Known names are interned (`arcstr::literal!`) under that same spelling -/// (case-folded to the traditional uppercase form). Unknown and parameterized -/// names keep the caller's spelling. Synonyms are not rewritten to a different -/// name. +/// Known names fold to their traditional uppercase form. Unknown names and +/// parameterized names such as `varchar(16)` keep the caller's spelling. +/// +/// ``` +/// use rapidquery::data_type::DataTypeName; +/// +/// assert_eq!(DataTypeName::from("varchar").as_str(), "VARCHAR"); +/// assert_eq!(DataTypeName::from("varchar(16)").as_str(), "varchar(16)"); +/// ``` #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[repr(transparent)] pub struct DataTypeName(arcstr::ArcStr); diff --git a/src/dialect/base.rs b/src/dialect/base.rs index 3ce9f21..67e489b 100644 --- a/src/dialect/base.rs +++ b/src/dialect/base.rs @@ -2,9 +2,9 @@ use crate::internal::utils; use super::flags; -/// SQL dialect. +/// SQL dialect: quoting, literals, and which syntax is legal. /// -/// Instances of the same dialect compare equal and hash together. +/// Use a builtin such as `PostgreSql`, or build one from `OPT_*` flags. #[rapidquery_macros::pyclass("rapidquery.dialect.Dialect", skip_from_py_object)] #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct PyDialect(pub flags::DialectFlags); @@ -249,30 +249,28 @@ rapidquery_macros::dialect_options! { #[pyo3::pymethods] impl PyDialect { + /// Write a boolean literal (`TRUE`/`FALSE` or `1`/`0`). fn write_boolean(&self, value: bool) -> pyo3::PyResult { let mut buf = String::new(); self.0.write_boolean(&mut buf, value)?; Ok(buf) } - fn escape_string(&self, value: &str) -> pyo3::PyResult { - let mut buf = String::new(); - self.0.escape_string(&mut buf, value)?; - Ok(buf) - } - + /// Escape `ident` and wrap it in this dialect’s identifier delimiters. fn write_identifier(&self, value: &str) -> pyo3::PyResult { let mut buf = String::new(); let _ = self.0.write_identifier(&mut buf, value); Ok(buf) } + /// Escape `value` and wrap it as a complete string literal. fn write_string_literal(&self, value: &str) -> pyo3::PyResult { let mut buf = String::new(); self.0.write_string_literal(&mut buf, value)?; Ok(buf) } + /// Write a bit-string ('0'/'1' characters) as a native literal. fn write_bits(&self, value: &str) -> pyo3::PyResult { if value.chars().any(|c| c != '0' && c != '1') { return rapidquery_macros::pyerr!(Err(PyValueError), "invalid input: {}", value); @@ -283,6 +281,7 @@ impl PyDialect { Ok(buf) } + /// Write bytes as a native blob/bytea/varbyte literal. fn write_bytes(&self, value: &[u8]) -> pyo3::PyResult { let mut buf = String::new(); self.0.write_bytes(&mut buf, value)?; diff --git a/src/dialect/errors.rs b/src/dialect/errors.rs index 48a7ce9..aab5729 100644 --- a/src/dialect/errors.rs +++ b/src/dialect/errors.rs @@ -1,9 +1,13 @@ use std::fmt; +/// Formatting / serializing errors #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FormatError { + /// Equals to [`fmt::Write`] Write, + /// Means a `NUL` character has found in string during escaping/serializing a string value. NulInString, + /// Means something is not supported by a dialect Unsupported(&'static str), } @@ -17,7 +21,7 @@ impl From for FormatError { impl fmt::Display for FormatError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Write => f.write_str("failed to write SQL"), + Self::Write => f.write_str("failed to write SQL to memory"), Self::NulInString => f.write_str("NUL is not allowed in a string literal"), Self::Unsupported(name) => write!(f, "this dialect does not support {name}"), } diff --git a/src/dialect/flags.rs b/src/dialect/flags.rs index 809974c..954ba37 100644 --- a/src/dialect/flags.rs +++ b/src/dialect/flags.rs @@ -490,12 +490,26 @@ pub enum JsonFormat { ParseJson, } +/// Packed options that decide how one dialect spells SQL. +/// +/// Each field is a small enum or a bool. Builtin dialects set every field. +/// A flag the writer does not implement makes rendering fail. +/// +/// ``` +/// use rapidquery::dialect::flags::BoolFormat; +/// use rapidquery::dialect::flags::DialectFlags; +/// +/// let flags = DialectFlags::new().with_bool_format(BoolFormat::Integer); +/// let mut out = String::new(); +/// flags.write_boolean(&mut out, true).unwrap(); +/// assert_eq!(out, "1"); +/// ``` #[bitfield(u128)] #[derive(PartialEq, Eq, Hash)] pub struct DialectFlags { /// Placeholder character as u32 /// - /// Always is a valid character + /// NOTE: Caller is responsible to set a valid character. #[bits(32)] pub placeholder_character: u32, /// Positional parameter syntax @@ -625,7 +639,7 @@ impl DialectFlags { /// Write `string` with special characters neutralized, without wrapping it. #[inline] - pub fn escape_string(&self, writer: &mut W, string: &str) -> Result<(), FormatError> { + pub(super) fn escape_string(&self, writer: &mut W, string: &str) -> Result<(), FormatError> { let result = match self.string_escape() { StringEscape::Quote => utils::replace_to(string, '\'', "''", writer), StringEscape::BackslashHex => write_backslash_hex(writer, string), diff --git a/src/expr/base.rs b/src/expr/base.rs index 2141a0b..3046265 100644 --- a/src/expr/base.rs +++ b/src/expr/base.rs @@ -14,10 +14,7 @@ use crate::sql; use super::enum_::ExprInner; -/// Abstract SQL expression. -/// -/// Concrete nodes are frozen subclasses (`Ident`, `Value`, `Func`, -/// `Binary`, …). This base class cannot be instantiated. +/// Base of every SQL expression. Construct a subclass, not this class. #[rapidquery_macros::pyclass("rapidquery.expr.Expr", subclass, skip_from_py_object)] #[derive(Debug, Clone)] pub struct PyExpr(pub ExprInner); diff --git a/src/expr/enum_/func.rs b/src/expr/enum_/func.rs index 771c16a..ad6cb4f 100644 --- a/src/expr/enum_/func.rs +++ b/src/expr/enum_/func.rs @@ -29,6 +29,26 @@ impl NullTreatment { } } +/// Function call: name, arguments, `DISTINCT`, filter, and null treatment. +/// +/// The name is an [`Ident`]. Arguments are expression nodes. `filter` and +/// `null_treatment` are omitted from the SQL when they are `None`. +/// +/// ``` +/// use std::str::FromStr; +/// +/// use rapidquery::expr::enum_::Function; +/// use rapidquery::expr::enum_::Ident; +/// +/// let func = Function { +/// name: Ident::from_str("count").unwrap(), +/// distinct: false, +/// args: Vec::new(), +/// filter: None, +/// null_treatment: None, +/// }; +/// assert_eq!(func.name.len(), 1); +/// ``` #[derive(Clone, Debug, PartialEq)] pub struct Function { /// The function name diff --git a/src/expr/enum_/ident.rs b/src/expr/enum_/ident.rs index b7ee3d8..fe08bdf 100644 --- a/src/expr/enum_/ident.rs +++ b/src/expr/enum_/ident.rs @@ -46,12 +46,9 @@ impl IdentPart { } impl> From for IdentPart { - /// Parser helper used by [`Ident::from_str`]. + /// `"*"` becomes [`IdentPart::Asterisk`]. Any other string is quoted. /// - /// A literal `"*"` becomes [`IdentPart::Asterisk`]. Everything else is - /// [`IdentPart::Quote`] with no emptiness check — [`Ident::from_str`] - /// rejects empty chunks first. Python `str` extract does **not** use - /// this impl (it always goes through [`IdentPart::quoted`]). + /// Empty chunks are not rejected here. [`Ident::from_str`] checks those first. fn from(value: S) -> Self { let s = value.as_ref(); if s == "*" { @@ -80,14 +77,19 @@ impl WriteSql for IdentPart { } } -/// Represents a SQL identifier (`schema.table.column`). +/// One or more identifier parts, joined with `.` when rendered. /// -/// An identifier is a sequence of parts. Each constructor argument is one -/// part: a quoted name (`str`), an `Unquote` name, an `Asterisk` wildcard, -/// or a nested `Ident` whose parts are flattened. +/// A validated ident is never empty and holds at most [`Ident::MAX_PARTS`] parts. +/// [`IdentPart::Asterisk`] is legal only as the last part. /// -/// Never empty. At most [`Ident::MAX_PARTS`] parts. An [`IdentPart::Asterisk`] -/// may appear only as the last part. +/// ``` +/// use std::str::FromStr; +/// +/// use rapidquery::expr::enum_::Ident; +/// +/// let ident = Ident::from_str("users.id").unwrap(); +/// assert_eq!(ident.len(), 2); +/// ``` #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Ident(smallvec::SmallVec<[IdentPart; 4]>); diff --git a/src/expr/enum_/like.rs b/src/expr/enum_/like.rs index 16f826a..4b60b09 100644 --- a/src/expr/enum_/like.rs +++ b/src/expr/enum_/like.rs @@ -46,20 +46,27 @@ impl LikePattern { } } -/// `expr [NOT] LIKE / ILIKE [ANY] pattern [ESCAPE char]`. +/// Pattern predicate: `LIKE`, `ILIKE`, or `LIKE ANY`, plus an optional escape. /// -/// `case_sensitive == false` follows the dialect's `ilike_expression_options` -/// (`ILIKE`, plain `LIKE`, or `LOWER(expr) LIKE LOWER(pattern)`). `ESCAPE` is -/// a single inlined character (never a bind). A set escape character raises -/// when [`flags::LikeEscape`] does not allow it on that form. +/// One pattern is a scalar match. [`LikePattern::Any`] is `LIKE ANY`, spelled +/// as the dialect allows. The escape character is inlined, never bound. /// -/// LIKE ANY follows [`flags::LikeAny`]: Postgres `ANY (ARRAY[…])` wraps -/// negation as `NOT (x LIKE ANY …)` because `NOT LIKE ANY` is a quantified -/// NOT LIKE; Snowflake/Spark/Databricks use `LIKE ANY (p, …)`; everyone else -/// expands to parenthesized `OR` of scalar `LIKE`s (`AND` of `NOT LIKE` when -/// negated). Snowflake appends `ESCAPE` after the pattern list, inside that -/// negation wrap. Postgres, Spark, and Databricks have no `ESCAPE` slot on -/// native `LIKE ANY`. +/// ``` +/// use rapidquery::expr::enum_::ExprInner; +/// use rapidquery::expr::enum_::Ident; +/// use rapidquery::expr::enum_::Like; +/// use rapidquery::expr::enum_::LikePattern; +/// use std::str::FromStr; +/// +/// let expr = Like { +/// case_sensitive: true, +/// negated: false, +/// expr: Box::new(ExprInner::Ident(Ident::from_str("name").unwrap())), +/// pattern: LikePattern::One(Box::new(ExprInner::Ident(Ident::from_str("pat").unwrap()))), +/// escape: None, +/// }; +/// assert!(expr.escape.is_none()); +/// ``` #[derive(Clone, Debug, PartialEq)] pub struct Like { pub case_sensitive: bool, @@ -253,13 +260,22 @@ impl WriteSql for Like { /// `expr [NOT] SIMILAR TO pattern`. /// -/// Emitted only when [`Dialect::supports_similar_to`](crate::dialect::inner::Dialect::supports_similar_to) -/// is set (PostgreSQL, Redshift, DuckDB). Other dialects raise. +/// Rendered only when the dialect sets `supports_similar_to`. There is no +/// `ESCAPE` clause. Other dialects fail at render time. +/// +/// ``` +/// use rapidquery::expr::enum_::ExprInner; +/// use rapidquery::expr::enum_::Ident; +/// use rapidquery::expr::enum_::SimilarTo; +/// use std::str::FromStr; /// -/// No `ESCAPE` clause: regex engines use `\` (and dialect string-literal -/// rules). See [PostgreSQL pattern matching](https://www.postgresql.org/docs/current/functions-matching.html), -/// [Redshift SIMILAR TO](https://docs.aws.amazon.com/redshift/latest/dg/pattern-matching-conditions-similar-to.html), -/// [DuckDB pattern matching](https://duckdb.org/docs/current/sql/functions/pattern_matching.html). +/// let expr = SimilarTo { +/// negated: false, +/// expr: Box::new(ExprInner::Ident(Ident::from_str("name").unwrap())), +/// pattern: Box::new(ExprInner::Ident(Ident::from_str("pat").unwrap())), +/// }; +/// assert!(!expr.negated); +/// ``` #[derive(Clone, Debug, PartialEq)] pub struct SimilarTo { pub negated: bool, @@ -299,21 +315,24 @@ impl WriteSql for SimilarTo { } } -/// Regex match: spelling follows [`RLikeExpressionOptions`]. +/// Regex predicate. The dialect chooses `RLIKE`, `~`, or a function. +/// +/// Dialects with no regex predicate fail at render time. There is no `ESCAPE` +/// clause. Function names written for this node stay unquoted. /// -/// - MySQL / Hive / Spark / Databricks / Snowflake: `expr [NOT] RLIKE pattern` -/// ([MySQL REGEXP](https://dev.mysql.com/doc/refman/8.4/en/regexp.html), -/// [Spark LIKE/RLIKE](https://spark.apache.org/docs/latest/sql-ref-syntax-qry-select-like.html), -/// [Snowflake RLIKE](https://docs.snowflake.com/en/sql-reference/functions/rlike)). -/// Snowflake implicitly anchors the pattern (`^…$`); that rewrite is later. -/// - PostgreSQL / Redshift: `expr ~ pattern` / `expr !~ pattern` -/// ([PostgreSQL POSIX](https://www.postgresql.org/docs/current/functions-matching.html)). -/// - Oracle: `REGEXP_LIKE(expr, pattern)`. -/// - BigQuery: `REGEXP_CONTAINS(expr, pattern)`. -/// - DuckDB: `regexp_matches(expr, pattern)` (substring; DuckDB `~` is full match). -/// - MsSql / Sqlite / Teradata: raise. +/// ``` +/// use rapidquery::expr::enum_::ExprInner; +/// use rapidquery::expr::enum_::Ident; +/// use rapidquery::expr::enum_::RLike; +/// use std::str::FromStr; /// -/// Function names are unquoted. No `ESCAPE` clause. +/// let expr = RLike { +/// negated: false, +/// expr: Box::new(ExprInner::Ident(Ident::from_str("name").unwrap())), +/// pattern: Box::new(ExprInner::Ident(Ident::from_str("pat").unwrap())), +/// }; +/// assert!(!expr.negated); +/// ``` #[derive(Clone, Debug, PartialEq)] pub struct RLike { pub negated: bool, diff --git a/src/expr/enum_/nodes.rs b/src/expr/enum_/nodes.rs index 6eb15d2..ee7befb 100644 --- a/src/expr/enum_/nodes.rs +++ b/src/expr/enum_/nodes.rs @@ -19,7 +19,22 @@ fn unsupported(message: &'static str) -> crate::sql::PrepareError { rapidquery_macros::pyerr!(PyRuntimeError, message).into() } -/// Row value. SQL spelling depends on [`TupleSyntax`]. +/// Row value. The dialect chooses `ROW`, `struct`, or parentheses. +/// +/// An empty row is legal only for dialects that accept it. A string stored as +/// an item is a value expression, not a column name. +/// +/// ``` +/// use rapidquery::expr::enum_::ExprInner; +/// use rapidquery::expr::enum_::Ident; +/// use rapidquery::expr::enum_::Tuple; +/// use std::str::FromStr; +/// +/// let row = Tuple { +/// items: vec![ExprInner::Ident(Ident::from_str("id").unwrap())], +/// }; +/// assert_eq!(row.items.len(), 1); +/// ``` #[derive(Clone, Debug, PartialEq)] pub struct Tuple { pub items: Vec, @@ -73,11 +88,24 @@ impl WriteSql for Tuple { } } -/// Unary operation (`NOT x`, `-x`, `~x`). +/// Prefix operator: `NOT`, `+`, `-`, or `~`. +/// +/// `NOT` is written with a space. The others sit against the operand. Dialects +/// that have no prefix `~` render it as a function. /// -/// `NOT` binds between comparisons and `AND`. `+`, `-`, and `~` bind tighter -/// than multiplication. Nested equal-precedence operands are parenthesized. -/// `~` is `BITNOT(expr)` where the dialect's bitwise profile says so. +/// ``` +/// use rapidquery::expr::enum_::ExprInner; +/// use rapidquery::expr::enum_::Ident; +/// use rapidquery::expr::enum_::Unary; +/// use rapidquery::expr::enum_::UnaryOp; +/// use std::str::FromStr; +/// +/// let expr = Unary { +/// op: UnaryOp::Not, +/// expr: Box::new(ExprInner::Ident(Ident::from_str("active").unwrap())), +/// }; +/// assert_eq!(expr.op.as_sql(), "NOT"); +/// ``` #[derive(Clone, Debug, PartialEq)] pub struct Unary { pub op: UnaryOp, @@ -130,10 +158,25 @@ impl WriteSql for Unary { } } -/// Binary operation (`a + b`, `a AND b`, custom operators). +/// Infix operation, including arithmetic, comparison, and boolean operators. +/// +/// `!=` is stored as `<>`. An unknown spelling is kept as a custom operator. +/// The dialect may rewrite `%`, `^`, `||`, and shifts into function calls. /// -/// `!=` is stored and emitted as `<>`. Precedence follows [`BinaryOp::precedence`]. -/// The right operand is parenthesized when it has equal precedence. +/// ``` +/// use rapidquery::expr::enum_::Binary; +/// use rapidquery::expr::enum_::BinaryOp; +/// use rapidquery::expr::enum_::ExprInner; +/// use rapidquery::expr::enum_::Ident; +/// use std::str::FromStr; +/// +/// let expr = Binary { +/// left: Box::new(ExprInner::Ident(Ident::from_str("age").unwrap())), +/// op: BinaryOp::Gt, +/// right: Box::new(ExprInner::Ident(Ident::from_str("n").unwrap())), +/// }; +/// assert_eq!(expr.op.as_sql(), ">"); +/// ``` #[derive(Clone, Debug, PartialEq)] pub struct Binary { pub left: Box, @@ -285,7 +328,24 @@ impl WriteSql for Binary { } } -/// `expr IS [NOT] {NULL|TRUE|FALSE|UNKNOWN}`. +/// `expr IS [NOT] NULL`, `TRUE`, `FALSE`, or `UNKNOWN`. +/// +/// `negated` selects `IS NOT`. The kind is a fixed word, not an expression. +/// +/// ``` +/// use rapidquery::expr::enum_::ExprInner; +/// use rapidquery::expr::enum_::Ident; +/// use rapidquery::expr::enum_::Is; +/// use rapidquery::expr::enum_::IsKind; +/// use std::str::FromStr; +/// +/// let expr = Is { +/// expr: Box::new(ExprInner::Ident(Ident::from_str("name").unwrap())), +/// negated: false, +/// kind: IsKind::Null, +/// }; +/// assert_eq!(expr.kind.as_sql(), "NULL"); +/// ``` #[derive(Clone, Debug, PartialEq)] pub struct Is { pub expr: Box, @@ -319,7 +379,24 @@ impl WriteSql for Is { } } -/// `left IS [NOT] DISTINCT FROM right`. +/// Null-safe comparison, `IS [NOT] DISTINCT FROM`. +/// +/// Either side may be `NULL` without making the predicate unknown. The dialect +/// writes the standard form, a null-safe operator, or rejects the node. +/// +/// ``` +/// use rapidquery::expr::enum_::ExprInner; +/// use rapidquery::expr::enum_::Ident; +/// use rapidquery::expr::enum_::IsDistinctFrom; +/// use std::str::FromStr; +/// +/// let expr = IsDistinctFrom { +/// left: Box::new(ExprInner::Ident(Ident::from_str("a").unwrap())), +/// right: Box::new(ExprInner::Ident(Ident::from_str("b").unwrap())), +/// negated: false, +/// }; +/// assert!(!expr.negated); +/// ``` #[derive(Clone, Debug, PartialEq)] pub struct IsDistinctFrom { pub left: Box, @@ -370,10 +447,24 @@ impl WriteSql for IsDistinctFrom { } } -/// `expr [NOT] IN (list…)`. +/// Membership test, `expr [NOT] IN (list…)`. +/// +/// An empty list renders as a boolean literal and does not write `expr`. +/// `NOT IN` of an empty list is the true literal. +/// +/// ``` +/// use rapidquery::expr::enum_::ExprInner; +/// use rapidquery::expr::enum_::Ident; +/// use rapidquery::expr::enum_::InList; +/// use std::str::FromStr; /// -/// An empty list is `FALSE` (`NOT IN` is `TRUE`), spelled with the dialect's -/// boolean literal. The tested expression is not written. +/// let expr = InList { +/// expr: Box::new(ExprInner::Ident(Ident::from_str("id").unwrap())), +/// list: Vec::new(), +/// negated: false, +/// }; +/// assert!(expr.list.is_empty()); +/// ``` #[derive(Clone, Debug, PartialEq)] pub struct InList { pub expr: Box, @@ -425,9 +516,25 @@ impl WriteSql for InList { } } -/// `expr [NOT] BETWEEN low AND high`. +/// Inclusive range, `expr [NOT] BETWEEN low AND high`. /// -/// No `SYMMETRIC` clause. +/// Both bounds are expressions. There is no `SYMMETRIC` clause. Some dialects +/// parenthesize the whole predicate. +/// +/// ``` +/// use rapidquery::expr::enum_::Between; +/// use rapidquery::expr::enum_::ExprInner; +/// use rapidquery::expr::enum_::Ident; +/// use std::str::FromStr; +/// +/// let expr = Between { +/// expr: Box::new(ExprInner::Ident(Ident::from_str("n").unwrap())), +/// negated: false, +/// low: Box::new(ExprInner::Ident(Ident::from_str("lo").unwrap())), +/// high: Box::new(ExprInner::Ident(Ident::from_str("hi").unwrap())), +/// }; +/// assert!(!expr.negated); +/// ``` #[derive(Clone, Debug, PartialEq)] pub struct Between { pub expr: Box, @@ -471,10 +578,26 @@ impl WriteSql for Between { } } -/// `CAST(expr AS type)`, `TRY_CAST(expr AS type)`, or `(expr)::type`. +/// Cast to a SQL type name: `CAST`, `TRY_CAST`, or `::`. +/// +/// `kind` stays one of those three. The dialect rewrites `TRY_CAST` to its own +/// spelling, such as `SAFE_CAST`, or rejects it. +/// +/// ``` +/// use rapidquery::data_type::DataTypeName; +/// use rapidquery::expr::enum_::Cast; +/// use rapidquery::expr::enum_::CastKind; +/// use rapidquery::expr::enum_::ExprInner; +/// use rapidquery::expr::enum_::Ident; +/// use std::str::FromStr; /// -/// [`kind`](Self::kind) stays `CAST` / `TRY_CAST` / `::`. BigQuery spells -/// `TRY_CAST` as `SAFE_CAST`; Teradata spells it as `TRYCAST`. +/// let expr = Cast { +/// kind: CastKind::Cast, +/// expr: Box::new(ExprInner::Ident(Ident::from_str("id").unwrap())), +/// data_type_name: DataTypeName::from("int"), +/// }; +/// assert_eq!(expr.data_type_name.as_str(), "INT"); +/// ``` #[derive(Clone, Debug, PartialEq)] pub struct Cast { pub kind: CastKind, @@ -531,11 +654,25 @@ impl WriteSql for Cast { } } -/// `TRIM` of [`expr`](Self::expr), optionally from one side, optionally of -/// [`characters`](Self::characters). +/// `TRIM` of an expression, from one side, of an optional character set. /// -/// [`where_`](Self::where_) defaults to both ends. The SQL spelling is -/// `TRIM([LEADING|TRAILING] [chars FROM] expr)` or `TRIM` / `LTRIM` / `RTRIM`. +/// `where_` selects both ends, the leading end, or the trailing end. `characters` +/// is `None` when the call strips spaces. The dialect picks the function name. +/// +/// ``` +/// use rapidquery::expr::enum_::ExprInner; +/// use rapidquery::expr::enum_::Ident; +/// use rapidquery::expr::enum_::Trim; +/// use rapidquery::expr::enum_::TrimWhere; +/// use std::str::FromStr; +/// +/// let expr = Trim { +/// expr: Box::new(ExprInner::Ident(Ident::from_str("s").unwrap())), +/// where_: TrimWhere::Both, +/// characters: None, +/// }; +/// assert!(expr.characters.is_none()); +/// ``` #[derive(Clone, Debug, PartialEq)] pub struct Trim { pub expr: Box, @@ -620,9 +757,23 @@ impl WriteSql for Trim { } } -/// `expr COLLATE collation`. +/// Expression with a collation name attached. +/// +/// The collation is an identifier. Dialects that spell this as a function quote +/// that name as a string instead. Rendering fails when collation is unsupported. /// -/// Precedence 0, so a binary operand is parenthesized. +/// ``` +/// use rapidquery::expr::enum_::Collate; +/// use rapidquery::expr::enum_::ExprInner; +/// use rapidquery::expr::enum_::Ident; +/// use std::str::FromStr; +/// +/// let expr = Collate { +/// expr: Box::new(ExprInner::Ident(Ident::from_str("s").unwrap())), +/// collation: Ident::from_str("en_US").unwrap(), +/// }; +/// assert_eq!(expr.collation.len(), 1); +/// ``` #[derive(Clone, Debug, PartialEq)] pub struct Collate { pub expr: Box, @@ -670,7 +821,21 @@ impl WriteSql for Collate { } } -/// `CASE [operand] WHEN … THEN … [ELSE …] END`. +/// `CASE` expression, searched or matched against an operand. +/// +/// `operand` is `None` for `CASE WHEN`. Each `when` entry is a condition or +/// comparison value paired with its result. +/// +/// ``` +/// use rapidquery::expr::enum_::Case; +/// +/// let expr = Case { +/// operand: None, +/// when: Vec::new(), +/// else_result: None, +/// }; +/// assert!(expr.operand.is_none()); +/// ``` #[derive(Clone, Debug, PartialEq)] pub struct Case { pub operand: Option>, @@ -733,8 +898,23 @@ impl WriteSql for Case { } } -/// Escape-hatch SQL template. `{}` slots are filled by argument expressions. -/// `{{` / `}}` emit literal braces. Slot count must match [`args`](Self::args). +/// SQL template. Each `{}` slot is filled by one argument expression. +/// +/// `{{` and `}}` write a literal brace. The template itself is not escaped. +/// Rendering fails when the number of slots and arguments differ. +/// +/// ``` +/// use rapidquery::expr::enum_::Custom; +/// use rapidquery::expr::enum_::ExprInner; +/// use rapidquery::expr::enum_::Ident; +/// use std::str::FromStr; +/// +/// let expr = Custom { +/// sql: arcstr::ArcStr::from("COALESCE({}, 0)"), +/// args: vec![ExprInner::Ident(Ident::from_str("n").unwrap())], +/// }; +/// assert_eq!(expr.args.len(), 1); +/// ``` #[derive(Clone, Debug, PartialEq)] pub struct Custom { pub sql: arcstr::ArcStr, diff --git a/src/expr/enum_/value.rs b/src/expr/enum_/value.rs index bc667e2..d5a2274 100644 --- a/src/expr/enum_/value.rs +++ b/src/expr/enum_/value.rs @@ -12,13 +12,18 @@ use crate::sql::PrepareResult; use crate::sql::SqlWriter; use crate::sql::WriteSql; -/// A Python object plus the [`Serializer`] used to emit it as SQL. +/// Python object plus the serializer that turns it into a SQL literal. /// -/// Parameterized SQL binds [`Self::object`] as a placeholder and ignores the -/// serializer. Raw SQL (`BufferedSql`) calls [`SerializerType::serialize`]; -/// Python `None` becomes `NULL`. +/// Parameterized SQL binds [`Self::object`] and ignores the serializer. +/// Raw SQL runs the serializer. Python `None` becomes `NULL`. /// -/// Clone requires the GIL (`Py::clone`). +/// ``` +/// pyo3::Python::attach(|py| { +/// let object = pyo3::types::PyString::new(py, "hi").into_any().unbind(); +/// let value = rapidquery::expr::enum_::Value::new(object, rapidquery::serializers::SerializerType::String); +/// assert!(matches!(value.serializer, rapidquery::serializers::SerializerType::String)); +/// }); +/// ``` #[derive(Debug, Clone)] pub struct Value { pub object: alias::PyObject, diff --git a/src/expr/extractors.rs b/src/expr/extractors.rs index fb8e6a4..07e222e 100644 --- a/src/expr/extractors.rs +++ b/src/expr/extractors.rs @@ -22,13 +22,37 @@ unsafe fn is_asterisk(obj: *mut pyo3::ffi::PyObject) -> bool { type_ptr == crate::typeref::ASTERISK_TYPE } -/// Literal-priority extractor: a bare `str` becomes [`ExprInner::Value`]. +/// Expression extracted from Python. A bare `str` becomes a value, not a name. +/// +/// Use this for arguments where a string is data. Identifiers still pass through +/// as identifiers. The wrapped node is [`ExprInner`]. +/// +/// ``` +/// use std::str::FromStr; +/// +/// use rapidquery::expr::enum_::ExprInner; +/// use rapidquery::expr::enum_::Ident; +/// use rapidquery::expr::extractors::LiteralExprExtractor; +/// +/// let extracted = LiteralExprExtractor(ExprInner::Ident(Ident::from_str("id").unwrap())); +/// assert!(matches!(extracted.0, ExprInner::Ident(_))); +/// ``` pub struct LiteralExprExtractor(pub ExprInner); -/// Extracts a native [`Ident`](crate::Ident) from a Python object. +/// Native [`Ident`] extracted from a Python identifier argument. +/// +/// Accepts `Ident`, one quoted `str` (including `"*"`), `Unquote`, `Asterisk`, +/// and `type[Asterisk]`. Sequences are rejected. +/// +/// ``` +/// use std::str::FromStr; +/// +/// use rapidquery::expr::enum_::Ident; +/// use rapidquery::expr::extractors::IdentExtractor; /// -/// Accepts `Ident`, `str` (one quoted part, including `"*"`), `Unquote`, -/// `Asterisk`, and `type[Asterisk]`. Sequences are rejected. +/// let extracted = IdentExtractor(Ident::from_str("users.id").unwrap()); +/// assert_eq!(extracted.0.len(), 2); +/// ``` pub struct IdentExtractor(pub Ident); fn extract_expr( diff --git a/src/expr/func.rs b/src/expr/func.rs index 97fdfee..9e39b37 100644 --- a/src/expr/func.rs +++ b/src/expr/func.rs @@ -9,16 +9,7 @@ use super::enum_::NullTreatment; use super::extractors::IdentExtractor; use super::extractors::LiteralExprExtractor; -/// SQL function call. -/// -/// `name` is an identifier (`SUM`, `schema.fn`). The constructor emits a -/// bare name such as `CURRENT_TIMESTAMP` (no parentheses). `.call(...)` -/// adds an argument list; `.filter(...)` adds `FILTER (WHERE …)`; -/// `.ignore_nulls()` / `.respect_nulls()` add a postfix null-treatment -/// clause. Each method returns a new frozen `Function`. -/// -/// Args: -/// name: Function name (`str`, `Ident`, `Unquote`). +/// SQL function call. The name is an identifier, and each method returns a new call. #[rapidquery_macros::pyclass("rapidquery.expr.Function", skip_from_py_object, extends = PyExpr)] #[derive(Debug, Default)] pub struct PyFunction; @@ -70,34 +61,40 @@ impl PyFunction { Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) } + /// Return a copy whose filter predicate is `predicate`. fn filter(slf: pyo3::PyRef<'_, Self>, predicate: LiteralExprExtractor) -> pyo3::PyResult { let mut inner = Self::inner(&slf).clone(); inner.filter = Some(Box::new(predicate.0)); Self::wrap(slf.py(), inner) } + /// Return a copy that writes `IGNORE NULLS`. fn ignore_nulls(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { let mut inner = Self::inner(&slf).clone(); inner.null_treatment = Some(NullTreatment::IgnoreNulls); Self::wrap(slf.py(), inner) } + /// Return a copy that writes `RESPECT NULLS`. fn respect_nulls(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { let mut inner = Self::inner(&slf).clone(); inner.null_treatment = Some(NullTreatment::RespectNulls); Self::wrap(slf.py(), inner) } + /// The function name. fn get_name(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { let inner = Self::inner(&slf); PyExpr(super::enum_::ExprInner::Ident(inner.name.clone())).into_subclasses(slf.py()) } + /// Whether the call writes `DISTINCT`. fn get_distinct(slf: pyo3::PyRef<'_, Self>) -> bool { let inner = Self::inner(&slf); inner.distinct } + /// The argument expressions. fn get_args(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { let py = slf.py(); let inner = Self::inner(&slf); @@ -114,6 +111,7 @@ impl PyFunction { } } + /// The filter predicate, or `None`. fn get_filter(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { let py = slf.py(); let inner = Self::inner(&slf); @@ -124,6 +122,7 @@ impl PyFunction { } } + /// `IGNORE NULLS`, `RESPECT NULLS`, or `None`. fn get_null_treatment(slf: pyo3::PyRef<'_, Self>) -> Option<&'static str> { Self::inner(&slf).null_treatment.map(NullTreatment::as_sql) } diff --git a/src/expr/ident.rs b/src/expr/ident.rs index 125f917..f57af73 100644 --- a/src/expr/ident.rs +++ b/src/expr/ident.rs @@ -22,27 +22,7 @@ fn ident_part_to_pyobject<'py>( } } -/// Represents a SQL identifier (`schema.table.column`). -/// -/// An identifier is a sequence of parts. Each constructor argument is one -/// part: a quoted name (`str`), an `Unquote` name, an `Asterisk` wildcard, -/// or a nested `Ident` whose parts are flattened. -/// -/// Never empty. At most 10 parts. An asterisk may appear only as the last -/// part. -/// -/// Args: -/// parts: One argument per part. A `str` is always quoted, including -/// `"*"` (a column named star). Pass `Asterisk` for the SQL -/// wildcard. Dotted names are `Ident("schema", "table")`, not -/// `Ident("schema.table")`. -/// -/// Raises: -/// TypeError: If an argument is not `str`, `Unquote`, `Asterisk`, or -/// `Ident`. -/// ValueError: If there are no parts, a part is empty, `Asterisk` is -/// not last, an `Unquote` body is not a simple ASCII name, or -/// there are more than 10 parts. +/// Dotted SQL name. A string part is quoted; `Asterisk` is the wildcard. #[rapidquery_macros::pyclass("rapidquery.expr.Ident", skip_from_py_object, extends = super::base::PyExpr)] #[derive(Debug, Default)] pub struct PyIdent; @@ -111,7 +91,7 @@ impl PyIdent { } } - /// Returns the parts as a list (`str`, `Unquote`, or `Asterisk`). + /// Copy the parts into a list. fn to_list(slf: pyo3::PyRef<'_, Self>, py: pyo3::Python<'_>) -> pyo3::PyResult> { Self::inner(&slf) .parts() @@ -148,18 +128,7 @@ impl PyIdent { } } -/// Marker for an unquoted identifier part. -/// -/// The body is emitted without dialect delimiters. It must be a simple -/// ASCII name (`[A-Za-z_][A-Za-z0-9_]*`). This is **not** a raw-SQL -/// fragment (`COUNT(*)` belongs on a later expression type). -/// -/// Args: -/// name: The identifier body, unquoted. -/// -/// Raises: -/// ValueError: If `name` is empty or not a simple ASCII identifier -/// (letters, digits, underscore; first character not a digit). +/// Identifier body written with no quote characters. #[rapidquery_macros::pyclass("rapidquery.expr.Unquote", skip_from_py_object, eq, hash)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct PyUnquote(pub arcstr::ArcStr); @@ -175,10 +144,7 @@ impl PyUnquote { Ok(Self(arcstr::ArcStr::from(name))) } - /// Returns the unquoted identifier body. - /// - /// Returns: - /// The string passed to the constructor. + /// The identifier body. #[getter] fn get_name(&self) -> &str { self.0.as_str() @@ -191,11 +157,7 @@ impl PyUnquote { } } -/// SQL wildcard `*`. -/// -/// Pass the class (`Asterisk`) or an instance (`Asterisk()`) as an -/// identifier part. It must be the last part of an `Ident` (`t.*`). -/// `Ident("*")` is a quoted column named star, not this wildcard. +/// SQL wildcard `*`, used as the last part of an identifier. #[rapidquery_macros::pyclass("rapidquery.expr.Asterisk", skip_from_py_object, eq, hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct PyAsterisk; diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index f05271b..e97af78 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -44,7 +44,7 @@ empty_node! { /// Parenthesized row / tuple expression `(a, b, …)`. PyTupleExpr(Tuple(super::enum_::Tuple)) => "TupleExpr", - /// Unary expression (`NOT x`, `-x`, `|/x`, `x!`, …). + /// Prefix expression: `NOT x`, `+x`, `-x`, or `~x`. PyUnaryExpr(Unary(super::enum_::Unary)) => "UnaryExpr", /// Binary expression (`a + b`, `a AND b`, custom operators). @@ -83,8 +83,7 @@ empty_node! { /// `CASE [operand] WHEN … THEN … [ELSE …] END`. PyCaseExpr(Case(super::enum_::Case)) => "CaseExpr", - /// Escape-hatch SQL template. `{}` slots are filled by argument - /// expressions. `{{` / `}}` emit literal braces. + /// SQL template. `{}` slots are filled by arguments. `{{` and `}}` are braces. PyCustomExpr(Custom(super::enum_::Custom)) => "CustomExpr", } diff --git a/src/expr/value.rs b/src/expr/value.rs index e954864..ec186fb 100644 --- a/src/expr/value.rs +++ b/src/expr/value.rs @@ -4,15 +4,7 @@ use crate::serializers::SerializerExtractor; use super::enum_::Value; -/// A Python object plus the [`Serializer`] used to emit it as SQL. -/// -/// `Value` does not convert the object until SQL is built. Parameterized -/// statements bind the object as a placeholder. Raw SQL runs the serializer -/// (`None` becomes `NULL`). -/// -/// If `serializer` is omitted, one is inferred from the object's type. -/// Passing an existing `Value` keeps its object (and its serializer, unless -/// a new one is given). +/// Python object kept until SQL is rendered. `prepare` binds it; `to_sql` serializes it. #[rapidquery_macros::pyclass("rapidquery.expr.Value", skip_from_py_object, extends = super::base::PyExpr, generic)] #[derive(Debug, Default)] pub struct PyValue; @@ -34,17 +26,7 @@ impl PyValue { #[pyo3::pymethods] impl PyValue { - /// Creates a SQL value. - /// - /// Args: - /// object: The Python object to bind or serialize. If this is already - /// a `Value`, its object is reused. - /// serializer: How to render `object` as a SQL literal. If omitted, - /// a serializer is inferred from `object` (or kept, when - /// `object` is already a `Value`). - /// - /// Raises: - /// TypeError: If `serializer` is not a `Serializer`. + /// Wrap `object`. Omit `serializer` to infer one from its type. #[new] #[pyo3(signature = (object, serializer = None, /))] fn __new__(object: alias::BoundObject, serializer: Option) -> pyo3::PyClassInitializer { @@ -81,19 +63,12 @@ impl PyValue { pyo3::PyClassInitializer::from(expr).add_subclass(PyValue) } - /// Returns the wrapped Python object. - /// - /// Returns: - /// The object passed to the constructor (or taken from an existing `Value`). + /// The wrapped Python object. fn get_object(slf: pyo3::PyRef<'_, Self>) -> alias::PyObject { Self::inner(&slf).object.clone_ref(slf.py()) } - /// Returns the serializer used for raw SQL literals. - /// - /// Returns: - /// The `Serializer` stored on this value. Not used when the value - /// is bound as a parameter. + /// Serializer used by `to_sql`. fn get_serializer(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { PySerializer(Self::inner(&slf).serializer.clone()).into_subclasses(slf.py()) } diff --git a/src/internal/repr.rs b/src/internal/repr.rs index 365a72b..1a6a399 100644 --- a/src/internal/repr.rs +++ b/src/internal/repr.rs @@ -1,7 +1,16 @@ -/// Builds a Python `__repr__` of the form `Name(...)` or `[...]`. +/// Buffer for a Python `__repr__` of the form `Name(...)` or `[...]`. /// -/// One buffer. Arguments are written in place. A nested node is another -/// formatter; the parent copies that buffer once. +/// Arguments are written in place. A nested formatter is copied into the parent +/// once, after it is closed. +/// +/// ``` +/// use rapidquery::internal::repr::ReprFormatter; +/// +/// let mut fmt = ReprFormatter::new_call("Ident"); +/// fmt.positional_debug("id"); +/// fmt.close(); +/// assert_eq!(fmt.as_str(), "Ident(\"id\")"); +/// ``` pub struct ReprFormatter { buf: String, separated: bool, diff --git a/src/lib.rs b/src/lib.rs index 5d2e0a5..ef46152 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,9 +9,6 @@ pub mod serializers; pub mod sql; pub mod typeref; -// Tasks until being stable (until this part) -// - Update docstrings / doccomments - // Later tasks: // - Benchmark and compare with old version // - Open PR diff --git a/src/serializers/mod.rs b/src/serializers/mod.rs index 310f2df..d83fbf4 100644 --- a/src/serializers/mod.rs +++ b/src/serializers/mod.rs @@ -17,7 +17,7 @@ pub fn serializer_repr(serializer: &SerializerType) -> ReprFormatter { fmt } -/// Converts a Python object into a dialect-specific SQL literal string. +/// Turns a Python object into a SQL literal for one dialect. #[rapidquery_macros::pyclass("rapidquery.serializers.Serializer", subclass, from_py_object, generic)] #[derive(Debug, Clone)] #[repr(transparent)] @@ -123,149 +123,57 @@ macro_rules! impl_unit_serializers { } impl_unit_serializers! { - /// Serializes Python integers as bare SQL integer literals. - /// Accepts Python `int` of any width (including values beyond 64-bit). - /// Rejects `bool`; `True` and `False` are not treated as integers. - /// - /// Raises: - /// TypeError: If the value is not an `int`. + /// Bare SQL integer. Accepts `int`, not `bool`. Integer => PyIntegerSerializer => "IntegerSerializer", - /// Serializes numeric values as bare SQL numeric literals. - /// Accepts `float`, `int`, and `decimal.Decimal`. Finite floats are formatted - /// so that `1.0` stays distinct from `1`. `str` is not accepted. - /// - /// Raises: - /// TypeError: If the value is not a `float`, `int`, or `decimal.Decimal`. - /// ValueError: If the value is a non-finite float (`nan`, `inf`, `-inf`). + /// Bare SQL number for `float`, `int`, or `Decimal`. Float => PyFloatSerializer => "FloatSerializer", - /// Serializes Python booleans as dialect boolean literals. - /// Accepts Python `bool` only. The dialect chooses the spelling - /// (`TRUE`/`FALSE` or `1`/`0`). - /// - /// Raises: - /// TypeError: If the value is not a `bool`. + /// Dialect boolean literal for a Python `bool`. Boolean => PyBooleanSerializer => "BooleanSerializer", - /// Serializes text as a quoted, escaped SQL string literal. - /// Accepts `str`, or an `enum.Enum` member whose `.value` is a `str`. The - /// dialect supplies quoting and escaping. - /// - /// Raises: - /// TypeError: If the value is not a `str` or a string-valued enum member. + /// Quoted SQL string for `str` or a string enum member. String => PyStringSerializer => "StringSerializer", - /// Serializes UUIDs using the dialect's `uuid_format`. - /// Accepts `uuid.UUID` or `str`. A quoted literal, `UUID '…'`, or `'…'::uuid`. - /// Raises: - /// TypeError: If the value is not a `uuid.UUID` or `str`. + /// UUID literal for `uuid.UUID` or `str`. Uuid => PyUuidSerializer => "UuidSerializer", - /// Serializes binary data as a dialect blob/bytea literal. - /// Accepts exact `bytes` (not `bytearray` or `memoryview`). - /// - /// Raises: - /// TypeError: If the value is not `bytes`. + /// Blob or bytea literal for exact `bytes`. Bytes => PyBytesSerializer => "BytesSerializer", - /// Serializes bit strings as dialect bit-string literals. - /// Accepts a `str` consisting only of `'0'` and `'1'` characters. - /// Supported on PostgreSQL, DuckDB, MySQL, and Teradata. - /// - /// Raises: - /// TypeError: If the value is not a `str`. - /// ValueError: If the string is empty or contains characters other than `'0'` and `'1'`. - /// NotImplementedError: If the dialect has no native bit-string literal. + /// Bit-string literal for a non-empty `0`/`1` string. Bit => PyBitSerializer => "BitSerializer", - /// Serializes a JSON-encodable object using the dialect's `json_format`. - /// A quoted literal, `JSON '…'`, or `PARSE_JSON('…')`. - /// Accepts any object that the JSON encoder can dump. Uses `orjson` when it - /// is installed, otherwise the stdlib `json` module. The JSON text is then - /// wrapped with the dialect's string quoting. With `orjson`, datetime, - /// UUID, enum.StrEnum, and dataclass are encoded natively; stdlib - /// `json` rejects those. - /// - /// Raises: - /// TypeError: If the value cannot be JSON-encoded. + /// JSON literal for an object the JSON encoder can dump. Json => PyJSONSerializer => "JSONSerializer", - /// Serializes dates as `YYYY-MM-DD` literals. - /// `date_format` chooses a quoted literal or `DATE` plus that literal. - /// Accepts exact `datetime.date` (not `datetime.datetime`). - /// - /// Raises: - /// TypeError: If the value is not a `datetime.date`. + /// Date literal for `datetime.date`, not `datetime.datetime`. Date => PyDateSerializer => "DateSerializer", - /// Serializes times as `HH:MM:SS[.ffffff]` literals. - /// `time_format` chooses a quoted literal or `TIME` plus that literal. - /// Accepts `datetime.time`. - /// - /// Raises: - /// TypeError: If the value is not a `datetime.time`. + /// Time literal for `datetime.time`. Time => PyTimeSerializer => "TimeSerializer", - /// Serializes timestamps as SQL timestamp literals. - /// Accepts naive or aware `datetime.datetime`, or `int`/`float` UTC epoch - /// seconds. Naive values keep wall time. Aware values keep their original - /// offset (they are not converted to UTC). When `timestamp_format` sets a - /// keyword, naive values use `TIMESTAMP` and aware values (including epoch - /// numbers) use `TIMESTAMPTZ` or `TIMESTAMP WITH TIMEZONE`. - /// - /// Raises: - /// TypeError: If the value is not a `datetime.datetime`, `int`, or `float`. - /// OverflowError: If an epoch value is out of range for a timestamp. + /// Timestamp literal for `datetime` or a UTC epoch number. Datetime => PyDatetimeSerializer => "DatetimeSerializer", - /// Serializes IP addresses and networks using the dialect's `inet_format`. - /// A quoted literal or `'…'::inet`. - /// Accepts `str` or `ipaddress` objects (`IPv4Address`, `IPv6Address`, - /// `IPv4Network`, `IPv6Network`, and related types whose `str()` is a valid - /// address or CIDR). Supported on PostgreSQL and DuckDB. - /// - /// Raises: - /// ValueError: If the value is not a valid IPv4/IPv6 address or network. - /// NotImplementedError: If the dialect has no native INET type. + /// Inet literal for an IP address, network, or address string. Inet => PyInetSerializer => "InetSerializer", - /// Serializes MAC addresses using the dialect's `macaddr_format`. - /// A quoted literal or `'…'::macaddr`. - /// Accepts a `str` using `:` or `-` as the separator. - /// Supported on PostgreSQL. - /// - /// Raises: - /// ValueError: If the value is not a valid MAC address. - /// NotImplementedError: If the dialect has no native MACADDR type. + /// MAC address literal for a `:` or `-` separated string. MacAddress => PyMacAddressSerializer => "MacAddressSerializer", - /// Inserts `str(obj)` into SQL with no quoting or type check. - /// Used when the Python object matches no built-in serializer. The result - /// is not a safe SQL value; prefer an explicit serializer. + /// Inserts `str(object)` with no quoting and no type check. Unspecified => PyUnspecifiedSerializer => "UnspecifiedSerializer", } -/// Serializes sequences as `ARRAY[elem, ...]` literals. -/// -/// Each element is serialized independently with `element`. Accepts `list` -/// or `tuple`. Nested arrays are expressed by wrapping another -/// `ArraySerializer`. Supported on PostgreSQL, DuckDB, and BigQuery. -/// -/// Raises: -/// TypeError: If the value is not a `list` or `tuple`. -/// NotImplementedError: If the dialect has no `ARRAY[…]` constructor. +/// `ARRAY[…]` serializer. Each item is rendered by the element serializer. #[rapidquery_macros::pyclass("rapidquery.serializers.ArraySerializer", extends = PySerializer)] #[derive(Debug, Default)] pub struct PyArraySerializer; #[pyo3::pymethods] impl PyArraySerializer { - /// Creates an array serializer. - /// - /// Args: - /// element: The serializer applied to every array item. Nested arrays - /// are expressed by passing another `ArraySerializer`. + /// Build an array serializer from the serializer used for each item. #[new] #[inline] fn __new__(element: PySerializer) -> pyo3::PyClassInitializer { @@ -287,23 +195,14 @@ impl PyArraySerializer { } } -/// Serializes values by calling a user-supplied Python function. -/// -/// `function` has the form `fn(dialect_name: str, obj) -> str`. The return -/// value is inserted into SQL as-is (not re-quoted or escaped). It is the -/// caller's responsibility to produce a safe fragment. +/// Serializer that inserts the `str` returned by a callable. The text is not escaped. #[rapidquery_macros::pyclass("rapidquery.serializers.CustomSerializer", extends = PySerializer)] #[derive(Debug, Default)] pub struct PyCustomSerializer; #[pyo3::pymethods] impl PyCustomSerializer { - /// Creates a serializer backed by a Python callable. - /// - /// Args: - /// function: A callable of the form `fn(dialect_name: str, obj) -> str`. - /// The return value is inserted into SQL as-is (not re-quoted). - /// Must return a `str`. + /// Build a serializer. `function(dialect, object)` must return `str`. #[new] fn __new__(function: alias::PyObject) -> pyo3::PyResult> { unsafe { @@ -315,10 +214,7 @@ impl PyCustomSerializer { Ok(pyo3::PyClassInitializer::from(PySerializer(inner)).add_subclass(Self)) } - /// Returns the callable passed to the constructor. - /// - /// Returns: - /// The function given as `function` to `__new__`. + /// The callable passed to the constructor. #[getter] fn get_function(slf: pyo3::PyRef<'_, Self>) -> alias::PyObject { let base = slf.as_super(); @@ -329,9 +225,18 @@ impl PyCustomSerializer { } } -/// A `Serializer` instance. +/// Python `Serializer` extracted into a [`SerializerType`]. +/// +/// Use this as a `FromPyObject` argument when a method accepts only serializer +/// instances. Other objects fail extraction. +/// +/// ``` +/// use rapidquery::serializers::SerializerExtractor; +/// use rapidquery::serializers::SerializerType; /// -/// Use [`PySerializer`] itself as an argument to accept only `PySerializer` types. +/// let extracted = SerializerExtractor(SerializerType::Integer); +/// assert!(matches!(extracted.0, SerializerType::Integer)); +/// ``` pub struct SerializerExtractor(pub SerializerType); impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for SerializerExtractor { diff --git a/src/sql.rs b/src/sql.rs index eccf043..a21e133 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -1,9 +1,7 @@ -//! SQL writers: raw (`SqlBuf`) and parameterized (`PrepareContext`). +//! SQL text buffers. //! -//! One AST trait, [`WriteSql`], walks a node into any [`SqlWriter`]. Identifiers -//! and type names only push text. Values (when they land) call -//! [`SqlWriter::push_value`]: raw inlines a literal, prepared emits a -//! placeholder. +//! [`WriteSql`] walks a node into a [`SqlWriter`]. [`BufferedSql`] inlines values. +//! [`ParameterizedSql`] binds them as placeholders. use std::fmt::Write; @@ -106,10 +104,10 @@ impl From for pyo3::PyErr { pub type PrepareResult = Result; pub type PrepareParams = smallvec::SmallVec<[alias::PyObject; 1]>; -/// Output target for [`WriteSql`]. +/// Destination for [`WriteSql`]. /// -/// [`SqlBuf`] inlines values as SQL literals. [`PrepareContext`] binds them as -/// named placeholders. Everything else is `push_str` / `push_char`. +/// [`BufferedSql`] inlines values. [`ParameterizedSql`] binds them. Other methods +/// only append text. pub trait SqlWriter: std::fmt::Write { fn push_str(&mut self, s: &str) -> &mut Self; @@ -136,7 +134,17 @@ pub trait SqlWriter: std::fmt::Write { ) -> PrepareResult<()>; } -/// Raw SQL buffer. No parameter map. +/// SQL buffer that inlines values. It does not keep a parameter list. +/// +/// Build one with a Python token, append text through [`SqlWriter`], then take +/// the string with [`Self::into_string`]. +/// +/// ``` +/// pyo3::Python::attach(|py| { +/// let buf = rapidquery::sql::BufferedSql::new(py); +/// assert_eq!(buf.as_str(), ""); +/// }); +/// ``` pub struct BufferedSql<'py> { py: pyo3::Python<'py>, sql: String, @@ -215,10 +223,18 @@ impl<'py> std::fmt::Write for BufferedSql<'py> { } } -/// Accumulates rendered SQL and the bound-parameter map. +/// SQL text plus the Python objects bound for each placeholder. +/// +/// [`std::fmt::Write`] appends text. [`Self::push_param`] appends a placeholder +/// and stores the object. Numbering starts at 1. /// -/// Implements [`std::fmt::Write`], so `write!` / `writeln!` work directly: -/// `write!(ctx, "{name} = ")?;` +/// ``` +/// pyo3::Python::attach(|py| { +/// let sql = rapidquery::sql::ParameterizedSql::new(py); +/// assert_eq!(sql.sql(), ""); +/// assert!(sql.params().is_empty()); +/// }); +/// ``` pub struct ParameterizedSql<'py> { py: pyo3::Python<'py>, sql: String, diff --git a/tests/test_dialect.py b/tests/test_dialect.py index 8e4da6b..89f55d3 100644 --- a/tests/test_dialect.py +++ b/tests/test_dialect.py @@ -61,10 +61,6 @@ def test_dialect_methods(): assert default.write_string_literal("love") == "E'love'" - assert default.escape_string("\r") == "\\r" - assert default.escape_string("\t") == "\\t" - assert default.escape_string("\\") == "\\\\" - assert default.write_identifier("lov`e") == "`lov\\`e`" assert default.write_bits("10101") == "'10101'B" From eec90fa15ca32e4046b25265786abb73975ccad9 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Wed, 7 Oct 2026 12:58:32 +0330 Subject: [PATCH 73/87] Update lib.rs --- src/lib.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index ef46152..8941d93 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,8 +10,6 @@ pub mod sql; pub mod typeref; // Later tasks: -// - Benchmark and compare with old version -// - Open PR // - Complete `Expr` methods // - Support customization protocols (e.g. __serializer__, __expr__, __ident__) // - Start writing query ASTs. From 732632780e0d4d661652c4f6dbacb8fcb46b8161 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Wed, 7 Oct 2026 13:05:56 +0330 Subject: [PATCH 74/87] Update lib.rs --- src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib.rs b/src/lib.rs index 8941d93..a6f55ef 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod typeref; // Later tasks: // - Complete `Expr` methods +// - Update github workflows // - Support customization protocols (e.g. __serializer__, __expr__, __ident__) // - Start writing query ASTs. From 556ff462c5fe8f507a8cd7b1bc30278d4bfd9e36 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Fri, 9 Oct 2026 12:28:30 +0330 Subject: [PATCH 75/87] Update internal repr utils --- Makefile | 1 + src/data_type/mod.rs | 2 +- src/expr/base.rs | 2 +- src/expr/enum_/func.rs | 29 +-- src/expr/enum_/ident.rs | 30 +-- src/expr/enum_/like.rs | 64 +++--- src/expr/enum_/mod.rs | 6 +- src/expr/enum_/nodes.rs | 200 ++++++++++--------- src/expr/enum_/value.rs | 21 +- src/expr/ident.rs | 6 +- src/internal/mod.rs | 1 + src/internal/repr.rs | 401 ++++++++++++++++++++++++++------------ src/{ => internal}/sql.rs | 4 +- src/lib.rs | 1 - src/serializers/mod.rs | 10 +- 15 files changed, 485 insertions(+), 293 deletions(-) rename src/{ => internal}/sql.rs (98%) diff --git a/Makefile b/Makefile index 8e63963..16f06e4 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,7 @@ install: .uv .PHONY: rebuild-lockfiles ## Rebuild lockfiles from scratch, updating all dependencies rebuild-lockfiles: .uv uv lock --upgrade + cargo update .PHONY: build-dev ## Build the development version of the package build-dev: diff --git a/src/data_type/mod.rs b/src/data_type/mod.rs index f882ddd..9d6b5fb 100644 --- a/src/data_type/mod.rs +++ b/src/data_type/mod.rs @@ -1,4 +1,4 @@ -use crate::sql; +use crate::internal::sql; #[cfg(test)] mod tests; diff --git a/src/expr/base.rs b/src/expr/base.rs index 3046265..93e3a29 100644 --- a/src/expr/base.rs +++ b/src/expr/base.rs @@ -9,8 +9,8 @@ // `custom`. Prefer named methods over v0 dunders that return `Expr`. use crate::internal::alias; +use crate::internal::sql; use crate::internal::utils; -use crate::sql; use super::enum_::ExprInner; diff --git a/src/expr/enum_/func.rs b/src/expr/enum_/func.rs index ad6cb4f..7555ef8 100644 --- a/src/expr/enum_/func.rs +++ b/src/expr/enum_/func.rs @@ -2,11 +2,11 @@ use super::ExprInner; use super::ident::Ident; use crate::dialect::flags; -use crate::internal::repr::ReprFormatter; -use crate::sql::PrepareResult; -use crate::sql::SqlWriter; -use crate::sql::WriteSql; -use crate::sql::write_separated; +use crate::internal::repr; +use crate::internal::sql::PrepareResult; +use crate::internal::sql::SqlWriter; +use crate::internal::sql::WriteSql; +use crate::internal::sql::write_separated; /// How `NULL`s are treated in some window/aggregate functions. /// @@ -67,14 +67,16 @@ pub struct Function { } impl Function { - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut fmt = ReprFormatter::new_call("Function"); - fmt.positional_formatter(self.name.python_repr()); + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = repr::ReprFormatter::new_call("Function"); + + fmt.positional(self.name.python_repr()); + for arg in &self.args { - fmt.positional_formatter(arg.python_repr(py)?); + fmt.positional(arg.python_repr(py)?); } - fmt.keyword_if_true("distinct", self.distinct); - fmt.close(); + + fmt.keyword_true("distinct", self.distinct).close(); match self.null_treatment { Some(NullTreatment::IgnoreNulls) => { @@ -85,10 +87,11 @@ impl Function { } None => {} } + if let Some(filter) = &self.filter { - let argument = filter.python_repr(py)?; - fmt.append_call("filter", &argument); + fmt.append_call("filter").positional(filter.python_repr(py)?); } + Ok(fmt) } } diff --git a/src/expr/enum_/ident.rs b/src/expr/enum_/ident.rs index fe08bdf..11ab9b4 100644 --- a/src/expr/enum_/ident.rs +++ b/src/expr/enum_/ident.rs @@ -1,11 +1,11 @@ //! SQL identifiers. use crate::dialect::PyDialect; -use crate::internal::repr::ReprFormatter; -use crate::sql::PrepareResult; -use crate::sql::SqlWriter; -use crate::sql::WriteSql; -use crate::sql::write_separated; +use crate::internal::repr; +use crate::internal::sql::PrepareResult; +use crate::internal::sql::SqlWriter; +use crate::internal::sql::WriteSql; +use crate::internal::sql::write_separated; /// One segment of a dotted identifier. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -162,25 +162,27 @@ impl Ident { self.0 } - pub fn python_repr(&self) -> ReprFormatter { - let mut result = ReprFormatter::new_call("Ident"); + pub fn python_repr(&self) -> repr::ReprFormatter { + let mut fmt = repr::ReprFormatter::new_call("Ident"); + for item in self.parts() { match item { IdentPart::Quote(name) => { - result.positional_debug(name.as_str()); + fmt.positional(name.as_str()); } IdentPart::Unquote(name) => { - let mut unquote = ReprFormatter::new_call("Unquote"); - unquote.positional_debug(name.as_str()); - result.positional_formatter(unquote); + let mut unquote = repr::ReprFormatter::new_call("Unquote"); + unquote.positional(name.as_str()); + fmt.positional(unquote); } IdentPart::Asterisk => { - result.positional_raw("Asterisk"); + fmt.positional_raw("Asterisk"); } } } - result.close(); - result + + fmt.close(); + fmt } /// One already-validated part. Prefer this over [`from_parts`] for a diff --git a/src/expr/enum_/like.rs b/src/expr/enum_/like.rs index 4b60b09..3e38083 100644 --- a/src/expr/enum_/like.rs +++ b/src/expr/enum_/like.rs @@ -3,11 +3,11 @@ use super::ExprInner; use crate::dialect::PyDialect; use crate::dialect::flags; -use crate::internal::repr::ReprFormatter; -use crate::sql::PrepareResult; -use crate::sql::SqlWriter; -use crate::sql::WriteSql; -use crate::sql::write_separated; +use crate::internal::repr; +use crate::internal::sql::PrepareResult; +use crate::internal::sql::SqlWriter; +use crate::internal::sql::WriteSql; +use crate::internal::sql::write_separated; pub(super) fn write_escape(dialect: &PyDialect, out: &mut W, escape: Option) -> PrepareResult<()> { let Some(c) = escape else { @@ -31,14 +31,16 @@ pub enum LikePattern { } impl LikePattern { - fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { match self { Self::One(pattern) => pattern.python_repr(py), Self::Any(patterns) => { - let mut fmt = ReprFormatter::new_list(); + let mut fmt = repr::ReprFormatter::new_list(); + for item in patterns { - fmt.positional_formatter(item.python_repr(py)?); + fmt.positional(item.python_repr(py)?); } + fmt.close(); Ok(fmt) } @@ -79,14 +81,16 @@ pub struct Like { impl Like { pub(crate) const PRECEDENCE: u8 = 5; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut fmt = ReprFormatter::new_call("LikeExpr"); - fmt.positional_formatter(self.expr.python_repr(py)?); - fmt.positional_formatter(self.pattern.python_repr(py)?); - fmt.keyword_if_false("case_sensitive", self.case_sensitive); - fmt.keyword_if_true("negated", self.negated); - fmt.keyword_optional_debug("escape", self.escape); - fmt.close(); + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = repr::ReprFormatter::new_call("LikeExpr"); + + fmt.positional(self.expr.python_repr(py)?) + .positional(self.pattern.python_repr(py)?) + .keyword_false("case_sensitive", self.case_sensitive) + .keyword_true("negated", self.negated) + .keyword("escape", self.escape) + .close(); + Ok(fmt) } } @@ -286,12 +290,14 @@ pub struct SimilarTo { impl SimilarTo { pub(crate) const PRECEDENCE: u8 = 5; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut fmt = ReprFormatter::new_call("SimilarToExpr"); - fmt.positional_formatter(self.expr.python_repr(py)?); - fmt.positional_formatter(self.pattern.python_repr(py)?); - fmt.keyword_if_true("negated", self.negated); - fmt.close(); + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = repr::ReprFormatter::new_call("SimilarToExpr"); + + fmt.positional(self.expr.python_repr(py)?) + .positional(self.pattern.python_repr(py)?) + .keyword_true("negated", self.negated) + .close(); + Ok(fmt) } } @@ -343,12 +349,14 @@ pub struct RLike { impl RLike { pub(crate) const PRECEDENCE: u8 = 5; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut fmt = ReprFormatter::new_call("RLikeExpr"); - fmt.positional_formatter(self.expr.python_repr(py)?); - fmt.positional_formatter(self.pattern.python_repr(py)?); - fmt.keyword_if_true("negated", self.negated); - fmt.close(); + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = repr::ReprFormatter::new_call("RLikeExpr"); + + fmt.positional(self.expr.python_repr(py)?) + .positional(self.pattern.python_repr(py)?) + .keyword_true("negated", self.negated) + .close(); + Ok(fmt) } } diff --git a/src/expr/enum_/mod.rs b/src/expr/enum_/mod.rs index 5b632d8..b422133 100644 --- a/src/expr/enum_/mod.rs +++ b/src/expr/enum_/mod.rs @@ -14,9 +14,9 @@ pub use value::*; use crate::dialect::PyDialect; use crate::internal::repr::ReprFormatter; -use crate::sql::PrepareResult; -use crate::sql::SqlWriter; -use crate::sql::WriteSql; +use crate::internal::sql::PrepareResult; +use crate::internal::sql::SqlWriter; +use crate::internal::sql::WriteSql; /// SQL expression tree. /// diff --git a/src/expr/enum_/nodes.rs b/src/expr/enum_/nodes.rs index ee7befb..49fd6ca 100644 --- a/src/expr/enum_/nodes.rs +++ b/src/expr/enum_/nodes.rs @@ -8,14 +8,14 @@ use super::UnaryOp; use super::UnarySpacing; use crate::dialect::PyDialect; use crate::dialect::flags; -use crate::internal::repr::ReprFormatter; -use crate::sql::PrepareResult; -use crate::sql::SqlWriter; -use crate::sql::WriteSql; -use crate::sql::write_separated; +use crate::internal::repr; +use crate::internal::sql::PrepareResult; +use crate::internal::sql::SqlWriter; +use crate::internal::sql::WriteSql; +use crate::internal::sql::write_separated; #[inline] -fn unsupported(message: &'static str) -> crate::sql::PrepareError { +fn unsupported(message: &'static str) -> crate::internal::sql::PrepareError { rapidquery_macros::pyerr!(PyRuntimeError, message).into() } @@ -43,12 +43,14 @@ pub struct Tuple { impl Tuple { pub(crate) const PRECEDENCE: u8 = 0; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut fmt = ReprFormatter::new_call("TupleExpr"); + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = repr::ReprFormatter::new_call("TupleExpr"); + for item in &self.items { - fmt.positional_formatter(item.python_repr(py)?); + fmt.positional(item.python_repr(py)?); } fmt.close(); + Ok(fmt) } } @@ -113,11 +115,13 @@ pub struct Unary { } impl Unary { - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut fmt = ReprFormatter::new_call("UnaryExpr"); - fmt.positional_debug(self.op.as_sql()); - fmt.positional_formatter(self.expr.python_repr(py)?); - fmt.close(); + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = repr::ReprFormatter::new_call("UnaryExpr"); + + fmt.positional(self.op.as_sql()) + .positional(self.expr.python_repr(py)?) + .close(); + Ok(fmt) } } @@ -185,12 +189,14 @@ pub struct Binary { } impl Binary { - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut fmt = ReprFormatter::new_call("BinaryExpr"); - fmt.positional_formatter(self.left.python_repr(py)?); - fmt.positional_debug(self.op.as_sql()); - fmt.positional_formatter(self.right.python_repr(py)?); - fmt.close(); + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = repr::ReprFormatter::new_call("BinaryExpr"); + + fmt.positional(self.left.python_repr(py)?) + .positional(self.op.as_sql()) + .positional(self.right.python_repr(py)?) + .close(); + Ok(fmt) } } @@ -356,12 +362,14 @@ pub struct Is { impl Is { pub(crate) const PRECEDENCE: u8 = 5; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut fmt = ReprFormatter::new_call("IsExpr"); - fmt.positional_formatter(self.expr.python_repr(py)?); - fmt.positional_debug(self.kind.as_sql()); - fmt.keyword_if_true("negated", self.negated); - fmt.close(); + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = repr::ReprFormatter::new_call("IsExpr"); + + fmt.positional(self.expr.python_repr(py)?) + .positional(self.kind.as_sql()) + .keyword_true("negated", self.negated) + .close(); + Ok(fmt) } } @@ -407,12 +415,14 @@ pub struct IsDistinctFrom { impl IsDistinctFrom { pub(crate) const PRECEDENCE: u8 = 5; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut fmt = ReprFormatter::new_call("IsDistinctFromExpr"); - fmt.positional_formatter(self.left.python_repr(py)?); - fmt.positional_formatter(self.right.python_repr(py)?); - fmt.keyword_if_true("negated", self.negated); - fmt.close(); + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = repr::ReprFormatter::new_call("IsDistinctFromExpr"); + + fmt.positional(self.left.python_repr(py)?) + .positional(self.right.python_repr(py)?) + .keyword_true("negated", self.negated) + .close(); + Ok(fmt) } } @@ -475,16 +485,18 @@ pub struct InList { impl InList { pub(crate) const PRECEDENCE: u8 = 5; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut fmt = ReprFormatter::new_call("InListExpr"); - fmt.positional_formatter(self.expr.python_repr(py)?); - fmt.positional_tuple(|group| -> pyo3::PyResult<()> { - for item in &self.list { - group.positional_formatter(item.python_repr(py)?); - } - Ok(()) - })?; - fmt.keyword_if_true("negated", self.negated); + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = repr::ReprFormatter::new_call("InListExpr"); + + fmt.positional(self.expr.python_repr(py)?); + + let mut tuple = fmt.positional_tuple(); + for item in &self.list { + tuple.positional(item.python_repr(py)?); + } + tuple.close(); + + fmt.keyword_true("negated", self.negated); fmt.close(); Ok(fmt) } @@ -546,13 +558,14 @@ pub struct Between { impl Between { pub(crate) const PRECEDENCE: u8 = 5; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut fmt = ReprFormatter::new_call("BetweenExpr"); - fmt.positional_formatter(self.expr.python_repr(py)?); - fmt.positional_formatter(self.low.python_repr(py)?); - fmt.positional_formatter(self.high.python_repr(py)?); - fmt.keyword_if_true("negated", self.negated); - fmt.close(); + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = repr::ReprFormatter::new_call("BetweenExpr"); + fmt.positional(self.expr.python_repr(py)?) + .positional(self.low.python_repr(py)?) + .positional(self.high.python_repr(py)?) + .keyword_true("negated", self.negated) + .close(); + Ok(fmt) } } @@ -608,13 +621,16 @@ pub struct Cast { impl Cast { pub(crate) const PRECEDENCE: u8 = 0; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut fmt = ReprFormatter::new_call("CastExpr"); - fmt.positional_formatter(self.expr.python_repr(py)?); - fmt.positional_debug(self.data_type_name.as_str()); + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = repr::ReprFormatter::new_call("CastExpr"); + + fmt.positional(self.expr.python_repr(py)?) + .positional(self.data_type_name.as_str()); + if self.kind != CastKind::Cast { - fmt.keyword_debug("kind", self.kind.as_sql()); + fmt.keyword("kind", self.kind.as_sql()); } + fmt.close(); Ok(fmt) } @@ -683,19 +699,22 @@ pub struct Trim { impl Trim { pub(crate) const PRECEDENCE: u8 = 0; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { let characters = self .characters .as_ref() .map(|characters| characters.python_repr(py)) .transpose()?; - let mut fmt = ReprFormatter::new_call("TrimExpr"); - fmt.positional_formatter(self.expr.python_repr(py)?); + + let mut fmt = repr::ReprFormatter::new_call("TrimExpr"); + + fmt.positional(self.expr.python_repr(py)?); + if self.where_ != TrimWhere::Both { - fmt.keyword_debug("where", self.where_.as_sql()); + fmt.keyword("where", self.where_.as_sql()); } - fmt.keyword_optional_formatter("characters", characters); - fmt.close(); + + fmt.keyword("characters", characters).close(); Ok(fmt) } } @@ -783,11 +802,12 @@ pub struct Collate { impl Collate { pub(crate) const PRECEDENCE: u8 = 0; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut fmt = ReprFormatter::new_call("CollateExpr"); - fmt.positional_formatter(self.expr.python_repr(py)?); - fmt.positional_formatter(self.collation.python_repr()); - fmt.close(); + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = repr::ReprFormatter::new_call("CollateExpr"); + fmt.positional(self.expr.python_repr(py)?) + .positional(self.collation.python_repr()) + .close(); + Ok(fmt) } } @@ -846,31 +866,26 @@ pub struct Case { impl Case { pub(crate) const PRECEDENCE: u8 = 0; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut fmt = ReprFormatter::new_call("CaseExpr"); - fmt.positional_list(|list| -> pyo3::PyResult<()> { - for (when, then) in &self.when { - let when = when.python_repr(py)?; - let then = then.python_repr(py)?; - list.positional_tuple(|pair| { - pair.positional_formatter(when); - pair.positional_formatter(then); - }); - } - Ok(()) - })?; + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { let operand = self .operand .as_ref() .map(|operand| operand.python_repr(py)) .transpose()?; - let else_result = self - .else_result - .as_ref() - .map(|else_result| else_result.python_repr(py)) - .transpose()?; - fmt.keyword_optional_formatter("operand", operand); - fmt.keyword_optional_formatter("else_", else_result); + + let mut fmt = repr::ReprFormatter::new_call("CaseExpr"); + fmt.positional(operand); + + for (when, then) in self.when.iter() { + fmt.append_call("when") + .positional(when.python_repr(py)?) + .positional(then.python_repr(py)?); + } + + if let Some(else_result) = &self.else_result { + fmt.append_call("else_").positional(else_result.python_repr(py)?); + } + fmt.close(); Ok(fmt) } @@ -924,12 +939,15 @@ pub struct Custom { impl Custom { pub(crate) const PRECEDENCE: u8 = 0; - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { - let mut fmt = ReprFormatter::new_call("CustomExpr"); - fmt.positional_debug(self.sql.as_str()); + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + let mut fmt = repr::ReprFormatter::new_call("CustomExpr"); + + fmt.positional(self.sql.as_str()); + for arg in &self.args { - fmt.positional_formatter(arg.python_repr(py)?); + fmt.positional(arg.python_repr(py)?); } + fmt.close(); Ok(fmt) } @@ -955,7 +973,7 @@ impl WriteSql for Custom { if i + 1 < bytes.len() && bytes[i + 1] == b'}' { out.push_str(&self.sql[start..i]); if used >= self.args.len() { - return Err(crate::sql::PrepareError::CustomPlaceholders { + return Err(crate::internal::sql::PrepareError::CustomPlaceholders { expected: used + 1, got: self.args.len(), }); @@ -978,7 +996,7 @@ impl WriteSql for Custom { out.push_str(&self.sql[start..]); if used != self.args.len() { - return Err(crate::sql::PrepareError::CustomPlaceholders { + return Err(crate::internal::sql::PrepareError::CustomPlaceholders { expected: used, got: self.args.len(), }); diff --git a/src/expr/enum_/value.rs b/src/expr/enum_/value.rs index d5a2274..50d606d 100644 --- a/src/expr/enum_/value.rs +++ b/src/expr/enum_/value.rs @@ -5,12 +5,12 @@ use pyo3::types::PyStringMethods; use crate::dialect::PyDialect; use crate::internal::alias; -use crate::internal::repr::ReprFormatter; +use crate::internal::repr; +use crate::internal::sql::PrepareResult; +use crate::internal::sql::SqlWriter; +use crate::internal::sql::WriteSql; use crate::serializers::SerializerType; use crate::serializers::serializer_repr; -use crate::sql::PrepareResult; -use crate::sql::SqlWriter; -use crate::sql::WriteSql; /// Python object plus the serializer that turns it into a SQL literal. /// @@ -42,13 +42,16 @@ impl Value { Self::new(bound.unbind(), serializer) } - pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { + pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { let obj_repr = self.object.bind(py).repr()?; let text = obj_repr.to_cow()?; - let mut fmt = ReprFormatter::new_call("Value"); - fmt.positional_raw(&text); - fmt.positional_formatter(serializer_repr(&self.serializer)); - fmt.close(); + + let mut fmt = repr::ReprFormatter::new_call("Value"); + + fmt.positional_raw(&text) + .positional(serializer_repr(&self.serializer)) + .close(); + Ok(fmt) } } diff --git a/src/expr/ident.rs b/src/expr/ident.rs index f57af73..d9f7dd6 100644 --- a/src/expr/ident.rs +++ b/src/expr/ident.rs @@ -6,7 +6,7 @@ use super::enum_::ParseIdentError; use super::extractors::ExtractedIdentArgument; use super::extractors::extract_ident_argument; use crate::internal::alias; -use crate::internal::repr::ReprFormatter; +use crate::internal::repr; /// Convert a native part into the matching Python object (`str`, `Unquote`, /// or `Asterisk`). @@ -151,8 +151,8 @@ impl PyUnquote { } fn __repr__(&self) -> String { - let mut fmt = ReprFormatter::new_call("Unquote"); - fmt.positional_debug(self.0.as_str()); + let mut fmt = repr::ReprFormatter::new_call("Unquote"); + fmt.positional(self.0.as_str()); fmt.into_string() } } diff --git a/src/internal/mod.rs b/src/internal/mod.rs index 700fc1c..1eb9c2d 100644 --- a/src/internal/mod.rs +++ b/src/internal/mod.rs @@ -1,4 +1,5 @@ pub mod alias; pub mod modules; pub mod repr; +pub mod sql; pub mod utils; diff --git a/src/internal/repr.rs b/src/internal/repr.rs index 1a6a399..27c74ca 100644 --- a/src/internal/repr.rs +++ b/src/internal/repr.rs @@ -1,138 +1,245 @@ +use std::fmt::Write; +use std::fmt::{self}; + +/// A value rendered straight into a [`ReprFormatter`] buffer. +/// +/// Omitted values write nothing and do not emit a separator. [`Option`] is +/// omitted when empty; [`IfTrue`] / [`IfFalse`] are omitted on the other polarity. +pub trait WriteRepr { + fn omit(&self) -> bool { + false + } + + fn write_repr(self, buf: &mut String); +} + +impl WriteRepr for &str { + fn write_repr(self, buf: &mut String) { + write!(buf, "{self:?}").unwrap(); + } +} + +impl WriteRepr for String { + fn write_repr(self, buf: &mut String) { + write!(buf, "{self:?}").unwrap(); + } +} + +impl WriteRepr for char { + fn write_repr(self, buf: &mut String) { + write!(buf, "{self:?}").unwrap(); + } +} + +impl WriteRepr for bool { + fn write_repr(self, buf: &mut String) { + buf.push_str(if self { "True" } else { "False" }); + } +} + +/// Text inserted with no quoting or escaping. +pub struct Raw<'a>(pub &'a str); + +impl WriteRepr for Raw<'_> { + fn write_repr(self, buf: &mut String) { + buf.push_str(self.0); + } +} + +/// Python `True`, written only when the flag is set. +pub struct IfTrue(pub bool); + +impl WriteRepr for IfTrue { + fn omit(&self) -> bool { + !self.0 + } + + fn write_repr(self, buf: &mut String) { + buf.push_str("True"); + } +} + +/// Python `False`, written only when the flag is clear. +pub struct IfFalse(pub bool); + +impl WriteRepr for IfFalse { + fn omit(&self) -> bool { + self.0 + } + + fn write_repr(self, buf: &mut String) { + buf.push_str("False"); + } +} + +impl WriteRepr for ReprFormatter { + fn write_repr(mut self, buf: &mut String) { + self.close(); + buf.push_str(&self.buf); + } +} + +impl WriteRepr for Option { + fn omit(&self) -> bool { + match self { + Some(value) => value.omit(), + None => true, + } + } + + fn write_repr(self, buf: &mut String) { + if let Some(value) = self { + value.write_repr(buf); + } + } +} + /// Buffer for a Python `__repr__` of the form `Name(...)` or `[...]`. /// /// Arguments are written in place. A nested formatter is copied into the parent -/// once, after it is closed. +/// once, after it is closed. Bracketed groups are a [`ReprGroup`] borrow of this +/// buffer; dropping the group writes the closer. /// /// ``` /// use rapidquery::internal::repr::ReprFormatter; /// /// let mut fmt = ReprFormatter::new_call("Ident"); -/// fmt.positional_debug("id"); +/// fmt.positional("id"); /// fmt.close(); /// assert_eq!(fmt.as_str(), "Ident(\"id\")"); /// ``` pub struct ReprFormatter { buf: String, - separated: bool, - closer: Closer, - closed: bool, + close_token: char, + is_separated: bool, + is_closed: bool, } -#[derive(Clone, Copy)] -enum Closer { - Paren, - Bracket, +impl ReprFormatter { + #[inline] + fn separate(&mut self) { + debug_assert!(!self.is_closed); + + if self.is_separated { + self.buf.push_str(", "); + } else { + self.is_separated = true; + } + } + + #[inline] + fn push_key(&mut self, key: &str) { + self.buf.push_str(key); + self.buf.push('='); + } + + #[inline] + fn write_value(&mut self, value: impl WriteRepr) -> bool { + if value.omit() { + return false; + } + + self.separate(); + value.write_repr(&mut self.buf); + true + } + + fn open_group(&mut self, open: char, close: char) -> ReprGroup<'_> { + self.separate(); + self.buf.push(open); + self.is_separated = false; + + ReprGroup { + fmt: self, + close_token: close, + finished: false, + } + } } impl ReprFormatter { - /// `name(`. pub fn new_call(name: &str) -> Self { let mut buf = String::with_capacity(name.len() + 32); buf.push_str(name); buf.push('('); + Self { buf, - separated: false, - closer: Closer::Paren, - closed: false, + close_token: ')', + is_separated: false, + is_closed: false, } } - /// `[`. + #[inline] pub fn new_list() -> Self { Self { buf: String::from("["), - separated: false, - closer: Closer::Bracket, - closed: false, + close_token: ']', + is_separated: false, + is_closed: false, } } - pub fn positional_debug(&mut self, value: impl std::fmt::Debug) -> &mut Self { - self.separate(); - self.push_debug(&value); + pub fn positional(&mut self, value: impl WriteRepr) -> &mut Self { + self.write_value(value); self } - /// Paste `value` with no extra quotes and no separator logic inside it. - pub fn positional_raw(&mut self, value: &str) -> &mut Self { + pub fn positional_debug(&mut self, value: &impl fmt::Debug) -> &mut Self { self.separate(); - self.buf.push_str(value); + write!(self.buf, "{value:?}").unwrap(); self } - pub fn positional_formatter(&mut self, child: ReprFormatter) -> &mut Self { - self.separate(); - self.push_formatter(child); - self + pub fn positional_raw(&mut self, value: &str) -> &mut Self { + self.positional(Raw(value)) } - /// Positional `[...]`. Items are written with the same argument methods. - pub fn positional_list(&mut self, write_items: impl FnOnce(&mut Self) -> R) -> R { - self.group('[', ']', write_items) + pub fn positional_list(&mut self) -> ReprGroup<'_> { + self.open_group('[', ']') } - /// Positional `(...)`. - pub fn positional_tuple(&mut self, write_items: impl FnOnce(&mut Self) -> R) -> R { - self.group('(', ')', write_items) + pub fn positional_tuple(&mut self) -> ReprGroup<'_> { + self.open_group('(', ')') } - pub fn keyword_debug(&mut self, key: &str, value: impl std::fmt::Debug) -> &mut Self { - self.separate(); - self.push_key(key); - self.push_debug(&value); + pub fn keyword(&mut self, key: &str, value: impl WriteRepr) -> &mut Self { + if !value.omit() { + self.separate(); + self.push_key(key); + value.write_repr(&mut self.buf); + } + self } - pub fn keyword_formatter(&mut self, key: &str, child: ReprFormatter) -> &mut Self { + pub fn keyword_debug(&mut self, key: &str, value: &impl fmt::Debug) -> &mut Self { self.separate(); self.push_key(key); - self.push_formatter(child); - self - } - - pub fn keyword_optional_debug(&mut self, key: &str, value: Option) -> &mut Self { - if let Some(value) = value { - self.keyword_debug(key, value); - } + write!(self.buf, "{value:?}").unwrap(); self } - pub fn keyword_optional_formatter(&mut self, key: &str, value: Option) -> &mut Self { - if let Some(child) = value { - self.keyword_formatter(key, child); - } - self + pub fn keyword_raw(&mut self, key: &str, value: &str) -> &mut Self { + self.keyword(key, Raw(value)) } - /// `key=True` when `value` is set. Omitted when it is clear. - pub fn keyword_if_true(&mut self, key: &str, value: bool) -> &mut Self { - if value { - self.separate(); - self.push_key(key); - self.buf.push_str("True"); - } - self + /// Write `key=True` only when `value` is set. + pub fn keyword_true(&mut self, key: &str, value: bool) -> &mut Self { + self.keyword(key, IfTrue(value)) } - /// `key=False` when `value` is clear. Omitted when it is set. - pub fn keyword_if_false(&mut self, key: &str, value: bool) -> &mut Self { - if !value { - self.separate(); - self.push_key(key); - self.buf.push_str("False"); - } - self + /// Write `key=False` only when `value` is clear. + pub fn keyword_false(&mut self, key: &str, value: bool) -> &mut Self { + self.keyword(key, IfFalse(value)) } - /// Write the closing `)` or `]`. Further calls leave the text unchanged. + /// Write the closing character. Further calls leave the text unchanged. pub fn close(&mut self) -> &mut Self { - if !self.closed { - self.buf.push(match self.closer { - Closer::Paren => ')', - Closer::Bracket => ']', - }); - self.closed = true; + if !self.is_closed { + self.buf.push(self.close_token); + self.is_closed = true; } self } @@ -144,16 +251,16 @@ impl ReprFormatter { self } - /// Append `.method(argument)` after the closer. `argument` is already closed. - pub fn append_call(&mut self, method: &str, argument: &ReprFormatter) -> &mut Self { - debug_assert!(argument.closed); + /// Append `.method(` after the closer and reopen for more arguments. + pub fn append_call(&mut self, method: &str) -> &mut Self { self.close(); + self.buf.push('.'); self.buf.push_str(method); self.buf.push('('); - self.buf.reserve(argument.buf.len()); - self.buf.push_str(&argument.buf); - self.buf.push(')'); + + self.is_closed = false; + self.is_separated = false; self } @@ -166,56 +273,102 @@ impl ReprFormatter { self.close(); self.buf } +} - fn separate(&mut self) { - debug_assert!(!self.closed); - if self.separated { - self.buf.push_str(", "); - } else { - self.separated = true; +/// A `[...]` or `(...)` opened inside a [`ReprFormatter`]. +/// +/// Writes into the parent buffer. Drop writes the closer and marks the parent +/// argument list as separated, so the next sibling gets a comma. +pub struct ReprGroup<'a> { + fmt: &'a mut ReprFormatter, + close_token: char, + finished: bool, +} + +impl ReprGroup<'_> { + fn finish(&mut self) { + if !self.finished { + self.fmt.buf.push(self.close_token); + self.fmt.is_separated = true; + self.finished = true; } } - fn push_key(&mut self, key: &str) { - self.buf.push_str(key); - self.buf.push('='); + pub fn positional(&mut self, value: impl WriteRepr) -> &mut Self { + self.fmt.positional(value); + self } - fn push_debug(&mut self, value: &impl std::fmt::Debug) { - use std::fmt::Write; - write!(self.buf, "{value:?}").unwrap(); + pub fn positional_debug(&mut self, value: &impl fmt::Debug) -> &mut Self { + self.fmt.positional_debug(value); + self } - fn push_formatter(&mut self, mut child: ReprFormatter) { - child.close(); - self.buf.reserve(child.buf.len()); - self.buf.push_str(&child.buf); + pub fn positional_raw(&mut self, value: &str) -> &mut Self { + self.fmt.positional_raw(value); + self } - fn group(&mut self, open: char, close: char, write_items: impl FnOnce(&mut Self) -> R) -> R { - self.separate(); - self.buf.push(open); - self.separated = false; - let result = write_items(self); - self.buf.push(close); - self.separated = true; - result + pub fn positional_list(&mut self) -> ReprGroup<'_> { + self.fmt.positional_list() + } + + pub fn positional_tuple(&mut self) -> ReprGroup<'_> { + self.fmt.positional_tuple() + } + + pub fn keyword(&mut self, key: &str, value: impl WriteRepr) -> &mut Self { + self.fmt.keyword(key, value); + self + } + + pub fn keyword_debug(&mut self, key: &str, value: &impl fmt::Debug) -> &mut Self { + self.fmt.keyword_debug(key, value); + self + } + + pub fn keyword_raw(&mut self, key: &str, value: &str) -> &mut Self { + self.fmt.keyword_raw(key, value); + self + } + + pub fn keyword_true(&mut self, key: &str, value: bool) -> &mut Self { + self.fmt.keyword_true(key, value); + self + } + + pub fn keyword_false(&mut self, key: &str, value: bool) -> &mut Self { + self.fmt.keyword_false(key, value); + self + } + + /// Write the closer now. A later drop is a no-op. + pub fn close(mut self) { + self.finish(); + } +} + +impl Drop for ReprGroup<'_> { + fn drop(&mut self) { + self.finish(); } } #[cfg(test)] mod tests { - use super::ReprFormatter; + use super::*; #[test] fn call_debug_escape_and_bools() { let mut fmt = ReprFormatter::new_call("LikeExpr"); - fmt.positional_debug("a\"b\\c"); - fmt.keyword_if_false("case_sensitive", false); - fmt.keyword_if_true("negated", true); - fmt.keyword_if_true("distinct", false); - fmt.keyword_optional_debug::<&str>("escape", None); - fmt.keyword_optional_debug("where", Some("LEADING")); + + fmt.positional("a\"b\\c") + .keyword_false("case_sensitive", false) + .keyword_true("negated", true) + .keyword_true("distinct", false) + .keyword("escape", None::<&str>) + .keyword("where", Some("LEADING")); + assert_eq!( fmt.into_string(), "LikeExpr(\"a\\\"b\\\\c\", case_sensitive=False, negated=True, where=\"LEADING\")" @@ -225,15 +378,16 @@ mod tests { #[test] fn list_embeds_formatter_and_tuple() { let mut ident = ReprFormatter::new_call("Ident"); - ident.positional_debug("id"); + ident.positional("id"); let mut fmt = ReprFormatter::new_list(); - fmt.positional_formatter(ident); - fmt.positional_tuple(|pair| { - pair.positional_debug("when"); - pair.positional_raw("Value(1, IntegerSerializer())"); - }); - fmt.keyword_optional_formatter("else_", None); + fmt.positional(ident); + fmt.positional_tuple() + .positional("when") + .positional_raw("Value(1, IntegerSerializer())"); + + fmt.keyword("else_", None::<&str>); + assert_eq!( fmt.into_string(), "[Ident(\"id\"), (\"when\", Value(1, IntegerSerializer()))]" @@ -243,13 +397,14 @@ mod tests { #[test] fn append_call_after_close() { let mut argument = ReprFormatter::new_call("Ident"); - argument.positional_debug("x"); - argument.close(); + argument.positional("x").close(); let mut fmt = ReprFormatter::new_call("Function"); - fmt.positional_debug("lag"); - fmt.append_suffix(".ignore_nulls()"); - fmt.append_call("filter", &argument); + fmt.positional("lag") + .append_suffix(".ignore_nulls()") + .append_call("filter") + .positional(argument); + assert_eq!( fmt.into_string(), "Function(\"lag\").ignore_nulls().filter(Ident(\"x\"))" diff --git a/src/sql.rs b/src/internal/sql.rs similarity index 98% rename from src/sql.rs rename to src/internal/sql.rs index a21e133..ab53ef7 100644 --- a/src/sql.rs +++ b/src/internal/sql.rs @@ -141,7 +141,7 @@ pub trait SqlWriter: std::fmt::Write { /// /// ``` /// pyo3::Python::attach(|py| { -/// let buf = rapidquery::sql::BufferedSql::new(py); +/// let buf = rapidquery::internal::sql::BufferedSql::new(py); /// assert_eq!(buf.as_str(), ""); /// }); /// ``` @@ -230,7 +230,7 @@ impl<'py> std::fmt::Write for BufferedSql<'py> { /// /// ``` /// pyo3::Python::attach(|py| { -/// let sql = rapidquery::sql::ParameterizedSql::new(py); +/// let sql = rapidquery::internal::sql::ParameterizedSql::new(py); /// assert_eq!(sql.sql(), ""); /// assert!(sql.params().is_empty()); /// }); diff --git a/src/lib.rs b/src/lib.rs index a6f55ef..25c4888 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,6 @@ pub mod dialect; pub mod expr; pub mod internal; pub mod serializers; -pub mod sql; pub mod typeref; // Later tasks: diff --git a/src/serializers/mod.rs b/src/serializers/mod.rs index d83fbf4..709e4d8 100644 --- a/src/serializers/mod.rs +++ b/src/serializers/mod.rs @@ -6,13 +6,15 @@ mod inner; pub use inner::SerializerType; use crate::internal::alias; -use crate::internal::repr::ReprFormatter; +use crate::internal::repr; + +pub fn serializer_repr(serializer: &SerializerType) -> repr::ReprFormatter { + let mut fmt = repr::ReprFormatter::new_call(serializer_type_to_pyname(serializer)); -pub fn serializer_repr(serializer: &SerializerType) -> ReprFormatter { - let mut fmt = ReprFormatter::new_call(serializer_type_to_pyname(serializer)); if let SerializerType::Array(element) = serializer { - fmt.positional_formatter(serializer_repr(element)); + fmt.positional(serializer_repr(element)); } + fmt.close(); fmt } From a40146504ea43d81bb81424ee8629ce097297a14 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Fri, 9 Oct 2026 12:31:12 +0330 Subject: [PATCH 76/87] Add CONTRIBUTING.md and SECURITY.md --- CONTRIBUTING.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++++ SECURITY.md | 15 +++++++++ 2 files changed, 102 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..13b5f14 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,87 @@ +# How to contribute to RapidQuery + +## Issues + +- Did you find a bug? +- Do you have any questions? +- Do you have any feature requests? + +These are all welcome as [issues](https://github.com/pydantic/pydantic/issues/new). + +> For reporting a security vulnerability, please see [Security Policy](./SECURITY.md). + +If you're going to report a bug, please: + +- Include RapidQuery version you're working with. +- Include a minimal example if possible. +- Include a reference if the bug is about SQL syntax. + +## Pull Requests + +It should be extremely simple to get started and create a PR. All PRs are welcome. + +To make contributing as easy and fast as possible, you'll want to run tests and linting locally. + +> Use `make format` to fix formatting, and `make` to run tests and linting. + +## AI Policy + +We welcome the use of AI when contributing to RapidQuery. + +But, you should certify that they fully understand the code being submitted. We reserve the right to close any pull request at our discretion, without further justification. This includes, but is not limited to, cases where: + +- the contribution does not meet our quality standards, +- the author appears to be mass-submitting pull requests across multiple repositories (spam), +- the pull request description is AI generated and incoherent or nonsensical. + +## Installation & Setup + +You'll need the following prerequisites: + +- Any Python version between Python 3.10 and 3.15 +- uv +- git - For version control +- make - For running development commands (or use nmake on Windows) +- Rust - Both stable and nightly (nightly is used for formatting) + +Fork the repository on GitHub and clone your fork locally. + +```bash +# Clone your fork and cd into the repo directory +git clone git@github.com:/rapidquery.git +cd rapidquery + +# Install UV +# for other options see: +# https://docs.astral.sh/uv/getting-started/installation/ +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Setup +make install +``` + +### Check out a new branch and make your changes + +Create a new branch for your changes. + +```bash +git switch -c my-new-feature-branch +``` + +### Run tests and linting + +Run tests and linting locally to make sure everything is working as expected. + +```bash +# Run automated code formatting and linting +make format + +# Run tests and linting +make +``` + +### Commit and push your changes + +Commit your changes, push your branch to GitHub, and create a drafted pull request. + +When your pull request is ready for review, add a comment with the message "please review" and we'll take a look as soon as we can. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..570f1ee --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security Policy + +## Reporting a Vulnerability + +**DO NOT OPEN A PUBLIC ISSUE** if you think you have found a security issue with RapidQuery. + +Instead, navigate to "Security & quality" tab for the repository and use "Report a vulnerability" button. + +Be sure to: + +- Describe the issue clearly and +- include as much detail as necessary in your report. +- Also a minimal example will help to find the issue faster. + +Thank you for helping. From 71228b836498f8d9b04079d7b83a54f9a570485f Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Sat, 10 Oct 2026 18:54:13 +0330 Subject: [PATCH 77/87] Complete PyExpr methods, such as `__add__`. Drop support for `Value.__hash__` and `Ident.__hash__` --- rapidquery/expr.pyi | 250 ++++++++++++++++- src/expr/base.rs | 563 ++++++++++++++++++++++++++++++++++++-- src/expr/ident.rs | 27 -- src/expr/nodes.rs | 270 ++++++++++++------ src/expr/value.rs | 31 --- src/internal/utils/mod.rs | 1 + src/lib.rs | 1 - tests/test_expr.py | 336 ++++++++++++++++++----- tests/test_ident.py | 18 -- tests/test_value.py | 22 -- 10 files changed, 1224 insertions(+), 295 deletions(-) diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index 2b11c77..cb30da6 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -15,8 +15,8 @@ class Expr: Examples: >>> from rapidquery.dialect import Sqlite - >>> from rapidquery.expr import BinaryExpr, Ident - >>> BinaryExpr(Ident("age"), ">", 18).to_sql(Sqlite) + >>> from rapidquery.expr import Ident + >>> (Ident("age") > 18).to_sql(Sqlite) '"age" > 18' """ @@ -67,6 +67,245 @@ class Expr: ('$1', (1,)) """ + def __add__(self, other: object, /) -> BinaryExpr: + """SQL ``self + other``.""" + + def __radd__(self, other: object, /) -> BinaryExpr: + """SQL ``other + self``, when the left operand is not an expression.""" + + def __sub__(self, other: object, /) -> BinaryExpr: + """SQL ``self - other``.""" + + def __rsub__(self, other: object, /) -> BinaryExpr: + """SQL ``other - self``, when the left operand is not an expression.""" + + def __mul__(self, other: object, /) -> BinaryExpr: + """SQL ``self * other``.""" + + def __rmul__(self, other: object, /) -> BinaryExpr: + """SQL ``other * self``, when the left operand is not an expression.""" + + def __truediv__(self, other: object, /) -> BinaryExpr: + """SQL ``self / other``.""" + + def __rtruediv__(self, other: object, /) -> BinaryExpr: + """SQL ``other / self``, when the left operand is not an expression.""" + + def __mod__(self, other: object, /) -> BinaryExpr: + """SQL ``self % other``.""" + + def __rmod__(self, other: object, /) -> BinaryExpr: + """SQL ``other % self``, when the left operand is not an expression.""" + + def __eq__(self, other: object, /) -> BinaryExpr: # pyright: ignore[reportIncompatibleMethodOverride] + """SQL ``self = other``.""" + + def __ne__(self, other: object, /) -> BinaryExpr: # pyright: ignore[reportIncompatibleMethodOverride] + """SQL ``self <> other``.""" + + def __lt__(self, other: object, /) -> BinaryExpr: + """SQL ``self < other``.""" + + def __le__(self, other: object, /) -> BinaryExpr: + """SQL ``self <= other``.""" + + def __gt__(self, other: object, /) -> BinaryExpr: + """SQL ``self > other``.""" + + def __ge__(self, other: object, /) -> BinaryExpr: + """SQL ``self >= other``.""" + + def __and__(self, other: object, /) -> BinaryExpr: + """SQL ``self AND other``. Python ``&`` is logical AND, not bitwise.""" + + def __rand__(self, other: object, /) -> BinaryExpr: + """SQL ``other AND self``. Python ``&`` is logical AND, not bitwise.""" + + def __or__(self, other: object, /) -> BinaryExpr: + """SQL ``self OR other``. Python ``|`` is logical OR, not bitwise.""" + + def __ror__(self, other: object, /) -> BinaryExpr: + """SQL ``other OR self``. Python ``|`` is logical OR, not bitwise.""" + + def bit_and(self, other: object, /) -> BinaryExpr: + """SQL bitwise ``self & other``.""" + + def bit_or(self, other: object, /) -> BinaryExpr: + """SQL bitwise ``self | other``.""" + + def bit_xor(self, other: object, /) -> BinaryExpr: + """SQL bitwise ``self ^ other``.""" + + def __lshift__(self, other: object, /) -> BinaryExpr: + """SQL ``self << other``.""" + + def __rlshift__(self, other: object, /) -> BinaryExpr: + """SQL ``other << self``, when the left operand is not an expression.""" + + def __rshift__(self, other: object, /) -> BinaryExpr: + """SQL ``self >> other``.""" + + def __rrshift__(self, other: object, /) -> BinaryExpr: + """SQL ``other >> self``, when the left operand is not an expression.""" + + def concat(self, other: object, /) -> BinaryExpr: + """SQL ``self || other``. Dialects may rewrite ``||`` as a function or as ``+``.""" + + def __neg__(self) -> UnaryExpr: + """SQL ``-self``.""" + + def __pos__(self) -> UnaryExpr: + """SQL ``+self``.""" + + def not_(self) -> UnaryExpr: + """SQL ``NOT self``.""" + + def bit_not(self) -> UnaryExpr: + """SQL ``~self``. This is not bound to Python ``~``.""" + + def between(self, low: object, high: object, /) -> BetweenExpr: + """SQL ``self BETWEEN low AND high``.""" + + def not_between(self, low: object, high: object, /) -> BetweenExpr: + """SQL ``self NOT BETWEEN low AND high``.""" + + def is_(self, kind: None | bool | str, /) -> IsExpr: + """ + SQL ``self IS kind``. + + ``None`` is ``IS NULL``. ``True`` and ``False`` are ``IS TRUE`` and ``IS FALSE``. + A string is ``NULL``, ``TRUE``, ``FALSE``, or ``UNKNOWN``. + + Raises: + TypeError: ``kind`` is not ``None``, ``bool``, or ``str``. + ValueError: ``kind`` is a string and not one of those four words. + """ + + def is_not(self, kind: None | bool | str, /) -> IsExpr: + """ + SQL ``self IS NOT kind``. + + ``None`` is ``IS NOT NULL``. ``True`` and ``False`` are ``IS NOT TRUE`` and + ``IS NOT FALSE``. A string is ``NULL``, ``TRUE``, ``FALSE``, or ``UNKNOWN``. + + Raises: + TypeError: ``kind`` is not ``None``, ``bool``, or ``str``. + ValueError: ``kind`` is a string and not one of those four words. + """ + + def is_distinct_from(self, other: object, /) -> IsDistinctFromExpr: + """SQL ``self IS DISTINCT FROM other``.""" + + def is_not_distinct_from(self, other: object, /) -> IsDistinctFromExpr: + """SQL ``self IS NOT DISTINCT FROM other``.""" + + def in_(self, list: Iterable[object], /) -> InListExpr: + """SQL ``self IN (list)``.""" + + def not_in(self, list: Iterable[object], /) -> InListExpr: + """SQL ``self NOT IN (list)``.""" + + def like(self, pattern: object, /, *, escape: str | None = None) -> LikeExpr: + """ + SQL ``self LIKE pattern``. + + A list or tuple of patterns is ``LIKE ANY``. + + Args: + pattern: One pattern, or a list or tuple for ``ANY``. + escape: One escape character, or ``None``. + + Raises: + TypeError: ``escape`` is not one character. + """ + + def not_like(self, pattern: object, /, *, escape: str | None = None) -> LikeExpr: + """ + SQL ``self NOT LIKE pattern``. + + A list or tuple of patterns is ``NOT LIKE ANY``. + + Args: + pattern: One pattern, or a list or tuple for ``ANY``. + escape: One escape character, or ``None``. + + Raises: + TypeError: ``escape`` is not one character. + """ + + def ilike(self, pattern: object, /, *, escape: str | None = None) -> LikeExpr: + """ + SQL ``self ILIKE pattern``. + + A list or tuple of patterns is ``ILIKE ANY``. + + Args: + pattern: One pattern, or a list or tuple for ``ANY``. + escape: One escape character, or ``None``. + + Raises: + TypeError: ``escape`` is not one character. + """ + + def not_ilike(self, pattern: object, /, *, escape: str | None = None) -> LikeExpr: + """ + SQL ``self NOT ILIKE pattern``. + + A list or tuple of patterns is ``NOT ILIKE ANY``. + + Args: + pattern: One pattern, or a list or tuple for ``ANY``. + escape: One escape character, or ``None``. + + Raises: + TypeError: ``escape`` is not one character. + """ + + def similar_to(self, pattern: object, /) -> SimilarToExpr: + """SQL ``self SIMILAR TO pattern``.""" + + def not_similar_to(self, pattern: object, /) -> SimilarToExpr: + """SQL ``self NOT SIMILAR TO pattern``.""" + + def rlike(self, pattern: object, /) -> RLikeExpr: + """SQL regex match of ``self`` against ``pattern``.""" + + def not_rlike(self, pattern: object, /) -> RLikeExpr: + """Negated SQL regex match of ``self`` against ``pattern``.""" + + def cast_as(self, data_type_name: str, /) -> CastExpr: + """SQL ``CAST(self AS data_type_name)``.""" + + def try_cast_as(self, data_type_name: str, /) -> CastExpr: + """SQL ``TRY_CAST(self AS data_type_name)``.""" + + def collate( + self, + collation: str | Unquote | Ident | Asterisk | type[Asterisk], + /, + ) -> CollateExpr: + """SQL ``self COLLATE collation``. The collation is an identifier.""" + + def trim( + self, + *, + where: typing.Literal["BOTH", "LEADING", "TRAILING"] | str = "BOTH", + characters: object | None = None, + ) -> TrimExpr: + """ + SQL ``TRIM``, ``LTRIM``, or ``RTRIM`` of this expression. + + Args: + where: ``BOTH``, ``LEADING``, or ``TRAILING``. + characters: Characters to strip, or ``None`` for spaces. + + Raises: + ValueError: ``where`` is not one of those three words. + """ + + def __repr__(self) -> str: + """Constructor-style text, such as ``Ident("id")``.""" + @final class Unquote: """ @@ -165,9 +404,6 @@ class Ident(Expr): IndexError: `index` is outside the parts. """ - def __hash__(self) -> int: ... - def __eq__(self, value: object, /) -> bool: ... - def __ne__(self, value: object, /) -> bool: ... def to_list(self) -> list[str | Unquote | Asterisk]: """ Copy the parts into a list. @@ -220,10 +456,6 @@ class Value(Expr, typing.Generic[_T]): def get_serializer(self) -> Serializer[_T]: """Serializer used by `to_sql`. `prepare` does not call it.""" - def __hash__(self) -> int: ... - def __eq__(self, value: object, /) -> bool: ... - def __ne__(self, value: object, /) -> bool: ... - @final class Function(Expr): """ diff --git a/src/expr/base.rs b/src/expr/base.rs index 93e3a29..332d070 100644 --- a/src/expr/base.rs +++ b/src/expr/base.rs @@ -2,22 +2,17 @@ //! //! [`PyExpr`] holds [`ExprInner`]. Concrete nodes (`Ident`, `Binary`, …) are //! empty frozen subclasses. Shared methods live on the base. -//! -// TODO: `to_sql` / `prepare`; `eq` / `ne` / `lt` / `le` / `gt` / `ge`; `add` / `sub` / `mul` / `div` / `mod`; -// `and_` / `or_` / `xor` / `not_`; `bit_and` / `bit_or`; `like` / `ilike` / -// `between` / `in_` / `is_null` / `is_distinct_from` / `cast`; `case`; -// `custom`. Prefer named methods over v0 dunders that return `Expr`. use crate::internal::alias; use crate::internal::sql; use crate::internal::utils; -use super::enum_::ExprInner; +use super::enum_; /// Base of every SQL expression. Construct a subclass, not this class. #[rapidquery_macros::pyclass("rapidquery.expr.Expr", subclass, skip_from_py_object)] #[derive(Debug, Clone)] -pub struct PyExpr(pub ExprInner); +pub struct PyExpr(pub enum_::ExprInner); #[inline(always)] fn wrap<'py, T>(py: pyo3::Python<'py>, base: PyExpr) -> pyo3::PyResult> @@ -30,8 +25,11 @@ where /// Wrap `expr` as the matching Python subclass. /// /// Shorthand for `PyExpr(expr).into_subclasses(py)` -#[inline] -pub(super) fn into_subclasses<'py>(py: pyo3::Python<'py>, expr: ExprInner) -> pyo3::PyResult> { +#[inline(always)] +pub(super) fn into_subclasses<'py>( + py: pyo3::Python<'py>, + expr: enum_::ExprInner, +) -> pyo3::PyResult> { PyExpr(expr).into_subclasses(py) } @@ -40,30 +38,541 @@ impl PyExpr { #[inline] pub fn into_subclasses<'py>(self, py: pyo3::Python<'py>) -> pyo3::PyResult> { match &self.0 { - ExprInner::Ident(_) => wrap::(py, self), - ExprInner::Value(_) => wrap::(py, self), - ExprInner::Tuple(_) => wrap::(py, self), - ExprInner::Unary(_) => wrap::(py, self), - ExprInner::Binary(_) => wrap::(py, self), - ExprInner::Function(_) => wrap::(py, self), - ExprInner::Is(_) => wrap::(py, self), - ExprInner::IsDistinctFrom(_) => wrap::(py, self), - ExprInner::InList(_) => wrap::(py, self), - ExprInner::Between(_) => wrap::(py, self), - ExprInner::Like(_) => wrap::(py, self), - ExprInner::SimilarTo(_) => wrap::(py, self), - ExprInner::RLike(_) => wrap::(py, self), - ExprInner::Cast(_) => wrap::(py, self), - ExprInner::Trim(_) => wrap::(py, self), - ExprInner::Collate(_) => wrap::(py, self), - ExprInner::Case(_) => wrap::(py, self), - ExprInner::Custom(_) => wrap::(py, self), + enum_::ExprInner::Ident(_) => wrap::(py, self), + enum_::ExprInner::Value(_) => wrap::(py, self), + enum_::ExprInner::Tuple(_) => wrap::(py, self), + enum_::ExprInner::Unary(_) => wrap::(py, self), + enum_::ExprInner::Binary(_) => wrap::(py, self), + enum_::ExprInner::Function(_) => wrap::(py, self), + enum_::ExprInner::Is(_) => wrap::(py, self), + enum_::ExprInner::IsDistinctFrom(_) => wrap::(py, self), + enum_::ExprInner::InList(_) => wrap::(py, self), + enum_::ExprInner::Between(_) => wrap::(py, self), + enum_::ExprInner::Like(_) => wrap::(py, self), + enum_::ExprInner::SimilarTo(_) => wrap::(py, self), + enum_::ExprInner::RLike(_) => wrap::(py, self), + enum_::ExprInner::Cast(_) => wrap::(py, self), + enum_::ExprInner::Trim(_) => wrap::(py, self), + enum_::ExprInner::Collate(_) => wrap::(py, self), + enum_::ExprInner::Case(_) => wrap::(py, self), + enum_::ExprInner::Custom(_) => wrap::(py, self), + } + } +} + +/// `None`, exact `bool`, or exact `str` → an `IS` expression. +fn is_from_kind<'py>(expr: enum_::ExprInner, kind: &alias::BoundObject<'py>, negated: bool) -> pyo3::PyResult { + let ptr = kind.as_ptr(); + + // SAFETY: `ptr` is a live borrowed object and this runs with the GIL held. + // The checks only read the type tag. The string borrow lasts until `new_base` returns. + unsafe { + if pyo3::ffi::Py_IsNone(ptr) == 1 { + return super::nodes::PyIsExpr::new_base(expr, "NULL", negated); + } + + if pyo3::ffi::PyBool_Check(ptr) == 1 { + let name = if ptr == pyo3::ffi::Py_True() { "TRUE" } else { "FALSE" }; + return super::nodes::PyIsExpr::new_base(expr, name, negated); + } + + if pyo3::ffi::PyUnicode_CheckExact(ptr) == 1 { + let text = utils::borrow_py_str_buffer(kind.py(), ptr)?; + return super::nodes::PyIsExpr::new_base(expr, text, negated); } + + rapidquery_macros::pyerr!( + Err(PyTypeError), + "expected None, bool, or str, got {}", + utils::get_type_name(kind.py(), ptr) + ) } } #[pyo3::pymethods] impl PyExpr { + // ===== Binary Expressions ===== + + /// e.g. `a + b` + fn __add__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(self.0.clone(), enum_::BinaryOp::Add, other.0).into_subclasses(py) + } + + /// e.g. `a - b` + fn __sub__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(self.0.clone(), enum_::BinaryOp::Sub, other.0).into_subclasses(py) + } + + /// e.g. `a * b` + fn __mul__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(self.0.clone(), enum_::BinaryOp::Mul, other.0).into_subclasses(py) + } + + /// e.g. `a / b` + fn __truediv__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(self.0.clone(), enum_::BinaryOp::Div, other.0).into_subclasses(py) + } + + /// e.g. `a % b` + fn __mod__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(self.0.clone(), enum_::BinaryOp::Mod, other.0).into_subclasses(py) + } + + /// e.g. `a = b` + fn __eq__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(self.0.clone(), enum_::BinaryOp::Eq, other.0).into_subclasses(py) + } + + /// e.g. `a <> b` + fn __ne__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(self.0.clone(), enum_::BinaryOp::NotEq, other.0).into_subclasses(py) + } + + /// e.g. `a < b` + fn __lt__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(self.0.clone(), enum_::BinaryOp::Lt, other.0).into_subclasses(py) + } + + /// e.g. `a <= b` + fn __le__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(self.0.clone(), enum_::BinaryOp::LtEq, other.0).into_subclasses(py) + } + + /// e.g. `a > b` + fn __gt__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(self.0.clone(), enum_::BinaryOp::Gt, other.0).into_subclasses(py) + } + + /// e.g. `a >= b` + fn __ge__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(self.0.clone(), enum_::BinaryOp::GtEq, other.0).into_subclasses(py) + } + + /// e.g. `a AND b` + fn __and__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(self.0.clone(), enum_::BinaryOp::And, other.0).into_subclasses(py) + } + + /// e.g. `a OR b` + fn __or__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(self.0.clone(), enum_::BinaryOp::Or, other.0).into_subclasses(py) + } + + /// e.g. `a & b` + fn bit_and<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(self.0.clone(), enum_::BinaryOp::BitAnd, other.0).into_subclasses(py) + } + + /// e.g. `a | b` + fn bit_or<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(self.0.clone(), enum_::BinaryOp::BitOr, other.0).into_subclasses(py) + } + + /// e.g. `a ^ b` + fn bit_xor<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(self.0.clone(), enum_::BinaryOp::BitXor, other.0).into_subclasses(py) + } + + /// e.g. `a << b` + fn __lshift__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(self.0.clone(), enum_::BinaryOp::LShift, other.0).into_subclasses(py) + } + + /// e.g. `a >> b` + fn __rshift__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(self.0.clone(), enum_::BinaryOp::RShift, other.0).into_subclasses(py) + } + + /// e.g. `a || b` + fn concat<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(self.0.clone(), enum_::BinaryOp::StringConcat, other.0).into_subclasses(py) + } + + /// e.g. `b + a` + fn __radd__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(other.0, enum_::BinaryOp::Add, self.0.clone()).into_subclasses(py) + } + + /// e.g. `b - a` + fn __rsub__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(other.0, enum_::BinaryOp::Sub, self.0.clone()).into_subclasses(py) + } + + /// e.g. `b * a` + fn __rmul__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(other.0, enum_::BinaryOp::Mul, self.0.clone()).into_subclasses(py) + } + + /// e.g. `b / a` + fn __rtruediv__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(other.0, enum_::BinaryOp::Div, self.0.clone()).into_subclasses(py) + } + + /// e.g. `b % a` + fn __rmod__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(other.0, enum_::BinaryOp::Mod, self.0.clone()).into_subclasses(py) + } + + /// e.g. `b AND a` + fn __rand__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(other.0, enum_::BinaryOp::And, self.0.clone()).into_subclasses(py) + } + + /// e.g. `b OR a` + fn __ror__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(other.0, enum_::BinaryOp::Or, self.0.clone()).into_subclasses(py) + } + + /// e.g. `b << a` + fn __rlshift__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(other.0, enum_::BinaryOp::LShift, self.0.clone()).into_subclasses(py) + } + + /// e.g. `b >> a` + fn __rrshift__<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBinaryExpr::new_base(other.0, enum_::BinaryOp::RShift, self.0.clone()).into_subclasses(py) + } + + // ===== Unary ===== + + /// e.g. `-a` + fn __neg__<'py>(&self, py: pyo3::Python<'py>) -> pyo3::PyResult> { + super::nodes::PyUnaryExpr::new_base(enum_::UnaryOp::Minus, self.0.clone()).into_subclasses(py) + } + + /// e.g. `+a` + fn __pos__<'py>(&self, py: pyo3::Python<'py>) -> pyo3::PyResult> { + super::nodes::PyUnaryExpr::new_base(enum_::UnaryOp::Plus, self.0.clone()).into_subclasses(py) + } + + /// e.g. `NOT a` + fn not_<'py>(&self, py: pyo3::Python<'py>) -> pyo3::PyResult> { + super::nodes::PyUnaryExpr::new_base(enum_::UnaryOp::Not, self.0.clone()).into_subclasses(py) + } + + /// e.g. `~a` + fn bit_not<'py>(&self, py: pyo3::Python<'py>) -> pyo3::PyResult> { + super::nodes::PyUnaryExpr::new_base(enum_::UnaryOp::BitNot, self.0.clone()).into_subclasses(py) + } + + // ===== Between ====== + + /// e.g. `a BETWEEN b AND c` + fn between<'py>( + &self, + py: pyo3::Python<'py>, + low: super::extractors::LiteralExprExtractor, + high: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBetweenExpr::new_base(self.0.clone(), low.0, high.0, false).into_subclasses(py) + } + + /// e.g. `a NOT BETWEEN b AND c` + fn not_between<'py>( + &self, + py: pyo3::Python<'py>, + low: super::extractors::LiteralExprExtractor, + high: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyBetweenExpr::new_base(self.0.clone(), low.0, high.0, true).into_subclasses(py) + } + + // ===== Is ===== + + /// e.g. `a IS [kind]` + fn is_<'py>( + &self, + py: pyo3::Python<'py>, + kind: alias::BoundObject<'py>, + ) -> pyo3::PyResult> { + is_from_kind(self.0.clone(), &kind, false).and_then(|x| x.into_subclasses(py)) + } + + /// e.g. `a IS NOT [kind]` + fn is_not<'py>( + &self, + py: pyo3::Python<'py>, + kind: alias::BoundObject<'py>, + ) -> pyo3::PyResult> { + is_from_kind(self.0.clone(), &kind, true).and_then(|x| x.into_subclasses(py)) + } + + // ===== IsDistinctFrom ===== + + /// e.g. `a IS DISTINCT FROM b` + fn is_distinct_from<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyIsDistinctFromExpr::new_base(self.0.clone(), other.0, false).into_subclasses(py) + } + + /// e.g. `a IS NOT DISTINCT FROM b` + fn is_not_distinct_from<'py>( + &self, + py: pyo3::Python<'py>, + other: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyIsDistinctFromExpr::new_base(self.0.clone(), other.0, true).into_subclasses(py) + } + + // ===== InList ===== + + /// e.g. `a IN (b, c, ...)` + fn in_<'py>(&self, list: alias::BoundObject<'py>) -> pyo3::PyResult> { + let py = list.py(); + + super::nodes::PyInListExpr::new_base(self.0.clone(), list, false).and_then(|x| x.into_subclasses(py)) + } + + /// e.g. `a NOT IN (b, c, ...)` + fn not_in<'py>(&self, list: alias::BoundObject<'py>) -> pyo3::PyResult> { + let py = list.py(); + + super::nodes::PyInListExpr::new_base(self.0.clone(), list, true).and_then(|x| x.into_subclasses(py)) + } + + // // ===== Like ===== + + /// e.g. `a LIKE 'a%'` + #[pyo3(signature = (pattern, /, *, escape = None))] + fn like<'py>( + &self, + pattern: alias::BoundObject<'py>, + escape: Option, + ) -> pyo3::PyResult> { + let py = pattern.py(); + + super::nodes::PyLikeExpr::new_base(self.0.clone(), pattern, false, true, escape) + .and_then(|x| x.into_subclasses(py)) + } + + /// e.g. `a NOT LIKE 'a%'` + #[pyo3(signature = (pattern, /, *, escape = None))] + fn not_like<'py>( + &self, + pattern: alias::BoundObject<'py>, + escape: Option, + ) -> pyo3::PyResult> { + let py = pattern.py(); + + super::nodes::PyLikeExpr::new_base(self.0.clone(), pattern, true, true, escape) + .and_then(|x| x.into_subclasses(py)) + } + + /// e.g. `a ILIKE 'a%'` + #[pyo3(signature = (pattern, /, *, escape = None))] + fn ilike<'py>( + &self, + pattern: alias::BoundObject<'py>, + escape: Option, + ) -> pyo3::PyResult> { + let py = pattern.py(); + + super::nodes::PyLikeExpr::new_base(self.0.clone(), pattern, false, false, escape) + .and_then(|x| x.into_subclasses(py)) + } + + /// e.g. `a NOT ILIKE 'a%'` + #[pyo3(signature = (pattern, /, *, escape = None))] + fn not_ilike<'py>( + &self, + pattern: alias::BoundObject<'py>, + escape: Option, + ) -> pyo3::PyResult> { + let py = pattern.py(); + + super::nodes::PyLikeExpr::new_base(self.0.clone(), pattern, true, false, escape) + .and_then(|x| x.into_subclasses(py)) + } + + // ===== SimilarTo / RLike ===== + + /// e.g. `a SIMILAR TO b` + fn similar_to<'py>( + &self, + py: pyo3::Python<'py>, + pattern: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PySimilarToExpr::new_base(self.0.clone(), pattern.0, false).into_subclasses(py) + } + + /// e.g. `a NOT SIMILAR TO b` + fn not_similar_to<'py>( + &self, + py: pyo3::Python<'py>, + pattern: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PySimilarToExpr::new_base(self.0.clone(), pattern.0, true).into_subclasses(py) + } + + /// e.g. `a RLIKE b` + fn rlike<'py>( + &self, + py: pyo3::Python<'py>, + pattern: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyRLikeExpr::new_base(self.0.clone(), pattern.0, false).into_subclasses(py) + } + + /// e.g. `a NOT RLIKE b` + fn not_rlike<'py>( + &self, + py: pyo3::Python<'py>, + pattern: super::extractors::LiteralExprExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyRLikeExpr::new_base(self.0.clone(), pattern.0, true).into_subclasses(py) + } + + // ===== Cast ===== + + /// e.g. `CAST(a AS int)` + fn cast_as<'py>(&self, py: pyo3::Python<'py>, data_type_name: &str) -> pyo3::PyResult> { + super::nodes::PyCastExpr::new_base(self.0.clone(), data_type_name, "CAST").and_then(|x| x.into_subclasses(py)) + } + + /// e.g. `TRY_CAST(a AS int)` + fn try_cast_as<'py>(&self, py: pyo3::Python<'py>, data_type_name: &str) -> pyo3::PyResult> { + super::nodes::PyCastExpr::new_base(self.0.clone(), data_type_name, "TRY_CAST") + .and_then(|x| x.into_subclasses(py)) + } + + // ===== Collate / Trim ===== + + /// e.g. `a COLLATE coll` + fn collate<'py>( + &self, + py: pyo3::Python<'py>, + collation: super::extractors::IdentExtractor, + ) -> pyo3::PyResult> { + super::nodes::PyCollateExpr::new_base(self.0.clone(), collation.0).into_subclasses(py) + } + + /// e.g. `TRIM(a)` + #[pyo3(signature = (*, r#where = "BOTH", characters = None))] + fn trim<'py>( + &self, + py: pyo3::Python<'py>, + r#where: &str, + characters: Option, + ) -> pyo3::PyResult> { + super::nodes::PyTrimExpr::new_base(self.0.clone(), r#where, characters.map(|x| x.0)) + .and_then(|x| x.into_subclasses(py)) + } + /// Render this expression as a parameterized statement. fn prepare<'py>( slf: pyo3::PyRef<'py, Self>, diff --git a/src/expr/ident.rs b/src/expr/ident.rs index d9f7dd6..8131cd6 100644 --- a/src/expr/ident.rs +++ b/src/expr/ident.rs @@ -99,33 +99,6 @@ impl PyIdent { .map(|part| ident_part_to_pyobject(py, part).map(|bound| bound.unbind())) .collect() } - - fn __hash__(slf: pyo3::PyRef<'_, Self>) -> u64 { - use std::hash::DefaultHasher; - use std::hash::Hash; - use std::hash::Hasher; - - let mut state = DefaultHasher::default(); - let inner = Self::inner(&slf); - inner.hash(&mut state); - state.finish() - } - - fn __eq__(slf: pyo3::PyRef<'_, Self>, other: pyo3::PyRef<'_, Self>) -> bool { - if slf.as_ptr() == other.as_ptr() { - return true; - } - - Self::inner(&slf).eq(Self::inner(&other)) - } - - fn __ne__(slf: pyo3::PyRef<'_, Self>, other: pyo3::PyRef<'_, Self>) -> bool { - if slf.as_ptr() == other.as_ptr() { - return true; - } - - Self::inner(&slf).ne(Self::inner(&other)) - } } /// Identifier body written with no quote characters. diff --git a/src/expr/nodes.rs b/src/expr/nodes.rs index e97af78..95b71db 100644 --- a/src/expr/nodes.rs +++ b/src/expr/nodes.rs @@ -118,6 +118,16 @@ impl PyTupleExpr { } } +impl PyUnaryExpr { + pub(super) fn new_base(op: super::enum_::UnaryOp, expr: ExprInner) -> PyExpr { + let inner = super::enum_::Unary { + op, + expr: Box::new(expr), + }; + PyExpr(inner.into()) + } +} + #[pyo3::pymethods] impl PyUnaryExpr { #[new] @@ -127,11 +137,8 @@ impl PyUnaryExpr { return rapidquery_macros::pyerr!(Err(PyValueError), "unknown unary operator {:?}", operator); }; - let inner = super::enum_::Unary { - op, - expr: Box::new(expr.0), - }; - Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) + let base = Self::new_base(op, expr.0); + Ok(pyo3::PyClassInitializer::from(base).add_subclass(Self)) } fn get_operator(slf: pyo3::PyRef<'_, Self>) -> &'static str { @@ -145,6 +152,17 @@ impl PyUnaryExpr { } } +impl PyBinaryExpr { + pub(super) fn new_base(left: ExprInner, op: super::enum_::BinaryOp, right: ExprInner) -> PyExpr { + let inner = super::enum_::Binary { + left: Box::new(left), + op, + right: Box::new(right), + }; + PyExpr(inner.into()) + } +} + #[pyo3::pymethods] impl PyBinaryExpr { #[new] @@ -154,12 +172,8 @@ impl PyBinaryExpr { operator: &str, right: LiteralExprExtractor, ) -> pyo3::PyClassInitializer { - let inner = super::enum_::Binary { - left: Box::new(left.0), - op: super::enum_::BinaryOp::parse(operator), - right: Box::new(right.0), - }; - pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self) + let base = Self::new_base(left.0, super::enum_::BinaryOp::parse(operator), right.0); + pyo3::PyClassInitializer::from(base).add_subclass(Self) } fn get_left(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { @@ -178,15 +192,8 @@ impl PyBinaryExpr { } } -#[pyo3::pymethods] impl PyIsExpr { - #[new] - #[pyo3(signature = (expr, kind, /, *, negated = false))] - fn __new__( - expr: LiteralExprExtractor, - kind: &str, - negated: bool, - ) -> pyo3::PyResult> { + pub(super) fn new_base(expr: ExprInner, kind: &str, negated: bool) -> pyo3::PyResult { let Some(kind) = super::enum_::IsKind::parse(kind) else { return rapidquery_macros::pyerr!( Err(PyValueError), @@ -196,11 +203,25 @@ impl PyIsExpr { }; let inner = super::enum_::Is { - expr: Box::new(expr.0), + expr: Box::new(expr), negated, kind, }; - Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) + Ok(PyExpr(inner.into())) + } +} + +#[pyo3::pymethods] +impl PyIsExpr { + #[new] + #[pyo3(signature = (expr, kind, /, *, negated = false))] + fn __new__( + expr: LiteralExprExtractor, + kind: &str, + negated: bool, + ) -> pyo3::PyResult> { + let base = Self::new_base(expr.0, kind, negated)?; + Ok(pyo3::PyClassInitializer::from(base).add_subclass(Self)) } fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { @@ -219,6 +240,17 @@ impl PyIsExpr { } } +impl PyIsDistinctFromExpr { + pub(super) fn new_base(left: ExprInner, right: ExprInner, negated: bool) -> PyExpr { + let inner = super::enum_::IsDistinctFrom { + left: Box::new(left), + right: Box::new(right), + negated, + }; + PyExpr(inner.into()) + } +} + #[pyo3::pymethods] impl PyIsDistinctFromExpr { #[new] @@ -228,12 +260,8 @@ impl PyIsDistinctFromExpr { right: LiteralExprExtractor, negated: bool, ) -> pyo3::PyClassInitializer { - let inner = super::enum_::IsDistinctFrom { - left: Box::new(left.0), - right: Box::new(right.0), - negated, - }; - pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self) + let base = Self::new_base(left.0, right.0, negated); + pyo3::PyClassInitializer::from(base).add_subclass(Self) } fn get_left(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { @@ -252,26 +280,33 @@ impl PyIsDistinctFromExpr { } } -#[pyo3::pymethods] impl PyInListExpr { - #[new] - #[pyo3(signature = (expr, list, /, *, negated = false))] - fn __new__( - expr: LiteralExprExtractor, - list: alias::BoundObject<'_>, - negated: bool, - ) -> pyo3::PyResult> { + pub(super) fn new_base(expr: ExprInner, list: alias::BoundObject<'_>, negated: bool) -> pyo3::PyResult { let mut items = Vec::new(); for item in list.try_iter()? { items.push(item?.extract::()?.0); } let inner = super::enum_::InList { - expr: Box::new(expr.0), + expr: Box::new(expr), list: items, negated, }; - Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) + Ok(PyExpr(inner.into())) + } +} + +#[pyo3::pymethods] +impl PyInListExpr { + #[new] + #[pyo3(signature = (expr, list, /, *, negated = false))] + fn __new__( + expr: LiteralExprExtractor, + list: alias::BoundObject<'_>, + negated: bool, + ) -> pyo3::PyResult> { + let base = Self::new_base(expr.0, list, negated)?; + Ok(pyo3::PyClassInitializer::from(base).add_subclass(Self)) } fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { @@ -301,6 +336,18 @@ impl PyInListExpr { } } +impl PyBetweenExpr { + pub(super) fn new_base(expr: ExprInner, low: ExprInner, high: ExprInner, negated: bool) -> PyExpr { + let inner = super::enum_::Between { + expr: Box::new(expr), + negated, + low: Box::new(low), + high: Box::new(high), + }; + PyExpr(inner.into()) + } +} + #[pyo3::pymethods] impl PyBetweenExpr { #[new] @@ -311,13 +358,8 @@ impl PyBetweenExpr { high: LiteralExprExtractor, negated: bool, ) -> pyo3::PyClassInitializer { - let inner = super::enum_::Between { - expr: Box::new(expr.0), - negated, - low: Box::new(low.0), - high: Box::new(high.0), - }; - pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self) + let base = Self::new_base(expr.0, low.0, high.0, negated); + pyo3::PyClassInitializer::from(base).add_subclass(Self) } fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { @@ -341,25 +383,38 @@ impl PyBetweenExpr { } } -#[pyo3::pymethods] impl PyLikeExpr { - #[new] - #[pyo3(signature = (expr, pattern, /, *, negated = false, case_sensitive = true, escape = None))] - fn __new__( - expr: LiteralExprExtractor, + pub(super) fn new_base( + expr: ExprInner, pattern: alias::BoundObject<'_>, negated: bool, case_sensitive: bool, escape: Option, - ) -> pyo3::PyResult> { + ) -> pyo3::PyResult { let inner = super::enum_::Like { case_sensitive, negated, - expr: Box::new(expr.0), + expr: Box::new(expr), pattern: extract_like_pattern(pattern.as_borrowed())?, escape, }; - Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) + Ok(PyExpr(inner.into())) + } +} + +#[pyo3::pymethods] +impl PyLikeExpr { + #[new] + #[pyo3(signature = (expr, pattern, /, *, negated = false, case_sensitive = true, escape = None))] + fn __new__( + expr: LiteralExprExtractor, + pattern: alias::BoundObject<'_>, + negated: bool, + case_sensitive: bool, + escape: Option, + ) -> pyo3::PyResult> { + let base = Self::new_base(expr.0, pattern, negated, case_sensitive, escape)?; + Ok(pyo3::PyClassInitializer::from(base).add_subclass(Self)) } fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { @@ -399,6 +454,17 @@ impl PyLikeExpr { } } +impl PySimilarToExpr { + pub(super) fn new_base(expr: ExprInner, pattern: ExprInner, negated: bool) -> PyExpr { + let inner = super::enum_::SimilarTo { + negated, + expr: Box::new(expr), + pattern: Box::new(pattern), + }; + PyExpr(inner.into()) + } +} + #[pyo3::pymethods] impl PySimilarToExpr { #[new] @@ -408,12 +474,8 @@ impl PySimilarToExpr { pattern: LiteralExprExtractor, negated: bool, ) -> pyo3::PyClassInitializer { - let inner = super::enum_::SimilarTo { - negated, - expr: Box::new(expr.0), - pattern: Box::new(pattern.0), - }; - pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self) + let base = Self::new_base(expr.0, pattern.0, negated); + pyo3::PyClassInitializer::from(base).add_subclass(Self) } fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { @@ -432,6 +494,17 @@ impl PySimilarToExpr { } } +impl PyRLikeExpr { + pub(super) fn new_base(expr: ExprInner, pattern: ExprInner, negated: bool) -> PyExpr { + let inner = super::enum_::RLike { + negated, + expr: Box::new(expr), + pattern: Box::new(pattern), + }; + PyExpr(inner.into()) + } +} + #[pyo3::pymethods] impl PyRLikeExpr { #[new] @@ -441,12 +514,8 @@ impl PyRLikeExpr { pattern: LiteralExprExtractor, negated: bool, ) -> pyo3::PyClassInitializer { - let inner = super::enum_::RLike { - negated, - expr: Box::new(expr.0), - pattern: Box::new(pattern.0), - }; - pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self) + let base = Self::new_base(expr.0, pattern.0, negated); + pyo3::PyClassInitializer::from(base).add_subclass(Self) } fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { @@ -465,25 +534,32 @@ impl PyRLikeExpr { } } -#[pyo3::pymethods] impl PyCastExpr { - #[new] - #[pyo3(signature = (expr, data_type_name, /, *, kind = "CAST"))] - fn __new__( - expr: LiteralExprExtractor, - data_type_name: &str, - kind: &str, - ) -> pyo3::PyResult> { + pub(super) fn new_base(expr: ExprInner, data_type_name: &str, kind: &str) -> pyo3::PyResult { let Some(kind) = super::enum_::CastKind::parse(kind) else { return rapidquery_macros::pyerr!(Err(PyValueError), "unknown cast kind {:?}", kind); }; let inner = super::enum_::Cast { kind, - expr: Box::new(expr.0), + expr: Box::new(expr), data_type_name: data_type_name.into(), }; - Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) + Ok(PyExpr(inner.into())) + } +} + +#[pyo3::pymethods] +impl PyCastExpr { + #[new] + #[pyo3(signature = (expr, data_type_name, /, *, kind = "CAST"))] + fn __new__( + expr: LiteralExprExtractor, + data_type_name: &str, + kind: &str, + ) -> pyo3::PyResult> { + let base = Self::new_base(expr.0, data_type_name, kind)?; + Ok(pyo3::PyClassInitializer::from(base).add_subclass(Self)) } fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { @@ -502,15 +578,8 @@ impl PyCastExpr { } } -#[pyo3::pymethods] impl PyTrimExpr { - #[new] - #[pyo3(signature = (expr, /, *, r#where = "BOTH", characters = None))] - fn __new__( - expr: LiteralExprExtractor, - r#where: &str, - characters: Option, - ) -> pyo3::PyResult> { + pub(super) fn new_base(expr: ExprInner, r#where: &str, characters: Option) -> pyo3::PyResult { let Some(where_) = super::enum_::TrimWhere::parse(r#where) else { return rapidquery_macros::pyerr!( Err(PyValueError), @@ -520,11 +589,25 @@ impl PyTrimExpr { }; let inner = super::enum_::Trim { - expr: Box::new(expr.0), + expr: Box::new(expr), where_, - characters: characters.map(|LiteralExprExtractor(e)| Box::new(e)), + characters: characters.map(Box::new), }; - Ok(pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self)) + Ok(PyExpr(inner.into())) + } +} + +#[pyo3::pymethods] +impl PyTrimExpr { + #[new] + #[pyo3(signature = (expr, /, *, r#where = "BOTH", characters = None))] + fn __new__( + expr: LiteralExprExtractor, + r#where: &str, + characters: Option, + ) -> pyo3::PyResult> { + let base = Self::new_base(expr.0, r#where, characters.map(|LiteralExprExtractor(e)| e))?; + Ok(pyo3::PyClassInitializer::from(base).add_subclass(Self)) } fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { @@ -546,16 +629,23 @@ impl PyTrimExpr { } } +impl PyCollateExpr { + pub(super) fn new_base(expr: ExprInner, collation: super::enum_::Ident) -> PyExpr { + let inner = super::enum_::Collate { + expr: Box::new(expr), + collation, + }; + PyExpr(inner.into()) + } +} + #[pyo3::pymethods] impl PyCollateExpr { #[new] #[pyo3(signature = (expr, collation, /))] fn __new__(expr: LiteralExprExtractor, collation: IdentExtractor) -> pyo3::PyClassInitializer { - let inner = super::enum_::Collate { - expr: Box::new(expr.0), - collation: collation.0, - }; - pyo3::PyClassInitializer::from(PyExpr(inner.into())).add_subclass(Self) + let base = Self::new_base(expr.0, collation.0); + pyo3::PyClassInitializer::from(base).add_subclass(Self) } fn get_expr(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { diff --git a/src/expr/value.rs b/src/expr/value.rs index ec186fb..a165509 100644 --- a/src/expr/value.rs +++ b/src/expr/value.rs @@ -72,35 +72,4 @@ impl PyValue { fn get_serializer(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult> { PySerializer(Self::inner(&slf).serializer.clone()).into_subclasses(slf.py()) } - - fn __hash__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { - use pyo3::types::PyAnyMethods; - use std::hash::DefaultHasher; - use std::hash::Hash; - use std::hash::Hasher; - - let inner = Self::inner(&slf); - let hash_object = inner.object.bind(slf.py()).hash()?; - - let mut state = DefaultHasher::default(); - state.write_isize(hash_object); - inner.serializer.hash(&mut state); - Ok(state.finish()) - } - - fn __eq__(slf: pyo3::PyRef<'_, Self>, other: pyo3::PyRef<'_, Self>) -> bool { - if slf.as_ptr() == other.as_ptr() { - return true; - } - - Self::inner(&slf).eq(Self::inner(&other)) - } - - fn __ne__(slf: pyo3::PyRef<'_, Self>, other: pyo3::PyRef<'_, Self>) -> bool { - if slf.as_ptr() == other.as_ptr() { - return true; - } - - Self::inner(&slf).ne(Self::inner(&other)) - } } diff --git a/src/internal/utils/mod.rs b/src/internal/utils/mod.rs index d87741d..4043190 100644 --- a/src/internal/utils/mod.rs +++ b/src/internal/utils/mod.rs @@ -64,6 +64,7 @@ pub fn new_pynone<'py>(py: pyo3::Python<'py>) -> pyo3::Bound<'py, pyo3::PyAny> { /// # Safety /// The items returned by the iterator must be owned, not borrowed. #[inline] +#[cfg_attr(feature = "nightly", optimize(speed))] pub unsafe fn iterator_to_tuple<'py, I: Iterator>>( py: pyo3::Python<'py>, len: isize, diff --git a/src/lib.rs b/src/lib.rs index 25c4888..7c25ffc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,6 @@ pub mod serializers; pub mod typeref; // Later tasks: -// - Complete `Expr` methods // - Update github workflows // - Support customization protocols (e.g. __serializer__, __expr__, __ident__) // - Start writing query ASTs. diff --git a/tests/test_expr.py b/tests/test_expr.py index 56a56c9..e42a7a2 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -75,37 +75,233 @@ def test_render_succeeds_on_every_dialect(self, dialect: Dialect): assert "10" in sql +def same(actual: object, expected: object) -> bool: + """Structural check. + + ``==`` on an expression builds a SQL comparison and returns that node, so + it is not a boolean. Compare ``repr`` instead. A tuple of nodes is compared + element by element. + """ + + if isinstance(actual, tuple) and isinstance(expected, tuple): + return len(actual) == len(expected) and all( + same(left, right) for left, right in zip(actual, expected, strict=True) + ) + if isinstance(actual, Expr) or isinstance(expected, Expr): + return repr(actual) == repr(expected) + return actual == expected + + +def _assert_binary(expr: Expr, operator: str, left: Expr, right: Expr) -> None: + assert isinstance(expr, BinaryExpr) + assert expr.get_operator() == operator + assert same(expr.get_left(), left) + assert same(expr.get_right(), right) + + +class TestExprMethods: + """Fluent methods build the same nodes as the constructors. + + ``==`` and ``!=`` are SQL on every node, including ``Ident`` and ``Value``. + Python ``&`` / ``|`` are ``AND`` / ``OR``. + """ + + def test_operators(self): + name = Ident("name") + one = Value(1) + two = Value(2) + text = Value("x") + + # A bare string is a literal. ``&`` / ``|`` are logical; bitwise forms + # are named so ``~expr`` is not silently ``bit_not``. + built = [ + (name + "x", "+", name, text), + (name - 1, "-", name, one), + (name * 2, "*", name, two), + (name / 2, "/", name, two), + (name % 2, "%", name, two), + (name < 1, "<", name, one), + (name <= 1, "<=", name, one), + (name > 1, ">", name, one), + (name >= 1, ">=", name, one), + (name == 1, "=", name, one), + (name != 1, "<>", name, one), + (name << 1, "<<", name, one), + (name >> 1, ">>", name, one), + (name & 1, "AND", name, one), + (name | 1, "OR", name, one), + (name.bit_and(1), "&", name, one), + (name.bit_or(1), "|", name, one), + (name.bit_xor(1), "^", name, one), + (name.concat("x"), "||", name, text), + # Reflected: the other operand is on the left. + (1 + name, "+", one, name), + (1 - name, "-", one, name), + (2 * name, "*", two, name), + (2 / name, "/", two, name), + (2 % name, "%", two, name), + (1 << name, "<<", one, name), + (1 >> name, ">>", one, name), + (1 & name, "AND", one, name), + (1 | name, "OR", one, name), + (one + 2, "+", one, two), + (one == 2, "=", one, two), + (one != 2, "<>", one, two), + # Python swaps comparisons. There is no reflected ``<``. + (1 < name, ">", name, one), + ] + for expr, operator, left, right in built: + _assert_binary(expr, operator, left, right) + + assert (+name).get_operator() == "+" + assert same((+name).get_expr(), name) + assert (-name).get_operator() == "-" + assert name.not_().get_operator() == "NOT" + assert name.bit_not().get_operator() == "~" + with pytest.raises(TypeError): + ~name # pyright: ignore + + # ``*`` binds tighter than ``+``, so the sum is parenthesized. + assert ((name + 1) * 2).to_sql(PostgreSql) == '("name" + 1) * 2' + + def test_is_accepts_none_bool_and_string(self): + name = Ident("name") + + # ``None`` / ``True`` / ``False`` are ``IS`` tests, not ``= NULL``. + null = name.is_(None) + assert isinstance(null, IsExpr) + assert same(null.get_expr(), name) + assert null.get_kind() == "NULL" + assert null.get_negated() is False + assert name.is_(True).get_kind() == "TRUE" + assert name.is_(False).get_kind() == "FALSE" + assert name.is_("true").get_kind() == "TRUE" + assert name.is_("unknown").get_kind() == "UNKNOWN" + + negated = name.is_not(False) + assert negated.get_kind() == "FALSE" + assert negated.get_negated() is True + assert name.is_not(None).to_sql(PostgreSql) == '"name" IS NOT NULL' + assert name.is_(True).to_sql(PostgreSql) == '"name" IS TRUE' + + with pytest.raises(TypeError): + name.is_(1) + + with pytest.raises(ValueError, match="unknown IS kind"): + name.is_("MAYBE") + + def test_predicates(self): + name = Ident("name") + + between = name.between(1, 3) + assert isinstance(between, BetweenExpr) + assert same(between.get_low(), Value(1)) + assert same(between.get_high(), Value(3)) + assert between.get_negated() is False + assert name.not_between(1, 3).get_negated() is True + + inside = name.in_([1, name]) + assert isinstance(inside, InListExpr) + assert same(inside.get_list(), (Value(1), name)) + assert name.not_in([1]).get_negated() is True + + distinct = name.is_distinct_from(1) + assert isinstance(distinct, IsDistinctFromExpr) + assert same(distinct.get_right(), Value(1)) + assert distinct.get_negated() is False + assert name.is_not_distinct_from(1).get_negated() is True + + like = name.like("a%", escape="!") + assert isinstance(like, LikeExpr) + assert same(like.get_pattern(), Value("a%")) + assert like.get_escape() == "!" + assert like.get_negated() is False + assert like.get_case_sensitive() is True + assert name.not_like("a%").get_negated() is True + assert name.ilike("a%").get_case_sensitive() is False + assert same(name.not_ilike(["a%", "b%"]).get_pattern(), (Value("a%"), Value("b%"))) + # PyO3 extracts ``escape`` as one character before the node is built. + with pytest.raises(ValueError, match="length 1"): + name.like("a", escape="ab") # pyright: ignore + + similar = name.similar_to("a%") + assert isinstance(similar, SimilarToExpr) + assert same(similar.get_pattern(), Value("a%")) + assert name.not_similar_to("a%").get_negated() is True + + regex = name.rlike("a.*") + assert isinstance(regex, RLikeExpr) + assert same(regex.get_pattern(), Value("a.*")) + assert name.not_rlike("a.*").get_negated() is True + + # One render per family. Dialect spellings stay on the constructor tests. + assert between.to_sql(PostgreSql) == '"name" BETWEEN 1 AND 3' + assert like.to_sql(PostgreSql) == "\"name\" LIKE E'a%' ESCAPE E'!'" + assert similar.to_sql(PostgreSql) == "\"name\" SIMILAR TO E'a%'" + assert regex.to_sql(PostgreSql) == "\"name\" ~ E'a.*'" + + def test_cast_collate_and_trim(self): + name = Ident("name") + + cast = name.cast_as("int") + assert isinstance(cast, CastExpr) + assert cast.get_kind() == "CAST" + assert cast.get_data_type_name() == "INT" + assert cast.to_sql(PostgreSql) == 'CAST("name" AS INT)' + + attempt = name.try_cast_as("int") + assert attempt.get_kind() == "TRY_CAST" + assert attempt.to_sql(BigQuery) == "SAFE_CAST(`name` AS INT)" + + collated = name.collate(Unquote("C")) + assert isinstance(collated, CollateExpr) + assert same(collated.get_collation(), Ident(Unquote("C"))) + assert name.collate("en_US").to_sql(PostgreSql) == '"name" COLLATE "en_US"' + + trimmed = name.trim() + assert isinstance(trimmed, TrimExpr) + assert trimmed.get_where() == "BOTH" + assert trimmed.get_characters() is None + assert trimmed.to_sql(PostgreSql) == 'TRIM("name")' + + leading = name.trim(where="leading", characters="x") + assert leading.get_where() == "LEADING" + assert same(leading.get_characters(), Value("x")) + with pytest.raises(ValueError, match="unknown TRIM where"): + name.trim(where="NOPE") + + class TestFunction: def test_new(self): fn = Function("now") - assert fn.get_name() == Ident("now") + assert same(fn.get_name(), Ident("now")) assert fn.get_distinct() is False assert fn.get_args() == () assert fn.get_filter() is None assert fn.get_null_treatment() is None fn = Function("example").filter(BinaryExpr(1, ">", 2)) - assert fn.get_name() == Ident("example") + assert same(fn.get_name(), Ident("example")) assert fn.get_distinct() is False assert fn.get_args() == () assert isinstance(fn.get_filter(), BinaryExpr) fn = Function("count", Ident("id"), distinct=False).filter(BinaryExpr(1, ">", 2)) - assert fn.get_name() == Ident("count") + assert same(fn.get_name(), Ident("count")) assert fn.get_distinct() is False - assert fn.get_args() == (Ident("id"),) + assert same(fn.get_args(), (Ident("id"),)) assert isinstance(fn.get_filter(), BinaryExpr) fn = Function(Unquote("NOW")) - assert fn.get_name() == Ident(Unquote("NOW")) + assert same(fn.get_name(), Ident(Unquote("NOW"))) fn = Function("schema.fn") - assert fn.get_name() == Ident("schema", "fn") + assert same(fn.get_name(), Ident("schema", "fn")) bare = Function("now") with_arg = Function("now", 1) assert with_arg is not bare - assert with_arg.get_args() == (Value(1),) + assert same(with_arg.get_args(), (Value(1),)) assert bare.get_args() == () def test_to_sql(self): @@ -162,17 +358,17 @@ def test_edge_cases(self): Function("now").filter(1) fn = Function("f", "id") - assert fn.get_args() == (Value("id"),) + assert same(fn.get_args(), (Value("id"),)) assert fn.to_sql(PostgreSql) == "\"f\"(E'id')" fn = Function(Ident("schema.fn")) - assert fn.get_name() == Ident("schema.fn") + assert same(fn.get_name(), Ident("schema.fn")) assert fn.to_sql(PostgreSql) == '"schema.fn"()' first = Function("f", 1) second = Function("f", 2, 3) - assert first.get_args() == (Value(1),) - assert second.get_args() == (Value(2), Value(3)) + assert same(first.get_args(), (Value(1),)) + assert same(second.get_args(), (Value(2), Value(3))) filtered = Function("f").filter(BinaryExpr(1, ">", 0)) replaced = filtered.filter(BinaryExpr(2, ">", 0)) @@ -197,18 +393,18 @@ def test_edge_cases(self): class TestTupleExpr: def test_new(self): tpl = TupleExpr(1, 2, "A") - assert tpl.get_items() == (Value(1), Value(2), Value("A")) + assert same(tpl.get_items(), (Value(1), Value(2), Value("A"))) tpl = TupleExpr(1, 2, Ident("A")) - assert tpl.get_items() == (Value(1), Value(2), Ident("A")) + assert same(tpl.get_items(), (Value(1), Value(2), Ident("A"))) empty = TupleExpr() assert empty.get_items() == () nested = TupleExpr((1, 2), Ident("A")) assert isinstance(nested.get_items()[0], TupleExpr) - assert nested.get_items()[0].get_items() == (Value(1), Value(2)) - assert nested.get_items()[1] == Ident("A") + assert same(nested.get_items()[0].get_items(), (Value(1), Value(2))) + assert same(nested.get_items()[1], Ident("A")) def test_to_sql(self): tpl = TupleExpr(1, 2, "A") @@ -233,7 +429,7 @@ def test_to_sql(self): class TestUnaryExpr: def test_new(self): un = UnaryExpr("NOT", 1) - assert un.get_expr() == Value(1) + assert same(un.get_expr(), Value(1)) assert un.get_operator() == "NOT" un = UnaryExpr("Not", 1) @@ -244,7 +440,7 @@ def test_new(self): un = UnaryExpr("+", Ident("id")) assert un.get_operator() == "+" - assert un.get_expr() == Ident("id") + assert same(un.get_expr(), Ident("id")) un = UnaryExpr("~", 1) assert un.get_operator() == "~" @@ -285,14 +481,14 @@ def test_to_sql(self): class TestBinaryExpr: def test_new(self): bin = BinaryExpr(Ident("id"), ">", 10) - assert bin.get_left() == Ident("id") + assert same(bin.get_left(), Ident("id")) assert bin.get_operator() == ">" - assert bin.get_right() == Value(10) + assert same(bin.get_right(), Value(10)) bin = BinaryExpr("str", "-custom-", 10) - assert bin.get_left() == Value("str") + assert same(bin.get_left(), Value("str")) assert bin.get_operator() == "-custom-" - assert bin.get_right() == Value(10) + assert same(bin.get_right(), Value(10)) bin = BinaryExpr(1, "!=", 2) assert bin.get_operator() == "<>" @@ -354,12 +550,12 @@ def test_repr(self): class TestIsExpr: def test_new(self): is_ = IsExpr(Ident("id"), "null") - assert is_.get_expr() == Ident("id") + assert same(is_.get_expr(), Ident("id")) assert is_.get_kind() == "NULL" assert is_.get_negated() is False is_ = IsExpr(None, "UNKNOWN", negated=True) - assert is_.get_expr() == Value(None) + assert same(is_.get_expr(), Value(None)) assert is_.get_kind() == "UNKNOWN" assert is_.get_negated() is True @@ -395,23 +591,23 @@ def test_repr(self): class TestIsDistinctFromExpr: def test_new(self): idf = IsDistinctFromExpr(2, 3) - assert idf.get_left() == Value(2) - assert idf.get_right() == Value(3) + assert same(idf.get_left(), Value(2)) + assert same(idf.get_right(), Value(3)) assert idf.get_negated() is False idf = IsDistinctFromExpr("A", "B") - assert idf.get_left() == Value("A") - assert idf.get_right() == Value("B") + assert same(idf.get_left(), Value("A")) + assert same(idf.get_right(), Value("B")) assert idf.get_negated() is False idf = IsDistinctFromExpr(Ident("id"), 10, negated=True) - assert idf.get_left() == Ident("id") - assert idf.get_right() == Value(10) + assert same(idf.get_left(), Ident("id")) + assert same(idf.get_right(), Value(10)) assert idf.get_negated() is True idf = IsDistinctFromExpr(Ident("a"), Ident("b")) - assert idf.get_left() == Ident("a") - assert idf.get_right() == Ident("b") + assert same(idf.get_left(), Ident("a")) + assert same(idf.get_right(), Ident("b")) def test_to_sql(self): idf = IsDistinctFromExpr(2, 3) @@ -435,13 +631,13 @@ def test_to_sql(self): class TestInListExpr: def test_new(self): lst = InListExpr(Ident("id"), [1, 2, 3]) - assert lst.get_expr() == Ident("id") - assert lst.get_list() == (Value(1), Value(2), Value(3)) + assert same(lst.get_expr(), Ident("id")) + assert same(lst.get_list(), (Value(1), Value(2), Value(3))) assert lst.get_negated() is False lst = InListExpr("A", [1, Ident("id"), 3], negated=True) - assert lst.get_expr() == Value("A") - assert lst.get_list() == (Value(1), Ident("id"), Value(3)) + assert same(lst.get_expr(), Value("A")) + assert same(lst.get_list(), (Value(1), Ident("id"), Value(3))) assert lst.get_negated() is True lst = InListExpr(Ident("id"), []) @@ -476,15 +672,15 @@ def test_to_sql(self): class TestBetweenExpr: def test_new(self): bw = BetweenExpr(Ident("id"), 0, 10) - assert bw.get_expr() == Ident("id") - assert bw.get_low() == Value(0) - assert bw.get_high() == Value(10) + assert same(bw.get_expr(), Ident("id")) + assert same(bw.get_low(), Value(0)) + assert same(bw.get_high(), Value(10)) assert bw.get_negated() is False bw = BetweenExpr(Ident("id"), BinaryExpr(Ident("old"), "|", 0), 10, negated=True) - assert bw.get_expr() == Ident("id") + assert same(bw.get_expr(), Ident("id")) assert isinstance(bw.get_low(), BinaryExpr) - assert bw.get_high() == Value(10) + assert same(bw.get_high(), Value(10)) assert bw.get_negated() is True def test_to_sql(self): @@ -506,8 +702,8 @@ def test_to_sql(self): class TestLikeExpr: def test_new(self): like = LikeExpr(Ident("name"), "%a%") - assert like.get_expr() == Ident("name") - assert like.get_pattern() == Value("%a%") + assert same(like.get_expr(), Ident("name")) + assert same(like.get_pattern(), Value("%a%")) assert like.get_case_sensitive() is True assert like.get_negated() is False assert like.get_escape() is None @@ -519,20 +715,20 @@ def test_new(self): negated=True, escape="!", ) - assert like.get_expr() == Ident("name") - assert like.get_pattern() == Ident("pattern") + assert same(like.get_expr(), Ident("name")) + assert same(like.get_pattern(), Ident("pattern")) assert like.get_case_sensitive() is False assert like.get_negated() is True assert like.get_escape() == "!" any_like = LikeExpr(Ident("name"), ["%a%", "%b%"]) - assert any_like.get_pattern() == (Value("%a%"), Value("%b%")) + assert same(any_like.get_pattern(), (Value("%a%"), Value("%b%"))) one_any = LikeExpr(Ident("name"), ["%a%"]) - assert one_any.get_pattern() == (Value("%a%"),) + assert same(one_any.get_pattern(), (Value("%a%"),)) tup = LikeExpr(Ident("name"), ("%a%", Ident("p"))) - assert tup.get_pattern() == (Value("%a%"), Ident("p")) + assert same(tup.get_pattern(), (Value("%a%"), Ident("p"))) with pytest.raises(ValueError, match="empty"): LikeExpr(Ident("name"), []) @@ -588,13 +784,13 @@ def test_to_sql(self): class TestSimilarToExpr: def test_new(self): sim = SimilarToExpr(Ident("name"), "%a%") - assert sim.get_expr() == Ident("name") - assert sim.get_pattern() == Value("%a%") + assert same(sim.get_expr(), Ident("name")) + assert same(sim.get_pattern(), Value("%a%")) assert sim.get_negated() is False sim = SimilarToExpr(Ident("name"), Ident("pattern"), negated=True) - assert sim.get_expr() == Ident("name") - assert sim.get_pattern() == Ident("pattern") + assert same(sim.get_expr(), Ident("name")) + assert same(sim.get_pattern(), Ident("pattern")) assert sim.get_negated() is True def test_to_sql(self): @@ -627,13 +823,13 @@ def test_to_sql(self): class TestRLikeExpr: def test_new(self): rlike = RLikeExpr(Ident("name"), "%a%") - assert rlike.get_expr() == Ident("name") - assert rlike.get_pattern() == Value("%a%") + assert same(rlike.get_expr(), Ident("name")) + assert same(rlike.get_pattern(), Value("%a%")) assert rlike.get_negated() is False rlike = RLikeExpr(Ident("name"), Ident("pattern"), negated=True) - assert rlike.get_expr() == Ident("name") - assert rlike.get_pattern() == Ident("pattern") + assert same(rlike.get_expr(), Ident("name")) + assert same(rlike.get_pattern(), Ident("pattern")) assert rlike.get_negated() is True def test_to_sql(self): @@ -669,12 +865,12 @@ def test_to_sql(self): class TestCastExpr: def test_new(self): cast = CastExpr(Ident("id"), "int") - assert cast.get_expr() == Ident("id") + assert same(cast.get_expr(), Ident("id")) assert cast.get_data_type_name() == "INT" assert cast.get_kind() == "CAST" cast = CastExpr(1, "varchar(255)", kind="TRY_CAST") - assert cast.get_expr() == Value(1) + assert same(cast.get_expr(), Value(1)) assert cast.get_data_type_name() == "varchar(255)" assert cast.get_kind() == "TRY_CAST" @@ -714,16 +910,16 @@ def test_to_sql(self): class TestTrimExpr: def test_new(self): trim = TrimExpr(Ident("s")) - assert trim.get_expr() == Ident("s") + assert same(trim.get_expr(), Ident("s")) assert trim.get_where() == "BOTH" assert trim.get_characters() is None trim = TrimExpr(Ident("s"), where="leading", characters="x") assert trim.get_where() == "LEADING" - assert trim.get_characters() == Value("x") + assert same(trim.get_characters(), Value("x")) trim = TrimExpr(Ident("s"), characters=Ident("b")) - assert trim.get_characters() == Ident("b") + assert same(trim.get_characters(), Ident("b")) with pytest.raises(ValueError, match="unknown TRIM where"): TrimExpr(Ident("s"), where="MIDDLE") @@ -750,18 +946,18 @@ def test_to_sql(self): class TestCollateExpr: def test_new(self): col = CollateExpr(Ident("name"), "en_US") - assert col.get_expr() == Ident("name") - assert col.get_collation() == Ident("en_US") + assert same(col.get_expr(), Ident("name")) + assert same(col.get_collation(), Ident("en_US")) col = CollateExpr("cafĆ©", Ident("pg_catalog", "default")) - assert col.get_expr() == Value("cafĆ©") - assert col.get_collation() == Ident("pg_catalog", "default") + assert same(col.get_expr(), Value("cafĆ©")) + assert same(col.get_collation(), Ident("pg_catalog", "default")) col = CollateExpr(Ident("name"), Unquote("C")) - assert col.get_collation() == Ident(Unquote("C")) + assert same(col.get_collation(), Ident(Unquote("C"))) col = CollateExpr(Ident("name"), "pg_catalog.default") - assert col.get_collation() == Ident("pg_catalog", "default") + assert same(col.get_collation(), Ident("pg_catalog", "default")) def test_to_sql(self): assert CollateExpr(Ident("name"), "en_US").to_sql(PostgreSql) == ('"name" COLLATE "en_US"') @@ -790,16 +986,16 @@ def test_new(self): when_list = searched.get_when_list() assert len(when_list) == 1 assert isinstance(when_list[0][0], BinaryExpr) - assert when_list[0][1] == Value("yes") + assert same(when_list[0][1], Value("yes")) assert searched.get_else() is None with_else = searched.else_("no") assert with_else is not searched assert searched.get_else() is None - assert with_else.get_else() == Value("no") + assert same(with_else.get_else(), Value("no")) simple = CaseExpr(Ident("status")) - assert simple.get_operand() == Ident("status") + assert same(simple.get_operand(), Ident("status")) CaseExpr().when(Ident("x"), 1) CaseExpr().when(Value(1), 1) @@ -827,7 +1023,7 @@ def test_new(self): custom = CustomExpr("{} = {}", Ident("a"), 1) assert custom.get_sql() == "{} = {}" - assert custom.get_args() == (Ident("a"), Value(1)) + assert same(custom.get_args(), (Ident("a"), Value(1))) def test_to_sql(self): assert CustomExpr("1 = 1").to_sql(PostgreSql) == "1 = 1" diff --git a/tests/test_ident.py b/tests/test_ident.py index cba05ba..9c9d6d5 100644 --- a/tests/test_ident.py +++ b/tests/test_ident.py @@ -140,24 +140,6 @@ def test_accepts_underscore_and_digits(self): class TestEqualityHashReprFrozen: - def test_equal_when_parts_match(self): - assert Ident("t", "id") == Ident("t", "id") - assert Ident("t", "id") != Ident("t", "name") - - def test_quoted_is_not_unquoted(self): - assert Ident("id") != Ident(Unquote("id")) - assert hash(Ident("id")) != hash(Ident(Unquote("id"))) - - def test_asterisk_instances_are_equal(self): - assert Asterisk() == Asterisk() - assert Ident(Asterisk) == Ident(Asterisk()) - - def test_usable_as_dict_key(self): - mapping = {Ident("t", "id"): 1, Unquote("id"): 2, Asterisk(): 3} - assert mapping[Ident("t", "id")] == 1 - assert mapping[Unquote("id")] == 2 - assert mapping[Asterisk()] == 3 - def test_repr(self): assert repr(Ident("schema", "table")) == 'Ident("schema", "table")' assert repr(Ident("t", Asterisk)) == 'Ident("t", Asterisk)' diff --git a/tests/test_value.py b/tests/test_value.py index 5239062..6dee3ee 100644 --- a/tests/test_value.py +++ b/tests/test_value.py @@ -118,25 +118,3 @@ def test_none_is_null(self): def test_propagates_serializer_type_error(self): with pytest.raises(TypeError): Value("not an int", IntegerSerializer()).to_sql(PostgreSql) - - -class TestValueEquality: - def test_equal_when_object_and_serializer_match(self): - assert Value(1) == Value(1) - assert hash(Value(1)) == hash(Value(1)) - - def test_unequal_when_serializer_differs(self): - assert Value(1) != Value(1, StringSerializer()) - - def test_unequal_when_object_differs(self): - assert Value(1) != Value(2) - - def test_not_equal_to_bare_object(self): - assert Value(1) != 1 - - def test_unhashable_object_raises(self): - with pytest.raises(TypeError): - hash(Value({"a": 1})) - - def test_repr_includes_object_and_serializer(self): - assert repr(Value(42)) == "Value(42, IntegerSerializer())" From 733b6a255205159ddd565eef3f677c9bfa8fd40f Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Sun, 11 Oct 2026 22:17:03 +0330 Subject: [PATCH 78/87] Update github workflows, add pytest-xdist dep --- .github/workflows/publish.yml | 254 ++++++++++++++++++++-------------- .github/workflows/test.yml | 130 +++++++++++------ Makefile | 2 +- pyproject.toml | 6 +- src/lib.rs | 1 - tests/test_value.py | 18 +-- uv.lock | 28 +++- 7 files changed, 276 insertions(+), 163 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fb1df32..cc902c5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,116 +4,147 @@ on: push: tags: - "*" + workflow_dispatch: +permissions: + contents: read + +env: + (^_^): &interpreters "3.10 3.11 3.12 3.13 3.14 3.14t 3.15 3.15t pypy3.11" + ā—‹_ā—‹: &interpreters_without_pypy "3.10 3.11 3.12 3.13 3.14 3.14t 3.15 3.15t" + (o_o): &interpreters_win_arm64 "3.11 3.12 3.13 3.14 3.14t 3.15 3.15t" + + UV_FROZEN: true + jobs: - linux: - runs-on: ${{ matrix.platform.runner }} + build-linux: + runs-on: ubuntu-latest strategy: matrix: platform: - { - runner: ubuntu-latest, - target: x86_64, - interpreter: "3.10 3.11 3.12 3.13 3.14 3.14t pypy3.11 graalpy3.11 graalpy3.12", + target: x86_64-unknown-linux-gnu, + arch: x86_64, + interpreter: *interpreters, } - { - runner: ubuntu-latest, - target: i686, - interpreter: "3.10 3.11 3.12 3.13 3.14 3.14t pypy3.11", + target: i686-unknown-linux-gnu, + arch: x86, + interpreter: *interpreters, } - { - runner: ubuntu-latest, - target: aarch64, - interpreter: "3.10 3.11 3.12 3.13 3.14 3.14t pypy3.11 graalpy3.11 graalpy3.12", + target: aarch64-unknown-linux-gnu, + arch: aarch64, + interpreter: *interpreters, } - { - runner: ubuntu-latest, - target: armv7, - interpreter: "3.10 3.11 3.12 3.13 3.14 3.14t", + target: armv7-unknown-linux-gnueabihf, + arch: armv7, + interpreter: *interpreters, } - { - runner: ubuntu-latest, - target: s390x, - interpreter: "3.10 3.11 3.12 3.13 3.14 3.14t", + target: s390x-unknown-linux-gnu, + arch: s390x, + interpreter: *interpreters, } - { - runner: ubuntu-latest, - target: ppc64le, - interpreter: "3.10 3.11 3.12 3.13 3.14 3.14t", + target: powerpc64le-unknown-linux-gnu, + arch: ppc64le, + interpreter: *interpreters, } - { - runner: ubuntu-latest, target: riscv64gc-unknown-linux-gnu, - interpreter: "3.10 3.11 3.12 3.13 3.14 3.14t", + arch: riscv64, + interpreter: *interpreters, } steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-python@v6 + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: - python-version: "3.x" + activate-environment: true + enable-cache: false + python-version: "3" - - uses: PyO3/maturin-action@v1.51.0 + - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 with: target: ${{ matrix.platform.target }} - args: --release --out dist --interpreter ${{ matrix.platform.interpreter }} - sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + sccache: ${{ github.ref_type != 'tag' }} manylinux: auto - rust-toolchain: nightly + args: >- + --release + --out dist + --interpreter ${{ matrix.platform.interpreter }} + --compatibility pypi - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: wheels-linux-${{ matrix.platform.target }} + name: wheels-linux-${{ matrix.platform.arch }} path: dist - musllinux: - runs-on: ${{ matrix.platform.runner }} + build-musllinux: + runs-on: ubuntu-latest strategy: matrix: platform: - { - runner: ubuntu-latest, - target: x86_64, - interpreter: "3.10 3.11 3.12 3.13 3.14 3.14t pypy3.11", + target: x86_64-unknown-linux-musl, + arch: x86_64, + interpreter: *interpreters, } - { - runner: ubuntu-latest, - target: aarch64, - interpreter: "3.10 3.11 3.12 3.13 3.14 3.14t pypy3.11", + target: i686-unknown-linux-musl, + arch: x86, + interpreter: *interpreters, } - { - runner: ubuntu-latest, - target: armv7, - interpreter: "3.10 3.11 3.12 3.13 3.14 3.14t pypy3.11", + target: aarch64-unknown-linux-musl, + arch: aarch64, + interpreter: *interpreters, + } + - { + target: armv7-unknown-linux-musleabihf, + arch: armv7, + interpreter: *interpreters, + } + - { + target: riscv64gc-unknown-linux-musl, + arch: riscv64, + interpreter: *interpreters, } steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-python@v6 + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: - python-version: "3.x" + activate-environment: true + enable-cache: false + python-version: "3" - - uses: PyO3/maturin-action@v1.51.0 + - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 with: target: ${{ matrix.platform.target }} - args: --release --out dist --interpreter ${{ matrix.platform.interpreter }} - sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} - manylinux: musllinux_1_1 - rust-toolchain: nightly + sccache: ${{ github.ref_type != 'tag' }} + manylinux: musllinux_1_2 + args: >- + --release + --out dist + --interpreter ${{ matrix.platform.interpreter }} + --compatibility pypi - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: wheels-musllinux-${{ matrix.platform.target }} + name: wheels-musllinux-${{ matrix.platform.arch }} path: dist - windows: + build-windows: runs-on: ${{ matrix.platform.runner }} strategy: matrix: @@ -121,108 +152,121 @@ jobs: - { runner: windows-latest, target: x64, - interpreter: "3.10 3.11 3.12 3.13 3.14 3.14t", + arch: x64, + interpreter: *interpreters, } - { runner: windows-latest, target: x86, - interpreter: "3.10 3.11 3.12 3.13 3.14 3.14t", + arch: x86, + interpreter: *interpreters_without_pypy, + } + - { + runner: windows-11-arm, + target: aarch64, + arch: arm64, + interpreter: *interpreters_win_arm64, } + steps: - - uses: actions/checkout@v5.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-python@v6.0.0 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: 3.x - architecture: ${{ matrix.platform.target }} - - uses: PyO3/maturin-action@v1.49.4 + python-version: "3.x" + architecture: ${{ matrix.platform.arch }} + + - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 with: target: ${{ matrix.platform.target }} - args: --release --out dist --interpreter ${{ matrix.platform.interpreter }} - sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} - rust-toolchain: nightly + sccache: ${{ github.ref_type != 'tag' }} + args: >- + --release + --out dist + --interpreter ${{ matrix.platform.interpreter }} + --compatibility pypi - - uses: actions/upload-artifact@v4.6.2 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wheels-windows-${{ matrix.platform.target }} path: dist - macos: - runs-on: ${{ matrix.platform.runner }} + build-macos: + runs-on: macos-latest strategy: matrix: platform: - - { - runner: macos-latest, - target: x86_64, - interpreter: "3.10 3.11 3.12 3.13 3.13t 3.14 3.14t pypy3.11", - } - - { - runner: macos-latest, - target: aarch64, - interpreter: "3.10 3.11 3.12 3.13 3.13t 3.14 3.14t pypy3.11", - } + - { target: x86_64, interpreter: *interpreters } + - { target: aarch64, interpreter: *interpreters } + steps: - - uses: actions/checkout@v5.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-python@v6.0.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: 3.x + python-version: "3.x" - - uses: PyO3/maturin-action@v1.49.4 + - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 with: target: ${{ matrix.platform.target }} - args: --release --out dist --interpreter ${{ matrix.platform.interpreter }} - sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} - rust-toolchain: nightly + sccache: ${{ github.ref_type != 'tag' }} + args: >- + --release + --out dist + --interpreter ${{ matrix.platform.interpreter }} + --compatibility pypi - - uses: actions/upload-artifact@v4.6.2 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wheels-macos-${{ matrix.platform.target }} path: dist + build-sdist: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: PyO3/maturin-action@v1.51.0 + - name: Build sdist + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 with: command: sdist args: --out dist - rust-toolchain: nightly - - uses: actions/upload-artifact@v4 + - name: Upload sdist + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wheels-sdist path: dist release: runs-on: ubuntu-latest - if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }} - needs: [linux, musllinux, windows, macos, build-sdist] + if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' + needs: + - build-linux + - build-musllinux + - build-windows + - build-macos + - build-sdist + permissions: - id-token: write # signing release artifacts - contents: write # uploading release artifacts - attestations: write + id-token: write + contents: write steps: - - uses: actions/download-artifact@v5 - - - uses: actions/attest-build-provenance@v3 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - subject-path: "wheels-*/*" + pattern: wheels-* + merge-multiple: true + path: dist - name: Publish to PyPI - if: ${{ startsWith(github.ref, 'refs/tags/') }} - uses: PyO3/maturin-action@v1.51.0 - env: - MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} - with: - command: upload - args: --non-interactive --skip-existing wheels-*/* - rust-toolchain: nightly + if: github.ref_type == 'tag' + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: dist diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c1822d4..ca10068 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,81 +1,125 @@ -name: Test +name: Python Test on: pull_request: - types: [opened, reopened] workflow_dispatch: permissions: contents: read +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +env: + RUST_BACKTRACE: 1 + UV_PYTHON_DOWNLOADS: 0 + jobs: test-python: - name: test ${{ matrix.python-version }} + name: ${{ matrix.python-version }} + runs-on: ubuntu-latest + continue-on-error: ${{ endsWith(matrix.python-version, 't') }} strategy: fail-fast: false matrix: python-version: - - "3.10" - - "3.14" - - "3.14t" - - "pypy3.11" + [ + "3.10", + "3.11", + "3.12", + "3.13", + "3.14", + "3.14t", + "3.15", + "3.15t", + "pypy3.11", + ] - runs-on: ubuntu-latest - continue-on-error: ${{ endsWith(matrix.python-version, 't') }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + if: github.event_name == 'pull_request' && github.run_attempt == 1 + with: + lookup-only: false + shared-key: ${{ matrix.python-version }} - - name: install rust stable - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - toolchain: nightly + python-version: ${{ matrix.python-version }} + allow-prereleases: true - - name: install uv - uses: astral-sh/setup-uv@v6 + - name: "Install uv" + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + enable-cache: false - - name: set up python - run: uv python install ${{ matrix.python-version }} + - name: "Create and activate .venv" + run: | + uv venv .venv --python ${{ matrix.python-version }} + echo ".venv/bin" >> "$GITHUB_PATH" + echo "PYO3_PYTHON=.venv/bin/python3" >> "$GITHUB_ENV" - - name: install dependencies - run: uv sync --all-extras --dev - env: - RUST_BACKTRACE: 1 + - name: "Install dependencies" + run: uv sync --group dev + + - name: "Run maturin" + run: maturin develop - - name: run tests - run: uv run pytest -vv + - name: "Run pytest" + run: pytest -v --workers auto env: - RUST_BACKTRACE: 1 HYPOTHESIS_PROFILE: slow test-os: - name: test on ${{ matrix.os }} + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }}-latest strategy: fail-fast: false matrix: os: [ubuntu, macos, windows] - runs-on: ${{ matrix.os }}-latest steps: - - uses: actions/checkout@v4 - - - name: install rust stable - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - toolchain: nightly - - - name: install uv - uses: astral-sh/setup-uv@v6 + persist-credentials: false - - name: set up python - run: uv python install 3.13 + - uses: dtolnay/rust-toolchain@stable - - name: install dependencies - run: uv sync --all-extras --dev - env: - RUST_BACKTRACE: 1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" - - name: run tests - run: uv run pytest -vv + - name: "Install uv" + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + enable-cache: false + + - name: "Create and activate .venv" + shell: bash + run: | + uv venv .venv --python 3.13 + if [[ "${{ matrix.os }}" == "windows" ]]; then + echo ".venv/Scripts" >> "$GITHUB_PATH" + echo "PYO3_PYTHON=.venv/Scripts/python" >> "$GITHUB_ENV" + echo "UV_LINK_MODE=copy" >> "$GITHUB_ENV" + else + echo ".venv/bin" >> "$GITHUB_PATH" + echo "PYO3_PYTHON=.venv/bin/python3" >> "$GITHUB_ENV" + fi + + - name: "Install dependencies" + run: uv pip install --group ci + + - name: "Run maturin" + run: maturin develop + + - name: "Run pytest" + run: pytest -v -n auto env: - RUST_BACKTRACE: 1 HYPOTHESIS_PROFILE: slow diff --git a/Makefile b/Makefile index 16f06e4..4d4aadc 100644 --- a/Makefile +++ b/Makefile @@ -56,7 +56,7 @@ lint: lint-python lint-rust .PHONY: test ## Run all tests test: cargo test --workspace --all --no-default-features --features auto-initialize - uv run pytest + uv run pytest -n auto .PHONY: all ## Run the standard set of checks performed in CI all: format lint build-dev test diff --git a/pyproject.toml b/pyproject.toml index e14edd5..2a54dcd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,11 @@ lint = [ "pre-commit>=4.6.2", "ruff>=0.16.9", ] -test = ["hypothesis>=6.0.0", "pytest>=8.0.0"] +test = [ + "hypothesis>=6.0.0", + "pytest>=8.0.0", + "pytest-xdist>=3.8.0", +] [build-system] requires = ["maturin == 1.15.0"] diff --git a/src/lib.rs b/src/lib.rs index 7c25ffc..9c3d138 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,6 @@ pub mod serializers; pub mod typeref; // Later tasks: -// - Update github workflows // - Support customization protocols (e.g. __serializer__, __expr__, __ident__) // - Start writing query ASTs. diff --git a/tests/test_value.py b/tests/test_value.py index 6dee3ee..ed14a4c 100644 --- a/tests/test_value.py +++ b/tests/test_value.py @@ -41,17 +41,13 @@ class InvalidEnum(enum.Enum): FloatSerializer: [1.0, decimal.Decimal("1.0"), decimal.Decimal(1)], BooleanSerializer: [True, False], StringSerializer: ["string", ValidEnum.TEST], - UuidSerializer: [uuid.uuid4()], + UuidSerializer: [uuid.UUID("58073209-2238-4d99-9e74-1bc21110393b")], BytesSerializer: [b"bytes"], BitSerializer: [], - JSONSerializer: [ - [], - {}, - {"OK": 1}, - ], - DateSerializer: [datetime.datetime.now(tz=datetime.UTC).date()], - TimeSerializer: [datetime.datetime.now(tz=datetime.UTC).time()], - DatetimeSerializer: [datetime.datetime.now(tz=datetime.UTC)], + JSONSerializer: [[], {}, {"OK": 1}], + DateSerializer: [datetime.date(2026, 10, 3)], + TimeSerializer: [datetime.time(10, 23)], + DatetimeSerializer: [datetime.datetime(2026, 10, 3, 23, 42, 34)], InetSerializer: [], MacAddressSerializer: [], UnspecifiedSerializer: [datetime.timezone.utc, InvalidEnum.TEST], @@ -59,8 +55,8 @@ class InvalidEnum(enum.Enum): } if USING_ORJSON: - INFER_TEST_CASES[JSONSerializer].append({"OK": datetime.datetime.now()}) - INFER_TEST_CASES[JSONSerializer].append({"OK": uuid.uuid4()}) + INFER_TEST_CASES[JSONSerializer].append({"OK": datetime.datetime(2026, 10, 3, 23, 42, 34)}) + INFER_TEST_CASES[JSONSerializer].append({"OK": uuid.UUID("58073209-2238-4d99-9e74-1bc21110393b")}) class TestValueConstruction: diff --git a/uv.lock b/uv.lock index 7eaeb51..2059735 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15'", @@ -109,6 +109,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "filelock" version = "4.0.10" @@ -622,6 +631,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-discovery" version = "1.6.1" @@ -717,6 +739,7 @@ dev = [ { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, + { name = "pytest-xdist" }, { name = "ruff" }, ] lint = [ @@ -727,6 +750,7 @@ lint = [ test = [ { name = "hypothesis" }, { name = "pytest" }, + { name = "pytest-xdist" }, ] [package.metadata] @@ -741,6 +765,7 @@ dev = [ { name = "mypy", specifier = ">=2.3.1" }, { name = "pre-commit", specifier = ">=4.6.2" }, { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "ruff", specifier = ">=0.16.9" }, ] lint = [ @@ -751,6 +776,7 @@ lint = [ test = [ { name = "hypothesis", specifier = ">=6.0.0" }, { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, ] [[package]] From ef0d5796a6fc81a25b60aabe56c8b530e15b6cec Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 12 Oct 2026 14:05:14 +0330 Subject: [PATCH 79/87] Support customization protocols: __serializer__, __ident__, __expr__ --- rapidquery-macros/lib.rs | 128 +++++++++++++++++++++++++++++++++++ rapidquery/expr.pyi | 25 ++++--- src/expr/enum_/value.rs | 5 +- src/expr/extractors.rs | 36 +++++++++- src/expr/value.rs | 11 +-- src/lib.rs | 1 - src/serializers/functions.rs | 2 +- src/serializers/inner.rs | 43 ++++++++---- tests/test_expr.py | 26 +++++++ tests/test_ident.py | 30 ++++++++ tests/test_serializers.py | 4 +- tests/test_value.py | 73 ++++++++++++++++++++ 12 files changed, 346 insertions(+), 38 deletions(-) diff --git a/rapidquery-macros/lib.rs b/rapidquery-macros/lib.rs index 2eba512..b07ab43 100644 --- a/rapidquery-macros/lib.rs +++ b/rapidquery-macros/lib.rs @@ -203,3 +203,131 @@ pub fn dialect_options(input: TokenStream) -> TokenStream { Err(err) => err.to_compile_error().into(), } } + +struct CustomizationProtocolInput { + bound_object: syn::Ident, + attribute_name: syn::LitCStr, + pyobject_type: syn::Path, + ok_expr: syn::Expr, + err_expr: syn::Expr, +} + +impl Parse for CustomizationProtocolInput { + fn parse(input: ParseStream) -> syn::Result { + // if $bound:ident + input.parse::()?; + let bound_object: syn::Ident = input.parse()?; + + // ...($attribute_name:cstr) + let content; + syn::parenthesized!(content in input); + + let attribute_name: syn::LitCStr = content.parse()?; + + // ...== $pyobject_type:path + input.parse::()?; + let pyobject_type: syn::Path = input.parse()?; + + // ...{ + // $ok_expr:expr + // } + let ok_expr: syn::Expr = input.parse()?; + + // else { + // $err_expr:expr + // } + input.parse::()?; + let err_expr: syn::Expr = input.parse()?; + + Ok(Self { + bound_object, + attribute_name, + pyobject_type, + ok_expr, + err_expr, + }) + } +} + +/// Builds a customization protocol checking. +/// +/// Note: you can access to attribute result using `protocol` variable. +/// +/// For example: +/// ```ignore +/// customization_protocol! { +/// if bound(c"__serializer__") == crate::typeref::SERIALIZER_TYPE { +/// // OK expr +/// } else { +/// // ERR expr +/// } +/// } +/// ``` +/// +/// becomes: +/// ```ignore +/// unsafe { +/// const PROTOCOL: &'static std::ffi::CStr = c"__serializer__"; +/// +/// let result = { +/// let attribute: *mut pyo3::ffi::PyObject = +/// pyo3::ffi::PyObject_GetAttrString(bound.as_ptr(), PROTOCOL.as_ptr()); +/// +/// if attribute.is_null() { +/// if pyo3::ffi::PyErr_ExceptionMatches(pyo3::ffi::PyExc_AttributeError) != 1 { +/// return Err(pyo3::PyErr::fetch(bound.py())); +/// } else { +/// None +/// } +/// } else { +/// Some(pyo3::Bound::from_owned_ptr(bound.py(), attribute)) +/// } +/// }; +/// +/// if let Some(protocol) = result { +/// if crate::typeref::check(protocol.as_ptr(), crate::typeref::SERIALIZER_TYPE) { +/// // OK expr +/// } else { +/// // ERR expr +/// } +/// } +/// } +/// ``` +#[proc_macro] +pub fn customization_protocol(item: TokenStream) -> TokenStream { + let CustomizationProtocolInput { + bound_object, + attribute_name, + pyobject_type, + ok_expr, + err_expr, + } = syn::parse_macro_input!(item as CustomizationProtocolInput); + + quote! { + unsafe { + const PROTOCOL: &'static std::ffi::CStr = #attribute_name; + + let result = { + let attribute: *mut pyo3::ffi::PyObject = + pyo3::ffi::PyObject_GetAttrString(#bound_object.as_ptr(), PROTOCOL.as_ptr()); + + if attribute.is_null() { + if pyo3::ffi::PyErr_ExceptionMatches(pyo3::ffi::PyExc_AttributeError) != 1 { + return Err(pyo3::PyErr::fetch(#bound_object.py())); + } else { + pyo3::ffi::PyErr_Clear(); + None + } + } else { + Some(pyo3::Bound::from_owned_ptr(#bound_object.py(), attribute)) + } + }; + + if let Some(protocol) = result { + if pyo3::ffi::PyObject_TypeCheck(protocol.as_ptr(), #pyobject_type) == 1 #ok_expr + else #err_expr + } + } + } + .into() +} diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index cb30da6..e2325a9 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -5,6 +5,15 @@ from typing import Self, final, overload from rapidquery.dialect import Dialect from rapidquery.serializers import Serializer +class _IdentClassVarProtocol(typing.Protocol): + __ident__: Ident + +class _IdentPropertyProtocol(typing.Protocol): + @property + def __ident__(self) -> Ident: ... + +_IdentExtractor = str | Unquote | Ident | Asterisk | type[Asterisk] | _IdentClassVarProtocol | _IdentPropertyProtocol + class Expr: """ Base of every SQL expression node. @@ -279,11 +288,7 @@ class Expr: def try_cast_as(self, data_type_name: str, /) -> CastExpr: """SQL ``TRY_CAST(self AS data_type_name)``.""" - def collate( - self, - collation: str | Unquote | Ident | Asterisk | type[Asterisk], - /, - ) -> CollateExpr: + def collate(self, collation: _IdentExtractor, /) -> CollateExpr: """SQL ``self COLLATE collation``. The collation is an identifier.""" def trim( @@ -377,7 +382,7 @@ class Ident(Expr): '"users"."id"' """ - def __new__(cls, *parts: str | Unquote | Ident | Asterisk | type[Asterisk]) -> Self: + def __new__(cls, *parts: _IdentExtractor) -> Self: """ Build an identifier from parts. @@ -474,13 +479,7 @@ class Function(Expr): '"count"("id")' """ - def __new__( - cls, - name: str | Ident | Unquote | Asterisk | type[Asterisk], - /, - *args: object, - distinct: bool = False, - ) -> Self: + def __new__(cls, name: _IdentExtractor, /, *args: object, distinct: bool = False) -> Self: """ Build a call. diff --git a/src/expr/enum_/value.rs b/src/expr/enum_/value.rs index 50d606d..a6b99f6 100644 --- a/src/expr/enum_/value.rs +++ b/src/expr/enum_/value.rs @@ -37,9 +37,8 @@ impl Value { } #[inline] - pub fn infer_bound(bound: alias::BoundObject) -> Self { - let serializer = SerializerType::infer_bound(&bound); - Self::new(bound.unbind(), serializer) + pub fn infer_bound(bound: alias::BoundObject) -> pyo3::PyResult { + SerializerType::infer_bound(&bound).map(|x| Self::new(bound.unbind(), x)) } pub fn python_repr(&self, py: pyo3::Python<'_>) -> pyo3::PyResult { diff --git a/src/expr/extractors.rs b/src/expr/extractors.rs index 07e222e..a2e7b3f 100644 --- a/src/expr/extractors.rs +++ b/src/expr/extractors.rs @@ -106,7 +106,21 @@ fn extract_expr( } } - Ok(ExprInner::Value(super::enum_::Value::infer_bound(obj.as_any().clone()))) + // __expr__ protocol + rapidquery_macros::customization_protocol! { + if obj(c"__expr__") == crate::typeref::EXPR_TYPE { + let expr = protocol.cast_into_unchecked::(); + return Ok(expr.get().0.clone()); + } else { + return rapidquery_macros::pyerr!( + Err(PyTypeError), + "__expr__ protocol attribute returns non-expr type: {}", + utils::get_type_name(obj.py(), protocol.as_ptr()) + ) + } + } + + super::enum_::Value::infer_bound(obj.as_any().clone()).map(ExprInner::Value) } /// Scalar `LIKE` pattern, or a list/tuple of patterns (`LIKE ANY`). @@ -187,6 +201,26 @@ pub(super) fn extract_ident_argument( } } + // __ident__ protocol + rapidquery_macros::customization_protocol! { + if obj(c"__ident__") == crate::typeref::IDENT_TYPE { + let expr = protocol.cast_into_unchecked::(); + + match &expr.get().0 { + ExprInner::Ident(ident) => { + return Ok(ExtractedIdentArgument::Ident(ident.clone())); + } + _ => unreachable!("Ident instance always holds ExprInner::Ident"), + } + } else { + return rapidquery_macros::pyerr!( + Err(PyTypeError), + "__ident__ protocol attribute returns non-ident type: {}", + utils::get_type_name(obj.py(), protocol.as_ptr()) + ) + } + } + rapidquery_macros::pyerr!( Err(PyTypeError), "expected str, Ident, Unquote or Asterisk, got {}", diff --git a/src/expr/value.rs b/src/expr/value.rs index a165509..f2347c0 100644 --- a/src/expr/value.rs +++ b/src/expr/value.rs @@ -29,7 +29,10 @@ impl PyValue { /// Wrap `object`. Omit `serializer` to infer one from its type. #[new] #[pyo3(signature = (object, serializer = None, /))] - fn __new__(object: alias::BoundObject, serializer: Option) -> pyo3::PyClassInitializer { + fn __new__( + object: alias::BoundObject, + serializer: Option, + ) -> pyo3::PyResult> { // handle Value(Value(...), new_serializer) if unsafe { crate::typeref::check_exact(object.as_ptr(), crate::typeref::VALUE_TYPE) } { let expr = unsafe { object.cast_unchecked::() }; @@ -51,16 +54,16 @@ impl PyValue { }; let new_expr = super::base::PyExpr(super::enum_::ExprInner::Value(value)); - return pyo3::PyClassInitializer::from(new_expr).add_subclass(PyValue); + return Ok(pyo3::PyClassInitializer::from(new_expr).add_subclass(PyValue)); } let value = match serializer { Some(SerializerExtractor(s)) => Value::new(object.unbind(), s), - None => Value::infer_bound(object), + None => Value::infer_bound(object)?, }; let expr = super::base::PyExpr(super::enum_::ExprInner::Value(value)); - pyo3::PyClassInitializer::from(expr).add_subclass(PyValue) + Ok(pyo3::PyClassInitializer::from(expr).add_subclass(PyValue)) } /// The wrapped Python object. diff --git a/src/lib.rs b/src/lib.rs index 9c3d138..47fc307 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,6 @@ pub mod serializers; pub mod typeref; // Later tasks: -// - Support customization protocols (e.g. __serializer__, __expr__, __ident__) // - Start writing query ASTs. /// RapidQuery native extension. diff --git a/src/serializers/functions.rs b/src/serializers/functions.rs index 143bd48..9fb6bab 100644 --- a/src/serializers/functions.rs +++ b/src/serializers/functions.rs @@ -55,7 +55,7 @@ pub(super) unsafe fn write_integer( ptr: *mut pyo3::ffi::PyObject, out: &mut String, ) -> pyo3::PyResult<()> { - if pyo3::ffi::PyLong_CheckExact(ptr) != 1 { + if pyo3::ffi::PyLong_Check(ptr) != 1 { return rapidquery_macros::pyerr!( Err(PyTypeError), "expected int for integer serialization, got {}", diff --git a/src/serializers/inner.rs b/src/serializers/inner.rs index 75b6e5c..6c57dba 100644 --- a/src/serializers/inner.rs +++ b/src/serializers/inner.rs @@ -1,6 +1,7 @@ use crate::dialect::PyDialect; use crate::internal::alias; use crate::internal::modules; +use crate::internal::utils; /// A thin enum which specifies the serializer type. /// @@ -69,7 +70,7 @@ impl std::hash::Hash for SerializerType { } impl SerializerType { - pub fn infer_bound(bound: &alias::BoundObject) -> Self { + pub fn infer_bound(bound: &alias::BoundObject) -> pyo3::PyResult { unsafe { let object_ptr = bound.as_ptr(); let object_type_ptr = pyo3::ffi::Py_TYPE(object_ptr); @@ -81,59 +82,73 @@ impl SerializerType { // So it's not important what [`Serializer`] is. // `None` values are handled by [`crate::sql::SqlWriter`] if pyo3::ffi::Py_IsNone(object_ptr) == 1 || pyo3::ffi::PyBool_Check(object_ptr) == 1 { - return Self::Boolean; + return Ok(Self::Boolean); } // float / decimal -> Float if pyo3::ffi::PyFloat_CheckExact(object_ptr) == 1 || object_type_ptr == crate::typeref::STD_DECIMAL_TYPE { - return Self::Float; + return Ok(Self::Float); } - // int -> Integer - if pyo3::ffi::PyLong_CheckExact(object_ptr) == 1 { - return Self::Integer; + // int/enum.IntEnum -> Integer + if pyo3::ffi::PyLong_Check(object_ptr) == 1 { + return Ok(Self::Integer); } // str/enum.StrEnum -> String if pyo3::ffi::PyUnicode_Check(object_ptr) == 1 { - return Self::String; + return Ok(Self::String); } // bytes -> Bytes if pyo3::ffi::PyBytes_CheckExact(object_ptr) == 1 { - return Self::Bytes; + return Ok(Self::Bytes); } // date -> Date if object_type_ptr == crate::typeref::STD_DATE_TYPE { - return Self::Date; + return Ok(Self::Date); } // time -> Time if object_type_ptr == crate::typeref::STD_TIME_TYPE { - return Self::Time; + return Ok(Self::Time); } // datetime -> Datetime if object_type_ptr == crate::typeref::STD_DATETIME_TYPE { - return Self::Datetime; + return Ok(Self::Datetime); } // UUID -> Uuid if object_type_ptr == crate::typeref::STD_UUID_TYPE { - return Self::Uuid; + return Ok(Self::Uuid); } // list/dict -> Json if (pyo3::ffi::PyList_CheckExact(object_ptr) == 1 || pyo3::ffi::PyDict_CheckExact(object_ptr) == 1) && modules::json_dumps(bound.clone(), |_| Ok(())).is_ok() { - return Self::Json; + return Ok(Self::Json); + } + } + + // __serializer__ protocol + rapidquery_macros::customization_protocol! { + if bound(c"__serializer__") == crate::typeref::SERIALIZER_TYPE { + let serializer = protocol.cast_into_unchecked::(); + return Ok(serializer.get().0.clone()); + } else { + return rapidquery_macros::pyerr!( + Err(PyTypeError), + "__serializer__ protocol attribute returns non-serializer type: {}", + utils::get_type_name(bound.py(), protocol.as_ptr()) + ) } } // * -> Unspecified - Self::Unspecified + Ok(Self::Unspecified) } } diff --git a/tests/test_expr.py b/tests/test_expr.py index e42a7a2..41549eb 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -1044,3 +1044,29 @@ def test_to_sql(self): with pytest.raises(ValueError, match="placeholders"): CustomExpr("{} {}", 1).prepare(PostgreSql) + + +class TestExprProtocol: + def test_attribute(self): + + class Eq: + __expr__ = BinaryExpr(1, "=", 2) + + cust = CustomExpr("{}", Eq) + assert isinstance(cust.get_args()[0], BinaryExpr) + assert cust.to_sql(PostgreSql) == "1 = 2" + + def test_property(self): + + class Eq: + def __init__(self, a: object, b: object): + self.a = a + self.b = b + + @property + def __expr__(self): + return BinaryExpr(1, "=", 2) + + cust = CustomExpr("{}", Eq(1, 2)) + assert isinstance(cust.get_args()[0], BinaryExpr) + assert cust.to_sql(PostgreSql) == "1 = 2" diff --git a/tests/test_ident.py b/tests/test_ident.py index 9c9d6d5..e6e4ab7 100644 --- a/tests/test_ident.py +++ b/tests/test_ident.py @@ -1,3 +1,5 @@ +import typing + import pytest from rapidquery import Asterisk, Ident, Unquote @@ -156,3 +158,31 @@ def test_quoted_injection_stays_inside_delimiters(self): payload = "a; DROP TABLE t--" sql = Ident(payload).to_sql(PostgreSql) assert sql == f'"{payload}"' + + +class TestIdentProtocol: + def test_attribute(self): + + class Unk: + __ident__: Ident = Ident("schema", "unk") + + ident = Ident("database", Unk()) + assert ident.to_list() == ["database", "schema", "unk"] + + def test_property(self): + + class Unk: + @property + def __ident__(self) -> Ident: + return Ident("schema", "unk") + + ident = Ident(Unk(), "col") + assert ident.to_list() == ["schema", "unk", "col"] + + def test_invalid_type(self): + + class Unk: + __ident__ = "A" + + with pytest.raises(TypeError): + _ = Ident(Unk()) # pyright: ignore[reportArgumentType] diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 9b70d7b..d3d8f9e 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -86,8 +86,10 @@ def test_values(self): assert s.serialize(PG, 1) == "1" assert s.serialize(PG, 18_446_744_073_709_551_615) == "18446744073709551615" assert s.serialize(PG, -9_223_372_036_854_775_808) == "-9223372036854775808" + assert s.serialize(PG, True) == "1" + assert s.serialize(PG, False) == "0" - @pytest.mark.parametrize("value", [2.5, True, "invalid"]) + @pytest.mark.parametrize("value", [2.5, "invalid"]) def test_rejects_non_int(self, value: typing.Any): with pytest.raises(TypeError): IntegerSerializer().serialize(PG, value) diff --git a/tests/test_value.py b/tests/test_value.py index ed14a4c..ff5f43c 100644 --- a/tests/test_value.py +++ b/tests/test_value.py @@ -12,6 +12,7 @@ BitSerializer, BooleanSerializer, BytesSerializer, + CustomSerializer, DateSerializer, DatetimeSerializer, FloatSerializer, @@ -114,3 +115,75 @@ def test_none_is_null(self): def test_propagates_serializer_type_error(self): with pytest.raises(TypeError): Value("not an int", IntegerSerializer()).to_sql(PostgreSql) + + +class TestSerializerProtocol: + def test_classvar_attribute(self): + + class Unk: + __serializer__: Serializer = CustomSerializer["Unk"](lambda _, x: str(x)) + + def __str__(self) -> str: + return "UNK" + + val = Value(Unk()) + assert isinstance(val.get_serializer(), CustomSerializer) + assert val.to_sql(PostgreSql) == "UNK" + + def test_property(self): + + class Unk: + @property + def __serializer__(self): + return CustomSerializer["Unk"](lambda _, x: str(x)) + + def __str__(self) -> str: + return "UNK" + + val = Value(Unk()) + assert isinstance(val.get_serializer(), CustomSerializer) + assert val.to_sql(PostgreSql) == "UNK" + + def test_invalid_attribute(self): + + class Unk: + __serializer__ = 10 + + with pytest.raises(TypeError, match="non-serializer"): + _ = Value(Unk()) + + def test_attribute_error(self): + + class Unk: + def __str__(self) -> str: + return "UNK" + + val = Value(Unk()) + assert isinstance(val.get_serializer(), UnspecifiedSerializer) + assert val.to_sql(PostgreSql) == "UNK" + + def test_builtin_types_priority(self): + + class String(str): + # It must not use this attribute + # We are using PyUnicode_Check to support StrEnum + __serializer__ = IntegerSerializer() + + val = Value(String("test")) + assert isinstance(val.get_serializer(), StringSerializer) + + class Integer(int): + # It must not use this attribute + # We are using PyLong_Check to support IntEnum + __serializer__ = UnspecifiedSerializer() + + val = Value(Integer(1)) + assert isinstance(val.get_serializer(), IntegerSerializer) + + class Blob(bytes): + # It MUST use this attribute + # We are using PyBytes_CheckExact, so we only use BytesSerializer to literaly `bytes`. + __serializer__ = FloatSerializer() + + val = Value(Blob(b"test")) + assert isinstance(val.get_serializer(), FloatSerializer) From 3e47ee396d40375481c1708dc746674e4de4d32b Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 12 Oct 2026 23:04:28 +0330 Subject: [PATCH 80/87] Rename rapidquery_macros::customization_protocol to cust_protocol, and redefine its syntax --- rapidquery-macros/lib.rs | 69 ++++++++++++++++++++++++++++------------ src/expr/extractors.rs | 20 ++++++++---- src/serializers/inner.rs | 10 ++++-- 3 files changed, 70 insertions(+), 29 deletions(-) diff --git a/rapidquery-macros/lib.rs b/rapidquery-macros/lib.rs index b07ab43..909fa06 100644 --- a/rapidquery-macros/lib.rs +++ b/rapidquery-macros/lib.rs @@ -214,31 +214,55 @@ struct CustomizationProtocolInput { impl Parse for CustomizationProtocolInput { fn parse(input: ParseStream) -> syn::Result { - // if $bound:ident - input.parse::()?; + // on: $bound:ident, + let on_label: syn::Ident = input.parse()?; + if on_label != "on" { + return Err(input.error("expected `on`")); + } + input.parse::()?; let bound_object: syn::Ident = input.parse()?; + input.parse::()?; - // ...($attribute_name:cstr) - let content; - syn::parenthesized!(content in input); - - let attribute_name: syn::LitCStr = content.parse()?; - - // ...== $pyobject_type:path - input.parse::()?; + // attr: $attribute_name:cstr, + let attr_label: syn::Ident = input.parse()?; + if attr_label != "attr" { + return Err(input.error("expected `attr`")); + } + input.parse::()?; + let attribute_name: syn::LitCStr = input.parse()?; + input.parse::()?; + + // ty: $pyobject_type:path, + let ty_label: syn::Ident = input.parse()?; + if ty_label != "ty" { + return Err(input.error("expected `ty`")); + } + input.parse::()?; let pyobject_type: syn::Path = input.parse()?; + input.parse::()?; - // ...{ - // $ok_expr:expr - // } + // matches: $ok_expr:expr, + let matches_label: syn::Ident = input.parse()?; + if matches_label != "matches" { + return Err(input.error("expected `matches`")); + } + input.parse::()?; let ok_expr: syn::Expr = input.parse()?; + input.parse::()?; - // else { - // $err_expr:expr - // } - input.parse::()?; + // mismatches: $err_expr:expr + let mismatches_label: syn::Ident = input.parse()?; + if mismatches_label != "mismatches" { + return Err(input.error("expected `mismatches`")); + } + input.parse::()?; let err_expr: syn::Expr = input.parse()?; + // Optional trailing comma + if input.peek(syn::Token![,]) { + input.parse::()?; + } + Ok(Self { bound_object, attribute_name, @@ -256,9 +280,13 @@ impl Parse for CustomizationProtocolInput { /// For example: /// ```ignore /// customization_protocol! { -/// if bound(c"__serializer__") == crate::typeref::SERIALIZER_TYPE { +/// on: bound, +/// attr: c"__serializer__", +/// ty: crate::typeref::SERIALIZER_TYPE, +/// matches: { /// // OK expr -/// } else { +/// }, +/// mismatches: { /// // ERR expr /// } /// } @@ -277,6 +305,7 @@ impl Parse for CustomizationProtocolInput { /// if pyo3::ffi::PyErr_ExceptionMatches(pyo3::ffi::PyExc_AttributeError) != 1 { /// return Err(pyo3::PyErr::fetch(bound.py())); /// } else { +/// pyo3::ffi::PyErr_Clear(); /// None /// } /// } else { @@ -294,7 +323,7 @@ impl Parse for CustomizationProtocolInput { /// } /// ``` #[proc_macro] -pub fn customization_protocol(item: TokenStream) -> TokenStream { +pub fn cust_protocol(item: TokenStream) -> TokenStream { let CustomizationProtocolInput { bound_object, attribute_name, diff --git a/src/expr/extractors.rs b/src/expr/extractors.rs index a2e7b3f..696aed7 100644 --- a/src/expr/extractors.rs +++ b/src/expr/extractors.rs @@ -107,11 +107,15 @@ fn extract_expr( } // __expr__ protocol - rapidquery_macros::customization_protocol! { - if obj(c"__expr__") == crate::typeref::EXPR_TYPE { + rapidquery_macros::cust_protocol! { + on: obj, + attr: c"__expr__", + ty: crate::typeref::EXPR_TYPE, + matches: { let expr = protocol.cast_into_unchecked::(); return Ok(expr.get().0.clone()); - } else { + }, + mismatches: { return rapidquery_macros::pyerr!( Err(PyTypeError), "__expr__ protocol attribute returns non-expr type: {}", @@ -202,8 +206,11 @@ pub(super) fn extract_ident_argument( } // __ident__ protocol - rapidquery_macros::customization_protocol! { - if obj(c"__ident__") == crate::typeref::IDENT_TYPE { + rapidquery_macros::cust_protocol! { + on: obj, + attr: c"__ident__", + ty: crate::typeref::IDENT_TYPE, + matches: { let expr = protocol.cast_into_unchecked::(); match &expr.get().0 { @@ -212,7 +219,8 @@ pub(super) fn extract_ident_argument( } _ => unreachable!("Ident instance always holds ExprInner::Ident"), } - } else { + }, + mismatches: { return rapidquery_macros::pyerr!( Err(PyTypeError), "__ident__ protocol attribute returns non-ident type: {}", diff --git a/src/serializers/inner.rs b/src/serializers/inner.rs index 6c57dba..85cd397 100644 --- a/src/serializers/inner.rs +++ b/src/serializers/inner.rs @@ -134,11 +134,15 @@ impl SerializerType { } // __serializer__ protocol - rapidquery_macros::customization_protocol! { - if bound(c"__serializer__") == crate::typeref::SERIALIZER_TYPE { + rapidquery_macros::cust_protocol! { + on: bound, + attr: c"__serializer__", + ty: crate::typeref::SERIALIZER_TYPE, + matches: { let serializer = protocol.cast_into_unchecked::(); return Ok(serializer.get().0.clone()); - } else { + }, + mismatches: { return rapidquery_macros::pyerr!( Err(PyTypeError), "__serializer__ protocol attribute returns non-serializer type: {}", From d4a1327fa31bb3c42ed3cfdc57fa808494e1a008 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Mon, 12 Oct 2026 23:47:25 +0330 Subject: [PATCH 81/87] Optimize customization protocol on Python 3.13+ --- Cargo.lock | 236 +++++++++++++---------------------- Cargo.toml | 3 + build.rs | 3 + rapidquery-macros/Cargo.toml | 6 +- rapidquery-macros/lib.rs | 22 +++- rust-toolchain.toml | 2 + src/serializers/functions.rs | 8 +- 7 files changed, 118 insertions(+), 162 deletions(-) create mode 100644 build.rs create mode 100644 rust-toolchain.toml diff --git a/Cargo.lock b/Cargo.lock index 236a477..af81283 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,9 +25,9 @@ checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bitfield-struct" @@ -37,7 +37,7 @@ checksum = "3ca6739863c590881f038d033a146c51ddae239186a4327014839fd864f44ed5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -54,9 +54,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "cc" -version = "1.4.4" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", "shlex", @@ -64,9 +64,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.3" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -93,9 +93,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "foldhash" @@ -169,9 +169,9 @@ dependencies = [ [[package]] name = "inventory" -version = "0.3.24" +version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c" dependencies = [ "rustversion", ] @@ -184,9 +184,9 @@ checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -195,17 +195,16 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.175" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "lock_api" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] @@ -217,12 +216,12 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "mac_address" -version = "1.1.8" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +checksum = "f3d0581a75c45969c63afd2bb87b1d8b25cfcc556c7a918cf9bb13b0fe8d2381" dependencies = [ "nix", - "winapi", + "windows-sys", ] [[package]] @@ -236,9 +235,9 @@ dependencies = [ [[package]] name = "nix" -version = "0.29.0" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags", "cfg-if", @@ -264,9 +263,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "parking_lot" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -274,15 +273,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets", + "windows-link", ] [[package]] @@ -293,24 +292,24 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "pyo3" -version = "0.29.2" +version = "0.29.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b" +checksum = "700d18fa267b73b9b521fd7e13580e2f446916f176cacee1ab63fcc8191f1655" dependencies = [ "chrono", "hashbrown", @@ -325,18 +324,18 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.29.2" +version = "0.29.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9" +checksum = "7b3fc0c4d08f6bb10e71fe39dfb9e2f59c6eb6854e22ec8092f50c69a4499adb" dependencies = [ "target-lexicon", ] [[package]] name = "pyo3-ffi" -version = "0.29.2" +version = "0.29.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327" +checksum = "dfc0b8e19df29aad7086cf977bb0c2a2f143e30567eb113e9cf72b62ca698330" dependencies = [ "libc", "pyo3-build-config", @@ -344,33 +343,33 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.29.2" +version = "0.29.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c" +checksum = "6100e8a4b5eba53afaa5ed078364851a0b2499c553a44c31026b929049b49dc6" dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "pyo3-macros-backend" -version = "0.29.2" +version = "0.29.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952" +checksum = "6143877a16e82b5a727b7127ff4cd86858a24a28d745d72f43e6f227c7b1bdb3" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "quote" -version = "1.0.44" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -388,6 +387,7 @@ dependencies = [ "once_cell", "parking_lot", "pyo3", + "pyo3-build-config", "rapidquery-macros", "sea-query", "smallvec", @@ -401,14 +401,14 @@ version = "1.0.0-alpha1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] name = "redox_syscall" -version = "0.5.17" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ "bitflags", ] @@ -445,9 +445,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.0" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" [[package]] name = "sqlparser" @@ -460,9 +460,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +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.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -471,27 +482,27 @@ dependencies = [ [[package]] name = "target-lexicon" -version = "0.13.3" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "unicode-ident" -version = "1.0.19" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "uuid" -version = "1.26.1" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -502,9 +513,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -512,48 +523,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-core" version = "0.62.2" @@ -575,7 +564,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -586,7 +575,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -614,65 +603,10 @@ dependencies = [ ] [[package]] -name = "windows-targets" -version = "0.52.6" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows-link", ] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/Cargo.toml b/Cargo.toml index 369982c..9d66abf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,9 @@ sea-query = { version = "1.0.2", default-features = false } # ONLY for inspiring the source sqlparser = { version = "0.62.0", default-features = false } +[build-dependencies] +pyo3-build-config = "0.29.3" + [features] default = ["extension-module"] extension-module = ["pyo3/extension-module"] diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..0475124 --- /dev/null +++ b/build.rs @@ -0,0 +1,3 @@ +fn main() { + pyo3_build_config::use_pyo3_cfgs(); +} diff --git a/rapidquery-macros/Cargo.toml b/rapidquery-macros/Cargo.toml index 574bc0b..cd80df6 100644 --- a/rapidquery-macros/Cargo.toml +++ b/rapidquery-macros/Cargo.toml @@ -14,8 +14,4 @@ proc-macro = true [dependencies] proc-macro2 = "1" quote = "1" - -[dependencies.syn] -version = "2" -default-features = false -features = ["derive", "parsing", "printing", "proc-macro"] +syn = { version = "3", features = ["default", "full"] } diff --git a/rapidquery-macros/lib.rs b/rapidquery-macros/lib.rs index 909fa06..b7fc4c3 100644 --- a/rapidquery-macros/lib.rs +++ b/rapidquery-macros/lib.rs @@ -1,5 +1,6 @@ use proc_macro::TokenStream; use quote::quote; +use syn::ItemStruct; use syn::parse::Parse; use syn::parse::ParseStream; @@ -84,7 +85,8 @@ pub fn pyclass(attr: TokenStream, item: TokenStream) -> TokenStream { module, meta_args, } = syn::parse_macro_input!(attr as PyClassInput); - let item = syn::parse_macro_input!(item as syn::ItemStruct); + + let item = syn::parse_macro_input!(item as ItemStruct); // Fresh tokens, no input span, so rustc cannot substitute the dotted literal. let args: proc_macro2::TokenStream = format!("module = \"{module}\", name = \"{name}\", immutable_type, frozen") @@ -336,6 +338,24 @@ pub fn cust_protocol(item: TokenStream) -> TokenStream { unsafe { const PROTOCOL: &'static std::ffi::CStr = #attribute_name; + #[cfg(Py_3_13)] + let result = { + let mut attribute: *mut pyo3::ffi::PyObject = std::ptr::null_mut(); + + match pyo3::ffi::PyObject_GetOptionalAttrString( + #bound_object.as_ptr(), + PROTOCOL.as_ptr(), + &mut attribute + ) { + 1 => Some(pyo3::Bound::from_owned_ptr(#bound_object.py(), attribute)), + 0 => None, + _ => { + return Err(pyo3::PyErr::fetch(#bound_object.py())) + } + } + }; + + #[cfg(not(Py_3_13))] let result = { let attribute: *mut pyo3::ffi::PyObject = pyo3::ffi::PyObject_GetAttrString(#bound_object.as_ptr(), PROTOCOL.as_ptr()); diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..292fe49 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" diff --git a/src/serializers/functions.rs b/src/serializers/functions.rs index 9fb6bab..bba7383 100644 --- a/src/serializers/functions.rs +++ b/src/serializers/functions.rs @@ -24,10 +24,12 @@ pub(super) unsafe fn pyobject_string(py: pyo3::Python<'_>, ptr: *mut pyo3::ffi:: out } +#[inline(always)] fn write_quoted(dialect: &flags::DialectFlags, out: &mut String, raw: &str) -> pyo3::PyResult<()> { dialect.write_string_literal(out, raw).map_err(Into::into) } +#[inline] fn write_with_keyword( dialect: &flags::DialectFlags, out: &mut String, @@ -41,6 +43,7 @@ fn write_with_keyword( write_quoted(dialect, out, raw) } +#[inline(always)] fn timestamp_keyword(format: flags::TimestampFormat, aware: bool) -> Option<&'static str> { match format { flags::TimestampFormat::Quoted => None, @@ -217,7 +220,6 @@ pub(super) unsafe fn write_bytes( } } -#[inline] pub(super) unsafe fn write_bit( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, @@ -296,7 +298,6 @@ pub(super) unsafe fn write_date( } } -#[inline] pub(super) unsafe fn write_time( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, @@ -397,7 +398,6 @@ pub(super) unsafe fn write_datetime( ) } -#[inline] pub(super) unsafe fn write_inet( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, @@ -420,7 +420,6 @@ pub(super) unsafe fn write_inet( } } -#[inline] pub(super) unsafe fn write_macaddr( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, @@ -498,7 +497,6 @@ pub(super) unsafe fn write_array( Ok(()) } -#[inline] pub(super) unsafe fn write_custom( py: pyo3::Python<'_>, ptr: *mut pyo3::ffi::PyObject, From 572056cfd204aa296be1706026d2a074664c399b Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Tue, 13 Oct 2026 12:57:10 +0330 Subject: [PATCH 82/87] Change version from v1.0.0-alpha1 to 0.2.0-alpha1. Keep version low to show unstable API --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 9d66abf..527ed0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rapidquery" -version = "1.0.0-alpha1" +version = "0.2.0-alpha1" edition = "2024" description = "RapidQuery is the fastest, full-feature, and easy-to-use Python SQL query builder written in Rust." homepage = "https://github.com/awolverp/rapidquery" From a1e1af8cb8a89f6c38c6ead0832ada8da04a709a Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Wed, 14 Oct 2026 16:56:52 +0330 Subject: [PATCH 83/87] Update test workflow --- .github/workflows/test.yml | 8 +++++++- Cargo.lock | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ca10068..cbae70b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,6 +2,8 @@ name: Python Test on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: permissions: @@ -17,6 +19,8 @@ env: jobs: test-python: + if: github.event.pull_request.draft == false + name: ${{ matrix.python-version }} runs-on: ubuntu-latest continue-on-error: ${{ endsWith(matrix.python-version, 't') }} @@ -77,6 +81,8 @@ jobs: HYPOTHESIS_PROFILE: slow test-os: + if: github.event.pull_request.draft == false + name: ${{ matrix.os }} runs-on: ${{ matrix.os }}-latest strategy: @@ -114,7 +120,7 @@ jobs: fi - name: "Install dependencies" - run: uv pip install --group ci + run: uv pip install --group dev - name: "Run maturin" run: maturin develop diff --git a/Cargo.lock b/Cargo.lock index af81283..c4ff744 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "rapidquery" -version = "1.0.0-alpha1" +version = "0.2.0-alpha1" dependencies = [ "arcstr", "bitfield-struct", From 8e2dcbee649fe53662341141a80155d58e17d89d Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Wed, 14 Oct 2026 19:03:44 +0330 Subject: [PATCH 84/87] Specify initial tasks for implementing ASTs --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/dcl/mod.rs | 1 + src/ddl/mod.rs | 1 + src/dml/mod.rs | 1 + src/lib.rs | 6 ++++-- src/tcl/mod.rs | 1 + 6 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 src/dcl/mod.rs create mode 100644 src/ddl/mod.rs create mode 100644 src/dml/mod.rs create mode 100644 src/tcl/mod.rs diff --git a/README.md b/README.md index dfeca15..8cedefe 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,49 @@ RapidQuery is a powerful SQL query builder library designed for Python, combinin > [!WARNING]\ > This branch is under development and incomplete. + +## TODO + +ASTs until being merged into default branch. + +### DDL + +- [ ] CREATE SCHEMA +- [ ] DROP SCHEMA +- [ ] CREATE TABLE +- [ ] ALTER TABLE +- [ ] DROP TABLE +- [ ] CREATE VIEW +- [ ] DROP VIEW +- [ ] CREATE TYPE +- [ ] ALTER TYPE +- [ ] DROP TYPE +- [ ] CREATE SEQUENCE +- [ ] ALTER SEQUENCE +- [ ] DROP SEQUENCE +- [ ] CREATE FUNCTION +- [ ] ALTER FUNCTION +- [ ] DROP FUNCTION + +### DCL + +- [ ] GRANT +- [ ] REVOKE + +### DML + +- [ ] INSERT +- [ ] UPDATE +- [ ] DELETE +- [ ] MERGE +- [ ] TRUNCATE TABLE +- [ ] SELECT + +### TCL + +- [ ] START TRANSACTION +- [ ] COMMIT +- [ ] ROLLBACK +- [ ] SAVEPOINT +- [ ] RELEASE SAVEPOINT +- [ ] SET TRANSACTION diff --git a/src/dcl/mod.rs b/src/dcl/mod.rs new file mode 100644 index 0000000..91e3f4d --- /dev/null +++ b/src/dcl/mod.rs @@ -0,0 +1 @@ +//! Data Control Language diff --git a/src/ddl/mod.rs b/src/ddl/mod.rs new file mode 100644 index 0000000..df5b36e --- /dev/null +++ b/src/ddl/mod.rs @@ -0,0 +1 @@ +//! Data Definition Language diff --git a/src/dml/mod.rs b/src/dml/mod.rs new file mode 100644 index 0000000..333a79c --- /dev/null +++ b/src/dml/mod.rs @@ -0,0 +1 @@ +//! Data Manipulation Language diff --git a/src/lib.rs b/src/lib.rs index 47fc307..7ef11de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,8 +8,10 @@ pub mod internal; pub mod serializers; pub mod typeref; -// Later tasks: -// - Start writing query ASTs. +pub mod dcl; +pub mod ddl; +pub mod dml; +pub mod tcl; /// RapidQuery native extension. #[pyo3::pymodule(gil_used = false)] diff --git a/src/tcl/mod.rs b/src/tcl/mod.rs new file mode 100644 index 0000000..65e5dc6 --- /dev/null +++ b/src/tcl/mod.rs @@ -0,0 +1 @@ +//! Transaction Control Language From 384a441230db8313574944d66c89379dedfca3cb Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Thu, 15 Oct 2026 11:03:42 +0330 Subject: [PATCH 85/87] Update docstrings --- rapidquery/dialect.pyi | 3 -- rapidquery/expr.pyi | 75 ++++++++++++++++++++++++++++++++++------- src/dialect/base.rs | 1 - src/dialect/flags.rs | 8 ++--- src/expr/enum_/nodes.rs | 9 +++-- 5 files changed, 69 insertions(+), 27 deletions(-) diff --git a/rapidquery/dialect.pyi b/rapidquery/dialect.pyi index fdbddb1..df1d110 100644 --- a/rapidquery/dialect.pyi +++ b/rapidquery/dialect.pyi @@ -158,9 +158,6 @@ OPT_COLLATE_INFIX_IDENT: typing.Final[int] OPT_COLLATE_FUNCTION_STRING: typing.Final[int] """`COLLATE` is a function and the name is a string.""" -OPT_BITWISE_UNSUPPORTED: typing.Final[int] -"""Bitwise operators are rejected.""" - OPT_BITWISE_INFIX: typing.Final[int] """Bitwise AND, OR, NOT, and shifts are infix operators.""" diff --git a/rapidquery/expr.pyi b/rapidquery/expr.pyi index e2325a9..44ca754 100644 --- a/rapidquery/expr.pyi +++ b/rapidquery/expr.pyi @@ -140,22 +140,53 @@ class Expr: """SQL bitwise ``self & other``.""" def bit_or(self, other: object, /) -> BinaryExpr: - """SQL bitwise ``self | other``.""" + """ + SQL bitwise ``self | other``. + + Is not supported by Redshift, Oracle. + """ def bit_xor(self, other: object, /) -> BinaryExpr: - """SQL bitwise ``self ^ other``.""" + """ + SQL bitwise ``self ^ other``. + + Is not supported by Oracle, Redshift, SQLite. + """ + + def bit_not(self) -> UnaryExpr: + """ + SQL ``~self``. This is not bound to Python ``~``. + + Is not supported by Redshift, Oracle. + """ def __lshift__(self, other: object, /) -> BinaryExpr: - """SQL ``self << other``.""" + """ + SQL ``self << other``. + + Is not supported by MsSQL, Oracle. + """ def __rlshift__(self, other: object, /) -> BinaryExpr: - """SQL ``other << self``, when the left operand is not an expression.""" + """ + SQL ``other << self``, when the left operand is not an expression. + + Is not supported by MsSQL, Oracle. + """ def __rshift__(self, other: object, /) -> BinaryExpr: - """SQL ``self >> other``.""" + """ + SQL ``self >> other``. + + Is not supported by MsSQL, Oracle. + """ def __rrshift__(self, other: object, /) -> BinaryExpr: - """SQL ``other >> self``, when the left operand is not an expression.""" + """ + SQL ``other >> self``, when the left operand is not an expression. + + Is not supported by MsSQL, Oracle. + """ def concat(self, other: object, /) -> BinaryExpr: """SQL ``self || other``. Dialects may rewrite ``||`` as a function or as ``+``.""" @@ -169,9 +200,6 @@ class Expr: def not_(self) -> UnaryExpr: """SQL ``NOT self``.""" - def bit_not(self) -> UnaryExpr: - """SQL ``~self``. This is not bound to Python ``~``.""" - def between(self, low: object, high: object, /) -> BetweenExpr: """SQL ``self BETWEEN low AND high``.""" @@ -503,13 +531,23 @@ class Function(Expr): Args: predicate: Boolean expression placed in the filter. + + This method is not supported by Hive, MsSQL, MySQL, Redshift, Snowflake, Teradata. """ def ignore_nulls(self) -> Self: - """Return a copy that writes `IGNORE NULLS`.""" + """ + Return a copy that writes `IGNORE NULLS`. + + Is not supported by Hive, MySQL, PostgreSql, Sqlite. + """ def respect_nulls(self) -> Self: - """Return a copy that writes `RESPECT NULLS`.""" + """ + Return a copy that writes `RESPECT NULLS`. + + Is not supported by Hive, MySQL, PostgreSql, Sqlite. + """ def get_name(self) -> Ident: """The function name.""" @@ -717,6 +755,8 @@ class IsDistinctFromExpr(Expr): left: Left expression. right: Right expression. negated: Write `IS NOT DISTINCT FROM`. + + ``IS DISTINCT FROM`` is not supported by Oracle, Redshift, Teradata. """ def get_left(self) -> Expr: @@ -832,7 +872,8 @@ class LikeExpr(Expr): pattern: One pattern, or a list or tuple of patterns for `ANY`. negated: Negate the match. case_sensitive: When false, render the dialect's case-insensitive form. - escape: One escape character, or `None` to omit `ESCAPE`. + escape: One escape character, or `None` to omit ``ESCAPE``. ``ESCAPE`` is + not supported by BigQuery, Hive. Raises: TypeError: `escape` is not one character, or `pattern` is not an @@ -877,6 +918,8 @@ class SimilarToExpr(Expr): expr: Tested expression. pattern: SQL regular pattern. negated: Write `NOT SIMILAR TO`. + + ``SIMILAR TO`` is supported by DuckDB, PostgreSql, Redshift. """ def get_expr(self) -> Expr: @@ -911,6 +954,8 @@ class RLikeExpr(Expr): expr: Tested expression. pattern: Regular expression. negated: Negate the match. + + ``RLIKE`` is not supported by MsSQL, Sqlite, Teradata. """ def get_expr(self) -> Expr: @@ -952,7 +997,9 @@ class CastExpr(Expr): Args: expr: Input expression. data_type_name: SQL type name, such as `"int"` or `"varchar(16)"`. - kind: `CAST`, `TRY_CAST`, or `::`. + kind: `CAST`, `TRY_CAST`, or `::`. ``TRY_CAST`` is not supported by + Hive, MySQL, Oracle, PostgreSql, Sqlite. ``::`` is supported + by Databricks, DuckDB, PostgreSql, Redshift, Snowflake, Spark. Raises: ValueError: `kind` is not one of those three spellings. @@ -1035,6 +1082,8 @@ class CollateExpr(Expr): Args: expr: Input expression. collation: Collation name, as an identifier. + + ``COLLATE`` is not supported by Hive, Spark, Teradata. """ def get_expr(self) -> Expr: diff --git a/src/dialect/base.rs b/src/dialect/base.rs index 67e489b..a8c47b5 100644 --- a/src/dialect/base.rs +++ b/src/dialect/base.rs @@ -120,7 +120,6 @@ rapidquery_macros::dialect_options! { } enum Bitwise bitwise { - Unsupported => OPT_BITWISE_UNSUPPORTED, Infix => OPT_BITWISE_INFIX, InfixAndShift => OPT_BITWISE_INFIX_AND_SHIFT, InfixNoShift => OPT_BITWISE_INFIX_NO_SHIFT, diff --git a/src/dialect/flags.rs b/src/dialect/flags.rs index 954ba37..d514121 100644 --- a/src/dialect/flags.rs +++ b/src/dialect/flags.rs @@ -206,8 +206,6 @@ pub enum Collate { #[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Bitwise { - /// All raises. - Unsupported, /// `& | ~ << >>`. #[fallback] Infix, @@ -216,13 +214,13 @@ pub enum Bitwise { /// Redshift documents those three and not bitwise OR or NOT /// (`^` is exponentiation and stays on [`BitXorSyntax`]). InfixAndShift, - /// `& | ~`. Shifts raise (SQL Server). + /// `& | ~`. Shifts raise. InfixNoShift, /// `BITAND` / `BITOR` / `BITNOT` / `BITSHIFTLEFT` / `BITSHIFTRIGHT`. BitShiftFunctions, - /// `BITAND` / `BITOR` / `BITNOT` / `SHIFTLEFT` / `SHIFTRIGHT` (Teradata). + /// `BITAND` / `BITOR` / `BITNOT` / `SHIFTLEFT` / `SHIFTRIGHT`. ShiftFunctions, - /// `BITAND` only. `|`, `~`, `<<`, and `>>` raise (Oracle). + /// `BITAND` only. `|`, `~`, `<<`, and `>>` raise. BitAndOnly, } diff --git a/src/expr/enum_/nodes.rs b/src/expr/enum_/nodes.rs index 49fd6ca..7cbd80e 100644 --- a/src/expr/enum_/nodes.rs +++ b/src/expr/enum_/nodes.rs @@ -142,7 +142,7 @@ impl WriteSql for Unary { out.push_char(')'); return Ok(()); } - flags::Bitwise::BitAndOnly | flags::Bitwise::Unsupported | flags::Bitwise::InfixAndShift => { + flags::Bitwise::BitAndOnly | flags::Bitwise::InfixAndShift => { return Err(unsupported("bitwise NOT is not supported by this dialect")); } } @@ -296,14 +296,13 @@ impl WriteSql for Binary { flags::Bitwise::BitShiftFunctions | flags::Bitwise::ShiftFunctions | flags::Bitwise::BitAndOnly => { write_binary_call(dialect, out, "BITAND", &self.left, &self.right) } - flags::Bitwise::Unsupported => Err(unsupported("bitwise AND is not supported by this dialect")), }, BinaryOp::BitOr => match dialect.0.bitwise() { flags::Bitwise::Infix | flags::Bitwise::InfixNoShift => write_infix(self, dialect, out, "|", false), flags::Bitwise::BitShiftFunctions | flags::Bitwise::ShiftFunctions => { write_binary_call(dialect, out, "BITOR", &self.left, &self.right) } - flags::Bitwise::BitAndOnly | flags::Bitwise::Unsupported | flags::Bitwise::InfixAndShift => { + flags::Bitwise::BitAndOnly | flags::Bitwise::InfixAndShift => { Err(unsupported("bitwise OR is not supported by this dialect")) } }, @@ -313,7 +312,7 @@ impl WriteSql for Binary { write_binary_call(dialect, out, "BITSHIFTLEFT", &self.left, &self.right) } flags::Bitwise::ShiftFunctions => write_binary_call(dialect, out, "SHIFTLEFT", &self.left, &self.right), - flags::Bitwise::InfixNoShift | flags::Bitwise::BitAndOnly | flags::Bitwise::Unsupported => { + flags::Bitwise::InfixNoShift | flags::Bitwise::BitAndOnly => { Err(unsupported("bitwise shift is not supported by this dialect")) } }, @@ -325,7 +324,7 @@ impl WriteSql for Binary { flags::Bitwise::ShiftFunctions => { write_binary_call(dialect, out, "SHIFTRIGHT", &self.left, &self.right) } - flags::Bitwise::InfixNoShift | flags::Bitwise::BitAndOnly | flags::Bitwise::Unsupported => { + flags::Bitwise::InfixNoShift | flags::Bitwise::BitAndOnly => { Err(unsupported("bitwise shift is not supported by this dialect")) } }, From 4dc45f26d5464e57ad7da6bdb0bf78d7a6219247 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Thu, 15 Oct 2026 12:05:24 +0330 Subject: [PATCH 86/87] Add `dcl`, `ddl`, `dml`, and `tcl` modules --- pyproject.toml | 6 +++--- rapidquery/__init__.py | 13 ++++++------- rapidquery/dcl.py | 5 +++++ rapidquery/dcl.pyi | 6 ++++++ rapidquery/ddl.py | 5 +++++ rapidquery/ddl.pyi | 6 ++++++ rapidquery/dialect.py | 2 -- rapidquery/dml.py | 5 +++++ rapidquery/dml.pyi | 6 ++++++ rapidquery/tcl.py | 5 +++++ rapidquery/tcl.pyi | 6 ++++++ src/dcl/mod.rs | 11 +++++++++++ src/ddl/mod.rs | 11 +++++++++++ src/dml/mod.rs | 11 +++++++++++ src/lib.rs | 12 ++++++++++++ src/tcl/mod.rs | 11 +++++++++++ 16 files changed, 109 insertions(+), 12 deletions(-) create mode 100644 rapidquery/dcl.py create mode 100644 rapidquery/dcl.pyi create mode 100644 rapidquery/ddl.py create mode 100644 rapidquery/ddl.pyi create mode 100644 rapidquery/dml.py create mode 100644 rapidquery/dml.pyi create mode 100644 rapidquery/tcl.py create mode 100644 rapidquery/tcl.pyi diff --git a/pyproject.toml b/pyproject.toml index 2a54dcd..d58703a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,9 +60,9 @@ lint = [ "ruff>=0.16.9", ] test = [ - "hypothesis>=6.0.0", - "pytest>=8.0.0", - "pytest-xdist>=3.8.0", + "hypothesis>=6.0.0", + "pytest>=8.0.0", + "pytest-xdist>=3.8.0", ] [build-system] diff --git a/rapidquery/__init__.py b/rapidquery/__init__.py index 8206e08..444ee14 100644 --- a/rapidquery/__init__.py +++ b/rapidquery/__init__.py @@ -1,17 +1,16 @@ -from . import dialect as dialect -from . import expr as expr -from . import serializers as serializers -from .expr import Asterisk as Asterisk -from .expr import Ident as Ident -from .expr import Unquote as Unquote -from .expr import Value as Value +from . import dcl, ddl, dialect, dml, expr, serializers, tcl +from .expr import Asterisk, Ident, Unquote, Value __all__ = [ "Asterisk", "Ident", "Unquote", "Value", + "dcl", + "ddl", "dialect", + "dml", "expr", "serializers", + "tcl", ] diff --git a/rapidquery/dcl.py b/rapidquery/dcl.py new file mode 100644 index 0000000..dcd839e --- /dev/null +++ b/rapidquery/dcl.py @@ -0,0 +1,5 @@ +from ._core.dcl import DataControlLanguage + +__all__ = [ + "DataControlLanguage", +] diff --git a/rapidquery/dcl.pyi b/rapidquery/dcl.pyi new file mode 100644 index 0000000..b950892 --- /dev/null +++ b/rapidquery/dcl.pyi @@ -0,0 +1,6 @@ +"""Data Control Language""" + +class DataControlLanguage: + """Base of every DCL statement. Construct a subclass, not this class.""" + + ... diff --git a/rapidquery/ddl.py b/rapidquery/ddl.py new file mode 100644 index 0000000..6e97d68 --- /dev/null +++ b/rapidquery/ddl.py @@ -0,0 +1,5 @@ +from ._core.ddl import DataDefinitionLanguage + +__all__ = [ + "DataDefinitionLanguage", +] diff --git a/rapidquery/ddl.pyi b/rapidquery/ddl.pyi new file mode 100644 index 0000000..d513aa1 --- /dev/null +++ b/rapidquery/ddl.pyi @@ -0,0 +1,6 @@ +"""Data Definition Language""" + +class DataDefinitionLanguage: + """Base of every DDL statement. Construct a subclass, not this class.""" + + ... diff --git a/rapidquery/dialect.py b/rapidquery/dialect.py index 7eb945d..5d11267 100644 --- a/rapidquery/dialect.py +++ b/rapidquery/dialect.py @@ -14,7 +14,6 @@ OPT_BITWISE_INFIX_AND_SHIFT, OPT_BITWISE_INFIX_NO_SHIFT, OPT_BITWISE_SHIFT_FUNCTIONS, - OPT_BITWISE_UNSUPPORTED, OPT_BITXOR_BIT_XOR_FUNCTION, OPT_BITXOR_CARET, OPT_BITXOR_HASH, @@ -141,7 +140,6 @@ "OPT_BITWISE_INFIX_AND_SHIFT", "OPT_BITWISE_INFIX_NO_SHIFT", "OPT_BITWISE_SHIFT_FUNCTIONS", - "OPT_BITWISE_UNSUPPORTED", "OPT_BITXOR_BIT_XOR_FUNCTION", "OPT_BITXOR_CARET", "OPT_BITXOR_HASH", diff --git a/rapidquery/dml.py b/rapidquery/dml.py new file mode 100644 index 0000000..a49e5f3 --- /dev/null +++ b/rapidquery/dml.py @@ -0,0 +1,5 @@ +from ._core.dml import DataManipulationLanguage + +__all__ = [ + "DataManipulationLanguage", +] diff --git a/rapidquery/dml.pyi b/rapidquery/dml.pyi new file mode 100644 index 0000000..1e66ce2 --- /dev/null +++ b/rapidquery/dml.pyi @@ -0,0 +1,6 @@ +"""Data Manipulation Language""" + +class DataManipulationLanguage: + """Base of every DML statement. Construct a subclass, not this class.""" + + ... diff --git a/rapidquery/tcl.py b/rapidquery/tcl.py new file mode 100644 index 0000000..39039f9 --- /dev/null +++ b/rapidquery/tcl.py @@ -0,0 +1,5 @@ +from ._core.tcl import TransactionControlLanguage + +__all__ = [ + "TransactionControlLanguage", +] diff --git a/rapidquery/tcl.pyi b/rapidquery/tcl.pyi new file mode 100644 index 0000000..4c9f8b8 --- /dev/null +++ b/rapidquery/tcl.pyi @@ -0,0 +1,6 @@ +"""Transaction Control Language""" + +class TransactionControlLanguage: + """Base of every TCL statement. Construct a subclass, not this class.""" + + ... diff --git a/src/dcl/mod.rs b/src/dcl/mod.rs index 91e3f4d..85c397e 100644 --- a/src/dcl/mod.rs +++ b/src/dcl/mod.rs @@ -1 +1,12 @@ //! Data Control Language + +/// Base of every DCL statement. Construct a subclass, not this class. +#[rapidquery_macros::pyclass("rapidquery.dcl.DataControlLanguage", subclass, skip_from_py_object)] +#[derive(Debug, Clone, Copy)] +pub struct PyDataControlLanguage; + +#[pyo3::pymodule(name = "dcl", gil_used = false)] +pub mod dcl_mod { + #[pymodule_export] + use super::PyDataControlLanguage; +} diff --git a/src/ddl/mod.rs b/src/ddl/mod.rs index df5b36e..a213695 100644 --- a/src/ddl/mod.rs +++ b/src/ddl/mod.rs @@ -1 +1,12 @@ //! Data Definition Language + +/// Base of every DDL statement. Construct a subclass, not this class. +#[rapidquery_macros::pyclass("rapidquery.ddl.DataDefinitionLanguage", subclass, skip_from_py_object)] +#[derive(Debug, Clone, Copy)] +pub struct PyDataDefinitionLanguage; + +#[pyo3::pymodule(name = "ddl", gil_used = false)] +pub mod ddl_mod { + #[pymodule_export] + use super::PyDataDefinitionLanguage; +} diff --git a/src/dml/mod.rs b/src/dml/mod.rs index 333a79c..e5a6710 100644 --- a/src/dml/mod.rs +++ b/src/dml/mod.rs @@ -1 +1,12 @@ //! Data Manipulation Language + +/// Base of every DML statement. Construct a subclass, not this class. +#[rapidquery_macros::pyclass("rapidquery.dml.DataManipulationLanguage", subclass, skip_from_py_object)] +#[derive(Debug, Clone, Copy)] +pub struct PyDataManipulationLanguage; + +#[pyo3::pymodule(name = "dml", gil_used = false)] +pub mod dml_mod { + #[pymodule_export] + use super::PyDataManipulationLanguage; +} diff --git a/src/lib.rs b/src/lib.rs index 7ef11de..d9d852d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,18 @@ mod _core { #[pymodule_export] use super::expr::expr_mod; + #[pymodule_export] + use super::ddl::ddl_mod; + + #[pymodule_export] + use super::dcl::dcl_mod; + + #[pymodule_export] + use super::dml::dml_mod; + + #[pymodule_export] + use super::tcl::tcl_mod; + #[pymodule_init] #[cold] fn init(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> { diff --git a/src/tcl/mod.rs b/src/tcl/mod.rs index 65e5dc6..eb87779 100644 --- a/src/tcl/mod.rs +++ b/src/tcl/mod.rs @@ -1 +1,12 @@ //! Transaction Control Language + +/// Base of every TCL statement. Construct a subclass, not this class. +#[rapidquery_macros::pyclass("rapidquery.tcl.TransactionControlLanguage", subclass, skip_from_py_object)] +#[derive(Debug, Clone, Copy)] +pub struct PyTransactionControlLanguage; + +#[pyo3::pymodule(name = "tcl", gil_used = false)] +pub mod tcl_mod { + #[pymodule_export] + use super::PyTransactionControlLanguage; +} From d093fdf9c9a2df53d4d2e9c9c9ff0b14333e8d24 Mon Sep 17 00:00:00 2001 From: "A.Wolver.P" Date: Thu, 15 Oct 2026 12:10:35 +0330 Subject: [PATCH 87/87] Update CONTRIBUTING.md --- CONTRIBUTING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 13b5f14..a77863a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,8 @@ It should be extremely simple to get started and create a PR. All PRs are welcom To make contributing as easy and fast as possible, you'll want to run tests and linting locally. +If you're going to add a feature or make a change to SQL, our _current_ priority is things that are supported in at least 3 dialects or defined in Standard SQL. + > Use `make format` to fix formatting, and `make` to run tests and linting. ## AI Policy