Compare commits

...

8 Commits

Author SHA1 Message Date
c8a23e1360
feat: implement efficient upsert operation for create_or_update
Some checks failed
CI / tests (push) Failing after 3m38s
Replace the existing two-query create_or_update implementation with a
single atomic PostgreSQL upsert using ON CONFLICT clause to eliminate
race conditions and improve performance.

Race condition fix:
The previous implementation had a critical race condition where
multiple concurrent requests could:
1. Both call find() and get None (record doesn't exist)
2. Both call create() and the second one fails with duplicate key
   error
3. Or between find() and create(), another transaction inserts the
   record

This created unreliable behavior in high-concurrency scenarios.

Changes:
- Add generate_upsert_query function in trait_implementation.rs
- Generate SQL with INSERT ... ON CONFLICT ... DO UPDATE SET pattern
- Remove default trait implementation that used separate
  find/create/update calls
- Update derive_trait to include upsert query generation
- Convert create_or_update from default implementation to required
  trait method

The new implementation eliminates race conditions while reducing
database round trips from 2-3 queries down to 1, significantly
improving both reliability and performance.
2025-06-06 11:09:55 +02:00
ca2434da9a
chore: migrate development environment from Nix flakes to devenv
Replace Nix flake-based development setup with devenv for better
developer experience and more streamlined environment management.

Changes:
  - Remove flake.nix and flake.lock files
  - Add devenv.nix, devenv.yaml, and devenv.lock configuration
  - Update .envrc to use devenv instead of nix develop
  - Remove Docker development setup (compose.dev.yml, docker/mod.just)
  - Expand .gitignore with comprehensive IDE and OS exclusions
  - Remove Docker-related just commands from justfile
2025-06-06 11:09:54 +02:00
1fdf236159
chore(flake): remove cargo from explicitely installed packages
Cargo is already installed with rustVersion
2025-06-06 11:09:18 +02:00
6fba12da56
docs: complete rewrite of README
Replaces the existing README with a comprehensive guide that
significantly improves the developer and user experience. The new README
provides complete documentation for all Georm features and a detailed
development setup guide.
2025-06-06 11:09:18 +02:00
11fce7a1e2
fix(deps): update tokio to 1.45.1 to address RUSTSEC-2025-0023
Updates tokio dependency to address security advisory RUSTSEC-2025-0023.
This ensures the codebase uses a secure version of the tokio runtime.
2025-06-06 11:09:18 +02:00
2add2fc9c2
docs: add roadmap with prioritized feature development plan 2025-06-06 11:09:18 +02:00
9cb87105bb
feat: add defaultable field support with companion struct generation
Introduces support for `#[georm(defaultable)]` attribute on entity
fields. When fields are marked as defaultable, generates companion
`<Entity>Default` structs where defaultable fields become `Option<T>`,
enabling easier entity creation when some fields have database defaults
or are auto-generated.

Key features:
- Generates `<Entity>Default` structs with optional defaultable fields
- Implements `Defaultable<Id, Entity>` trait with async `create` method
- Validates that `Option<T>` fields cannot be marked as defaultable
- Preserves field visibility in generated companion structs
- Only generates companion struct when defaultable fields are present
2025-06-06 11:09:18 +02:00
91d7651eca
feat: add foreign one_to_one relationships
All checks were successful
CI / tests (push) Successful in 4m20s
2025-03-02 16:12:20 +01:00
32 changed files with 3557 additions and 651 deletions

9
.envrc
View File

@ -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

View File

@ -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
View File

@ -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
View File

@ -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
View File

@ -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 youre 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 thats easy and straightforward to use. I am aware
some other projects exist, such as
[SeaORM](https://www.sea-ql.org/SeaORM/), but they generally dont 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 Im quite happy with it right now. But
of course, Im 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 SQLxs `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

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

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
View 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
View 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
View File

@ -0,0 +1,8 @@
inputs:
rust-overlay:
url: github:oxalica/rust-overlay
inputs:
nixpkgs:
follows: nixpkgs
nixpkgs:
url: github:cachix/devenv-nixpkgs/rolling

View File

@ -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:

View File

@ -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
View File

@ -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
}

View File

@ -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
];
};
});
}

View 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
}
}

View File

@ -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
}
}
}
}

View 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
}
}
}
}

View 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
}
}
}
}

View 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
}
}
}
}

View File

@ -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)?),*
})
}
}
}
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -1,5 +1,3 @@
mod docker
default: lint
clean:

10
src/defaultable.rs Normal file
View 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
View 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
View 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;
}

View File

@ -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
View 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));
}
}
}

View File

@ -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),

View File

@ -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 = [{

View File

@ -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(())
}