diff --git a/.gitignore b/.gitignore
index 8d01d7f..a8cc95f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,7 @@
+# Editors
+.vscode/*
+.idea/*
+
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
@@ -231,5 +235,10 @@ ROADMAP*.md
libryx_core*
*.lock
-tests/test_compiler.rs
-*.txt
\ No newline at end of file
+**/tests/test_compiler.rs
+*.txt
+
+# test config files
+ex.py
+ryx.toml
+**/libryx*
diff --git a/Cargo.lock b/Cargo.lock
index 3c2803f..c5960b5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7,15 +7,10 @@ name = "Ryx"
version = "0.1.2"
dependencies = [
"criterion",
- "once_cell",
"pyo3",
"pyo3-async-runtimes",
- "ryx-query",
- "serde",
- "serde_json",
+ "ryx-backend",
"smallvec",
- "sqlx",
- "thiserror",
"tokio",
"tracing",
"tracing-subscriber",
@@ -158,9 +153,9 @@ dependencies = [
[[package]]
name = "async-signal"
-version = "0.2.13"
+version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
@@ -207,6 +202,17 @@ version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "atoi"
version = "2.0.0"
@@ -242,9 +248,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
-version = "2.11.0"
+version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
dependencies = [
"serde_core",
]
@@ -297,9 +303,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
-version = "1.2.58"
+version = "1.2.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
+checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -351,9 +357,9 @@ dependencies = [
[[package]]
name = "clap"
-version = "4.6.0"
+version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
]
@@ -415,9 +421,9 @@ dependencies = [
[[package]]
name = "crc-catalog"
-version = "2.4.0"
+version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
+checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "criterion"
@@ -507,6 +513,20 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "dashmap"
+version = "6.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core",
+]
+
[[package]]
name = "der"
version = "0.7.10"
@@ -612,9 +632,9 @@ dependencies = [
[[package]]
name = "fastrand"
-version = "2.3.0"
+version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "find-msvc-tools"
@@ -802,6 +822,12 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -815,9 +841,9 @@ dependencies = [
[[package]]
name = "hashbrown"
-version = "0.16.1"
+version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "hashlink"
@@ -899,12 +925,13 @@ dependencies = [
[[package]]
name = "icu_collections"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
+ "utf8_iter",
"yoke",
"zerofrom",
"zerovec",
@@ -912,9 +939,9 @@ dependencies = [
[[package]]
name = "icu_locale_core"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
@@ -925,9 +952,9 @@ dependencies = [
[[package]]
name = "icu_normalizer"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
@@ -939,15 +966,15 @@ dependencies = [
[[package]]
name = "icu_normalizer_data"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
-version = "2.1.2"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
@@ -959,15 +986,15 @@ dependencies = [
[[package]]
name = "icu_properties_data"
-version = "2.1.2"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
@@ -991,9 +1018,9 @@ dependencies = [
[[package]]
name = "idna_adapter"
-version = "1.2.1"
+version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
@@ -1001,12 +1028,12 @@ dependencies = [
[[package]]
name = "indexmap"
-version = "2.13.0"
+version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
- "hashbrown 0.16.1",
+ "hashbrown 0.17.0",
]
[[package]]
@@ -1037,9 +1064,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
-version = "0.3.94"
+version = "0.3.97"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
+checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf"
dependencies = [
"cfg-if",
"futures-util",
@@ -1067,9 +1094,9 @@ dependencies = [
[[package]]
name = "libc"
-version = "0.2.183"
+version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libm"
@@ -1079,14 +1106,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
-version = "0.1.15"
+version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
+checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [
"bitflags",
"libc",
"plain",
- "redox_syscall 0.7.3",
+ "redox_syscall 0.7.4",
]
[[package]]
@@ -1108,9 +1135,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
-version = "0.8.1"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "lock_api"
@@ -1323,9 +1350,9 @@ dependencies = [
[[package]]
name = "pkg-config"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plain"
@@ -1383,9 +1410,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "potential_utf"
-version = "0.1.4"
+version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
@@ -1504,9 +1531,9 @@ dependencies = [
[[package]]
name = "rand"
-version = "0.8.5"
+version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
@@ -1534,9 +1561,9 @@ dependencies = [
[[package]]
name = "rayon"
-version = "1.11.0"
+version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
@@ -1563,9 +1590,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
-version = "0.7.3"
+version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
+checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
dependencies = [
"bitflags",
]
@@ -1644,10 +1671,51 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+[[package]]
+name = "ryx-backend"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "criterion",
+ "dashmap",
+ "once_cell",
+ "ryx-core",
+ "ryx-query",
+ "serde",
+ "serde_json",
+ "smallvec",
+ "sqlx",
+ "thiserror",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "ryx-core"
+version = "0.1.2"
+dependencies = [
+ "chrono",
+ "criterion",
+ "once_cell",
+ "pyo3",
+ "pyo3-async-runtimes",
+ "ryx-query",
+ "serde",
+ "serde_json",
+ "smallvec",
+ "sqlx",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+]
+
[[package]]
name = "ryx-query"
version = "0.1.0"
dependencies = [
+ "criterion",
+ "dashmap",
"once_cell",
"serde",
"serde_json",
@@ -2106,9 +2174,9 @@ dependencies = [
[[package]]
name = "tinystr"
-version = "0.8.2"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
@@ -2141,9 +2209,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
-version = "1.50.0"
+version = "1.52.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
+checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
dependencies = [
"bytes",
"libc",
@@ -2158,9 +2226,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
-version = "2.6.1"
+version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
@@ -2242,9 +2310,9 @@ dependencies = [
[[package]]
name = "typenum"
-version = "1.19.0"
+version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "unicode-bidi"
@@ -2293,9 +2361,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
-version = "1.23.0"
+version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
+checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -2349,9 +2417,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
-version = "0.2.117"
+version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
+checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1"
dependencies = [
"cfg-if",
"once_cell",
@@ -2362,9 +2430,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
-version = "0.4.67"
+version = "0.4.70"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e"
+checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -2372,9 +2440,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.117"
+version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
+checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -2382,9 +2450,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.117"
+version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
+checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -2395,18 +2463,18 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.117"
+version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
+checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
-version = "0.3.94"
+version = "0.3.97"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
+checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -2567,15 +2635,15 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "writeable"
-version = "0.6.2"
+version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "yoke"
-version = "0.8.1"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
+checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
dependencies = [
"stable_deref_trait",
"yoke-derive",
@@ -2584,9 +2652,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
-version = "0.8.1"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
@@ -2616,18 +2684,18 @@ dependencies = [
[[package]]
name = "zerofrom"
-version = "0.1.6"
+version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
+checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
-version = "0.1.6"
+version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
@@ -2643,9 +2711,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
-version = "0.2.3"
+version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
@@ -2654,9 +2722,9 @@ dependencies = [
[[package]]
name = "zerovec"
-version = "0.11.5"
+version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
@@ -2665,9 +2733,9 @@ dependencies = [
[[package]]
name = "zerovec-derive"
-version = "0.11.2"
+version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
diff --git a/Cargo.toml b/Cargo.toml
index b4b518b..72bda66 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,51 +1,38 @@
-[package]
-name = "Ryx"
-version = "0.1.2"
+[workspace]
+members = [
+ "ryx-core",
+ "ryx-backend",
+ "ryx-query",
+ "ryx-python",
+]
+
+resolver = "2"
+
+[workspace.package]
+name = "ryx"
+version = "0.1.0"
edition = "2024"
-description = "Ryx ORM — a Django-style Python ORM powered by sqlx (Rust) via PyO3"
+authors = ["AllDotPy", "Ryx Contributors"]
license = "MIT OR Apache-2.0"
-authors = ["Wilfried GOEH", "AllDotPy", "Ryx Contributors"]
-
-# ──────────────────────────────────────────────────────────────────────────────
-# The crate is compiled as a C dynamic library so that Python can import it.
-# "cdylib" → produces a .so / .pyd file that maturin renames to ryx_core.so
-# We also keep "rlib" so that internal Rust tests (cargo test) can link against
-# the library without needing a Python interpreter.
-# ──────────────────────────────────────────────────────────────────────────────
-[lib]
-name = "ryx_core"
-crate-type = ["cdylib", "rlib"]
-
-# ──────────────────────────────────────────────────────────────────────────────
-# Feature flags
-#
-# Each database backend is opt-in so users only compile what they need.
-# Default: postgres only, which is the most common production choice.
-#
-# Usage in Cargo.toml:
-# ryx = { version = "0.1", features = ["sqlite", "mysql"] }
-# ──────────────────────────────────────────────────────────────────────────────
-[features]
-default = ["postgres", "mysql", "sqlite"] # enable all backends by default for dev convenience
-postgres = ["sqlx/postgres"]
-mysql = ["sqlx/mysql"]
-sqlite = ["sqlx/sqlite"]
-
-[dependencies]
-ryx-query = { path = "./ryx-query" }
-# ── PyO3 ──────────────────────────────────────────────────────────────────────
+repository = "https://github.com/AllDotPy/Ryx"
+homepage = "https://github.com/AllDotPy/Ryx"
+documentation = "https://docs.rs/Ryx"
+
+[workspace.dependencies]
+
+# 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 = { version = "0.28.3", features = ["extension-module"] }
-# ── Async bridge ──────────────────────────────────────────────────────────────
+# 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 = { version = "0.28.0", features = ["attributes", "async-std-runtime", "tokio-runtime"] }
-# ── sqlx ──────────────────────────────────────────────────────────────────────
+# 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.
@@ -55,22 +42,26 @@ sqlx = { version = "0.8.6", features = [
"chrono",
"uuid",
"json",
- "any"
+ "any",
+ "postgres",
+ "mysql",
+ "sqlite"
], default-features = false }
-# ── Tokio ─────────────────────────────────────────────────────────────────────
+# Tokio
# Full tokio runtime. "full" is fine for a library crate — callers can restrict
# features if they need a lighter binary.
tokio = { version = "1.40", features = ["full"] }
-smallvec = "1.13"
+smallvec = { version = "1.13", features = ["serde"] }
+chrono = { version = "0.4", default-features = false, features = ["clock"] }
-# ── Serialization ─────────────────────────────────────────────────────────────
+# Serialization
# serde + serde_json: used to pass structured data between Rust and Python
# (row data, query parameters, etc.)
serde = { version = "1", features = ["derive"] }
serde_json = "1"
-# ── Utilities ─────────────────────────────────────────────────────────────────
+# Utilities
# thiserror: ergonomic error type derivation. We define a rich BityaError type
# that converts cleanly into Python exceptions via PyO3's IntoPy trait.
thiserror = "2"
@@ -85,7 +76,25 @@ once_cell = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
-[dev-dependencies]
+
+[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"] }
+
+
+#
+# Profiles — favor peak perf in release builds (used by maturin/pip wheels).
+# LTO thin keeps link times reasonable while enabling cross-crate inlining.
+# codegen-units=1 avoids missed inlining across crates.
+#
+[profile.release]
+lto = "thin"
+codegen-units = 1
+opt-level = 3
+strip = "debuginfo"
+panic = "unwind"
+
+[profile.dev]
+opt-level = 3
+debug = true
diff --git a/README.md b/README.md
index 65022a6..582e8ce 100644
--- a/README.md
+++ b/README.md
@@ -15,12 +15,24 @@
+
+
+
+
+
+ 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.
diff --git a/benches/bench_compare.py b/benches/bench_compare.py
new file mode 100644
index 0000000..8b83a11
--- /dev/null
+++ b/benches/bench_compare.py
@@ -0,0 +1,346 @@
+"""
+Ryx ORM — Benchmark vs SQLAlchemy (inspired by examples/13_benchmark_sqlalchemy.py)
+
+Measures (N=10_000):
+ - bulk_create
+ - filter_query (category + is_active, order + limit)
+ - aggregate (count, sum, avg)
+ - bulk_update (price += 100 where is_active=1)
+ - bulk_delete (category = 'B')
+
+Supports SQLite and Postgres depending on RYX_DATABASE_URL.
+"""
+
+import asyncio
+import os
+import time
+from dataclasses import dataclass
+from typing import Dict
+
+import ryx
+from ryx import Model, CharField, IntField
+from ryx.migrations import MigrationRunner
+from ryx.executor_helpers import raw_fetch, raw_execute
+
+
+N = 10_000
+DEFAULT_SQLITE = "sqlite://bench.sqlite3?mode=rwc"
+
+
+def sa_async_url_from_env(url: str) -> str:
+ _url = url
+ if url.startswith("sqlite://"):
+ # sqlalchemy async driver
+ _url = url.replace("sqlite://", "sqlite+aiosqlite:///", 1).removesuffix('?mode=rwc')
+ if url.startswith("postgres://"):
+ _url = url.replace("postgres://", "postgresql+asyncpg://", 1)
+ if url.startswith("postgresql://"):
+ _url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
+ return _url
+
+
+class RyxItem(Model):
+ class Meta:
+ table_name = "bench_items"
+
+ name = CharField(max_length=100)
+ category = CharField(max_length=50)
+ price = IntField(default=0)
+ is_active = IntField(default=1)
+
+
+@dataclass
+class Row:
+ bulk_create: float
+ filter_query: float
+ aggregate: float
+ bulk_update: float
+ bulk_delete: float
+
+
+async def bench_ryx(url: str) -> Row:
+ await ryx.setup(url)
+ runner = MigrationRunner([RyxItem])
+ await runner.migrate()
+
+ # bulk_create
+ items = [
+ RyxItem(
+ name=f"Item {i}",
+ category="A" if i % 2 == 0 else "B",
+ price=i * 10,
+ is_active=1 if i % 3 != 0 else 0,
+ )
+ for i in range(N)
+ ]
+ t0 = time.monotonic()
+ await RyxItem.objects.bulk_create(items, batch_size=1000)
+ t_bulk_create = time.monotonic() - t0
+
+ # filter_query
+ t0 = time.monotonic()
+ await RyxItem.objects.filter(category="A", is_active=1).order_by("-price")[:50]
+ t_filter = time.monotonic() - t0
+
+ # aggregate
+ t0 = time.monotonic()
+ await RyxItem.objects.filter(category="A").aggregate(
+ total=ryx.Count("id"),
+ total_price=ryx.Sum("price"),
+ avg_price=ryx.Avg("price"),
+ )
+ t_agg = time.monotonic() - t0
+
+ # bulk_update (price += 100 where active)
+ active = await RyxItem.objects.filter(is_active=1)
+ for it in active:
+ it.price += 100
+ t0 = time.monotonic()
+ await RyxItem.objects.bulk_update(active, ["price"], batch_size=1000)
+ t_update = time.monotonic() - t0
+
+ # bulk_delete (category B)
+ t0 = time.monotonic()
+ await RyxItem.objects.filter(category="B").delete()
+ t_delete = time.monotonic() - t0
+
+ return Row(t_bulk_create, t_filter, t_agg, t_update, t_delete)
+
+
+async def bench_ryx_raw(url: str) -> Row:
+ # assumes table exists and filled by Ryx bench
+ # bulk_create raw
+ values = ", ".join(
+ [
+ f"('Raw {i}','A', {i*10}, 1)"
+ for i in range(N)
+ ]
+ )
+ t0 = time.monotonic()
+ await raw_execute(
+ f'INSERT INTO "bench_items" ("name","category","price","is_active") VALUES {values}',
+ None,
+ )
+ t_bulk_create = time.monotonic() - t0
+
+ t0 = time.monotonic()
+ await raw_fetch(
+ 'SELECT * FROM "bench_items" WHERE "category" = \'A\' AND "is_active" = 1 ORDER BY "price" DESC LIMIT 50',
+ None,
+ )
+ t_filter = time.monotonic() - t0
+
+ t0 = time.monotonic()
+ await raw_fetch(
+ 'SELECT COUNT(*) AS total, SUM("price") AS total_price, AVG("price") AS avg_price FROM "bench_items" WHERE "category" = \'A\'',
+ None,
+ )
+ t_agg = time.monotonic() - t0
+
+ t0 = time.monotonic()
+ await raw_execute(
+ 'UPDATE "bench_items" SET "price" = "price" + 100 WHERE "is_active" = 1',
+ None,
+ )
+ t_update = time.monotonic() - t0
+
+ t0 = time.monotonic()
+ await raw_execute('DELETE FROM "bench_items" WHERE "category" = \'B\'', None)
+ t_delete = time.monotonic() - t0
+
+ return Row(t_bulk_create, t_filter, t_agg, t_update, t_delete)
+
+
+async def bench_sqlalchemy(url: str) -> Dict[str, Row]:
+ try:
+ from sqlalchemy import (
+ Column,
+ Integer,
+ String,
+ select,
+ func,
+ update,
+ delete,
+ )
+ from sqlalchemy.orm import DeclarativeBase, sessionmaker
+ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
+ except ImportError:
+ print("SQLAlchemy not installed; skipping.")
+ return {}
+
+ async_url = sa_async_url_from_env(url)
+ engine = create_async_engine(async_url, echo=False)
+ async_session = sessionmaker(engine, class_=AsyncSession)
+
+ class Base(DeclarativeBase):
+ pass
+
+ class SAItem(Base):
+ __tablename__ = "sa_items"
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ name = Column(String(100), nullable=False)
+ category = Column(String(50), nullable=False)
+ price = Column(Integer, default=0)
+ is_active = Column(Integer, default=1)
+
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.drop_all)
+ await conn.run_sync(Base.metadata.create_all)
+
+ def sa_seed_values():
+ return [
+ dict(
+ name=f"Item {i}",
+ category="A" if i % 2 == 0 else "B",
+ price=i * 10,
+ is_active=1 if i % 3 != 0 else 0,
+ )
+ for i in range(N)
+ ]
+
+ # ORM bulk_create
+ t0 = time.monotonic()
+ async with async_session() as session:
+ session.add_all([SAItem(**v) for v in sa_seed_values()])
+ await session.commit()
+ sa_orm_create = time.monotonic() - t0
+
+ # ORM filter
+ t0 = time.monotonic()
+ async with async_session() as session:
+ stmt = (
+ select(SAItem)
+ .where(SAItem.category == "A", SAItem.is_active == 1)
+ .order_by(SAItem.price.desc())
+ .limit(50)
+ )
+ res = await session.execute(stmt)
+ res.scalars().all()
+ sa_orm_filter = time.monotonic() - t0
+
+ # ORM aggregate
+ t0 = time.monotonic()
+ async with async_session() as session:
+ stmt = select(
+ func.count(SAItem.id),
+ func.sum(SAItem.price),
+ func.avg(SAItem.price),
+ ).where(SAItem.category == "A")
+ await session.execute(stmt)
+ sa_orm_agg = time.monotonic() - t0
+
+ # ORM bulk_update
+ t0 = time.monotonic()
+ async with async_session() as session:
+ stmt = (
+ update(SAItem)
+ .where(SAItem.is_active == 1)
+ .values(price=SAItem.price + 100)
+ )
+ await session.execute(stmt)
+ await session.commit()
+ sa_orm_update = time.monotonic() - t0
+
+ # ORM bulk_delete
+ t0 = time.monotonic()
+ async with async_session() as session:
+ stmt = delete(SAItem).where(SAItem.category == "B")
+ await session.execute(stmt)
+ await session.commit()
+ sa_orm_delete = time.monotonic() - t0
+
+ # Core: re-seed
+ async with engine.begin() as conn:
+ await conn.execute(delete(SAItem))
+ await conn.execute(SAItem.__table__.insert(), sa_seed_values())
+
+ # Core filter
+ t0 = time.monotonic()
+ async with async_session() as session:
+ stmt = (
+ select(SAItem)
+ .where(SAItem.category == "A", SAItem.is_active == 1)
+ .order_by(SAItem.price.desc())
+ .limit(50)
+ )
+ res = await session.execute(stmt)
+ res.fetchall()
+ sa_core_filter = time.monotonic() - t0
+
+ # Core aggregate
+ t0 = time.monotonic()
+ async with async_session() as session:
+ stmt = select(
+ func.count(SAItem.id),
+ func.sum(SAItem.price),
+ func.avg(SAItem.price),
+ ).where(SAItem.category == "A")
+ await session.execute(stmt)
+ sa_core_agg = time.monotonic() - t0
+
+ # Core bulk_update
+ t0 = time.monotonic()
+ async with async_session() as session:
+ stmt = (
+ SAItem.__table__.update()
+ .where(SAItem.__table__.c.is_active == 1)
+ .values(price=SAItem.__table__.c.price + 100)
+ )
+ await session.execute(stmt)
+ await session.commit()
+ sa_core_update = time.monotonic() - t0
+
+ # Core bulk_delete
+ t0 = time.monotonic()
+ async with async_session() as session:
+ stmt = SAItem.__table__.delete().where(SAItem.__table__.c.category == "B")
+ await session.execute(stmt)
+ await session.commit()
+ sa_core_delete = time.monotonic() - t0
+
+ await engine.dispose()
+
+ orm_row = Row(sa_orm_create, sa_orm_filter, sa_orm_agg, sa_orm_update, sa_orm_delete)
+ core_row = Row(sa_orm_create, sa_core_filter, sa_core_agg, sa_core_update, sa_core_delete)
+ return {"orm": orm_row, "core": core_row}
+
+
+def print_table(ryx_row: Row, sa_rows: Dict[str, Row], raw_row: Row):
+ print("\n" + "=" * 70)
+ print("BENCHMARK SUMMARY (seconds, lower is better)")
+ print("=" * 70)
+ print(f"{'Operation':<18} | {'Ryx ORM':>10} | {'SA ORM':>10} | {'SA Core':>10} | {'Ryx raw':>10}")
+ print("-" * 70)
+ ops = ["bulk_create", "filter_query", "aggregate", "bulk_update", "bulk_delete"]
+ for op in ops:
+ print(
+ f"{op:<18} | "
+ f"{getattr(ryx_row, op):10.4f} | "
+ f"{getattr(sa_rows['orm'], op):10.4f} | "
+ f"{getattr(sa_rows['core'], op):10.4f} | "
+ f"{getattr(raw_row, op):10.4f}"
+ )
+ print("=" * 70)
+
+
+async def main():
+ url = os.environ.get("RYX_DATABASE_URL", DEFAULT_SQLITE)
+ print(f"Using database URL: {url}")
+
+ # Fresh table for Ryx benchmarks
+ ryx_row = await bench_ryx(url)
+
+ # Seed again for raw benchmarks
+ await raw_execute('DELETE FROM "bench_items"', None)
+ raw_row = await bench_ryx_raw(url)
+
+ sa_rows = await bench_sqlalchemy(url)
+ if not sa_rows:
+ print("SQLAlchemy benches skipped.")
+ return
+
+ print_table(ryx_row, sa_rows, raw_row)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/examples/13_benchmark_sqlalchemy.py b/examples/13_benchmark_sqlalchemy.py
index 046014c..5fe4c41 100644
--- a/examples/13_benchmark_sqlalchemy.py
+++ b/examples/13_benchmark_sqlalchemy.py
@@ -36,7 +36,7 @@
DATABASE_URL = f"sqlite://{DB_PATH}?mode=rwc"
os.environ["RYX_DATABASE_URL"] = DATABASE_URL
-N = 1000 # Number of rows for bulk operations
+N = 10_000 # Number of rows for bulk operations
#
@@ -85,7 +85,9 @@ async def bench_ryx_orm() -> dict:
print("Ryx ORM")
print("=" * 60)
- await ryx.setup(DATABASE_URL)
+ if not ryx.is_connected():
+ await ryx.setup(DATABASE_URL)
+
runner = MigrationRunner([RyxItem])
await runner.migrate()
@@ -107,7 +109,7 @@ async def bench_ryx_orm() -> dict:
# 2. Filtered query
with timed("filter + order + limit") as t:
- await RyxItem.objects.filter(category="A", is_active=1).order_by("-price")[:50]
+ await RyxItem.objects.filter(category="A", is_active=1).order_by("-price").limit(50) # Or [:50]
results["filter_query"] = t.elapsed
# 3. Aggregate
diff --git a/examples/ryx.example.toml b/examples/ryx.example.toml
index 0e46929..8d3288d 100644
--- a/examples/ryx.example.toml
+++ b/examples/ryx.example.toml
@@ -1,32 +1,22 @@
# Example ryx configuration file (TOML format)
# Copy to ryx.toml in your project root
-[database]
-url = "sqlite:///dev.db"
+[urls]
+default = "sqlite:///Users/einswilli/Documents/projects/AllDotPy/Ryx/test_db.sqlite3?mode=rwc"
+replica = "postgres://ryx_test:12345@localhost:5432/test_ryx"
+logs = "sqlite:///Users/einswilli/Documents/projects/AllDotPy/Ryx/logs.db?mode=rwc"
+# replica = "postgres://repl:replpass@replica-host:5432/appdb"
-[database.pool]
-max_connections = 5
-min_connections = 1
-connect_timeout = 10
-idle_timeout = 300
-max_lifetime = 900
+[pool]
+max_conn = 12
+min_conn = 2
+connect_timeout = 30
+idle_timeout = 600
+max_lifetime = 1800
-[debug]
-verbose = true
-
-# Environment-specific configs:
-# Use --env prod to activate the [prod] section
-# Values in environment sections override base values
-
-[dev]
-database.url = "sqlite:///dev.db"
-debug.verbose = true
-
-[test]
-database.url = "sqlite:///test.db"
-database.pool.max_connections = 2
-
-[prod]
-database.url = "postgres://user:pass@prod-server/mydb"
-database.pool.max_connections = 20
-database.pool.min_connections = 5
\ No newline at end of file
+[models]
+files = [
+ "user_app/models.py",
+ "order_app/models.py",
+ "billing_app/models/*"
+]
diff --git a/ryx-backend/Cargo.toml b/ryx-backend/Cargo.toml
new file mode 100644
index 0000000..bb1fa02
--- /dev/null
+++ b/ryx-backend/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "ryx-backend"
+version = "0.1.0"
+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" }
+sqlx = { workspace = true }
+tokio = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+once_cell = { workspace = true }
+tracing = { workspace = true }
+smallvec = { workspace = true }
+dashmap = "6.1.0"
+async-trait = "0.1"
+
+[dev-dependencies]
+criterion = { version = "0.5", features = ["async_tokio"] }
+
+# [[bench]]
+# name = "query_bench"
+# harness = false
diff --git a/ryx-backend/src/backends/mod.rs b/ryx-backend/src/backends/mod.rs
new file mode 100644
index 0000000..0f8a0ee
--- /dev/null
+++ b/ryx-backend/src/backends/mod.rs
@@ -0,0 +1,270 @@
+//
+//
+pub mod mysql;
+pub mod postgres;
+pub mod sqlite;
+
+use ryx_core::errors::{RyxError, RyxResult};
+use ryx_query::{
+ ast::{QueryNode, SqlValue},
+ compiler::CompiledQuery,
+};
+use sqlx::{Executor, MySqlConnection, PgConnection, SqliteConnection, Transaction};
+
+use crate::pool::{PoolStats, RyxPool};
+use crate::utils::decode_rows;
+
+/// Unified connection enum to avoid dynamic dispatch in the hot path.
+#[derive(Debug)]
+pub enum RyxConnection {
+ Postgres(PgConnection),
+ MySql(MySqlConnection),
+ Sqlite(SqliteConnection),
+}
+
+/// Unified transaction enum.
+/// Uses 'static because transactions are held across PyO3 boundaries in Arc>>.
+#[derive(Debug)]
+pub enum RyxTransaction {
+ Postgres(Transaction<'static, sqlx::Postgres>),
+ MySql(Transaction<'static, sqlx::MySql>),
+ Sqlite(Transaction<'static, sqlx::Sqlite>),
+}
+
+impl RyxTransaction {
+ pub async fn execute_raw(&mut self, sql: &str) -> RyxResult<()> {
+ match self {
+ RyxTransaction::Postgres(tx) => tx
+ .execute(sqlx::query::(sql))
+ .await
+ .map_err(RyxError::Database)
+ .map(|_| ()),
+ RyxTransaction::MySql(tx) => tx
+ .execute(sqlx::query::(sql))
+ .await
+ .map_err(RyxError::Database)
+ .map(|_| ()),
+ RyxTransaction::Sqlite(tx) => tx
+ .execute(sqlx::query::(sql))
+ .await
+ .map_err(RyxError::Database)
+ .map(|_| ()),
+ }
+ }
+
+ pub async fn fetch_raw(&mut self, sql: &str) -> RyxResult> {
+ match self {
+ RyxTransaction::Postgres(tx) => {
+ let rows = tx
+ .fetch_all(sqlx::query::(sql))
+ .await
+ .map_err(RyxError::Database)?;
+ Ok(decode_rows(&rows, None))
+ }
+ RyxTransaction::MySql(tx) => {
+ let rows = tx
+ .fetch_all(sqlx::query::(sql))
+ .await
+ .map_err(RyxError::Database)?;
+ Ok(decode_rows(&rows, None))
+ }
+ RyxTransaction::Sqlite(tx) => {
+ let rows = tx
+ .fetch_all(sqlx::query::(sql))
+ .await
+ .map_err(RyxError::Database)?;
+ Ok(decode_rows(&rows, None))
+ }
+ }
+ }
+
+ pub async fn execute_query(&mut self, query: CompiledQuery) -> RyxResult {
+ match self {
+ RyxTransaction::Postgres(tx) => {
+ let mut q = sqlx::query(&query.sql);
+ for v in &query.values {
+ q = bind_pg(q, v);
+ }
+ Ok(tx
+ .execute(q)
+ .await
+ .map_err(RyxError::Database)?
+ .rows_affected())
+ }
+ RyxTransaction::MySql(tx) => {
+ let mut q = sqlx::query(&query.sql);
+ for v in &query.values {
+ q = bind_mysql(q, v);
+ }
+ Ok(tx
+ .execute(q)
+ .await
+ .map_err(RyxError::Database)?
+ .rows_affected())
+ }
+ RyxTransaction::Sqlite(tx) => {
+ let mut q = sqlx::query(&query.sql);
+ for v in &query.values {
+ q = bind_sqlite(q, v);
+ }
+ Ok(tx
+ .execute(q)
+ .await
+ .map_err(RyxError::Database)?
+ .rows_affected())
+ }
+ }
+ }
+
+ pub async fn fetch_query(&mut self, query: CompiledQuery) -> RyxResult> {
+ match self {
+ RyxTransaction::Postgres(tx) => {
+ let mut q = sqlx::query(&query.sql);
+ for v in &query.values {
+ q = bind_pg(q, v);
+ }
+ let rows = tx.fetch_all(q).await.map_err(RyxError::Database)?;
+ Ok(decode_rows(&rows, query.base_table.as_deref()))
+ }
+ RyxTransaction::MySql(tx) => {
+ let mut q = sqlx::query(&query.sql);
+ for v in &query.values {
+ q = bind_mysql(q, v);
+ }
+ let rows = tx.fetch_all(q).await.map_err(RyxError::Database)?;
+ Ok(decode_rows(&rows, query.base_table.as_deref()))
+ }
+ RyxTransaction::Sqlite(tx) => {
+ let mut q = sqlx::query(&query.sql);
+ for v in &query.values {
+ q = bind_sqlite(q, v);
+ }
+ let rows = tx.fetch_all(q).await.map_err(RyxError::Database)?;
+ Ok(decode_rows(&rows, query.base_table.as_deref()))
+ }
+ }
+ }
+}
+
+// Binding helpers
+fn bind_pg<'q>(
+ q: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
+ v: &'q SqlValue,
+) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> {
+ match v {
+ SqlValue::Null => q.bind(None::),
+ 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::List(_) => q,
+ }
+}
+
+fn bind_mysql<'q>(
+ q: sqlx::query::Query<'q, sqlx::MySql, sqlx::mysql::MySqlArguments>,
+ v: &'q SqlValue,
+) -> sqlx::query::Query<'q, sqlx::MySql, sqlx::mysql::MySqlArguments> {
+ match v {
+ SqlValue::Null => q.bind(None::),
+ 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::List(_) => q,
+ }
+}
+
+fn bind_sqlite<'q>(
+ q: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>,
+ v: &'q SqlValue,
+) -> sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>> {
+ match v {
+ SqlValue::Null => q.bind(None::),
+ 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::List(_) => q,
+ }
+}
+
+#[async_trait::async_trait]
+pub trait RyxBackend: Send + Sync + 'static {
+ async fn __fetch_all(&self, query: CompiledQuery) -> RyxResult>;
+ async fn __fetch_one(&self, query: CompiledQuery) -> RyxResult;
+ async fn fetch_all(&self, query: CompiledQuery) -> RyxResult>;
+ async fn fetch_raw(&self, sql: String, db_alias: Option) -> RyxResult>;
+ async fn fetch_all_compiled(&self, node: QueryNode) -> RyxResult>;
+ async fn fetch_count(&self, query: CompiledQuery) -> RyxResult;
+ async fn fetch_count_compiled(&self, node: QueryNode) -> RyxResult;
+ async fn fetch_one(&self, query: CompiledQuery) -> RyxResult;
+ async fn fetch_one_compiled(&self, node: QueryNode) -> RyxResult;
+ async fn execute(&self, query: CompiledQuery) -> RyxResult;
+ async fn execute_compiled(&self, node: QueryNode) -> RyxResult;
+ async fn bulk_insert(
+ &self,
+ table: String,
+ columns: Vec,
+ rows: Vec>,
+ returning_id: bool,
+ ignore_conflicts: bool,
+ db_alias: Option,
+ ) -> RyxResult;
+ async fn bulk_delete(
+ &self,
+ table: String,
+ pk_col: String,
+ pks: Vec,
+ db_alias: Option,
+ ) -> RyxResult;
+ async fn bulk_update(
+ &self,
+ table: String,
+ pk_col: String,
+ col_names: Vec,
+ field_values: Vec>,
+ pks: Vec,
+ db_alias: Option,
+ ) -> RyxResult;
+ async fn execute_raw(&self, sql: String, db_alias: Option) -> RyxResult<()>;
+ fn pool_stats(&self) -> PoolStats;
+ fn get_pool(&self) -> RyxPool;
+}
+
+use std::sync::Arc;
+
+/// Mapping of column names to their indices in a row.
+/// Shared across all rows in a result set.
+#[derive(Debug, Clone)]
+pub struct RowMapping {
+ pub columns: Vec,
+}
+
+/// A lightweight view of a database row.
+/// Instead of a HashMap, it stores values in a Vec.
+#[derive(Debug, Clone)]
+pub struct RowView {
+ pub values: Vec,
+ pub mapping: Arc,
+}
+
+impl RowView {
+ pub fn get(&self, name: &str) -> Option<&ryx_query::ast::SqlValue> {
+ self.mapping
+ .columns
+ .iter()
+ .position(|c| c == name)
+ .and_then(|idx| self.values.get(idx))
+ }
+}
+
+pub type DecodedRow = RowView;
+
+/// Result of a non-SELECT query (INSERT/UPDATE/DELETE).
+#[derive(Debug)]
+pub struct MutationResult {
+ pub rows_affected: u64,
+ pub last_insert_id: Option,
+ pub returned_ids: Option>,
+}
diff --git a/ryx-backend/src/backends/mysql.rs b/ryx-backend/src/backends/mysql.rs
new file mode 100644
index 0000000..c948528
--- /dev/null
+++ b/ryx-backend/src/backends/mysql.rs
@@ -0,0 +1,712 @@
+// Mysql Backend for Ryx Query Compiler
+
+use smallvec::SmallVec;
+use sqlx::{
+ Column, Row,
+ mysql::{MySqlPool, MySqlPoolOptions},
+};
+
+use ryx_core::errors::{RyxError, RyxResult};
+use ryx_query::ast::{QueryNode, SqlValue};
+use ryx_query::compiler::{CompiledQuery, compile};
+
+use super::{DecodedRow, MutationResult, RyxBackend};
+use crate::pool::{PoolConfig, PoolStats, RyxPool};
+use crate::transaction::get_current_transaction;
+use crate::utils::{decode_row, decode_rows, is_date, is_timestamp};
+
+use tracing::{debug, instrument};
+
+pub struct MySqlBackend {
+ // The connection pool for MySql
+ pool: MySqlPool,
+}
+
+impl MySqlBackend {
+ /// Create a new MySqlBackend with a connection pool based on the provided config.
+ /// Uses `sqlx::MySqlPool` under the hood.
+ /// Usage:
+ /// ```
+ /// let config = PoolConfig {
+ /// url: "mysql://user:password@localhost/db".to_string(),
+ /// max_connections: 10,
+ /// min_connections: 1,
+ /// connect_timeout_secs: 5,
+ /// idle_timeout_secs: 300,
+ /// max_lifetime_secs: 1800,
+ /// };
+ /// let backend = MySqlBackend::new(config, url).await;
+ /// ```
+ pub async fn new(config: PoolConfig, url: String) -> Self {
+ // Create a new MySql connection pool using the provided config
+ let pool: sqlx::MySqlPool = MySqlPoolOptions::new()
+ .max_connections(config.max_connections)
+ .min_connections(config.min_connections)
+ .acquire_timeout(std::time::Duration::from_secs(config.connect_timeout_secs))
+ .idle_timeout(std::time::Duration::from_secs(config.idle_timeout_secs))
+ .max_lifetime(std::time::Duration::from_secs(config.max_lifetime_secs))
+ .connect(&url)
+ .await
+ .expect("Failed to create Postgres connection pool");
+ Self { pool }
+ }
+
+ /// Begin a new transaction by acquiring a connection from the pool.
+ /// Usage:
+ /// ```
+ /// let tx = backend.begin().await.unwrap();
+ ///
+ pub async fn begin(&self) -> RyxResult> {
+ self.pool.begin().await.map_err(RyxError::Database)
+ }
+
+ /// Bind all `SqlValue`s to a sqlx query in order.
+ ///
+ /// sqlx's `.bind()` takes ownership and returns a new query, so we chain
+ /// calls with a mutable variable rather than a functional fold to keep the
+ /// code readable.
+ fn bind_values<'q>(
+ &self,
+ mut q: sqlx::query::Query<'q, sqlx::MySql, sqlx::mysql::MySqlArguments>,
+ values: &'q [SqlValue],
+ ) -> sqlx::query::Query<'q, sqlx::MySql, sqlx::mysql::MySqlArguments> {
+ for value in values {
+ q = match value {
+ SqlValue::Null => q.bind(None::),
+ 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()),
+ // Lists should have been expanded by the compiler into individual
+ // placeholders. If we encounter a List here it's a compiler bug.
+ SqlValue::List(_) => {
+ // This is a defensive no-op — the compiler should have expanded
+ // lists already. We log a warning and skip.
+ tracing::warn!(
+ "Unexpected List value reached executor — this is a compiler bug"
+ );
+ q
+ }
+ };
+ }
+ q
+ }
+
+ /// Rewrite generic `?` placeholders to PostgreSQL-style `$1, $2, ...` when needed.
+ pub fn normalize_sql(&self, query: &CompiledQuery) -> String {
+ // Fast path: rewrite ? -> $n and append type casts when we know the
+ // column -> field type mapping.
+ let mut out = String::with_capacity(query.sql.len() + 8);
+ let mut idx = 0usize;
+
+ for ch in query.sql.chars() {
+ if ch == '?' {
+ idx += 1;
+ out.push('$');
+ out.push_str(&idx.to_string());
+ } else {
+ out.push(ch);
+ }
+ }
+ out
+ }
+}
+
+#[async_trait::async_trait]
+impl RyxBackend for MySqlBackend {
+ /// 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:
+ /// ```
+ /// let query = CompiledQuery {
+ /// sql: "SELECT id, name FROM users WHERE age > $1".to_string(),
+ /// values: vec![SqlValue::Int(30)],
+ /// };
+ /// let rows = backend.__fetch_all(query).await.unwrap();
+ /// for row in rows {
+ /// println!("User ID: {}, Name: {}", row.get("id").unwrap(), row.get("name").unwrap());
+ /// }
+ /// ```
+ async fn __fetch_all(&self, query: CompiledQuery) -> RyxResult> {
+ let sql = self.normalize_sql(&query);
+ let mut q = sqlx::query::(&sql);
+ // Bind parameters to the quer
+ q = self.bind_values(q, &query.values);
+ // Execute the query and return the results
+ let rows = q.fetch_all(&self.pool).await.map_err(RyxError::Database)?;
+
+ Ok(decode_rows(&rows, query.base_table.as_deref()))
+ }
+
+ /// 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:
+ /// ```
+ /// let query = CompiledQuery {
+ /// sql: "SELECT id, name FROM users WHERE id = $1".to_string(),
+ /// values: vec![SqlValue::Int(42)],
+ /// };
+ /// let row = backend.__fetch_one(query).await.unwrap();
+ /// println!("User ID: {}, Name: {}", row.get("id").unwrap(), row.get("name").unwrap());
+ /// ```
+ async fn __fetch_one(&self, query: CompiledQuery) -> RyxResult {
+ let mut q = sqlx::query::(&query.sql);
+ // Bind parameters to the query
+ q = self.bind_values(q, &query.values);
+ // Execute the query and return the result
+ let row = q.fetch_one(&self.pool).await.map_err(RyxError::Database)?;
+ let mapping = std::sync::Arc::new(crate::backends::RowMapping {
+ columns: row
+ .columns()
+ .iter()
+ .map(|c: &sqlx::mysql::MySqlColumn| c.name().to_string())
+ .collect(),
+ });
+
+ // Decode the single row into a DecodedRow and return it
+ Ok(decode_row(&row, &mapping, query.base_table.as_deref()))
+ }
+
+ /// 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:
+ /// ```
+ /// let query = CompiledQuery {
+ /// sql: "UPDATE users SET active = false WHERE last_login < $1".to_string(),
+ /// values: vec![SqlValue::Text("2024-01-01".to_string())],
+ /// };
+ /// let result = backend.__execute(query).await.unwrap();
+ /// println!("Number of users deactivated: {}", result.rows_affected);
+ /// ```
+ async fn fetch_all(&self, query: CompiledQuery) -> RyxResult> {
+ if let Some(tx) = get_current_transaction() {
+ let tx_guard = tx.lock().await;
+ if let Some(active_tx) = tx_guard.as_ref() {
+ return active_tx.fetch_query(query).await;
+ }
+ return Err(RyxError::Internal("Transaction is no longer active".into()));
+ }
+
+ // let pool = pool::get(query.db_alias.as_deref())?.as_any();
+ debug!(sql = %query.sql, "Executing SELECT");
+
+ // let sql = self.normalize_sql(&query);
+ // let mut q = sqlx::query::(&sql);
+ // q = self.bind_values(q, &query.values);
+
+ // let rows = q.fetch_all(&self.pool).await.map_err(RyxError::Database)?;
+ let rows: Vec = self.__fetch_all(query).await?;
+
+ // let decoded = decode_rows(&rows, query.base_table.as_deref());
+ Ok(rows)
+ }
+
+ /// 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:
+ /// ```
+ /// 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 {
+ /// println!("User ID: {}, Name: {}", row.get("id").unwrap(), row.get("name").unwrap());
+ /// }
+ /// ```
+ async fn fetch_raw(
+ &self,
+ sql: String,
+ _db_alias: Option,
+ ) -> RyxResult> {
+ let rows = sqlx::query::(&sql)
+ .fetch_all(&self.pool)
+ .await
+ .map_err(RyxError::Database)?;
+ Ok(decode_rows(&rows, None))
+ }
+
+ /// 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:
+ /// ```
+ /// let node = QueryNode::Select { ... }; // Construct a QueryNode representing the query
+ /// let rows = backend.fetch_all_compiled(node).await.unwrap();
+ /// for row in rows {
+ /// println!("User ID: {}, Name: {}", row.get("id").unwrap(), row.get("name").unwrap());
+ /// }
+ /// ```
+ async fn fetch_all_compiled(&self, node: QueryNode) -> RyxResult> {
+ let compiled = compile(&node).map_err(RyxError::from)?;
+ self.__fetch_all(compiled).await
+ }
+
+ /// Execute a SELECT COUNT(*) query and return the count.
+ ///
+ /// # Errors
+ /// Same as [`fetch_all`].
+ #[instrument(skip(query, self), fields(sql = %query.sql))]
+ async fn fetch_count(&self, query: CompiledQuery) -> RyxResult {
+ if let Some(tx) = get_current_transaction() {
+ let tx_guard = tx.lock().await;
+ if let Some(active_tx) = tx_guard.as_ref() {
+ let rows = active_tx.fetch_query(query).await?;
+ if rows.is_empty() {
+ return Ok(0);
+ }
+ if let Some(value) = rows[0].values.first() {
+ match value {
+ SqlValue::Int(i) => return Ok(*i),
+ SqlValue::Float(f) => return Ok(*f as i64),
+ _ => {}
+ }
+ }
+ return Err(RyxError::Internal(
+ "COUNT() returned unexpected value".into(),
+ ));
+ }
+ return Err(RyxError::Internal("Transaction is no longer active".into()));
+ }
+
+ // let pool = pool::get(query.db_alias.as_deref())?.as_any();
+
+ debug!(sql = %query.sql, "Executing COUNT");
+
+ let mut q = sqlx::query::(&query.sql);
+ q = self.bind_values(q, &query.values);
+
+ let row = q.fetch_one(&self.pool).await.map_err(RyxError::Database)?;
+
+ let count: i64 = row.try_get(0).unwrap_or_else(|_| {
+ let n: i32 = row.try_get(0).unwrap_or(0);
+ n as i64
+ });
+
+ Ok(count)
+ }
+
+ /// Execute a COUNT query represented as a QueryNode and return the count.
+ /// This is a convenience method that compiles the QueryNode and then executes it using fetch_count.
+ /// # Errors
+ /// Same as [`fetch_count`].
+ #[instrument(skip(node, self))]
+ async fn fetch_count_compiled(&self, node: QueryNode) -> RyxResult {
+ let compiled = compile(&node).map_err(RyxError::from)?;
+ self.fetch_count(compiled).await
+ }
+
+ /// Execute a SELECT and return at most one row.
+ ///
+ /// # Errors
+ /// - [`RyxError::DoesNotExist`] if no rows are found
+ /// - [`RyxError::MultipleObjectsReturned`] if more than one row is found
+ ///
+ /// This mirrors Django's `.get()` semantics exactly.
+ #[instrument(skip(query, self), fields(sql = %query.sql))]
+ async fn fetch_one(&self, query: CompiledQuery) -> RyxResult {
+ // We intentionally fetch up to 2 rows to detect MultipleObjectsReturned
+ // without fetching the entire result set. This is more efficient than
+ // `fetch_all` when the user calls `.get()` on a large table.
+ if let Some(tx) = get_current_transaction() {
+ let tx_guard = tx.lock().await;
+ if let Some(active_tx) = tx_guard.as_ref() {
+ let rows = active_tx.fetch_query(query).await?;
+ match rows.len() {
+ 0 => Err(RyxError::DoesNotExist),
+ 1 => Ok(rows.into_iter().next().unwrap()),
+ _ => Err(RyxError::MultipleObjectsReturned),
+ }
+ } else {
+ Err(RyxError::Internal("Transaction is no longer active".into()))
+ }
+ } else {
+ // let pool = pool::get(query.db_alias.as_deref())?.as_any();
+
+ let sql = self.normalize_sql(&query);
+ let mut q = sqlx::query::(&sql);
+ q = self.bind_values(q, &query.values);
+
+ // Limit to 2 at the executor level (the QueryNode may already have
+ // LIMIT 1 set by `.first()`, but for `.get()` it doesn't).
+ // We check the count in Rust rather than adding SQL complexity.
+ let rows = q.fetch_all(&self.pool).await.map_err(RyxError::Database)?;
+
+ let mapping = if rows.is_empty() {
+ None
+ } else {
+ Some(std::sync::Arc::new(crate::backends::RowMapping {
+ columns: rows[0]
+ .columns()
+ .iter()
+ .map(|c| c.name().to_string())
+ .collect(),
+ }))
+ };
+
+ match rows.len() {
+ 0 => Err(RyxError::DoesNotExist),
+ 1 => Ok(decode_row(
+ &rows[0],
+ mapping.as_ref().unwrap(),
+ query.base_table.as_deref(),
+ )),
+ _ => Err(RyxError::MultipleObjectsReturned),
+ }
+ }
+ }
+
+ /// Execute a SELECT represented as a QueryNode and return at most one row.
+ /// This is a convenience method that compiles the QueryNode and then executes it using fetch_one.
+ /// # Errors
+ /// - [`RyxError::DoesNotExist`] if no rows are found
+ /// - [`RyxError::MultipleObjectsReturned`] if more than one row is found
+ #[instrument(skip(node, self))]
+ async fn fetch_one_compiled(&self, node: QueryNode) -> RyxResult {
+ let compiled = compile(&node).map_err(RyxError::from)?;
+ self.fetch_one(compiled).await
+ }
+
+ /// Execute an INSERT, UPDATE, or DELETE query.
+ ///
+ /// For INSERT queries with `RETURNING` clause, this fetches the returned
+ /// value and populates `last_insert_id`.
+ ///
+ /// # Errors
+ /// - [`RyxError::PoolNotInitialized`]
+ /// - [`RyxError::Database`]
+ #[instrument(skip(query, self), fields(sql = %query.sql))]
+ async fn execute(&self, query: CompiledQuery) -> RyxResult {
+ // Check if we're in a transaction and execute there if so,
+ // to ensure we stay on the same connection.
+ if let Some(tx) = get_current_transaction() {
+ let tx_guard = tx.lock().await;
+ if let Some(active_tx) = tx_guard.as_ref() {
+ // Check if this is a RETURNING query
+ if query.sql.to_uppercase().contains("RETURNING") {
+ let rows = active_tx.fetch_query(query).await?;
+ let last_insert_id = rows.first().and_then(|row| {
+ row.values.first().and_then(|v| match v {
+ SqlValue::Int(i) => Some(*i),
+ SqlValue::Float(f) => Some(*f as i64),
+ _ => None,
+ })
+ });
+ return Ok(MutationResult {
+ rows_affected: 1,
+ last_insert_id,
+ returned_ids: Some(
+ rows.iter()
+ .filter_map(|row| {
+ row.values.first().and_then(|v| match v {
+ SqlValue::Int(i) => Some(*i),
+ SqlValue::Float(f) => Some(*f as i64),
+ _ => None,
+ })
+ })
+ .collect(),
+ ),
+ });
+ }
+ let rows_affected = active_tx.execute_query(query).await?;
+ return Ok(MutationResult {
+ rows_affected,
+ last_insert_id: None,
+ returned_ids: None,
+ });
+ }
+ return Err(RyxError::Internal("Transaction is no longer active".into()));
+ }
+
+ // let pool = pool::get(query.db_alias.as_deref())?.as_any();
+
+ debug!(sql = %query.sql, "Executing mutation");
+
+ // Check if this is a RETURNING query (e.g. INSERT ... RETURNING id)
+ let sql = self.normalize_sql(&query);
+ if sql.to_uppercase().contains("RETURNING") {
+ let mut q = sqlx::query::(&sql);
+ q = self.bind_values(q, &query.values);
+
+ let rows = q
+ .fetch_all(&self.pool)
+ .await
+ .map_err(|e| RyxError::DatabaseWithSql(sql.clone(), e))?;
+
+ let last_insert_id = rows.first().and_then(|row| row.try_get::(0).ok());
+ let returned_ids: Vec = rows
+ .iter()
+ .filter_map(|row| row.try_get::(0).ok())
+ .collect();
+
+ return Ok(MutationResult {
+ rows_affected: rows.len() as u64,
+ last_insert_id,
+ returned_ids: Some(returned_ids),
+ });
+ }
+
+ let mut q = sqlx::query::(&sql);
+ q = self.bind_values(q, &query.values);
+
+ let result = q
+ .execute(&self.pool)
+ .await
+ .map_err(|e| RyxError::DatabaseWithSql(sql.clone(), e))?;
+
+ Ok(MutationResult {
+ rows_affected: result.rows_affected(),
+ last_insert_id: None,
+ returned_ids: None,
+ })
+ }
+
+ /// Execute QueryNode
+ #[instrument(skip(node, self))]
+ async fn execute_compiled(&self, node: QueryNode) -> RyxResult {
+ let compiled = compile(&node).map_err(RyxError::from)?;
+ self.execute(compiled).await
+ }
+
+ /// Bulk insert rows with values already mapped to SqlValue in one shot.
+ /// This is used for efficient bulk inserts, especially when the data is already in memory and we want to avoid multiple round-trips to the database.
+ /// The `returning_id` flag indicates whether to return the last inserted ID(s), which is useful for auto-increment primary keys.
+ /// The `ignore_conflicts` flag allows the caller to specify whether to ignore conflicts (e.g. duplicate keys) during insertion, which can be useful for upsert-like behavior.
+ /// # Errors
+ /// - [`RyxError::PoolNotInitialized`]
+ /// - [`RyxError::Database`]
+ async fn bulk_insert(
+ &self,
+ table: String,
+ columns: Vec,
+ rows: Vec>,
+ returning_id: bool,
+ ignore_conflicts: bool,
+ _db_alias: Option,
+ ) -> RyxResult {
+ if rows.is_empty() {
+ return Ok(MutationResult {
+ rows_affected: 0,
+ last_insert_id: None,
+ returned_ids: None,
+ });
+ }
+ // let pool = pool::get(db_alias.as_deref())?.as_any();
+ // let backend = pool::get_backend(db_alias.as_deref())?;
+
+ let col_list = columns
+ .iter()
+ .map(|c| format!("\"{}\"", c))
+ .collect::>()
+ .join(", ");
+
+ // 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 raw = {
+ match rows.get(0).and_then(|r| r.get(idx)) {
+ 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()
+ }
+ _ => "?".to_string(),
+ }
+ };
+ placeholders.push(raw);
+ }
+
+ 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();
+
+ let values_sql = values_sql_parts.join(", ");
+
+ let mut flat: SmallVec<[SqlValue; 8]> = SmallVec::new();
+ for row in rows {
+ for v in row {
+ flat.push(v);
+ }
+ }
+
+ // On confilct
+ let (insert_kw, conflict_suffix) = if ignore_conflicts {
+ ("INSERT IGNORE INTO", "")
+ } else {
+ ("INSERT INTO", "")
+ };
+
+ let sql = format!(
+ "{} \"{}\" ({}) VALUES {}{}{}",
+ insert_kw,
+ table,
+ col_list,
+ values_sql,
+ conflict_suffix,
+ if returning_id { " RETURNING id" } else { "" }
+ );
+
+ let mut q = sqlx::query::(&sql);
+ q = self.bind_values(q, &flat);
+ if returning_id {
+ let rows = q.fetch_all(&self.pool).await.map_err(RyxError::Database)?;
+ let ids: Vec = rows
+ .iter()
+ .filter_map(|r| r.try_get::(0).ok())
+ .collect();
+ let last_insert_id = ids.first().cloned();
+ Ok(MutationResult {
+ rows_affected: rows.len() as u64,
+ last_insert_id,
+ returned_ids: Some(ids),
+ })
+ } else {
+ let res = q.execute(&self.pool).await.map_err(RyxError::Database)?;
+ Ok(MutationResult {
+ rows_affected: res.rows_affected(),
+ last_insert_id: Some(res.last_insert_id() as i64),
+ returned_ids: None,
+ })
+ }
+ }
+
+ /// Bulk delete by primary key values in one shot.
+ #[instrument(skip(table, pk_col, pks, self))]
+ async fn bulk_delete(
+ &self,
+ table: String,
+ pk_col: String,
+ pks: Vec,
+ db_alias: Option,
+ ) -> RyxResult {
+ if pks.is_empty() {
+ return Ok(MutationResult {
+ rows_affected: 0,
+ last_insert_id: None,
+ returned_ids: None,
+ });
+ }
+
+ let ph = (0..pks.len())
+ .map(|_| "?".to_string())
+ .collect::>()
+ .join(", ");
+
+ let sql = format!("DELETE FROM \"{}\" WHERE \"{}\" IN ({})", table, pk_col, ph);
+ debug!(
+ target: "ryx::bulk_delete",
+ db_alias = db_alias.as_deref().unwrap_or("default"),
+ params = pks.len(),
+ sql_len = sql.len(),
+ "bulk_delete compiled"
+ );
+ let mut q = sqlx::query::(&sql);
+ q = self.bind_values(q, &pks);
+ let res = q.execute(&self.pool).await.map_err(RyxError::Database)?;
+ Ok(MutationResult {
+ rows_affected: res.rows_affected(),
+ last_insert_id: None,
+ returned_ids: None,
+ })
+ }
+
+ /// Bulk update using CASE WHEN, values already mapped to SqlValue.
+ #[instrument(skip(table, pk_col, col_names, field_values, pks, self))]
+ async fn bulk_update(
+ &self,
+ table: String,
+ pk_col: String,
+ col_names: Vec,
+ field_values: Vec>,
+ pks: Vec,
+ db_alias: Option,
+ ) -> RyxResult {
+ // let pool = pool::get(db_alias.as_deref())?;
+ // let backend = pool::get_backend(db_alias.as_deref())?;
+ let n = pks.len();
+ let f = field_values.len();
+ if n == 0 || f == 0 {
+ return Ok(MutationResult {
+ rows_affected: 0,
+ last_insert_id: None,
+ returned_ids: None,
+ });
+ }
+
+ let mut case_clauses = Vec::with_capacity(f);
+ let mut all_values: SmallVec<[SqlValue; 8]> = SmallVec::with_capacity(n * f * 2 + n);
+
+ // Build CASE clauses with placeholders.
+ for (fi, col_name) in col_names.iter().enumerate() {
+ let mut case_parts = Vec::with_capacity(n * 3 + 2);
+ case_parts.push(format!("\"{}\" = CASE \"{}\"", col_name, pk_col));
+
+ for i in 0..n {
+ let when_ph = "?".to_string();
+
+ let then_ph = "?".to_string();
+
+ case_parts.push(format!("WHEN {} THEN {}", when_ph, then_ph));
+ all_values.push(pks[i].clone());
+ all_values.push(field_values[fi][i].clone());
+ }
+ case_parts.push("END".to_string());
+ case_clauses.push(case_parts.join(" "));
+ }
+
+ let pk_placeholders: Vec = (0..n).map(|_| "?".to_string()).collect();
+
+ for pk in &pks {
+ all_values.push(pk.clone());
+ }
+
+ let sql = format!(
+ "UPDATE \"{}\" SET {} WHERE \"{}\" IN ({})",
+ table,
+ case_clauses.join(", "),
+ pk_col,
+ pk_placeholders.join(", ")
+ );
+
+ debug!(
+ target: "ryx::bulk_update",
+ db_alias = db_alias.as_deref().unwrap_or("default"),
+ rows = n,
+ cols = f,
+ sql_len = sql.len(),
+ params = all_values.len(),
+ "bulk_update compiled"
+ );
+
+ let mut q = sqlx::query(&sql);
+ q = self.bind_values(q, &all_values);
+ let res = q.execute(&self.pool).await.map_err(RyxError::Database)?;
+ Ok(MutationResult {
+ rows_affected: res.rows_affected(),
+ last_insert_id: None,
+ returned_ids: None,
+ })
+ }
+
+ /// Execute raw SQL without bind params.
+ #[instrument(skip(sql, self))]
+ async fn execute_raw(&self, sql: String, _db_alias: Option) -> RyxResult<()> {
+ // let pool = pool::get(db_alias.as_deref())?;
+ sqlx::query(&sql)
+ .execute(&self.pool)
+ .await
+ .map_err(RyxError::Database)?;
+ Ok(())
+ }
+
+ fn pool_stats(&self) -> PoolStats {
+ PoolStats {
+ size: self.pool.size(),
+ idle: self.pool.num_idle() as u32,
+ }
+ }
+
+ fn get_pool(&self) -> RyxPool {
+ // We wrap the MySqlPool in our pooled enum to allow returning a reference to it.
+ // This is necessary because the RyxBackend trait needs to return a reference to a generic pool type.
+ // In a more complex implementation, we might have a more sophisticated way to manage multiple pools and backends.
+ RyxPool::MySQL(self.pool.clone())
+ }
+}
diff --git a/ryx-backend/src/backends/postgres.rs b/ryx-backend/src/backends/postgres.rs
new file mode 100644
index 0000000..f6ff397
--- /dev/null
+++ b/ryx-backend/src/backends/postgres.rs
@@ -0,0 +1,794 @@
+// Postgres Backend for Ryx Query Compiler
+
+use smallvec::SmallVec;
+use sqlx::{
+ Column, Row,
+ postgres::{PgPool, PgPoolOptions},
+};
+
+use ryx_core::{
+ errors::{RyxError, RyxResult},
+ model_registry,
+};
+use ryx_query::ast::{QueryNode, SqlValue};
+use ryx_query::compiler::{CompiledQuery, compile};
+
+use super::{DecodedRow, MutationResult, RyxBackend};
+use crate::pool::{PoolConfig, PoolStats, RyxPool};
+use crate::transaction::get_current_transaction;
+use crate::utils::{decode_row, decode_rows, is_date, is_timestamp};
+
+use tracing::{debug, instrument};
+
+pub struct PostgresBackend {
+ // The connection pool for Postgres
+ pool: PgPool,
+}
+
+impl PostgresBackend {
+ /// Create a new PostgresBackend with a connection pool based on the provided config.
+ /// Uses `sqlx::PgPool` under the hood.
+ /// Usage:
+ /// ```
+ /// let config = PoolConfig {
+ /// url: "postgres://user:password@localhost/db".to_string(),
+ /// max_connections: 10,
+ /// min_connections: 1,
+ /// connect_timeout_secs: 5,
+ /// idle_timeout_secs: 300,
+ /// max_lifetime_secs: 1800,
+ /// };
+ /// let backend = PostgresBackend::new(config, url).await;
+ /// ```
+ pub async fn new(config: PoolConfig, url: String) -> Self {
+ // Create a new Postgres connection pool using the provided config
+ let pool = PgPoolOptions::new()
+ .max_connections(config.max_connections)
+ .min_connections(config.min_connections)
+ .acquire_timeout(std::time::Duration::from_secs(config.connect_timeout_secs))
+ .idle_timeout(std::time::Duration::from_secs(config.idle_timeout_secs))
+ .max_lifetime(std::time::Duration::from_secs(config.max_lifetime_secs))
+ .connect(&url)
+ .await
+ .expect("Failed to create Postgres connection pool");
+ Self { pool }
+ }
+
+ /// Begin a new transaction by acquiring a connection from the pool.
+ /// Usage:
+ /// ```
+ /// let tx = backend.begin().await.unwrap();
+ /// ```
+ pub async fn begin(&self) -> RyxResult> {
+ self.pool.begin().await.map_err(RyxError::Database)
+ }
+
+ /// Bind all `SqlValue`s to a sqlx query in order.
+ ///
+ /// sqlx's `.bind()` takes ownership and returns a new query, so we chain
+ /// calls with a mutable variable rather than a functional fold to keep the
+ /// code readable.
+ fn bind_values<'q>(
+ &self,
+ mut q: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
+ values: &'q [SqlValue],
+ ) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> {
+ for value in values {
+ q = match value {
+ SqlValue::Null => q.bind(None::),
+ 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()),
+ // Lists should have been expanded by the compiler into individual
+ // placeholders. If we encounter a List here it's a compiler bug.
+ SqlValue::List(_) => {
+ // This is a defensive no-op — the compiler should have expanded
+ // lists already. We log a warning and skip.
+ tracing::warn!(
+ "Unexpected List value reached executor — this is a compiler bug"
+ );
+ q
+ }
+ };
+ }
+ q
+ }
+
+ /// Rewrite generic `?` placeholders to PostgreSQL-style `$1, $2, ...` when needed.
+ pub fn normalize_sql(&self, query: &CompiledQuery) -> String {
+ // Fast path: rewrite ? -> $n and append type casts when we know the
+ // column -> field type mapping.
+ let mut out = String::with_capacity(query.sql.len() + 8);
+ let mut idx = 0usize;
+
+ for ch in query.sql.chars() {
+ if ch == '?' {
+ idx += 1;
+ out.push('$');
+ out.push_str(&idx.to_string());
+
+ // Attach an explicit PostgreSQL cast when we know the field type.
+ if let Some(cast) = self.placeholder_cast(idx - 1, query) {
+ out.push_str(cast);
+ }
+ } else {
+ out.push(ch);
+ }
+ }
+ out
+ }
+
+ /// Decide which cast (if any) to append for a placeholder at `idx`.
+ ///
+ /// We only cast INSERT/UPDATE assignment parameters where we know the exact
+ /// column names; all other placeholders fall back to a lightweight heuristic
+ /// so we preserve previous behaviour for filters.
+ pub fn placeholder_cast(&self, idx: usize, query: &CompiledQuery) -> Option<&'static str> {
+ // If we have column names (INSERT or UPDATE) and a base table, look up the
+ // 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);
+ }
+ }
+ }
+
+ // Fallback heuristic (for WHERE values) to avoid regressions.
+ query.values.get(idx).and_then(|v| match v {
+ SqlValue::Text(s) if is_date(s) => Some("::date"),
+ SqlValue::Text(s) if is_timestamp(s) => Some("::timestamp"),
+ _ => 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 {
+ "DateField" => Some("::date"),
+ "DateTimeField" | "DateTimeTzField" | "DateTimeTZField" => Some("::timestamp"),
+ "TimeField" => Some("::time"),
+ "JSONField" => Some("::jsonb"),
+ // "UUIDField" => Some("::uuid"),
+ "AutoField" | "BigAutoField" | "SmallAutoField" => Some("::serial"),
+ _ => None,
+ }
+ }
+
+ /// Render a backend-specific placeholder (with cast for Postgres).
+ fn render_placeholder(&self, idx: usize, cast: Option<&'static str>) -> String {
+ let mut s = String::new();
+ s.push('$');
+ s.push_str(&(idx + 1).to_string());
+ if let Some(c) = cast {
+ s.push_str(c);
+ }
+ s
+ }
+}
+
+#[async_trait::async_trait]
+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:
+ /// ```
+ /// let query = CompiledQuery {
+ /// sql: "SELECT id, name FROM users WHERE age > $1".to_string(),
+ /// values: vec![SqlValue::Int(30)],
+ /// };
+ /// let rows = backend.__fetch_all(query).await.unwrap();
+ /// for row in rows {
+ /// println!("User ID: {}, Name: {}", row.get("id").unwrap(), row.get("name").unwrap());
+ /// }
+ /// ```
+ async fn __fetch_all(&self, query: CompiledQuery) -> RyxResult> {
+ let sql = self.normalize_sql(&query);
+ let mut q = sqlx::query(&sql);
+ // Bind parameters to the quer
+ q = self.bind_values(q, &query.values);
+ // Execute the query and return the results
+ let rows = q.fetch_all(&self.pool).await.map_err(RyxError::Database)?;
+
+ Ok(decode_rows(&rows, query.base_table.as_deref()))
+ }
+
+ /// 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:
+ /// ```
+ /// let query = CompiledQuery {
+ /// sql: "SELECT id, name FROM users WHERE id = $1".to_string(),
+ /// values: vec![SqlValue::Int(42)],
+ /// };
+ /// let row = backend.__fetch_one(query).await.unwrap();
+ /// println!("User ID: {}, Name: {}", row.get("id").unwrap(), row.get("name").unwrap());
+ /// ```
+ async fn __fetch_one(&self, query: CompiledQuery) -> RyxResult {
+ let mut q = sqlx::query(&query.sql);
+ // Bind parameters to the query
+ q = self.bind_values(q, &query.values);
+ // Execute the query and return the result
+ let row = q.fetch_one(&self.pool).await.map_err(RyxError::Database)?;
+ let mapping = std::sync::Arc::new(crate::backends::RowMapping {
+ columns: row.columns().iter().map(|c| c.name().to_string()).collect(),
+ });
+
+ // Decode the single row into a DecodedRow and return it
+ Ok(decode_row(&row, &mapping, query.base_table.as_deref()))
+ }
+
+ /// 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:
+ /// ```
+ /// let query = CompiledQuery {
+ /// sql: "UPDATE users SET active = false WHERE last_login < $1".to_string(),
+ /// values: vec![SqlValue::Text("2024-01-01".to_string())],
+ /// };
+ /// let result = backend.__execute(query).await.unwrap();
+ /// println!("Number of users deactivated: {}", result.rows_affected);
+ /// ```
+ async fn fetch_all(&self, query: CompiledQuery) -> RyxResult> {
+ if let Some(tx) = get_current_transaction() {
+ let tx_guard = tx.lock().await;
+ if let Some(active_tx) = tx_guard.as_ref() {
+ return active_tx.fetch_query(query).await;
+ }
+ return Err(RyxError::Internal("Transaction is no longer active".into()));
+ }
+
+ // let pool = pool::get(query.db_alias.as_deref())?.as_any();
+ debug!(sql = %query.sql, "Executing SELECT");
+
+ // let sql = self.normalize_sql(&query);
+ // let mut q = sqlx::query::(&sql);
+ // q = self.bind_values(q, &query.values);
+
+ // let rows = q.fetch_all(&self.pool).await.map_err(RyxError::Database)?;
+ let rows: Vec = self.__fetch_all(query).await?;
+
+ // let decoded = decode_rows(&rows, query.base_table.as_deref());
+ Ok(rows)
+ }
+
+ /// 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:
+ /// ```
+ /// 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 {
+ /// println!("User ID: {}, Name: {}", row.get("id").unwrap(), row.get("name").unwrap());
+ /// }
+ /// ```
+ async fn fetch_raw(
+ &self,
+ sql: String,
+ _db_alias: Option,
+ ) -> RyxResult> {
+ let rows = sqlx::query::(&sql)
+ .fetch_all(&self.pool)
+ .await
+ .map_err(RyxError::Database)?;
+ Ok(decode_rows(&rows, None))
+ }
+
+ /// 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:
+ /// ```
+ /// let node = QueryNode::Select { ... }; // Construct a QueryNode representing the query
+ /// let rows = backend.fetch_all_compiled(node).await.unwrap();
+ /// for row in rows {
+ /// println!("User ID: {}, Name: {}", row.get("id").unwrap(), row.get("name").unwrap());
+ /// }
+ /// ```
+ async fn fetch_all_compiled(&self, node: QueryNode) -> RyxResult> {
+ let compiled = compile(&node).map_err(RyxError::from)?;
+ self.__fetch_all(compiled).await
+ }
+
+ /// Execute a SELECT COUNT(*) query and return the count.
+ ///
+ /// # Errors
+ /// Same as [`fetch_all`].
+ #[instrument(skip(query, self), fields(sql = %query.sql))]
+ async fn fetch_count(&self, query: CompiledQuery) -> RyxResult {
+ if let Some(tx) = get_current_transaction() {
+ let tx_guard = tx.lock().await;
+ if let Some(active_tx) = tx_guard.as_ref() {
+ let rows = active_tx.fetch_query(query).await?;
+ if rows.is_empty() {
+ return Ok(0);
+ }
+ if let Some(value) = rows[0].values.first() {
+ match value {
+ SqlValue::Int(i) => return Ok(*i),
+ SqlValue::Float(f) => return Ok(*f as i64),
+ _ => {}
+ }
+ }
+ return Err(RyxError::Internal(
+ "COUNT() returned unexpected value".into(),
+ ));
+ }
+ return Err(RyxError::Internal("Transaction is no longer active".into()));
+ }
+
+ // let pool = pool::get(query.db_alias.as_deref())?.as_any();
+
+ debug!(sql = %query.sql, "Executing COUNT");
+
+ let mut q = sqlx::query::(&query.sql);
+ q = self.bind_values(q, &query.values);
+
+ let row = q.fetch_one(&self.pool).await.map_err(RyxError::Database)?;
+
+ let count: i64 = row.try_get(0).unwrap_or_else(|_| {
+ let n: i32 = row.try_get(0).unwrap_or(0);
+ n as i64
+ });
+
+ Ok(count)
+ }
+
+ /// Execute a COUNT query represented as a QueryNode and return the count.
+ /// This is a convenience method that compiles the QueryNode and then executes it using fetch_count.
+ /// # Errors
+ /// Same as [`fetch_count`].
+ #[instrument(skip(node, self))]
+ async fn fetch_count_compiled(&self, node: QueryNode) -> RyxResult {
+ let compiled = compile(&node).map_err(RyxError::from)?;
+ self.fetch_count(compiled).await
+ }
+
+ /// Execute a SELECT and return at most one row.
+ ///
+ /// # Errors
+ /// - [`RyxError::DoesNotExist`] if no rows are found
+ /// - [`RyxError::MultipleObjectsReturned`] if more than one row is found
+ ///
+ /// This mirrors Django's `.get()` semantics exactly.
+ #[instrument(skip(query, self), fields(sql = %query.sql))]
+ async fn fetch_one(&self, query: CompiledQuery) -> RyxResult {
+ // We intentionally fetch up to 2 rows to detect MultipleObjectsReturned
+ // without fetching the entire result set. This is more efficient than
+ // `fetch_all` when the user calls `.get()` on a large table.
+ if let Some(tx) = get_current_transaction() {
+ let tx_guard = tx.lock().await;
+ if let Some(active_tx) = tx_guard.as_ref() {
+ let rows = active_tx.fetch_query(query).await?;
+ match rows.len() {
+ 0 => Err(RyxError::DoesNotExist),
+ 1 => Ok(rows.into_iter().next().unwrap()),
+ _ => Err(RyxError::MultipleObjectsReturned),
+ }
+ } else {
+ Err(RyxError::Internal("Transaction is no longer active".into()))
+ }
+ } else {
+ // let pool = pool::get(query.db_alias.as_deref())?.as_any();
+
+ let sql = self.normalize_sql(&query);
+ let mut q = sqlx::query::(&sql);
+ q = self.bind_values(q, &query.values);
+
+ // Limit to 2 at the executor level (the QueryNode may already have
+ // LIMIT 1 set by `.first()`, but for `.get()` it doesn't).
+ // We check the count in Rust rather than adding SQL complexity.
+ let rows = q.fetch_all(&self.pool).await.map_err(RyxError::Database)?;
+ // self.__fetch_all(query).await?;
+ //q.fetch_all(&*pool).await.map_err(RyxError::Database)?;
+
+ let mapping = if rows.is_empty() {
+ None
+ } else {
+ Some(std::sync::Arc::new(crate::backends::RowMapping {
+ columns: rows[0]
+ .columns()
+ .iter()
+ .map(|c| c.name().to_string())
+ .collect(),
+ }))
+ };
+
+ match rows.len() {
+ 0 => Err(RyxError::DoesNotExist),
+ 1 => Ok(decode_row(
+ &rows[0],
+ mapping.as_ref().unwrap(),
+ query.base_table.as_deref(),
+ )),
+ _ => Err(RyxError::MultipleObjectsReturned),
+ }
+ }
+ }
+
+ /// Execute a SELECT represented as a QueryNode and return at most one row.
+ /// This is a convenience method that compiles the QueryNode and then executes it using fetch_one.
+ /// # Errors
+ /// - [`RyxError::DoesNotExist`] if no rows are found
+ /// - [`RyxError::MultipleObjectsReturned`] if more than one row is found
+ #[instrument(skip(node, self))]
+ async fn fetch_one_compiled(&self, node: QueryNode) -> RyxResult {
+ let compiled = compile(&node).map_err(RyxError::from)?;
+ self.fetch_one(compiled).await
+ }
+
+ /// Execute an INSERT, UPDATE, or DELETE query.
+ ///
+ /// For INSERT queries with `RETURNING` clause, this fetches the returned
+ /// value and populates `last_insert_id`.
+ ///
+ /// # Errors
+ /// - [`RyxError::PoolNotInitialized`]
+ /// - [`RyxError::Database`]
+ #[instrument(skip(query, self), fields(sql = %query.sql))]
+ async fn execute(&self, query: CompiledQuery) -> RyxResult {
+ // Check if we're in a transaction and execute there if so,
+ // to ensure we stay on the same connection.
+ if let Some(tx) = get_current_transaction() {
+ let tx_guard = tx.lock().await;
+ if let Some(active_tx) = tx_guard.as_ref() {
+ // Check if this is a RETURNING query
+ if query.sql.to_uppercase().contains("RETURNING") {
+ let rows = active_tx.fetch_query(query).await?;
+ let last_insert_id = rows.first().and_then(|row| {
+ row.values.first().and_then(|v| match v {
+ SqlValue::Int(i) => Some(*i),
+ SqlValue::Float(f) => Some(*f as i64),
+ _ => None,
+ })
+ });
+ return Ok(MutationResult {
+ rows_affected: 1,
+ last_insert_id,
+ returned_ids: Some(
+ rows.iter()
+ .filter_map(|row| {
+ row.values.first().and_then(|v| match v {
+ SqlValue::Int(i) => Some(*i),
+ SqlValue::Float(f) => Some(*f as i64),
+ _ => None,
+ })
+ })
+ .collect(),
+ ),
+ });
+ }
+ let rows_affected = active_tx.execute_query(query).await?;
+ return Ok(MutationResult {
+ rows_affected,
+ last_insert_id: None,
+ returned_ids: None,
+ });
+ }
+ return Err(RyxError::Internal("Transaction is no longer active".into()));
+ }
+
+ // let pool = pool::get(query.db_alias.as_deref())?.as_any();
+
+ debug!(sql = %query.sql, "Executing mutation");
+
+ // Check if this is a RETURNING query (e.g. INSERT ... RETURNING id)
+ let sql = self.normalize_sql(&query);
+ if sql.to_uppercase().contains("RETURNING") {
+ let mut q = sqlx::query::(&sql);
+ q = self.bind_values(q, &query.values);
+
+ let rows = q
+ .fetch_all(&self.pool)
+ .await
+ .map_err(|e| RyxError::DatabaseWithSql(sql.clone(), e))?;
+
+ let last_insert_id = rows.first().and_then(|row| row.try_get::(0).ok());
+ let returned_ids: Vec = rows
+ .iter()
+ .filter_map(|row| row.try_get::(0).ok())
+ .collect();
+
+ return Ok(MutationResult {
+ rows_affected: rows.len() as u64,
+ last_insert_id,
+ returned_ids: Some(returned_ids),
+ });
+ }
+
+ let mut q = sqlx::query::(&sql);
+ q = self.bind_values(q, &query.values);
+
+ let result = q
+ .execute(&self.pool)
+ .await
+ .map_err(|e| RyxError::DatabaseWithSql(sql.clone(), e))?;
+
+ Ok(MutationResult {
+ rows_affected: result.rows_affected(),
+ last_insert_id: None,
+ returned_ids: None,
+ })
+ }
+
+ /// Execute QueryNode
+ #[instrument(skip(node, self))]
+ async fn execute_compiled(&self, node: QueryNode) -> RyxResult {
+ let compiled = compile(&node).map_err(RyxError::from)?;
+ self.execute(compiled).await
+ }
+
+ /// Bulk insert rows with values already mapped to SqlValue in one shot.
+ /// This is used for efficient bulk inserts, especially when the data is already in memory and we want to avoid multiple round-trips to the database.
+ /// The `returning_id` flag indicates whether to return the last inserted ID(s), which is useful for auto-increment primary keys.
+ /// The `ignore_conflicts` flag allows the caller to specify whether to ignore conflicts (e.g. duplicate keys) during insertion, which can be useful for upsert-like behavior.
+ /// # Errors
+ /// - [`RyxError::PoolNotInitialized`]
+ /// - [`RyxError::Database`]
+ async fn bulk_insert(
+ &self,
+ table: String,
+ columns: Vec,
+ rows: Vec>,
+ returning_id: bool,
+ ignore_conflicts: bool,
+ _db_alias: Option,
+ ) -> RyxResult {
+ if rows.is_empty() {
+ return Ok(MutationResult {
+ rows_affected: 0,
+ last_insert_id: None,
+ returned_ids: None,
+ });
+ }
+ // let pool = pool::get(db_alias.as_deref())?.as_any();
+ // let backend = pool::get_backend(db_alias.as_deref())?;
+
+ let col_list = columns
+ .iter()
+ .map(|c| format!("\"{}\"", c))
+ .collect::>()
+ .join(", ");
+
+ // 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 raw = format!("${}{}", idx + 1, cast.unwrap_or(""));
+ placeholders.push(raw);
+ }
+
+ // For PostgreSQL we must bump placeholder numbers per row.
+ let mut values_sql_parts = Vec::with_capacity(rows.len());
+
+ let mut start_idx = 1;
+ for _ in 0..rows.len() {
+ let mut row_parts: Vec = Vec::with_capacity(columns.len());
+ for (local_i, ph) in placeholders.iter().enumerate() {
+ // Replace the `$1` with the correct global index.
+ let cast = ph.split_once("::").map(|(_, c)| c);
+ let expr = match cast {
+ Some(c) => format!("${}::{}", start_idx + local_i, c),
+ None => format!("${}", start_idx + local_i),
+ };
+ row_parts.push(expr);
+ }
+ start_idx += columns.len();
+ values_sql_parts.push(format!("({})", row_parts.join(", ")));
+ }
+
+ let values_sql = values_sql_parts.join(", ");
+
+ let mut flat: SmallVec<[SqlValue; 8]> = SmallVec::new();
+ for row in rows {
+ for v in row {
+ flat.push(v);
+ }
+ }
+
+ // On confilct
+ let (insert_kw, conflict_suffix) = if ignore_conflicts {
+ ("INSERT INTO", " ON CONFLICT DO NOTHING")
+ } else {
+ ("INSERT INTO", "")
+ };
+
+ let sql = format!(
+ "{} \"{}\" ({}) VALUES {}{}{}",
+ insert_kw,
+ table,
+ col_list,
+ values_sql,
+ conflict_suffix,
+ if returning_id { " RETURNING id" } else { "" }
+ );
+
+ let mut q = sqlx::query::(&sql);
+ q = self.bind_values(q, &flat);
+ if returning_id {
+ let rows = q.fetch_all(&self.pool).await.map_err(RyxError::Database)?;
+ let ids: Vec = rows
+ .iter()
+ .filter_map(|r| r.try_get::(0).ok())
+ .collect();
+ let last_insert_id = ids.first().cloned();
+ Ok(MutationResult {
+ rows_affected: rows.len() as u64,
+ last_insert_id,
+ returned_ids: Some(ids),
+ })
+ } else {
+ let res = q.execute(&self.pool).await.map_err(RyxError::Database)?;
+ Ok(MutationResult {
+ rows_affected: res.rows_affected(),
+ last_insert_id: None,
+ returned_ids: None,
+ })
+ }
+ }
+
+ /// Bulk delete by primary key values in one shot.
+ #[instrument(skip(table, pk_col, pks, self))]
+ async fn bulk_delete(
+ &self,
+ table: String,
+ pk_col: String,
+ pks: Vec,
+ db_alias: Option,
+ ) -> RyxResult {
+ if pks.is_empty() {
+ return Ok(MutationResult {
+ rows_affected: 0,
+ last_insert_id: None,
+ returned_ids: None,
+ });
+ }
+
+ let pk_cast = model_registry::lookup_field(&table, &pk_col)
+ .and_then(|s| self.postgres_cast_for_type(&s.data_type));
+
+ let mut param_idx = 0usize;
+ let ph = (0..pks.len())
+ .map(|_| {
+ let ph = self.render_placeholder(param_idx, pk_cast);
+ param_idx += 1;
+ ph
+ })
+ .collect::>()
+ .join(", ");
+
+ let sql = format!("DELETE FROM \"{}\" WHERE \"{}\" IN ({})", table, pk_col, ph);
+ debug!(
+ target: "ryx::bulk_delete",
+ db_alias = db_alias.as_deref().unwrap_or("default"),
+ params = pks.len(),
+ sql_len = sql.len(),
+ "bulk_delete compiled"
+ );
+ let mut q = sqlx::query::(&sql);
+ q = self.bind_values(q, &pks);
+ let res = q.execute(&self.pool).await.map_err(RyxError::Database)?;
+ Ok(MutationResult {
+ rows_affected: res.rows_affected(),
+ last_insert_id: None,
+ returned_ids: None,
+ })
+ }
+
+ /// Bulk update using CASE WHEN, values already mapped to SqlValue.
+ #[instrument(skip(table, pk_col, col_names, field_values, pks, self))]
+ async fn bulk_update(
+ &self,
+ table: String,
+ pk_col: String,
+ col_names: Vec,
+ field_values: Vec>,
+ pks: Vec,
+ db_alias: Option,
+ ) -> RyxResult {
+ // let pool = pool::get(db_alias.as_deref())?;
+ // let backend = pool::get_backend(db_alias.as_deref())?;
+ let n = pks.len();
+ let f = field_values.len();
+ if n == 0 || f == 0 {
+ return Ok(MutationResult {
+ rows_affected: 0,
+ last_insert_id: None,
+ returned_ids: None,
+ });
+ }
+
+ 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));
+
+ // 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 mut case_parts = Vec::with_capacity(n * 3 + 2);
+ case_parts.push(format!("\"{}\" = CASE \"{}\"", col_name, pk_col));
+
+ for i in 0..n {
+ let when_ph = self.render_placeholder(param_idx, pk_cast);
+ param_idx += 1;
+ let then_ph = self.render_placeholder(param_idx, value_cast);
+ param_idx += 1;
+
+ case_parts.push(format!("WHEN {} THEN {}", when_ph, then_ph));
+ all_values.push(pks[i].clone());
+ all_values.push(field_values[fi][i].clone());
+ }
+ case_parts.push("END".to_string());
+ case_clauses.push(case_parts.join(" "));
+ }
+
+ let pk_placeholders: Vec = (0..n)
+ .map(|_| {
+ let ph = self.render_placeholder(param_idx, pk_cast);
+ param_idx += 1;
+ ph
+ })
+ .collect();
+
+ for pk in &pks {
+ all_values.push(pk.clone());
+ }
+
+ let sql = format!(
+ "UPDATE \"{}\" SET {} WHERE \"{}\" IN ({})",
+ table,
+ case_clauses.join(", "),
+ pk_col,
+ pk_placeholders.join(", ")
+ );
+
+ debug!(
+ target: "ryx::bulk_update",
+ db_alias = db_alias.as_deref().unwrap_or("default"),
+ rows = n,
+ cols = f,
+ sql_len = sql.len(),
+ params = all_values.len(),
+ "bulk_update compiled"
+ );
+
+ let mut q = sqlx::query(&sql);
+ q = self.bind_values(q, &all_values);
+ let res = q.execute(&self.pool).await.map_err(RyxError::Database)?;
+ Ok(MutationResult {
+ rows_affected: res.rows_affected(),
+ last_insert_id: None,
+ returned_ids: None,
+ })
+ }
+
+ /// Execute raw SQL without bind params.
+ #[instrument(skip(sql, self))]
+ async fn execute_raw(&self, sql: String, _db_alias: Option) -> RyxResult<()> {
+ // let pool = pool::get(db_alias.as_deref())?;
+ sqlx::query(&sql)
+ .execute(&self.pool)
+ .await
+ .map_err(RyxError::Database)?;
+ Ok(())
+ }
+
+ fn pool_stats(&self) -> PoolStats {
+ PoolStats {
+ size: self.pool.size(),
+ idle: self.pool.num_idle() as u32,
+ }
+ }
+
+ fn get_pool(&self) -> RyxPool {
+ RyxPool::Postgres(self.pool.clone())
+ }
+}
diff --git a/ryx-backend/src/backends/sqlite.rs b/ryx-backend/src/backends/sqlite.rs
new file mode 100644
index 0000000..15e7f25
--- /dev/null
+++ b/ryx-backend/src/backends/sqlite.rs
@@ -0,0 +1,707 @@
+// Sqlite Backend for Ryx Query Compiler
+
+use smallvec::SmallVec;
+use sqlx::{
+ Column, Row,
+ sqlite::{SqlitePool, SqlitePoolOptions},
+};
+
+use ryx_core::errors::{RyxError, RyxResult};
+use ryx_query::ast::{QueryNode, SqlValue};
+use ryx_query::compiler::{CompiledQuery, compile};
+
+use super::{DecodedRow, MutationResult, RyxBackend};
+use crate::pool::{PoolConfig, PoolStats, RyxPool};
+use crate::transaction::get_current_transaction;
+use crate::utils::{decode_row, decode_rows, is_date, is_timestamp};
+
+use tracing::{debug, instrument};
+
+pub struct SqliteBackend {
+ // The connection pool for Sqlite
+ pool: SqlitePool,
+}
+
+impl SqliteBackend {
+ /// Create a new SqliteBackend with a connection pool based on the provided config.
+ /// Uses `sqlx::SqlitePool` under the hood.
+ /// Usage:
+ /// ```
+ /// let config = PoolConfig {
+ /// url: "sqlite:///path/to/database.db".to_string(),
+ /// max_connections: 10,
+ /// min_connections: 1,
+ /// connect_timeout_secs: 5,
+ /// idle_timeout_secs: 300,
+ /// max_lifetime_secs: 1800,
+ /// };
+ /// let backend = SqliteBackend::new(config, url).await;
+ /// ```
+ pub async fn new(config: PoolConfig, url: String) -> Self {
+ // Create a new Sqlite connection pool using the provided config
+ let pool = SqlitePoolOptions::new()
+ .max_connections(config.max_connections)
+ .min_connections(config.min_connections)
+ .acquire_timeout(std::time::Duration::from_secs(config.connect_timeout_secs))
+ .idle_timeout(std::time::Duration::from_secs(config.idle_timeout_secs))
+ .max_lifetime(std::time::Duration::from_secs(config.max_lifetime_secs))
+ .connect(&url)
+ .await
+ .expect("Failed to create Sqlite connection pool");
+ Self { pool }
+ }
+
+ /// Begin a new transaction by acquiring a connection from the pool.
+ /// Usage:
+ /// ```
+ /// let tx = backend.begin().await.unwrap();
+ ///
+ pub async fn begin(&self) -> RyxResult> {
+ self.pool.begin().await.map_err(RyxError::Database)
+ }
+
+ /// Bind all `SqlValue`s to a sqlx query in order.
+ ///
+ /// sqlx's `.bind()` takes ownership and returns a new query, so we chain
+ /// calls with a mutable variable rather than a functional fold to keep the
+ /// code readable.
+ fn bind_values<'q>(
+ &self,
+ mut q: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>,
+ values: &'q [SqlValue],
+ ) -> sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>> {
+ for value in values {
+ q = match value {
+ SqlValue::Null => q.bind(None::),
+ 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()),
+ // Lists should have been expanded by the compiler into individual
+ // placeholders. If we encounter a List here it's a compiler bug.
+ SqlValue::List(_) => {
+ // This is a defensive no-op — the compiler should have expanded
+ // lists already. We log a warning and skip.
+ tracing::warn!(
+ "Unexpected List value reached executor — this is a compiler bug"
+ );
+ q
+ }
+ };
+ }
+ q
+ }
+
+ /// Rewrite generic `?` placeholders to PostgreSQL-style `$1, $2, ...` when needed.
+ pub fn normalize_sql(&self, query: &CompiledQuery) -> String {
+ // Fast path: rewrite ? -> $n and append type casts when we know the
+ // column -> field type mapping.
+ let mut out = String::with_capacity(query.sql.len() + 8);
+ let mut idx = 0usize;
+
+ for ch in query.sql.chars() {
+ if ch == '?' {
+ idx += 1;
+ out.push('$');
+ out.push_str(&idx.to_string());
+ } else {
+ out.push(ch);
+ }
+ }
+ out
+ }
+}
+
+#[async_trait::async_trait]
+impl RyxBackend for SqliteBackend {
+ /// 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:
+ /// ```
+ /// let query = CompiledQuery {
+ /// sql: "SELECT id, name FROM users WHERE age > $1".to_string(),
+ /// values: vec![SqlValue::Int(30)],
+ /// };
+ /// let rows = backend.__fetch_all(query).await.unwrap();
+ /// for row in rows {
+ /// println!("User ID: {}, Name: {}", row.get("id").unwrap(), row.get("name").unwrap());
+ /// }
+ /// ```
+ async fn __fetch_all(&self, query: CompiledQuery) -> RyxResult> {
+ let sql = self.normalize_sql(&query);
+ let mut q = sqlx::query::(&sql);
+ // Bind parameters to the quer
+ q = self.bind_values(q, &query.values);
+ // Execute the query and return the results
+ let rows = q.fetch_all(&self.pool).await.map_err(RyxError::Database)?;
+
+ Ok(decode_rows(&rows, query.base_table.as_deref()))
+ }
+
+ /// 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:
+ /// ```
+ /// let query = CompiledQuery {
+ /// sql: "SELECT id, name FROM users WHERE id = $1".to_string(),
+ /// values: vec![SqlValue::Int(42)],
+ /// };
+ /// let row = backend.__fetch_one(query).await.unwrap();
+ /// println!("User ID: {}, Name: {}", row.get("id").unwrap(), row.get("name").unwrap());
+ /// ```
+ async fn __fetch_one(&self, query: CompiledQuery) -> RyxResult {
+ let mut q = sqlx::query::(&query.sql);
+ // Bind parameters to the query
+ q = self.bind_values(q, &query.values);
+ // Execute the query and return the result
+ let row = q.fetch_one(&self.pool).await.map_err(RyxError::Database)?;
+ let mapping = std::sync::Arc::new(crate::backends::RowMapping {
+ columns: row.columns().iter().map(|c| c.name().to_string()).collect(),
+ });
+
+ // Decode the single row into a DecodedRow and return it
+ Ok(decode_row(&row, &mapping, query.base_table.as_deref()))
+ }
+
+ /// 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:
+ /// ```
+ /// let query = CompiledQuery {
+ /// sql: "UPDATE users SET active = false WHERE last_login < $1".to_string(),
+ /// values: vec![SqlValue::Text("2024-01-01".to_string())],
+ /// };
+ /// let result = backend.__execute(query).await.unwrap();
+ /// println!("Number of users deactivated: {}", result.rows_affected);
+ /// ```
+ async fn fetch_all(&self, query: CompiledQuery) -> RyxResult> {
+ if let Some(tx) = get_current_transaction() {
+ let tx_guard = tx.lock().await;
+ if let Some(active_tx) = tx_guard.as_ref() {
+ return active_tx.fetch_query(query).await;
+ }
+ return Err(RyxError::Internal("Transaction is no longer active".into()));
+ }
+
+ // let pool = pool::get(query.db_alias.as_deref())?.as_any();
+ debug!(sql = %query.sql, "Executing SELECT");
+
+ // let sql = self.normalize_sql(&query);
+ // let mut q = sqlx::query::(&sql);
+ // q = self.bind_values(q, &query.values);
+
+ // let rows = q.fetch_all(&self.pool).await.map_err(RyxError::Database)?;
+ let rows: Vec = self.__fetch_all(query).await?;
+
+ // let decoded = decode_rows(&rows, query.base_table.as_deref());
+ Ok(rows)
+ }
+
+ /// 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:
+ /// ```
+ /// 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 {
+ /// println!("User ID: {}, Name: {}", row.get("id").unwrap(), row.get("name").unwrap());
+ /// }
+ /// ```
+ async fn fetch_raw(
+ &self,
+ sql: String,
+ _db_alias: Option,
+ ) -> RyxResult> {
+ let rows = sqlx::query::(&sql)
+ .fetch_all(&self.pool)
+ .await
+ .map_err(RyxError::Database)?;
+ Ok(decode_rows(&rows, None))
+ }
+
+ /// 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:
+ /// ```
+ /// let node = QueryNode::Select { ... }; // Construct a QueryNode representing the query
+ /// let rows = backend.fetch_all_compiled(node).await.unwrap();
+ /// for row in rows {
+ /// println!("User ID: {}, Name: {}", row.get("id").unwrap(), row.get("name").unwrap());
+ /// }
+ /// ```
+ async fn fetch_all_compiled(&self, node: QueryNode) -> RyxResult> {
+ let compiled = compile(&node).map_err(RyxError::from)?;
+ self.__fetch_all(compiled).await
+ }
+
+ /// Execute a SELECT COUNT(*) query and return the count.
+ ///
+ /// # Errors
+ /// Same as [`fetch_all`].
+ #[instrument(skip(query, self), fields(sql = %query.sql))]
+ async fn fetch_count(&self, query: CompiledQuery) -> RyxResult {
+ if let Some(tx) = get_current_transaction() {
+ let tx_guard = tx.lock().await;
+ if let Some(active_tx) = tx_guard.as_ref() {
+ let rows = active_tx.fetch_query(query).await?;
+ if rows.is_empty() {
+ return Ok(0);
+ }
+ if let Some(value) = rows[0].values.first() {
+ match value {
+ SqlValue::Int(i) => return Ok(*i),
+ SqlValue::Float(f) => return Ok(*f as i64),
+ _ => {}
+ }
+ }
+ return Err(RyxError::Internal(
+ "COUNT() returned unexpected value".into(),
+ ));
+ }
+ return Err(RyxError::Internal("Transaction is no longer active".into()));
+ }
+
+ // let pool = pool::get(query.db_alias.as_deref())?.as_any();
+
+ debug!(sql = %query.sql, "Executing COUNT");
+
+ let mut q = sqlx::query::(&query.sql);
+ q = self.bind_values(q, &query.values);
+
+ let row = q.fetch_one(&self.pool).await.map_err(RyxError::Database)?;
+
+ let count: i64 = row.try_get(0).unwrap_or_else(|_| {
+ let n: i32 = row.try_get(0).unwrap_or(0);
+ n as i64
+ });
+
+ Ok(count)
+ }
+
+ /// Execute a COUNT query represented as a QueryNode and return the count.
+ /// This is a convenience method that compiles the QueryNode and then executes it using fetch_count.
+ /// # Errors
+ /// Same as [`fetch_count`].
+ #[instrument(skip(node, self))]
+ async fn fetch_count_compiled(&self, node: QueryNode) -> RyxResult {
+ let compiled = compile(&node).map_err(RyxError::from)?;
+ self.fetch_count(compiled).await
+ }
+
+ /// Execute a SELECT and return at most one row.
+ ///
+ /// # Errors
+ /// - [`RyxError::DoesNotExist`] if no rows are found
+ /// - [`RyxError::MultipleObjectsReturned`] if more than one row is found
+ ///
+ /// This mirrors Django's `.get()` semantics exactly.
+ #[instrument(skip(query, self), fields(sql = %query.sql))]
+ async fn fetch_one(&self, query: CompiledQuery) -> RyxResult {
+ // We intentionally fetch up to 2 rows to detect MultipleObjectsReturned
+ // without fetching the entire result set. This is more efficient than
+ // `fetch_all` when the user calls `.get()` on a large table.
+ if let Some(tx) = get_current_transaction() {
+ let tx_guard = tx.lock().await;
+ if let Some(active_tx) = tx_guard.as_ref() {
+ let rows = active_tx.fetch_query(query).await?;
+ match rows.len() {
+ 0 => Err(RyxError::DoesNotExist),
+ 1 => Ok(rows.into_iter().next().unwrap()),
+ _ => Err(RyxError::MultipleObjectsReturned),
+ }
+ } else {
+ Err(RyxError::Internal("Transaction is no longer active".into()))
+ }
+ } else {
+ // let pool = pool::get(query.db_alias.as_deref())?.as_any();
+
+ let sql = self.normalize_sql(&query);
+ let mut q = sqlx::query::(&sql);
+ q = self.bind_values(q, &query.values);
+
+ // Limit to 2 at the executor level (the QueryNode may already have
+ // LIMIT 1 set by `.first()`, but for `.get()` it doesn't).
+ // We check the count in Rust rather than adding SQL complexity.
+ let rows = q.fetch_all(&self.pool).await.map_err(RyxError::Database)?;
+ //self.__fetch_all(query).await?;
+ //q.fetch_all(&*pool).await.map_err(RyxError::Database)?;
+
+ let mapping = if rows.is_empty() {
+ None
+ } else {
+ Some(std::sync::Arc::new(crate::backends::RowMapping {
+ columns: rows[0]
+ .columns()
+ .iter()
+ .map(|c| c.name().to_string())
+ .collect(),
+ }))
+ };
+
+ match rows.len() {
+ 0 => Err(RyxError::DoesNotExist),
+ 1 => Ok(decode_row(
+ &rows[0],
+ mapping.as_ref().unwrap(),
+ query.base_table.as_deref(),
+ )),
+ _ => Err(RyxError::MultipleObjectsReturned),
+ }
+ }
+ }
+
+ /// Execute a SELECT represented as a QueryNode and return at most one row.
+ /// This is a convenience method that compiles the QueryNode and then executes it using fetch_one.
+ /// # Errors
+ /// - [`RyxError::DoesNotExist`] if no rows are found
+ /// - [`RyxError::MultipleObjectsReturned`] if more than one row is found
+ #[instrument(skip(node, self))]
+ async fn fetch_one_compiled(&self, node: QueryNode) -> RyxResult {
+ let compiled = compile(&node).map_err(RyxError::from)?;
+ self.fetch_one(compiled).await
+ }
+
+ /// Execute an INSERT, UPDATE, or DELETE query.
+ ///
+ /// For INSERT queries with `RETURNING` clause, this fetches the returned
+ /// value and populates `last_insert_id`.
+ ///
+ /// # Errors
+ /// - [`RyxError::PoolNotInitialized`]
+ /// - [`RyxError::Database`]
+ #[instrument(skip(query, self), fields(sql = %query.sql))]
+ async fn execute(&self, query: CompiledQuery) -> RyxResult {
+ // Check if we're in a transaction and execute there if so,
+ // to ensure we stay on the same connection.
+ if let Some(tx) = get_current_transaction() {
+ let tx_guard = tx.lock().await;
+ if let Some(active_tx) = tx_guard.as_ref() {
+ // Check if this is a RETURNING query
+ if query.sql.to_uppercase().contains("RETURNING") {
+ let rows = active_tx.fetch_query(query).await?;
+ let last_insert_id = rows.first().and_then(|row| {
+ row.values.first().and_then(|v| match v {
+ SqlValue::Int(i) => Some(*i),
+ SqlValue::Float(f) => Some(*f as i64),
+ _ => None,
+ })
+ });
+ return Ok(MutationResult {
+ rows_affected: 1,
+ last_insert_id,
+ returned_ids: Some(
+ rows.iter()
+ .filter_map(|row| {
+ row.values.first().and_then(|v| match v {
+ SqlValue::Int(i) => Some(*i),
+ SqlValue::Float(f) => Some(*f as i64),
+ _ => None,
+ })
+ })
+ .collect(),
+ ),
+ });
+ }
+ let rows_affected = active_tx.execute_query(query).await?;
+ return Ok(MutationResult {
+ rows_affected,
+ last_insert_id: None,
+ returned_ids: None,
+ });
+ }
+ return Err(RyxError::Internal("Transaction is no longer active".into()));
+ }
+
+ // let pool = pool::get(query.db_alias.as_deref())?.as_any();
+
+ debug!(sql = %query.sql, "Executing mutation");
+
+ // Check if this is a RETURNING query (e.g. INSERT ... RETURNING id)
+ let sql = self.normalize_sql(&query);
+ if sql.to_uppercase().contains("RETURNING") {
+ let mut q = sqlx::query::(&sql);
+ q = self.bind_values(q, &query.values);
+
+ let rows = q
+ .fetch_all(&self.pool)
+ .await
+ .map_err(|e| RyxError::DatabaseWithSql(sql.clone(), e))?;
+
+ let last_insert_id = rows.first().and_then(|row| row.try_get::(0).ok());
+ let returned_ids: Vec = rows
+ .iter()
+ .filter_map(|row| row.try_get::(0).ok())
+ .collect();
+
+ return Ok(MutationResult {
+ rows_affected: rows.len() as u64,
+ last_insert_id,
+ returned_ids: Some(returned_ids),
+ });
+ }
+
+ let mut q = sqlx::query::(&sql);
+ q = self.bind_values(q, &query.values);
+
+ let result = q
+ .execute(&self.pool)
+ .await
+ .map_err(|e| RyxError::DatabaseWithSql(sql.clone(), e))?;
+
+ Ok(MutationResult {
+ rows_affected: result.rows_affected(),
+ last_insert_id: None,
+ returned_ids: None,
+ })
+ }
+
+ /// Execute QueryNode
+ #[instrument(skip(node, self))]
+ async fn execute_compiled(&self, node: QueryNode) -> RyxResult {
+ let compiled = compile(&node).map_err(RyxError::from)?;
+ self.execute(compiled).await
+ }
+
+ /// Bulk insert rows with values already mapped to SqlValue in one shot.
+ /// This is used for efficient bulk inserts, especially when the data is already in memory and we want to avoid multiple round-trips to the database.
+ /// The `returning_id` flag indicates whether to return the last inserted ID(s), which is useful for auto-increment primary keys.
+ /// The `ignore_conflicts` flag allows the caller to specify whether to ignore conflicts (e.g. duplicate keys) during insertion, which can be useful for upsert-like behavior.
+ /// # Errors
+ /// - [`RyxError::PoolNotInitialized`]
+ /// - [`RyxError::Database`]
+ async fn bulk_insert(
+ &self,
+ table: String,
+ columns: Vec,
+ rows: Vec>,
+ returning_id: bool,
+ ignore_conflicts: bool,
+ _db_alias: Option,
+ ) -> RyxResult {
+ if rows.is_empty() {
+ return Ok(MutationResult {
+ rows_affected: 0,
+ last_insert_id: None,
+ returned_ids: None,
+ });
+ }
+ // let pool = pool::get(db_alias.as_deref())?.as_any();
+ // let backend = pool::get_backend(db_alias.as_deref())?;
+
+ let col_list = columns
+ .iter()
+ .map(|c| format!("\"{}\"", c))
+ .collect::>()
+ .join(", ");
+
+ // 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 raw = {
+ match rows.get(0).and_then(|r| r.get(idx)) {
+ 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()
+ }
+ _ => "?".to_string(),
+ }
+ };
+ placeholders.push(raw);
+ }
+
+ 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();
+
+ let values_sql = values_sql_parts.join(", ");
+
+ let mut flat: SmallVec<[SqlValue; 8]> = SmallVec::new();
+ for row in rows {
+ for v in row {
+ flat.push(v);
+ }
+ }
+
+ // On confilct
+ let (insert_kw, conflict_suffix) = if ignore_conflicts {
+ ("INSERT OR IGNORE INTO", "")
+ } else {
+ ("INSERT INTO", "")
+ };
+
+ let sql = format!(
+ "{} \"{}\" ({}) VALUES {}{}{}",
+ insert_kw,
+ table,
+ col_list,
+ values_sql,
+ conflict_suffix,
+ if returning_id { " RETURNING id" } else { "" }
+ );
+
+ let mut q = sqlx::query::(&sql);
+ q = self.bind_values(q, &flat);
+ if returning_id {
+ let rows = q.fetch_all(&self.pool).await.map_err(RyxError::Database)?;
+ let ids: Vec = rows
+ .iter()
+ .filter_map(|r| r.try_get::(0).ok())
+ .collect();
+ let last_insert_id = ids.first().cloned();
+ Ok(MutationResult {
+ rows_affected: rows.len() as u64,
+ last_insert_id,
+ returned_ids: Some(ids),
+ })
+ } else {
+ let res = q.execute(&self.pool).await.map_err(RyxError::Database)?;
+ Ok(MutationResult {
+ rows_affected: res.rows_affected(),
+ last_insert_id: Some(res.last_insert_rowid() as i64),
+ returned_ids: None,
+ })
+ }
+ }
+
+ /// Bulk delete by primary key values in one shot.
+ #[instrument(skip(table, pk_col, pks, self))]
+ async fn bulk_delete(
+ &self,
+ table: String,
+ pk_col: String,
+ pks: Vec,
+ db_alias: Option,
+ ) -> RyxResult {
+ if pks.is_empty() {
+ return Ok(MutationResult {
+ rows_affected: 0,
+ last_insert_id: None,
+ returned_ids: None,
+ });
+ }
+
+ let ph = (0..pks.len())
+ .map(|_| "?".to_string())
+ .collect::>()
+ .join(", ");
+
+ let sql = format!("DELETE FROM \"{}\" WHERE \"{}\" IN ({})", table, pk_col, ph);
+ debug!(
+ target: "ryx::bulk_delete",
+ db_alias = db_alias.as_deref().unwrap_or("default"),
+ params = pks.len(),
+ sql_len = sql.len(),
+ "bulk_delete compiled"
+ );
+
+ let mut q = sqlx::query::(&sql);
+ q = self.bind_values(q, &pks);
+ let res = q.execute(&self.pool).await.map_err(RyxError::Database)?;
+ Ok(MutationResult {
+ rows_affected: res.rows_affected(),
+ last_insert_id: None,
+ returned_ids: None,
+ })
+ }
+
+ /// Bulk update using CASE WHEN, values already mapped to SqlValue.
+ #[instrument(skip(table, pk_col, col_names, field_values, pks, self))]
+ async fn bulk_update(
+ &self,
+ table: String,
+ pk_col: String,
+ col_names: Vec,
+ field_values: Vec>,
+ pks: Vec,
+ db_alias: Option,
+ ) -> RyxResult {
+ // let pool = pool::get(db_alias.as_deref())?;
+ // let backend = pool::get_backend(db_alias.as_deref())?;
+ let n = pks.len();
+ let f = field_values.len();
+ if n == 0 || f == 0 {
+ return Ok(MutationResult {
+ rows_affected: 0,
+ last_insert_id: None,
+ returned_ids: None,
+ });
+ }
+
+ let mut case_clauses = Vec::with_capacity(f);
+ let mut all_values: SmallVec<[SqlValue; 8]> = SmallVec::with_capacity(n * f * 2 + n);
+
+ // Build CASE clauses with placeholders.
+ for (fi, col_name) in col_names.iter().enumerate() {
+ let mut case_parts = Vec::with_capacity(n * 3 + 2);
+ case_parts.push(format!("\"{}\" = CASE \"{}\"", col_name, pk_col));
+
+ for i in 0..n {
+ let when_ph = "?".to_string();
+ let then_ph = "?".to_string();
+
+ case_parts.push(format!("WHEN {} THEN {}", when_ph, then_ph));
+ all_values.push(pks[i].clone());
+ all_values.push(field_values[fi][i].clone());
+ }
+ case_parts.push("END".to_string());
+ case_clauses.push(case_parts.join(" "));
+ }
+
+ let pk_placeholders: Vec = (0..n).map(|_| "?".to_string()).collect();
+
+ for pk in &pks {
+ all_values.push(pk.clone());
+ }
+
+ let sql = format!(
+ "UPDATE \"{}\" SET {} WHERE \"{}\" IN ({})",
+ table,
+ case_clauses.join(", "),
+ pk_col,
+ pk_placeholders.join(", ")
+ );
+
+ debug!(
+ target: "ryx::bulk_update",
+ db_alias = db_alias.as_deref().unwrap_or("default"),
+ rows = n,
+ cols = f,
+ sql_len = sql.len(),
+ params = all_values.len(),
+ "bulk_update compiled"
+ );
+
+ let mut q = sqlx::query(&sql);
+ q = self.bind_values(q, &all_values);
+ let res = q.execute(&self.pool).await.map_err(RyxError::Database)?;
+ Ok(MutationResult {
+ rows_affected: res.rows_affected(),
+ last_insert_id: None,
+ returned_ids: None,
+ })
+ }
+
+ /// Execute raw SQL without bind params.
+ #[instrument(skip(sql, self))]
+ async fn execute_raw(&self, sql: String, _db_alias: Option) -> RyxResult<()> {
+ // let pool = pool::get(db_alias.as_deref())?;
+ sqlx::query(&sql)
+ .execute(&self.pool)
+ .await
+ .map_err(RyxError::Database)?;
+ Ok(())
+ }
+
+ fn pool_stats(&self) -> PoolStats {
+ PoolStats {
+ size: self.pool.size(),
+ idle: self.pool.num_idle() as u32,
+ }
+ }
+
+ fn get_pool(&self) -> RyxPool {
+ RyxPool::SQLite(self.pool.clone())
+ }
+}
diff --git a/ryx-backend/src/core.rs b/ryx-backend/src/core.rs
new file mode 100644
index 0000000..1a89c9a
--- /dev/null
+++ b/ryx-backend/src/core.rs
@@ -0,0 +1,7 @@
+// Rexport core types for use in backends and pool management
+pub use ryx_core::{
+ errors::{RyxError, RyxResult},
+ model_registry::{
+ self, PyFieldSpec, PyModelOptions, PyModelSpec, get_model_spec, register_model_spec,
+ },
+};
diff --git a/ryx-backend/src/lib.rs b/ryx-backend/src/lib.rs
new file mode 100644
index 0000000..5de7675
--- /dev/null
+++ b/ryx-backend/src/lib.rs
@@ -0,0 +1,10 @@
+pub mod backends;
+pub mod pool;
+pub mod transaction;
+pub mod utils;
+
+// Rexport core types for use in backends and pool management
+pub mod core;
+
+// Rexport query types for use in backends
+pub mod query;
diff --git a/src/pool.rs b/ryx-backend/src/pool.rs
similarity index 65%
rename from src/pool.rs
rename to ryx-backend/src/pool.rs
index a8a4ff1..0de1f23 100644
--- a/src/pool.rs
+++ b/ryx-backend/src/pool.rs
@@ -26,21 +26,55 @@
use std::collections::HashMap;
use std::sync::{Arc, OnceLock, RwLock};
-
-use sqlx::{
- AnyPool,
- any::{AnyPoolOptions, install_default_drivers},
-};
+
+use sqlx::{any::install_default_drivers, mysql::MySqlPool, postgres::PgPool, sqlite::SqlitePool};
use tracing::{debug, info};
-
-use crate::errors::{RyxError, RyxResult};
+
use ryx_query::Backend;
+use crate::backends::{
+ RyxBackend, mysql::MySqlBackend, postgres::PostgresBackend, sqlite::SqliteBackend,
+};
+use ryx_core::errors::{RyxError, RyxResult};
+
+fn to_static(tx: sqlx::Transaction<'_, T>) -> sqlx::Transaction<'static, T> {
+ // SAFETY: transactions are tied to the process-lifetime pool. Extending the
+ // lifetime lets us store them behind Arc> across the FFI
+ // boundary without leaking the underlying connection.
+ unsafe { std::mem::transmute::, sqlx::Transaction<'static, T>>(tx) }
+}
+
+/// Enum to represent the type of database backend Pools.
+pub enum RyxPool {
+ Postgres(PgPool),
+ MySQL(MySqlPool),
+ SQLite(SqlitePool),
+}
+
+impl RyxPool {
+ pub async fn begin(&self) -> RyxResult {
+ match self {
+ RyxPool::Postgres(pool) => {
+ let tx = pool.begin().await.map_err(RyxError::Database)?;
+ Ok(crate::backends::RyxTransaction::Postgres(to_static(tx)))
+ }
+ RyxPool::MySQL(pool) => {
+ let tx = pool.begin().await.map_err(RyxError::Database)?;
+ Ok(crate::backends::RyxTransaction::MySql(to_static(tx)))
+ }
+ RyxPool::SQLite(pool) => {
+ let tx = pool.begin().await.map_err(RyxError::Database)?;
+ Ok(crate::backends::RyxTransaction::Sqlite(to_static(tx)))
+ }
+ }
+ }
+}
+
/// A registry of database connection pools.
/// Allows multiple databases to be configured and accessed via aliases.
pub struct PoolRegistry {
/// Map of alias (e.g., "default", "replica") to the connection pool and its backend.
- pub pools: HashMap, Backend)>,
+ pub backends: HashMap, Backend)>,
/// The alias used when no specific database is requested.
pub default_alias: String,
}
@@ -48,7 +82,6 @@ pub struct PoolRegistry {
/// Global singleton for the pool registry.
static REGISTRY: OnceLock> = OnceLock::new();
-
// ###
// Pool configuration options
//
@@ -110,57 +143,71 @@ impl Default for PoolConfig {
/// # Errors
/// - [`RyxError::PoolAlreadyInitialized`] if called more than once
/// - [`RyxError::Database`] if any URL is invalid or DB is unreachable
-pub async fn initialize(database_urls: HashMap, config: PoolConfig) -> RyxResult<()> {
+pub async fn initialize(
+ database_urls: HashMap,
+ config: PoolConfig,
+) -> RyxResult<()> {
// Register all built-in sqlx drivers with AnyPool.
install_default_drivers();
-
+
if database_urls.is_empty() {
- return Err(RyxError::Internal("No database URLs provided for initialization".into()));
+ return Err(RyxError::Internal(
+ "No database URLs provided for initialization".into(),
+ ));
}
debug!(urls = ?database_urls, "Initializing Ryx connection pool registry");
-
- let mut pools = HashMap::new();
+
+ let mut backends = HashMap::new();
let mut first_alias = None;
-
+
for (alias, url) in database_urls {
if first_alias.is_none() {
first_alias = Some(alias.clone());
}
-
- let pool = AnyPoolOptions::new()
- .max_connections(config.max_connections)
- .min_connections(config.min_connections)
- .acquire_timeout(std::time::Duration::from_secs(config.connect_timeout_secs))
- .idle_timeout(std::time::Duration::from_secs(config.idle_timeout_secs))
- .max_lifetime(std::time::Duration::from_secs(config.max_lifetime_secs))
- .connect(&url)
- .await
- .map_err(RyxError::Database)?;
-
- let backend = ryx_query::backend::detect_backend(&url);
- pools.insert(alias, (Arc::new(pool), backend));
+ // config.url = Some(url.clone());
+
+ let db_backend = ryx_query::backend::detect_backend(&url);
+
+ // Create a backend specified pool with the provided configuration.
+ let ryx_backend: (Arc, Backend) = match db_backend {
+ Backend::PostgreSQL => {
+ let b = PostgresBackend::new(config.clone(), url.clone()).await;
+ (Arc::new(b), db_backend)
+ }
+ Backend::MySQL => {
+ let b = MySqlBackend::new(config.clone(), url.clone()).await;
+ (Arc::new(b), db_backend)
+ }
+ Backend::SQLite => {
+ let b = SqliteBackend::new(config.clone(), url.clone()).await;
+ (Arc::new(b), db_backend)
+ }
+ };
+
+ backends.insert(alias, ryx_backend);
}
-
+
// Determine the default alias
- let default_alias = if pools.contains_key("default") {
+ let default_alias = if backends.contains_key("default") {
"default".to_string()
} else {
first_alias.expect("Registry cannot be empty")
};
-
+
let registry = PoolRegistry {
- pools,
+ backends,
default_alias,
};
-
- REGISTRY.set(RwLock::new(registry))
+
+ REGISTRY
+ .set(RwLock::new(registry))
.map_err(|_| RyxError::PoolAlreadyInitialized)?;
-
+
info!("Ryx connection pool registry initialized successfully");
Ok(())
}
-
+
/// Retrieve a reference to a specific connection pool.
///
/// # Arguments
@@ -169,24 +216,26 @@ pub async fn initialize(database_urls: HashMap, config: PoolConf
/// # Errors
/// Returns [`RyxError::PoolNotInitialized`] if `initialize()` has not been called,
/// or if the specified alias does not exist.
-pub fn get(alias: Option<&str>) -> RyxResult> {
+pub fn get(alias: Option<&str>) -> RyxResult> {
let registry_lock = REGISTRY.get().ok_or(RyxError::PoolNotInitialized)?;
let registry = registry_lock.read().unwrap();
-
+
let target_alias = alias.unwrap_or(®istry.default_alias);
-
- registry.pools.get(target_alias)
- .map(|(pool, _)| pool.clone())
+
+ registry
+ .backends
+ .get(target_alias)
+ .map(|(b, _)| b.clone())
.ok_or_else(|| RyxError::Internal(format!("Database pool '{}' not found", target_alias)))
}
-
+
/// Check whether the pool registry has been initialized.
pub fn is_initialized(alias: Option) -> bool {
-
// Alias provided
- if alias.is_some(){
+ if alias.is_some() {
REGISTRY.get().is_some_and(|f| {
- f.read().is_ok_and(|pc| pc.pools.contains_key(alias.unwrap().as_str()))
+ f.read()
+ .is_ok_and(|pc| pc.backends.contains_key(alias.unwrap().as_str()))
})
}
// Else is the registry not none?
@@ -194,12 +243,12 @@ pub fn is_initialized(alias: Option) -> bool {
REGISTRY.get().is_some()
}
}
-
+
/// Return a list of all configured database aliases.
pub fn list_aliases() -> RyxResult> {
let registry_lock = REGISTRY.get().ok_or(RyxError::PoolNotInitialized)?;
let registry = registry_lock.read().unwrap();
- Ok(registry.pools.keys().cloned().collect())
+ Ok(registry.backends.keys().cloned().collect())
}
/// Retrieve the backend type for a specific pool.
@@ -210,26 +259,25 @@ pub fn list_aliases() -> RyxResult> {
pub fn get_backend(alias: Option<&str>) -> RyxResult {
let registry_lock = REGISTRY.get().ok_or(RyxError::PoolNotInitialized)?;
let registry = registry_lock.read().unwrap();
-
+
let target_alias = alias.unwrap_or(®istry.default_alias);
-
- registry.pools.get(target_alias)
+
+ registry
+ .backends
+ .get(target_alias)
.map(|(_, backend)| *backend)
.ok_or_else(|| RyxError::Internal(format!("Database pool '{}' not found", target_alias)))
}
-
+
/// Return pool statistics for a specific pool.
#[derive(Debug)]
pub struct PoolStats {
pub size: u32,
pub idle: u32,
}
-
+
/// Retrieve current pool statistics for a specific pool.
pub fn stats(alias: Option<&str>) -> RyxResult {
- let pool = get(alias)?;
- Ok(PoolStats {
- size: pool.size(),
- idle: pool.num_idle() as u32,
- })
+ let backend: Arc = get(alias)?;
+ Ok(backend.pool_stats())
}
diff --git a/ryx-backend/src/query.rs b/ryx-backend/src/query.rs
new file mode 100644
index 0000000..bb1e1ac
--- /dev/null
+++ b/ryx-backend/src/query.rs
@@ -0,0 +1,11 @@
+// Rexport query types for use in backends
+pub use ryx_query::{
+ Backend, QueryError, QueryResult,
+ ast::{
+ AggFunc, AggregateExpr, FilterNode, JoinClause, JoinKind, OrderByClause, QNode, QueryNode,
+ QueryOperation, SqlValue,
+ },
+ compiler::{self, CompiledQuery, compile},
+ lookups::lookups,
+ symbols::Symbol,
+};
diff --git a/ryx-backend/src/transaction.rs b/ryx-backend/src/transaction.rs
new file mode 100644
index 0000000..fbf66b4
--- /dev/null
+++ b/ryx-backend/src/transaction.rs
@@ -0,0 +1,153 @@
+//
+// ###
+// Ryx — Transaction Manager
+//
+// Provides a Rust-side transaction handle that:
+// - Acquires a connection from the pool
+// - Wraps it in a sqlx transaction (BEGIN on acquire)
+// - Exposes commit() and rollback() to Python
+// - Supports named SAVEPOINTs for nested transactions
+// - Exposes execute_in_tx() so SQL can run within the transaction boundary
+//
+// Design decision: we use RyxTransaction enum to handle Postgres, MySQL, and SQLite.
+// The transaction is stored behind an Arc> so it can be sent across the PyO3 boundary.
+//
+// Usage from Python (via ryx/transaction.py):
+// async with ryx.transaction() as tx:
+// await Post.objects.filter(pk=1).update(views=42) # uses tx automatically
+// await tx.commit() # optional — commits on __aexit__ by default
+//
+// Savepoints (nested transactions):
+// async with ryx.transaction() as tx:
+// sp = await tx.savepoint("sp1")
+// ...
+// await tx.rollback_to("sp1")
+// ###
+
+use once_cell::sync::OnceCell;
+use std::sync::{Arc, Mutex as StdMutex};
+use tokio::sync::Mutex;
+
+use ryx_core::errors::{RyxError, RyxResult};
+use ryx_query::compiler::CompiledQuery;
+
+use crate::backends::{RowView, RyxBackend, RyxTransaction};
+use crate::pool;
+
+static ACTIVE_TX: OnceCell>>>>> =
+ OnceCell::new();
+
+pub fn set_current_transaction(tx: Option>>>) {
+ let lock = ACTIVE_TX.get_or_init(|| StdMutex::new(None));
+ let mut guard = lock.lock().unwrap();
+ *guard = tx;
+}
+
+pub fn get_current_transaction() -> Option>>> {
+ let lock = ACTIVE_TX.get_or_init(|| StdMutex::new(None));
+ lock.lock().unwrap().clone()
+}
+
+// ###
+// TransactionHandle — owns a live RyxTransaction
+// ###
+
+/// Wraps a live sqlx transaction.
+pub struct TransactionHandle {
+ inner: Arc>>,
+ savepoints: Vec,
+ pub alias: Option,
+}
+
+impl TransactionHandle {
+ /// Begin a new transaction by acquiring a connection from the pool.
+ pub async fn begin(alias: Option) -> RyxResult {
+ let pool_backend: Arc = pool::get(alias.as_deref())?;
+ let tx = pool_backend.get_pool().begin().await?;
+
+ Ok(Self {
+ inner: Arc::new(Mutex::new(Some(tx))),
+ savepoints: Vec::new(),
+ alias: alias.clone(),
+ })
+ }
+
+ /// Commit the transaction.
+ pub async fn commit(&self) -> RyxResult<()> {
+ let mut guard = self.inner.lock().await;
+ if let Some(tx) = guard.take() {
+ match tx {
+ RyxTransaction::Postgres(tx) => tx.commit().await.map_err(RyxError::Database),
+ RyxTransaction::MySql(tx) => tx.commit().await.map_err(RyxError::Database),
+ RyxTransaction::Sqlite(tx) => tx.commit().await.map_err(RyxError::Database),
+ }?;
+ }
+ Ok(())
+ }
+
+ /// Roll back the transaction.
+ pub async fn rollback(&self) -> RyxResult<()> {
+ let mut guard = self.inner.lock().await;
+ if let Some(tx) = guard.take() {
+ match tx {
+ RyxTransaction::Postgres(tx) => tx.rollback().await.map_err(RyxError::Database),
+ RyxTransaction::MySql(tx) => tx.rollback().await.map_err(RyxError::Database),
+ RyxTransaction::Sqlite(tx) => tx.rollback().await.map_err(RyxError::Database),
+ }?;
+ }
+ Ok(())
+ }
+
+ /// Create a named savepoint within the transaction.
+ pub async fn savepoint(&mut self, name: &str) -> RyxResult<()> {
+ self.execute_raw(&format!("SAVEPOINT {name}")).await?;
+ self.savepoints.push(name.to_string());
+ Ok(())
+ }
+
+ /// Roll back to a named savepoint.
+ pub async fn rollback_to(&self, name: &str) -> RyxResult<()> {
+ self.execute_raw(&format!("ROLLBACK TO SAVEPOINT {name}"))
+ .await?;
+ Ok(())
+ }
+
+ /// Release (drop) a named savepoint.
+ pub async fn release_savepoint(&self, name: &str) -> RyxResult<()> {
+ self.execute_raw(&format!("RELEASE SAVEPOINT {name}"))
+ .await?;
+ Ok(())
+ }
+
+ /// Execute a pre-compiled query within this transaction.
+ pub async fn execute_query(&self, query: CompiledQuery) -> RyxResult {
+ let mut guard = self.inner.lock().await;
+ let tx = guard.as_mut().ok_or_else(|| {
+ RyxError::Internal("Transaction already committed or rolled back".into())
+ })?;
+ tx.execute_query(query).await
+ }
+
+ /// Execute a raw SQL string within this transaction.
+ async fn execute_raw(&self, sql: &str) -> RyxResult<()> {
+ let mut guard = self.inner.lock().await;
+ let tx = guard.as_mut().ok_or_else(|| {
+ RyxError::Internal("Transaction already committed or rolled back".into())
+ })?;
+ tx.execute_raw(sql).await
+ }
+
+ /// Fetch rows within this transaction.
+ pub async fn fetch_query(&self, query: CompiledQuery) -> RyxResult> {
+ let mut guard = self.inner.lock().await;
+ let tx = guard.as_mut().ok_or_else(|| {
+ RyxError::Internal("Transaction already committed or rolled back".into())
+ })?;
+ tx.fetch_query(query).await
+ }
+
+ /// Whether the transaction is still active.
+ pub async fn is_active(&self) -> bool {
+ self.inner.lock().await.is_some()
+ }
+}
diff --git a/ryx-backend/src/utils.rs b/ryx-backend/src/utils.rs
new file mode 100644
index 0000000..d0ddbdc
--- /dev/null
+++ b/ryx-backend/src/utils.rs
@@ -0,0 +1,152 @@
+use sqlx::Column;
+
+use ryx_core::model_registry;
+use ryx_query::ast::SqlValue;
+
+use crate::backends::DecodedRow;
+
+pub fn is_date(s: &str) -> bool {
+ matches!(s.len(), 10) && s.chars().nth(4) == Some('-') && s.chars().nth(7) == Some('-')
+}
+
+pub fn is_timestamp(s: &str) -> bool {
+ s.contains(' ') && s.contains('-') && s.contains(':')
+}
+
+pub fn decode_rows(rows: &[T], base_table: Option<&str>) -> Vec
+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>,
+{
+ if rows.is_empty() {
+ return Vec::new();
+ }
+
+ let col_names: Vec = rows[0]
+ .columns()
+ .iter()
+ .map(|c| c.name().to_string())
+ .collect();
+
+ let mapping = std::sync::Arc::new(crate::backends::RowMapping { columns: col_names });
+
+ rows.iter()
+ .map(|row| decode_row(row, &mapping, base_table))
+ .collect()
+}
+
+pub fn decode_row(
+ row: &T,
+ mapping: &std::sync::Arc,
+ base_table: Option<&str>,
+) -> DecodedRow
+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>,
+{
+ let mut values = Vec::with_capacity(mapping.columns.len());
+
+ 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),
+ };
+ values.push(value);
+ }
+
+ crate::backends::RowView {
+ values,
+ mapping: std::sync::Arc::clone(mapping),
+ }
+}
+
+pub fn decode_with_spec(
+ row: &T,
+ ord: usize,
+ spec: &model_registry::PyFieldSpec,
+) -> 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>,
+{
+ let ty = spec.data_type.as_str();
+ match ty {
+ "BooleanField" | "NullBooleanField" => row
+ .try_get::(ord)
+ .map(SqlValue::Bool)
+ .unwrap_or(SqlValue::Null),
+ "IntegerField" | "BigIntField" | "SmallIntField" | "AutoField" | "BigAutoField"
+ | "SmallAutoField" | "PositiveIntField" => row
+ .try_get::(ord)
+ .map(SqlValue::Int)
+ .unwrap_or(SqlValue::Null),
+ "FloatField" | "DecimalField" => row
+ .try_get::(ord)
+ .map(SqlValue::Float)
+ .unwrap_or_else(|_| {
+ row.try_get::(ord)
+ .map(SqlValue::Text)
+ .unwrap_or(SqlValue::Null)
+ }),
+ "UUIDField" | "CharField" | "TextField" | "SlugField" | "EmailField" | "URLField" => row
+ .try_get::(ord)
+ .map(SqlValue::Text)
+ .unwrap_or(SqlValue::Null),
+ "DateTimeField" | "DateField" | "TimeField" => row
+ .try_get::(ord)
+ .map(SqlValue::Text)
+ .unwrap_or(SqlValue::Null),
+ "JSONField" => row
+ .try_get::(ord)
+ .map(SqlValue::Text)
+ .unwrap_or(SqlValue::Null),
+ _ => decode_heuristic(row, ord, &spec.name),
+ }
+}
+
+pub fn decode_heuristic(row: &T, column: usize, name: &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>,
+{
+ if let Ok(i) = row.try_get::(column) {
+ let looks_bool = name.starts_with("is_")
+ || name.starts_with("Is_")
+ || name.starts_with("IS_")
+ || name.starts_with("has_")
+ || name.starts_with("Has_")
+ || name.starts_with("HAS_")
+ || name.starts_with("can_")
+ || name.starts_with("Can_")
+ || name.starts_with("CAN_")
+ || name.ends_with("_flag")
+ || name.ends_with("_Flag")
+ || name.ends_with("_FLAG");
+ if looks_bool && (i == 0 || i == 1) {
+ SqlValue::Bool(i != 0)
+ } else {
+ SqlValue::Int(i)
+ }
+ } else if let Ok(b) = row.try_get::(column) {
+ SqlValue::Bool(b)
+ } else if let Ok(f) = row.try_get::(column) {
+ SqlValue::Float(f)
+ } else if let Ok(s) = row.try_get::(column) {
+ SqlValue::Text(s)
+ } else {
+ SqlValue::Null
+ }
+}
diff --git a/ryx-core/Cargo.toml b/ryx-core/Cargo.toml
new file mode 100644
index 0000000..080538b
--- /dev/null
+++ b/ryx-core/Cargo.toml
@@ -0,0 +1,103 @@
+[package]
+name = "ryx-core"
+version = "0.1.2"
+edition = "2024"
+description = "Ryx ORM — a Django-style Python ORM powered by sqlx (Rust) via PyO3"
+license = "MIT OR Apache-2.0"
+authors = ["Wilfried GOEH", "AllDotPy", "Ryx Contributors"]
+
+#
+# The crate is compiled as a C dynamic library so that Python can import it.
+# "cdylib" → produces a .so / .pyd file that maturin renames to ryx_core.so
+# We also keep "rlib" so that internal Rust tests (cargo test) can link against
+# the library without needing a Python interpreter.
+#
+[lib]
+name = "ryx_core"
+crate-type = ["cdylib", "rlib"]
+
+#
+# Feature flags
+#
+# Each database backend is opt-in so users only compile what they need.
+# Default: all.
+#
+# Usage in Cargo.toml:
+# ryx = { version = "0.1", features = ["sqlite", "mysql"] }
+#
+[features]
+default = ["all"] # enable all backends by default for dev convenience
+postgres = ["sqlx/postgres"]
+mysql = ["sqlx/mysql"]
+sqlite = ["sqlx/sqlite"]
+all = ["postgres", "mysql", "sqlite"]
+
+[dependencies]
+ryx-query = { path = "../ryx-query" }
+
+# 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 }
+
+#
+# Profiles — favor peak perf in release builds (used by maturin/pip wheels).
+# LTO thin keeps link times reasonable while enabling cross-crate inlining.
+# codegen-units=1 avoids missed inlining across crates.
+#
+[profile.release]
+lto = "thin"
+codegen-units = 1
+opt-level = 3
+strip = "debuginfo"
+panic = "unwind"
+
+[profile.dev]
+opt-level = 3
+debug = true
+
+[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/src/errors.rs b/ryx-core/src/errors.rs
similarity index 94%
rename from src/errors.rs
rename to ryx-core/src/errors.rs
index a9b78f4..4e0129a 100644
--- a/src/errors.rs
+++ b/ryx-core/src/errors.rs
@@ -39,6 +39,9 @@ pub enum RyxError {
/// 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}")]
@@ -97,6 +100,9 @@ impl From