fix: simple ORM for one struct and foreign references work

Currently, all methods declared in the Georm trait are available.

If a struct has an ID pointing towards another entity, the user can
create a get method to get the entity pointed at from the database
too (local one-to-one relationship).

I still need to implement remote one-to-one relationships (one-to-one
relationships when the ID of the remote object is not available
locally).

I still need to also test and debug one-to-many relationships (ID of
the remote entiies not available locally) and many-to-many
relationships (declared in a dedicated table).

For now, IDs in all cases are simple types recognized by SQLx that are
not arrays. Options are only supported when explicitely specified for
one-to-one relationships.
This commit is contained in:
Lucien Cartier-Tilet 2025-01-31 21:58:36 +01:00
parent 96ac2aa979
commit bca0619f30
Signed by: phundrak
GPG Key ID: 347803E8073EACE0
19 changed files with 511 additions and 107 deletions

View File

@ -1,7 +0,0 @@
[all]
out = ["Xml"]
target-dir = "coverage"
output-dir = "coverage"
fail-under = 40
exclude-files = ["target/*"]
run-types = ["AllTargets"]

View File

@ -1,8 +0,0 @@
[all]
out = ["Html", "Lcov"]
skip-clean = true
target-dir = "coverage"
output-dir = "coverage"
fail-under = 40
exclude-files = ["target/*"]
run-types = ["AllTargets"]

110
Cargo.lock generated
View File

