diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2cef7fa..ae0d5e5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -92,7 +92,7 @@ jobs:
run: uv run maturin develop --release
- name: Run pytest
- run: uv run pytest tests/ -v --tb=short
+ run: uv run pytest ryx-python/tests/ -v --tb=short
- name: Run examples smoke test
run: |
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 763ba42..c331251 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -168,3 +168,35 @@ jobs:
with:
packages-dir: dist/
skip-existing: true
+
+ # Publish Rust crates to crates.io
+ publish-cratesio:
+ name: Publish to crates.io
+ runs-on: ubuntu-latest
+ needs: [build-wheels, build-sdist]
+ if: startsWith(github.ref, 'refs/tags/v')
+ environment:
+ name: cratesio
+ url: https://crates.io/crates/ryx-rs
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Cache Rust dependencies
+ uses: Swatinem/rust-cache@v2
+
+ - name: Publish crates to crates.io
+ env:
+ CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+ run: |
+ for crate in ryx-macro ryx-query ryx-common ryx-backend ryx-rs; do
+ echo "::group::Publishing $crate"
+ FEATURES=""
+ [ "$crate" = "ryx-backend" ] && FEATURES="--no-default-features"
+ cargo publish -p "$crate" $FEATURES 2>&1 || echo "::warning:: $crate publish exited $? — likely already published or version mismatch"
+ echo "::endgroup::"
+ done
diff --git a/Cargo.lock b/Cargo.lock
index c5960b5..68c9c5e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -52,6 +52,21 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "arc-swap"
+version = "1.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
+dependencies = [
+ "rustversion",
+]
+
[[package]]
name = "async-channel"
version = "1.9.0"
@@ -380,6 +395,20 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "memchr",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+]
+
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -799,6 +828,19 @@ dependencies = [
"wasi",
]
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasip3",
+]
+
[[package]]
name = "gloo-timers"
version = "0.3.0"
@@ -1005,6 +1047,12 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
[[package]]
name = "idna"
version = "1.1.0"
@@ -1034,6 +1082,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.0",
+ "serde",
+ "serde_core",
]
[[package]]
@@ -1092,6 +1142,12 @@ dependencies = [
"spin",
]
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
[[package]]
name = "libc"
version = "0.2.186"
@@ -1202,6 +1258,16 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
@@ -1426,6 +1492,16 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -1529,6 +1605,12 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
[[package]]
name = "rand"
version = "0.8.6"
@@ -1556,7 +1638,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
- "getrandom",
+ "getrandom 0.2.17",
]
[[package]]
@@ -1579,6 +1661,28 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "redis"
+version = "0.28.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e37ec3fd44bea2ec947ba6cc7634d7999a6590aca7c35827c250bc0de502bda6"
+dependencies = [
+ "arc-swap",
+ "bytes",
+ "combine",
+ "futures-util",
+ "itoa",
+ "num-bigint",
+ "percent-encoding",
+ "pin-project-lite",
+ "ryu",
+ "sha1_smol",
+ "socket2 0.5.10",
+ "tokio",
+ "tokio-util",
+ "url",
+]
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -1679,17 +1783,30 @@ dependencies = [
"criterion",
"dashmap",
"once_cell",
+ "ryx-common",
"ryx-core",
"ryx-query",
"serde",
"serde_json",
"smallvec",
"sqlx",
+ "tempfile",
"thiserror",
"tokio",
"tracing",
]
+[[package]]
+name = "ryx-common"
+version = "0.1.0"
+dependencies = [
+ "ryx-query",
+ "serde",
+ "sqlx",
+ "thiserror",
+ "tokio",
+]
+
[[package]]
name = "ryx-core"
version = "0.1.2"
@@ -1699,6 +1816,7 @@ dependencies = [
"once_cell",
"pyo3",
"pyo3-async-runtimes",
+ "ryx-common",
"ryx-query",
"serde",
"serde_json",
@@ -1710,6 +1828,15 @@ dependencies = [
"tracing-subscriber",
]
+[[package]]
+name = "ryx-macro"
+version = "0.1.0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "ryx-query"
version = "0.1.0"
@@ -1725,6 +1852,27 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "ryx-rs"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "chrono",
+ "criterion",
+ "once_cell",
+ "redis",
+ "ryx-backend",
+ "ryx-common",
+ "ryx-macro",
+ "ryx-query",
+ "serde",
+ "serde_json",
+ "serde_yaml",
+ "sqlx",
+ "tokio",
+ "toml",
+]
+
[[package]]
name = "same-file"
version = "1.0.6"
@@ -1740,6 +1888,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
[[package]]
name = "serde"
version = "1.0.228"
@@ -1783,6 +1937,15 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -1795,6 +1958,19 @@ dependencies = [
"serde",
]
+[[package]]
+name = "serde_yaml"
+version = "0.9.34+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
[[package]]
name = "sha1"
version = "0.10.6"
@@ -1806,6 +1982,12 @@ dependencies = [
"digest",
]
+[[package]]
+name = "sha1_smol"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
+
[[package]]
name = "sha2"
version = "0.10.9"
@@ -1867,6 +2049,16 @@ dependencies = [
"serde",
]
+[[package]]
+name = "socket2"
+version = "0.5.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "socket2"
version = "0.6.3"
@@ -2143,6 +2335,19 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.2",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "thiserror"
version = "2.0.18"
@@ -2219,7 +2424,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
- "socket2",
+ "socket2 0.6.3",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -2246,6 +2451,60 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_write",
+ "winnow",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
[[package]]
name = "tracing"
version = "0.1.44"
@@ -2341,6 +2600,18 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+
[[package]]
name = "url"
version = "2.5.8"
@@ -2409,6 +2680,24 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen 0.57.1",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen 0.51.0",
+]
+
[[package]]
name = "wasite"
version = "0.1.0"
@@ -2470,6 +2759,40 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
[[package]]
name = "web-sys"
version = "0.3.97"
@@ -2564,7 +2887,16 @@ version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
- "windows-targets",
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
]
[[package]]
@@ -2582,13 +2914,29 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
- "windows_aarch64_gnullvm",
- "windows_aarch64_msvc",
- "windows_i686_gnu",
- "windows_i686_msvc",
- "windows_x86_64_gnu",
- "windows_x86_64_gnullvm",
- "windows_x86_64_msvc",
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
]
[[package]]
@@ -2597,42 +2945,193 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+[[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.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+[[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.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+[[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.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+[[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.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+[[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.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+[[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.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "prettyplease",
+ "syn",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
[[package]]
name = "writeable"
version = "0.6.3"
diff --git a/Cargo.toml b/Cargo.toml
index 72bda66..cd58380 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,6 +4,9 @@ members = [
"ryx-backend",
"ryx-query",
"ryx-python",
+ "ryx-common",
+ "ryx-macro",
+ "ryx-rs",
]
resolver = "2"
@@ -71,16 +74,16 @@ thiserror = "2"
# on Rust 1.70+, but once_cell has a slightly nicer API for our use case.
once_cell = "1"
+# Config file parsing (optional, used by ryx-rs config module)
+toml = "0.8"
+serde_yaml = "0.9"
+
# tracing: structured, async-aware logging. We instrument every SQL execution
# so users can enable RUST_LOG=ryx=debug for full query visibility.
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
-[workspace.dev-dependencies]
-# tokio test macro for async unit tests
-tokio = { version = "1.40", features = ["full", "test-util"] }
-criterion = { version = "0.5", features = ["async_tokio"] }
#
diff --git a/README.md b/README.md
index 236f312..1061dd2 100644
--- a/README.md
+++ b/README.md
@@ -5,130 +5,121 @@
Ryx ORM
- Django-style Python ORM. Powered by Rust.
+ Django-style ORM — Python and Rust. Powered by Rust.
-
-
+
+
-
-
-
-
+
-
- Quick Start •
- Features •
- Showcase •
- Docs •
- Discord
-
-
---
-Ryx gives you the query API you love — `.filter()`, `Q` objects, aggregations, relationships — with the raw performance of a compiled Rust core. Async-native. Zero event-loop blocking.
+## Dual-Language ORM
```python
import ryx
-from ryx import (
- Model, CharField, IntField, BooleanField,
- DateTimeField, Q, Count, Sum
-)
+from ryx import Model, CharField, Q
class Post(Model):
- title = CharField(max_length=200)
- slug = CharField(max_length=210, unique=True)
- views = IntField(default=0)
+ title = CharField(max_length=200)
+ views = IntField(default=0)
active = BooleanField(default=True)
- created = DateTimeField(auto_now_add=True)
- class Meta:
- ordering = ["-created"]
-
-# Setup once
await ryx.setup("postgres://user:pass@localhost/mydb")
+posts = await Post.objects.filter(Q(active=True) | Q(views__gte=1000))
+```
+
+```rust
+use ryx_rs::model;
+
+#[model]
+struct Post {
+ #[field(pk)] id: i64,
+ title: String,
+ views: i64,
+ active: bool,
+}
+
+let posts = Post::objects()
+ .filter(Q::or(Q::new("active", true), Q::new("views__gte", 1000)))
+ .all().await?;
+```
+
+## Quick Install
-# Query like Django, run like Rust
-posts = await (
- Post.objects
- .filter(Q(active=True) | Q(views__gte=1000))
- .exclude(title__startswith="Draft")
- .order_by("-views")
- .limit(20)
-)
-
-# Aggregations
-stats = await Post.objects.aggregate(
- total=Count("id"), avg_views=Avg("views"), top=Max("views"),
-)
-
-# Transactions with savepoints
-async with ryx.transaction():
- post = await Post.objects.create(title="Atomic post", slug="atomic")
- await post.save()
+```bash
+pip install ryx # Python
+cargo add ryx-rs ryx-macro # Rust
```
-## Why Ryx
+## Documentation
+
+Full docs, guides, API reference: **[ryx.alldotpy.com](https://ryx.alldotpy.com)**
+
+- [Python quick start](https://ryx.alldotpy.com/getting-started/quick-start)
+- [Rust quick start](https://ryx.alldotpy.com/getting-started/installation)
-| | Django ORM | SQLAlchemy | **Ryx** |
+## Comparison
+
+| | Diesel | SeaORM | **Ryx (Rust)** |
|---|---|---|---|
-| **API** | Ergonomic | Verbose | **Ergonomic** |
-| **Runtime** | Sync Python | Async Python | **Async Rust** |
-| **GIL blocking** | Yes | Yes | **Zero** |
-| **Backends** | All | All | **PG · MySQL · SQLite** |
-| **Migrations** | Built-in | Alembic | **Built-in** |
+| **API style** | Schema-first | Verbose builders | **Django-like** |
+| **Q objects (OR/AND/NOT)** | ❌ | ❌ | ✅ |
+| **Lookups** | Basic | Basic | **30+** |
+| **select_related** | ❌ | ✅ (Eager) | ✅ |
+| **Migrations** | Diesel CLI | sea-orm-cli | **Built-in** |
+| **Backends** | PG · MySQL · SQLite | PG · MySQL · SQLite | **PG · MySQL · SQLite** |
## Architecture
-
+
-
-Your Python queries are compiled to SQL in Rust, executed by sqlx, and decoded back — all without blocking the Python event loop.
-To achieve near-native performance, Ryx uses a **multi-crate workspace architecture**:
-- `ryx-query`: A standalone, ultra-fast SQL compiler.
-- `ryx-backend`: High-performance database drivers using **Enum Dispatch** (no vtables) to eliminate runtime overhead.
-- `ryx-core`: Shared base types and the core ORM engine.
-- `ryx-python`: Optimized PyO3 bindings.
+```
+ Python (ryx-python) Rust (ryx-rs)
+ │ │
+ PyO3 bridge ────────╗ no pyo3
+ │ ║ │
+ ┌─────┴─────────────║───────────┴──────┐
+ │ ryx-core ║ ryx-common │
+ └─────┬─────────────║───────────┬──────┘
+ │ ║ │
+ ┌─────┴─────────────║───────────┴──────┐
+ │ ryx-query (SQL compiler) │
+ └───────────────────┬──────────────────┘
+ │
+ ┌───────────────────┴──────────────────┐
+ │ ryx-backend (sqlx) │
+ │ Postgres · MySQL · SQLite │
+ └──────────────────────────────────────┘
+```
-**Key Performance Innovation**: Ryx uses a **Zero-Allocation Row View** system. Instead of creating a Python dictionary for every row, we use a shared column mapping and a flat value vector, drastically reducing heap allocations and GC pressure during large fetches.
-
## Performance
-
-Benchmark of 1 000 rows on SQLite (lower is better):
-
-| Operation | Ryx ORM | SQLAlchemy ORM | SQLAlchemy Core | Ryx raw |
-|-----------|--------:|---------------:|----------------:|--------:|
-| **bulk_create** | 0.0074 s | 0.1696 s | 0.0022 s | 0.0011 s |
-| **bulk_update** | 0.0023 s | 0.0018 s | 0.0010 s | 0.0005 s |
-| **bulk_delete** | 0.0005 s | 0.0012 s | 0.0009 s | 0.0004 s |
-| **filter + order + limit** | 0.0009 s | 0.0019 s | 0.0008 s | 0.0004 s |
-| **aggregate** | 0.0002 s | 0.0015 s | 0.0005 s | 0.0001 s |
-
-Ryx ORM is **16× faster** than SQLAlchemy ORM on bulk inserts and **2× faster** on deletes — while keeping the same Django-style API. The raw SQL layer (`raw_execute` / `raw_fetch`) gives you near-C speed when you need it.
-
-**Internal Compilation Speed**: Our query compiler is blindingly fast, with simple lookups compiled in **~248ns** and complex query trees in **~1µs**.
-
-Run the benchmark yourself:
+1 000 rows on SQLite (lower is better):
-## Documentation
-
-Full documentation with guides, API reference, and examples: **[docs](https://ryx.alldotpy.com)**
+| Operation | Ryx ORM | SQLAlchemy ORM | SQLAlchemy Core |
+|---|---|---|---|
+| **bulk_create** | 0.0074 s | 0.1696 s | 0.0022 s |
+| **bulk_update** | 0.0023 s | 0.0018 s | 0.0010 s |
+| **bulk_delete** | 0.0005 s | 0.0012 s | 0.0009 s |
+| **filter + order + limit** | 0.0009 s | 0.0019 s | 0.0008 s |
+| **aggregate** | 0.0002 s | 0.0015 s | 0.0005 s |
## Contributing
-See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, architecture details, and contribution guidelines.
+See [CONTRIBUTING.md](CONTRIBUTING.md)
## License
diff --git a/docs/doc/advanced/caching.mdx b/docs/doc/advanced/caching.mdx
index 39a8623..f954b82 100644
--- a/docs/doc/advanced/caching.mdx
+++ b/docs/doc/advanced/caching.mdx
@@ -62,6 +62,21 @@ class RedisCache(AbstractCache):
ryx.configure_cache(backend=RedisCache(redis_client), ttl=300)
```
+## Manual Invalidation
+
+```python
+import ryx.cache as cache
+
+# Invalidate a specific cache key
+await cache.invalidate("my_cache_key")
+
+# Invalidate all cache entries for a model
+await cache.invalidate_model(Post)
+
+# Invalidate the entire cache
+await cache.invalidate_all()
+```
+
## Memory Cache Options
```python
@@ -69,6 +84,9 @@ ryx.configure_cache(
ttl=300, # Time-to-live in seconds
max_size=1000, # Maximum number of cached queries
)
+
+# Access the cache backend directly
+backend = ryx.get_cache() # → Optional[AbstractCache]
```
## When to Cache
@@ -83,6 +101,31 @@ ryx.configure_cache(
- **Real-time requirements** — Stale data is unacceptable
- **Unique queries** — Every query has different parameters
+## Cache Key Mechanism
+
+Cache keys are generated as a SHA-256 hash of:
+- Model name
+- SQL query string
+- Bound parameter values
+
+The same query with the same parameters always produces the same cache key.
+
+## Full API Reference
+
+| Function | Description |
+|---|---|
+| `configure_cache(backend, auto_invalidate=True)` | Set the cache backend |
+| `get_cache()` | Get the current cache backend |
+| `make_cache_key(model_name, sql, values)` | Build a cache key manually |
+| `invalidate(key)` | Remove a specific cache entry |
+| `invalidate_model(model)` | Remove all cache entries for a model |
+| `invalidate_all()` | Clear the entire cache |
+
+| Class | Description |
+|---|---|
+| `AbstractCache` | Pluggable backend protocol |
+| `MemoryCache(max_size=1000, ttl=300)` | Built-in LRU in-process cache |
+
## Next Steps
→ **[Custom Lookups](./custom-lookups)** — Extend the query API
diff --git a/docs/doc/advanced/multi-db.mdx b/docs/doc/advanced/multi-db.mdx
index 046f0c0..c03934f 100644
--- a/docs/doc/advanced/multi-db.mdx
+++ b/docs/doc/advanced/multi-db.mdx
@@ -80,6 +80,25 @@ class MyProjectRouter(BaseRouter):
set_router(MyProjectRouter())
```
+## Utility Functions
+
+```python
+# List all configured database aliases
+aliases = ryx.list_aliases()
+# → ["default", "users", "logs"]
+
+# Check connection status
+is_up = ryx.is_connected("default") # → bool
+
+# Get pool stats
+stats = ryx.pool_stats("default")
+# → {"size": 5, "idle": 3}
+
+# Detect backend type
+backend = ryx.get_backend("default")
+# → "postgres" | "mysql" | "sqlite"
+```
+
## Multi-Database Transactions
Transactions in Ryx are tied to a specific database connection. To start a transaction on a non-default database, pass the `alias` to the `transaction()` context manager.
diff --git a/docs/doc/advanced/validation.mdx b/docs/doc/advanced/validation.mdx
index d57ec3d..1549725 100644
--- a/docs/doc/advanced/validation.mdx
+++ b/docs/doc/advanced/validation.mdx
@@ -41,12 +41,28 @@ class Product(Model):
| `MinValueValidator(n)` | Value ≥ n |
| `RangeValidator(min, max)` | Value in range |
| `NotBlankValidator()` | Not empty string |
+| `NotNullValidator()` | Not None |
| `RegexValidator(pattern)` | Matches regex |
| `EmailValidator()` | Valid email format |
| `URLValidator()` | Valid URL format |
| `ChoicesValidator(values)` | Value in allowed list |
-| `NotNullValidator()` | Not None |
-| `UniqueValueValidator()` | Unique in table |
+| `UniqueValueValidator()` | Unique in table (DB-enforced) |
+
+### Custom Validator (FunctionValidator)
+
+```python
+from ryx import FunctionValidator
+
+def must_be_even(value):
+ if value % 2 != 0:
+ raise ValidationError("Value must be even")
+
+class Product(Model):
+ code = CharField(
+ max_length=10,
+ validators=[FunctionValidator(must_be_even, message="Code must be even")],
+ )
+```
## Model-Level Validation
@@ -90,6 +106,23 @@ await product.save() # Raises ValidationError
await product.save(validate=False)
```
+## Merging Validation Errors
+
+```python
+from ryx import ValidationError
+
+try:
+ await product.full_clean()
+except ValidationError as e:
+ # e.errors → {"name": ["Too short"], "price": ["Negative"]}
+ pass
+
+# Merge multiple errors
+errors = ValidationError({"name": ["Invalid"]})
+errors.merge(ValidationError({"price": ["Out of range"]}))
+# errors.errors → {"name": ["Invalid"], "price": ["Out of range"]}
+```
+
## Collecting All Errors
`full_clean()` collects ALL errors from ALL fields before raising — you get the complete picture at once:
diff --git a/docs/doc/core-concepts/managers-and-querysets.mdx b/docs/doc/core-concepts/managers-and-querysets.mdx
index 479131e..b9ce73a 100644
--- a/docs/doc/core-concepts/managers-and-querysets.mdx
+++ b/docs/doc/core-concepts/managers-and-querysets.mdx
@@ -129,6 +129,14 @@ async for post in Post.objects.filter(active=True):
print(post.title)
```
+### Streaming
+
+```python
+async for batch in Post.objects.filter(active=True).stream(chunk_size=100, keyset="id"):
+ for post in batch:
+ print(post.title)
+```
+
### Count & Exists
```python
diff --git a/docs/doc/crud/bulk-operations.mdx b/docs/doc/crud/bulk-operations.mdx
index 58d6c01..917f7ea 100644
--- a/docs/doc/crud/bulk-operations.mdx
+++ b/docs/doc/crud/bulk-operations.mdx
@@ -18,10 +18,24 @@ posts = [
for i in range(1000)
]
+# Basic usage
created = await bulk_create(posts, batch_size=100)
-print(f"Created {len(created)} posts")
+
+# Skip validation (faster)
+created = await bulk_create(posts, batch_size=100, validate=False)
+
+# Ignore conflicts (PostgreSQL / MySQL)
+created = await bulk_create(posts, batch_size=100, ignore_conflicts=True)
```
+### Parameters
+
+| Param | Default | Description |
+|---|---|---|
+| `batch_size` | `500` | Rows per INSERT statement |
+| `validate` | `False` | Run field validators before insert |
+| `ignore_conflicts` | `False` | `INSERT ... ON CONFLICT DO NOTHING` |
+
### How It Works
Records are split into batches of `batch_size`. Each batch becomes a single multi-row INSERT:
@@ -72,12 +86,25 @@ Async generator for processing large result sets without loading everything into
```python
from ryx.bulk import stream
-async for batch in stream(Post.objects.all(), page_size=500):
+async for batch in stream(Post.objects.all(), chunk_size=500):
for post in batch:
process(post)
```
-Uses LIMIT/OFFSET pagination under the hood.
+Uses keyset pagination under the hood when `keyset` is provided, or LIMIT/OFFSET otherwise.
+
+For streaming with keyset-based pagination (more efficient for large datasets), use `QuerySet.stream()`:
+
+```python
+async for batch in (
+ Post.objects
+ .filter(active=True)
+ .order_by("id")
+ .stream(chunk_size=100, keyset="id")
+):
+ for post in batch:
+ process(post)
+```
## Performance Comparison
diff --git a/docs/doc/crud/reading.mdx b/docs/doc/crud/reading.mdx
index 489714f..f860e38 100644
--- a/docs/doc/crud/reading.mdx
+++ b/docs/doc/crud/reading.mdx
@@ -53,6 +53,13 @@ if await Post.objects.filter(active=True).exists():
count = await Post.objects.filter(active=True).count()
```
+## Bulk Lookup
+
+```python
+posts = await Post.objects.in_bulk([1, 2, 3, 4, 5])
+# → {1: , 2: , ...}
+```
+
## Slicing
```python
@@ -75,12 +82,37 @@ async for post in Post.objects.filter(active=True):
## Streaming Large Results
+### Via bulk.stream
+
```python
from ryx.bulk import stream
-async for batch in stream(Post.objects.filter(active=True), page_size=500):
+async for batch in stream(Post.objects.filter(active=True), chunk_size=500):
+ for post in batch:
+ process(post)
+```
+
+### Via QuerySet.stream
+
+```python
+# Keyset-based pagination (efficient for large datasets)
+async for batch in (
+ Post.objects
+ .filter(active=True)
+ .order_by("id")
+ .stream(chunk_size=100, keyset="id")
+):
for post in batch:
process(post)
+
+# As dicts instead of model instances
+async for batch in (
+ Post.objects
+ .filter(active=True)
+ .stream(chunk_size=100, as_dict=True)
+):
+ for row in batch:
+ print(row["title"])
```
## Next Steps
diff --git a/docs/doc/querying/filtering.mdx b/docs/doc/querying/filtering.mdx
index e02a93a..cabb7bd 100644
--- a/docs/doc/querying/filtering.mdx
+++ b/docs/doc/querying/filtering.mdx
@@ -141,8 +141,6 @@ import ryx
# Postgres ILIKE
ryx.register_lookup("ilike", "{col} ILIKE ?")
-
-# Usage
Post.objects.filter(title__ilike="%python%")
# Decorator style
@@ -151,6 +149,18 @@ def uuid_prefix_lookup(field, value):
"""{col}::text LIKE ?"""
```
+## Listing Available Lookups & Transforms
+
+```python
+# All built-in filter lookups
+lookups = ryx.available_lookups()
+# → ["exact", "gt", "gte", "lt", "lte", "contains", ...]
+
+# All date/json transforms
+transforms = ryx.available_transforms()
+# → ["date", "year", "month", "day", "key", "json", ...]
+```
+
## Next Steps
→ **[Q Objects](./q-objects)** — OR and NOT expressions
diff --git a/docs/doc/rust/advanced/caching.mdx b/docs/doc/rust/advanced/caching.mdx
new file mode 100644
index 0000000..5e35900
--- /dev/null
+++ b/docs/doc/rust/advanced/caching.mdx
@@ -0,0 +1,37 @@
+---
+sidebar_position: 14
+---
+
+# Caching
+
+Ryx includes an in-memory query cache.
+
+## Setup
+
+```rust
+use ryx_rs::cache::{MemoryCache, configure_cache};
+
+configure_cache(MemoryCache::new(1000));
+```
+
+Creates an LRU cache with 1000 entry capacity.
+
+## Cached Queries
+
+```rust
+let posts: Vec = Post::objects()
+ .filter("active", true)
+ .cache(60, Some("active_posts")) // TTL: 60 seconds, named key
+ .all().await?;
+```
+
+The `cache()` method takes:
+
+| Parameter | Description |
+|---|---|
+| TTL (seconds) | Time-to-live before the cache entry expires |
+| Key (optional) | Named key for manual invalidation |
+
+## Next Steps
+
+- **[Filtering](../querying/filtering)** — Query reference
diff --git a/docs/doc/rust/advanced/transactions.mdx b/docs/doc/rust/advanced/transactions.mdx
new file mode 100644
index 0000000..5ded0ca
--- /dev/null
+++ b/docs/doc/rust/advanced/transactions.mdx
@@ -0,0 +1,36 @@
+---
+sidebar_position: 13
+---
+
+# Transactions
+
+Ryx supports async transactions with explicit commit/rollback.
+
+## Basic Usage
+
+```rust
+use ryx_rs::transaction;
+
+transaction(|tx| async move {
+ Post::objects().filter("author", "bob").delete().await?;
+
+ Post::objects().create()
+ .set("title", "New Post")
+ .set("author_id", 1i64)
+ .save().await?;
+
+ tx.commit().await // or tx.rollback()
+}).await?;
+```
+
+The closure receives a transaction handle. Operations auto-rollback if the closure returns an error.
+
+## Safety
+
+- The active transaction is propagated through the async call stack via context
+- Nested calls to `transaction()` are not supported — use a single transaction block
+- Always call `tx.commit()` or `tx.rollback()` explicitly
+
+## Next Steps
+
+- **[Caching](./caching)** — Query result caching
diff --git a/docs/doc/rust/core-concepts/migrations.mdx b/docs/doc/rust/core-concepts/migrations.mdx
new file mode 100644
index 0000000..4687859
--- /dev/null
+++ b/docs/doc/rust/core-concepts/migrations.mdx
@@ -0,0 +1,54 @@
+---
+sidebar_position: 6
+---
+
+# Migrations
+
+Ryx can detect your models and create database tables automatically.
+
+## Basic Usage
+
+```rust
+use ryx_rs::migration::MigrationRunner;
+
+MigrationRunner::new()
+ .model::()
+ .model::()
+ .run().await?;
+```
+
+This introspects your database, diffs against your model definitions, and generates the necessary DDL (CREATE TABLE, ALTER COLUMN, etc.).
+
+## How It Works
+
+```
+1. Introspect live DB schema → SchemaState (current)
+2. Build target from models → SchemaState (desired)
+3. Diff the two states → List of changes
+4. Generate DDL → CREATE / ALTER statements
+5. Execute → Apply to database
+```
+
+## What Migrations Handle
+
+- Creating and dropping tables
+- Adding, altering, and dropping columns
+- Creating and dropping indexes
+- Adding constraints
+- Creating ManyToMany join tables
+
+## Tracking
+
+Ryx creates a `ryx_migrations` table to track applied state.
+
+## Backend Differences
+
+| Feature | PostgreSQL | MySQL | SQLite |
+|---|---|---|---|
+| `ALTER COLUMN` | Yes | Yes | No (recreate) |
+| Native UUID | Yes | No | No |
+| `SERIAL` | Yes | No | No |
+
+## Next Steps
+
+- **[Querying](/rust/querying/filtering)** — Start querying your data
diff --git a/docs/doc/rust/core-concepts/models.mdx b/docs/doc/rust/core-concepts/models.mdx
new file mode 100644
index 0000000..6e08a20
--- /dev/null
+++ b/docs/doc/rust/core-concepts/models.mdx
@@ -0,0 +1,111 @@
+---
+sidebar_position: 4
+---
+
+# Models
+
+Models are the heart of Ryx. In Rust, a model is a struct annotated with `#[model]`.
+
+## Basic Model
+
+```rust
+use ryx_rs::model;
+
+#[model]
+#[table("posts")]
+struct Post {
+ #[field(pk)]
+ id: i64,
+ title: String,
+ slug: String,
+ views: i64,
+ active: bool,
+ body: Option,
+}
+```
+
+## Field Attributes
+
+| Attribute | Description |
+|---|---|
+| `#[field(pk)]` | Primary key |
+| `#[field(unique)]` | Unique constraint |
+| `#[field(nullable)]` | Allows `NULL` |
+| `#[field(index)]` | Create an index |
+| `#[field(column = "col_name")]` | Override the column name |
+| `#[field(db_type = "TEXT")]` | Force a specific SQL type |
+| `#[field(default = "0")]` | Default value expression |
+
+## Table Naming
+
+Without `#[table(...)]`, Ryx converts the struct name to `snake_case` plural:
+
+| Struct | Table |
+|---|---|
+| `Post` | `posts` |
+| `BlogPost` | `blog_posts` |
+| `Category` | `categories` |
+
+Override with `#[table("custom_name")]`.
+
+## Supported Field Types
+
+| Rust Type | SQL Column |
+|---|---|
+| `i64` | `BIGINT` |
+| `String` | `TEXT` / `VARCHAR` |
+| `bool` | `BOOLEAN` |
+| `f64` | `DOUBLE PRECISION` |
+| `chrono::NaiveDateTime` | `TIMESTAMP` |
+| `chrono::NaiveDate` | `DATE` |
+| `Option` | Nullable version of `T` |
+| `Vec` | (multi-value bind, e.g. `IN` clauses) |
+
+All types implement the `IntoSqlValue` trait for automatic query parameter conversion.
+
+## Relationships
+
+Use `#[relation(...)]` for foreign keys:
+
+```rust
+#[model]
+struct Author {
+ #[field(pk)]
+ id: i64,
+ name: String,
+}
+
+#[model]
+#[relation(model = "Author", fk_column = "author_id", name = "author")]
+struct Post {
+ #[field(pk)]
+ id: i64,
+ title: String,
+ author_id: i64,
+ author: Option,
+}
+```
+
+| Parameter | Description |
+|---|---|
+| `model` | The related model type |
+| `fk_column` | The foreign key column name |
+| `name` | The struct field name (must match) |
+| `to_table` | (optional) Target table, defaults to related model's table |
+| `to_field` | (optional) Target column, defaults to primary key |
+
+## How #[model] Works
+
+The `#[model]` attribute is shorthand for combining:
+
+```rust
+#[derive(Serialize, Deserialize)] // from serde
+#[derive(Model)] // Model + Relationships traits
+#[derive(FromRow)] // Row deserialization
+// All in one step
+```
+
+## Next Steps
+
+- **[QuerySet](./queryset)** — Query your data
+- **[Migrations](./migrations)** — Create tables automatically
diff --git a/docs/doc/rust/core-concepts/queryset.mdx b/docs/doc/rust/core-concepts/queryset.mdx
new file mode 100644
index 0000000..2d0e064
--- /dev/null
+++ b/docs/doc/rust/core-concepts/queryset.mdx
@@ -0,0 +1,313 @@
+---
+sidebar_position: 5
+---
+
+# QuerySet
+
+`QuerySet` is the lazy, chainable query builder. Every model's `objects()` manager returns one.
+
+## Entry Points
+
+```rust
+// ObjectsManager — the entry point
+Post::objects() // → ObjectsManager
+Post::objects().all() // → QuerySet (all rows)
+Post::objects().filter("active", true) // → QuerySet
+Post::objects().exclude("status", "draft") // → QuerySet
+Post::objects().get("id", 1i64) // → QuerySet (expects 1 row)
+Post::objects().create() // → InsertBuilder
+```
+
+`QuerySet` methods are **chainable** and **lazy** — no query hits the database until awaited.
+
+## Filtering
+
+```rust
+// By field + value pair
+Post::objects().filter("active", true);
+Post::objects().filter("views__gte", 100i64);
+
+// With Q expressions
+Post::objects().filter(Q::or(
+ Q::new("active", true),
+ Q::new("views__gte", 1000),
+));
+
+// Exclude (NOT)
+Post::objects().exclude("status", "draft");
+Post::objects().exclude(Q::not("status", "draft"));
+
+// Chaining (AND)
+Post::objects()
+ .filter("active", true)
+ .filter("views__gt", 100i64);
+```
+
+### Supported Lookups
+
+| Lookup | SQL | Example |
+|---|---|---|
+| `exact` | `= ?` | `.filter("title", "Hello")` |
+| `gt` | `> ?` | `.filter("views__gt", 100)` |
+| `gte` | `>= ?` | `.filter("views__gte", 100)` |
+| `lt` | `< ?` | `.filter("views__lt", 50)` |
+| `lte` | `<= ?` | `.filter("views__lte", 1000)` |
+| `contains` | `LIKE '%?%'` | `.filter("title__contains", "Rust")` |
+| `icontains` | `LOWER(col) LIKE ?` | `.filter("title__icontains", "rust")` |
+| `startswith` | `LIKE '?%'` | `.filter("title__startswith", "Draft")` |
+| `istartswith` | `LOWER(col) LIKE ?` | `.filter("title__istartswith", "draft")` |
+| `endswith` | `LIKE '%?'` | `.filter("slug__endswith", "-ryx")` |
+| `iendswith` | `LOWER(col) LIKE ?` | `.filter("slug__iendswith", "-Ryx")` |
+| `in` | `IN (?, ...)` | `.filter("status__in", vec!["a", "b"])` |
+| `isnull` | `IS NULL / IS NOT NULL` | `.filter("author__isnull", true)` |
+| `range` | `BETWEEN ? AND ?` | `.filter("views__range", (100i64, 1000i64))` |
+
+### Date Transforms
+
+```rust
+.filter("created_at__year", 2024i64);
+.filter("created_at__month__gte", 6i64);
+.filter("created_at__day", 15i64);
+.filter("created_at__hour", 14i64);
+.filter("created_at__week", 20i64);
+.filter("created_at__quarter", 2i64);
+```
+
+## Q Objects
+
+```rust
+use ryx_rs::Q;
+
+// OR
+Post::objects().filter(Q::or(
+ Q::new("active", true),
+ Q::new("views__gte", 1000),
+));
+
+// AND
+Post::objects().filter(Q::and(
+ Q::new("active", true),
+ Q::new("featured", true),
+));
+
+// NOT
+Post::objects().filter(Q::not("status", "draft"));
+
+// Nested
+Post::objects().filter(Q::or(
+ Q::and(Q::new("active", true), Q::new("views__gte", 500)),
+ Q::new("featured", true),
+));
+```
+
+## Ordering
+
+```rust
+// Ascending
+Post::objects().order_by("title");
+
+// Descending (prefix with -)
+Post::objects().order_by("-views");
+
+// Multiple fields (single calls)
+Post::objects().order_by("-views").order_by("title");
+
+// Multiple fields at once
+Post::objects().order_by_all(&["-views", "title"]);
+```
+
+## Pagination
+
+```rust
+Post::objects().limit(10);
+Post::objects().limit(10).offset(20);
+```
+
+## Aggregations
+
+```rust
+use ryx_rs::agg::{count, sum, avg, min, max, count_distinct};
+
+let stats = Post::objects()
+ .aggregate(&[
+ count("total", "id"),
+ sum("total_views", "views"),
+ avg("avg_views", "views"),
+ min("min_views", "views"),
+ max("max_views", "views"),
+ count_distinct("unique_authors", "author_id"),
+ ]).await?;
+// stats: HashMap
+```
+
+### Annotate
+
+```rust
+let annotated = Post::objects()
+ .annotate(&[count("comment_count", "id")])
+ .await?;
+// annotated: Vec>
+```
+
+### Values & ValuesList
+
+```rust
+// Vec>
+let values = Post::objects()
+ .values(&["title", "views"])
+ .await?;
+
+// Vec>
+let list = Post::objects()
+ .values_list(&["title", "views"])
+ .await?;
+
+// GROUP BY — values + annotate
+let grouped = Post::objects()
+ .values(&["author_id"])
+ .annotate(&[
+ count("post_count", "id"),
+ sum("total_views", "views"),
+ ])
+ .order_by("-total_views")
+ .await?;
+```
+
+## Relationships
+
+```rust
+let posts: Vec = Post::objects()
+ .all()
+ .select_related(&["author"])
+ .all().await?;
+
+for post in &posts {
+ if let Some(author) = &post.author {
+ println!("{} — by {}", post.title, author.name);
+ }
+}
+```
+
+`select_related` is only available on models with `#[relation(...)]` attributes.
+
+## Execution Methods
+
+These execute the query and return results:
+
+```rust
+// All rows
+let all: Vec = Post::objects().all().await?;
+
+// First match (None if empty)
+let first: Option = Post::objects()
+ .filter("active", true)
+ .order_by("title")
+ .first().await?;
+
+// Get by field (panics if not found — use filter + first for safe access)
+let post: Post = Post::objects().get("id", 1i64).await?;
+
+// Count
+let count: i64 = Post::objects().filter("active", true).count().await?;
+
+// Exists
+let exists: bool = Post::objects()
+ .filter("title__startswith", "Draft")
+ .exists().await?;
+
+// Distinct
+Post::objects().distinct().all().await?;
+```
+
+## Update & Delete
+
+```rust
+// Bulk update (returns affected row count)
+let updated: u64 = Post::objects()
+ .filter("author", "bob")
+ .update(vec![("views", 9999i64)])
+ .await?;
+
+// Bulk delete (returns affected row count)
+let deleted: u64 = Post::objects()
+ .filter("title__startswith", "Draft")
+ .delete().await?;
+```
+
+## Multi-DB
+
+```rust
+Post::objects().using("logs").all().await?;
+Post::objects().using("users").filter("active", true).all().await?;
+```
+
+## Caching
+
+```rust
+let posts: Vec = Post::objects()
+ .filter("active", true)
+ .cache(60, Some("active_posts")) // TTL 60s, named key
+ .all().await?;
+
+// Without explicit key
+let posts: Vec = Post::objects()
+ .filter("active", true)
+ .cache(60, None)
+ .all().await?;
+```
+
+Returns a `CachedQuerySet` with a single `.all()` method.
+
+## Streaming (Keyset Pagination)
+
+```rust
+let mut stream = Post::objects()
+ .filter("active", true)
+ .order_by("id")
+ .stream(100, Some("id")); // chunk_size, keyset field
+
+while let Some(chunk) = stream.next_chunk().await? {
+ for post in chunk {
+ // process 100 at a time
+ }
+}
+```
+
+`QueryStream` yields `Vec` chunks until exhausted.
+
+## Debug: View SQL
+
+```rust
+let sql = Post::objects()
+ .filter("title__contains", "Rust")
+ .sql()?;
+// → SELECT * FROM "posts" WHERE "title" LIKE '%Rust%'
+```
+
+## Value Types (IntoSqlValue)
+
+The `IntoSqlValue` trait converts Rust values for query parameters. Built-in types supported:
+
+| Rust Type | SQL |
+|---|---|
+| `i32`, `i64` | Integer |
+| `f64` | Float |
+| `bool` | Boolean |
+| `String`, `&str` | Text |
+| `Option` | Nullable T |
+| `Vec` | Multiple values (for `in`) |
+| `chrono::NaiveDateTime` | Timestamp |
+| `chrono::NaiveDate` | Date |
+
+```rust
+Post::objects().filter("views", 42i64);
+Post::objects().filter("active", true);
+Post::objects().filter("title", "Hello");
+Post::objects().filter("author_id", None::);
+Post::objects().filter("status__in", vec!["draft", "published"]);
+```
+
+## Next Steps
+
+- **[Migrations](./migrations)** — Schema management
+- **[CRUD](/rust/crud/creating)** — Create, read, update, delete
diff --git a/docs/doc/rust/crud/creating.mdx b/docs/doc/rust/crud/creating.mdx
new file mode 100644
index 0000000..6ecfc08
--- /dev/null
+++ b/docs/doc/rust/crud/creating.mdx
@@ -0,0 +1,24 @@
+---
+sidebar_position: 9
+---
+
+# Creating Records
+
+## Via QuerySet
+
+Use the `.create()` builder pattern:
+
+```rust
+let post = Post::objects().create()
+ .set("title", "Hello Ryx")
+ .set("slug", "hello-ryx")
+ .set("views", 42i64)
+ .set("active", true)
+ .save().await?;
+```
+
+Each `.set()` call assigns a column value. `.save()` executes the INSERT and returns the model instance.
+
+## Next Steps
+
+- **[Reading](../crud/reading)** — Retrieve records
diff --git a/docs/doc/rust/crud/reading.mdx b/docs/doc/rust/crud/reading.mdx
new file mode 100644
index 0000000..ecf975c
--- /dev/null
+++ b/docs/doc/rust/crud/reading.mdx
@@ -0,0 +1,92 @@
+---
+sidebar_position: 10
+---
+
+# Reading Records
+
+## All Records
+
+```rust
+let all: Vec = Post::objects().all().await?;
+```
+
+## Filtered
+
+```rust
+let active: Vec = Post::objects()
+ .filter("active", true)
+ .all().await?;
+```
+
+## First Record
+
+```rust
+// Returns None if no match
+let first: Option = Post::objects()
+ .filter("active", true)
+ .order_by("title")
+ .first().await?;
+```
+
+## Get by ID
+
+```rust
+let post: Option = Post::objects()
+ .filter("id", 1i64)
+ .first().await?;
+```
+
+## Count
+
+```rust
+let count = Post::objects()
+ .filter("active", true)
+ .count().await?;
+```
+
+## Exists
+
+```rust
+if Post::objects()
+ .filter("title__startswith", "Draft")
+ .exists().await? {
+ println!("Has drafts");
+}
+```
+
+## Ordering & Pagination
+
+```rust
+// Ordering
+Post::objects().order_by("-views");
+
+// Limit / Offset
+Post::objects().limit(10).offset(20);
+
+// Combined
+let page: Vec = Post::objects()
+ .filter("active", true)
+ .order_by("-views")
+ .limit(20)
+ .offset(40)
+ .all().await?;
+```
+
+## Streaming (Keyset Pagination)
+
+```rust
+let mut stream = Post::objects()
+ .filter("active", true)
+ .order_by("id")
+ .stream(100, Some("id"));
+
+while let Some(chunk) = stream.next_chunk().await? {
+ for post in chunk {
+ // process 100 at a time
+ }
+}
+```
+
+## Next Steps
+
+- **[Updating & Deleting](./updating-deleting)** — Modify and remove records
diff --git a/docs/doc/rust/crud/updating-deleting.mdx b/docs/doc/rust/crud/updating-deleting.mdx
new file mode 100644
index 0000000..a2789cf
--- /dev/null
+++ b/docs/doc/rust/crud/updating-deleting.mdx
@@ -0,0 +1,47 @@
+---
+sidebar_position: 11
+---
+
+# Updating & Deleting Records
+
+## Bulk Update
+
+```rust
+// Update all matching rows
+Post::objects()
+ .filter("author", "bob")
+ .update(vec![("views", 9999i64)])
+ .await?;
+
+// Update multiple fields
+Post::objects()
+ .filter("active", false)
+ .update(vec![
+ ("active", true),
+ ("views", 0i64),
+ ]).await?;
+```
+
+The `.update()` method executes a single `UPDATE` SQL statement. It returns the number of affected rows.
+
+## Bulk Delete
+
+```rust
+// Delete matching rows
+Post::objects()
+ .filter("title__startswith", "Draft")
+ .delete().await?;
+
+// Delete all
+Post::objects().delete().await?;
+```
+
+The `.delete()` method returns the number of deleted rows.
+
+:::warning
+Bulk operations bypass per-instance hooks. They execute directly on the database.
+:::
+
+## Next Steps
+
+- **[Transactions](../advanced/transactions)** — Atomic operations
diff --git a/docs/doc/rust/getting-started/installation.mdx b/docs/doc/rust/getting-started/installation.mdx
new file mode 100644
index 0000000..e0c5db1
--- /dev/null
+++ b/docs/doc/rust/getting-started/installation.mdx
@@ -0,0 +1,52 @@
+---
+sidebar_position: 2
+---
+
+# Installation
+
+## Prerequisites
+
+- Rust 1.83+
+- A database: PostgreSQL, MySQL, or SQLite
+
+## Add Dependencies
+
+```toml
+[dependencies]
+ryx-rs = "0.1"
+ryx-macro = "0.1"
+tokio = { version = "1", features = ["full"] }
+```
+
+Or via cargo:
+
+```bash
+cargo add ryx-rs ryx-macro
+cargo add tokio --features full
+```
+
+## Database Backends
+
+Enable backends as Cargo features:
+
+```toml
+# All backends
+ryx-rs = { version = "0.1", features = ["postgres", "mysql", "sqlite"] }
+
+# Single backend
+ryx-rs = { version = "0.1", features = ["postgres"] }
+```
+
+The default includes **SQLite** only.
+
+## Verify
+
+```bash
+cargo check
+```
+
+You should see a successful build.
+
+## Next Steps
+
+- **[Quick Start](./quick-start)** — Your first query in 5 minutes
diff --git a/docs/doc/rust/getting-started/quick-start.mdx b/docs/doc/rust/getting-started/quick-start.mdx
new file mode 100644
index 0000000..e68d222
--- /dev/null
+++ b/docs/doc/rust/getting-started/quick-start.mdx
@@ -0,0 +1,148 @@
+---
+sidebar_position: 3
+---
+
+# Quick Start
+
+Let's go from zero to a working query in 5 minutes.
+
+## 1. Define a Model
+
+```rust
+use ryx_rs::model;
+
+#[model]
+#[table("posts")]
+struct Post {
+ #[field(pk)]
+ id: i64,
+ title: String,
+ slug: String,
+ views: i64,
+ active: bool,
+}
+```
+
+- `#[model]` derives `Model`, `FromRow`, and more.
+- `#[field(pk)]` marks the primary key.
+- `#[table("posts")` optionally overrides the table name.
+
+## 2. Configure & Migrate
+
+```rust
+use ryx_rs::migration::MigrationRunner;
+
+async fn run() -> ryx_rs::RyxResult<()> {
+ ryx_rs::init().await?;
+ MigrationRunner::new()
+ .model::()
+ .run().await?;
+ Ok(())
+}
+```
+
+`init()` reads a `ryx.toml` or uses the `DATABASE_URL` env var.
+
+## 3. Create Records
+
+```rust
+let post = Post::objects().create()
+ .set("title", "Hello Ryx")
+ .set("slug", "hello-ryx")
+ .set("views", 42i64)
+ .set("active", true)
+ .save().await?;
+```
+
+## 4. Query
+
+```rust
+// All active posts, newest first
+let posts: Vec = Post::objects()
+ .filter("active", true)
+ .order_by("-views")
+ .all().await?;
+
+// First match
+let first: Option = Post::objects()
+ .filter("active", true)
+ .order_by("title")
+ .first().await?;
+
+// Count
+let count = Post::objects()
+ .filter("active", true)
+ .count().await?;
+
+// Check existence
+let exists = Post::objects()
+ .filter("title__startswith", "Draft")
+ .exists().await?;
+```
+
+## 5. Update & Delete
+
+```rust
+// Bulk update
+Post::objects()
+ .filter("active", false)
+ .update(vec![("active", true)])
+ .await?;
+
+// Bulk delete
+Post::objects()
+ .filter("views", 0i64)
+ .delete().await?;
+```
+
+## Complete Example
+
+```rust
+use ryx_rs::model;
+use ryx_rs::migration::MigrationRunner;
+
+#[model]
+#[table("posts")]
+struct Post {
+ #[field(pk)]
+ id: i64,
+ title: String,
+ slug: String,
+ views: i64,
+ active: bool,
+}
+
+#[tokio::main]
+async fn main() -> ryx_rs::RyxResult<()> {
+ ryx_rs::init().await?;
+ MigrationRunner::new().model::().run().await?;
+
+ Post::objects().create()
+ .set("title", "Hello Ryx")
+ .set("slug", "hello-ryx")
+ .set("views", 100i64)
+ .save().await?;
+
+ let posts: Vec = Post::objects()
+ .filter("active", true)
+ .order_by("-views")
+ .all().await?;
+
+ println!("Found {} posts", posts.len());
+
+ let stats = Post::objects()
+ .aggregate(&[
+ ryx_rs::agg::count("total", "id"),
+ ryx_rs::agg::avg("avg", "views"),
+ ]).await?;
+
+ println!("Stats: {:?}", stats);
+
+ Ok(())
+}
+```
+
+## Next Steps
+
+- **[Models](../core-concepts/models)** — Deep dive into model definitions
+- **[QuerySet](../core-concepts/queryset)** — The full query API
diff --git a/docs/doc/rust/index.mdx b/docs/doc/rust/index.mdx
new file mode 100644
index 0000000..3178ab6
--- /dev/null
+++ b/docs/doc/rust/index.mdx
@@ -0,0 +1,50 @@
+---
+sidebar_position: 1
+slug: /rust
+---
+
+# Ryx for Rust
+
+Ryx is a Django-style ORM for Rust. Async-native, with zero Python dependencies.
+
+```rust
+use ryx_rs::model;
+
+#[model]
+struct Post {
+ #[field(pk)]
+ id: i64,
+ title: String,
+ views: i64,
+ active: bool,
+}
+
+let posts = Post::objects()
+ .filter("active", true)
+ .order_by("-views")
+ .all().await?;
+```
+
+## Crates
+
+| Crate | Description |
+|---|---|
+| [`ryx-rs`](https://crates.io/crates/ryx-rs) | Core ORM: models, QuerySet, migrations, caching |
+| [`ryx-macro`](https://crates.io/crates/ryx-macro) | Proc macros: `#[model]`, `#[field]`, `#[table]`, `#[relation]` |
+
+## Quick Comparison
+
+| Feature | Python (ryx) | Rust (ryx-rs) |
+|---|---|---|
+| Model definition | Class + Fields | `#[model]` struct |
+| QuerySet | `Post.objects` | `Post::objects()` |
+| Filtering | `.filter(active=True)` | `.filter("active", true)` |
+| Q objects | `Q(active=True) \| Q(...)` | `Q::or(Q::new(...), ...)` |
+| Create | `.create(title="...")` | `.create().set("title", "...").save()` |
+| Migrations | `MigrationRunner([Post])` | `MigrationRunner::new().model::()` |
+| Relations | `ForeignKey(Author)` | `#[relation(model = "Author", ...)]` |
+
+## Next Steps
+
+- **[Installation](./getting-started/installation)** — Add Ryx to your Cargo.toml
+- **[Quick Start](./getting-started/quick-start)** — Your first query in 5 minutes
diff --git a/docs/doc/rust/querying/aggregations.mdx b/docs/doc/rust/querying/aggregations.mdx
new file mode 100644
index 0000000..c1c48d1
--- /dev/null
+++ b/docs/doc/rust/querying/aggregations.mdx
@@ -0,0 +1,92 @@
+---
+sidebar_position: 8
+---
+
+# Aggregations & Values
+
+Compute summary values across your data.
+
+## Aggregate Functions
+
+```rust
+use ryx_rs::agg::{count, sum, avg, min, max};
+```
+
+| Function | SQL |
+|---|---|
+| `count(name, field)` | `COUNT(field)` |
+| `sum(name, field)` | `SUM(field)` |
+| `avg(name, field)` | `AVG(field)` |
+| `min(name, field)` | `MIN(field)` |
+| `max(name, field)` | `MAX(field)` |
+
+## aggregate() — Single Result
+
+```rust
+let stats = Post::objects()
+ .aggregate(&[
+ count("total", "id"),
+ sum("total_views", "views"),
+ avg("avg_views", "views"),
+ max("top_views", "views"),
+ ]).await?;
+
+println!("Total: {:?}", stats.get("total"));
+println!("Avg views: {:?}", stats.get("avg_views"));
+```
+
+Returns `HashMap`.
+
+## annotate() — Per-Row Values
+
+```rust
+let annotated = Post::objects()
+ .annotate(&[
+ count("comment_count", "id"),
+ ]).await?;
+
+for row in &annotated {
+ println!("Comments: {:?}", row.get("comment_count"));
+}
+```
+
+## values() — Dict Results
+
+```rust
+let values = Post::objects()
+ .values(&["title", "views"])
+ .await?;
+
+for row in &values {
+ println!("{}: {:?}", row.get("title").unwrap(), row.get("views").unwrap());
+}
+```
+
+## values_list() — Tuple Results
+
+```rust
+let list = Post::objects()
+ .values_list(&["title", "views"])
+ .await?;
+
+for cols in &list {
+ println!("{:?}", cols);
+}
+```
+
+## GROUP BY — values + annotate
+
+```rust
+let grouped = Post::objects()
+ .values(&["author_id"])
+ .annotate(&[
+ count("post_count", "id"),
+ sum("total_views", "views"),
+ ])
+ .order_by("-total_views")
+ .await?;
+```
+
+## Next Steps
+
+- **[Creating Records](../crud/creating)** — Insert data
diff --git a/docs/doc/rust/querying/filtering.mdx b/docs/doc/rust/querying/filtering.mdx
new file mode 100644
index 0000000..38ae8d1
--- /dev/null
+++ b/docs/doc/rust/querying/filtering.mdx
@@ -0,0 +1,115 @@
+---
+sidebar_position: 7
+---
+
+# Filtering
+
+The `.filter()` method translates field + value pairs into SQL `WHERE` clauses.
+
+## Basic Syntax
+
+```rust
+// Exact match
+Post::objects().filter("active", true);
+Post::objects().filter("author_id", 5i64);
+
+// With a lookup
+Post::objects().filter("views__gt", 100i64);
+```
+
+Pattern: `field__lookup, value`. When no lookup is given, `exact` is used.
+
+## Comparison
+
+```rust
+Post::objects().filter("views__gt", 100i64); // >
+Post::objects().filter("views__gte", 100i64); // >=
+Post::objects().filter("views__lt", 50i64); // <
+Post::objects().filter("views__lte", 50i64); // <=
+```
+
+## String Lookups
+
+```rust
+Post::objects().filter("title__contains", "Python"); // LIKE '%Python%'
+Post::objects().filter("title__startswith", "How"); // LIKE 'How%'
+Post::objects().filter("title__endswith", "guide"); // LIKE '%guide'
+```
+
+## Null Checks
+
+```rust
+Post::objects().filter("body__isnull", true); // IS NULL
+Post::objects().filter("body__isnull", false); // IS NOT NULL
+```
+
+## Membership & Range
+
+```rust
+// IN clause
+Post::objects().filter("status__in", vec!["draft", "published"]);
+
+// BETWEEN
+Post::objects().filter("views__range", (100i64, 1000i64));
+```
+
+## Multiple Filters
+
+Multiple `.filter()` calls are AND-ed together:
+
+```rust
+Post::objects()
+ .filter("active", true)
+ .filter("views__gt", 100i64)
+ .filter("title__contains", "Rust");
+```
+
+## Excluding (NOT)
+
+Use `Q::not()`:
+
+```rust
+Post::objects().filter(Q::not("status", "draft"));
+
+Post::objects()
+ .filter("active", true)
+ .filter(Q::not("title__startswith", "Draft"));
+```
+
+## Date Transforms
+
+```rust
+Post::objects().filter("created_at__year", 2024i64);
+Post::objects().filter("created_at__month", 5i64);
+Post::objects().filter("created_at__day", 15i64);
+```
+
+These work with all comparison lookups: `created_at__year__gte=2024`.
+
+## Custom SQL
+
+```rust
+Post::objects()
+ .sql_filter("\"title\" ILIKE '%python%'");
+```
+
+## Lookup Reference
+
+| Lookup | SQL | Example |
+|---|---|---|
+| `exact` | `col = ?` | `.filter("title", "Hello")` |
+| `gt` | `col > ?` | `.filter("views__gt", 100)` |
+| `gte` | `col >= ?` | `.filter("views__gte", 100)` |
+| `lt` | `col < ?` | `.filter("views__lt", 50)` |
+| `lte` | `col <= ?` | `.filter("views__lte", 1000)` |
+| `contains` | `LIKE '%?%'` | `.filter("title__contains", "Py")` |
+| `startswith` | `LIKE '?%'` | `.filter("title__startswith", "How")` |
+| `endswith` | `LIKE '%?'` | `.filter("slug__endswith", "-ryx")` |
+| `in` | `IN (?, ...)` | `.filter("status__in", vec!["a","b"])` |
+| `isnull` | `IS NULL / IS NOT NULL` | `.filter("body__isnull", true)` |
+| `range` | `BETWEEN ? AND ?` | `.filter("views__range", (100, 1000))` |
+
+## Next Steps
+
+- **[Q Objects](./../core-concepts/queryset)** — Complex boolean expressions
+- **[Aggregations](./aggregations)** — Count, Sum, Avg
diff --git a/docs/doc/rust/relationships/index.mdx b/docs/doc/rust/relationships/index.mdx
new file mode 100644
index 0000000..58ba431
--- /dev/null
+++ b/docs/doc/rust/relationships/index.mdx
@@ -0,0 +1,78 @@
+---
+sidebar_position: 12
+---
+
+# Relationships
+
+Ryx supports foreign-key relationships via `#[relation(...)]`.
+
+## Defining a Relation
+
+```rust
+#[model]
+struct Author {
+ #[field(pk)]
+ id: i64,
+ name: String,
+}
+
+#[model]
+#[relation(model = "Author", fk_column = "author_id", name = "author")]
+struct Post {
+ #[field(pk)]
+ id: i64,
+ title: String,
+ author_id: i64,
+ author: Option,
+}
+```
+
+The `#[relation]` attribute has three parameters:
+
+| Parameter | Description |
+|---|---|
+| `model` | The related model type |
+| `fk_column` | The foreign key column |
+| `name` | The struct field holding the related model |
+
+The related field must be `Option` — it's populated by `select_related`.
+
+## select_related
+
+Fetches related models in a single JOIN query, avoiding N+1:
+
+```rust
+let posts: Vec = Post::objects()
+ .all()
+ .select_related(&["author"])
+ .all().await?;
+
+for post in &posts {
+ if let Some(author) = &post.author {
+ println!("{} — by {}", post.title, author.name);
+ }
+}
+```
+
+Multiple relations:
+
+```rust
+Post::objects()
+ .all()
+ .select_related(&["author", "category"])
+ .all().await?;
+```
+
+## SQL Generated
+
+```sql
+SELECT "posts".*, "authors"."id" AS "author__id", "authors"."name" AS "author__name"
+FROM "posts"
+LEFT JOIN "authors" ON "posts"."author_id" = "authors"."id"
+```
+
+The result columns are aliased (`author__id`, `author__name`) and automatically decoded via `FromRow::from_row_prefixed` into the nested `Option`.
+
+## Next Steps
+
+- **[Transactions](../advanced/transactions)** — Atomic operations
diff --git a/docs/sidebars.js b/docs/sidebars.js
index e466e9b..1548c0d 100644
--- a/docs/sidebars.js
+++ b/docs/sidebars.js
@@ -109,6 +109,56 @@ const sidebars = {
'cookbook/deployment',
],
},
+ {
+ type: 'category',
+ label: '🦀 Rust',
+ link: { type: 'doc', id: 'rust/index' },
+ items: [
+ {
+ type: 'category',
+ label: 'Getting Started',
+ items: [
+ 'rust/getting-started/installation',
+ 'rust/getting-started/quick-start',
+ ],
+ },
+ {
+ type: 'category',
+ label: 'Core Concepts',
+ items: [
+ 'rust/core-concepts/models',
+ 'rust/core-concepts/queryset',
+ 'rust/core-concepts/migrations',
+ ],
+ },
+ {
+ type: 'category',
+ label: 'Querying',
+ items: [
+ 'rust/querying/filtering',
+ 'rust/querying/aggregations',
+ ],
+ },
+ {
+ type: 'category',
+ label: 'CRUD',
+ items: [
+ 'rust/crud/creating',
+ 'rust/crud/reading',
+ 'rust/crud/updating-deleting',
+ ],
+ },
+ 'rust/relationships/index',
+ {
+ type: 'category',
+ label: 'Advanced',
+ items: [
+ 'rust/advanced/transactions',
+ 'rust/advanced/caching',
+ ],
+ },
+ ],
+ },
],
};
diff --git a/ryx-backend/Cargo.toml b/ryx-backend/Cargo.toml
index bb1fa02..ab8054b 100644
--- a/ryx-backend/Cargo.toml
+++ b/ryx-backend/Cargo.toml
@@ -5,8 +5,9 @@ edition = "2024"
description = "Core query backend engine for Ryx ORM"
[dependencies]
-ryx-core = { path = "../ryx-core", version = "0.1.0" }
-ryx-query = { path = "../ryx-query", version = "0.1.0" }
+ryx-common = { path = "../ryx-common", version = "0.1" }
+ryx-query = { path = "../ryx-query", version = "0.1" }
+ryx-core = { path = "../ryx-core", version = "0.1", optional = true }
sqlx = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
@@ -20,7 +21,12 @@ async-trait = "0.1"
[dev-dependencies]
criterion = { version = "0.5", features = ["async_tokio"] }
+tempfile = "3"
-# [[bench]]
-# name = "query_bench"
-# harness = false
+[[bench]]
+name = "backend_bench"
+harness = false
+
+[features]
+default = ["python"]
+python = ["dep:ryx-core"]
diff --git a/ryx-backend/benches/backend_bench.rs b/ryx-backend/benches/backend_bench.rs
new file mode 100644
index 0000000..f6a8f10
--- /dev/null
+++ b/ryx-backend/benches/backend_bench.rs
@@ -0,0 +1,387 @@
+use std::collections::HashMap;
+use std::sync::OnceLock;
+
+use criterion::{black_box, criterion_group, criterion_main, Criterion};
+use ryx_backend::backends::RyxBackend;
+use ryx_backend::pool;
+use ryx_common::PoolConfig;
+use ryx_query::ast::{
+ FilterNode, OrderByClause, QNode, QueryNode, QueryOperation, SortDirection, SqlValue,
+};
+use ryx_query::Backend;
+use tokio::runtime::Runtime;
+
+static RT: OnceLock = OnceLock::new();
+static BACKEND: OnceLock = OnceLock::new();
+
+type ArcBackend = std::sync::Arc;
+
+fn rt() -> &'static Runtime {
+ RT.get_or_init(|| Runtime::new().expect("tokio runtime"))
+}
+
+fn backend() -> &'static ArcBackend {
+ BACKEND.get_or_init(|| {
+ ryx_query::lookups::init_registry();
+ let rt = RT.get_or_init(|| Runtime::new().expect("tokio runtime"));
+ rt.block_on(init_database())
+ })
+}
+
+async fn init_database() -> ArcBackend {
+ let tmp = std::env::temp_dir().join("ryx_bench_backend.db");
+ let _ = std::fs::remove_file(&tmp);
+
+ let path = tmp.to_str().expect("utf-8").to_string();
+ let url = format!("sqlite:{path}?mode=rwc");
+
+ let mut urls = HashMap::new();
+ urls.insert("default".into(), url);
+
+ pool::initialize(
+ urls,
+ PoolConfig {
+ max_connections: 4,
+ min_connections: 1,
+ ..Default::default()
+ },
+ )
+ .await
+ .expect("pool init");
+
+ let be = pool::get(None).expect("get backend");
+
+ be.execute_raw(
+ "CREATE TABLE bench_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ value REAL NOT NULL,
+ active INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL
+ )"
+ .into(),
+ None,
+ )
+ .await
+ .expect("create table");
+
+ // Insert 10_000 rows in batches of 100
+ for chunk in 0..100 {
+ let mut values = Vec::new();
+ for i in 0..100 {
+ let idx = chunk * 100 + i;
+ let active = if idx % 2 == 0 { 1 } else { 0 };
+ values.push(format!("('item_{idx}', {idx}.5, {active}, '2024-01-01')"));
+ }
+ let batch = values.join(", ");
+ be.execute_raw(
+ format!("INSERT INTO bench_items (name, value, active, created_at) VALUES {batch}"),
+ None,
+ )
+ .await
+ .expect("insert batch");
+ }
+
+ be
+}
+
+// ── Raw SQL ─────────────────────────────────────────────────
+
+fn bench_raw_select_single(c: &mut Criterion) {
+ let be = backend().clone();
+ c.bench_function("raw_select_single", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ async move {
+ let rows = be
+ .fetch_raw(black_box("SELECT * FROM bench_items WHERE id = 5000".into()), None)
+ .await
+ .unwrap();
+ black_box(rows)
+ }
+ })
+ });
+}
+
+fn bench_raw_select_100(c: &mut Criterion) {
+ let be = backend().clone();
+ c.bench_function("raw_select_100", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ async move {
+ let rows = be
+ .fetch_raw(black_box("SELECT * FROM bench_items LIMIT 100".into()), None)
+ .await
+ .unwrap();
+ black_box(rows)
+ }
+ })
+ });
+}
+
+fn bench_raw_select_count(c: &mut Criterion) {
+ let be = backend().clone();
+ c.bench_function("raw_select_count", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ async move {
+ let rows = be
+ .fetch_raw(
+ black_box("SELECT COUNT(*) as cnt FROM bench_items WHERE active = 1".into()),
+ None,
+ )
+ .await
+ .unwrap();
+ black_box(rows)
+ }
+ })
+ });
+}
+
+fn bench_raw_insert_single(c: &mut Criterion) {
+ let be = backend().clone();
+ c.bench_function("raw_insert_single", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ async move {
+ be.execute_raw(
+ black_box(
+ "INSERT INTO bench_items (name, value, active, created_at) \
+ VALUES ('new_item', 999.0, 1, '2024-06-01')"
+ .into(),
+ ),
+ None,
+ )
+ .await
+ .unwrap();
+ }
+ })
+ });
+}
+
+fn bench_raw_update(c: &mut Criterion) {
+ let be = backend().clone();
+ c.bench_function("raw_update_all", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ async move {
+ be.execute_raw(
+ black_box("UPDATE bench_items SET value = value + 0.5".into()),
+ None,
+ )
+ .await
+ .unwrap();
+ }
+ })
+ });
+}
+
+fn bench_raw_delete_single(c: &mut Criterion) {
+ let be = backend().clone();
+ c.bench_function("raw_delete_single", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ async move {
+ be.execute_raw(
+ black_box("DELETE FROM bench_items WHERE id = 9999".into()),
+ None,
+ )
+ .await
+ .unwrap();
+ }
+ })
+ });
+}
+
+// ── Compiled query ──────────────────────────────────────────
+
+fn bench_compiled_select_100(c: &mut Criterion) {
+ let be = backend().clone();
+ let mut node = QueryNode::select("bench_items")
+ .with_backend(Backend::SQLite)
+ .with_limit(100);
+
+ c.bench_function("compiled_select_100", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ node.limit = Some(100);
+ let n = node.clone();
+ async move {
+ let rows = be.fetch_all_compiled(black_box(n)).await.unwrap();
+ black_box(rows)
+ }
+ })
+ });
+}
+
+fn bench_compiled_select_filtered(c: &mut Criterion) {
+ let be = backend().clone();
+ let node = QueryNode::select("bench_items")
+ .with_backend(Backend::SQLite)
+ .with_filter(FilterNode {
+ field: "active".into(),
+ lookup: "exact".to_string(),
+ value: SqlValue::Int(1),
+ negated: false,
+ })
+ .with_limit(50);
+
+ c.bench_function("compiled_select_filtered", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ let n = node.clone();
+ async move {
+ let rows = be.fetch_all_compiled(black_box(n)).await.unwrap();
+ black_box(rows)
+ }
+ })
+ });
+}
+
+fn bench_compiled_select_complex(c: &mut Criterion) {
+ let be = backend().clone();
+ let node = QueryNode::select("bench_items")
+ .with_backend(Backend::SQLite)
+ .with_q(QNode::And(vec![
+ QNode::Leaf {
+ field: "value".into(),
+ lookup: "gte".to_string(),
+ value: SqlValue::Float(100.0),
+ negated: false,
+ },
+ QNode::Leaf {
+ field: "value".into(),
+ lookup: "lte".to_string(),
+ value: SqlValue::Float(5000.0),
+ negated: false,
+ },
+ QNode::Leaf {
+ field: "active".into(),
+ lookup: "exact".to_string(),
+ value: SqlValue::Int(1),
+ negated: false,
+ },
+ ]))
+ .with_order_by(OrderByClause {
+ field: "name".into(),
+ direction: SortDirection::Desc,
+ })
+ .with_limit(50);
+
+ c.bench_function("compiled_select_complex", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ let n = node.clone();
+ async move {
+ let rows = be.fetch_all_compiled(black_box(n)).await.unwrap();
+ black_box(rows)
+ }
+ })
+ });
+}
+
+fn bench_compiled_count(c: &mut Criterion) {
+ let be = backend().clone();
+ let node = QueryNode::count("bench_items").with_backend(Backend::SQLite);
+
+ c.bench_function("compiled_count", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ let n = node.clone();
+ async move {
+ let cnt = be.fetch_count_compiled(black_box(n)).await.unwrap();
+ black_box(cnt)
+ }
+ })
+ });
+}
+
+fn bench_compiled_insert(c: &mut Criterion) {
+ let be = backend().clone();
+ let mut node = QueryNode::select("bench_items").with_backend(Backend::SQLite);
+
+ c.bench_function("compiled_insert", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ node.operation = QueryOperation::Insert {
+ values: vec![
+ ("name".into(), SqlValue::Text("c_insert".into())),
+ ("value".into(), SqlValue::Float(42.0)),
+ ("active".into(), SqlValue::Int(1)),
+ ("created_at".into(), SqlValue::Text("2024-06-01".into())),
+ ],
+ returning_id: true,
+ };
+ let n = node.clone();
+ async move {
+ let result = be.execute_compiled(black_box(n)).await.unwrap();
+ black_box(result)
+ }
+ })
+ });
+}
+
+fn bench_compiled_update(c: &mut Criterion) {
+ let be = backend().clone();
+ let mut node = QueryNode::select("bench_items").with_backend(Backend::SQLite);
+
+ c.bench_function("compiled_update", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ node.operation = QueryOperation::Update {
+ assignments: vec![("value".into(), SqlValue::Float(999.0))],
+ };
+ let n = node.clone();
+ async move {
+ let result = be.execute_compiled(black_box(n)).await.unwrap();
+ black_box(result)
+ }
+ })
+ });
+}
+
+// ── Row decode (no DB query) ────────────────────────────────
+
+fn bench_decode_row_small(c: &mut Criterion) {
+ use ryx_query::ast::SqlValue;
+
+ let row = ryx_backend::backends::RowView {
+ values: vec![
+ SqlValue::Int(1),
+ SqlValue::Text("hello".into()),
+ SqlValue::Float(3.14),
+ SqlValue::Bool(true),
+ ],
+ mapping: std::sync::Arc::new(ryx_backend::backends::RowMapping {
+ columns: vec!["id".into(), "name".into(), "value".into(), "active".into()],
+ }),
+ };
+
+ c.bench_function("decode_row_4_fields", |b| {
+ b.iter(|| {
+ let id = black_box(row.get("id"));
+ let name = black_box(row.get("name"));
+ let val = black_box(row.get("value"));
+ let act = black_box(row.get("active"));
+ black_box((id, name, val, act))
+ })
+ });
+}
+
+criterion_group!(
+ benches,
+ bench_raw_select_single,
+ bench_raw_select_100,
+ bench_raw_select_count,
+ bench_raw_insert_single,
+ bench_raw_update,
+ bench_raw_delete_single,
+ bench_compiled_select_100,
+ bench_compiled_select_filtered,
+ bench_compiled_select_complex,
+ bench_compiled_count,
+ bench_compiled_insert,
+ bench_compiled_update,
+ bench_decode_row_small,
+);
+criterion_main!(benches);
diff --git a/ryx-backend/src/backends/mod.rs b/ryx-backend/src/backends/mod.rs
index 0f8a0ee..f9c65e0 100644
--- a/ryx-backend/src/backends/mod.rs
+++ b/ryx-backend/src/backends/mod.rs
@@ -4,7 +4,7 @@ pub mod mysql;
pub mod postgres;
pub mod sqlite;
-use ryx_core::errors::{RyxError, RyxResult};
+use ryx_common::errors::{RyxError, RyxResult};
use ryx_query::{
ast::{QueryNode, SqlValue},
compiler::CompiledQuery,
@@ -156,7 +156,13 @@ fn bind_pg<'q>(
SqlValue::Bool(b) => q.bind(*b),
SqlValue::Int(i) => q.bind(*i),
SqlValue::Float(f) => q.bind(*f),
- SqlValue::Text(s) => q.bind(s.as_str()),
+ SqlValue::Text(s)
+ | SqlValue::Date(s)
+ | SqlValue::DateTime(s)
+ | SqlValue::Time(s)
+ | SqlValue::Uuid(s)
+ | SqlValue::Decimal(s)
+ | SqlValue::Json(s) => q.bind(s.as_str()),
SqlValue::List(_) => q,
}
}
@@ -170,7 +176,13 @@ fn bind_mysql<'q>(
SqlValue::Bool(b) => q.bind(*b),
SqlValue::Int(i) => q.bind(*i),
SqlValue::Float(f) => q.bind(*f),
- SqlValue::Text(s) => q.bind(s.as_str()),
+ SqlValue::Text(s)
+ | SqlValue::Date(s)
+ | SqlValue::DateTime(s)
+ | SqlValue::Time(s)
+ | SqlValue::Uuid(s)
+ | SqlValue::Decimal(s)
+ | SqlValue::Json(s) => q.bind(s.as_str()),
SqlValue::List(_) => q,
}
}
@@ -184,7 +196,13 @@ fn bind_sqlite<'q>(
SqlValue::Bool(b) => q.bind(*b),
SqlValue::Int(i) => q.bind(*i),
SqlValue::Float(f) => q.bind(*f),
- SqlValue::Text(s) => q.bind(s.as_str()),
+ SqlValue::Text(s)
+ | SqlValue::Date(s)
+ | SqlValue::DateTime(s)
+ | SqlValue::Time(s)
+ | SqlValue::Uuid(s)
+ | SqlValue::Decimal(s)
+ | SqlValue::Json(s) => q.bind(s.as_str()),
SqlValue::List(_) => q,
}
}
diff --git a/ryx-backend/src/backends/mysql.rs b/ryx-backend/src/backends/mysql.rs
index 6117a92..fe48e77 100644
--- a/ryx-backend/src/backends/mysql.rs
+++ b/ryx-backend/src/backends/mysql.rs
@@ -6,7 +6,7 @@ use sqlx::{
mysql::{MySqlPool, MySqlPoolOptions},
};
-use ryx_core::errors::{RyxError, RyxResult};
+use ryx_common::errors::{RyxError, RyxResult};
use ryx_query::ast::{QueryNode, SqlValue};
use ryx_query::compiler::{CompiledQuery, compile};
@@ -76,7 +76,13 @@ impl MySqlBackend {
SqlValue::Bool(b) => q.bind(*b),
SqlValue::Int(i) => q.bind(*i),
SqlValue::Float(f) => q.bind(*f),
- SqlValue::Text(s) => q.bind(s.as_str()),
+ SqlValue::Text(s)
+ | SqlValue::Date(s)
+ | SqlValue::DateTime(s)
+ | SqlValue::Time(s)
+ | SqlValue::Uuid(s)
+ | SqlValue::Decimal(s)
+ | SqlValue::Json(s) => q.bind(s.as_str()),
// Lists should have been expanded by the compiler into individual
// placeholders. If we encounter a List here it's a compiler bug.
SqlValue::List(_) => {
@@ -487,6 +493,9 @@ impl RyxBackend for MySqlBackend {
for (idx, _col) in columns.iter().enumerate() {
let raw = {
match rows.get(0).and_then(|r| r.get(idx)) {
+ Some(SqlValue::Date(_)) => "CAST(? AS DATE)".to_string(),
+ Some(SqlValue::DateTime(_)) => "CAST(? AS TIMESTAMP)".to_string(),
+ Some(SqlValue::Time(_)) => "CAST(? AS TIME)".to_string(),
Some(SqlValue::Text(s)) if is_date(s) => "CAST(? AS DATE)".to_string(),
Some(SqlValue::Text(s)) if is_timestamp(s) => {
"CAST(? AS TIMESTAMP)".to_string()
diff --git a/ryx-backend/src/backends/postgres.rs b/ryx-backend/src/backends/postgres.rs
index f6ff397..3992def 100644
--- a/ryx-backend/src/backends/postgres.rs
+++ b/ryx-backend/src/backends/postgres.rs
@@ -6,10 +6,10 @@ use sqlx::{
postgres::{PgPool, PgPoolOptions},
};
-use ryx_core::{
- errors::{RyxError, RyxResult},
- model_registry,
-};
+use ryx_common::errors::{RyxError, RyxResult};
+
+#[cfg(feature = "python")]
+use ryx_core::model_registry;
use ryx_query::ast::{QueryNode, SqlValue};
use ryx_query::compiler::{CompiledQuery, compile};
@@ -79,7 +79,13 @@ impl PostgresBackend {
SqlValue::Bool(b) => q.bind(*b),
SqlValue::Int(i) => q.bind(*i),
SqlValue::Float(f) => q.bind(*f),
- SqlValue::Text(s) => q.bind(s.as_str()),
+ SqlValue::Text(s)
+ | SqlValue::Date(s)
+ | SqlValue::DateTime(s)
+ | SqlValue::Time(s)
+ | SqlValue::Uuid(s)
+ | SqlValue::Decimal(s)
+ | SqlValue::Json(s) => q.bind(s.as_str()),
// Lists should have been expanded by the compiler into individual
// placeholders. If we encounter a List here it's a compiler bug.
SqlValue::List(_) => {
@@ -129,20 +135,36 @@ impl PostgresBackend {
// field in the registry to get an authoritative type.
if let (Some(cols), Some(table)) = (&query.column_names, &query.base_table) {
if idx < cols.len() {
- if let Some(spec) = model_registry::lookup_field(table, &cols[idx]) {
- return self.postgres_cast_for_type(&spec.data_type);
+ if let Some(cast) = self.maybe_model_cast(table, &cols[idx]) {
+ return Some(cast);
}
}
}
// Fallback heuristic (for WHERE values) to avoid regressions.
query.values.get(idx).and_then(|v| match v {
+ SqlValue::Date(_) => Some("::date"),
+ SqlValue::DateTime(_) => Some("::timestamp"),
SqlValue::Text(s) if is_date(s) => Some("::date"),
SqlValue::Text(s) if is_timestamp(s) => Some("::timestamp"),
_ => None,
})
}
+ /// Look up a field in the model registry and return its PG cast suffix.
+ /// Falls back to `None` when the Python model registry is not available.
+ #[inline]
+ fn maybe_model_cast(&self, table: &str, col: &str) -> Option<&'static str> {
+ #[cfg(feature = "python")]
+ {
+ if let Some(spec) = model_registry::lookup_field(table, col) {
+ return self.postgres_cast_for_type(&spec.data_type);
+ }
+ }
+ let _ = (table, col);
+ None
+ }
+
/// Map a Django-style field type string to a PostgreSQL cast suffix.
pub fn postgres_cast_for_type(&self, data_type: &str) -> Option<&'static str> {
match data_type {
@@ -150,7 +172,7 @@ impl PostgresBackend {
"DateTimeField" | "DateTimeTzField" | "DateTimeTZField" => Some("::timestamp"),
"TimeField" => Some("::time"),
"JSONField" => Some("::jsonb"),
- // "UUIDField" => Some("::uuid"),
+ "UUIDField" => Some("::uuid"),
"AutoField" | "BigAutoField" | "SmallAutoField" => Some("::serial"),
_ => None,
}
@@ -173,7 +195,7 @@ impl RyxBackend for PostgresBackend {
/// Execute a compiled query and return all resulting rows as a vector of DecodedRow.
/// Uses `sqlx::query` to prepare the query, binds parameters, and executes it against the pool.
/// Usage:
- /// ```
+ /// ```ignore
/// let query = CompiledQuery {
/// sql: "SELECT id, name FROM users WHERE age > $1".to_string(),
/// values: vec![SqlValue::Int(30)],
@@ -197,7 +219,7 @@ impl RyxBackend for PostgresBackend {
/// Execute a compiled query and return a single DecodedRow.
/// Uses `sqlx::query` to prepare the query, binds parameters, and executes it against the pool.
/// Usage:
- /// ```
+ /// ```ignore
/// let query = CompiledQuery {
/// sql: "SELECT id, name FROM users WHERE id = $1".to_string(),
/// values: vec![SqlValue::Int(42)],
@@ -222,7 +244,7 @@ impl RyxBackend for PostgresBackend {
/// Execute a compiled mutation query (INSERT/UPDATE/DELETE) and return the number of affected rows.
/// Uses `sqlx::query` to prepare the query, binds parameters, and executes it against the pool.
/// Usage:
- /// ```
+ /// ```ignore
/// let query = CompiledQuery {
/// sql: "UPDATE users SET active = false WHERE last_login < $1".to_string(),
/// values: vec![SqlValue::Text("2024-01-01".to_string())],
@@ -256,7 +278,7 @@ impl RyxBackend for PostgresBackend {
/// Execute a raw SQL query and return all resulting rows as a vector of DecodedRow.
/// This is used for queries that bypass the compiler and are executed directly.
/// Usage:
- /// ```
+ /// ```ignore
/// let sql = "SELECT id, name FROM users WHERE active = true".to_string();
/// let rows = backend.fetch_raw(sql, None).await.unwrap();
/// for row in rows {
@@ -278,7 +300,7 @@ impl RyxBackend for PostgresBackend {
/// Execute a compiled query represented as a QueryNode and return all resulting rows as a vector of DecodedRow.
/// This is a convenience method that compiles the QueryNode and then executes it using fetch_all.
/// Usage:
- /// ```
+ /// ```ignore
/// let node = QueryNode::Select { ... }; // Construct a QueryNode representing the query
/// let rows = backend.fetch_all_compiled(node).await.unwrap();
/// for row in rows {
@@ -553,11 +575,7 @@ impl RyxBackend for PostgresBackend {
// Build placeholders once with proper casting for PostgreSQL.
let mut placeholders: Vec = Vec::with_capacity(columns.len());
for (idx, col) in columns.iter().enumerate() {
- let cast = if let Some(spec) = model_registry::lookup_field(&table, col) {
- self.postgres_cast_for_type(&spec.data_type)
- } else {
- None
- };
+ let cast = self.maybe_model_cast(&table, col);
let raw = format!("${}{}", idx + 1, cast.unwrap_or(""));
placeholders.push(raw);
}
@@ -648,8 +666,7 @@ impl RyxBackend for PostgresBackend {
});
}
- let pk_cast = model_registry::lookup_field(&table, &pk_col)
- .and_then(|s| self.postgres_cast_for_type(&s.data_type));
+ let pk_cast = self.maybe_model_cast(&table, &pk_col);
let mut param_idx = 0usize;
let ph = (0..pks.len())
@@ -704,14 +721,12 @@ impl RyxBackend for PostgresBackend {
let mut case_clauses = Vec::with_capacity(f);
let mut all_values: SmallVec<[SqlValue; 8]> = SmallVec::with_capacity(n * f * 2 + n);
- let pk_cast = model_registry::lookup_field(&table, &pk_col)
- .and_then(|s| self.postgres_cast_for_type(&s.data_type));
+ let pk_cast = self.maybe_model_cast(&table, &pk_col);
// Build CASE clauses with placeholders.
let mut param_idx: usize = 0;
for (fi, col_name) in col_names.iter().enumerate() {
- let value_cast = model_registry::lookup_field(&table, col_name)
- .and_then(|s| self.postgres_cast_for_type(&s.data_type));
+ let value_cast = self.maybe_model_cast(&table, col_name);
let mut case_parts = Vec::with_capacity(n * 3 + 2);
case_parts.push(format!("\"{}\" = CASE \"{}\"", col_name, pk_col));
diff --git a/ryx-backend/src/backends/sqlite.rs b/ryx-backend/src/backends/sqlite.rs
index b7d6462..38ae35c 100644
--- a/ryx-backend/src/backends/sqlite.rs
+++ b/ryx-backend/src/backends/sqlite.rs
@@ -6,7 +6,7 @@ use sqlx::{
sqlite::{SqlitePool, SqlitePoolOptions},
};
-use ryx_core::errors::{RyxError, RyxResult};
+use ryx_common::errors::{RyxError, RyxResult};
use ryx_query::ast::{QueryNode, SqlValue};
use ryx_query::compiler::{CompiledQuery, compile};
@@ -76,7 +76,13 @@ impl SqliteBackend {
SqlValue::Bool(b) => q.bind(*b),
SqlValue::Int(i) => q.bind(*i),
SqlValue::Float(f) => q.bind(*f),
- SqlValue::Text(s) => q.bind(s.as_str()),
+ SqlValue::Text(s)
+ | SqlValue::Date(s)
+ | SqlValue::DateTime(s)
+ | SqlValue::Time(s)
+ | SqlValue::Uuid(s)
+ | SqlValue::Decimal(s)
+ | SqlValue::Json(s) => q.bind(s.as_str()),
// Lists should have been expanded by the compiler into individual
// placeholders. If we encounter a List here it's a compiler bug.
SqlValue::List(_) => {
@@ -480,11 +486,14 @@ impl RyxBackend for SqliteBackend {
.collect::>()
.join(", ");
- // Build placeholders once with proper casting for PostgreSQL.
+ // Build placeholders once with proper casting for SQLite.
let mut placeholders: Vec = Vec::with_capacity(columns.len());
for (idx, _col) in columns.iter().enumerate() {
let raw = {
match rows.get(0).and_then(|r| r.get(idx)) {
+ Some(SqlValue::Date(_)) => "CAST(? AS DATE)".to_string(),
+ Some(SqlValue::DateTime(_)) => "CAST(? AS TIMESTAMP)".to_string(),
+ Some(SqlValue::Time(_)) => "CAST(? AS TIME)".to_string(),
Some(SqlValue::Text(s)) if is_date(s) => "CAST(? AS DATE)".to_string(),
Some(SqlValue::Text(s)) if is_timestamp(s) => {
"CAST(? AS TIMESTAMP)".to_string()
@@ -496,7 +505,7 @@ impl RyxBackend for SqliteBackend {
}
let row_ph = format!("({})", placeholders.join(", "));
- // For PostgreSQL we must bump placeholder numbers per row.
+
let mut values_sql_parts = Vec::with_capacity(rows.len());
values_sql_parts = std::iter::repeat(row_ph.clone()).take(rows.len()).collect();
diff --git a/ryx-backend/src/core.rs b/ryx-backend/src/core.rs
index 1a89c9a..b5e8adf 100644
--- a/ryx-backend/src/core.rs
+++ b/ryx-backend/src/core.rs
@@ -1,7 +1,7 @@
-// Rexport core types for use in backends and pool management
-pub use ryx_core::{
+pub use ryx_common::{
errors::{RyxError, RyxResult},
- model_registry::{
- self, PyFieldSpec, PyModelOptions, PyModelSpec, get_model_spec, register_model_spec,
- },
+ model::{FieldMeta, ModelMeta},
};
+
+#[cfg(feature = "python")]
+pub use ryx_core::model_registry::{self, PyFieldSpec, PyModelOptions, PyModelSpec};
diff --git a/ryx-backend/src/pool.rs b/ryx-backend/src/pool.rs
index 0de1f23..9c709c1 100644
--- a/ryx-backend/src/pool.rs
+++ b/ryx-backend/src/pool.rs
@@ -35,7 +35,8 @@ use ryx_query::Backend;
use crate::backends::{
RyxBackend, mysql::MySqlBackend, postgres::PostgresBackend, sqlite::SqliteBackend,
};
-use ryx_core::errors::{RyxError, RyxResult};
+use ryx_common::errors::{RyxError, RyxResult};
+pub use ryx_common::PoolConfig;
fn to_static(tx: sqlx::Transaction<'_, T>) -> sqlx::Transaction<'static, T> {
// SAFETY: transactions are tied to the process-lifetime pool. Extending the
@@ -82,54 +83,6 @@ pub struct PoolRegistry {
/// Global singleton for the pool registry.
static REGISTRY: OnceLock> = OnceLock::new();
-// ###
-// Pool configuration options
-//
-// We expose a subset of sqlx's PoolOptions to Python so users can tune the
-// pool without having to write Rust. These map 1:1 to sqlx fields.
-// ###
-
-/// Configuration options for the connection pool.
-///
-/// Passed from Python to `initialize()`. All fields are optional — sane
-/// defaults are applied when fields are `None`.
-#[derive(Debug, Clone)]
-pub struct PoolConfig {
- /// Maximum number of connections the pool will maintain.
- /// Default: 10. Tune based on your database's `max_connections` setting.
- pub max_connections: u32,
-
- /// Minimum number of idle connections the pool will keep alive.
- /// Default: 1. Setting this higher reduces connection establishment latency
- /// at the cost of holding connections open.
- pub min_connections: u32,
-
- /// How long (in seconds) to wait for a connection before giving up.
- /// Default: 30s. Raise this for slow networks or cold-start scenarios.
- pub connect_timeout_secs: u64,
-
- /// How long (in seconds) an idle connection is kept before being closed.
- /// Default: 600s (10 min). Lower this if your database has a tight
- /// `wait_timeout` setting (common with MySQL/MariaDB).
- pub idle_timeout_secs: u64,
-
- /// Maximum lifetime (in seconds) of any connection regardless of usage.
- /// Default: 1800s (30 min). Protects against stale connections.
- pub max_lifetime_secs: u64,
-}
-
-impl Default for PoolConfig {
- fn default() -> Self {
- Self {
- max_connections: 10,
- min_connections: 1,
- connect_timeout_secs: 30,
- idle_timeout_secs: 600,
- max_lifetime_secs: 1800,
- }
- }
-}
-
//
// Public API
//
diff --git a/ryx-backend/src/transaction.rs b/ryx-backend/src/transaction.rs
index fbf66b4..67fb31f 100644
--- a/ryx-backend/src/transaction.rs
+++ b/ryx-backend/src/transaction.rs
@@ -28,7 +28,7 @@ use once_cell::sync::OnceCell;
use std::sync::{Arc, Mutex as StdMutex};
use tokio::sync::Mutex;
-use ryx_core::errors::{RyxError, RyxResult};
+use ryx_common::errors::{RyxError, RyxResult};
use ryx_query::compiler::CompiledQuery;
use crate::backends::{RowView, RyxBackend, RyxTransaction};
diff --git a/ryx-backend/src/utils.rs b/ryx-backend/src/utils.rs
index d0ddbdc..7307f5b 100644
--- a/ryx-backend/src/utils.rs
+++ b/ryx-backend/src/utils.rs
@@ -1,10 +1,12 @@
use sqlx::Column;
-use ryx_core::model_registry;
use ryx_query::ast::SqlValue;
use crate::backends::DecodedRow;
+#[cfg(feature = "python")]
+use ryx_core::model_registry;
+
pub fn is_date(s: &str) -> bool {
matches!(s.len(), 10) && s.chars().nth(4) == Some('-') && s.chars().nth(7) == Some('-')
}
@@ -54,10 +56,7 @@ where
for (idx, name) in mapping.columns.iter().enumerate() {
let ord = row.columns().get(idx).map(|c| c.ordinal()).unwrap_or(idx);
- let value = match base_table.and_then(|t| model_registry::lookup_field(t, name)) {
- Some(spec) => decode_with_spec(row, ord, &spec),
- None => decode_heuristic(row, ord, name),
- };
+ let value = lookup_with_spec(row, ord, name, base_table);
values.push(value);
}
@@ -67,6 +66,33 @@ where
}
}
+/// Try to decode a column using the Python field registry (if available),
+/// otherwise fall back to heuristic decoding.
+fn lookup_with_spec(
+ row: &T,
+ ord: usize,
+ name: &str,
+ #[allow(unused)] base_table: Option<&str>,
+) -> SqlValue
+where
+ usize: sqlx::ColumnIndex,
+ bool: sqlx::Type + for<'r> sqlx::Decode<'r, T::Database>,
+ i64: sqlx::Type + for<'r> sqlx::Decode<'r, T::Database>,
+ f64: sqlx::Type + for<'r> sqlx::Decode<'r, T::Database>,
+ String: sqlx::Type + for<'r> sqlx::Decode<'r, T::Database>,
+{
+ #[cfg(feature = "python")]
+ {
+ if let Some(base_table) = base_table {
+ if let Some(spec) = model_registry::lookup_field(base_table, name) {
+ return decode_with_spec(row, ord, &spec);
+ }
+ }
+ }
+ decode_heuristic(row, ord, name)
+}
+
+#[cfg(feature = "python")]
pub fn decode_with_spec(
row: &T,
ord: usize,
@@ -90,7 +116,7 @@ where
.try_get::(ord)
.map(SqlValue::Int)
.unwrap_or(SqlValue::Null),
- "FloatField" | "DecimalField" => row
+ "FloatField" => row
.try_get::(ord)
.map(SqlValue::Float)
.unwrap_or_else(|_| {
@@ -98,17 +124,33 @@ where
.map(SqlValue::Text)
.unwrap_or(SqlValue::Null)
}),
- "UUIDField" | "CharField" | "TextField" | "SlugField" | "EmailField" | "URLField" => row
+ "DecimalField" => row
.try_get::(ord)
- .map(SqlValue::Text)
+ .map(SqlValue::Decimal)
.unwrap_or(SqlValue::Null),
- "DateTimeField" | "DateField" | "TimeField" => row
+ "UUIDField" => row
+ .try_get::(ord)
+ .map(SqlValue::Uuid)
+ .unwrap_or(SqlValue::Null),
+ "CharField" | "TextField" | "SlugField" | "EmailField" | "URLField" => row
.try_get::(ord)
.map(SqlValue::Text)
.unwrap_or(SqlValue::Null),
+ "DateTimeField" => row
+ .try_get::(ord)
+ .map(SqlValue::DateTime)
+ .unwrap_or(SqlValue::Null),
+ "DateField" => row
+ .try_get::(ord)
+ .map(SqlValue::Date)
+ .unwrap_or(SqlValue::Null),
+ "TimeField" => row
+ .try_get::(ord)
+ .map(SqlValue::Time)
+ .unwrap_or(SqlValue::Null),
"JSONField" => row
.try_get::(ord)
- .map(SqlValue::Text)
+ .map(SqlValue::Json)
.unwrap_or(SqlValue::Null),
_ => decode_heuristic(row, ord, &spec.name),
}
diff --git a/ryx-common/Cargo.toml b/ryx-common/Cargo.toml
new file mode 100644
index 0000000..bb899f6
--- /dev/null
+++ b/ryx-common/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "ryx-common"
+version = "0.1.0"
+edition = "2024"
+description = "Shared types for Ryx ORM (no PyO3 dependency)"
+
+[dependencies]
+ryx-query = { path = "../ryx-query", version = "0.1" }
+sqlx = { workspace = true }
+tokio = { workspace = true }
+thiserror = { workspace = true }
+serde = { workspace = true }
diff --git a/ryx-common/src/errors.rs b/ryx-common/src/errors.rs
new file mode 100644
index 0000000..d8cf462
--- /dev/null
+++ b/ryx-common/src/errors.rs
@@ -0,0 +1,31 @@
+use ryx_query::QueryError;
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum RyxError {
+ #[error("Database error: {0}")]
+ Database(#[from] sqlx::Error),
+
+ #[error("Database error: {1} (sql: {0})")]
+ DatabaseWithSql(String, sqlx::Error),
+
+ #[error("Query error: {0}")]
+ Query(#[from] QueryError),
+
+ #[error("No matching object found for the given query")]
+ DoesNotExist,
+
+ #[error("Query returned multiple objects; expected exactly one")]
+ MultipleObjectsReturned,
+
+ #[error("Connection pool is not initialized. Call setup() first.")]
+ PoolNotInitialized,
+
+ #[error("Connection pool already initialized")]
+ PoolAlreadyInitialized,
+
+ #[error("Internal Ryx error: {0}")]
+ Internal(String),
+}
+
+pub type RyxResult = Result;
diff --git a/ryx-common/src/lib.rs b/ryx-common/src/lib.rs
new file mode 100644
index 0000000..b8e3d4f
--- /dev/null
+++ b/ryx-common/src/lib.rs
@@ -0,0 +1,11 @@
+pub mod errors;
+pub mod model;
+pub mod pool;
+
+pub use errors::{RyxError, RyxResult};
+pub use model::{FieldMeta, ModelMeta};
+pub use pool::PoolConfig;
+
+// Re-export key types from ryx-query for convenience
+pub use ryx_query::ast::SqlValue;
+pub use ryx_query::Backend;
diff --git a/ryx-common/src/model.rs b/ryx-common/src/model.rs
new file mode 100644
index 0000000..533f4b0
--- /dev/null
+++ b/ryx-common/src/model.rs
@@ -0,0 +1,29 @@
+#[derive(Clone, Debug)]
+pub struct FieldMeta {
+ pub name: String,
+ pub column: String,
+ pub primary_key: bool,
+ pub data_type: String,
+ pub nullable: bool,
+ pub unique: bool,
+}
+
+#[derive(Clone, Debug)]
+pub struct ModelMeta {
+ pub name: String,
+ pub table: String,
+ pub app_label: Option,
+ pub database: Option,
+ pub ordering: Vec,
+ pub managed: bool,
+ pub abstract_model: bool,
+ pub fields: Vec,
+}
+
+impl ModelMeta {
+ pub fn field(&self, name: &str) -> Option<&FieldMeta> {
+ self.fields
+ .iter()
+ .find(|f| f.name == name || f.column == name)
+ }
+}
diff --git a/ryx-common/src/pool.rs b/ryx-common/src/pool.rs
new file mode 100644
index 0000000..57ce069
--- /dev/null
+++ b/ryx-common/src/pool.rs
@@ -0,0 +1,20 @@
+#[derive(Debug, Clone)]
+pub struct PoolConfig {
+ pub max_connections: u32,
+ pub min_connections: u32,
+ pub connect_timeout_secs: u64,
+ pub idle_timeout_secs: u64,
+ pub max_lifetime_secs: u64,
+}
+
+impl Default for PoolConfig {
+ fn default() -> Self {
+ Self {
+ max_connections: 10,
+ min_connections: 1,
+ connect_timeout_secs: 30,
+ idle_timeout_secs: 600,
+ max_lifetime_secs: 1800,
+ }
+ }
+}
diff --git a/ryx-core/Cargo.toml b/ryx-core/Cargo.toml
index 080538b..422eeef 100644
--- a/ryx-core/Cargo.toml
+++ b/ryx-core/Cargo.toml
@@ -33,51 +33,19 @@ sqlite = ["sqlx/sqlite"]
all = ["postgres", "mysql", "sqlite"]
[dependencies]
-ryx-query = { path = "../ryx-query" }
+ryx-common = { path = "../ryx-common", version = "0.1" }
+ryx-query = { path = "../ryx-query", version = "0.1" }
-# PyO3
-# "extension-module" is required when building a cdylib for Python import.
-# Without it, PyO3 tries to link against libpython, which breaks on Linux/macOS
-# when Python dynamically loads the extension.
pyo3 = { workspace = true }
-
-# Async bridge
-# pyo3-async-runtimes is the maintained successor of the abandoned pyo3-asyncio.
-# The "tokio-runtime" feature wires Rust Futures into Python's asyncio event
-# loop via tokio — users simply `await` our ORM calls from Python.
pyo3-async-runtimes = { workspace = true }
-
-# sqlx
-# We use sqlx 0.8.x (stable). The "runtime-tokio" feature is mandatory since
-# we drive everything through tokio. "macros" enables the query!/query_as!
-# macros if needed later. "chrono" adds DateTime support.
sqlx = { workspace = true }
-
-# Tokio
-# Full tokio runtime. "full" is fine for a library crate — callers can restrict
-# features if they need a lighter binary.
tokio = { workspace = true }
smallvec = { workspace = true }
chrono = { workspace = true }
-
-# Serialization
-# serde + serde_json: used to pass structured data between Rust and Python
-# (row data, query parameters, etc.)
serde = { workspace = true }
serde_json = { workspace = true }
-
-# Utilities
-# thiserror: ergonomic error type derivation. We define a rich BityaError type
-# that converts cleanly into Python exceptions via PyO3's IntoPy trait.
thiserror = { workspace = true }
-
-# once_cell: used to store the global tokio Runtime and the connection pool
-# as lazily-initialized singletons. Using std::sync::OnceLock would also work
-# on Rust 1.70+, but once_cell has a slightly nicer API for our use case.
once_cell = { workspace = true }
-
-# tracing: structured, async-aware logging. We instrument every SQL execution
-# so users can enable RUST_LOG=ryx=debug for full query visibility.
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
diff --git a/ryx-core/src/errors.rs b/ryx-core/src/errors.rs
index 4e0129a..eb3ea35 100644
--- a/ryx-core/src/errors.rs
+++ b/ryx-core/src/errors.rs
@@ -1,113 +1,5 @@
-//
-// ###
-// Ryx — Unified Error Type
-// ###
-//
-// Design decision: we define a single RyxError enum that covers every failure
-// mode across the entire crate (database errors, type mapping errors, pool
-// errors, etc.). This enum implements:
-//
-// 1. `thiserror::Error` → gives us Display + Error + From impls for free
-// 2. `From for PyErr` → converts every Rust error into the
-// appropriate Python exception transparently (PyO3 calls this when a
-// #[pyfunction] returns Err(RyxError))
-//
-// We map Rust errors to Python exception types that users already know:
-// - DoesNotExist → raises `Ryx.exceptions.DoesNotExist` (like Django)
-// - MultipleObjects → raises `Ryx.exceptions.MultipleObjectsReturned`
-// - DatabaseError → raises `Ryx.exceptions.DatabaseError`
-// - ...
-//
-// This keeps the Python surface clean: users never see "PyRuntimeError: sqlx::…"
-// ###
-
-use pyo3::exceptions::{PyRuntimeError, PyValueError};
-use pyo3::prelude::*;
-use ryx_query::QueryError;
-use thiserror::Error;
-
-/// The master error type for the entire Ryx ORM.
+/// Re-export the core error types from ryx-common (no PyO3 dependency).
///
-/// Every function in this crate that can fail returns `Result`.
-/// PyO3 automatically converts this into a Python exception via the `From` impl
-/// below whenever a `#[pyfunction]` or `#[pymethods]` method returns `Err(...)`.
-#[derive(Debug, Error)]
-pub enum RyxError {
- // Database-level errors
- /// Wraps every error produced by sqlx (connection failures, query errors,
- /// constraint violations, etc.). We keep the original sqlx error so that
- /// tracing/logging can capture the full details.
- #[error("Database error: {0}")]
- Database(#[from] sqlx::Error),
- /// Database error with SQL context
- #[error("Database error: {1} (sql: {0})")]
- DatabaseWithSql(String, sqlx::Error),
-
- /// Errors from the query compiler.
- #[error("Query error: {0}")]
- Query(#[from] QueryError),
-
- /// Raised when `.get()` or `.first()` finds no matching row.
- /// Mirrors Django's `Model.DoesNotExist`.
- #[error("No matching object found for the given query")]
- DoesNotExist,
-
- /// Raised when `.get()` matches more than one row.
- /// Mirrors Django's `Model.MultipleObjectsReturned`.
- #[error("Query returned multiple objects; expected exactly one")]
- MultipleObjectsReturned,
-
- // Connection pool errors
- /// Raised when user code calls any ORM operation before `Ryx.setup()`
- /// has been called to initialize the connection pool.
- #[error("Connection pool is not initialized. Call Ryx.setup() first.")]
- PoolNotInitialized,
-
- /// Raised when the connection pool was already initialized and the user
- /// calls `Ryx.setup()` a second time with a different URL.
- #[error("Connection pool already initialized")]
- PoolAlreadyInitialized,
-
- // Runtime / internal errors
- /// Catch-all for internal errors that shouldn't reach users but are
- /// wrapped here so we don't use `.unwrap()` anywhere in the codebase.
- /// If this appears in production, it's always a bug — please file an issue.
- #[error("Internal Ryx error: {0}")]
- Internal(String),
-}
-
-// ###
-// PyO3 conversion: RyxError → Python exception
-//
-// PyO3 requires `From for PyErr` so that functions marked
-// `-> PyResult` can use `?` to propagate RyxError automatically.
-//
-// We deliberately keep Python exception types simple and familiar:
-// - Lookup / field errors → ValueError (user code problem)
-// - DoesNotExist → RuntimeError (matches Django behaviour)
-// - Everything else → RuntimeError with full message
-//
-// TODO: In a future version we should define custom Python exception classes
-// (via `pyo3::create_exception!`) so users can do `except Ryx.DoesNotExist`.
-// For now we keep it simple to avoid complexity in the foundation layer.
-// ###
-impl From for PyErr {
- fn from(err: RyxError) -> PyErr {
- match &err {
- RyxError::Query(qe) => match qe {
- QueryError::UnknownLookup { .. }
- | QueryError::UnknownField { .. }
- | QueryError::TypeMismatch { .. } => PyValueError::new_err(qe.to_string()),
- QueryError::Internal(_) => PyRuntimeError::new_err(qe.to_string()),
- },
- RyxError::DatabaseWithSql(sql, e) => {
- PyRuntimeError::new_err(format!("Database error: {e} (sql: {sql})"))
- }
- _ => PyRuntimeError::new_err(err.to_string()),
- }
- }
-}
-
-/// Convenience type alias used throughout the crate.
-/// Every Ryx function returns `RyxResult` instead of `Result`.
-pub type RyxResult = Result;
+/// The `From for PyErr` conversion lives in `ryx-python`
+/// where both `ryx-common` and `pyo3` are available as direct deps.
+pub use ryx_common::errors::{RyxError, RyxResult};
diff --git a/ryx-core/src/executor.rs b/ryx-core/src/executor.rs
index eacede1..03c2b57 100644
--- a/ryx-core/src/executor.rs
+++ b/ryx-core/src/executor.rs
@@ -616,7 +616,13 @@ fn bind_values<'q>(
SqlValue::Bool(b) => q.bind(*b),
SqlValue::Int(i) => q.bind(*i),
SqlValue::Float(f) => q.bind(*f),
- SqlValue::Text(s) => q.bind(s.as_str()),
+ SqlValue::Text(s)
+ | SqlValue::Date(s)
+ | SqlValue::DateTime(s)
+ | SqlValue::Time(s)
+ | SqlValue::Uuid(s)
+ | SqlValue::Decimal(s)
+ | SqlValue::Json(s) => q.bind(s.as_str()),
// Lists should have been expanded by the compiler into individual
// placeholders. If we encounter a List here it's a compiler bug.
SqlValue::List(_) => {
@@ -779,7 +785,7 @@ fn decode_with_spec(
.try_get::(ord)
.map(SqlValue::Int)
.unwrap_or(SqlValue::Null),
- "FloatField" | "DecimalField" => row
+ "FloatField" => row
.try_get::(ord)
.map(SqlValue::Float)
.unwrap_or_else(|_| {
@@ -787,17 +793,33 @@ fn decode_with_spec(
.map(SqlValue::Text)
.unwrap_or(SqlValue::Null)
}),
- "UUIDField" | "CharField" | "TextField" | "SlugField" | "EmailField" | "URLField" => row
+ "DecimalField" => row
.try_get::(ord)
- .map(SqlValue::Text)
+ .map(SqlValue::Decimal)
+ .unwrap_or(SqlValue::Null),
+ "UUIDField" => row
+ .try_get::(ord)
+ .map(SqlValue::Uuid)
.unwrap_or(SqlValue::Null),
- "DateTimeField" | "DateField" | "TimeField" => row
+ "CharField" | "TextField" | "SlugField" | "EmailField" | "URLField" => row
.try_get::(ord)
.map(SqlValue::Text)
.unwrap_or(SqlValue::Null),
+ "DateTimeField" => row
+ .try_get::(ord)
+ .map(SqlValue::DateTime)
+ .unwrap_or(SqlValue::Null),
+ "DateField" => row
+ .try_get::(ord)
+ .map(SqlValue::Date)
+ .unwrap_or(SqlValue::Null),
+ "TimeField" => row
+ .try_get::(ord)
+ .map(SqlValue::Time)
+ .unwrap_or(SqlValue::Null),
"JSONField" => row
.try_get::(ord)
- .map(SqlValue::Text)
+ .map(SqlValue::Json)
.unwrap_or(SqlValue::Null),
_ => decode_heuristic(row, ord, &spec.name),
}
diff --git a/ryx-core/src/lib.rs b/ryx-core/src/lib.rs
index 24eb4c2..f517c17 100644
--- a/ryx-core/src/lib.rs
+++ b/ryx-core/src/lib.rs
@@ -1,2 +1,5 @@
pub mod errors;
pub mod model_registry;
+
+// Re-export key common types for convenience.
+pub use ryx_common::pool::PoolConfig;
diff --git a/ryx-macro/Cargo.toml b/ryx-macro/Cargo.toml
new file mode 100644
index 0000000..0aa80d4
--- /dev/null
+++ b/ryx-macro/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "ryx-macro"
+version = "0.1.0"
+edition = "2024"
+description = "Derive macros for the Ryx ORM (Model, FromRow)"
+
+[lib]
+proc-macro = true
+
+[dependencies]
+syn = { version = "2", features = ["full"] }
+quote = "1"
+proc-macro2 = "1"
diff --git a/ryx-macro/src/lib.rs b/ryx-macro/src/lib.rs
new file mode 100644
index 0000000..4eefcca
--- /dev/null
+++ b/ryx-macro/src/lib.rs
@@ -0,0 +1,893 @@
+use proc_macro::TokenStream;
+use quote::quote;
+use syn::{
+ parse_macro_input, Attribute, Data, DeriveInput, Fields, ItemStruct, Lit, Meta,
+ MetaNameValue, Type,
+};
+
+/// Parsed content of a single `#[field(...)]` attribute.
+struct FieldAttr {
+ column: Option,
+ pk: bool,
+ db_type: Option,
+ nullable: bool,
+ unique: bool,
+ default: Option,
+}
+
+fn parse_field_attr(field: &syn::Field) -> FieldAttr {
+ let mut result = FieldAttr {
+ column: None,
+ pk: false,
+ db_type: None,
+ nullable: false,
+ unique: false,
+ default: None,
+ };
+ for attr in &field.attrs {
+ if !attr.path().is_ident("field") {
+ continue;
+ }
+ if let Meta::List(list) = &attr.meta {
+ let tokens = &list.tokens;
+ let raw = quote!(#tokens).to_string();
+ for segment in raw.split(',') {
+ let segment = segment.trim();
+ if segment == "pk" {
+ result.pk = true;
+ } else if segment == "nullable" {
+ result.nullable = true;
+ } else if segment == "unique" {
+ result.unique = true;
+ } else if let Some(val) = segment.strip_prefix("column = ") {
+ result.column = Some(
+ val.trim()
+ .trim_matches('"')
+ .trim_matches('\'')
+ .to_string(),
+ );
+ } else if let Some(val) = segment.strip_prefix("db_type = ") {
+ result.db_type = Some(
+ val.trim()
+ .trim_matches('"')
+ .trim_matches('\'')
+ .to_string(),
+ );
+ } else if let Some(val) = segment.strip_prefix("default = ") {
+ result.default = Some(
+ val.trim()
+ .trim_matches('"')
+ .trim_matches('\'')
+ .to_string(),
+ );
+ }
+ }
+ }
+ }
+ result
+}
+
+/// Parsed content of a `#[relation(...)]` struct-level attribute.
+struct RelationAttr {
+ name: String,
+ model: String,
+ fk_column: String,
+ to_table: Option,
+ to_field: Option,
+}
+
+fn parse_relation_attrs(attrs: &[Attribute]) -> Vec {
+ let mut relations = Vec::new();
+ for attr in attrs {
+ if !attr.path().is_ident("relation") {
+ continue;
+ }
+ if let Meta::List(list) = &attr.meta {
+ let tokens = &list.tokens;
+ let raw = quote!(#tokens).to_string();
+ let mut rel = RelationAttr {
+ name: String::new(),
+ model: String::new(),
+ fk_column: String::new(),
+ to_table: None,
+ to_field: None,
+ };
+ for segment in raw.split(',') {
+ let segment = segment.trim();
+ if let Some(val) = segment.strip_prefix("name = ") {
+ rel.name = val.trim().trim_matches('"').trim_matches('\'').to_string();
+ } else if let Some(val) = segment.strip_prefix("model = ") {
+ rel.model = val.trim().trim_matches('"').trim_matches('\'').to_string();
+ } else if let Some(val) = segment.strip_prefix("fk_column = ") {
+ rel.fk_column = val.trim().trim_matches('"').trim_matches('\'').to_string();
+ } else if let Some(val) = segment.strip_prefix("to_table = ") {
+ rel.to_table = Some(val.trim().trim_matches('"').trim_matches('\'').to_string());
+ } else if let Some(val) = segment.strip_prefix("to_field = ") {
+ rel.to_field = Some(val.trim().trim_matches('"').trim_matches('\'').to_string());
+ }
+ }
+ if rel.model.is_empty() {
+ continue;
+ }
+ // Default name: snake_case of model name
+ if rel.name.is_empty() {
+ let s = &rel.model;
+ let mut result = String::with_capacity(s.len() + 5);
+ for (i, c) in s.chars().enumerate() {
+ if c.is_uppercase() && i > 0 {
+ result.push('_');
+ result.push(c.to_ascii_lowercase());
+ } else {
+ result.push(c.to_ascii_lowercase());
+ }
+ }
+ rel.name = result;
+ }
+ relations.push(rel);
+ }
+ }
+ relations
+}
+
+fn generate_relationships_impl(name: &syn::Ident, relations: &[RelationAttr]) -> proc_macro2::TokenStream {
+ if relations.is_empty() {
+ return quote! {};
+ }
+ let entries: Vec<_> = relations
+ .iter()
+ .map(|r| {
+ let rel_name = &r.name;
+ let fk_col = &r.fk_column;
+ let model_ident = syn::Ident::new(&r.model, proc_macro2::Span::call_site());
+ // to_table: optional override, else ModelType::table_name()
+ let to_table = match &r.to_table {
+ Some(s) => {
+ let lit = syn::LitStr::new(s, proc_macro2::Span::call_site());
+ quote! { #lit }
+ }
+ None => quote! { <#model_ident as ::ryx_rs::model::Model>::table_name() },
+ };
+ // to_field: optional override, else ModelType::pk_field()
+ let to_field = match &r.to_field {
+ Some(s) => {
+ let lit = syn::LitStr::new(s, proc_macro2::Span::call_site());
+ quote! { #lit }
+ }
+ None => quote! { <#model_ident as ::ryx_rs::model::Model>::pk_field() },
+ };
+ quote! {
+ ::ryx_rs::model::RelationMeta {
+ name: #rel_name,
+ fk_column: #fk_col,
+ to_table: #to_table,
+ to_field: #to_field,
+ relation_fields: <#model_ident as ::ryx_rs::model::Model>::field_names(),
+ }
+ }
+ })
+ .collect();
+
+ quote! {
+ impl ::ryx_rs::model::Relationships for #name {
+ fn relations() -> &'static [::ryx_rs::model::RelationMeta] {
+ use ::std::sync::OnceLock;
+ static RELS: OnceLock> = OnceLock::new();
+ RELS.get_or_init(|| vec![#(#entries),*])
+ }
+ }
+ }
+}
+
+/// Map a Rust type to a SQL column type string.
+fn rust_type_to_sql(ty: &Type, field_attr: &FieldAttr) -> String {
+ // #[field(db_type = "...")] takes priority
+ if let Some(ref custom) = field_attr.db_type {
+ return custom.clone();
+ }
+
+ // Match on the outer type (peel Option first)
+ let (inner_ty, _is_option) = peel_option(ty);
+ let type_str = type_ident_str(inner_ty).unwrap_or_else(|| "?".to_string());
+
+ match type_str.as_str() {
+ "i32" | "Int32" => "INTEGER".to_string(),
+ "i64" | "Int64" => "BIGINT".to_string(),
+ "f32" => "REAL".to_string(),
+ "f64" => "DOUBLE PRECISION".to_string(),
+ "bool" | "Bool" => "BOOLEAN".to_string(),
+ "String" => "TEXT".to_string(),
+ "NaiveDateTime" | "DateTime" => "TIMESTAMP".to_string(),
+ "NaiveDate" | "Date" => "DATE".to_string(),
+ "NaiveTime" | "Time" => "TIME".to_string(),
+ "Uuid" => "UUID".to_string(),
+ "serde_json::Value" | "Value" => "JSONB".to_string(),
+ _ => "TEXT".to_string(), // fallback
+ }
+}
+
+/// Peel one level of Option, return (inner_type, is_option).
+fn peel_option(ty: &Type) -> (&Type, bool) {
+ if let Type::Path(type_path) = ty {
+ if let Some(first) = type_path.path.segments.first() {
+ if first.ident == "Option" {
+ if let syn::PathArguments::AngleBracketed(args) = &first.arguments {
+ if let Some(syn::GenericArgument::Type(inner)) = args.args.first() {
+ return (inner, true);
+ }
+ }
+ }
+ }
+ }
+ (ty, false)
+}
+
+/// Get the last path segment identifier of a type as a string.
+fn type_ident_str(ty: &Type) -> Option {
+ if let Type::Path(type_path) = ty {
+ type_path
+ .path
+ .segments
+ .last()
+ .map(|s| s.ident.to_string())
+ } else {
+ None
+ }
+}
+
+fn find_table_name(attrs: &[Attribute]) -> String {
+ for attr in attrs {
+ if attr.path().is_ident("table") {
+ match &attr.meta {
+ Meta::List(list) => {
+ if let Ok(lit) = syn::parse2::(list.tokens.clone()) {
+ return lit.value();
+ }
+ }
+ Meta::NameValue(MetaNameValue {
+ value:
+ syn::Expr::Lit(syn::ExprLit {
+ lit: Lit::Str(s), ..
+ }),
+ ..
+ }) => return s.value(),
+ _ => {}
+ }
+ }
+ }
+ String::new()
+}
+
+fn field_column_name(field: &syn::Field) -> String {
+ parse_field_attr(field)
+ .column
+ .unwrap_or_else(|| {
+ field
+ .ident
+ .as_ref()
+ .map(|i| i.to_string())
+ .unwrap_or_default()
+ })
+}
+
+fn is_pk_field(field: &syn::Field) -> bool {
+ parse_field_attr(field).pk
+}
+
+fn type_to_sql_reader(ty: &Type, col_name: &str) -> proc_macro2::TokenStream {
+ let col_str = col_name.to_string();
+ let col_ts = quote! { #col_str };
+ type_to_sql_reader_expr(ty, col_ts.clone(), col_ts)
+}
+
+/// Same as `type_to_sql_reader` but uses arbitrary expressions for column access.
+/// `col_expr` — expression yielding `&str` for `row.get()`
+/// `err_col` — expression for error messages
+fn type_to_sql_reader_expr(
+ ty: &Type,
+ col_expr: proc_macro2::TokenStream,
+ err_col: proc_macro2::TokenStream,
+) -> proc_macro2::TokenStream {
+
+ match ty {
+ Type::Path(type_path) => {
+ let ident = &type_path.path.segments.last().unwrap().ident;
+ let ident_str = ident.to_string();
+
+ // Handle Option
+ if type_path.path.segments.first().unwrap().ident == "Option" {
+ let inner_type = &type_path.path.segments.last().unwrap();
+ if let syn::PathArguments::AngleBracketed(args) = &inner_type.arguments {
+ if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() {
+ let inner = type_to_sql_reader_expr(inner_ty, col_expr.clone(), err_col.clone());
+ return quote! {
+ match row.get(#col_expr) {
+ Some(v) => Some({ #inner }),
+ None => None,
+ }
+ };
+ }
+ }
+ return quote! { row.get(#col_expr).and_then(|v| v.as_null().map(|_| None)).unwrap_or(None) };
+ }
+
+ let sv = quote! { ::ryx_rs::SqlValue };
+ match ident_str.as_str() {
+ "i32" => quote! {
+ row.get(#col_expr).and_then(|v: sv| match v {
+ #sv::Int(n) => Some(*n as i32),
+ _ => None,
+ }).ok_or_else(|| internal_err(#err_col, "i32"))?
+ },
+ "i64" => quote! {
+ row.get(#col_expr).and_then(|v: sv| match v {
+ #sv::Int(n) => Some(*n),
+ _ => None,
+ }).ok_or_else(|| internal_err(#err_col, "i64"))?
+ },
+ "String" => quote! {
+ row.get(#col_expr).and_then(|v: sv| match v {
+ #sv::Text(s) => Some(s.clone()),
+ _ => None,
+ }).ok_or_else(|| internal_err(#err_col, "String"))?
+ },
+ "bool" => quote! {
+ row.get(#col_expr).and_then(|v: sv| match v {
+ #sv::Bool(b) => Some(*b),
+ #sv::Int(n) => Some(*n != 0),
+ _ => None,
+ }).ok_or_else(|| internal_err(#err_col, "bool"))?
+ },
+ "f64" => quote! {
+ row.get(#col_expr).and_then(|v: sv| match v {
+ #sv::Float(f) => Some(*f),
+ #sv::Int(n) => Some(*n as f64),
+ _ => None,
+ }).ok_or_else(|| internal_err(#err_col, "f64"))?
+ },
+ _ => {
+ // Try chrono types
+ if ident_str == "NaiveDateTime" {
+ quote! {
+ row.get(#col_expr).and_then(|v: sv| match v {
+ #sv::Text(s) => chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").ok(),
+ _ => None,
+ }).ok_or_else(|| internal_err(#err_col, "NaiveDateTime"))?
+ }
+ } else if ident_str == "NaiveDate" {
+ quote! {
+ row.get(#col_expr).and_then(|v: sv| match v {
+ #sv::Text(s) => chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok(),
+ _ => None,
+ }).ok_or_else(|| internal_err(#err_col, "NaiveDate"))?
+ }
+ } else {
+ // Fallback: try to parse as string
+ quote! {
+ row.get(#col_expr).and_then(|v: sv| match v {
+ #sv::Text(s) => Some(s.parse().ok()),
+ _ => None,
+ }).flatten().ok_or_else(|| internal_err(#err_col, #ident_str))?
+ }
+ }
+ }
+ }
+ }
+ _ => quote! { compile_error!("unsupported field type") },
+ }
+}
+
+fn field_names(fields: &Fields) -> Vec {
+ let mut names = Vec::new();
+ match fields {
+ Fields::Named(named) => {
+ for field in &named.named {
+ let col = field_column_name(field);
+ let name = if col.is_empty() {
+ field.ident.as_ref().map(|i| i.to_string()).unwrap_or_default()
+ } else {
+ col
+ };
+ names.push(name);
+ }
+ }
+ _ => {}
+ }
+ names
+}
+
+fn pk_field_name(fields: &Fields) -> String {
+ match fields {
+ Fields::Named(named) => {
+ for field in &named.named {
+ if is_pk_field(field) {
+ return field.ident.as_ref().map(|i| i.to_string()).unwrap_or_default();
+ }
+ }
+ }
+ _ => {}
+ }
+ "id".to_string()
+}
+
+#[proc_macro_derive(Model, attributes(table, field, relation))]
+pub fn derive_model(input: TokenStream) -> TokenStream {
+ let input = parse_macro_input!(input as DeriveInput);
+ let name = &input.ident;
+ let relations = parse_relation_attrs(&input.attrs);
+ let table_name = find_table_name(&input.attrs);
+ let table_name = if table_name.is_empty() {
+ // Convert PascalCase to snake_case
+ let s = name.to_string();
+ let mut result = String::with_capacity(s.len() + 5);
+ for (i, c) in s.chars().enumerate() {
+ if c.is_uppercase() && i > 0 {
+ result.push('_');
+ result.push(c.to_ascii_lowercase());
+ } else {
+ result.push(c.to_ascii_lowercase());
+ }
+ }
+ result
+ } else {
+ table_name
+ };
+
+ let fields = match &input.data {
+ Data::Struct(data) => &data.fields,
+ _ => panic!("Model derive only supports structs"),
+ };
+
+ let fnames = field_names(fields);
+ let pk_field = pk_field_name(fields);
+
+ // Build FieldMeta entries (same logic as #[model])
+ let field_meta_entries: Vec<_> = match fields {
+ syn::Fields::Named(named) => named
+ .named
+ .iter()
+ .map(|field| {
+ let fattr = parse_field_attr(field);
+ let col_name = fattr.column.clone().unwrap_or_else(|| {
+ field
+ .ident
+ .as_ref()
+ .map(|i| i.to_string())
+ .unwrap_or_default()
+ });
+ let db_type = rust_type_to_sql(&field.ty, &fattr);
+ let nullable = fattr.nullable || peel_option(&field.ty).1;
+ let pk = fattr.pk;
+ let unique = fattr.unique;
+ let default = match &fattr.default {
+ Some(d) => {
+ let lit = syn::LitStr::new(d, proc_macro2::Span::call_site());
+ quote! { Some(#lit) }
+ }
+ None => quote! { None },
+ };
+ quote! {
+ ::ryx_rs::model::FieldMeta {
+ name: #col_name,
+ db_type: #db_type,
+ nullable: #nullable,
+ primary_key: #pk,
+ unique: #unique,
+ default: #default,
+ }
+ }
+ })
+ .collect(),
+ _ => vec![],
+ };
+
+ let relationships_impl = generate_relationships_impl(name, &relations);
+
+ let expanded = quote! {
+ impl ::ryx_rs::model::Model for #name {
+ fn table_name() -> &'static str {
+ #table_name
+ }
+
+ fn field_names() -> &'static [&'static str] {
+ &[#(#fnames),*]
+ }
+
+ fn pk_field() -> &'static str {
+ #pk_field
+ }
+
+ fn field_meta() -> &'static [::ryx_rs::model::FieldMeta] {
+ &[#(#field_meta_entries),*]
+ }
+ }
+
+ #relationships_impl
+ };
+
+ expanded.into()
+}
+
+#[proc_macro_derive(FromRow, attributes(field))]
+pub fn derive_from_row(input: TokenStream) -> TokenStream {
+ let input = parse_macro_input!(input as DeriveInput);
+ let name = &input.ident;
+
+ let fields = match &input.data {
+ Data::Struct(data) => &data.fields,
+ _ => panic!("FromRow derive only supports structs"),
+ };
+
+ let field_reads: Vec<_> = match fields {
+ Fields::Named(named) => named
+ .named
+ .iter()
+ .map(|field| {
+ let col_name = field_column_name(field);
+ let col_name = if col_name.is_empty() {
+ field
+ .ident
+ .as_ref()
+ .map(|i| i.to_string())
+ .unwrap_or_default()
+ } else {
+ col_name
+ };
+ let field_name = &field.ident;
+ let reader = type_to_sql_reader(&field.ty, &col_name);
+ quote! { #field_name: #reader }
+ })
+ .collect(),
+ _ => vec![],
+ };
+
+ let expanded = quote! {
+ impl FromRow for #name {
+ fn from_row(row: &RowView) -> ::ryx_rs::RyxResult {
+ fn internal_err(col: &str, ty: &str) -> ::ryx_rs::RyxError {
+ ::ryx_rs::RyxError::Internal(format!(
+ "Failed to decode column '{}' as {}", col, ty
+ ))
+ }
+ Ok(Self {
+ #(#field_reads),*
+ })
+ }
+ }
+ };
+
+ expanded.into()
+}
+
+/// Attribute macro that marks a struct as a database model with automatic
+/// `Serialize`/`Deserialize` derives.
+///
+/// Combines `Model`, `FromRow`, `Serialize`, and `Deserialize` in one step.
+///
+/// # Usage
+///
+/// ```ignore
+/// #[model]
+/// #[table("posts")]
+/// struct Post {
+/// #[field(pk)]
+/// id: i64,
+/// title: String,
+/// #[field(column = "is_active")]
+/// active: bool,
+/// }
+/// ```
+///
+/// The `#[table]` and `#[field]` attributes work the same as with
+/// `#[derive(Model, FromRow)]`.
+#[proc_macro_attribute]
+pub fn model(_attr: TokenStream, item: TokenStream) -> TokenStream {
+ let mut strukt: ItemStruct = syn::parse(item).expect("expected a struct");
+ let name = &strukt.ident;
+ let fields = &strukt.fields;
+ let relations = parse_relation_attrs(&strukt.attrs);
+
+ let data_fields = match fields {
+ syn::Fields::Named(named) => {
+ let list = named.named.clone();
+ syn::Fields::Named(syn::FieldsNamed {
+ brace_token: named.brace_token,
+ named: list,
+ })
+ }
+ other => other.clone(),
+ };
+
+ let fnames_original = field_names(&data_fields);
+ let pk = pk_field_name(&data_fields);
+
+ // Collect relation field names — computed here so field_meta can skip them
+ let relation_field_names: std::collections::HashSet =
+ relations.iter().map(|r| r.name.clone()).collect();
+
+ // Filter field names to exclude relation fields (not real DB columns)
+ let fnames: Vec = fnames_original
+ .into_iter()
+ .filter(|name| !relation_field_names.contains(name))
+ .collect();
+
+ // Build FieldMeta array entries (skip relation fields)
+ let field_meta_entries: Vec<_> = match &data_fields {
+ syn::Fields::Named(named) => named
+ .named
+ .iter()
+ .filter_map(|field| {
+ let fattr = parse_field_attr(field);
+ let col_name = fattr.column.clone().unwrap_or_else(|| {
+ field
+ .ident
+ .as_ref()
+ .map(|i| i.to_string())
+ .unwrap_or_default()
+ });
+ let field_name_str = field
+ .ident
+ .as_ref()
+ .map(|i| i.to_string())
+ .unwrap_or_default();
+ // Skip relation fields — they are not DB columns
+ if relation_field_names.contains(&field_name_str) {
+ return None;
+ }
+ let db_type = rust_type_to_sql(&field.ty, &fattr);
+ let nullable = fattr.nullable || peel_option(&field.ty).1;
+ let pk = fattr.pk;
+ let unique = fattr.unique;
+ let default = match &fattr.default {
+ Some(d) => {
+ // Generate `Some("...")` with the string value as a literal
+ let lit = syn::LitStr::new(d, proc_macro2::Span::call_site());
+ quote! { Some(#lit) }
+ }
+ None => quote! { None },
+ };
+ Some(quote! {
+ ::ryx_rs::model::FieldMeta {
+ name: #col_name,
+ db_type: #db_type,
+ nullable: #nullable,
+ primary_key: #pk,
+ unique: #unique,
+ default: #default,
+ }
+ })
+ })
+ .collect(),
+ _ => vec![],
+ };
+
+ // Compute table name: from #[table(...)] attribute or PascalCase→snake_case
+ let table_name = find_table_name(&strukt.attrs);
+ let table_name = if table_name.is_empty() {
+ let s = name.to_string();
+ let mut result = String::with_capacity(s.len() + 5);
+ for (i, c) in s.chars().enumerate() {
+ if c.is_uppercase() && i > 0 {
+ result.push('_');
+ result.push(c.to_ascii_lowercase());
+ } else {
+ result.push(c.to_ascii_lowercase());
+ }
+ }
+ result
+ } else {
+ table_name
+ };
+
+ // Strip #[table], #[field], #[relation] helper attrs
+ strukt.attrs.retain(|a| {
+ !a.path().is_ident("table") && !a.path().is_ident("field") && !a.path().is_ident("relation")
+ });
+ // Also strip #[field] from each field
+ if let syn::Fields::Named(ref mut named) = strukt.fields {
+ for field in &mut named.named {
+ field.attrs.retain(|a| !a.path().is_ident("field"));
+ }
+ }
+
+ let relationships_impl2 = generate_relationships_impl(name, &relations);
+
+ // Build field_reads for FromRow::from_row (skip relation fields → None)
+ let field_reads: Vec<_> = match &data_fields {
+ syn::Fields::Named(named) => named
+ .named
+ .iter()
+ .filter_map(|field| {
+ let col_name = field_column_name(field);
+ let col_name = if col_name.is_empty() {
+ field
+ .ident
+ .as_ref()
+ .map(|i| i.to_string())
+ .unwrap_or_default()
+ } else {
+ col_name
+ };
+ let field_name = &field.ident;
+ let field_name_str = field_name.as_ref().map(|i| i.to_string()).unwrap_or_default();
+ if relation_field_names.contains(&field_name_str) {
+ Some(quote! { #field_name: None })
+ } else {
+ let reader = type_to_sql_reader(&field.ty, &col_name);
+ Some(quote! { #field_name: #reader })
+ }
+ })
+ .collect(),
+ _ => vec![],
+ };
+
+ // Build field_reads for from_row_prefixed (skip relation fields, prefix column names)
+ let prefixed_field_reads: Vec<_> = match &data_fields {
+ syn::Fields::Named(named) => named
+ .named
+ .iter()
+ .filter_map(|field| {
+ let col_name = field_column_name(field);
+ let col_name = if col_name.is_empty() {
+ field
+ .ident
+ .as_ref()
+ .map(|i| i.to_string())
+ .unwrap_or_default()
+ } else {
+ col_name
+ };
+ let field_name = &field.ident;
+ let field_name_str = field_name.as_ref().map(|i| i.to_string()).unwrap_or_default();
+ if relation_field_names.contains(&field_name_str) {
+ Some(quote! { #field_name: None })
+ } else {
+ let col_expr = quote! { &::std::format!("{}__{}", prefix, #col_name) };
+ let err_col = col_name.to_string();
+ let err_ts = quote! { #err_col };
+ let reader = type_to_sql_reader_expr(&field.ty, col_expr, err_ts);
+ Some(quote! { #field_name: #reader })
+ }
+ })
+ .collect(),
+ _ => vec![],
+ };
+
+ // Build field_reads for from_row_joined (main fields direct, relation fields via from_row_prefixed)
+ let joined_field_reads: Vec<_> = match &data_fields {
+ syn::Fields::Named(named) => named
+ .named
+ .iter()
+ .filter_map(|field| {
+ let col_name = field_column_name(field);
+ let col_name = if col_name.is_empty() {
+ field
+ .ident
+ .as_ref()
+ .map(|i| i.to_string())
+ .unwrap_or_default()
+ } else {
+ col_name
+ };
+ let field_name = &field.ident;
+ let field_name_str = field_name.as_ref().map(|i| i.to_string()).unwrap_or_default();
+ if let Some(rel) = relations.iter().find(|r| r.name == field_name_str) {
+ let rel_type: syn::Type = syn::parse_str(&rel.model)
+ .unwrap_or_else(|_| panic!("Invalid model type '{}' in relation", rel.model));
+ let rel_name = &rel.name;
+ Some(quote! {
+ #field_name: {
+ let __rel_pk_col = ::std::format!("{}__{}", #rel_name, <#rel_type as ::ryx_rs::model::Model>::pk_field());
+ let __rel_pk_val = row.get(&__rel_pk_col);
+ match __rel_pk_val {
+ Some(v) if !matches!(v, ::ryx_rs::SqlValue::Null) => {
+ Some(#rel_type::from_row_prefixed(row, #rel_name)?)
+ }
+ _ => None,
+ }
+ }
+ })
+ } else {
+ let reader = type_to_sql_reader(&field.ty, &col_name);
+ Some(quote! { #field_name: #reader })
+ }
+ })
+ .collect(),
+ _ => vec![],
+ };
+
+ // Build FromRow impl(s) — always includes from_row and from_row_prefixed.
+ // from_row_joined is only generated when the model itself has relations.
+ let from_row_trait_impl = if relations.is_empty() {
+ quote! {
+ impl ::ryx_rs::row::FromRow for #name {
+ fn from_row(row: &::ryx_rs::row::RowView) -> ::ryx_rs::RyxResult {
+ fn internal_err(col: &str, ty: &str) -> ::ryx_rs::RyxError {
+ ::ryx_rs::RyxError::Internal(format!(
+ "Failed to decode column '{}' as {}", col, ty
+ ))
+ }
+ Ok(Self {
+ #(#field_reads),*
+ })
+ }
+
+ fn from_row_prefixed(row: &::ryx_rs::row::RowView, prefix: &str) -> ::ryx_rs::RyxResult {
+ fn internal_err(col: &str, ty: &str) -> ::ryx_rs::RyxError {
+ ::ryx_rs::RyxError::Internal(format!(
+ "Failed to decode column '{}' as {}", col, ty
+ ))
+ }
+ Ok(Self {
+ #(#prefixed_field_reads),*
+ })
+ }
+ }
+ }
+ } else {
+ quote! {
+ impl ::ryx_rs::row::FromRow for #name {
+ fn from_row(row: &::ryx_rs::row::RowView) -> ::ryx_rs::RyxResult {
+ fn internal_err(col: &str, ty: &str) -> ::ryx_rs::RyxError {
+ ::ryx_rs::RyxError::Internal(format!(
+ "Failed to decode column '{}' as {}", col, ty
+ ))
+ }
+ Ok(Self {
+ #(#field_reads),*
+ })
+ }
+
+ fn from_row_joined(row: &::ryx_rs::row::RowView) -> ::ryx_rs::RyxResult {
+ fn internal_err(col: &str, ty: &str) -> ::ryx_rs::RyxError {
+ ::ryx_rs::RyxError::Internal(format!(
+ "Failed to decode column '{}' as {}", col, ty
+ ))
+ }
+ Ok(Self {
+ #(#joined_field_reads),*
+ })
+ }
+
+ fn from_row_prefixed(row: &::ryx_rs::row::RowView, prefix: &str) -> ::ryx_rs::RyxResult {
+ fn internal_err(col: &str, ty: &str) -> ::ryx_rs::RyxError {
+ ::ryx_rs::RyxError::Internal(format!(
+ "Failed to decode column '{}' as {}", col, ty
+ ))
+ }
+ Ok(Self {
+ #(#prefixed_field_reads),*
+ })
+ }
+ }
+ }
+ };
+
+ let expanded = quote! {
+ #[derive(::ryx_rs::serde::Serialize, ::ryx_rs::serde::Deserialize)]
+ #strukt
+
+ impl ::ryx_rs::model::Model for #name {
+ fn table_name() -> &'static str {
+ #table_name
+ }
+
+ fn field_names() -> &'static [&'static str] {
+ &[#(#fnames),*]
+ }
+
+ fn pk_field() -> &'static str {
+ #pk
+ }
+
+ fn field_meta() -> &'static [::ryx_rs::model::FieldMeta] {
+ &[#(#field_meta_entries),*]
+ }
+ }
+
+ #from_row_trait_impl
+
+ #relationships_impl2
+ };
+
+ expanded.into()
+}
diff --git a/ryx-python/Cargo.toml b/ryx-python/Cargo.toml
index 1a8e775..c097df2 100644
--- a/ryx-python/Cargo.toml
+++ b/ryx-python/Cargo.toml
@@ -19,7 +19,7 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
# ryx-core = { path = "../ryx-core" }
-ryx-backend = { path = "../ryx-backend" }
+ryx-backend = { path = "../ryx-backend", version = "0.1" }
# ryx-query = { path = "../ryx-query" }
# PyO3
diff --git a/ryx-python/src/lib.rs b/ryx-python/src/lib.rs
index d1341e9..8d994f2 100644
--- a/ryx-python/src/lib.rs
+++ b/ryx-python/src/lib.rs
@@ -3,6 +3,7 @@ pub mod plan;
use std::collections::HashMap;
use std::sync::Arc;
+use pyo3::exceptions::{PyRuntimeError, PyValueError};
use pyo3::prelude::IntoPyObject;
use pyo3::types::{PyBool, PyDict, PyFloat, PyInt, PyList, PyString, PyTuple};
use pyo3::{IntoPyObjectExt, prelude::*};
@@ -13,12 +14,54 @@ use ryx_backend::{
core::{RyxError, model_registry},
pool::{self, PoolConfig},
query::{
- AggFunc, AggregateExpr, FilterNode, JoinClause, JoinKind, OrderByClause, QNode, QueryNode,
- QueryOperation, SqlValue, Symbol, compiler, lookups,
+ AggFunc, AggregateExpr, FilterNode, JoinClause, JoinKind, OrderByClause, QNode, QueryError,
+ QueryNode, QueryOperation, SqlValue, Symbol, compiler, lookups,
},
transaction::{self, TransactionHandle},
};
+/// Extension trait to convert `RyxResult` into `PyResult`.
+pub(crate) trait IntoPyResult {
+ fn into_py(self) -> PyResult;
+}
+
+impl IntoPyResult for Result {
+ fn into_py(self) -> PyResult {
+ self.map_err(|e| {
+ match e {
+ RyxError::Query(qe) => match qe {
+ QueryError::UnknownLookup { .. }
+ | QueryError::UnknownField { .. }
+ | QueryError::TypeMismatch { .. } => PyValueError::new_err(qe.to_string()),
+ QueryError::Internal(_) => PyRuntimeError::new_err(qe.to_string()),
+ },
+ RyxError::DatabaseWithSql(sql, e) => {
+ PyRuntimeError::new_err(format!("Database error: {e} (sql: {sql})"))
+ }
+ other => PyRuntimeError::new_err(other.to_string()),
+ }
+ })
+ }
+}
+
+/// Convert a `RyxError` into a Python exception.
+/// Used in place of `map_err(PyErr::from)` because the orphan rule
+/// prevents implementing `From for PyErr` externally.
+pub(crate) fn err_to_py(err: impl Into) -> PyErr {
+ match err.into() {
+ RyxError::Query(qe) => match qe {
+ QueryError::UnknownLookup { .. }
+ | QueryError::UnknownField { .. }
+ | QueryError::TypeMismatch { .. } => PyValueError::new_err(qe.to_string()),
+ QueryError::Internal(_) => PyRuntimeError::new_err(qe.to_string()),
+ },
+ RyxError::DatabaseWithSql(sql, e) => {
+ PyRuntimeError::new_err(format!("Database error: {e} (sql: {sql})"))
+ }
+ other => PyRuntimeError::new_err(other.to_string()),
+ }
+}
+
// ###
// Setup / pool functions
// ###
@@ -60,7 +103,7 @@ fn setup<'py>(
pyo3_async_runtimes::tokio::future_into_py(py, async move {
pool::initialize(database_urls, config)
.await
- .map_err(PyErr::from)?;
+ .map_err(err_to_py)?;
Python::attach(|py| Ok(py.None().into_pyobject(py)?.unbind()))
})
}
@@ -68,15 +111,13 @@ fn setup<'py>(
#[pyfunction]
fn register_lookup(name: String, sql_template: String) -> PyResult<()> {
lookups::register_custom(name, sql_template)
- .map_err(RyxError::from)
- .map_err(PyErr::from)
+ .map_err(err_to_py)
}
#[pyfunction]
fn available_lookups() -> PyResult> {
lookups::registered_lookups()
- .map_err(RyxError::from)
- .map_err(PyErr::from)
+ .map_err(err_to_py)
}
#[pyfunction]
@@ -91,13 +132,13 @@ fn list_transforms() -> Vec<&'static str> {
#[pyfunction]
fn list_aliases<'py>(py: Python<'py>) -> PyResult> {
- let aliases = pool::list_aliases().map_err(PyErr::from)?;
+ let aliases = pool::list_aliases().map_err(err_to_py)?;
Ok(aliases.into_py_any(py)?.into_bound(py))
}
#[pyfunction]
fn get_backend(alias: Option) -> PyResult {
- let backend = pool::get_backend(alias.as_deref()).map_err(PyErr::from)?;
+ let backend = pool::get_backend(alias.as_deref()).map_err(err_to_py)?;
Ok(format!("{}", backend.as_str()))
}
@@ -109,7 +150,7 @@ fn is_connected(_py: Python<'_>, alias: Option) -> bool {
#[pyfunction]
fn pool_stats<'py>(py: Python<'py>, alias: Option) -> PyResult> {
- let stats = pool::stats(alias.as_deref()).map_err(PyErr::from)?;
+ let stats = pool::stats(alias.as_deref()).map_err(err_to_py)?;
let dict = PyDict::new(py);
dict.set_item("size", stats.size)?;
dict.set_item("idle", stats.idle)?;
@@ -125,9 +166,9 @@ fn raw_fetch<'py>(
) -> PyResult> {
pyo3_async_runtimes::tokio::future_into_py(py, async move {
// Get appropriate backend for the query based on the node's db_alias (if set) or default
- let b = pool::get(alias.as_deref())?;
+ let b = pool::get(alias.as_deref()).into_py()?;
- let rows = b.fetch_raw(sql, alias).await.map_err(PyErr::from)?;
+ let rows = b.fetch_raw(sql, alias).await.map_err(err_to_py)?;
Python::attach(|py| {
let py_rows = decoded_rows_to_py(py, rows)?;
Ok(py_rows.unbind())
@@ -144,9 +185,9 @@ fn raw_execute<'py>(
) -> PyResult> {
pyo3_async_runtimes::tokio::future_into_py(py, async move {
// Get appropriate backend for the query based on the node's db_alias (if set) or default
- let b = pool::get(alias.as_deref())?;
+ let b = pool::get(alias.as_deref()).into_py()?;
- b.execute_raw(sql, alias).await.map_err(PyErr::from)?;
+ b.execute_raw(sql, alias).await.map_err(err_to_py)?;
Python::attach(|py| Ok(py.None().into_pyobject(py)?.unbind()))
})
}
@@ -166,7 +207,7 @@ impl PyQueryBuilder {
#[new]
fn new(table: String) -> PyResult {
// Get the backend from the pool at QueryBuilder creation time
- let backend = pool::get_backend(None)?;
+ let backend = pool::get_backend(None).into_py()?;
Ok(Self {
node: Arc::new(QueryNode::select(table).with_backend(backend)),
@@ -342,9 +383,9 @@ impl PyQueryBuilder {
let node = self.node.as_ref().clone();
// Get appropriate backend for the query based on the node's db_alias (if set) or default
- let b = pool::get(node.db_alias.as_deref())?;
+ let b = pool::get(node.db_alias.as_deref()).into_py()?;
pyo3_async_runtimes::tokio::future_into_py(py, async move {
- let rows = b.fetch_all_compiled(node).await.map_err(PyErr::from)?;
+ let rows = b.fetch_all_compiled(node).await.map_err(err_to_py)?;
Python::attach(|py| Ok(decoded_rows_to_py(py, rows)?.unbind()))
})
}
@@ -353,10 +394,10 @@ impl PyQueryBuilder {
let node = self.node.as_ref().clone().with_limit(1);
// Get appropriate backend for the query based on the node's db_alias (if set) or default
- let b = pool::get(node.db_alias.as_deref())?;
+ let b = pool::get(node.db_alias.as_deref()).into_py()?;
pyo3_async_runtimes::tokio::future_into_py(py, async move {
- let rows = b.fetch_all_compiled(node).await.map_err(PyErr::from)?;
+ let rows = b.fetch_all_compiled(node).await.map_err(err_to_py)?;
Python::attach(|py| match rows.into_iter().next() {
Some(row) => Ok(decoded_row_to_py(py, row)?.into_any().unbind()),
None => Ok(py.None().into_pyobject(py)?.unbind()),
@@ -368,10 +409,10 @@ impl PyQueryBuilder {
let node = self.node.as_ref().clone();
// Get appropriate backend for the query based on the node's db_alias (if set) or default
- let b = pool::get(node.db_alias.as_deref())?;
+ let b = pool::get(node.db_alias.as_deref()).into_py()?;
pyo3_async_runtimes::tokio::future_into_py(py, async move {
- let row = b.fetch_one_compiled(node).await.map_err(PyErr::from)?;
+ let row = b.fetch_one_compiled(node).await.map_err(err_to_py)?;
Python::attach(|py| Ok(decoded_row_to_py(py, row)?.into_any().unbind()))
})
}
@@ -380,14 +421,14 @@ impl PyQueryBuilder {
let mut count_node = self.node.as_ref().clone();
// Get appropriate backend for the query based on the node's db_alias (if set) or default
- let b = pool::get(count_node.db_alias.as_deref())?;
+ let b = pool::get(count_node.db_alias.as_deref()).into_py()?;
count_node.operation = QueryOperation::Count;
pyo3_async_runtimes::tokio::future_into_py(py, async move {
let count = b
.fetch_count_compiled(count_node)
.await
- .map_err(PyErr::from)?;
+ .map_err(err_to_py)?;
Python::attach(|py| Ok(count.into_pyobject(py)?.unbind()))
})
}
@@ -397,10 +438,10 @@ impl PyQueryBuilder {
agg_node.operation = QueryOperation::Aggregate;
// Get appropriate backend for the query based on the node's db_alias (if set) or default
- let b = pool::get(agg_node.db_alias.as_deref())?;
+ let b = pool::get(agg_node.db_alias.as_deref()).into_py()?;
pyo3_async_runtimes::tokio::future_into_py(py, async move {
- let rows = b.fetch_all_compiled(agg_node).await.map_err(PyErr::from)?;
+ let rows = b.fetch_all_compiled(agg_node).await.map_err(err_to_py)?;
Python::attach(|py| match rows.into_iter().next() {
Some(row) => Ok(decoded_row_to_py(py, row)?.into_any().unbind()),
None => Ok(PyDict::new(py).into_any().unbind()),
@@ -413,10 +454,10 @@ impl PyQueryBuilder {
del_node.operation = QueryOperation::Delete;
// Get appropriate backend for the query based on the node's db_alias (if set) or default
- let b = pool::get(del_node.db_alias.as_deref())?;
+ let b = pool::get(del_node.db_alias.as_deref()).into_py()?;
pyo3_async_runtimes::tokio::future_into_py(py, async move {
- let res = b.execute_compiled(del_node).await.map_err(PyErr::from)?;
+ let res = b.execute_compiled(del_node).await.map_err(err_to_py)?;
Python::attach(|py| Ok(res.rows_affected.into_pyobject(py)?.unbind()))
})
}
@@ -437,10 +478,10 @@ impl PyQueryBuilder {
};
// Get appropriate backend for the query based on the node's db_alias (if set) or default
- let b = pool::get(upd_node.db_alias.as_deref())?;
+ let b = pool::get(upd_node.db_alias.as_deref()).into_py()?;
pyo3_async_runtimes::tokio::future_into_py(py, async move {
- let res = b.execute_compiled(upd_node).await.map_err(PyErr::from)?;
+ let res = b.execute_compiled(upd_node).await.map_err(err_to_py)?;
Python::attach(|py| Ok(res.rows_affected.into_pyobject(py)?.unbind()))
})
}
@@ -463,10 +504,10 @@ impl PyQueryBuilder {
};
// Get appropriate backend for the query based on the node's db_alias (if set) or default
- let b = pool::get(ins_node.db_alias.as_deref())?;
+ let b = pool::get(ins_node.db_alias.as_deref()).into_py()?;
pyo3_async_runtimes::tokio::future_into_py(py, async move {
- let res = b.execute_compiled(ins_node).await.map_err(PyErr::from)?;
+ let res = b.execute_compiled(ins_node).await.map_err(err_to_py)?;
Python::attach(|py| {
if let Some(ids) = res.returned_ids {
Ok(ids.into_pyobject(py)?.into_any().unbind())
@@ -480,7 +521,7 @@ impl PyQueryBuilder {
}
fn compiled_sql(&self) -> PyResult {
- Ok(compiler::compile(&self.node).map_err(RyxError::from)?.sql)
+ Ok(compiler::compile(&self.node).map_err(err_to_py)?.sql)
}
}
@@ -639,6 +680,12 @@ fn sql_to_py<'py>(py: Python<'py>, v: &SqlValue) -> PyResult> {
SqlValue::Int(i) => i.into_pyobject(py)?.into_any().unbind(),
SqlValue::Float(f) => f.into_pyobject(py)?.into_any().unbind(),
SqlValue::Text(s) => s.into_pyobject(py)?.into_any().unbind(),
+ SqlValue::Date(s) => s.into_pyobject(py)?.into_any().unbind(),
+ SqlValue::DateTime(s) => s.into_pyobject(py)?.into_any().unbind(),
+ SqlValue::Time(s) => s.into_pyobject(py)?.into_any().unbind(),
+ SqlValue::Uuid(s) => s.into_pyobject(py)?.into_any().unbind(),
+ SqlValue::Decimal(s) => s.into_pyobject(py)?.into_any().unbind(),
+ SqlValue::Json(s) => s.into_pyobject(py)?.into_any().unbind(),
SqlValue::List(items) => {
let list = PyList::empty(py);
for item in items {
@@ -674,7 +721,7 @@ impl PyTransactionHandle {
pyo3_async_runtimes::tokio::future_into_py(py, async move {
let g = h.lock().await;
if let Some(tx) = g.as_ref() {
- tx.commit().await.map_err(PyErr::from)?;
+ tx.commit().await.map_err(err_to_py)?;
}
Python::attach(|py| Ok(py.None().into_pyobject(py)?.unbind()))
})
@@ -685,7 +732,7 @@ impl PyTransactionHandle {
pyo3_async_runtimes::tokio::future_into_py(py, async move {
let g = h.lock().await;
if let Some(tx) = g.as_ref() {
- tx.rollback().await.map_err(PyErr::from)?;
+ tx.rollback().await.map_err(err_to_py)?;
}
Python::attach(|py| Ok(py.None().into_pyobject(py)?.unbind()))
})
@@ -696,7 +743,7 @@ impl PyTransactionHandle {
pyo3_async_runtimes::tokio::future_into_py(py, async move {
let mut g = h.lock().await;
if let Some(tx) = g.as_mut() {
- tx.savepoint(&name).await.map_err(PyErr::from)?;
+ tx.savepoint(&name).await.map_err(err_to_py)?;
}
Python::attach(|py| Ok(py.None().into_pyobject(py)?.unbind()))
})
@@ -707,7 +754,7 @@ impl PyTransactionHandle {
pyo3_async_runtimes::tokio::future_into_py(py, async move {
let g = h.lock().await;
if let Some(tx) = g.as_ref() {
- tx.rollback_to(&name).await.map_err(PyErr::from)?;
+ tx.rollback_to(&name).await.map_err(err_to_py)?;
}
Python::attach(|py| Ok(py.None().into_pyobject(py)?.unbind()))
})
@@ -741,7 +788,7 @@ fn begin_transaction<'py>(
pyo3_async_runtimes::tokio::future_into_py(py, async move {
let handle = TransactionHandle::begin(alias_str)
.await
- .map_err(PyErr::from)?;
+ .map_err(err_to_py)?;
Python::attach(|py| {
let py_handle = PyTransactionHandle {
handle: Arc::new(TokioMutex::new(Some(handle))),
@@ -794,13 +841,13 @@ fn execute_with_params<'py>(
db_alias: alias.clone(),
base_table: None,
column_names: None,
- backend: pool::get_backend(alias.as_deref())?,
+ backend: pool::get_backend(alias.as_deref()).into_py()?,
};
// Get appropriate backend for the query based on the node's db_alias (if set) or default
- let b = pool::get(alias.as_deref())?;
+ let b = pool::get(alias.as_deref()).into_py()?;
- let result = b.execute(compiled).await.map_err(PyErr::from)?;
+ let result = b.execute(compiled).await.map_err(err_to_py)?;
Python::attach(|py| Ok(result.rows_affected.into_pyobject(py)?.unbind()))
})
}
@@ -824,13 +871,13 @@ fn fetch_with_params<'py>(
db_alias: alias.clone(),
base_table: None,
column_names: None,
- backend: pool::get_backend(alias.as_deref())?,
+ backend: pool::get_backend(alias.as_deref()).into_py()?,
};
// Get appropriate backend for the query based on the node's db_alias (if set) or default
- let b = pool::get(alias.as_deref())?;
+ let b = pool::get(alias.as_deref()).into_py()?;
- let rows = b.fetch_all(compiled).await.map_err(PyErr::from)?;
+ let rows = b.fetch_all(compiled).await.map_err(err_to_py)?;
Python::attach(|py| Ok(decoded_rows_to_py(py, rows)?.unbind()))
})
}
@@ -857,12 +904,12 @@ fn bulk_delete<'py>(
pyo3_async_runtimes::tokio::future_into_py(py, async move {
// Get appropriate backend for the query based on the node's db_alias (if set) or default
- let b = pool::get(alias.as_deref())?;
+ let b = pool::get(alias.as_deref()).into_py()?;
let result = b
.bulk_delete(table, pk_col, pk_values, alias)
.await
- .map_err(PyErr::from)?;
+ .map_err(err_to_py)?;
Python::attach(|py| {
let n = (result.rows_affected as i64).into_pyobject(py)?;
Ok(n.unbind())
@@ -893,7 +940,7 @@ fn bulk_insert<'py>(
pyo3_async_runtimes::tokio::future_into_py(py, async move {
// Get appropriate backend for the query based on the node's db_alias (if set) or default
- let b = pool::get(alias.as_deref())?;
+ let b = pool::get(alias.as_deref()).into_py()?;
let res = b
.bulk_insert(
table,
@@ -904,7 +951,7 @@ fn bulk_insert<'py>(
alias,
)
.await
- .map_err(PyErr::from)?;
+ .map_err(err_to_py)?;
Python::attach(|py| {
if let Some(ids) = res.returned_ids {
Ok(ids.into_pyobject(py)?.into_any().unbind())
@@ -949,11 +996,11 @@ fn bulk_update<'py>(
pyo3_async_runtimes::tokio::future_into_py(py, async move {
// Get appropriate backend for the query based on the node's db_alias (if set) or default
- let b = pool::get(alias.as_deref())?;
+ let b = pool::get(alias.as_deref()).into_py()?;
let result = b
.bulk_update(table, pk_col, columns, rust_field_values, pk_values, alias)
.await
- .map_err(PyErr::from)?;
+ .map_err(err_to_py)?;
Python::attach(|py| {
let n = (result.rows_affected as i64).into_pyobject(py)?;
Ok(n.unbind())
diff --git a/ryx-python/src/plan.rs b/ryx-python/src/plan.rs
index b0e5c24..31cf064 100644
--- a/ryx-python/src/plan.rs
+++ b/ryx-python/src/plan.rs
@@ -34,7 +34,7 @@ pub fn build_plan<'py>(
ops: Vec>,
alias: Option,
) -> PyResult {
- let backend = ryx_pool::get_backend(alias.as_deref())?;
+ let backend = ryx_pool::get_backend(alias.as_deref()).map_err(crate::err_to_py)?;
let mut node = QueryNode::select(table).with_backend(backend);
if let Some(a) = alias {
node = node.with_db_alias(a);
@@ -170,7 +170,7 @@ pub fn build_plan<'py>(
}
"using" => {
let db_alias: String = tuple.get_item(1)?.extract()?;
- let backend = ryx_pool::get_backend(Some(&db_alias))?;
+ let backend = ryx_pool::get_backend(Some(&db_alias)).map_err(crate::err_to_py)?;
node = node.with_backend(backend).with_db_alias(db_alias);
}
_ => {}
diff --git a/ryx-query/src/ast.rs b/ryx-query/src/ast.rs
index 2a98fa6..0f60436 100644
--- a/ryx-query/src/ast.rs
+++ b/ryx-query/src/ast.rs
@@ -30,8 +30,20 @@ pub enum SqlValue {
Bool(bool),
Int(i64),
Float(f64),
- /// String, datetime, UUID, Decimal — all stored as text and parsed by the driver.
+ /// Generic string value (label, name, slug, etc.).
Text(String),
+ /// ISO-8601 date (YYYY-MM-DD).
+ Date(String),
+ /// ISO-8601 datetime (YYYY-MM-DD HH:MM:SS).
+ DateTime(String),
+ /// ISO-8601 time (HH:MM:SS).
+ Time(String),
+ /// UUID as canonical text.
+ Uuid(String),
+ /// Decimal number as string (avoids f64 precision loss).
+ Decimal(String),
+ /// Raw JSON text.
+ Json(String),
/// Used by `__in` and `__range` lookups. The compiler expands it into
/// multiple bind placeholders.
List(smallvec::SmallVec<[Box; 4]>),
@@ -45,6 +57,12 @@ impl SqlValue {
SqlValue::Int(_) => "int",
SqlValue::Float(_) => "float",
SqlValue::Text(_) => "str",
+ SqlValue::Date(_) => "date",
+ SqlValue::DateTime(_) => "datetime",
+ SqlValue::Time(_) => "time",
+ SqlValue::Uuid(_) => "uuid",
+ SqlValue::Decimal(_) => "decimal",
+ SqlValue::Json(_) => "json",
SqlValue::List(_) => "list",
}
}
@@ -282,6 +300,10 @@ pub struct QueryNode {
pub limit: Option,
pub offset: Option,
pub distinct: bool,
+
+ /// Extra columns emitted in SELECT with `AS alias` (for JOIN aliasing).
+ /// Each entry is `(column_expr, alias)`.
+ pub extra_aliases: Vec<(Symbol, Symbol)>,
}
impl QueryNode {
@@ -302,6 +324,7 @@ impl QueryNode {
limit: None,
offset: None,
distinct: false,
+ extra_aliases: Vec::new(),
}
}
@@ -387,4 +410,10 @@ impl QueryNode {
self.db_alias = Some(alias);
self
}
+
+ #[must_use]
+ pub fn with_extra_alias(mut self, column: impl Into, alias: impl Into) -> Self {
+ self.extra_aliases.push((column.into(), alias.into()));
+ self
+ }
}
diff --git a/ryx-query/src/compiler/compilr.rs b/ryx-query/src/compiler/compilr.rs
index 799090d..05cf399 100644
--- a/ryx-query/src/compiler/compilr.rs
+++ b/ryx-query/src/compiler/compilr.rs
@@ -322,6 +322,16 @@ fn compile_select(
}
}
+ // Render extra aliases (for JOIN column aliasing in select_related)
+ if !node.extra_aliases.is_empty() {
+ for (col, alias) in &node.extra_aliases {
+ writer.write(", ");
+ writer.write_qualified_symbol(*col);
+ writer.write(" AS ");
+ writer.write_symbol(*alias);
+ }
+ }
+
writer.write(" FROM ");
writer.write_symbol(node.table);
diff --git a/ryx-rs/Cargo.toml b/ryx-rs/Cargo.toml
new file mode 100644
index 0000000..4e59f4e
--- /dev/null
+++ b/ryx-rs/Cargo.toml
@@ -0,0 +1,37 @@
+[package]
+name = "ryx-rs"
+version = "0.1.0"
+edition = "2024"
+description = "Rust-native ORM with Django-like syntax, powered by sqlx"
+
+[dependencies]
+ryx-common = { path = "../ryx-common", version = "0.1" }
+ryx-query = { path = "../ryx-query", version = "0.1" }
+ryx-backend = { path = "../ryx-backend", version = "0.1", default-features = false }
+ryx-macro = { path = "../ryx-macro", version = "0.1" }
+
+sqlx = { workspace = true }
+tokio = { workspace = true }
+async-trait = "0.1"
+chrono = { workspace = true }
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
+once_cell = { workspace = true }
+toml = { workspace = true, optional = true }
+serde_yaml = { workspace = true, optional = true }
+redis = { version = "0.28", optional = true, features = ["tokio-comp"] }
+
+[dev-dependencies]
+criterion = { version = "0.5", features = ["async_tokio"] }
+
+[[bench]]
+name = "orm_bench"
+harness = false
+
+[features]
+default = ["config"]
+python = ["ryx-backend/python"]
+cache-redis = ["redis"]
+config-toml = ["toml"]
+config-yaml = ["serde_yaml"]
+config = ["config-toml", "config-yaml"]
diff --git a/ryx-rs/benches/orm_bench.rs b/ryx-rs/benches/orm_bench.rs
new file mode 100644
index 0000000..e3dcaee
--- /dev/null
+++ b/ryx-rs/benches/orm_bench.rs
@@ -0,0 +1,468 @@
+use std::collections::HashMap;
+use std::sync::OnceLock;
+
+use criterion::{black_box, criterion_group, criterion_main, Criterion};
+use ryx_common::PoolConfig;
+use ryx_query::ast::SqlValue;
+use ryx_query::Backend;
+use tokio::runtime::Runtime;
+
+static RT: OnceLock = OnceLock::new();
+static BACKEND: OnceLock> = OnceLock::new();
+
+fn rt() -> &'static Runtime {
+ RT.get_or_init(|| Runtime::new().expect("tokio runtime"))
+}
+
+fn backend() -> &'static std::sync::Arc {
+ BACKEND.get_or_init(|| {
+ ryx_query::lookups::init_registry();
+ let rt = RT.get_or_init(|| Runtime::new().expect("tokio runtime"));
+ rt.block_on(init_database())
+ })
+}
+
+async fn init_database() -> std::sync::Arc {
+ let tmp = std::env::temp_dir().join("ryx_bench_orm.db");
+ let _ = std::fs::remove_file(&tmp);
+
+ let path = tmp.to_str().expect("utf-8").to_string();
+ let url = format!("sqlite:{path}?mode=rwc");
+
+ let mut urls = HashMap::new();
+ urls.insert("default".into(), url);
+
+ ryx_backend::pool::initialize(
+ urls,
+ PoolConfig {
+ max_connections: 4,
+ min_connections: 1,
+ ..Default::default()
+ },
+ )
+ .await
+ .expect("pool init");
+
+ let be = ryx_backend::pool::get(None).expect("get backend");
+
+ be.execute_raw(
+ "CREATE TABLE posts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ content TEXT NOT NULL,
+ author TEXT NOT NULL,
+ views INTEGER NOT NULL DEFAULT 0,
+ published INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL
+ )"
+ .into(),
+ None,
+ )
+ .await
+ .expect("create table");
+
+ // Insert 5_000 posts
+ for chunk in 0..50 {
+ let mut values = Vec::new();
+ for i in 0..100 {
+ let idx = chunk * 100 + i;
+ let published = if idx % 3 == 0 { 1 } else { 0 };
+ values.push(format!(
+ "('Post {idx}', 'Content for post {idx}', 'author_{}', {idx}, {published}, '2024-01-01')",
+ idx % 10
+ ));
+ }
+ let batch = values.join(", ");
+ be.execute_raw(
+ format!("INSERT INTO posts (title, content, author, views, published, created_at) VALUES {batch}"),
+ None,
+ )
+ .await
+ .expect("insert batch");
+ }
+
+ be
+}
+
+// ── QuerySet .all() ───────────────────────────────────────
+
+fn bench_queryset_all(c: &mut Criterion) {
+ let be = backend().clone();
+
+ c.bench_function("queryset_all_100", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ async move {
+ let node = ryx_query::ast::QueryNode::select("posts")
+ .with_backend(Backend::SQLite)
+ .with_limit(100);
+ let rows = be.fetch_all_compiled(black_box(node)).await.unwrap();
+ black_box(rows.len())
+ }
+ })
+ });
+}
+
+fn bench_queryset_all_1000(c: &mut Criterion) {
+ let be = backend().clone();
+
+ c.bench_function("queryset_all_1000", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ async move {
+ let node = ryx_query::ast::QueryNode::select("posts")
+ .with_backend(Backend::SQLite)
+ .with_limit(1000);
+ let rows = be.fetch_all_compiled(black_box(node)).await.unwrap();
+ black_box(rows.len())
+ }
+ })
+ });
+}
+
+// ── QuerySet .filter() ────────────────────────────────────
+
+fn bench_queryset_filter_exact(c: &mut Criterion) {
+ let be = backend().clone();
+ let node = ryx_query::ast::QueryNode::select("posts")
+ .with_backend(Backend::SQLite)
+ .with_filter(ryx_query::ast::FilterNode {
+ field: "author".into(),
+ lookup: "exact".to_string(),
+ value: SqlValue::Text("author_5".into()),
+ negated: false,
+ })
+ .with_limit(50);
+
+ c.bench_function("queryset_filter_exact", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ let n = node.clone();
+ async move {
+ let rows = be.fetch_all_compiled(black_box(n)).await.unwrap();
+ black_box(rows.len())
+ }
+ })
+ });
+}
+
+fn bench_queryset_filter_gte(c: &mut Criterion) {
+ let be = backend().clone();
+ let node = ryx_query::ast::QueryNode::select("posts")
+ .with_backend(Backend::SQLite)
+ .with_q(ryx_query::ast::QNode::Leaf {
+ field: "views".into(),
+ lookup: "gte".to_string(),
+ value: SqlValue::Int(2500),
+ negated: false,
+ })
+ .with_limit(50);
+
+ c.bench_function("queryset_filter_gte", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ let n = node.clone();
+ async move {
+ let rows = be.fetch_all_compiled(black_box(n)).await.unwrap();
+ black_box(rows.len())
+ }
+ })
+ });
+}
+
+fn bench_queryset_filter_complex(c: &mut Criterion) {
+ let be = backend().clone();
+ let node = ryx_query::ast::QueryNode::select("posts")
+ .with_backend(Backend::SQLite)
+ .with_q(ryx_query::ast::QNode::Or(vec![
+ ryx_query::ast::QNode::And(vec![
+ ryx_query::ast::QNode::Leaf {
+ field: "published".into(),
+ lookup: "exact".to_string(),
+ value: SqlValue::Int(1),
+ negated: false,
+ },
+ ryx_query::ast::QNode::Leaf {
+ field: "views".into(),
+ lookup: "gte".to_string(),
+ value: SqlValue::Int(1000),
+ negated: false,
+ },
+ ]),
+ ryx_query::ast::QNode::Leaf {
+ field: "author".into(),
+ lookup: "exact".to_string(),
+ value: SqlValue::Text("author_0".into()),
+ negated: false,
+ },
+ ]))
+ .with_order_by(ryx_query::ast::OrderByClause {
+ field: "views".into(),
+ direction: ryx_query::ast::SortDirection::Desc,
+ })
+ .with_limit(20);
+
+ c.bench_function("queryset_filter_complex", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ let n = node.clone();
+ async move {
+ let rows = be.fetch_all_compiled(black_box(n)).await.unwrap();
+ black_box(rows.len())
+ }
+ })
+ });
+}
+
+// ── QuerySet .count() ─────────────────────────────────────
+
+fn bench_queryset_count(c: &mut Criterion) {
+ let be = backend().clone();
+ let node = ryx_query::ast::QueryNode::count("posts").with_backend(Backend::SQLite);
+
+ c.bench_function("queryset_count", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ let n = node.clone();
+ async move {
+ let cnt = be.fetch_count_compiled(black_box(n)).await.unwrap();
+ black_box(cnt)
+ }
+ })
+ });
+}
+
+fn bench_queryset_count_filtered(c: &mut Criterion) {
+ let be = backend().clone();
+ let mut node = ryx_query::ast::QueryNode::count("posts").with_backend(Backend::SQLite);
+
+ c.bench_function("queryset_count_filtered", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ node.operation = ryx_query::ast::QueryOperation::Count;
+ let n = node.clone();
+ async move {
+ let cnt = be.fetch_count_compiled(black_box(n)).await.unwrap();
+ black_box(cnt)
+ }
+ })
+ });
+}
+
+// ── QuerySet .filter().order_by() ─────────────────────────
+
+fn bench_queryset_order_by(c: &mut Criterion) {
+ let be = backend().clone();
+ let node = ryx_query::ast::QueryNode::select("posts")
+ .with_backend(Backend::SQLite)
+ .with_filter(ryx_query::ast::FilterNode {
+ field: "published".into(),
+ lookup: "exact".to_string(),
+ value: SqlValue::Int(1),
+ negated: false,
+ })
+ .with_order_by(ryx_query::ast::OrderByClause {
+ field: "created_at".into(),
+ direction: ryx_query::ast::SortDirection::Desc,
+ })
+ .with_order_by(ryx_query::ast::OrderByClause {
+ field: "views".into(),
+ direction: ryx_query::ast::SortDirection::Desc,
+ })
+ .with_limit(30);
+
+ c.bench_function("queryset_filter_order_by", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ let n = node.clone();
+ async move {
+ let rows = be.fetch_all_compiled(black_box(n)).await.unwrap();
+ black_box(rows.len())
+ }
+ })
+ });
+}
+
+// ── QuerySet .insert() / create() ────────────────────────
+
+fn bench_queryset_create(c: &mut Criterion) {
+ let be = backend().clone();
+ let mut base = ryx_query::ast::QueryNode::select("posts").with_backend(Backend::SQLite);
+
+ c.bench_function("queryset_create", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ base.operation = ryx_query::ast::QueryOperation::Insert {
+ values: vec![
+ ("title".into(), SqlValue::Text("Bench post".into())),
+ ("content".into(), SqlValue::Text("Bench content".into())),
+ ("author".into(), SqlValue::Text("benchmark".into())),
+ ("views".into(), SqlValue::Int(0)),
+ ("published".into(), SqlValue::Int(1)),
+ ("created_at".into(), SqlValue::Text("2024-06-01".into())),
+ ],
+ returning_id: true,
+ };
+ let n = base.clone();
+ async move {
+ let result = be.execute_compiled(black_box(n)).await.unwrap();
+ black_box(result)
+ }
+ })
+ });
+}
+
+// ── QuerySet .update() ────────────────────────────────────
+
+fn bench_queryset_update(c: &mut Criterion) {
+ let be = backend().clone();
+ let mut base = ryx_query::ast::QueryNode::select("posts").with_backend(Backend::SQLite);
+
+ c.bench_function("queryset_update_all", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ base.operation = ryx_query::ast::QueryOperation::Update {
+ assignments: vec![("views".into(), SqlValue::Int(9999))],
+ };
+ let n = base.clone();
+ async move {
+ let result = be.execute_compiled(black_box(n)).await.unwrap();
+ black_box(result)
+ }
+ })
+ });
+}
+
+// ── QuerySet .delete() ────────────────────────────────────
+
+fn bench_queryset_delete(c: &mut Criterion) {
+ let be = backend().clone();
+ let node = ryx_query::ast::QueryNode::delete("posts").with_backend(Backend::SQLite);
+
+ // Reinsert rows before benchmark so there's something to delete
+ rt().block_on(async {
+ be.execute_raw(
+ "INSERT INTO posts (title, content, author, views, published, created_at) \
+ VALUES ('del', 'del', 'del', 0, 0, '2024-01-01')"
+ .into(),
+ None,
+ )
+ .await
+ .unwrap();
+ });
+
+ c.bench_function("queryset_delete", |b| {
+ b.to_async(rt()).iter(|| {
+ let be = be.clone();
+ let n = node.clone();
+ async move {
+ let result = be.execute_compiled(black_box(n)).await.unwrap();
+ black_box(result)
+ }
+ })
+ });
+}
+
+// ── QueryNode compilation (no DB query) ───────────────────
+
+fn bench_compile_select_simple(c: &mut Criterion) {
+ let node = ryx_query::ast::QueryNode::select("posts").with_backend(Backend::SQLite);
+
+ c.bench_function("compile_select_simple", |b| {
+ b.iter(|| {
+ let result = ryx_query::compiler::compile(black_box(&node)).unwrap();
+ black_box(result)
+ })
+ });
+}
+
+fn bench_compile_select_complex(c: &mut Criterion) {
+ let node = ryx_query::ast::QueryNode::select("posts")
+ .with_backend(Backend::SQLite)
+ .with_q(ryx_query::ast::QNode::And(vec![
+ ryx_query::ast::QNode::Leaf {
+ field: "views".into(),
+ lookup: "gte".to_string(),
+ value: SqlValue::Int(100),
+ negated: false,
+ },
+ ryx_query::ast::QNode::Leaf {
+ field: "published".into(),
+ lookup: "exact".to_string(),
+ value: SqlValue::Int(1),
+ negated: false,
+ },
+ ]))
+ .with_order_by(ryx_query::ast::OrderByClause {
+ field: "views".into(),
+ direction: ryx_query::ast::SortDirection::Desc,
+ })
+ .with_limit(50);
+
+ c.bench_function("compile_select_complex", |b| {
+ b.iter(|| {
+ let result = ryx_query::compiler::compile(black_box(&node)).unwrap();
+ black_box(result)
+ })
+ });
+}
+
+// ── Model / row conversion ────────────────────────────────
+
+fn bench_row_to_hashmap(c: &mut Criterion) {
+ use ryx_backend::backends::{RowMapping, RowView};
+
+ let row = RowView {
+ values: vec![
+ SqlValue::Int(42),
+ SqlValue::Text("Hello World".into()),
+ SqlValue::Text("Content here".into()),
+ SqlValue::Text("author_3".into()),
+ SqlValue::Int(1500),
+ SqlValue::Int(1),
+ SqlValue::Text("2024-01-15".into()),
+ ],
+ mapping: std::sync::Arc::new(RowMapping {
+ columns: vec![
+ "id".into(),
+ "title".into(),
+ "content".into(),
+ "author".into(),
+ "views".into(),
+ "published".into(),
+ "created_at".into(),
+ ],
+ }),
+ };
+
+ c.bench_function("row_to_hashmap_7_fields", |b| {
+ b.iter(|| {
+ let mut map = std::collections::HashMap::new();
+ for col in ["id", "title", "content", "author", "views", "published", "created_at"] {
+ if let Some(val) = row.get(col) {
+ map.insert(col.to_string(), val.clone());
+ }
+ }
+ black_box(map)
+ })
+ });
+}
+
+criterion_group!(
+ benches,
+ bench_queryset_all,
+ bench_queryset_all_1000,
+ bench_queryset_filter_exact,
+ bench_queryset_filter_gte,
+ bench_queryset_filter_complex,
+ bench_queryset_count,
+ bench_queryset_count_filtered,
+ bench_queryset_order_by,
+ bench_queryset_create,
+ bench_queryset_update,
+ bench_queryset_delete,
+ bench_compile_select_simple,
+ bench_compile_select_complex,
+ bench_row_to_hashmap,
+);
+criterion_main!(benches);
diff --git a/ryx-rs/src/agg.rs b/ryx-rs/src/agg.rs
new file mode 100644
index 0000000..064c9d6
--- /dev/null
+++ b/ryx-rs/src/agg.rs
@@ -0,0 +1,82 @@
+use ryx_query::ast::{AggFunc, AggregateExpr};
+use ryx_query::symbols::Symbol;
+
+/// An aggregate expression for use with `.aggregate()`.
+#[derive(Clone)]
+pub struct AggExpr {
+ pub alias: String,
+ pub func: AggFunc,
+ pub field: String,
+ pub distinct: bool,
+}
+
+impl AggExpr {
+ pub fn into_ast(self) -> AggregateExpr {
+ AggregateExpr {
+ alias: Symbol::from(self.alias.as_str()),
+ func: self.func,
+ field: Symbol::from(self.field.as_str()),
+ distinct: self.distinct,
+ }
+ }
+}
+
+/// `COUNT(field) AS alias`
+pub fn count(alias: &str, field: &str) -> AggExpr {
+ AggExpr {
+ alias: alias.to_string(),
+ func: AggFunc::Count,
+ field: field.to_string(),
+ distinct: false,
+ }
+}
+
+/// `SUM(field) AS alias`
+pub fn sum(alias: &str, field: &str) -> AggExpr {
+ AggExpr {
+ alias: alias.to_string(),
+ func: AggFunc::Sum,
+ field: field.to_string(),
+ distinct: false,
+ }
+}
+
+/// `AVG(field) AS alias`
+pub fn avg(alias: &str, field: &str) -> AggExpr {
+ AggExpr {
+ alias: alias.to_string(),
+ func: AggFunc::Avg,
+ field: field.to_string(),
+ distinct: false,
+ }
+}
+
+/// `MIN(field) AS alias`
+pub fn min(alias: &str, field: &str) -> AggExpr {
+ AggExpr {
+ alias: alias.to_string(),
+ func: AggFunc::Min,
+ field: field.to_string(),
+ distinct: false,
+ }
+}
+
+/// `MAX(field) AS alias`
+pub fn max(alias: &str, field: &str) -> AggExpr {
+ AggExpr {
+ alias: alias.to_string(),
+ func: AggFunc::Max,
+ field: field.to_string(),
+ distinct: false,
+ }
+}
+
+/// `COUNT(DISTINCT field) AS alias`
+pub fn count_distinct(alias: &str, field: &str) -> AggExpr {
+ AggExpr {
+ alias: alias.to_string(),
+ func: AggFunc::Count,
+ field: field.to_string(),
+ distinct: true,
+ }
+}
diff --git a/ryx-rs/src/cache.rs b/ryx-rs/src/cache.rs
new file mode 100644
index 0000000..f880978
--- /dev/null
+++ b/ryx-rs/src/cache.rs
@@ -0,0 +1,312 @@
+use std::collections::HashMap;
+use std::hash::{Hash, Hasher};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use async_trait::async_trait;
+use ryx_backend::backends::{RowMapping, RowView};
+use ryx_common::{RyxError, RyxResult, SqlValue};
+use ryx_query::compiler;
+use tokio::sync::RwLock;
+
+use crate::queryset::QuerySet;
+use crate::row::FromRow;
+
+// ============================================================
+// Cache Backend Trait
+// ============================================================
+
+/// A cache storage backend.
+///
+/// Implement this trait to provide custom caching (Redis, memcached, etc.).
+#[async_trait]
+pub trait CacheBackend: Send + Sync {
+ /// Retrieve a cached value by key.
+ async fn get(&self, key: &str) -> RyxResult