Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
c8a23e1360 | |||
ca2434da9a | |||
1fdf236159 | |||
6fba12da56 | |||
11fce7a1e2 | |||
2add2fc9c2 | |||
9cb87105bb | |||
91d7651eca |
9
.envrc
9
.envrc
@ -1,2 +1,7 @@
|
||||
use flake
|
||||
dotenv_if_exists
|
||||
export DIRENV_WARN_TIMEOUT=20s
|
||||
|
||||
eval "$(devenv direnvrc)"
|
||||
|
||||
# The use_devenv function supports passing flags to the devenv command
|
||||
# For example: use devenv --impure --option services.postgres.enable:bool true
|
||||
use devenv
|
||||
|
16
.github/workflows/ci.yaml
vendored
16
.github/workflows/ci.yaml
vendored
@ -32,16 +32,16 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v27
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
uses: cachix/install-nix-action@v31
|
||||
- name: Install devenv
|
||||
run: nix profile install nixpkgs#devenv
|
||||
- name: Migrate database
|
||||
run: nix develop --command -- just migrate
|
||||
run: devenv shell just migrate
|
||||
- name: Formatting check
|
||||
run: nix develop --command -- just format-check
|
||||
run: devenv shell just format-check
|
||||
- name: Lint
|
||||
run: nix develop --command -- just lint
|
||||
run: devenv shell just lint
|
||||
- name: Audit
|
||||
run: nix develop --command -- just audit
|
||||
run: devenv shell just audit
|
||||
- name: Tests
|
||||
run: nix develop --command -- just test
|
||||
run: devenv shell just test
|
||||
|
51
.gitignore
vendored
51
.gitignore
vendored
@ -2,3 +2,54 @@
|
||||
.env
|
||||
/coverage
|
||||
/target
|
||||
|
||||
# Devenv
|
||||
.devenv*
|
||||
devenv.local.nix
|
||||
|
||||
# direnv
|
||||
.direnv
|
||||
|
||||
# pre-commit
|
||||
.pre-commit-config.yaml
|
||||
|
||||
# Emacs backup files
|
||||
*~
|
||||
\#*\#
|
||||
.\#*
|
||||
|
||||
# Vim files
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
*.code-workspace
|
||||
|
||||
# JetBrains IDEs
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Linux
|
||||
*~
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
*.log
|
||||
|
||||
# OS generated files
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
._*
|
||||
|
115
Cargo.lock
generated
115
Cargo.lock
generated
@ -73,9 +73,9 @@ checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.8.0"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36"
|
||||
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@ -97,9 +97,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.9.0"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"
|
||||
checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
@ -254,18 +254,18 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.13.0"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
|
||||
checksum = "b7914353092ddf589ad78f25c5c1c21b7f80b0ff8621e7c814c3485b5306da9d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
@ -415,7 +415,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "georm"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"georm-macros",
|
||||
"rand 0.9.0",
|
||||
@ -424,7 +424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "georm-macros"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"deluxe",
|
||||
"proc-macro2",
|
||||
@ -698,9 +698,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.169"
|
||||
version = "0.2.170"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
|
||||
checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
@ -726,9 +726,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.7.4"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104"
|
||||
checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
@ -742,9 +742,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.25"
|
||||
version = "0.4.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f"
|
||||
checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
@ -764,9 +764,9 @@ checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.3"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924"
|
||||
checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
]
|
||||
@ -840,9 +840,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.20.2"
|
||||
version = "1.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
|
||||
checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e"
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
@ -982,8 +982,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.0",
|
||||
"zerocopy 0.8.14",
|
||||
"rand_core 0.9.3",
|
||||
"zerocopy 0.8.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1003,7 +1003,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.0",
|
||||
"rand_core 0.9.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1017,19 +1017,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.0"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b08f3c9802962f7e1b25113931d94f43ed9725bebc59db9d0c3e9a23b67e15ff"
|
||||
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
|
||||
dependencies = [
|
||||
"getrandom 0.3.1",
|
||||
"zerocopy 0.8.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.8"
|
||||
version = "0.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834"
|
||||
checksum = "82b568323e98e49e2a0899dcee453dd679fae22d69adf9b11dd508d1549b7e2f"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
@ -1075,9 +1074,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.18"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
|
||||
checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
@ -1087,18 +1086,18 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.217"
|
||||
version = "1.0.218"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70"
|
||||
checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.217"
|
||||
version = "1.0.218"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0"
|
||||
checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -1107,9 +1106,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.137"
|
||||
version = "1.0.139"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "930cfb6e6abf99298aaad7d29abbef7a9999a9a8806a40088f55f0dcec03146b"
|
||||
checksum = "44f86c3acccc9c65b153fe1b85a3be07fe5515274ec9f0653b4a0875731c72a6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@ -1172,9 +1171,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.13.2"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
|
||||
checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@ -1422,9 +1421,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.96"
|
||||
version = "2.0.98"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80"
|
||||
checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -1444,13 +1443,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.15.0"
|
||||
version = "3.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704"
|
||||
checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"fastrand",
|
||||
"getrandom 0.2.15",
|
||||
"getrandom 0.3.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
@ -1503,9 +1502,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.43.0"
|
||||
version = "1.45.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e"
|
||||
checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bytes",
|
||||
@ -1578,9 +1577,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.17.0"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
|
||||
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
@ -1590,9 +1589,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.15"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11cd88e12b17c6494200a9c1b683a04fcac9573ed74cd1b62aeb2727c5592243"
|
||||
checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
@ -1889,11 +1888,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.14"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a367f292d93d4eab890745e75a778da40909cab4d6ff8173693812f79c4a2468"
|
||||
checksum = "dcf01143b2dd5d134f11f545cf9f1431b13b749695cb33bcce051e7568f99478"
|
||||
dependencies = [
|
||||
"zerocopy-derive 0.8.14",
|
||||
"zerocopy-derive 0.8.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1909,9 +1908,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.14"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3931cb58c62c13adec22e38686b559c86a30565e16ad6e8510a337cedc611e1"
|
||||
checksum = "712c8386f4f4299382c9abee219bee7084f78fb939d88b6840fcc1320d5f6da2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -1920,18 +1919,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e"
|
||||
checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808"
|
||||
checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
733
README.md
733
README.md
@ -1,116 +1,281 @@
|
||||
<div align="center">
|
||||
<a href="https://github.com/Phundrak/georm">
|
||||
<img src="assets/logo.png" alt="Georm logo" width="150px" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h1 align="center">Georm</h1>
|
||||
<div align="center">
|
||||
<strong>
|
||||
A simple, opinionated SQLx ORM for PostgreSQL
|
||||
A simple, type-safe SQLx ORM for PostgreSQL
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<!-- Github Actions -->
|
||||
<a href="https://github.com/phundrak/georm/actions/workflows/ci.yaml?query=branch%3Amain">
|
||||
<img src="https://img.shields.io/github/actions/workflow/status/phundrak/georm/ci.yaml?branch=main&style=flat-square" alt="actions status" /></a>
|
||||
<img src="https://img.shields.io/github/actions/workflow/status/phundrak/georm/ci.yaml?branch=main&style=flat-square" alt="actions status" />
|
||||
</a>
|
||||
<!-- Version -->
|
||||
<a href="https://crates.io/crates/georm">
|
||||
<img src="https://img.shields.io/crates/v/georm.svg?style=flat-square"
|
||||
alt="Crates.io version" /></a>
|
||||
<!-- Discord -->
|
||||
<img src="https://img.shields.io/crates/v/georm.svg?style=flat-square" alt="Crates.io version" />
|
||||
</a>
|
||||
<!-- Docs -->
|
||||
<a href="https://docs.rs/georm">
|
||||
<img src="https://img.shields.io/badge/docs-latest-blue.svg?style=flat-square" alt="docs.rs docs" /></a>
|
||||
<img src="https://img.shields.io/badge/docs-latest-blue.svg?style=flat-square" alt="docs.rs docs" />
|
||||
</a>
|
||||
<!-- License -->
|
||||
<a href="#license">
|
||||
<img src="https://img.shields.io/badge/license-MIT%20OR%20GPL--3.0-blue?style=flat-square" alt="License" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<h4>What is Georm?</h4>
|
||||
</div>
|
||||
## Overview
|
||||
|
||||
Georm is a quite simple ORM built around
|
||||
[SQLx](https://crates.io/crates/sqlx) that gives access to a few
|
||||
useful functions when interacting with a database, implementing
|
||||
automatically the most basic SQL interactions you’re tired of writing.
|
||||
Georm is a lightweight, opinionated Object-Relational Mapping (ORM) library built on top of [SQLx](https://crates.io/crates/sqlx) for PostgreSQL. It provides a clean, type-safe interface for common database operations while leveraging SQLx's compile-time query verification.
|
||||
|
||||
<div align="center">
|
||||
<h4>Why is Georm?</h4>
|
||||
</div>
|
||||
### Key Features
|
||||
|
||||
I wanted an ORM that’s easy and straightforward to use. I am aware
|
||||
some other projects exist, such as
|
||||
[SeaORM](https://www.sea-ql.org/SeaORM/), but they generally don’t fit
|
||||
my needs and/or my wants of a simple interface. I ended up writing the
|
||||
ORM I wanted to use.
|
||||
- **Type Safety**: Compile-time verified SQL queries using SQLx macros
|
||||
- **Zero Runtime Cost**: No reflection or runtime query building
|
||||
- **Simple API**: Intuitive derive macros for common operations
|
||||
- **Relationship Support**: One-to-one, one-to-many, and many-to-many relationships
|
||||
- **Defaultable Fields**: Easy entity creation with database defaults and auto-generated values
|
||||
- **PostgreSQL Native**: Optimized for PostgreSQL features and data types
|
||||
|
||||
<div align="center">
|
||||
<h4>How is Georm?</h4>
|
||||
</div>
|
||||
## Quick Start
|
||||
|
||||
I use it in a few projects, and I’m quite happy with it right now. But
|
||||
of course, I’m open to constructive criticism and suggestions!
|
||||
### Installation
|
||||
|
||||
<div align="center">
|
||||
<h4>How can I use it?</h4>
|
||||
</div>
|
||||
Add Georm and SQLx to your `Cargo.toml`:
|
||||
|
||||
Georm works with SQLx, but does not re-export it itself. To get
|
||||
started, install both Georm and SQLx in your Rust project:
|
||||
|
||||
```sh
|
||||
cargo add sqlx --features postgres,macros # and any other feature you might want
|
||||
cargo add georm
|
||||
```toml
|
||||
[dependencies]
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "macros"] }
|
||||
georm = "0.1"
|
||||
```
|
||||
|
||||
As Georm relies heavily on the macro
|
||||
[`query_as!`](https://docs.rs/sqlx/latest/sqlx/macro.query_as.html),
|
||||
the `macros` feature is not optional. Declare your tables in your
|
||||
Postgres database (you may want to use SQLx’s `migrate` feature for
|
||||
this), and then declare their equivalent in Rust.
|
||||
### Basic Usage
|
||||
|
||||
1. **Define your database schema**:
|
||||
|
||||
```sql
|
||||
CREATE TABLE biographies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
content TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE authors (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
biography_id INT,
|
||||
FOREIGN KEY (biography_id) REFERENCES biographies(id)
|
||||
email VARCHAR(255) UNIQUE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE posts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
published BOOLEAN DEFAULT FALSE,
|
||||
author_id INT NOT NULL REFERENCES authors(id),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
```rust
|
||||
pub struct Author {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
}
|
||||
```
|
||||
2. **Define your Rust entities**:
|
||||
|
||||
To link a struct to a table in your database, derive the
|
||||
`sqlx::FromRow` and the `georm::Georm` traits.
|
||||
```rust
|
||||
#[derive(sqlx::FromRow, Georm)]
|
||||
pub struct Author {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
}
|
||||
```
|
||||
use georm::Georm;
|
||||
|
||||
Now, indicate with the `georm` proc-macro which table they refer to.
|
||||
```rust
|
||||
#[derive(sqlx::FromRow, Georm)]
|
||||
#[georm(table = "authors")]
|
||||
pub struct Author {
|
||||
#[georm(id)]
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Georm)]
|
||||
#[georm(table = "posts")]
|
||||
pub struct Post {
|
||||
#[georm(id)]
|
||||
pub id: i32,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub published: bool,
|
||||
#[georm(relation = {
|
||||
entity = Author,
|
||||
table = "authors",
|
||||
name = "author"
|
||||
})]
|
||||
pub author_id: i32,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
```
|
||||
|
||||
Finally, indicate with the same proc-macro which field of your struct
|
||||
is the primary key in your database.
|
||||
3. **Use the generated methods**:
|
||||
|
||||
```rust
|
||||
use sqlx::PgPool;
|
||||
|
||||
async fn example(pool: &PgPool) -> sqlx::Result<()> {
|
||||
// Create an author
|
||||
let author = Author {
|
||||
id: 0, // Will be auto-generated
|
||||
name: "Jane Doe".to_string(),
|
||||
email: "jane@example.com".to_string(),
|
||||
};
|
||||
let author = author.create(pool).await?;
|
||||
|
||||
// Create a post
|
||||
let post = Post {
|
||||
id: 0,
|
||||
title: "Hello, Georm!".to_string(),
|
||||
content: "This is my first post using Georm.".to_string(),
|
||||
published: false,
|
||||
author_id: author.id,
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
let post = post.create(pool).await?;
|
||||
|
||||
// Find all posts
|
||||
let all_posts = Post::find_all(pool).await?;
|
||||
|
||||
// Get the post's author
|
||||
let post_author = post.get_author(pool).await?;
|
||||
|
||||
println!("Post '{}' by {}", post.title, post_author.name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Defaultable Fields
|
||||
|
||||
For fields with database defaults or auto-generated values, use the `defaultable` attribute:
|
||||
|
||||
```rust
|
||||
#[derive(sqlx::FromRow, Georm)]
|
||||
#[georm(table = "authors")]
|
||||
#[georm(table = "posts")]
|
||||
pub struct Post {
|
||||
#[georm(id, defaultable)]
|
||||
pub id: i32, // Auto-generated serial
|
||||
pub title: String,
|
||||
#[georm(defaultable)]
|
||||
pub published: bool, // Has database default (false)
|
||||
#[georm(defaultable)]
|
||||
pub created_at: chrono::DateTime<chrono::Utc>, // DEFAULT NOW()
|
||||
pub author_id: i32,
|
||||
}
|
||||
```
|
||||
|
||||
This generates a `PostDefault` struct for easier creation:
|
||||
|
||||
```rust
|
||||
use georm::Defaultable;
|
||||
|
||||
let post_default = PostDefault {
|
||||
id: None, // Let database auto-generate
|
||||
title: "My Post".to_string(),
|
||||
published: None, // Use database default
|
||||
created_at: None, // Use database default (NOW())
|
||||
author_id: 42,
|
||||
};
|
||||
|
||||
let created_post = post_default.create(pool).await?;
|
||||
```
|
||||
|
||||
### Relationships
|
||||
|
||||
Georm supports comprehensive relationship modeling with two approaches: field-level relationships for foreign keys and struct-level relationships for reverse lookups.
|
||||
|
||||
#### Field-Level Relationships (Foreign Keys)
|
||||
|
||||
Use the `relation` attribute on foreign key fields to generate lookup methods:
|
||||
|
||||
```rust
|
||||
#[derive(sqlx::FromRow, Georm)]
|
||||
#[georm(table = "posts")]
|
||||
pub struct Post {
|
||||
#[georm(id)]
|
||||
pub id: i32,
|
||||
pub title: String,
|
||||
#[georm(relation = {
|
||||
entity = Author, // Target entity type
|
||||
table = "authors", // Target table name
|
||||
name = "author", // Method name (generates get_author)
|
||||
remote_id = "id", // Target table's key column (default: "id")
|
||||
nullable = false // Whether relationship can be null (default: false)
|
||||
})]
|
||||
pub author_id: i32,
|
||||
}
|
||||
```
|
||||
|
||||
**Generated method**: `post.get_author(pool).await? -> Author`
|
||||
|
||||
For nullable relationships:
|
||||
|
||||
```rust
|
||||
#[derive(sqlx::FromRow, Georm)]
|
||||
#[georm(table = "posts")]
|
||||
pub struct Post {
|
||||
#[georm(id)]
|
||||
pub id: i32,
|
||||
pub title: String,
|
||||
#[georm(relation = {
|
||||
entity = Category,
|
||||
table = "categories",
|
||||
name = "category",
|
||||
nullable = true // Allows NULL values
|
||||
})]
|
||||
pub category_id: Option<i32>,
|
||||
}
|
||||
```
|
||||
|
||||
**Generated method**: `post.get_category(pool).await? -> Option<Category>`
|
||||
|
||||
#### Struct-Level Relationships (Reverse Lookups)
|
||||
|
||||
Define relationships at the struct level to query related entities that reference this entity:
|
||||
|
||||
##### One-to-One Relationships
|
||||
|
||||
```rust
|
||||
#[derive(sqlx::FromRow, Georm)]
|
||||
#[georm(
|
||||
table = "users",
|
||||
one_to_one = [{
|
||||
entity = Profile, // Related entity type
|
||||
name = "profile", // Method name (generates get_profile)
|
||||
table = "profiles", // Related table name
|
||||
remote_id = "user_id", // Foreign key in related table
|
||||
}]
|
||||
)]
|
||||
pub struct User {
|
||||
#[georm(id)]
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
}
|
||||
```
|
||||
|
||||
**Generated method**: `user.get_profile(pool).await? -> Option<Profile>`
|
||||
|
||||
##### One-to-Many Relationships
|
||||
|
||||
```rust
|
||||
#[derive(sqlx::FromRow, Georm)]
|
||||
#[georm(
|
||||
table = "authors",
|
||||
one_to_many = [{
|
||||
entity = Post, // Related entity type
|
||||
name = "posts", // Method name (generates get_posts)
|
||||
table = "posts", // Related table name
|
||||
remote_id = "author_id" // Foreign key in related table
|
||||
}, {
|
||||
entity = Comment, // Multiple relationships allowed
|
||||
name = "comments",
|
||||
table = "comments",
|
||||
remote_id = "author_id"
|
||||
}]
|
||||
)]
|
||||
pub struct Author {
|
||||
#[georm(id)]
|
||||
pub id: i32,
|
||||
@ -118,38 +283,430 @@ pub struct Author {
|
||||
}
|
||||
```
|
||||
|
||||
Congratulations, your struct `Author` now has access to all the
|
||||
functions described in the `Georm` trait!
|
||||
**Generated methods**:
|
||||
- `author.get_posts(pool).await? -> Vec<Post>`
|
||||
- `author.get_comments(pool).await? -> Vec<Comment>`
|
||||
|
||||
<div align="center">
|
||||
<h4>Entity relationship</h4>
|
||||
</div>
|
||||
##### Many-to-Many Relationships
|
||||
|
||||
For many-to-many relationships, specify the link table that connects the entities:
|
||||
|
||||
```sql
|
||||
-- Example schema for books and genres
|
||||
CREATE TABLE books (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE genres (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE book_genres (
|
||||
book_id INT NOT NULL REFERENCES books(id),
|
||||
genre_id INT NOT NULL REFERENCES genres(id),
|
||||
PRIMARY KEY (book_id, genre_id)
|
||||
);
|
||||
```
|
||||
|
||||
It is possible to implement one-to-one, one-to-many, and many-to-many
|
||||
relationships with Georm. This is a quick example of how a struct with
|
||||
several relationships of different types may be declared:
|
||||
```rust
|
||||
#[derive(sqlx::FromRow, Georm)]
|
||||
#[georm(
|
||||
table = "books",
|
||||
one_to_many = [
|
||||
{ name = "reviews", remote_id = "book_id", table = "reviews", entity = Review }
|
||||
],
|
||||
many_to_many = [{
|
||||
name = "genres",
|
||||
table = "genres",
|
||||
entity = Genre,
|
||||
link = { table = "book_genres", from = "book_id", to = "genre_id" }
|
||||
entity = Genre, // Related entity type
|
||||
name = "genres", // Method name (generates get_genres)
|
||||
table = "genres", // Related table name
|
||||
remote_id = "id", // Primary key in related table (default: "id")
|
||||
link = { // Link table configuration
|
||||
table = "book_genres", // Join table name
|
||||
from = "book_id", // Column referencing this entity
|
||||
to = "genre_id" // Column referencing related entity
|
||||
}
|
||||
}]
|
||||
)]
|
||||
pub struct Book {
|
||||
#[georm(id)]
|
||||
ident: i32,
|
||||
title: String,
|
||||
#[georm(relation = {entity = Author, table = "authors", name = "author"})]
|
||||
author_id: i32,
|
||||
pub id: i32,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Georm)]
|
||||
#[georm(
|
||||
table = "genres",
|
||||
many_to_many = [{
|
||||
entity = Book,
|
||||
name = "books",
|
||||
table = "books",
|
||||
link = {
|
||||
table = "book_genres",
|
||||
from = "genre_id", // Note: reversed perspective
|
||||
to = "book_id"
|
||||
}
|
||||
}]
|
||||
)]
|
||||
pub struct Genre {
|
||||
#[georm(id)]
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
}
|
||||
```
|
||||
|
||||
To read more about these features, you can refer to the [online
|
||||
documentation](https://docs.rs/georm/).
|
||||
**Generated methods**:
|
||||
- `book.get_genres(pool).await? -> Vec<Genre>`
|
||||
- `genre.get_books(pool).await? -> Vec<Book>`
|
||||
|
||||
#### Relationship Attribute Reference
|
||||
|
||||
| Attribute | Description | Required | Default |
|
||||
|--------------|------------------------------------------------------|----------|---------|
|
||||
| `entity` | Target entity type | Yes | N/A |
|
||||
| `name` | Method name (generates `get_{name}`) | Yes | N/A |
|
||||
| `table` | Target table name | Yes | N/A |
|
||||
| `remote_id` | Target table's key column | No | `"id"` |
|
||||
| `nullable` | Whether relationship can be null (field-level only) | No | `false` |
|
||||
| `link.table` | Join table name (many-to-many only) | Yes* | N/A |
|
||||
| `link.from` | Column referencing this entity (many-to-many only) | Yes* | N/A |
|
||||
| `link.to` | Column referencing target entity (many-to-many only) | Yes* | N/A |
|
||||
|
||||
*Required for many-to-many relationships
|
||||
|
||||
#### Complex Relationship Example
|
||||
|
||||
Here's a comprehensive example showing multiple relationship types:
|
||||
|
||||
```rust
|
||||
#[derive(sqlx::FromRow, Georm)]
|
||||
#[georm(
|
||||
table = "posts",
|
||||
one_to_many = [{
|
||||
entity = Comment,
|
||||
name = "comments",
|
||||
table = "comments",
|
||||
remote_id = "post_id"
|
||||
}],
|
||||
many_to_many = [{
|
||||
entity = Tag,
|
||||
name = "tags",
|
||||
table = "tags",
|
||||
link = {
|
||||
table = "post_tags",
|
||||
from = "post_id",
|
||||
to = "tag_id"
|
||||
}
|
||||
}]
|
||||
)]
|
||||
pub struct Post {
|
||||
#[georm(id)]
|
||||
pub id: i32,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
|
||||
// Field-level relationship (foreign key)
|
||||
#[georm(relation = {
|
||||
entity = Author,
|
||||
table = "authors",
|
||||
name = "author"
|
||||
})]
|
||||
pub author_id: i32,
|
||||
|
||||
// Nullable field-level relationship
|
||||
#[georm(relation = {
|
||||
entity = Category,
|
||||
table = "categories",
|
||||
name = "category",
|
||||
nullable = true
|
||||
})]
|
||||
pub category_id: Option<i32>,
|
||||
}
|
||||
```
|
||||
|
||||
**Generated methods**:
|
||||
- `post.get_author(pool).await? -> Author` (from field relation)
|
||||
- `post.get_category(pool).await? -> Option<Category>` (nullable field relation)
|
||||
- `post.get_comments(pool).await? -> Vec<Comment>` (one-to-many)
|
||||
- `post.get_tags(pool).await? -> Vec<Tag>` (many-to-many)
|
||||
|
||||
## API Reference
|
||||
|
||||
### Core Operations
|
||||
|
||||
All entities implementing `Georm<Id>` get these methods:
|
||||
|
||||
```rust
|
||||
// Query operations
|
||||
Post::find_all(pool).await?; // Find all posts
|
||||
Post::find(pool, &post_id).await?; // Find by ID
|
||||
|
||||
// Mutation operations
|
||||
post.create(pool).await?; // Insert new record
|
||||
post.update(pool).await?; // Update existing record
|
||||
post.create_or_update(pool).await?; // Upsert operation
|
||||
post.delete(pool).await?; // Delete this record
|
||||
Post::delete_by_id(pool, &post_id).await?; // Delete by ID
|
||||
|
||||
// Utility
|
||||
post.get_id(); // Get entity ID
|
||||
```
|
||||
|
||||
### Defaultable Operations
|
||||
|
||||
Entities with defaultable fields get a companion `<Entity>Default` struct:
|
||||
|
||||
```rust
|
||||
// Create with defaults
|
||||
post_default.create(pool).await?;
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Attributes Reference
|
||||
|
||||
#### Struct-level attributes
|
||||
|
||||
```rust
|
||||
#[georm(
|
||||
table = "table_name", // Required: database table name
|
||||
one_to_one = [{ /* ... */ }], // Optional: one-to-one relationships
|
||||
one_to_many = [{ /* ... */ }], // Optional: one-to-many relationships
|
||||
many_to_many = [{ /* ... */ }] // Optional: many-to-many relationships
|
||||
)]
|
||||
```
|
||||
|
||||
#### Field-level attributes
|
||||
|
||||
```rust
|
||||
#[georm(id)] // Mark as primary key
|
||||
#[georm(defaultable)] // Mark as defaultable field
|
||||
#[georm(relation = { /* ... */ })] // Define relationship
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
Georm is designed for zero runtime overhead:
|
||||
|
||||
- **Compile-time queries**: All SQL is verified at compile time
|
||||
- **No reflection**: Direct field access, no runtime introspection
|
||||
- **Minimal allocations**: Efficient use of owned vs borrowed data
|
||||
- **SQLx integration**: Leverages SQLx's optimized PostgreSQL driver
|
||||
|
||||
## Comparison
|
||||
|
||||
| Feature | Georm | SeaORM | Diesel |
|
||||
|----------------------|-------|--------|--------|
|
||||
| Compile-time safety | ✅ | ✅ | ✅ |
|
||||
| Relationship support | ✅ | ✅ | ✅ |
|
||||
| Async support | ✅ | ✅ | ⚠️ |
|
||||
| Learning curve | Low | Medium | High |
|
||||
| Macro simplicity | ✅ | ❌ | ❌ |
|
||||
| Advanced queries | ❌ | ✅ | ✅ |
|
||||
|
||||
## Roadmap
|
||||
|
||||
### High Priority
|
||||
- **Transaction Support**: Comprehensive transaction handling with atomic operations
|
||||
|
||||
### Medium Priority
|
||||
- **Multi-Database Support**: MySQL and SQLite support with feature flags
|
||||
- **Relationship Optimization**: Eager loading and N+1 query prevention
|
||||
- **Composite Primary Keys**: Multi-field primary key support
|
||||
- **Soft Delete**: Optional soft delete with `deleted_at` timestamps
|
||||
|
||||
### Lower Priority
|
||||
- **Migration Support**: Schema generation and evolution utilities
|
||||
- **Enhanced Error Handling**: Custom error types with better context
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
|
||||
|
||||
### Development Setup
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- **Rust 1.81+**: Georm uses modern Rust features and follows the MSRV specified in `rust-toolchain.toml`
|
||||
- **PostgreSQL 12+**: Required for running tests and development
|
||||
- **Git**: For version control
|
||||
- **Jujutsu**: For version control (alternative to Git)
|
||||
|
||||
#### Required Tools
|
||||
|
||||
The following tools are used in the development workflow:
|
||||
|
||||
- **[just](https://github.com/casey/just)**: Task runner for common development commands
|
||||
- **[cargo-deny](https://github.com/EmbarkStudios/cargo-deny)**: License and security auditing
|
||||
- **[sqlx-cli](https://github.com/launchbadge/sqlx/tree/main/sqlx-cli)**: Database migrations and management
|
||||
- **[bacon](https://github.com/Canop/bacon)**: Background code checker (optional but recommended)
|
||||
|
||||
Install these tools:
|
||||
|
||||
```bash
|
||||
# Install just (task runner)
|
||||
cargo install just
|
||||
|
||||
# Install cargo-deny (for auditing)
|
||||
cargo install cargo-deny
|
||||
|
||||
# Install sqlx-cli (for database management)
|
||||
cargo install sqlx-cli --no-default-features --features native-tls,postgres
|
||||
|
||||
# Install bacon (optional, for live feedback)
|
||||
cargo install bacon
|
||||
```
|
||||
|
||||
#### Quick Start
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/Phundrak/georm.git
|
||||
cd georm
|
||||
|
||||
# Set up your PostgreSQL database and set DATABASE_URL
|
||||
export DATABASE_URL="postgres://username:password@localhost/georm_test"
|
||||
|
||||
# Run migrations
|
||||
just migrate
|
||||
|
||||
# Run all tests
|
||||
just test
|
||||
|
||||
# Run linting
|
||||
just lint
|
||||
|
||||
# Run security audit
|
||||
just audit
|
||||
|
||||
# Run all checks (format, lint, audit, test)
|
||||
just check-all
|
||||
```
|
||||
|
||||
#### Available Commands (via just)
|
||||
|
||||
```bash
|
||||
just # Default: run linting
|
||||
just build # Build the project
|
||||
just build-release # Build in release mode
|
||||
just test # Run all tests
|
||||
just lint # Run clippy linting
|
||||
just audit # Run security and license audit
|
||||
just migrate # Run database migrations
|
||||
just format # Format all code
|
||||
just format-check # Check code formatting
|
||||
just check-all # Run all checks (format, lint, audit, test)
|
||||
just clean # Clean build artifacts
|
||||
```
|
||||
|
||||
#### Running Specific Tests
|
||||
|
||||
```bash
|
||||
# Run tests for a specific module
|
||||
cargo test --test simple_struct
|
||||
cargo test --test defaultable_struct
|
||||
cargo test --test m2m_relationship
|
||||
|
||||
# Run tests with output
|
||||
cargo test -- --nocapture
|
||||
|
||||
# Run a specific test function
|
||||
cargo test defaultable_struct_should_exist
|
||||
```
|
||||
|
||||
#### Development with Bacon (Optional)
|
||||
|
||||
For continuous feedback during development:
|
||||
|
||||
```bash
|
||||
# Run clippy continuously
|
||||
bacon
|
||||
|
||||
# Run tests continuously
|
||||
bacon test
|
||||
|
||||
# Build docs continuously
|
||||
bacon doc
|
||||
```
|
||||
|
||||
#### Devenv Development Environment (Optional)
|
||||
|
||||
If you use [Nix](https://nixos.org/), you can use the provided devenv configuration for a reproducible development environment:
|
||||
|
||||
```bash
|
||||
# Enter the development shell with all tools pre-installed
|
||||
devenv shell
|
||||
|
||||
# Or use direnv for automatic environment activation
|
||||
direnv allow
|
||||
```
|
||||
|
||||
The devenv configuration provides:
|
||||
- Exact Rust version (1.81) with required components
|
||||
- All development tools (just, cargo-deny, sqlx-cli, bacon)
|
||||
- LSP support (rust-analyzer)
|
||||
- SQL tooling (sqls for SQL language server)
|
||||
- PostgreSQL database for development
|
||||
|
||||
**Devenv configuration:**
|
||||
- **Rust toolchain**: Specified version with rustfmt, clippy, and rust-analyzer
|
||||
- **Development tools**: just, cargo-deny, sqlx-cli, bacon
|
||||
- **SQL tools**: sqls (SQL language server)
|
||||
- **Database**: PostgreSQL with automatic setup
|
||||
- **Platform support**: Cross-platform (Linux, macOS, etc.)
|
||||
|
||||
#### Database Setup for Tests
|
||||
|
||||
Tests require a PostgreSQL database. Set up a test database:
|
||||
|
||||
```sql
|
||||
-- Connect to PostgreSQL as superuser
|
||||
CREATE DATABASE georm_test;
|
||||
CREATE USER georm_user WITH PASSWORD 'georm_password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE georm_test TO georm_user;
|
||||
```
|
||||
|
||||
Set the environment variable:
|
||||
|
||||
```bash
|
||||
export DATABASE_URL="postgres://georm_user:georm_password@localhost/georm_test"
|
||||
```
|
||||
|
||||
#### IDE Setup
|
||||
|
||||
- Ensure `rust-analyzer` is configured
|
||||
- Set up PostgreSQL connection for SQL syntax highlighting
|
||||
|
||||
#### Code Style
|
||||
|
||||
The project uses standard Rust formatting:
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
just format
|
||||
|
||||
# Check formatting (CI)
|
||||
just format-check
|
||||
```
|
||||
|
||||
Clippy linting is enforced:
|
||||
|
||||
```bash
|
||||
# Run linting
|
||||
just lint
|
||||
|
||||
# Fix auto-fixable lints
|
||||
cargo clippy --fix
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Licensed under either of
|
||||
|
||||
* MIT License ([LICENSE-MIT](LICENSE-MIT.md) or http://opensource.org/licenses/MIT)
|
||||
* GNU General Public License v3.0 ([LICENSE-GPL](LICENSE-GPL.md) or https://www.gnu.org/licenses/gpl-3.0.html)
|
||||
|
||||
at your option.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Built on top of the excellent [SQLx](https://github.com/launchbadge/sqlx) library
|
||||
- Inspired by [Hibernate](https://hibernate.org/)
|
||||
|
BIN
assets/logo.png
Normal file
BIN
assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 64 KiB |
1272
assets/logo.svg
Normal file
1272
assets/logo.svg
Normal file
File diff suppressed because it is too large
Load Diff
After Width: | Height: | Size: 59 KiB |
123
devenv.lock
Normal file
123
devenv.lock
Normal file
@ -0,0 +1,123 @@
|
||||
{
|
||||
"nodes": {
|
||||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1749054588,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "b6be42d9e6f6053be1d180e4a4fb95e0aa9a8424",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"dir": "src/modules",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1747046372,
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1747372754,
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "80479b6ec16fefd9c1db3ea13aeb038c60530f46",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1746807397,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"rev": "c5208b594838ea8e6cca5997fbf784b7cca1ca90",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "rolling",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"pre-commit-hooks": [
|
||||
"git-hooks"
|
||||
],
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1749091064,
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "12419593ce78f2e8e1e89a373c6515885e218acb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
36
devenv.nix
Normal file
36
devenv.nix
Normal file
@ -0,0 +1,36 @@
|
||||
{ pkgs, nixpkgs, rust-overlay, ... }:
|
||||
let
|
||||
overlays = [ (import rust-overlay) ];
|
||||
system = pkgs.stdenv.system;
|
||||
rustPkgs = import nixpkgs { inherit system overlays; };
|
||||
rustVersion = (rustPkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml);
|
||||
in {
|
||||
dotenv.enable = true;
|
||||
|
||||
packages = with rustPkgs; [
|
||||
bacon
|
||||
cargo-deny
|
||||
just
|
||||
postgresql
|
||||
sqls
|
||||
sqlx-cli
|
||||
(rustVersion.override {
|
||||
extensions = [
|
||||
"rust-src"
|
||||
"rustfmt"
|
||||
"clippy"
|
||||
"rust-analyzer"
|
||||
];
|
||||
})
|
||||
];
|
||||
|
||||
services.postgres = {
|
||||
enable = true;
|
||||
listen_addresses = "localhost";
|
||||
initialScript = ''
|
||||
CREATE USER georm WITH PASSWORD 'georm' SUPERUSER;
|
||||
CREATE DATABASE georm OWNER georm;
|
||||
GRANT ALL PRIVILEGES ON DATABASE georm TO georm;
|
||||
'';
|
||||
};
|
||||
}
|
8
devenv.yaml
Normal file
8
devenv.yaml
Normal file
@ -0,0 +1,8 @@
|
||||
inputs:
|
||||
rust-overlay:
|
||||
url: github:oxalica/rust-overlay
|
||||
inputs:
|
||||
nixpkgs:
|
||||
follows: nixpkgs
|
||||
nixpkgs:
|
||||
url: github:cachix/devenv-nixpkgs/rolling
|
@ -1,33 +0,0 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
container_name: georm-backend-db
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
ports:
|
||||
- 127.0.0.1:5432:5432
|
||||
volumes:
|
||||
- georm_backend_db_data:/var/lib/postgresql/data
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:8
|
||||
restart: unless-stopped
|
||||
container_name: georm-backend-pgadmin
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: admin@example.com
|
||||
PGADMIN_DEFAULT_PASSWORD: password
|
||||
PGADMIN_DISABLE_POSTFIX: true
|
||||
PGADMIN_CONFIG_SERVER_MODE: 'False'
|
||||
ports:
|
||||
- 127.0.0.1:8080:80
|
||||
volumes:
|
||||
- georm_backend_pgadmin_data:/var/lib/pgadmin
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
volumes:
|
||||
georm_backend_db_data:
|
||||
georm_backend_pgadmin_data:
|
@ -1,14 +0,0 @@
|
||||
default: start
|
||||
|
||||
start:
|
||||
docker compose -f compose.dev.yml up -d
|
||||
|
||||
stop:
|
||||
docker compose -f compose.dev.yml down
|
||||
|
||||
logs:
|
||||
docker compose -f compose.dev.yml logs -f
|
||||
|
||||
## Local Variables:
|
||||
## mode: makefile
|
||||
## End:
|
96
flake.lock
generated
96
flake.lock
generated
@ -1,96 +0,0 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1738142207,
|
||||
"narHash": "sha256-NGqpVVxNAHwIicXpgaVqJEJWeyqzoQJ9oc8lnK9+WC4=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9d3ae807ebd2981d593cddd0080856873139aa40",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1736320768,
|
||||
"narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "4bc9c909d9ac828a039f288cf872d16d38185db8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1738290352,
|
||||
"narHash": "sha256-YKOHUmc0Clm4tMV8grnxYL4IIwtjTayoq/3nqk0QM7k=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "b031b584125d33d23a0182f91ddbaf3ab4880236",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
37
flake.nix
37
flake.nix
@ -1,37 +0,0 @@
|
||||
{
|
||||
description = "Georm, a simple, opiniated SQLx ORM for PostgreSQL";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
|
||||
flake-utils.lib.eachSystem ["x86_64-linux"] (system:
|
||||
let
|
||||
overlays = [ (import rust-overlay) ];
|
||||
pkgs = import nixpkgs { inherit system overlays; };
|
||||
rustVersion = (pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml);
|
||||
in {
|
||||
devShell = with pkgs; mkShell {
|
||||
buildInputs = [
|
||||
bacon
|
||||
cargo
|
||||
cargo-deny
|
||||
just
|
||||
rust-analyzer
|
||||
(rustVersion.override {
|
||||
extensions = [
|
||||
"rust-src"
|
||||
"rustfmt"
|
||||
"clippy"
|
||||
"rust-analyzer"
|
||||
];
|
||||
})
|
||||
sqls
|
||||
sqlx-cli
|
||||
];
|
||||
};
|
||||
});
|
||||
}
|
144
georm-macros/src/georm/defaultable_struct.rs
Normal file
144
georm-macros/src/georm/defaultable_struct.rs
Normal file
@ -0,0 +1,144 @@
|
||||
//! This module creates the defaultable version of a structured derived with
|
||||
//! Georm. It creates a new struct named `<StructName>Default` where the fields
|
||||
//! marked as defaultable become an `Option<T>`, where `T` is the initial type
|
||||
//! of the field.
|
||||
//!
|
||||
//! The user does not have to mark a field defaultable if the field already has
|
||||
//! a type `Option<T>`. It is intended only for fields marked as `NOT NULL` in
|
||||
//! the database, but not required when creating the entity due to a `DEFAULT`
|
||||
//! or something similar. The type `<StructName>Default` implements the
|
||||
//! `Defaultable` trait.
|
||||
|
||||
use super::ir::{GeormField, GeormStructAttributes};
|
||||
use quote::quote;
|
||||
|
||||
fn create_defaultable_field(field: &GeormField) -> proc_macro2::TokenStream {
|
||||
let ident = &field.ident;
|
||||
let ty = &field.ty;
|
||||
let vis = &field.field.vis;
|
||||
|
||||
// If the field is marked as defaultable, wrap it in Option<T>
|
||||
// Otherwise, keep the original type
|
||||
let field_type = if field.defaultable {
|
||||
quote! { Option<#ty> }
|
||||
} else {
|
||||
quote! { #ty }
|
||||
};
|
||||
|
||||
quote! {
|
||||
#vis #ident: #field_type
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_defaultable_trait_impl(
|
||||
struct_name: &syn::Ident,
|
||||
defaultable_struct_name: &syn::Ident,
|
||||
struct_attrs: &GeormStructAttributes,
|
||||
fields: &[GeormField],
|
||||
) -> proc_macro2::TokenStream {
|
||||
let table = &struct_attrs.table;
|
||||
|
||||
// Find the ID field
|
||||
let id_field = fields
|
||||
.iter()
|
||||
.find(|field| field.id)
|
||||
.expect("Must have an ID field");
|
||||
let id_type = &id_field.ty;
|
||||
|
||||
// Separate defaultable and non-defaultable fields
|
||||
let non_defaultable_fields: Vec<_> = fields.iter().filter(|f| !f.defaultable).collect();
|
||||
let defaultable_fields: Vec<_> = fields.iter().filter(|f| f.defaultable).collect();
|
||||
|
||||
// Build static parts for non-defaultable fields
|
||||
let static_field_names: Vec<String> = non_defaultable_fields.iter().map(|f| f.ident.to_string()).collect();
|
||||
let static_field_idents: Vec<&syn::Ident> = non_defaultable_fields.iter().map(|f| &f.ident).collect();
|
||||
|
||||
// Generate field checks for defaultable fields
|
||||
let mut field_checks = Vec::new();
|
||||
let mut bind_checks = Vec::new();
|
||||
|
||||
for field in &defaultable_fields {
|
||||
let field_name = field.ident.to_string();
|
||||
let field_ident = &field.ident;
|
||||
|
||||
field_checks.push(quote! {
|
||||
if self.#field_ident.is_some() {
|
||||
dynamic_fields.push(#field_name);
|
||||
}
|
||||
});
|
||||
|
||||
bind_checks.push(quote! {
|
||||
if let Some(ref value) = self.#field_ident {
|
||||
query_builder = query_builder.bind(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
quote! {
|
||||
impl ::georm::Defaultable<#id_type, #struct_name> for #defaultable_struct_name {
|
||||
async fn create(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#struct_name> {
|
||||
let mut dynamic_fields = Vec::new();
|
||||
|
||||
#(#field_checks)*
|
||||
|
||||
let mut all_fields = vec![#(#static_field_names),*];
|
||||
all_fields.extend(dynamic_fields);
|
||||
|
||||
let placeholders: Vec<String> = (1..=all_fields.len())
|
||||
.map(|i| format!("${}", i))
|
||||
.collect();
|
||||
|
||||
let query = format!(
|
||||
"INSERT INTO {} ({}) VALUES ({}) RETURNING *",
|
||||
#table,
|
||||
all_fields.join(", "),
|
||||
placeholders.join(", ")
|
||||
);
|
||||
|
||||
let mut query_builder = ::sqlx::query_as::<_, #struct_name>(&query);
|
||||
|
||||
// Bind non-defaultable fields first
|
||||
#(query_builder = query_builder.bind(&self.#static_field_idents);)*
|
||||
|
||||
// Then bind defaultable fields that have values
|
||||
#(#bind_checks)*
|
||||
|
||||
query_builder.fetch_one(pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn derive_defaultable_struct(
|
||||
ast: &syn::DeriveInput,
|
||||
struct_attrs: &GeormStructAttributes,
|
||||
fields: &[GeormField],
|
||||
) -> proc_macro2::TokenStream {
|
||||
// Only generate if there are defaultable fields
|
||||
if fields.iter().all(|field| !field.defaultable) {
|
||||
return quote! {};
|
||||
}
|
||||
|
||||
let struct_name = &ast.ident;
|
||||
let vis = &ast.vis;
|
||||
let defaultable_struct_name = quote::format_ident!("{}Default", struct_name);
|
||||
|
||||
let defaultable_fields: Vec<proc_macro2::TokenStream> =
|
||||
fields.iter().map(create_defaultable_field).collect();
|
||||
|
||||
let trait_impl = generate_defaultable_trait_impl(
|
||||
struct_name,
|
||||
&defaultable_struct_name,
|
||||
struct_attrs,
|
||||
fields,
|
||||
);
|
||||
|
||||
quote! {
|
||||
#[derive(Debug, Clone)]
|
||||
#vis struct #defaultable_struct_name {
|
||||
#(#defaultable_fields),*
|
||||
}
|
||||
|
||||
#trait_impl
|
||||
}
|
||||
}
|
@ -1,195 +0,0 @@
|
||||
use quote::quote;
|
||||
|
||||
#[derive(deluxe::ExtractAttributes)]
|
||||
#[deluxe(attributes(georm))]
|
||||
pub struct GeormStructAttributes {
|
||||
pub table: String,
|
||||
#[deluxe(default = Vec::new())]
|
||||
pub one_to_many: Vec<O2MRelationship>,
|
||||
#[deluxe(default = Vec::new())]
|
||||
pub many_to_many: Vec<M2MRelationship>,
|
||||
}
|
||||
|
||||
#[derive(deluxe::ParseMetaItem)]
|
||||
pub struct O2MRelationship {
|
||||
pub name: String,
|
||||
pub remote_id: String,
|
||||
pub table: String,
|
||||
pub entity: syn::Type,
|
||||
}
|
||||
|
||||
impl From<&O2MRelationship> for proc_macro2::TokenStream {
|
||||
fn from(value: &O2MRelationship) -> Self {
|
||||
let query = format!(
|
||||
"SELECT * FROM {} WHERE {} = $1",
|
||||
value.table, value.remote_id
|
||||
);
|
||||
let entity = &value.entity;
|
||||
let function = syn::Ident::new(
|
||||
&format!("get_{}", value.name),
|
||||
proc_macro2::Span::call_site(),
|
||||
);
|
||||
quote! {
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
|
||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(deluxe::ParseMetaItem, Clone)]
|
||||
pub struct M2MLink {
|
||||
pub table: String,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
#[derive(deluxe::ParseMetaItem)]
|
||||
pub struct M2MRelationship {
|
||||
pub name: String,
|
||||
pub entity: syn::Type,
|
||||
pub table: String,
|
||||
#[deluxe(default = String::from("id"))]
|
||||
pub remote_id: String,
|
||||
pub link: M2MLink,
|
||||
}
|
||||
|
||||
pub struct Identifier {
|
||||
pub table: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
pub struct M2MRelationshipComplete {
|
||||
pub name: String,
|
||||
pub entity: syn::Type,
|
||||
pub local: Identifier,
|
||||
pub remote: Identifier,
|
||||
pub link: M2MLink,
|
||||
}
|
||||
|
||||
impl M2MRelationshipComplete {
|
||||
pub fn new(other: &M2MRelationship, local_table: &String, local_id: String) -> Self {
|
||||
Self {
|
||||
name: other.name.clone(),
|
||||
entity: other.entity.clone(),
|
||||
link: other.link.clone(),
|
||||
local: Identifier {
|
||||
table: local_table.to_string(),
|
||||
id: local_id,
|
||||
},
|
||||
remote: Identifier {
|
||||
table: other.table.clone(),
|
||||
id: other.remote_id.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&M2MRelationshipComplete> for proc_macro2::TokenStream {
|
||||
fn from(value: &M2MRelationshipComplete) -> Self {
|
||||
let function = syn::Ident::new(
|
||||
&format!("get_{}", value.name),
|
||||
proc_macro2::Span::call_site(),
|
||||
);
|
||||
let entity = &value.entity;
|
||||
let query = format!(
|
||||
"SELECT remote.*
|
||||
FROM {} local
|
||||
JOIN {} link ON link.{} = local.{}
|
||||
JOIN {} remote ON link.{} = remote.{}
|
||||
WHERE local.{} = $1",
|
||||
value.local.table,
|
||||
value.link.table,
|
||||
value.link.from,
|
||||
value.local.id,
|
||||
value.remote.table,
|
||||
value.link.to,
|
||||
value.remote.id,
|
||||
value.local.id
|
||||
);
|
||||
quote! {
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
|
||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(deluxe::ExtractAttributes, Clone)]
|
||||
#[deluxe(attributes(georm))]
|
||||
struct GeormFieldAttributes {
|
||||
#[deluxe(default = false)]
|
||||
pub id: bool,
|
||||
#[deluxe(default = None)]
|
||||
pub relation: Option<O2ORelationship>,
|
||||
}
|
||||
|
||||
#[derive(deluxe::ParseMetaItem, Clone, Debug)]
|
||||
pub struct O2ORelationship {
|
||||
pub entity: syn::Type,
|
||||
pub table: String,
|
||||
#[deluxe(default = String::from("id"))]
|
||||
pub remote_id: String,
|
||||
#[deluxe(default = false)]
|
||||
pub nullable: bool,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GeormField {
|
||||
pub ident: syn::Ident,
|
||||
pub field: syn::Field,
|
||||
pub ty: syn::Type,
|
||||
pub id: bool,
|
||||
pub relation: Option<O2ORelationship>,
|
||||
}
|
||||
|
||||
impl GeormField {
|
||||
pub fn new(field: &mut syn::Field) -> Self {
|
||||
let ident = field.clone().ident.unwrap();
|
||||
let ty = field.clone().ty;
|
||||
let attrs: GeormFieldAttributes =
|
||||
deluxe::extract_attributes(field).expect("Could not extract attributes from field");
|
||||
let GeormFieldAttributes { id, relation } = attrs;
|
||||
Self {
|
||||
ident,
|
||||
field: field.to_owned(),
|
||||
id,
|
||||
ty,
|
||||
relation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&GeormField> for proc_macro2::TokenStream {
|
||||
fn from(value: &GeormField) -> Self {
|
||||
let Some(relation) = value.relation.clone() else {
|
||||
return quote! {};
|
||||
};
|
||||
let function = syn::Ident::new(
|
||||
&format!("get_{}", relation.name),
|
||||
proc_macro2::Span::call_site(),
|
||||
);
|
||||
let entity = &relation.entity;
|
||||
let return_type = if relation.nullable {
|
||||
quote! { Option<#entity> }
|
||||
} else {
|
||||
quote! { #entity }
|
||||
};
|
||||
let query = format!(
|
||||
"SELECT * FROM {} WHERE {} = $1",
|
||||
relation.table, relation.remote_id
|
||||
);
|
||||
let local_ident = &value.field.ident;
|
||||
let fetch = if relation.nullable {
|
||||
quote! { fetch_optional }
|
||||
} else {
|
||||
quote! { fetch_one }
|
||||
};
|
||||
quote! {
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#return_type> {
|
||||
::sqlx::query_as!(#entity, #query, self.#local_ident).#fetch(pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
79
georm-macros/src/georm/ir/m2m_relationship.rs
Normal file
79
georm-macros/src/georm/ir/m2m_relationship.rs
Normal file
@ -0,0 +1,79 @@
|
||||
use quote::quote;
|
||||
|
||||
#[derive(deluxe::ParseMetaItem, Clone)]
|
||||
pub struct M2MLink {
|
||||
pub table: String,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
#[derive(deluxe::ParseMetaItem)]
|
||||
pub struct M2MRelationship {
|
||||
pub name: String,
|
||||
pub entity: syn::Type,
|
||||
pub table: String,
|
||||
#[deluxe(default = String::from("id"))]
|
||||
pub remote_id: String,
|
||||
pub link: M2MLink,
|
||||
}
|
||||
|
||||
pub struct Identifier {
|
||||
pub table: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
pub struct M2MRelationshipComplete {
|
||||
pub name: String,
|
||||
pub entity: syn::Type,
|
||||
pub local: Identifier,
|
||||
pub remote: Identifier,
|
||||
pub link: M2MLink,
|
||||
}
|
||||
|
||||
impl M2MRelationshipComplete {
|
||||
pub fn new(other: &M2MRelationship, local_table: &String, local_id: String) -> Self {
|
||||
Self {
|
||||
name: other.name.clone(),
|
||||
entity: other.entity.clone(),
|
||||
link: other.link.clone(),
|
||||
local: Identifier {
|
||||
table: local_table.to_string(),
|
||||
id: local_id,
|
||||
},
|
||||
remote: Identifier {
|
||||
table: other.table.clone(),
|
||||
id: other.remote_id.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&M2MRelationshipComplete> for proc_macro2::TokenStream {
|
||||
fn from(value: &M2MRelationshipComplete) -> Self {
|
||||
let function = syn::Ident::new(
|
||||
&format!("get_{}", value.name),
|
||||
proc_macro2::Span::call_site(),
|
||||
);
|
||||
let entity = &value.entity;
|
||||
let query = format!(
|
||||
"SELECT remote.*
|
||||
FROM {} local
|
||||
JOIN {} link ON link.{} = local.{}
|
||||
JOIN {} remote ON link.{} = remote.{}
|
||||
WHERE local.{} = $1",
|
||||
value.local.table,
|
||||
value.link.table,
|
||||
value.link.from,
|
||||
value.local.id,
|
||||
value.remote.table,
|
||||
value.link.to,
|
||||
value.remote.id,
|
||||
value.local.id
|
||||
);
|
||||
quote! {
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
|
||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
130
georm-macros/src/georm/ir/mod.rs
Normal file
130
georm-macros/src/georm/ir/mod.rs
Normal file
@ -0,0 +1,130 @@
|
||||
use quote::quote;
|
||||
|
||||
pub mod simple_relationship;
|
||||
use simple_relationship::{OneToMany, OneToOne, SimpleRelationship};
|
||||
|
||||
pub mod m2m_relationship;
|
||||
use m2m_relationship::M2MRelationship;
|
||||
|
||||
#[derive(deluxe::ExtractAttributes)]
|
||||
#[deluxe(attributes(georm))]
|
||||
pub struct GeormStructAttributes {
|
||||
pub table: String,
|
||||
#[deluxe(default = Vec::new())]
|
||||
pub one_to_one: Vec<SimpleRelationship<OneToOne>>,
|
||||
#[deluxe(default = Vec::new())]
|
||||
pub one_to_many: Vec<SimpleRelationship<OneToMany>>,
|
||||
#[deluxe(default = Vec::new())]
|
||||
pub many_to_many: Vec<M2MRelationship>,
|
||||
}
|
||||
|
||||
#[derive(deluxe::ExtractAttributes, Clone)]
|
||||
#[deluxe(attributes(georm))]
|
||||
struct GeormFieldAttributes {
|
||||
#[deluxe(default = false)]
|
||||
pub id: bool,
|
||||
#[deluxe(default = None)]
|
||||
pub relation: Option<O2ORelationship>,
|
||||
#[deluxe(default = false)]
|
||||
pub defaultable: bool,
|
||||
}
|
||||
|
||||
#[derive(deluxe::ParseMetaItem, Clone, Debug)]
|
||||
pub struct O2ORelationship {
|
||||
pub entity: syn::Type,
|
||||
pub table: String,
|
||||
#[deluxe(default = String::from("id"))]
|
||||
pub remote_id: String,
|
||||
#[deluxe(default = false)]
|
||||
pub nullable: bool,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GeormField {
|
||||
pub ident: syn::Ident,
|
||||
pub field: syn::Field,
|
||||
pub ty: syn::Type,
|
||||
pub id: bool,
|
||||
pub relation: Option<O2ORelationship>,
|
||||
pub defaultable: bool,
|
||||
}
|
||||
|
||||
impl GeormField {
|
||||
pub fn new(field: &mut syn::Field) -> Self {
|
||||
let ident = field.clone().ident.unwrap();
|
||||
let ty = field.clone().ty;
|
||||
let attrs: GeormFieldAttributes =
|
||||
deluxe::extract_attributes(field).expect("Could not extract attributes from field");
|
||||
let GeormFieldAttributes {
|
||||
id,
|
||||
relation,
|
||||
defaultable,
|
||||
} = attrs;
|
||||
|
||||
// Validate that defaultable is not used on Option<T> fields
|
||||
if defaultable && Self::is_option_type(&ty) {
|
||||
panic!(
|
||||
"Field '{}' is already an Option<T> and cannot be marked as defaultable. \
|
||||
Remove the #[georm(defaultable)] attribute.",
|
||||
ident
|
||||
);
|
||||
}
|
||||
|
||||
Self {
|
||||
ident,
|
||||
field: field.to_owned(),
|
||||
id,
|
||||
ty,
|
||||
relation,
|
||||
defaultable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a type is Option<T>
|
||||
fn is_option_type(ty: &syn::Type) -> bool {
|
||||
match ty {
|
||||
syn::Type::Path(type_path) => {
|
||||
if let Some(segment) = type_path.path.segments.last() {
|
||||
segment.ident == "Option"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&GeormField> for proc_macro2::TokenStream {
|
||||
fn from(value: &GeormField) -> Self {
|
||||
let Some(relation) = value.relation.clone() else {
|
||||
return quote! {};
|
||||
};
|
||||
let function = syn::Ident::new(
|
||||
&format!("get_{}", relation.name),
|
||||
proc_macro2::Span::call_site(),
|
||||
);
|
||||
let entity = &relation.entity;
|
||||
let return_type = if relation.nullable {
|
||||
quote! { Option<#entity> }
|
||||
} else {
|
||||
quote! { #entity }
|
||||
};
|
||||
let query = format!(
|
||||
"SELECT * FROM {} WHERE {} = $1",
|
||||
relation.table, relation.remote_id
|
||||
);
|
||||
let local_ident = &value.field.ident;
|
||||
let fetch = if relation.nullable {
|
||||
quote! { fetch_optional }
|
||||
} else {
|
||||
quote! { fetch_one }
|
||||
};
|
||||
quote! {
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#return_type> {
|
||||
::sqlx::query_as!(#entity, #query, self.#local_ident).#fetch(pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
66
georm-macros/src/georm/ir/simple_relationship.rs
Normal file
66
georm-macros/src/georm/ir/simple_relationship.rs
Normal file
@ -0,0 +1,66 @@
|
||||
use quote::quote;
|
||||
|
||||
pub trait SimpleRelationshipType {}
|
||||
|
||||
#[derive(deluxe::ParseMetaItem, Default)]
|
||||
pub struct OneToOne;
|
||||
impl SimpleRelationshipType for OneToOne {}
|
||||
|
||||
#[derive(deluxe::ParseMetaItem, Default)]
|
||||
pub struct OneToMany;
|
||||
impl SimpleRelationshipType for OneToMany {}
|
||||
|
||||
#[derive(deluxe::ParseMetaItem)]
|
||||
pub struct SimpleRelationship<T>
|
||||
where
|
||||
T: SimpleRelationshipType + deluxe::ParseMetaItem + Default,
|
||||
{
|
||||
pub name: String,
|
||||
pub remote_id: String,
|
||||
pub table: String,
|
||||
pub entity: syn::Type,
|
||||
#[deluxe(default = T::default())]
|
||||
_phantom: T,
|
||||
}
|
||||
|
||||
impl<T> SimpleRelationship<T>
|
||||
where
|
||||
T: SimpleRelationshipType + deluxe::ParseMetaItem + Default,
|
||||
{
|
||||
pub fn make_query(&self) -> String {
|
||||
format!("SELECT * FROM {} WHERE {} = $1", self.table, self.remote_id)
|
||||
}
|
||||
|
||||
pub fn make_function_name(&self) -> syn::Ident {
|
||||
syn::Ident::new(
|
||||
&format!("get_{}", self.name),
|
||||
proc_macro2::Span::call_site(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SimpleRelationship<OneToOne>> for proc_macro2::TokenStream {
|
||||
fn from(value: &SimpleRelationship<OneToOne>) -> Self {
|
||||
let query = value.make_query();
|
||||
let entity = &value.entity;
|
||||
let function = value.make_function_name();
|
||||
quote! {
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Option<#entity>> {
|
||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_optional(pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SimpleRelationship<OneToMany>> for proc_macro2::TokenStream {
|
||||
fn from(value: &SimpleRelationship<OneToMany>) -> Self {
|
||||
let query = value.make_query();
|
||||
let entity = &value.entity;
|
||||
let function = value.make_function_name();
|
||||
quote! {
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
|
||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
use ir::GeormField;
|
||||
use quote::quote;
|
||||
|
||||
mod defaultable_struct;
|
||||
mod ir;
|
||||
mod relationships;
|
||||
mod trait_implementation;
|
||||
@ -51,9 +52,34 @@ pub fn georm_derive_macro2(
|
||||
let (fields, id) = extract_georm_field_attrs(&mut ast)?;
|
||||
let relationships = relationships::derive_relationships(&ast, &struct_attrs, &fields, &id);
|
||||
let trait_impl = trait_implementation::derive_trait(&ast, &struct_attrs.table, &fields, &id);
|
||||
let defaultable_struct =
|
||||
defaultable_struct::derive_defaultable_struct(&ast, &struct_attrs, &fields);
|
||||
let from_row_impl = generate_from_row_impl(&ast, &fields);
|
||||
let code = quote! {
|
||||
#relationships
|
||||
#trait_impl
|
||||
#defaultable_struct
|
||||
#from_row_impl
|
||||
};
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
fn generate_from_row_impl(
|
||||
ast: &syn::DeriveInput,
|
||||
fields: &[GeormField],
|
||||
) -> proc_macro2::TokenStream {
|
||||
let struct_name = &ast.ident;
|
||||
let field_idents: Vec<&syn::Ident> = fields.iter().map(|f| &f.ident).collect();
|
||||
let field_names: Vec<String> = fields.iter().map(|f| f.ident.to_string()).collect();
|
||||
|
||||
quote! {
|
||||
impl<'r> ::sqlx::FromRow<'r, ::sqlx::postgres::PgRow> for #struct_name {
|
||||
fn from_row(row: &'r ::sqlx::postgres::PgRow) -> ::sqlx::Result<Self> {
|
||||
use ::sqlx::Row;
|
||||
Ok(Self {
|
||||
#(#field_idents: row.try_get(#field_names)?),*
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::georm::ir::M2MRelationshipComplete;
|
||||
use crate::georm::ir::m2m_relationship::M2MRelationshipComplete;
|
||||
|
||||
use super::ir::GeormField;
|
||||
use proc_macro2::TokenStream;
|
||||
@ -15,16 +15,12 @@ fn join_token_streams(token_streams: &[TokenStream]) -> TokenStream {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn derive<T, P>(relationships: &[T], condition: P) -> TokenStream
|
||||
fn derive<T>(relationships: &[T]) -> TokenStream
|
||||
where
|
||||
for<'a> &'a T: Into<TokenStream>,
|
||||
P: FnMut(&&T) -> bool,
|
||||
{
|
||||
let implementations: Vec<TokenStream> = relationships
|
||||
.iter()
|
||||
.filter(condition)
|
||||
.map(std::convert::Into::into)
|
||||
.collect();
|
||||
let implementations: Vec<TokenStream> =
|
||||
relationships.iter().map(std::convert::Into::into).collect();
|
||||
join_token_streams(&implementations)
|
||||
}
|
||||
|
||||
@ -35,18 +31,20 @@ pub fn derive_relationships(
|
||||
id: &GeormField,
|
||||
) -> TokenStream {
|
||||
let struct_name = &ast.ident;
|
||||
let one_to_one = derive(fields, |field| field.relation.is_some());
|
||||
let one_to_many = derive(&struct_attrs.one_to_many, |_| true);
|
||||
let one_to_one_local = derive(fields);
|
||||
let one_to_one_remote = derive(&struct_attrs.one_to_one);
|
||||
let one_to_many = derive(&struct_attrs.one_to_many);
|
||||
let many_to_many: Vec<M2MRelationshipComplete> = struct_attrs
|
||||
.many_to_many
|
||||
.iter()
|
||||
.map(|v| M2MRelationshipComplete::new(v, &struct_attrs.table, id.ident.to_string()))
|
||||
.collect();
|
||||
let many_to_many = derive(&many_to_many, |_| true);
|
||||
let many_to_many = derive(&many_to_many);
|
||||
|
||||
quote! {
|
||||
impl #struct_name {
|
||||
#one_to_one
|
||||
#one_to_one_local
|
||||
#one_to_one_remote
|
||||
#one_to_many
|
||||
#many_to_many
|
||||
}
|
||||
|
@ -97,6 +97,47 @@ fn generate_delete_query(table: &str, id: &GeormField) -> proc_macro2::TokenStre
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_upsert_query(
|
||||
table: &str,
|
||||
fields: &[GeormField],
|
||||
id: &GeormField,
|
||||
) -> proc_macro2::TokenStream {
|
||||
let inputs: Vec<String> = (1..=fields.len()).map(|num| format!("${num}")).collect();
|
||||
let columns = fields
|
||||
.iter()
|
||||
.map(|f| f.ident.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
|
||||
// For ON CONFLICT DO UPDATE, exclude the ID field from updates
|
||||
let update_assignments = fields
|
||||
.iter()
|
||||
.filter(|f| !f.id)
|
||||
.map(|f| format!("{} = EXCLUDED.{}", f.ident, f.ident))
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
|
||||
let upsert_string = format!(
|
||||
"INSERT INTO {table} ({columns}) VALUES ({}) ON CONFLICT ({}) DO UPDATE SET {update_assignments} RETURNING *",
|
||||
inputs.join(", "),
|
||||
id.ident
|
||||
);
|
||||
|
||||
let field_idents: Vec<syn::Ident> = fields.iter().map(|f| f.ident.clone()).collect();
|
||||
|
||||
quote! {
|
||||
async fn create_or_update(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
|
||||
::sqlx::query_as!(
|
||||
Self,
|
||||
#upsert_string,
|
||||
#(self.#field_idents),*
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_get_id(id: &GeormField) -> proc_macro2::TokenStream {
|
||||
let ident = &id.ident;
|
||||
let ty = &id.ty;
|
||||
@ -125,6 +166,7 @@ pub fn derive_trait(
|
||||
let find_query = generate_find_query(table, id);
|
||||
let create_query = generate_create_query(table, fields);
|
||||
let update_query = generate_update_query(table, fields, id);
|
||||
let upsert_query = generate_upsert_query(table, fields, id);
|
||||
let delete_query = generate_delete_query(table, id);
|
||||
quote! {
|
||||
impl #impl_generics Georm<#ty> for #ident #type_generics #where_clause {
|
||||
@ -133,6 +175,7 @@ pub fn derive_trait(
|
||||
#find_query
|
||||
#create_query
|
||||
#update_query
|
||||
#upsert_query
|
||||
#delete_query
|
||||
}
|
||||
}
|
||||
|
10
src/defaultable.rs
Normal file
10
src/defaultable.rs
Normal file
@ -0,0 +1,10 @@
|
||||
pub trait Defaultable<Id, Entity> {
|
||||
/// Creates an entity in the database.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error the database may have encountered
|
||||
fn create(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Entity>> + Send;
|
||||
}
|
83
src/entity.rs
Normal file
83
src/entity.rs
Normal file
@ -0,0 +1,83 @@
|
||||
pub trait Georm<Id> {
|
||||
/// Find all the entities in the database.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn find_all(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl ::std::future::Future<Output = ::sqlx::Result<Vec<Self>>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Find the entiy in the database based on its identifier.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn find(
|
||||
pool: &sqlx::PgPool,
|
||||
id: &Id,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Option<Self>>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Create the entity in the database.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn create(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Update an entity with a matching identifier in the database.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn update(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Update an entity with a matching identifier in the database if
|
||||
/// it exists, create it otherwise.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn create_or_update(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Delete the entity from the database if it exists.
|
||||
///
|
||||
/// # Returns
|
||||
/// Returns the amount of rows affected by the deletion.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn delete(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
||||
|
||||
/// Delete any entity with the identifier `id`.
|
||||
///
|
||||
/// # Returns
|
||||
/// Returns the amount of rows affected by the deletion.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn delete_by_id(
|
||||
pool: &sqlx::PgPool,
|
||||
id: &Id,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
||||
|
||||
/// Returns the identifier of the entity.
|
||||
fn get_id(&self) -> &Id;
|
||||
}
|
92
src/georm.rs
Normal file
92
src/georm.rs
Normal file
@ -0,0 +1,92 @@
|
||||
pub trait Georm<Id> {
|
||||
/// Find all the entities in the database.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn find_all(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl ::std::future::Future<Output = ::sqlx::Result<Vec<Self>>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Find the entiy in the database based on its identifier.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn find(
|
||||
pool: &sqlx::PgPool,
|
||||
id: &Id,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Option<Self>>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Create the entity in the database.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn create(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Update an entity with a matching identifier in the database.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn update(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Update an entity with a matching identifier in the database if
|
||||
/// it exists, create it otherwise.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn create_or_update(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl ::std::future::Future<Output = sqlx::Result<Self>>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
async {
|
||||
if Self::find(pool, self.get_id()).await?.is_some() {
|
||||
self.update(pool).await
|
||||
} else {
|
||||
self.create(pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the entity from the database if it exists.
|
||||
///
|
||||
/// # Returns
|
||||
/// Returns the amount of rows affected by the deletion.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn delete(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
||||
|
||||
/// Delete any entity with the identifier `id`.
|
||||
///
|
||||
/// # Returns
|
||||
/// Returns the amount of rows affected by the deletion.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn delete_by_id(
|
||||
pool: &sqlx::PgPool,
|
||||
id: &Id,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
||||
|
||||
/// Returns the identifier of the entity.
|
||||
fn get_id(&self) -> &Id;
|
||||
}
|
213
src/lib.rs
213
src/lib.rs
@ -58,13 +58,13 @@
|
||||
//!
|
||||
//! Here is an explanation of what these different values mean:
|
||||
//!
|
||||
//! | Value Name | Explanation | Default value |
|
||||
//! |------------|-----------------------------------------------------------------------------------------|---------------|
|
||||
//! | entity | Rust type of the entity found in the database | N/A |
|
||||
//! | Value Name | Explanation | Default value |
|
||||
//! |------------|------------------------------------------------------------------------------------------|---------------|
|
||||
//! | entity | Rust type of the entity found in the database | N/A |
|
||||
//! | name | Name of the remote entity within the local entity; generates a method named `get_{name}` | N/A |
|
||||
//! | table | Database table where the entity is stored | N/A |
|
||||
//! | remote_id | Name of the column serving as the identifier of the entity | `"id"` |
|
||||
//! | nullable | Whether the relationship can be broken | `false` |
|
||||
//! | table | Database table where the entity is stored | N/A |
|
||||
//! | remote_id | Name of the column serving as the identifier of the entity | `"id"` |
|
||||
//! | nullable | Whether the relationship can be broken | `false` |
|
||||
//!
|
||||
//! Note that in this instance, the `remote_id` and `nullable` values can be
|
||||
//! omitted as this is their default value. This below is a strict equivalent:
|
||||
@ -81,6 +81,39 @@
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! But what if I have a one-to-one relationship with another entity and
|
||||
//! my current entity holds no data to reference that other identity? No
|
||||
//! worries, there is another way to declare such relationships.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! #[georm(
|
||||
//! one_to_one = [{
|
||||
//! name = "profile",
|
||||
//! remote_id = "user_id",
|
||||
//! table = "profiles",
|
||||
//! entity = User
|
||||
//! }]
|
||||
//! )]
|
||||
//! struct User {
|
||||
//! #[georm(id)]
|
||||
//! id: i32,
|
||||
//! username: String,
|
||||
//! hashed_password: String,
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! We now have access to the method `User::get_profile(&self, &pool:
|
||||
//! sqlx::PgPool) -> Option<User>`.
|
||||
//!
|
||||
//! Here is an explanation of the values of `one_to_many`:
|
||||
//!
|
||||
//! | Value Name | Explanaion | Default Value |
|
||||
//! |------------|------------------------------------------------------------------------------------------|---------------|
|
||||
//! | entity | Rust type of the entity found in the database | N/A |
|
||||
//! | name | Name of the remote entity within the local entity; generates a method named `get_{name}` | N/A |
|
||||
//! | table | Database table where the entity is stored | N/A |
|
||||
//! | remote_id | Name of the column serving as the identifier of the entity | `"id"` |
|
||||
//!
|
||||
//! ## One-to-many relationships
|
||||
//!
|
||||
//! Sometimes, our entity is the one being referenced to by multiple entities,
|
||||
@ -105,7 +138,7 @@
|
||||
//! entity = Post,
|
||||
//! name = "posts",
|
||||
//! table = "posts",
|
||||
//! remote_id = "id"
|
||||
//! remote_id = "author_id"
|
||||
//! }]
|
||||
//! )]
|
||||
//! struct User {
|
||||
@ -234,6 +267,76 @@
|
||||
//! | link.from | Column of the linking table referring to this entity | N/A |
|
||||
//! | link.to | Column of the linking table referring to the remote entity | N/A |
|
||||
//!
|
||||
//! ## Defaultable Fields
|
||||
//!
|
||||
//! Georm supports defaultable fields for entities where some fields have database
|
||||
//! defaults or are auto-generated (like serial IDs). When you mark fields as
|
||||
//! `defaultable`, Georm generates a companion struct that makes these fields
|
||||
//! optional during entity creation.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! #[derive(sqlx::FromRow, Georm)]
|
||||
//! #[georm(table = "posts")]
|
||||
//! pub struct Post {
|
||||
//! #[georm(id, defaultable)]
|
||||
//! id: i32, // Auto-generated serial
|
||||
//! title: String, // Required field
|
||||
//! #[georm(defaultable)]
|
||||
//! published: bool, // Has database default
|
||||
//! #[georm(defaultable)]
|
||||
//! created_at: chrono::DateTime<chrono::Utc>, // Has database default
|
||||
//! author_id: i32, // Required field
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! This generates a `PostDefault` struct where defaultable fields become `Option<T>`:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! // Generated automatically by the macro
|
||||
//! pub struct PostDefault {
|
||||
//! pub id: Option<i32>, // Can be None for auto-generation
|
||||
//! pub title: String, // Required field stays the same
|
||||
//! pub published: Option<bool>, // Can be None to use database default
|
||||
//! pub created_at: Option<chrono::DateTime<chrono::Utc>>, // Can be None
|
||||
//! pub author_id: i32, // Required field stays the same
|
||||
//! }
|
||||
//!
|
||||
//! impl Defaultable<i32, Post> for PostDefault {
|
||||
//! async fn create(&self, pool: &sqlx::PgPool) -> sqlx::Result<Post>;
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ### Usage Example
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use georm::{Georm, Defaultable};
|
||||
//!
|
||||
//! // Create a post with some fields using database defaults
|
||||
//! let post_default = PostDefault {
|
||||
//! id: None, // Let database auto-generate
|
||||
//! title: "My Blog Post".to_string(),
|
||||
//! published: None, // Use database default (e.g., false)
|
||||
//! created_at: None, // Use database default (e.g., NOW())
|
||||
//! author_id: 42,
|
||||
//! };
|
||||
//!
|
||||
//! // Create the entity in the database
|
||||
//! let created_post = post_default.create(&pool).await?;
|
||||
//! println!("Created post with ID: {}", created_post.id);
|
||||
//! ```
|
||||
//!
|
||||
//! ### Rules and Limitations
|
||||
//!
|
||||
//! - **Option fields cannot be marked as defaultable**: If a field is already
|
||||
//! `Option<T>`, you cannot mark it with `#[georm(defaultable)]`. This prevents
|
||||
//! `Option<Option<T>>` types.
|
||||
//! - **Field visibility is preserved**: The generated defaultable struct maintains
|
||||
//! the same field visibility (`pub`, `pub(crate)`, private) as the original struct.
|
||||
//! - **ID fields can be defaultable**: It's common to mark ID fields as defaultable
|
||||
//! when they are auto-generated serials in PostgreSQL.
|
||||
//! - **Only generates when needed**: The defaultable struct is only generated if
|
||||
//! at least one field is marked as defaultable.
|
||||
//!
|
||||
//! ## Limitations
|
||||
//! ### Database
|
||||
//!
|
||||
@ -249,95 +352,7 @@
|
||||
|
||||
pub use georm_macros::Georm;
|
||||
|
||||
pub trait Georm<Id> {
|
||||
/// Find all the entities in the database.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn find_all(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl ::std::future::Future<Output = ::sqlx::Result<Vec<Self>>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Find the entiy in the database based on its identifier.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn find(
|
||||
pool: &sqlx::PgPool,
|
||||
id: &Id,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Option<Self>>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Create the entity in the database.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn create(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Update an entity with a matching identifier in the database.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn update(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Update an entity with a matching identifier in the database if
|
||||
/// it exists, create it otherwise.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn create_or_update(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl ::std::future::Future<Output = sqlx::Result<Self>>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
async {
|
||||
if Self::find(pool, self.get_id()).await?.is_some() {
|
||||
self.update(pool).await
|
||||
} else {
|
||||
self.create(pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the entity from the database if it exists.
|
||||
///
|
||||
/// # Returns
|
||||
/// Returns the amount of rows affected by the deletion.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn delete(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
||||
|
||||
/// Delete any entity with the identifier `id`.
|
||||
///
|
||||
/// # Returns
|
||||
/// Returns the amount of rows affected by the deletion.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn delete_by_id(
|
||||
pool: &sqlx::PgPool,
|
||||
id: &Id,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
||||
|
||||
/// Returns the identifier of the entity.
|
||||
fn get_id(&self) -> &Id;
|
||||
}
|
||||
mod georm;
|
||||
pub use georm::Georm;
|
||||
mod defaultable;
|
||||
pub use defaultable::Defaultable;
|
||||
|
519
tests/defaultable_struct.rs
Normal file
519
tests/defaultable_struct.rs
Normal file
@ -0,0 +1,519 @@
|
||||
use georm::Georm;
|
||||
|
||||
// Test struct with defaultable fields using existing table structure
|
||||
#[derive(Georm, Debug)]
|
||||
#[georm(table = "authors")]
|
||||
struct TestAuthor {
|
||||
#[georm(id, defaultable)]
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub biography_id: Option<i32>, // Don't mark Option fields as defaultable
|
||||
}
|
||||
|
||||
// Test struct with only ID defaultable
|
||||
#[derive(Georm)]
|
||||
#[georm(table = "authors")]
|
||||
struct MinimalDefaultable {
|
||||
#[georm(id, defaultable)]
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub biography_id: Option<i32>,
|
||||
}
|
||||
|
||||
// Test struct with multiple defaultable fields
|
||||
#[derive(Georm)]
|
||||
#[georm(table = "authors")]
|
||||
struct MultiDefaultable {
|
||||
#[georm(id, defaultable)]
|
||||
pub id: i32,
|
||||
#[georm(defaultable)]
|
||||
pub name: String,
|
||||
pub biography_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaultable_struct_should_exist() {
|
||||
// This test will compile only if TestAuthorDefault struct exists
|
||||
let _author_default = TestAuthorDefault {
|
||||
id: Some(1), // Should be Option<i32> since ID is defaultable
|
||||
name: "Test Author".to_string(), // Should remain String
|
||||
biography_id: None, // Should remain Option<i32>
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_defaultable_struct_should_exist() {
|
||||
// MinimalDefaultableDefault should exist because ID is marked as defaultable
|
||||
let _minimal_default = MinimalDefaultableDefault {
|
||||
id: None, // Should be Option<i32>
|
||||
name: "testuser".to_string(), // Should remain String
|
||||
biography_id: None, // Should remain Option<i32>
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaultable_fields_can_be_none() {
|
||||
let _author_default = TestAuthorDefault {
|
||||
id: None, // Can be None since it's defaultable (auto-generated)
|
||||
name: "Test Author".to_string(),
|
||||
biography_id: None, // Can remain None
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_visibility_is_preserved() {
|
||||
let _author_default = TestAuthorDefault {
|
||||
id: Some(1), // pub
|
||||
name: "Test".to_string(), // pub
|
||||
biography_id: Some(1), // pub, Option<i32>
|
||||
};
|
||||
|
||||
// This test ensures field visibility is preserved in generated struct
|
||||
}
|
||||
|
||||
mod defaultable_tests {
|
||||
use super::*;
|
||||
use georm::Defaultable;
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||
async fn test_create_entity_from_defaultable_with_id(pool: PgPool) {
|
||||
// Test creating entity from defaultable struct with explicit ID
|
||||
let author_default = TestAuthorDefault {
|
||||
id: Some(999),
|
||||
name: "John Doe".to_string(),
|
||||
biography_id: None,
|
||||
};
|
||||
|
||||
let created_author = author_default.create(&pool).await.unwrap();
|
||||
|
||||
assert_eq!(created_author.id, 999);
|
||||
assert_eq!(created_author.name, "John Doe");
|
||||
assert_eq!(created_author.biography_id, None);
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||
async fn test_create_entity_from_defaultable_without_id(pool: PgPool) {
|
||||
// Test creating entity from defaultable struct with auto-generated ID
|
||||
let author_default = TestAuthorDefault {
|
||||
id: None, // Let database generate the ID
|
||||
name: "Jane Smith".to_string(),
|
||||
biography_id: None,
|
||||
};
|
||||
|
||||
let created_author = author_default.create(&pool).await.unwrap();
|
||||
|
||||
// ID should be auto-generated (positive value)
|
||||
assert!(created_author.id > 0);
|
||||
assert_eq!(created_author.name, "Jane Smith");
|
||||
assert_eq!(created_author.biography_id, None);
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||
async fn test_create_entity_from_minimal_defaultable(pool: PgPool) {
|
||||
// Test creating entity from minimal defaultable struct
|
||||
let minimal_default = MinimalDefaultableDefault {
|
||||
id: None,
|
||||
name: "Alice Wonder".to_string(),
|
||||
biography_id: Some(1), // Reference existing biography
|
||||
};
|
||||
|
||||
let created_author = minimal_default.create(&pool).await.unwrap();
|
||||
|
||||
assert!(created_author.id > 0);
|
||||
assert_eq!(created_author.name, "Alice Wonder");
|
||||
assert_eq!(created_author.biography_id, Some(1));
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||
async fn test_create_multiple_entities_from_defaultable(pool: PgPool) {
|
||||
// Test creating multiple entities to ensure ID generation works properly
|
||||
let author1_default = TestAuthorDefault {
|
||||
id: None,
|
||||
name: "Author One".to_string(),
|
||||
biography_id: None,
|
||||
};
|
||||
|
||||
let author2_default = TestAuthorDefault {
|
||||
id: None,
|
||||
name: "Author Two".to_string(),
|
||||
biography_id: None,
|
||||
};
|
||||
|
||||
let created_author1 = author1_default.create(&pool).await.unwrap();
|
||||
let created_author2 = author2_default.create(&pool).await.unwrap();
|
||||
|
||||
// Both should have unique IDs
|
||||
assert!(created_author1.id > 0);
|
||||
assert!(created_author2.id > 0);
|
||||
assert_ne!(created_author1.id, created_author2.id);
|
||||
|
||||
assert_eq!(created_author1.name, "Author One");
|
||||
assert_eq!(created_author2.name, "Author Two");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||
async fn test_multiple_defaultable_fields_all_none(pool: PgPool) {
|
||||
// Test with multiple defaultable fields all set to None
|
||||
let multi_default = MultiDefaultableDefault {
|
||||
id: None,
|
||||
name: None, // This should use database default or be handled gracefully
|
||||
biography_id: None,
|
||||
};
|
||||
|
||||
let result = multi_default.create(&pool).await;
|
||||
|
||||
// This might fail if database doesn't have a default for name
|
||||
// That's expected behavior - test documents the current behavior
|
||||
match result {
|
||||
Ok(created) => {
|
||||
assert!(created.id > 0);
|
||||
// If successful, name should have some default value
|
||||
},
|
||||
Err(e) => {
|
||||
// Expected if no database default for name column
|
||||
assert!(e.to_string().contains("null") || e.to_string().contains("NOT NULL"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||
async fn test_multiple_defaultable_fields_mixed(pool: PgPool) {
|
||||
// Test with some defaultable fields set and others None
|
||||
let multi_default = MultiDefaultableDefault {
|
||||
id: None, // Let database generate
|
||||
name: Some("Explicit Name".to_string()), // Explicit value
|
||||
biography_id: Some(1), // Reference existing biography
|
||||
};
|
||||
|
||||
let created = multi_default.create(&pool).await.unwrap();
|
||||
|
||||
assert!(created.id > 0);
|
||||
assert_eq!(created.name, "Explicit Name");
|
||||
assert_eq!(created.biography_id, Some(1));
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||
async fn test_multiple_defaultable_fields_all_explicit(pool: PgPool) {
|
||||
// Test with all defaultable fields having explicit values
|
||||
let multi_default = MultiDefaultableDefault {
|
||||
id: Some(888),
|
||||
name: Some("All Explicit".to_string()),
|
||||
biography_id: None,
|
||||
};
|
||||
|
||||
let created = multi_default.create(&pool).await.unwrap();
|
||||
|
||||
assert_eq!(created.id, 888);
|
||||
assert_eq!(created.name, "All Explicit");
|
||||
assert_eq!(created.biography_id, None);
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||
async fn test_error_duplicate_id(pool: PgPool) {
|
||||
// Test error handling for duplicate ID constraint violation
|
||||
let author1 = TestAuthorDefault {
|
||||
id: Some(777),
|
||||
name: "First Author".to_string(),
|
||||
biography_id: None,
|
||||
};
|
||||
|
||||
let author2 = TestAuthorDefault {
|
||||
id: Some(777), // Same ID - should cause constraint violation
|
||||
name: "Second Author".to_string(),
|
||||
biography_id: None,
|
||||
};
|
||||
|
||||
// First creation should succeed
|
||||
let _created1 = author1.create(&pool).await.unwrap();
|
||||
|
||||
// Second creation should fail due to duplicate key
|
||||
let result2 = author2.create(&pool).await;
|
||||
assert!(result2.is_err());
|
||||
|
||||
let error = result2.unwrap_err();
|
||||
let error_str = error.to_string();
|
||||
assert!(error_str.contains("duplicate") || error_str.contains("unique") || error_str.contains("UNIQUE"));
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||
async fn test_error_invalid_foreign_key(pool: PgPool) {
|
||||
// Test error handling for invalid foreign key reference
|
||||
let author_default = TestAuthorDefault {
|
||||
id: None,
|
||||
name: "Test Author".to_string(),
|
||||
biography_id: Some(99999), // Non-existent biography ID
|
||||
};
|
||||
|
||||
let result = author_default.create(&pool).await;
|
||||
|
||||
// This should fail if there's a foreign key constraint
|
||||
// If no constraint exists, it will succeed (documents current behavior)
|
||||
match result {
|
||||
Ok(created) => {
|
||||
// No foreign key constraint - this is valid behavior
|
||||
assert!(created.id > 0);
|
||||
assert_eq!(created.biography_id, Some(99999));
|
||||
},
|
||||
Err(e) => {
|
||||
// Foreign key constraint violation
|
||||
let error_str = e.to_string();
|
||||
assert!(error_str.contains("foreign") || error_str.contains("constraint") || error_str.contains("violates"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||
async fn test_error_connection_handling(pool: PgPool) {
|
||||
// Test behavior with a closed/invalid pool
|
||||
// Note: This is tricky to test without actually closing the pool
|
||||
// Instead, we test with extremely long string that might cause issues
|
||||
let author_default = TestAuthorDefault {
|
||||
id: None,
|
||||
name: "A".repeat(10000), // Very long string - might hit database limits
|
||||
biography_id: None,
|
||||
};
|
||||
|
||||
let result = author_default.create(&pool).await;
|
||||
|
||||
// This documents current behavior - might succeed or fail depending on DB limits
|
||||
match result {
|
||||
Ok(created) => {
|
||||
assert!(created.id > 0);
|
||||
assert_eq!(created.name.len(), 10000);
|
||||
},
|
||||
Err(e) => {
|
||||
// Some kind of database limit hit
|
||||
assert!(!e.to_string().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod sql_validation_tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||
async fn test_sql_generation_no_defaultable_fields(pool: PgPool) {
|
||||
// Test SQL generation when no defaultable fields have None values
|
||||
let author_default = TestAuthorDefault {
|
||||
id: Some(100),
|
||||
name: "Test Name".to_string(),
|
||||
biography_id: Some(1),
|
||||
};
|
||||
|
||||
// Capture the SQL by creating a custom query that logs the generated SQL
|
||||
// Since we can't directly inspect the generated SQL from the macro,
|
||||
// we test the behavior indirectly by ensuring all fields are included
|
||||
let created = author_default.create(&pool).await.unwrap();
|
||||
|
||||
// Verify all fields were properly inserted
|
||||
assert_eq!(created.id, 100);
|
||||
assert_eq!(created.name, "Test Name");
|
||||
assert_eq!(created.biography_id, Some(1));
|
||||
|
||||
// Verify the record exists in database with all expected values
|
||||
let found: TestAuthor = sqlx::query_as!(
|
||||
TestAuthor,
|
||||
"SELECT id, name, biography_id FROM authors WHERE id = $1",
|
||||
100
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(found.id, 100);
|
||||
assert_eq!(found.name, "Test Name");
|
||||
assert_eq!(found.biography_id, Some(1));
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||
async fn test_sql_generation_with_defaultable_none(pool: PgPool) {
|
||||
// Test SQL generation when defaultable fields are None (should be excluded)
|
||||
let author_default = TestAuthorDefault {
|
||||
id: None, // This should be excluded from INSERT
|
||||
name: "Auto ID Test".to_string(),
|
||||
biography_id: None,
|
||||
};
|
||||
|
||||
let created = author_default.create(&pool).await.unwrap();
|
||||
|
||||
// ID should be auto-generated (not explicitly set)
|
||||
assert!(created.id > 0);
|
||||
assert_eq!(created.name, "Auto ID Test");
|
||||
assert_eq!(created.biography_id, None);
|
||||
|
||||
// Verify the generated ID is actually from database auto-increment
|
||||
// by checking it's different from any manually set values
|
||||
assert_ne!(created.id, 100); // Different from previous test
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||
async fn test_sql_generation_mixed_defaultable_fields(pool: PgPool) {
|
||||
// Test SQL with multiple defaultable fields where some are None
|
||||
let multi_default = MultiDefaultableDefault {
|
||||
id: None, // Should be excluded
|
||||
name: Some("Explicit Name".to_string()), // Should be included
|
||||
biography_id: Some(1), // Should be included
|
||||
};
|
||||
|
||||
let created = multi_default.create(&pool).await.unwrap();
|
||||
|
||||
// Verify the mixed field inclusion worked correctly
|
||||
assert!(created.id > 0); // Auto-generated
|
||||
assert_eq!(created.name, "Explicit Name"); // Explicitly set
|
||||
assert_eq!(created.biography_id, Some(1)); // Explicitly set
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||
async fn test_placeholder_ordering_consistency(pool: PgPool) {
|
||||
// Test that placeholders are ordered correctly when fields are dynamically included
|
||||
// Create multiple records with different field combinations
|
||||
|
||||
// First: only non-defaultable fields
|
||||
let record1 = MultiDefaultableDefault {
|
||||
id: None,
|
||||
name: None,
|
||||
biography_id: Some(1),
|
||||
};
|
||||
|
||||
// Second: all fields explicit
|
||||
let record2 = MultiDefaultableDefault {
|
||||
id: Some(201),
|
||||
name: Some("Full Record".to_string()),
|
||||
biography_id: Some(1),
|
||||
};
|
||||
|
||||
// Third: mixed combination
|
||||
let record3 = MultiDefaultableDefault {
|
||||
id: None,
|
||||
name: Some("Mixed Record".to_string()),
|
||||
biography_id: None,
|
||||
};
|
||||
|
||||
// All should succeed with correct placeholder ordering
|
||||
let result1 = record1.create(&pool).await;
|
||||
let result2 = record2.create(&pool).await;
|
||||
let result3 = record3.create(&pool).await;
|
||||
|
||||
// Handle record1 based on whether name has a database default
|
||||
match result1 {
|
||||
Ok(created1) => {
|
||||
assert!(created1.id > 0);
|
||||
assert_eq!(created1.biography_id, Some(1));
|
||||
},
|
||||
Err(_) => {
|
||||
// Expected if name field has no database default
|
||||
}
|
||||
}
|
||||
|
||||
let created2 = result2.unwrap();
|
||||
assert_eq!(created2.id, 201);
|
||||
assert_eq!(created2.name, "Full Record");
|
||||
assert_eq!(created2.biography_id, Some(1));
|
||||
|
||||
let created3 = result3.unwrap();
|
||||
assert!(created3.id > 0);
|
||||
assert_eq!(created3.name, "Mixed Record");
|
||||
assert_eq!(created3.biography_id, None);
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||
async fn test_field_inclusion_logic(pool: PgPool) {
|
||||
// Test that the field inclusion logic works correctly
|
||||
// by creating records that should result in different SQL queries
|
||||
|
||||
let minimal = TestAuthorDefault {
|
||||
id: None,
|
||||
name: "Minimal".to_string(),
|
||||
biography_id: None,
|
||||
};
|
||||
|
||||
let maximal = TestAuthorDefault {
|
||||
id: Some(300),
|
||||
name: "Maximal".to_string(),
|
||||
biography_id: Some(1),
|
||||
};
|
||||
|
||||
let created_minimal = minimal.create(&pool).await.unwrap();
|
||||
let created_maximal = maximal.create(&pool).await.unwrap();
|
||||
|
||||
// Minimal should have auto-generated ID, explicit name, NULL biography_id
|
||||
assert!(created_minimal.id > 0);
|
||||
assert_eq!(created_minimal.name, "Minimal");
|
||||
assert_eq!(created_minimal.biography_id, None);
|
||||
|
||||
// Maximal should have all explicit values
|
||||
assert_eq!(created_maximal.id, 300);
|
||||
assert_eq!(created_maximal.name, "Maximal");
|
||||
assert_eq!(created_maximal.biography_id, Some(1));
|
||||
|
||||
// Verify they are different records
|
||||
assert_ne!(created_minimal.id, created_maximal.id);
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||
async fn test_returning_clause_functionality(pool: PgPool) {
|
||||
// Test that the RETURNING * clause works correctly with dynamic fields
|
||||
let author_default = TestAuthorDefault {
|
||||
id: None, // Should be populated by RETURNING clause
|
||||
name: "Return Test".to_string(),
|
||||
biography_id: None,
|
||||
};
|
||||
|
||||
let created = author_default.create(&pool).await.unwrap();
|
||||
|
||||
// Verify RETURNING clause populated all fields correctly
|
||||
assert!(created.id > 0); // Database-generated ID returned
|
||||
assert_eq!(created.name, "Return Test"); // Explicit value returned
|
||||
assert_eq!(created.biography_id, None); // NULL value returned correctly
|
||||
|
||||
// Double-check by querying the database directly
|
||||
let verified: TestAuthor = sqlx::query_as!(
|
||||
TestAuthor,
|
||||
"SELECT id, name, biography_id FROM authors WHERE id = $1",
|
||||
created.id
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(verified.id, created.id);
|
||||
assert_eq!(verified.name, created.name);
|
||||
assert_eq!(verified.biography_id, created.biography_id);
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||
async fn test_query_parameter_binding_order(pool: PgPool) {
|
||||
// Test that query parameters are bound in the correct order
|
||||
// This is critical for the dynamic SQL generation
|
||||
|
||||
// Create a record where the parameter order matters
|
||||
let test_record = MultiDefaultableDefault {
|
||||
id: Some(400), // This should be bound first (if included)
|
||||
name: Some("Param Order Test".to_string()), // This should be bound second (if included)
|
||||
biography_id: Some(1), // This should be bound last
|
||||
};
|
||||
|
||||
let created = test_record.create(&pool).await.unwrap();
|
||||
|
||||
// Verify all parameters were bound correctly
|
||||
assert_eq!(created.id, 400);
|
||||
assert_eq!(created.name, "Param Order Test");
|
||||
assert_eq!(created.biography_id, Some(1));
|
||||
|
||||
// Test with different parameter inclusion order
|
||||
let test_record2 = MultiDefaultableDefault {
|
||||
id: None, // Excluded - should not affect parameter order
|
||||
name: Some("No ID Test".to_string()), // Should be bound first now
|
||||
biography_id: Some(1), // Should be bound second now
|
||||
};
|
||||
|
||||
let created2 = test_record2.create(&pool).await.unwrap();
|
||||
|
||||
assert!(created2.id > 0); // Auto-generated
|
||||
assert_eq!(created2.name, "No ID Test");
|
||||
assert_eq!(created2.biography_id, Some(1));
|
||||
}
|
||||
}
|
||||
}
|
3
tests/fixtures/simple_struct.sql
vendored
3
tests/fixtures/simple_struct.sql
vendored
@ -1,6 +1,7 @@
|
||||
INSERT INTO biographies (content)
|
||||
VALUES ('Some text'),
|
||||
('Some other text');
|
||||
('Some other text'),
|
||||
('Biography for no one');
|
||||
|
||||
INSERT INTO authors (name, biography_id)
|
||||
VALUES ('J.R.R. Tolkien', 2),
|
||||
|
@ -1,14 +1,19 @@
|
||||
use georm::Georm;
|
||||
|
||||
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)]
|
||||
#[georm(table = "biographies")]
|
||||
#[derive(Debug, Georm, PartialEq, Eq, Default)]
|
||||
#[georm(
|
||||
table = "biographies",
|
||||
one_to_one = [{
|
||||
name = "author", remote_id = "biography_id", table = "authors", entity = Author
|
||||
}]
|
||||
)]
|
||||
pub struct Biography {
|
||||
#[georm(id)]
|
||||
pub id: i32,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)]
|
||||
#[derive(Debug, Georm, PartialEq, Eq, Default)]
|
||||
#[georm(table = "authors")]
|
||||
pub struct Author {
|
||||
#[georm(id)]
|
||||
@ -30,7 +35,7 @@ impl Ord for Author {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)]
|
||||
#[derive(Debug, Georm, PartialEq, Eq, Default)]
|
||||
#[georm(
|
||||
table = "books",
|
||||
one_to_many = [
|
||||
@ -63,7 +68,7 @@ impl Ord for Book {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq)]
|
||||
#[derive(Debug, Georm, PartialEq, Eq)]
|
||||
#[georm(table = "reviews")]
|
||||
pub struct Review {
|
||||
#[georm(id)]
|
||||
@ -73,7 +78,7 @@ pub struct Review {
|
||||
pub review: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq)]
|
||||
#[derive(Debug, Georm, PartialEq, Eq)]
|
||||
#[georm(
|
||||
table = "genres",
|
||||
many_to_many = [{
|
||||
|
@ -53,3 +53,24 @@ async fn books_are_found_despite_nonstandard_id_name(pool: sqlx::PgPool) -> sqlx
|
||||
assert_eq!(tolkien, book.get_author(&pool).await?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("simple_struct"))]
|
||||
async fn biographies_should_find_remote_o2o_author(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let london = Author::find(&pool, &3).await?.unwrap();
|
||||
let london_biography = Biography::find(&pool, &1).await?.unwrap();
|
||||
let result = london_biography.get_author(&pool).await;
|
||||
assert!(result.is_ok());
|
||||
let result = result.unwrap();
|
||||
assert!(result.is_some());
|
||||
let result = result.unwrap();
|
||||
assert_eq!(london, result);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("simple_struct"))]
|
||||
async fn biographies_may_not_have_corresponding_author(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let biography = Biography::find(&pool, &3).await?.unwrap();
|
||||
let result = biography.get_author(&pool).await?;
|
||||
assert!(result.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user