@ -418,6 +418,7 @@ name = "georm"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"georm-macros", "georm-macros",
"rand 0.9.0",
"sqlx", "sqlx",
] ]
@ -439,7 +440,19 @@ checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"wasi", "wasi 0.11.0+wasi-snapshot-preview1",
]
[[package]]
name = "getrandom"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8"
dependencies = [
"cfg-if",
"libc",
"wasi 0.13.3+wasi-0.2.2",
"windows-targets 0.52.6",
] ]
[[package]] [[package]]
@ -765,7 +778,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
dependencies = [ dependencies = [
"libc", "libc",
"wasi", "wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
@ -781,7 +794,7 @@ dependencies = [
"num-integer", "num-integer",
"num-iter", "num-iter",
"num-traits", "num-traits",
"rand", "rand 0.8.5",
"smallvec", "smallvec",
"zeroize", "zeroize",
] ]
@ -920,7 +933,7 @@ version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
dependencies = [ dependencies = [
"zerocopy", "zerocopy 0.7.35",
] ]
[[package]] [[package]]
@ -958,8 +971,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "rand_chacha 0.3.1",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.0"
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",
] ]
[[package]] [[package]]
@ -969,7 +993,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.0",
] ]
[[package]] [[package]]
@ -978,7 +1012,17 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.2.15",
]
[[package]]
name = "rand_core"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b08f3c9802962f7e1b25113931d94f43ed9725bebc59db9d0c3e9a23b67e15ff"
dependencies = [
"getrandom 0.3.1",
"zerocopy 0.8.14",
] ]
[[package]] [[package]]
@ -1003,7 +1047,7 @@ dependencies = [
"num-traits", "num-traits",
"pkcs1", "pkcs1",
"pkcs8", "pkcs8",
"rand_core", "rand_core 0.6.4",
"signature", "signature",
"spki", "spki",
"subtle", "subtle",
@ -1114,7 +1158,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"digest", "digest",
"rand_core", "rand_core 0.6.4",
] ]
[[package]] [[package]]
@ -1276,7 +1320,7 @@ dependencies = [
"memchr", "memchr",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
"rand", "rand 0.8.5",
"rsa", "rsa",
"sha1", "sha1",
"sha2", "sha2",
@ -1313,7 +1357,7 @@ dependencies = [
"md-5", "md-5",
"memchr", "memchr",
"once_cell", "once_cell",
"rand", "rand 0.8.5",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@ -1406,7 +1450,7 @@ checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"fastrand", "fastrand",
"getrandom", "getrandom 0.2.15",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.59.0", "windows-sys 0.59.0",
@ -1606,6 +1650,15 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasi"
version = "0.13.3+wasi-0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2"
dependencies = [
"wit-bindgen-rt",
]
[[package]] [[package]]
name = "wasite" name = "wasite"
version = "0.1.0" version = "0.1.0"
@ -1779,6 +1832,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "wit-bindgen-rt"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c"
dependencies = [
"bitflags",
]
[[package]] [[package]]
name = "write16" name = "write16"
version = "1.0.0" version = "1.0.0"
@ -1822,7 +1884,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
dependencies = [ dependencies = [
"byteorder", "byteorder",
"zerocopy-derive", "zerocopy-derive 0.7.35",
]
[[package]]
name = "zerocopy"
version = "0.8.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a367f292d93d4eab890745e75a778da40909cab4d6ff8173693812f79c4a2468"
dependencies = [
"zerocopy-derive 0.8.14",
] ]
[[package]] [[package]]
@ -1836,6 +1907,17 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "zerocopy-derive"
version = "0.8.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3931cb58c62c13adec22e38686b559c86a30565e16ad6e8510a337cedc611e1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.5" version = "0.1.5"

View File

@ -34,6 +34,9 @@ features = ["postgres", "runtime-tokio", "macros", "migrate"]
sqlx = { workspace = true } sqlx = { workspace = true }
georm-macros = { workspace = true } georm-macros = { workspace = true }
[dev-dependencies]
rand = "0.9"
[workspace.lints.rust] [workspace.lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"

View File

@ -1,11 +1,12 @@
[licenses] [licenses]
# If there is a need to add another license, please refer to this # If there is a need to add another license, please refer to this page
# page: https://www.gnu.org/licenses/license-list.html # for compatible licenses:
# https://www.gnu.org/licenses/license-list.html
allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "Unicode-3.0", "Zlib"] allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "Unicode-3.0", "Zlib"]
confidence-threshold = 0.8 confidence-threshold = 0.8
[bans] [bans]
multiple-versions = "warn" multiple-versions = "allow"
wildcards = "allow" wildcards = "allow"
highlight = "all" highlight = "all"
workspace-default-features = "allow" workspace-default-features = "allow"

View File

@ -39,7 +39,6 @@ SQLX_OFFLINE="1" cargo build --release
bacon bacon
cargo cargo
cargo-deny cargo-deny
cargo-tarpaulin
just just
rust-analyzer rust-analyzer
(rustVersion.override { (rustVersion.override {

View File

@ -1,5 +1,4 @@
use quote::quote; use quote::quote;
use std::fmt::{self, Display};
#[derive(deluxe::ExtractAttributes)] #[derive(deluxe::ExtractAttributes)]
#[deluxe(attributes(georm))] #[deluxe(attributes(georm))]
@ -32,7 +31,7 @@ impl From<&O2MRelationship> for proc_macro2::TokenStream {
); );
quote! { quote! {
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> { pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
query_as!(#entity, #query, self.get_id()).fetch_all(pool).await ::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
} }
} }
} }
@ -122,7 +121,7 @@ WHERE local.{} = $1
); );
quote! { quote! {
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> { pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
query_as!(#entity, #query, self.get_id()).fetch_all(pool).await ::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
} }
} }
} }
@ -134,16 +133,11 @@ struct GeormFieldAttributes {
#[deluxe(default = false)] #[deluxe(default = false)]
pub id: bool, pub id: bool,
#[deluxe(default = None)] #[deluxe(default = None)]
pub column: Option<String>,
#[deluxe(default = None)]
pub relation: Option<O2ORelationship>, pub relation: Option<O2ORelationship>,
} }
// #[georm( // #[georm(relation = { name = profile, id = "id", entity = Profile, nullable })]
// table = "profileId", #[derive(deluxe::ParseMetaItem, Clone, Debug)]
// one_to_one = { name = profile, id = "id", entity = Profile, nullable }
// )]
#[derive(deluxe::ParseMetaItem, Clone)]
pub struct O2ORelationship { pub struct O2ORelationship {
pub entity: syn::Type, pub entity: syn::Type,
pub table: String, pub table: String,
@ -154,12 +148,11 @@ pub struct O2ORelationship {
pub name: String, pub name: String,
} }
#[derive(Clone)] #[derive(Clone, Debug)]
pub struct GeormField { pub struct GeormField {
pub ident: syn::Ident, pub ident: syn::Ident,
pub field: syn::Field, pub field: syn::Field,
pub ty: syn::Type, pub ty: syn::Type,
pub column: Option<String>,
pub id: bool, pub id: bool,
pub relation: Option<O2ORelationship>, pub relation: Option<O2ORelationship>,
} }
@ -170,40 +163,22 @@ impl GeormField {
let ty = field.clone().ty; let ty = field.clone().ty;
let attrs: GeormFieldAttributes = let attrs: GeormFieldAttributes =
deluxe::extract_attributes(field).expect("Could not extract attributes from field"); deluxe::extract_attributes(field).expect("Could not extract attributes from field");
let GeormFieldAttributes { let GeormFieldAttributes { id, relation } = attrs;
id,
column,
relation,
} = attrs;
Self { Self {
ident, ident,
field: field.to_owned(), field: field.to_owned(),
id, id,
ty, ty,
relation, relation,
column,
} }
} }
} }
impl Display for GeormField {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"{}",
self.column
.clone()
.unwrap_or_else(|| self.ident.to_string())
)
}
}
impl From<&GeormField> for proc_macro2::TokenStream { impl From<&GeormField> for proc_macro2::TokenStream {
fn from(value: &GeormField) -> Self { fn from(value: &GeormField) -> Self {
let Some(relation) = value.relation.clone() else { let Some(relation) = value.relation.clone() else {
return quote! {}; return quote! {};
}; };
let function = syn::Ident::new( let function = syn::Ident::new(
&format!("get_{}", relation.name), &format!("get_{}", relation.name),
proc_macro2::Span::call_site(), proc_macro2::Span::call_site(),
@ -225,8 +200,8 @@ impl From<&GeormField> for proc_macro2::TokenStream {
quote! { fetch_one } quote! { fetch_one }
}; };
quote! { quote! {
pub async fn #function(&value, pool: &::sqlx::PgPool) -> ::sqlx::Result<#return_type> { pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#return_type> {
query_as!(#entity, #query, value.#local_ident).#fetch(pool).await ::sqlx::query_as!(#entity, #query, self.#local_ident).#fetch(pool).await
} }
} }
} }

View File

@ -49,12 +49,11 @@ pub fn georm_derive_macro2(
let struct_attrs: ir::GeormStructAttributes = let struct_attrs: ir::GeormStructAttributes =
deluxe::extract_attributes(&mut ast).expect("Could not extract attributes from struct"); deluxe::extract_attributes(&mut ast).expect("Could not extract attributes from struct");
let (fields, id) = extract_georm_field_attrs(&mut ast)?; let (fields, id) = extract_georm_field_attrs(&mut ast)?;
let trait_impl = trait_implementation::derive_trait(&ast, &struct_attrs.table, &fields, &id);
let relationships = relationships::derive_relationships(&ast, &struct_attrs, &fields, &id); let relationships = relationships::derive_relationships(&ast, &struct_attrs, &fields, &id);
let trait_impl = trait_implementation::derive_trait(&ast, &struct_attrs.table, &fields, &id);
let code = quote! { let code = quote! {
#trait_impl
#relationships #relationships
#trait_impl
}; };
println!("{code}");
Ok(code) Ok(code)
} }

View File

@ -35,12 +35,12 @@ pub fn derive_relationships(
id: &GeormField, id: &GeormField,
) -> TokenStream { ) -> TokenStream {
let struct_name = &ast.ident; let struct_name = &ast.ident;
let one_to_one = derive(fields, |field| field.relation.is_none()); 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_many = derive(&struct_attrs.one_to_many, |_| true);
let many_to_many: Vec<M2MRelationshipComplete> = struct_attrs let many_to_many: Vec<M2MRelationshipComplete> = struct_attrs
.many_to_many .many_to_many
.iter() .iter()
.map(|v| M2MRelationshipComplete::new(v, &struct_attrs.table, id.to_string())) .map(|v| M2MRelationshipComplete::new(v, &struct_attrs.table, id.ident.to_string()))
.collect(); .collect();
let many_to_many = derive(&many_to_many, |_| true); let many_to_many = derive(&many_to_many, |_| true);

View File

@ -1,8 +1,17 @@
use super::ir::GeormField; use super::ir::GeormField;
use quote::quote; use quote::quote;
fn generate_find_all_query(table: &str) -> proc_macro2::TokenStream {
let find_string = format!("SELECT * FROM {table}");
quote! {
async fn find_all(pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<Self>> {
::sqlx::query_as!(Self, #find_string).fetch_all(pool).await
}
}
}
fn generate_find_query(table: &str, id: &GeormField) -> proc_macro2::TokenStream { fn generate_find_query(table: &str, id: &GeormField) -> proc_macro2::TokenStream {
let find_string = format!("SELECT * FROM {table} WHERE {id} = $1",); let find_string = format!("SELECT * FROM {table} WHERE {} = $1", id.ident);
let ty = &id.ty; let ty = &id.ty;
quote! { quote! {
async fn find(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<Option<Self>> { async fn find(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<Option<Self>> {
@ -19,7 +28,7 @@ fn generate_create_query(table: &str, fields: &[GeormField]) -> proc_macro2::Tok
"INSERT INTO {table} ({}) VALUES ({}) RETURNING *", "INSERT INTO {table} ({}) VALUES ({}) RETURNING *",
fields fields
.iter() .iter()
.map(std::string::ToString::to_string) .map(|f| f.ident.to_string())
.collect::<Vec<String>>() .collect::<Vec<String>>()
.join(", "), .join(", "),
inputs.join(", ") inputs.join(", ")
@ -47,11 +56,12 @@ fn generate_update_query(
let update_columns = fields let update_columns = fields
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, &field)| format!("{field} = ${}", i + 1)) .map(|(i, &field)| format!("{} = ${}", field.ident, i + 1))
.collect::<Vec<String>>() .collect::<Vec<String>>()
.join(", "); .join(", ");
let update_string = format!( let update_string = format!(
"UPDATE {table} SET {update_columns} WHERE {id} = ${} RETURNING *", "UPDATE {table} SET {update_columns} WHERE {} = ${} RETURNING *",
id.ident,
fields.len() + 1 fields.len() + 1
); );
fields.push(id); fields.push(id);
@ -70,7 +80,7 @@ fn generate_update_query(
} }
fn generate_delete_query(table: &str, id: &GeormField) -> proc_macro2::TokenStream { fn generate_delete_query(table: &str, id: &GeormField) -> proc_macro2::TokenStream {
let delete_string = format!("DELETE FROM {table} WHERE {id} = $1"); let delete_string = format!("DELETE FROM {table} WHERE {} = $1", id.ident);
let ty = &id.ty; let ty = &id.ty;
quote! { quote! {
async fn delete_by_id(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<u64> { async fn delete_by_id(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<u64> {
@ -104,13 +114,13 @@ pub fn derive_trait(
id: &GeormField, id: &GeormField,
) -> proc_macro2::TokenStream { ) -> proc_macro2::TokenStream {
let ty = &id.ty; let ty = &id.ty;
let id_ident = &id.ident;
// define impl variables // define impl variables
let ident = &ast.ident; let ident = &ast.ident;
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl(); let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
// generate // generate
let get_all = generate_find_all_query(table);
let get_id = generate_get_id(id); let get_id = generate_get_id(id);
let find_query = generate_find_query(table, id); let find_query = generate_find_query(table, id);
let create_query = generate_create_query(table, fields); let create_query = generate_create_query(table, fields);
@ -118,19 +128,11 @@ pub fn derive_trait(
let delete_query = generate_delete_query(table, id); let delete_query = generate_delete_query(table, id);
quote! { quote! {
impl #impl_generics Georm<#ty> for #ident #type_generics #where_clause { impl #impl_generics Georm<#ty> for #ident #type_generics #where_clause {
#get_all
#get_id #get_id
#find_query #find_query
#create_query #create_query
#update_query #update_query
async fn create_or_update(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
if Self::find(pool, &self.#id_ident).await?.is_some() {
self.update(pool).await
} else {
self.create(pool).await
}
}
#delete_query #delete_query
} }
} }

View File

@ -2,17 +2,11 @@ mod docker
default: lint default: lint
format: clean:
cargo fmt --all cargo clean
format-check: test:
cargo fmt --check --all cargo test --all-targets --all
build:
cargo build
build-release:
cargo build --release
lint: lint:
cargo clippy --all-targets cargo clippy --all-targets
@ -20,18 +14,19 @@ lint:
audit: audit:
cargo deny check all cargo deny check all
test: build:
cargo test --all-targets --all cargo build
coverage: build-release:
mkdir -p coverage cargo build --release
cargo tarpaulin --config .tarpaulin.local.toml
coverage-ci: format:
mkdir -p coverage cargo fmt --all
cargo tarpaulin --config .tarpaulin.ci.toml
check-all: format-check lint coverage audit format-check:
cargo fmt --check --all
check-all: format-check lint audit test
## Local Variables: ## Local Variables:
## mode: makefile ## mode: makefile

View File

@ -0,0 +1,6 @@
DROP TABLE IF EXISTS reviews;
DROP TABLE IF EXISTS book_genres;
DROP TABLE IF EXISTS books;
DROP TABLE IF EXISTS genres;
DROP TABLE IF EXISTS authors;
DROP TABLE IF EXISTS biographies;

View File

@ -0,0 +1,38 @@
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)
);
CREATE TABLE books (
ident SERIAL PRIMARY KEY,
title VARCHAR(100) NOT NULL,
author_id INT NOT NULL,
FOREIGN KEY (author_id) REFERENCES authors(id) ON DELETE CASCADE
);
CREATE TABLE reviews (
id SERIAL PRIMARY KEY,
book_id INT NOT NULL,
review TEXT NOT NULL,
FOREIGN KEY (book_id) REFERENCES books(ident) ON DELETE CASCADE
);
CREATE TABLE genres (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
CREATE TABLE book_genres (
book_id INT NOT NULL,
genre_id INT NOT NULL,
PRIMARY KEY (book_id, genre_id),
FOREIGN KEY (book_id) REFERENCES books(ident) ON DELETE CASCADE,
FOREIGN KEY (genre_id) REFERENCES genres(id) ON DELETE CASCADE
);

View File

@ -1,6 +1,16 @@
pub use georm_macros::Georm; pub use georm_macros::Georm;
pub trait Georm<Id> { 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. /// Find the entiy in the database based on its identifier.
/// ///
/// # Errors /// # Errors
@ -42,9 +52,18 @@ pub trait Georm<Id> {
fn create_or_update( fn create_or_update(
&self, &self,
pool: &sqlx::PgPool, pool: &sqlx::PgPool,
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send ) -> impl ::std::future::Future<Output = sqlx::Result<Self>>
where where
Self: Sized; 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. /// Delete the entity from the database if it exists.
/// ///

10
tests/fixtures/o2o.sql vendored Normal file
View File

@ -0,0 +1,10 @@
INSERT INTO books (title, author_id)
VALUES ('The Lord of the Rings: The Fellowship of the Ring', 1),
('The Lord of the Rings: The Two Towers', 1),
('The Lord of the Rings: The Return of the King', 1),
('To Build a Fire', 3);
INSERT INTO reviews (book_id, review)
VALUES (1, 'Great book'),
(3, 'Awesome book'),
(2, 'Greatest book');

8
tests/fixtures/simple_struct.sql vendored Normal file
View File

@ -0,0 +1,8 @@
INSERT INTO biographies (content)
VALUES ('Some text'),
('Some other text');
INSERT INTO authors (name, biography_id)
VALUES ('J.R.R. Tolkien', 2),
('George Orwell', NULL),
('Jack London', 1);

63
tests/models.rs Normal file
View File

@ -0,0 +1,63 @@
use georm::Georm;
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)]
#[georm(table = "biographies")]
pub struct Biography {
#[georm(id)]
pub id: i32,
pub content: String,
}
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)]
#[georm(table = "authors")]
pub struct Author {
#[georm(id)]
pub id: i32,
pub name: String,
#[georm(relation = {entity = Biography, table = "biographies", name = "biography", nullable = true})]
pub biography_id: Option<i32>,
}
impl PartialOrd for Author {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.id.cmp(&other.id))
}
}
impl Ord for Author {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.id.cmp(&other.id)
}
}
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)]
#[georm(table = "books")]
pub struct Book {
#[georm(id)]
ident: i32,
title: String,
#[georm(relation = {entity = Author, table = "authors", name = "author"})]
author_id: i32,
}
impl PartialOrd for Book {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.ident.cmp(&other.ident))
}
}
impl Ord for Book {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.ident.cmp(&other.ident)
}
}
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq)]
#[georm(table = "reviews")]
pub struct Review {
#[georm(id)]
pub id: i32,
#[georm(relation = {entity = Book, table = "books", remote_id = "ident", name = "book"})]
pub book_id: i32,
pub review: String
}

55
tests/o2o_relationship.rs Normal file
View File

@ -0,0 +1,55 @@
use georm::Georm;
mod models;
use models::*;
#[sqlx::test(fixtures("simple_struct", "o2o"))]
async fn book_should_have_working_get_author_method(pool: sqlx::PgPool) -> sqlx::Result<()> {
let book = Book::find(&pool, &1).await?;
assert!(book.is_some());
let book = book.unwrap();
let author = book.get_author(&pool).await?;
let expected_author = Author {
id: 1,
name: "J.R.R. Tolkien".into(),
biography_id: Some(2),
};
assert_eq!(expected_author, author);
Ok(())
}
#[sqlx::test(fixtures("simple_struct"))]
async fn author_should_have_working_get_biography_method(pool: sqlx::PgPool) -> sqlx::Result<()> {
let author = Author::find(&pool, &1).await?;
assert!(author.is_some());
let author = author.unwrap();
let biography = author.get_biography(&pool).await?;
assert!(biography.is_some());
Ok(())
}
#[sqlx::test(fixtures("simple_struct"))]
async fn author_should_have_optional_biographies(pool: sqlx::PgPool) -> sqlx::Result<()> {
let tolkien = Author::find(&pool, &1).await?;
assert!(tolkien.is_some());
let tolkien_biography = tolkien.unwrap().get_biography(&pool).await?;
assert!(tolkien_biography.is_some());
let biography = Biography {
id: 2,
content: "Some other text".into(),
};
assert_eq!(biography, tolkien_biography.unwrap());
let orwell = Author::find(&pool, &2).await?;
assert!(orwell.is_some());
assert!(orwell.unwrap().get_biography(&pool).await?.is_none());
Ok(())
}
#[sqlx::test(fixtures("simple_struct", "o2o"))]
async fn books_are_found_despite_nonstandard_id_name(pool: sqlx::PgPool) -> sqlx::Result<()> {
let review = Review::find(&pool, &1).await?.unwrap();
let book = review.get_book(&pool).await?;
let tolkien = Author::find(&pool, &1).await?.unwrap();
assert_eq!(tolkien, book.get_author(&pool).await?);
Ok(())
}

164
tests/simple_struct.rs Normal file
View File

@ -0,0 +1,164 @@
use georm::Georm;
use rand::seq::SliceRandom;
use models::Author;
mod models;
#[sqlx::test(fixtures("simple_struct"))]
async fn find_all_query_works(pool: sqlx::PgPool) -> sqlx::Result<()> {
let result = Author::find_all(&pool).await?;
assert_eq!(3, result.len());
Ok(())
}
#[sqlx::test]
async fn find_all_returns_empty_vec_on_empty_table(pool: sqlx::PgPool) -> sqlx::Result<()> {
let result = Author::find_all(&pool).await?;
assert_eq!(0, result.len());
Ok(())
}
#[sqlx::test(fixtures("simple_struct"))]
async fn find_query_works(pool: sqlx::PgPool) -> sqlx::Result<()> {
let id = 1;
let res = Author::find(&pool, &id).await?;
assert!(res.is_some());
let res = res.unwrap();
assert_eq!(String::from("J.R.R. Tolkien"), res.name);
assert_eq!(1, res.id);
Ok(())
}
#[sqlx::test]
async fn find_returns_none_if_not_found(pool: sqlx::PgPool) -> sqlx::Result<()> {
let res = Author::find(&pool, &420).await?;
assert!(res.is_none());
Ok(())
}
#[sqlx::test]
async fn create_works(pool: sqlx::PgPool) -> sqlx::Result<()> {
let author = Author {
id: 1,
name: "J.R.R. Tolkien".into(),
..Default::default()
};
author.create(&pool).await?;
let all_authors = Author::find_all(&pool).await?;
assert_eq!(1, all_authors.len());
assert_eq!(vec![author], all_authors);
Ok(())
}
#[sqlx::test(fixtures("simple_struct"))]
async fn create_fails_if_already_exists(pool: sqlx::PgPool) -> sqlx::Result<()> {
let author = Author {
id: 2,
name: "Miura Kentaro".into(),
..Default::default()
};
let result = author.create(&pool).await;
assert!(result.is_err());
let error = result.err().unwrap();
assert_eq!("error returned from database: duplicate key value violates unique constraint \"authors_pkey\"", error.to_string());
Ok(())
}
#[sqlx::test(fixtures("simple_struct"))]
async fn update_works(pool: sqlx::PgPool) -> sqlx::Result<()> {
let expected_initial = Author {
name: "J.R.R. Tolkien".into(),
id: 1,
biography_id: Some(2),
};
let expected_final = Author {
name: "Jolkien Rolkien Rolkien Tolkien".into(),
id: 1,
biography_id: Some(2),
};
let tolkien = Author::find(&pool, &1).await?;
assert!(tolkien.is_some());
let mut tolkien = tolkien.unwrap();
assert_eq!(expected_initial, tolkien);
tolkien.name = expected_final.name.clone();
let updated = tolkien.update(&pool).await?;
assert_eq!(expected_final, updated);
Ok(())
}
#[sqlx::test]
async fn update_fails_if_not_already_exists(pool: sqlx::PgPool) -> sqlx::Result<()> {
let author = Author {
id: 2,
name: "Miura Kentaro".into(),
..Default::default()
};
let result = author.update(&pool).await;
assert!(result.is_err());
let error = result.err().unwrap();
assert_eq!(
"no rows returned by a query that expected to return at least one row",
error.to_string()
);
Ok(())
}
#[sqlx::test]
async fn should_create_if_does_not_exist(pool: sqlx::PgPool) -> sqlx::Result<()> {
let all_authors = Author::find_all(&pool).await?;
assert_eq!(0, all_authors.len());
let author = Author {
id: 4,
name: "Miura Kentaro".into(),
..Default::default()
};
author.create_or_update(&pool).await?;
let all_authors = Author::find_all(&pool).await?;
assert_eq!(1, all_authors.len());
Ok(())
}
#[sqlx::test(fixtures("simple_struct"))]
async fn should_update_if_exist(pool: sqlx::PgPool) -> sqlx::Result<()> {
let all_authors = Author::find_all(&pool).await?;
assert_eq!(3, all_authors.len());
let author = Author {
id: 2,
name: "Miura Kentaro".into(),
..Default::default()
};
author.create_or_update(&pool).await?;
let mut all_authors = Author::find_all(&pool).await?;
all_authors.sort();
assert_eq!(3, all_authors.len());
assert_eq!(author, all_authors[1]);
Ok(())
}
#[sqlx::test(fixtures("simple_struct"))]
async fn delete_by_id_should_delete_only_one_entry(pool: sqlx::PgPool) -> sqlx::Result<()> {
let id = 2;
let all_authors = Author::find_all(&pool).await?;
assert_eq!(3, all_authors.len());
assert!(all_authors.iter().any(|author| author.get_id() == &id));
let result = Author::delete_by_id(&pool, &id).await?;
assert_eq!(1, result);
let all_authors = Author::find_all(&pool).await?;
assert_eq!(2, all_authors.len());
assert!(all_authors.iter().all(|author| author.get_id() != &id));
Ok(())
}
#[sqlx::test(fixtures("simple_struct"))]
async fn delete_should_delete_current_entity_from_db(pool: sqlx::PgPool) -> sqlx::Result<()> {
let mut all_authors = Author::find_all(&pool).await?;
assert_eq!(3, all_authors.len());
all_authors.shuffle(&mut rand::rng());
let author = all_authors.first().unwrap();
let result = author.delete(&pool).await?;
assert_eq!(1, result);
let all_authors = Author::find_all(&pool).await?;
assert_eq!(2, all_authors.len());
assert!(all_authors.iter().all(|a| a.get_id() != author.get_id()));
Ok(())
}