diff --git a/Cargo.lock b/Cargo.lock index e5c09cd..0fb8371 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,21 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "anstream" version = "0.6.19" @@ -145,6 +160,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" + [[package]] name = "byteorder" version = "1.5.0" @@ -157,12 +178,36 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "cc" +version = "1.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956a5e21988b87f372569b66183b78babf23ebc2e744b733e4350a752c4dafac" +dependencies = [ + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "clap" version = "4.5.39" @@ -224,6 +269,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -552,6 +603,7 @@ dependencies = [ name = "georm" version = "0.1.1" dependencies = [ + "chrono", "georm-macros", "rand 0.9.1", "sqlx", @@ -673,6 +725,30 @@ dependencies = [ "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]] name = "icu_collections" version = "2.0.0" @@ -825,6 +901,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "lazy_static" version = "1.5.0" @@ -1232,6 +1318,12 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + [[package]] name = "ryu" version = "1.0.20" @@ -1310,6 +1402,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook" version = "0.3.18" @@ -1418,6 +1516,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ "base64", "bytes", + "chrono", "crc", "crossbeam-queue", "either", @@ -1474,7 +1573,9 @@ dependencies = [ "serde_json", "sha2", "sqlx-core", + "sqlx-mysql", "sqlx-postgres", + "sqlx-sqlite", "syn", "tokio", "url", @@ -1491,6 +1592,7 @@ dependencies = [ "bitflags 2.9.1", "byteorder", "bytes", + "chrono", "crc", "digest", "dotenvy", @@ -1511,6 +1613,7 @@ dependencies = [ "percent-encoding", "rand 0.8.5", "rsa", + "serde", "sha1", "sha2", "smallvec", @@ -1531,6 +1634,7 @@ dependencies = [ "base64", "bitflags 2.9.1", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -1565,6 +1669,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", + "chrono", "flume", "futures-channel", "futures-core", @@ -1574,6 +1679,7 @@ dependencies = [ "libsqlite3-sys", "log", "percent-encoding", + "serde", "serde_urlencoded", "sqlx-core", "thiserror", @@ -1883,6 +1989,64 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "whoami" version = "1.6.0" @@ -1915,6 +2079,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index 7d3caf7..6d3cc9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,8 +39,14 @@ sqlx = { workspace = true } georm-macros = { workspace = true } [dev-dependencies] +chrono = { version = "0.4", features = ["serde"] } rand = "0.9" +[dev-dependencies.sqlx] +version = "0.8.6" +default-features = false +features = ["postgres", "runtime-tokio", "macros", "migrate", "chrono"] + [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index cc6d21c..df4b259 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Georm is a lightweight, opinionated Object-Relational Mapping (ORM) library buil - **Zero Runtime Cost**: No reflection or runtime query building - **Simple API**: Intuitive derive macros for common operations - **Relationship Support**: One-to-one, one-to-many, and many-to-many relationships +- **Composite Primary Keys**: Support for multi-field primary keys - **Defaultable Fields**: Easy entity creation with database defaults and auto-generated values - **PostgreSQL Native**: Optimized for PostgreSQL features and data types @@ -148,6 +149,38 @@ async fn example(pool: &PgPool) -> sqlx::Result<()> { ## 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, +} +``` + +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 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 ### 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 - **Field-Based Queries**: Generate `find_by_{field_name}` methods that return `Vec` for regular fields or `Option` for unique fields - **Relationship Optimization**: Eager loading and N+1 query prevention -- **Composite Primary Keys**: Multi-field primary key support - **Soft Delete**: Optional soft delete with `deleted_at` timestamps ### Lower Priority diff --git a/georm-macros/src/georm/composite_keys.rs b/georm-macros/src/georm/composite_keys.rs new file mode 100644 index 0000000..612e24e --- /dev/null +++ b/georm-macros/src/georm/composite_keys.rs @@ -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, + 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 = 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 = 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, + ) + } + } +} diff --git a/georm-macros/src/georm/defaultable_struct.rs b/georm-macros/src/georm/defaultable_struct.rs index 9beedac..cb6be3b 100644 --- a/georm-macros/src/georm/defaultable_struct.rs +++ b/georm-macros/src/georm/defaultable_struct.rs @@ -138,7 +138,6 @@ pub fn derive_defaultable_struct( ); quote! { - #[derive(Debug, Clone)] #vis struct #defaultable_struct_name { #(#defaultable_fields),* } diff --git a/georm-macros/src/georm/ir/m2m_relationship.rs b/georm-macros/src/georm/ir/m2m_relationship.rs index 36091d5..dc876cc 100644 --- a/georm-macros/src/georm/ir/m2m_relationship.rs +++ b/georm-macros/src/georm/ir/m2m_relationship.rs @@ -31,14 +31,14 @@ pub struct 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 { name: other.name.clone(), entity: other.entity.clone(), link: other.link.clone(), local: Identifier { table: local_table.to_string(), - id: local_id, + id: local_id.to_string(), }, remote: Identifier { table: other.table.clone(), diff --git a/georm-macros/src/georm/mod.rs b/georm-macros/src/georm/mod.rs index 805b71d..817d953 100644 --- a/georm-macros/src/georm/mod.rs +++ b/georm-macros/src/georm/mod.rs @@ -1,14 +1,13 @@ use ir::GeormField; use quote::quote; +mod composite_keys; mod defaultable_struct; mod ir; mod relationships; mod trait_implementation; -fn extract_georm_field_attrs( - ast: &mut syn::DeriveInput, -) -> deluxe::Result<(Vec, GeormField)> { +fn extract_georm_field_attrs(ast: &mut syn::DeriveInput) -> deluxe::Result> { let syn::Data::Struct(s) = &mut ast.data else { return Err(syn::Error::new_spanned( ast, @@ -26,23 +25,13 @@ fn extract_georm_field_attrs( .into_iter() .filter(|field| field.id) .collect(); - match identifiers.len() { - 0 => Err(syn::Error::new_spanned( + if identifiers.is_empty() { + Err(syn::Error::new_spanned( ast, "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 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 relationships = relationships::derive_relationships(&ast, &struct_attrs, &fields, &id); - let trait_impl = trait_implementation::derive_trait(&ast, &struct_attrs.table, &fields, &id); + let fields = extract_georm_field_attrs(&mut ast)?; let defaultable_struct = defaultable_struct::derive_defaultable_struct(&ast, &struct_attrs, &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! { + #id_struct + #defaultable_struct #relationships #trait_impl - #defaultable_struct #from_row_impl }; Ok(code) diff --git a/georm-macros/src/georm/relationships.rs b/georm-macros/src/georm/relationships.rs index 0ac318c..1837032 100644 --- a/georm-macros/src/georm/relationships.rs +++ b/georm-macros/src/georm/relationships.rs @@ -2,6 +2,7 @@ use std::str::FromStr; use crate::georm::ir::m2m_relationship::M2MRelationshipComplete; +use super::composite_keys::IdType; use super::ir::GeormField; use proc_macro2::TokenStream; use quote::quote; @@ -28,8 +29,24 @@ pub fn derive_relationships( ast: &syn::DeriveInput, struct_attrs: &super::ir::GeormStructAttributes, fields: &[GeormField], - id: &GeormField, + id: &IdType, ) -> 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 one_to_one_local = derive(fields); let one_to_one_remote = derive(&struct_attrs.one_to_one); @@ -37,7 +54,7 @@ pub fn derive_relationships( let many_to_many: Vec = struct_attrs .many_to_many .iter() - .map(|v| M2MRelationshipComplete::new(v, &struct_attrs.table, id.ident.to_string())) + .map(|v| M2MRelationshipComplete::new(v, &struct_attrs.table, &id)) .collect(); let many_to_many = derive(&many_to_many); diff --git a/georm-macros/src/georm/trait_implementation.rs b/georm-macros/src/georm/trait_implementation.rs index 4bc3647..9b9e277 100644 --- a/georm-macros/src/georm/trait_implementation.rs +++ b/georm-macros/src/georm/trait_implementation.rs @@ -1,3 +1,4 @@ +use super::composite_keys::IdType; use super::ir::GeormField; use quote::quote; @@ -10,14 +11,38 @@ fn generate_find_all_query(table: &str) -> proc_macro2::TokenStream { } } -fn generate_find_query(table: &str, id: &GeormField) -> proc_macro2::TokenStream { - 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> { - ::sqlx::query_as!(Self, #find_string, id) - .fetch_optional(pool) - .await +fn generate_find_query(table: &str, id: &IdType) -> proc_macro2::TokenStream { + match id { + IdType::Simple { + field_name, + field_type, + } => { + let find_string = format!("SELECT * FROM {table} WHERE {} = $1", field_name); + quote! { + async fn find(pool: &::sqlx::PgPool, id: &#field_type) -> ::sqlx::Result> { + ::sqlx::query_as!(Self, #find_string, id) + .fetch_optional(pool) + .await + } + } + } + IdType::Composite { fields, field_type } => { + let id_match_string = fields + .iter() + .enumerate() + .map(|(i, field)| format!("{} = ${}", field.name, i + 1)) + .collect::>() + .join(" AND "); + let id_members: Vec = + 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> { + ::sqlx::query_as!(Self, #find_string, #(id.#id_members),*) + .fetch_optional(pool) + .await + } + } } } } @@ -50,28 +75,42 @@ fn generate_create_query(table: &str, fields: &[GeormField]) -> proc_macro2::Tok fn generate_update_query( table: &str, fields: &[GeormField], - id: &GeormField, + id: &IdType, ) -> proc_macro2::TokenStream { - let mut fields: Vec<&GeormField> = fields.iter().filter(|f| !f.id).collect(); - let update_columns = fields + let non_id_fields: Vec = fields + .iter() + .filter_map(|f| if f.id { None } else { Some(f.ident.clone()) }) + .collect(); + let update_columns = non_id_fields .iter() .enumerate() - .map(|(i, &field)| format!("{} = ${}", field.ident, i + 1)) + .map(|(i, field)| format!("{} = ${}", field, i + 1)) .collect::>() .join(", "); - let update_string = format!( - "UPDATE {table} SET {update_columns} WHERE {} = ${} RETURNING *", - id.ident, - fields.len() + 1 - ); - fields.push(id); - let field_idents: Vec<_> = fields.iter().map(|f| f.ident.clone()).collect(); + let mut all_fields = non_id_fields.clone(); + let where_clause = match id { + IdType::Simple { field_name, .. } => { + let where_clause = format!("{} = ${}", field_name, non_id_fields.len() + 1); + all_fields.push(field_name.clone()); + where_clause + } + 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::>() + .join(" AND "), + }; + let update_string = + format!("UPDATE {table} SET {update_columns} WHERE {where_clause} RETURNING *"); quote! { async fn update(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result { ::sqlx::query_as!( - Self, - #update_string, - #(self.#field_idents),* + Self, #update_string, #(self.#all_fields),* ) .fetch_one(pool) .await @@ -79,12 +118,31 @@ fn generate_update_query( } } -fn generate_delete_query(table: &str, id: &GeormField) -> proc_macro2::TokenStream { - let delete_string = format!("DELETE FROM {table} WHERE {} = $1", id.ident); - let ty = &id.ty; +fn generate_delete_query(table: &str, id: &IdType) -> proc_macro2::TokenStream { + let where_clause = match id { + IdType::Simple { field_name, .. } => format!("{} = $1", field_name), + IdType::Composite { fields, .. } => fields + .iter() + .enumerate() + .map(|(i, field)| format!("{} = ${}", field.name, i + 1)) + .collect::>() + .join(" AND "), + }; + let query_args = match id { + IdType::Simple { .. } => quote! { id }, + IdType::Composite { fields, .. } => { + let fields: Vec = 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! { - async fn delete_by_id(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result { - let rows_affected = ::sqlx::query!(#delete_string, id) + async fn delete_by_id(pool: &::sqlx::PgPool, id: &#id_type) -> ::sqlx::Result { + let rows_affected = ::sqlx::query!(#delete_string, #query_args) .execute(pool) .await? .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 { - 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( table: &str, fields: &[GeormField], - id: &GeormField, + id: &IdType, ) -> proc_macro2::TokenStream { let inputs: Vec = (1..=fields.len()).map(|num| format!("${num}")).collect(); let columns = fields @@ -109,6 +167,16 @@ fn generate_upsert_query( .collect::>() .join(", "); + let primary_key: proc_macro2::TokenStream = match id { + IdType::Simple { field_name, .. } => quote! {#field_name}, + IdType::Composite { fields, .. } => { + let field_names: Vec = fields.iter().map(|f| f.name.clone()).collect(); + quote! { + #(#field_names),* + } + } + }; + // For ON CONFLICT DO UPDATE, exclude the ID field from updates let update_assignments = fields .iter() @@ -120,7 +188,7 @@ fn generate_upsert_query( let upsert_string = format!( "INSERT INTO {table} ({columns}) VALUES ({}) ON CONFLICT ({}) DO UPDATE SET {update_assignments} RETURNING *", inputs.join(", "), - id.ident + primary_key ); let field_idents: Vec = 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 { - let ident = &id.ident; - let ty = &id.ty; - quote! { - fn get_id(&self) -> &#ty { - &self.#ident +fn generate_get_id(id: &IdType) -> proc_macro2::TokenStream { + match id { + IdType::Simple { + field_name, + field_type, + } => { + quote! { + fn get_id(&self) -> #field_type { + self.#field_name.clone() + } + } + } + IdType::Composite { fields, field_type } => { + let field_names: Vec = 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, table: &str, fields: &[GeormField], - id: &GeormField, + id: &IdType, ) -> 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 let ident = &ast.ident; diff --git a/migrations/20250609181248_composite-key.down.sql b/migrations/20250609181248_composite-key.down.sql new file mode 100644 index 0000000..d0c556b --- /dev/null +++ b/migrations/20250609181248_composite-key.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +DROP TABLE IF EXISTS UserRoles; diff --git a/migrations/20250609181248_composite-key.up.sql b/migrations/20250609181248_composite-key.up.sql new file mode 100644 index 0000000..3cd323d --- /dev/null +++ b/migrations/20250609181248_composite-key.up.sql @@ -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) +); diff --git a/src/georm.rs b/src/georm.rs index 207564e..e43e575 100644 --- a/src/georm.rs +++ b/src/georm.rs @@ -79,5 +79,5 @@ pub trait Georm { ) -> impl std::future::Future> + Send; /// Returns the identifier of the entity. - fn get_id(&self) -> &Id; + fn get_id(&self) -> Id; } diff --git a/src/lib.rs b/src/lib.rs index 905bd44..db1b3c8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -331,12 +331,64 @@ //! `Option`, you cannot mark it with `#[georm(defaultable)]`. This prevents //! `Option>` types. //! - **Field visibility is preserved**: The generated defaultable struct maintains -//! the same field visibility (`pub`, `pub(crate)`, private) as the original struct. -//! - **ID fields can be defaultable**: It's common to mark ID fields as defaultable -//! when they are auto-generated serials in PostgreSQL. +//! the same field visibility (`pub`, `pub(crate)`, private) as the original +//! struct. +//! - **ID fields can be defaultable**: It's common to mark ID fields as +//! defaultable when they are auto-generated serials in PostgreSQL. //! - **Only generates when needed**: The defaultable struct is only generated if //! at least one field is marked as defaultable. //! +//! ## 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, +//! } +//! ``` +//! +//! 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 //! ### Database //! @@ -346,9 +398,9 @@ //! ## Identifiers //! //! 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 -//! optionals are only supported in one-to-one relationships when explicitly -//! marked as nullables. +//! be simple types recognized by SQLx or composite keys (multiple fields marked +//! with `#[georm(id)]`). Single primary keys cannot be arrays, and optionals are +//! only supported in one-to-one relationships when explicitly marked as nullables. pub use georm_macros::Georm; diff --git a/tests/composite_key.rs b/tests/composite_key.rs new file mode 100644 index 0000000..8c16079 --- /dev/null +++ b/tests/composite_key.rs @@ -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::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(()) +} diff --git a/tests/fixtures/composite_key.sql b/tests/fixtures/composite_key.sql new file mode 100644 index 0000000..81ec876 --- /dev/null +++ b/tests/fixtures/composite_key.sql @@ -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'); diff --git a/tests/models.rs b/tests/models.rs index 44a0745..b4e819f 100644 --- a/tests/models.rs +++ b/tests/models.rs @@ -94,3 +94,14 @@ pub struct Genre { id: i32, 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, +} diff --git a/tests/simple_struct.rs b/tests/simple_struct.rs index bdb208f..ea17dad 100644 --- a/tests/simple_struct.rs +++ b/tests/simple_struct.rs @@ -143,12 +143,12 @@ async fn delete_by_id_should_delete_only_one_entry(pool: sqlx::PgPool) -> sqlx:: 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)); + 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)); + assert!(all_authors.iter().all(|author| author.get_id() != id)); Ok(()) }