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:
parent
96ac2aa979
commit
bca0619f30
@ -1,7 +0,0 @@
|
||||
[all]
|
||||
out = ["Xml"]
|
||||
target-dir = "coverage"
|
||||
output-dir = "coverage"
|
||||
fail-under = 40
|
||||
exclude-files = ["target/*"]
|
||||
run-types = ["AllTargets"]
|
@ -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
110
Cargo.lock
generated
@ -418,6 +418,7 @@ name = "georm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"georm-macros",
|
||||
"rand 0.9.0",
|
||||
"sqlx",
|
||||
]
|
||||
|
||||
@ -439,7 +440,19 @@ checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"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]]
|
||||
@ -765,7 +778,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
@ -781,7 +794,7 @@ dependencies = [
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
@ -920,7 +933,7 @@ version = "0.2.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
"zerocopy 0.7.35",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -958,8 +971,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_chacha 0.3.1",
|
||||
"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]]
|
||||
@ -969,7 +993,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"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]]
|
||||
@ -978,7 +1012,17 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
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]]
|
||||
@ -1003,7 +1047,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"signature",
|
||||
"spki",
|
||||
"subtle",
|
||||
@ -1114,7 +1158,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1276,7 +1320,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"rsa",
|
||||
"sha1",
|
||||
"sha2",
|
||||
@ -1313,7 +1357,7 @@ dependencies = [
|
||||
"md-5",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@ -1406,7 +1450,7 @@ checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"getrandom 0.2.15",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"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"
|
||||
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]]
|
||||
name = "wasite"
|
||||
version = "0.1.0"
|
||||
@ -1779,6 +1832,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "write16"
|
||||
version = "1.0.0"
|
||||
@ -1822,7 +1884,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
|
||||
dependencies = [
|
||||
"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]]
|
||||
@ -1836,6 +1907,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.5"
|
||||
|
@ -34,6 +34,9 @@ features = ["postgres", "runtime-tokio", "macros", "migrate"]
|
||||
sqlx = { workspace = true }
|
||||
georm-macros = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
rand = "0.9"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
|
@ -1,11 +1,12 @@
|
||||
[licenses]
|
||||
# If there is a need to add another license, please refer to this
|
||||
# page: https://www.gnu.org/licenses/license-list.html
|
||||
# If there is a need to add another license, please refer to this page
|
||||
# for compatible licenses:
|
||||
# https://www.gnu.org/licenses/license-list.html
|
||||
allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "Unicode-3.0", "Zlib"]
|
||||
confidence-threshold = 0.8
|
||||
|
||||
[bans]
|
||||
multiple-versions = "warn"
|
||||
multiple-versions = "allow"
|
||||
wildcards = "allow"
|
||||
highlight = "all"
|
||||
workspace-default-features = "allow"
|
||||
|
@ -39,7 +39,6 @@ SQLX_OFFLINE="1" cargo build --release
|
||||
bacon
|
||||
cargo
|
||||
cargo-deny
|
||||
cargo-tarpaulin
|
||||
just
|
||||
rust-analyzer
|
||||
(rustVersion.override {
|
||||
|
@ -1,5 +1,4 @@
|
||||
use quote::quote;
|
||||
use std::fmt::{self, Display};
|
||||
|
||||
#[derive(deluxe::ExtractAttributes)]
|
||||
#[deluxe(attributes(georm))]
|
||||
@ -32,7 +31,7 @@ impl From<&O2MRelationship> for proc_macro2::TokenStream {
|
||||
);
|
||||
quote! {
|
||||
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! {
|
||||
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)]
|
||||
pub id: bool,
|
||||
#[deluxe(default = None)]
|
||||
pub column: Option<String>,
|
||||
#[deluxe(default = None)]
|
||||
pub relation: Option<O2ORelationship>,
|
||||
}
|
||||
|
||||
// #[georm(
|
||||
// table = "profileId",
|
||||
// one_to_one = { name = profile, id = "id", entity = Profile, nullable }
|
||||
// )]
|
||||
#[derive(deluxe::ParseMetaItem, Clone)]
|
||||
// #[georm(relation = { name = profile, id = "id", entity = Profile, nullable })]
|
||||
#[derive(deluxe::ParseMetaItem, Clone, Debug)]
|
||||
pub struct O2ORelationship {
|
||||
pub entity: syn::Type,
|
||||
pub table: String,
|
||||
@ -154,12 +148,11 @@ pub struct O2ORelationship {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GeormField {
|
||||
pub ident: syn::Ident,
|
||||
pub field: syn::Field,
|
||||
pub ty: syn::Type,
|
||||
pub column: Option<String>,
|
||||
pub id: bool,
|
||||
pub relation: Option<O2ORelationship>,
|
||||
}
|
||||
@ -170,40 +163,22 @@ impl GeormField {
|
||||
let ty = field.clone().ty;
|
||||
let attrs: GeormFieldAttributes =
|
||||
deluxe::extract_attributes(field).expect("Could not extract attributes from field");
|
||||
let GeormFieldAttributes {
|
||||
id,
|
||||
column,
|
||||
relation,
|
||||
} = attrs;
|
||||
let GeormFieldAttributes { id, relation } = attrs;
|
||||
Self {
|
||||
ident,
|
||||
field: field.to_owned(),
|
||||
id,
|
||||
ty,
|
||||
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 {
|
||||
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(),
|
||||
@ -225,8 +200,8 @@ impl From<&GeormField> for proc_macro2::TokenStream {
|
||||
quote! { fetch_one }
|
||||
};
|
||||
quote! {
|
||||
pub async fn #function(&value, pool: &::sqlx::PgPool) -> ::sqlx::Result<#return_type> {
|
||||
query_as!(#entity, #query, value.#local_ident).#fetch(pool).await
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#return_type> {
|
||||
::sqlx::query_as!(#entity, #query, self.#local_ident).#fetch(pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -49,12 +49,11 @@ pub fn georm_derive_macro2(
|
||||
let struct_attrs: ir::GeormStructAttributes =
|
||||
deluxe::extract_attributes(&mut ast).expect("Could not extract attributes from struct");
|
||||
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 trait_impl = trait_implementation::derive_trait(&ast, &struct_attrs.table, &fields, &id);
|
||||
let code = quote! {
|
||||
#trait_impl
|
||||
#relationships
|
||||
#trait_impl
|
||||
};
|
||||
println!("{code}");
|
||||
Ok(code)
|
||||
}
|
||||
|
@ -35,12 +35,12 @@ pub fn derive_relationships(
|
||||
id: &GeormField,
|
||||
) -> TokenStream {
|
||||
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 many_to_many: Vec<M2MRelationshipComplete> = struct_attrs
|
||||
.many_to_many
|
||||
.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();
|
||||
let many_to_many = derive(&many_to_many, |_| true);
|
||||
|
||||
|
@ -1,8 +1,17 @@
|
||||
use super::ir::GeormField;
|
||||
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 {
|
||||
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;
|
||||
quote! {
|
||||
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 *",
|
||||
fields
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.map(|f| f.ident.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(", "),
|
||||
inputs.join(", ")
|
||||
@ -47,11 +56,12 @@ fn generate_update_query(
|
||||
let update_columns = fields
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &field)| format!("{field} = ${}", i + 1))
|
||||
.map(|(i, &field)| format!("{} = ${}", field.ident, i + 1))
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
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.push(id);
|
||||
@ -70,7 +80,7 @@ fn generate_update_query(
|
||||
}
|
||||
|
||||
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;
|
||||
quote! {
|
||||
async fn delete_by_id(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<u64> {
|
||||
@ -104,13 +114,13 @@ pub fn derive_trait(
|
||||
id: &GeormField,
|
||||
) -> proc_macro2::TokenStream {
|
||||
let ty = &id.ty;
|
||||
let id_ident = &id.ident;
|
||||
|
||||
// define impl variables
|
||||
let ident = &ast.ident;
|
||||
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
|
||||
|
||||
// generate
|
||||
let get_all = generate_find_all_query(table);
|
||||
let get_id = generate_get_id(id);
|
||||
let find_query = generate_find_query(table, id);
|
||||
let create_query = generate_create_query(table, fields);
|
||||
@ -118,19 +128,11 @@ pub fn derive_trait(
|
||||
let delete_query = generate_delete_query(table, id);
|
||||
quote! {
|
||||
impl #impl_generics Georm<#ty> for #ident #type_generics #where_clause {
|
||||
#get_all
|
||||
#get_id
|
||||
#find_query
|
||||
#create_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
|
||||
}
|
||||
}
|
||||
|
33
justfile
33
justfile
@ -2,17 +2,11 @@ mod docker
|
||||
|
||||
default: lint
|
||||
|
||||
format:
|
||||
cargo fmt --all
|
||||
clean:
|
||||
cargo clean
|
||||
|
||||
format-check:
|
||||
cargo fmt --check --all
|
||||
|
||||
build:
|
||||
cargo build
|
||||
|
||||
build-release:
|
||||
cargo build --release
|
||||
test:
|
||||
cargo test --all-targets --all
|
||||
|
||||
lint:
|
||||
cargo clippy --all-targets
|
||||
@ -20,18 +14,19 @@ lint:
|
||||
audit:
|
||||
cargo deny check all
|
||||
|
||||
test:
|
||||
cargo test --all-targets --all
|
||||
build:
|
||||
cargo build
|
||||
|
||||
coverage:
|
||||
mkdir -p coverage
|
||||
cargo tarpaulin --config .tarpaulin.local.toml
|
||||
build-release:
|
||||
cargo build --release
|
||||
|
||||
coverage-ci:
|
||||
mkdir -p coverage
|
||||
cargo tarpaulin --config .tarpaulin.ci.toml
|
||||
format:
|
||||
cargo fmt --all
|
||||
|
||||
check-all: format-check lint coverage audit
|
||||
format-check:
|
||||
cargo fmt --check --all
|
||||
|
||||
check-all: format-check lint audit test
|
||||
|
||||
## Local Variables:
|
||||
## mode: makefile
|
||||
|
6
migrations/20250126153330_simple-struct-tests.down.sql
Normal file
6
migrations/20250126153330_simple-struct-tests.down.sql
Normal 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;
|
38
migrations/20250126153330_simple-struct-tests.up.sql
Normal file
38
migrations/20250126153330_simple-struct-tests.up.sql
Normal 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
|
||||
);
|
23
src/lib.rs
23
src/lib.rs
@ -1,6 +1,16 @@
|
||||
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
|
||||
@ -42,9 +52,18 @@ pub trait Georm<Id> {
|
||||
fn create_or_update(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||
) -> impl ::std::future::Future<Output = sqlx::Result<Self>>
|
||||
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.
|
||||
///
|
||||
|
10
tests/fixtures/o2o.sql
vendored
Normal file
10
tests/fixtures/o2o.sql
vendored
Normal 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
8
tests/fixtures/simple_struct.sql
vendored
Normal 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
63
tests/models.rs
Normal 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
55
tests/o2o_relationship.rs
Normal 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
164
tests/simple_struct.rs
Normal 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(())
|
||||
}
|
Loading…
Reference in New Issue
Block a user