feat: implement preliminary composite primary key support

Add support for entities with composite primary keys using multiple
#[georm(id)] fields. Automatically generates {EntityName}Id structs for
type-safe composite key handling.

Features:
- Multi-field primary key detection and ID struct generation
- Full CRUD operations (find, create, update, delete, create_or_update)
- Proper SQL generation with AND clauses for composite keys
- Updated documNtation in README and lib.rs

Note: Relationships not yet supported for composite key entities
This commit is contained in:
Lucien Cartier-Tilet 2025-06-07 16:16:46 +02:00
parent 190c4d7b1d
commit 19284665e6
Signed by: phundrak
SSH Key Fingerprint: SHA256:CE0HPsbW3L2YiJETx1zYZ2muMptaAqTN2g3498KrMkc
17 changed files with 712 additions and 75 deletions

223
Cargo.lock generated
View File

@ -23,6 +23,21 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "anstream" name = "anstream"
version = "0.6.19" version = "0.6.19"
@ -145,6 +160,12 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "bumpalo"
version = "3.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
@ -157,12 +178,36 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "cc"
version = "1.2.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "956a5e21988b87f372569b66183b78babf23ebc2e744b733e4350a752c4dafac"
dependencies = [
"shlex",
]
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.0" version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.39" version = "4.5.39"
@ -224,6 +269,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@ -552,6 +603,7 @@ dependencies = [
name = "georm" name = "georm"
version = "0.1.1" version = "0.1.1"
dependencies = [ dependencies = [
"chrono",
"georm-macros", "georm-macros",
"rand 0.9.1", "rand 0.9.1",
"sqlx", "sqlx",
@ -673,6 +725,30 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "iana-time-zone"
version = "0.1.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.0.0" version = "2.0.0"
@ -825,6 +901,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "js-sys"
version = "0.3.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@ -1232,6 +1318,12 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustversion"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d"
[[package]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.20" version = "1.0.20"
@ -1310,6 +1402,12 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]] [[package]]
name = "signal-hook" name = "signal-hook"
version = "0.3.18" version = "0.3.18"
@ -1418,6 +1516,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"chrono",
"crc", "crc",
"crossbeam-queue", "crossbeam-queue",
"either", "either",
@ -1474,7 +1573,9 @@ dependencies = [
"serde_json", "serde_json",
"sha2", "sha2",
"sqlx-core", "sqlx-core",
"sqlx-mysql",
"sqlx-postgres", "sqlx-postgres",
"sqlx-sqlite",
"syn", "syn",
"tokio", "tokio",
"url", "url",
@ -1491,6 +1592,7 @@ dependencies = [
"bitflags 2.9.1", "bitflags 2.9.1",
"byteorder", "byteorder",
"bytes", "bytes",
"chrono",
"crc", "crc",
"digest", "digest",
"dotenvy", "dotenvy",
@ -1511,6 +1613,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"rand 0.8.5", "rand 0.8.5",
"rsa", "rsa",
"serde",
"sha1", "sha1",
"sha2", "sha2",
"smallvec", "smallvec",
@ -1531,6 +1634,7 @@ dependencies = [
"base64", "base64",
"bitflags 2.9.1", "bitflags 2.9.1",
"byteorder", "byteorder",
"chrono",
"crc", "crc",
"dotenvy", "dotenvy",
"etcetera", "etcetera",
@ -1565,6 +1669,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
dependencies = [ dependencies = [
"atoi", "atoi",
"chrono",
"flume", "flume",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@ -1574,6 +1679,7 @@ dependencies = [
"libsqlite3-sys", "libsqlite3-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"serde",
"serde_urlencoded", "serde_urlencoded",
"sqlx-core", "sqlx-core",
"thiserror", "thiserror",
@ -1883,6 +1989,64 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
dependencies = [
"bumpalo",
"log",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
dependencies = [
"unicode-ident",
]
[[package]] [[package]]
name = "whoami" name = "whoami"
version = "1.6.0" version = "1.6.0"
@ -1915,6 +2079,65 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
[[package]]
name = "windows-result"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.48.0" version = "0.48.0"

View File

@ -39,8 +39,14 @@ sqlx = { workspace = true }
georm-macros = { workspace = true } georm-macros = { workspace = true }
[dev-dependencies] [dev-dependencies]
chrono = { version = "0.4", features = ["serde"] }
rand = "0.9" rand = "0.9"
[dev-dependencies.sqlx]
version = "0.8.6"
default-features = false
features = ["postgres", "runtime-tokio", "macros", "migrate", "chrono"]
[workspace.lints.rust] [workspace.lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"

View File

@ -41,6 +41,7 @@ Georm is a lightweight, opinionated Object-Relational Mapping (ORM) library buil
- **Zero Runtime Cost**: No reflection or runtime query building - **Zero Runtime Cost**: No reflection or runtime query building
- **Simple API**: Intuitive derive macros for common operations - **Simple API**: Intuitive derive macros for common operations
- **Relationship Support**: One-to-one, one-to-many, and many-to-many relationships - **Relationship Support**: One-to-one, one-to-many, and many-to-many relationships
- **Composite Primary Keys**: Support for multi-field primary keys
- **Defaultable Fields**: Easy entity creation with database defaults and auto-generated values - **Defaultable Fields**: Easy entity creation with database defaults and auto-generated values
- **PostgreSQL Native**: Optimized for PostgreSQL features and data types - **PostgreSQL Native**: Optimized for PostgreSQL features and data types
@ -148,6 +149,38 @@ async fn example(pool: &PgPool) -> sqlx::Result<()> {
## Advanced Features ## Advanced Features
### Composite Primary Keys
Georm supports composite primary keys by marking multiple fields with `#[georm(id)]`:
```rust
#[derive(Georm)]
#[georm(table = "user_roles")]
pub struct UserRole {
#[georm(id)]
pub user_id: i32,
#[georm(id)]
pub role_id: i32,
pub assigned_at: chrono::DateTime<chrono::Utc>,
}
```
This automatically generates a composite ID struct:
```rust
// Generated automatically
pub struct UserRoleId {
pub user_id: i32,
pub role_id: i32,
}
// Usage
let id = UserRoleId { user_id: 1, role_id: 2 };
let user_role = UserRole::find(pool, &id).await?;
```
**Note**: Relationships are not yet supported for entities with composite primary keys.
### Defaultable Fields ### Defaultable Fields
For fields with database defaults or auto-generated values, use the `defaultable` attribute: For fields with database defaults or auto-generated values, use the `defaultable` attribute:
@ -534,10 +567,10 @@ cargo run help # For a list of all available actions
- **Transaction Support**: Comprehensive transaction handling with atomic operations - **Transaction Support**: Comprehensive transaction handling with atomic operations
### Medium Priority ### Medium Priority
- **Composite Key Relationships**: Add relationship support (one-to-one, one-to-many, many-to-many) for entities with composite primary keys
- **Multi-Database Support**: MySQL and SQLite support with feature flags - **Multi-Database Support**: MySQL and SQLite support with feature flags
- **Field-Based Queries**: Generate `find_by_{field_name}` methods that return `Vec<T>` for regular fields or `Option<T>` for unique fields - **Field-Based Queries**: Generate `find_by_{field_name}` methods that return `Vec<T>` for regular fields or `Option<T>` for unique fields
- **Relationship Optimization**: Eager loading and N+1 query prevention - **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 - **Soft Delete**: Optional soft delete with `deleted_at` timestamps
### Lower Priority ### Lower Priority

View File

@ -0,0 +1,87 @@
use super::ir::GeormField;
use quote::quote;
#[derive(Debug)]
pub enum IdType {
Simple {
field_name: syn::Ident,
field_type: syn::Type,
},
Composite {
fields: Vec<IdField>,
field_type: syn::Ident,
},
}
#[derive(Debug, Clone)]
pub struct IdField {
pub name: syn::Ident,
pub ty: syn::Type,
}
fn field_to_code(field: &GeormField) -> proc_macro2::TokenStream {
let ident = field.ident.clone();
let ty = field.ty.clone();
quote! {
pub #ident: #ty
}
}
fn generate_struct(
ast: &syn::DeriveInput,
fields: &[GeormField],
) -> (syn::Ident, proc_macro2::TokenStream) {
let struct_name = &ast.ident;
let id_struct_name = quote::format_ident!("{struct_name}Id");
let vis = &ast.vis;
let fields: Vec<proc_macro2::TokenStream> = fields
.iter()
.filter_map(|field| {
if field.id {
Some(field_to_code(field))
} else {
None
}
})
.collect();
let code = quote! {
#vis struct #id_struct_name {
#(#fields),*
}
};
(id_struct_name, code)
}
pub fn create_primary_key(
ast: &syn::DeriveInput,
fields: &[GeormField],
) -> (IdType, proc_macro2::TokenStream) {
let georm_id_fields: Vec<&GeormField> = fields.iter().filter(|field| field.id).collect();
let id_fields: Vec<IdField> = georm_id_fields
.iter()
.map(|field| IdField {
name: field.ident.clone(),
ty: field.ty.clone(),
})
.collect();
match id_fields.len() {
0 => panic!("No ID field found"),
1 => (
IdType::Simple {
field_name: id_fields[0].name.clone(),
field_type: id_fields[0].ty.clone(),
},
quote! {},
),
_ => {
let (struct_name, struct_code) = generate_struct(ast, fields);
(
IdType::Composite {
fields: id_fields.clone(),
field_type: struct_name,
},
struct_code,
)
}
}
}

View File

@ -138,7 +138,6 @@ pub fn derive_defaultable_struct(
); );
quote! { quote! {
#[derive(Debug, Clone)]
#vis struct #defaultable_struct_name { #vis struct #defaultable_struct_name {
#(#defaultable_fields),* #(#defaultable_fields),*
} }

View File

@ -31,14 +31,14 @@ pub struct M2MRelationshipComplete {
} }
impl M2MRelationshipComplete { impl M2MRelationshipComplete {
pub fn new(other: &M2MRelationship, local_table: &String, local_id: String) -> Self { pub fn new(other: &M2MRelationship, local_table: &String, local_id: &String) -> Self {
Self { Self {
name: other.name.clone(), name: other.name.clone(),
entity: other.entity.clone(), entity: other.entity.clone(),
link: other.link.clone(), link: other.link.clone(),
local: Identifier { local: Identifier {
table: local_table.to_string(), table: local_table.to_string(),
id: local_id, id: local_id.to_string(),
}, },
remote: Identifier { remote: Identifier {
table: other.table.clone(), table: other.table.clone(),

View File

@ -1,14 +1,13 @@
use ir::GeormField; use ir::GeormField;
use quote::quote; use quote::quote;
mod composite_keys;
mod defaultable_struct; mod defaultable_struct;
mod ir; mod ir;
mod relationships; mod relationships;
mod trait_implementation; mod trait_implementation;
fn extract_georm_field_attrs( fn extract_georm_field_attrs(ast: &mut syn::DeriveInput) -> deluxe::Result<Vec<GeormField>> {
ast: &mut syn::DeriveInput,
) -> deluxe::Result<(Vec<GeormField>, GeormField)> {
let syn::Data::Struct(s) = &mut ast.data else { let syn::Data::Struct(s) = &mut ast.data else {
return Err(syn::Error::new_spanned( return Err(syn::Error::new_spanned(
ast, ast,
@ -26,23 +25,13 @@ fn extract_georm_field_attrs(
.into_iter() .into_iter()
.filter(|field| field.id) .filter(|field| field.id)
.collect(); .collect();
match identifiers.len() { if identifiers.is_empty() {
0 => Err(syn::Error::new_spanned( Err(syn::Error::new_spanned(
ast, ast,
"Struct {name} must have one identifier", "Struct {name} must have one identifier",
)),
1 => Ok((fields, identifiers.first().unwrap().clone())),
_ => {
let id1 = identifiers.first().unwrap();
let id2 = identifiers.get(1).unwrap();
Err(syn::Error::new_spanned(
id2.field.clone(),
format!(
"Field {} cannot be an identifier, {} already is one.\nOnly one identifier is supported.",
id1.ident, id2.ident
),
)) ))
} } else {
Ok(fields)
} }
} }
@ -52,16 +41,23 @@ pub fn georm_derive_macro2(
let mut ast: syn::DeriveInput = syn::parse2(item).expect("Failed to parse input"); let mut ast: syn::DeriveInput = syn::parse2(item).expect("Failed to parse input");
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 = 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 = let defaultable_struct =
defaultable_struct::derive_defaultable_struct(&ast, &struct_attrs, &fields); defaultable_struct::derive_defaultable_struct(&ast, &struct_attrs, &fields);
let from_row_impl = generate_from_row_impl(&ast, &fields); let from_row_impl = generate_from_row_impl(&ast, &fields);
let (identifier, id_struct) = composite_keys::create_primary_key(&ast, &fields);
let relationships =
relationships::derive_relationships(&ast, &struct_attrs, &fields, &identifier);
let trait_impl =
trait_implementation::derive_trait(&ast, &struct_attrs.table, &fields, &identifier);
let code = quote! { let code = quote! {
#id_struct
#defaultable_struct
#relationships #relationships
#trait_impl #trait_impl
#defaultable_struct
#from_row_impl #from_row_impl
}; };
Ok(code) Ok(code)

View File

@ -2,6 +2,7 @@ use std::str::FromStr;
use crate::georm::ir::m2m_relationship::M2MRelationshipComplete; use crate::georm::ir::m2m_relationship::M2MRelationshipComplete;
use super::composite_keys::IdType;
use super::ir::GeormField; use super::ir::GeormField;
use proc_macro2::TokenStream; use proc_macro2::TokenStream;
use quote::quote; use quote::quote;
@ -28,8 +29,24 @@ pub fn derive_relationships(
ast: &syn::DeriveInput, ast: &syn::DeriveInput,
struct_attrs: &super::ir::GeormStructAttributes, struct_attrs: &super::ir::GeormStructAttributes,
fields: &[GeormField], fields: &[GeormField],
id: &GeormField, id: &IdType,
) -> TokenStream { ) -> TokenStream {
let id = match id {
IdType::Simple {
field_name,
field_type: _,
} => field_name.to_string(),
IdType::Composite {
fields: _,
field_type: _,
} => {
eprintln!(
"Warning: entity {}: Relationships are not supported for entities with composite primary keys yet",
ast.ident
);
return quote! {};
}
};
let struct_name = &ast.ident; let struct_name = &ast.ident;
let one_to_one_local = derive(fields); let one_to_one_local = derive(fields);
let one_to_one_remote = derive(&struct_attrs.one_to_one); let one_to_one_remote = derive(&struct_attrs.one_to_one);
@ -37,7 +54,7 @@ pub fn derive_relationships(
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.ident.to_string())) .map(|v| M2MRelationshipComplete::new(v, &struct_attrs.table, &id))
.collect(); .collect();
let many_to_many = derive(&many_to_many); let many_to_many = derive(&many_to_many);

View File

@ -1,3 +1,4 @@
use super::composite_keys::IdType;
use super::ir::GeormField; use super::ir::GeormField;
use quote::quote; use quote::quote;
@ -10,16 +11,40 @@ fn generate_find_all_query(table: &str) -> proc_macro2::TokenStream {
} }
} }
fn generate_find_query(table: &str, id: &GeormField) -> proc_macro2::TokenStream { fn generate_find_query(table: &str, id: &IdType) -> proc_macro2::TokenStream {
let find_string = format!("SELECT * FROM {table} WHERE {} = $1", id.ident); match id {
let ty = &id.ty; IdType::Simple {
field_name,
field_type,
} => {
let find_string = format!("SELECT * FROM {table} WHERE {} = $1", field_name);
quote! { quote! {
async fn find(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<Option<Self>> { async fn find(pool: &::sqlx::PgPool, id: &#field_type) -> ::sqlx::Result<Option<Self>> {
::sqlx::query_as!(Self, #find_string, id) ::sqlx::query_as!(Self, #find_string, id)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
} }
} }
}
IdType::Composite { fields, field_type } => {
let id_match_string = fields
.iter()
.enumerate()
.map(|(i, field)| format!("{} = ${}", field.name, i + 1))
.collect::<Vec<String>>()
.join(" AND ");
let id_members: Vec<syn::Ident> =
fields.iter().map(|field| field.name.clone()).collect();
let find_string = format!("SELECT * FROM {table} WHERE {id_match_string}");
quote! {
async fn find(pool: &::sqlx::PgPool, id: &#field_type) -> ::sqlx::Result<Option<Self>> {
::sqlx::query_as!(Self, #find_string, #(id.#id_members),*)
.fetch_optional(pool)
.await
}
}
}
}
} }
fn generate_create_query(table: &str, fields: &[GeormField]) -> proc_macro2::TokenStream { fn generate_create_query(table: &str, fields: &[GeormField]) -> proc_macro2::TokenStream {
@ -50,28 +75,42 @@ fn generate_create_query(table: &str, fields: &[GeormField]) -> proc_macro2::Tok
fn generate_update_query( fn generate_update_query(
table: &str, table: &str,
fields: &[GeormField], fields: &[GeormField],
id: &GeormField, id: &IdType,
) -> proc_macro2::TokenStream { ) -> proc_macro2::TokenStream {
let mut fields: Vec<&GeormField> = fields.iter().filter(|f| !f.id).collect(); let non_id_fields: Vec<syn::Ident> = fields
let update_columns = fields .iter()
.filter_map(|f| if f.id { None } else { Some(f.ident.clone()) })
.collect();
let update_columns = non_id_fields
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, &field)| format!("{} = ${}", field.ident, i + 1)) .map(|(i, field)| format!("{} = ${}", field, i + 1))
.collect::<Vec<String>>() .collect::<Vec<String>>()
.join(", "); .join(", ");
let update_string = format!( let mut all_fields = non_id_fields.clone();
"UPDATE {table} SET {update_columns} WHERE {} = ${} RETURNING *", let where_clause = match id {
id.ident, IdType::Simple { field_name, .. } => {
fields.len() + 1 let where_clause = format!("{} = ${}", field_name, non_id_fields.len() + 1);
); all_fields.push(field_name.clone());
fields.push(id); where_clause
let field_idents: Vec<_> = fields.iter().map(|f| f.ident.clone()).collect(); }
IdType::Composite { fields, .. } => fields
.iter()
.enumerate()
.map(|(i, field)| {
let where_clause = format!("{} = ${}", field.name, non_id_fields.len() + i + 1);
all_fields.push(field.name.clone());
where_clause
})
.collect::<Vec<String>>()
.join(" AND "),
};
let update_string =
format!("UPDATE {table} SET {update_columns} WHERE {where_clause} RETURNING *");
quote! { quote! {
async fn update(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> { async fn update(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
::sqlx::query_as!( ::sqlx::query_as!(
Self, Self, #update_string, #(self.#all_fields),*
#update_string,
#(self.#field_idents),*
) )
.fetch_one(pool) .fetch_one(pool)
.await .await
@ -79,12 +118,31 @@ fn generate_update_query(
} }
} }
fn generate_delete_query(table: &str, id: &GeormField) -> proc_macro2::TokenStream { fn generate_delete_query(table: &str, id: &IdType) -> proc_macro2::TokenStream {
let delete_string = format!("DELETE FROM {table} WHERE {} = $1", id.ident); let where_clause = match id {
let ty = &id.ty; IdType::Simple { field_name, .. } => format!("{} = $1", field_name),
IdType::Composite { fields, .. } => fields
.iter()
.enumerate()
.map(|(i, field)| format!("{} = ${}", field.name, i + 1))
.collect::<Vec<String>>()
.join(" AND "),
};
let query_args = match id {
IdType::Simple { .. } => quote! { id },
IdType::Composite { fields, .. } => {
let fields: Vec<syn::Ident> = fields.iter().map(|f| f.name.clone()).collect();
quote! { #(id.#fields), * }
}
};
let id_type = match id {
IdType::Simple { field_type, .. } => quote! { #field_type },
IdType::Composite { field_type, .. } => quote! { #field_type },
};
let delete_string = format!("DELETE FROM {table} WHERE {where_clause}");
quote! { quote! {
async fn delete_by_id(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<u64> { async fn delete_by_id(pool: &::sqlx::PgPool, id: &#id_type) -> ::sqlx::Result<u64> {
let rows_affected = ::sqlx::query!(#delete_string, id) let rows_affected = ::sqlx::query!(#delete_string, #query_args)
.execute(pool) .execute(pool)
.await? .await?
.rows_affected(); .rows_affected();
@ -92,7 +150,7 @@ fn generate_delete_query(table: &str, id: &GeormField) -> proc_macro2::TokenStre
} }
async fn delete(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<u64> { async fn delete(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<u64> {
Self::delete_by_id(pool, self.get_id()).await Self::delete_by_id(pool, &self.get_id()).await
} }
} }
} }
@ -100,7 +158,7 @@ fn generate_delete_query(table: &str, id: &GeormField) -> proc_macro2::TokenStre
fn generate_upsert_query( fn generate_upsert_query(
table: &str, table: &str,
fields: &[GeormField], fields: &[GeormField],
id: &GeormField, id: &IdType,
) -> proc_macro2::TokenStream { ) -> proc_macro2::TokenStream {
let inputs: Vec<String> = (1..=fields.len()).map(|num| format!("${num}")).collect(); let inputs: Vec<String> = (1..=fields.len()).map(|num| format!("${num}")).collect();
let columns = fields let columns = fields
@ -109,6 +167,16 @@ fn generate_upsert_query(
.collect::<Vec<String>>() .collect::<Vec<String>>()
.join(", "); .join(", ");
let primary_key: proc_macro2::TokenStream = match id {
IdType::Simple { field_name, .. } => quote! {#field_name},
IdType::Composite { fields, .. } => {
let field_names: Vec<syn::Ident> = fields.iter().map(|f| f.name.clone()).collect();
quote! {
#(#field_names),*
}
}
};
// For ON CONFLICT DO UPDATE, exclude the ID field from updates // For ON CONFLICT DO UPDATE, exclude the ID field from updates
let update_assignments = fields let update_assignments = fields
.iter() .iter()
@ -120,7 +188,7 @@ fn generate_upsert_query(
let upsert_string = format!( let upsert_string = format!(
"INSERT INTO {table} ({columns}) VALUES ({}) ON CONFLICT ({}) DO UPDATE SET {update_assignments} RETURNING *", "INSERT INTO {table} ({columns}) VALUES ({}) ON CONFLICT ({}) DO UPDATE SET {update_assignments} RETURNING *",
inputs.join(", "), inputs.join(", "),
id.ident primary_key
); );
let field_idents: Vec<syn::Ident> = fields.iter().map(|f| f.ident.clone()).collect(); let field_idents: Vec<syn::Ident> = fields.iter().map(|f| f.ident.clone()).collect();
@ -138,12 +206,27 @@ fn generate_upsert_query(
} }
} }
fn generate_get_id(id: &GeormField) -> proc_macro2::TokenStream { fn generate_get_id(id: &IdType) -> proc_macro2::TokenStream {
let ident = &id.ident; match id {
let ty = &id.ty; IdType::Simple {
field_name,
field_type,
} => {
quote! { quote! {
fn get_id(&self) -> &#ty { fn get_id(&self) -> #field_type {
&self.#ident self.#field_name.clone()
}
}
}
IdType::Composite { fields, field_type } => {
let field_names: Vec<syn::Ident> = fields.iter().map(|f| f.name.clone()).collect();
quote! {
fn get_id(&self) -> #field_type {
#field_type {
#(#field_names: self.#field_names),*
}
}
}
} }
} }
} }
@ -152,9 +235,12 @@ pub fn derive_trait(
ast: &syn::DeriveInput, ast: &syn::DeriveInput,
table: &str, table: &str,
fields: &[GeormField], fields: &[GeormField],
id: &GeormField, id: &IdType,
) -> proc_macro2::TokenStream { ) -> proc_macro2::TokenStream {
let ty = &id.ty; let ty = match id {
IdType::Simple { field_type, .. } => quote! {#field_type},
IdType::Composite { field_type, .. } => quote! {#field_type},
};
// define impl variables // define impl variables
let ident = &ast.ident; let ident = &ast.ident;

View File

@ -0,0 +1,2 @@
-- Add down migration script here
DROP TABLE IF EXISTS UserRoles;

View File

@ -0,0 +1,7 @@
-- Add up migration script here
CREATE TABLE UserRoles (
user_id INTEGER NOT NULL,
role_id INTEGER NOT NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, role_id)
);

View File

@ -79,5 +79,5 @@ pub trait Georm<Id> {
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send; ) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
/// Returns the identifier of the entity. /// Returns the identifier of the entity.
fn get_id(&self) -> &Id; fn get_id(&self) -> Id;
} }

View File

@ -331,12 +331,64 @@
//! `Option<T>`, you cannot mark it with `#[georm(defaultable)]`. This prevents //! `Option<T>`, you cannot mark it with `#[georm(defaultable)]`. This prevents
//! `Option<Option<T>>` types. //! `Option<Option<T>>` types.
//! - **Field visibility is preserved**: The generated defaultable struct maintains //! - **Field visibility is preserved**: The generated defaultable struct maintains
//! the same field visibility (`pub`, `pub(crate)`, private) as the original struct. //! the same field visibility (`pub`, `pub(crate)`, private) as the original
//! - **ID fields can be defaultable**: It's common to mark ID fields as defaultable //! struct.
//! when they are auto-generated serials in PostgreSQL. //! - **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 //! - **Only generates when needed**: The defaultable struct is only generated if
//! at least one field is marked as defaultable. //! at least one field is marked as defaultable.
//! //!
//! ## Composite Primary Keys
//!
//! Georm supports composite primary keys by marking multiple fields with
//! `#[georm(id)]`:
//!
//! ```ignore
//! #[derive(sqlx::FromRow, Georm)]
//! #[georm(table = "user_roles")]
//! pub struct UserRole {
//! #[georm(id)]
//! user_id: i32,
//! #[georm(id)]
//! role_id: i32,
//! assigned_at: chrono::DateTime<chrono::Utc>,
//! }
//! ```
//!
//! When multiple fields are marked as ID fields, Georm automatically generates a
//! composite ID struct:
//!
//! ```ignore
//! // Generated automatically by the macro
//! pub struct UserRoleId {
//! pub user_id: i32,
//! pub role_id: i32,
//! }
//! ```
//!
//! This allows you to use the generated ID struct with all Georm methods:
//!
//! ```ignore
//! // Find by composite key
//! let id = UserRoleId { user_id: 1, role_id: 2 };
//! let user_role = UserRole::find(&pool, &id).await?;
//!
//! // Delete by composite key
//! UserRole::delete_by_id(&pool, &id).await?;
//!
//! // Get composite ID from instance
//! let user_role = UserRole { user_id: 1, role_id: 2, assigned_at: chrono::Utc::now() };
//! let id = user_role.get_id(); // Returns UserRoleId
//! ```
//!
//! ### Composite Key Limitations
//!
//! - **Relationships not supported**: Entities with composite primary keys cannot
//! yet define relationships (one-to-one, one-to-many, many-to-many) as those
//! features require single-field primary keys.
//! - **ID struct naming**: The generated ID struct follows the pattern
//! `{EntityName}Id`.
//!
//! ## Limitations //! ## Limitations
//! ### Database //! ### Database
//! //!
@ -346,9 +398,9 @@
//! ## Identifiers //! ## Identifiers
//! //!
//! Identifiers, or primary keys from the point of view of the database, may //! Identifiers, or primary keys from the point of view of the database, may
//! only be simple types recognized by SQLx. They also cannot be arrays, and //! be simple types recognized by SQLx or composite keys (multiple fields marked
//! optionals are only supported in one-to-one relationships when explicitly //! with `#[georm(id)]`). Single primary keys cannot be arrays, and optionals are
//! marked as nullables. //! only supported in one-to-one relationships when explicitly marked as nullables.
pub use georm_macros::Georm; pub use georm_macros::Georm;

112
tests/composite_key.rs Normal file
View File

@ -0,0 +1,112 @@
use georm::Georm;
mod models;
use models::{UserRole, UserRoleId};
#[sqlx::test(fixtures("composite_key"))]
async fn composite_key_find(pool: sqlx::PgPool) -> sqlx::Result<()> {
// This will test the find query generation bug
let id = models::UserRoleId {
user_id: 1,
role_id: 1,
};
let result = UserRole::find(&pool, &id).await?;
assert!(result.is_some());
let user_role = result.unwrap();
assert_eq!(1, user_role.user_id);
assert_eq!(1, user_role.role_id);
Ok(())
}
#[test]
fn composite_key_get_id() {
let user_role = UserRole {
user_id: 1,
role_id: 1,
assigned_at: chrono::Local::now().into(),
};
// This will test the get_id implementation bug
let id = user_role.get_id();
assert_eq!(1, id.user_id);
assert_eq!(1, id.role_id);
}
#[sqlx::test(fixtures("composite_key"))]
async fn composite_key_create_or_update(pool: sqlx::PgPool) -> sqlx::Result<()> {
let new_user_role = UserRole {
user_id: 5,
role_id: 2,
assigned_at: chrono::Local::now().into(),
};
// This will test the upsert query generation bug
let result = new_user_role.create_or_update(&pool).await?;
assert_eq!(5, result.user_id);
assert_eq!(2, result.role_id);
Ok(())
}
#[sqlx::test(fixtures("composite_key"))]
async fn composite_key_delete(pool: sqlx::PgPool) -> sqlx::Result<()> {
let id = models::UserRoleId {
user_id: 1,
role_id: 1,
};
let rows_affected = UserRole::delete_by_id(&pool, &id).await?;
assert_eq!(1, rows_affected);
// Verify it's deleted
let result = UserRole::find(&pool, &id).await?;
assert!(result.is_none());
Ok(())
}
#[sqlx::test(fixtures("composite_key"))]
async fn composite_key_find_all(pool: sqlx::PgPool) -> sqlx::Result<()> {
let all_user_roles = UserRole::find_all(&pool).await?;
assert_eq!(4, all_user_roles.len());
Ok(())
}
#[sqlx::test(fixtures("composite_key"))]
async fn composite_key_create(pool: sqlx::PgPool) -> sqlx::Result<()> {
let new_user_role = UserRole {
user_id: 10,
role_id: 5,
assigned_at: chrono::Local::now().into(),
};
let result = new_user_role.create(&pool).await?;
assert_eq!(new_user_role.user_id, result.user_id);
assert_eq!(new_user_role.role_id, result.role_id);
Ok(())
}
#[sqlx::test(fixtures("composite_key"))]
async fn composite_key_update(pool: sqlx::PgPool) -> sqlx::Result<()> {
let mut user_role = UserRole::find(
&pool,
&UserRoleId {
user_id: 1,
role_id: 1,
},
)
.await?
.unwrap();
let now: chrono::DateTime<chrono::Utc> = chrono::Local::now().into();
user_role.assigned_at = now;
let updated = user_role.update(&pool).await?;
assert_eq!(
now.timestamp_millis(),
updated.assigned_at.timestamp_millis()
);
assert_eq!(1, updated.user_id);
assert_eq!(1, updated.role_id);
Ok(())
}

6
tests/fixtures/composite_key.sql vendored Normal file
View File

@ -0,0 +1,6 @@
INSERT INTO UserRoles (user_id, role_id, assigned_at)
VALUES
(1, 1, '2024-01-01 10:00:00+00:00'),
(1, 2, '2024-01-02 11:00:00+00:00'),
(2, 1, '2024-01-03 12:00:00+00:00'),
(3, 3, '2024-01-04 13:00:00+00:00');

View File

@ -94,3 +94,14 @@ pub struct Genre {
id: i32, id: i32,
name: String, name: String,
} }
#[derive(Debug, Georm, PartialEq, Eq, Default)]
#[georm(table = "UserRoles")]
pub struct UserRole {
#[georm(id)]
pub user_id: i32,
#[georm(id)]
pub role_id: i32,
#[georm(defaultable)]
pub assigned_at: chrono::DateTime<chrono::Utc>,
}

View File

@ -143,12 +143,12 @@ async fn delete_by_id_should_delete_only_one_entry(pool: sqlx::PgPool) -> sqlx::
let id = 2; let id = 2;
let all_authors = Author::find_all(&pool).await?; let all_authors = Author::find_all(&pool).await?;
assert_eq!(3, all_authors.len()); assert_eq!(3, all_authors.len());
assert!(all_authors.iter().any(|author| author.get_id() == &id)); assert!(all_authors.iter().any(|author| author.get_id() == id));
let result = Author::delete_by_id(&pool, &id).await?; let result = Author::delete_by_id(&pool, &id).await?;
assert_eq!(1, result); assert_eq!(1, result);
let all_authors = Author::find_all(&pool).await?; let all_authors = Author::find_all(&pool).await?;
assert_eq!(2, all_authors.len()); assert_eq!(2, all_authors.len());
assert!(all_authors.iter().all(|author| author.get_id() != &id)); assert!(all_authors.iter().all(|author| author.get_id() != id));
Ok(()) Ok(())
} }