From 32828f9ee5d76a7799082f64ff3fd3cac97d803e Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Wed, 4 Jun 2025 22:15:38 +0200 Subject: [PATCH] feat: add defaultable field support with companion struct generation Introduces support for `#[georm(defaultable)]` attribute on entity fields. When fields are marked as defaultable, generates companion `Default` structs where defaultable fields become `Option`, enabling easier entity creation when some fields have database defaults or are auto-generated. Key features: - Generates `Default` structs with optional defaultable fields - Implements `Defaultable` trait with async `create` method - Validates that `Option` fields cannot be marked as defaultable - Preserves field visibility in generated companion structs - Only generates companion struct when defaultable fields are present --- georm-macros/src/georm/defaultable_struct.rs | 110 ++++++++++++ georm-macros/src/georm/ir/mod.rs | 34 +++- georm-macros/src/georm/mod.rs | 4 + src/defaultable.rs | 10 ++ src/entity.rs | 92 ++++++++++ src/georm.rs | 92 ++++++++++ src/lib.rs | 166 +++++++++---------- tests/defaultable_struct.rs | 61 +++++++ 8 files changed, 476 insertions(+), 93 deletions(-) create mode 100644 georm-macros/src/georm/defaultable_struct.rs create mode 100644 src/defaultable.rs create mode 100644 src/entity.rs create mode 100644 src/georm.rs create mode 100644 tests/defaultable_struct.rs diff --git a/georm-macros/src/georm/defaultable_struct.rs b/georm-macros/src/georm/defaultable_struct.rs new file mode 100644 index 0000000..2e3d459 --- /dev/null +++ b/georm-macros/src/georm/defaultable_struct.rs @@ -0,0 +1,110 @@ +//! This module creates the defaultable version of a structured derived with +//! Georm. It creates a new struct named `Default` where the fields +//! marked as defaultable become an `Option`, where `T` is the initial type +//! of the field. +//! +//! The user does not have to mark a field defaultable if the field already has +//! a type `Option`. It is intended only for fields marked as `NOT NULL` in +//! the database, but not required when creating the entity due to a `DEFAULT` +//! or something similar. The type `Default` implements the +//! `Defaultable` trait. + +use super::ir::{GeormField, GeormStructAttributes}; +use quote::quote; + +fn create_defaultable_field(field: &GeormField) -> proc_macro2::TokenStream { + let ident = &field.ident; + let ty = &field.ty; + let vis = &field.field.vis; + + // If the field is marked as defaultable, wrap it in Option + // Otherwise, keep the original type + let field_type = if field.defaultable { + quote! { Option<#ty> } + } else { + quote! { #ty } + }; + + quote! { + #vis #ident: #field_type + } +} + +fn generate_defaultable_trait_impl( + struct_name: &syn::Ident, + defaultable_struct_name: &syn::Ident, + struct_attrs: &GeormStructAttributes, + fields: &[GeormField], +) -> proc_macro2::TokenStream { + let table = &struct_attrs.table; + + // Find the ID field + let id_field = fields + .iter() + .find(|field| field.id) + .expect("Must have an ID field"); + let id_type = &id_field.ty; + + // Generate the INSERT query for defaultable struct + let field_names: Vec = fields.iter().map(|f| f.ident.to_string()).collect(); + let field_placeholders: Vec = + (1..=field_names.len()).map(|i| format!("${}", i)).collect(); + + let insert_query = format!( + "INSERT INTO {} ({}) VALUES ({}) RETURNING *", + table, + field_names.join(", "), + field_placeholders.join(", ") + ); + + // Generate field identifiers for binding + let field_idents: Vec<&syn::Ident> = fields.iter().map(|field| &field.ident).collect(); + + quote! { + impl ::georm::Defaultable<#id_type, #struct_name> for #defaultable_struct_name { + async fn create(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#struct_name> { + ::sqlx::query_as!( + #struct_name, + #insert_query, + #(self.#field_idents),* + ) + .fetch_one(pool) + .await + } + } + } +} + +pub fn derive_defaultable_struct( + ast: &syn::DeriveInput, + struct_attrs: &GeormStructAttributes, + fields: &[GeormField], +) -> proc_macro2::TokenStream { + // Only generate if there are defaultable fields + if fields.iter().all(|field| !field.defaultable) { + return quote! {}; + } + + let struct_name = &ast.ident; + let vis = &ast.vis; + let defaultable_struct_name = quote::format_ident!("{}Default", struct_name); + + let defaultable_fields: Vec = + fields.iter().map(create_defaultable_field).collect(); + + let trait_impl = generate_defaultable_trait_impl( + struct_name, + &defaultable_struct_name, + struct_attrs, + fields, + ); + + quote! { + #[derive(Debug, Clone)] + #vis struct #defaultable_struct_name { + #(#defaultable_fields),* + } + + #trait_impl + } +} diff --git a/georm-macros/src/georm/ir/mod.rs b/georm-macros/src/georm/ir/mod.rs index 84f61e8..e9f1d69 100644 --- a/georm-macros/src/georm/ir/mod.rs +++ b/georm-macros/src/georm/ir/mod.rs @@ -25,6 +25,8 @@ struct GeormFieldAttributes { pub id: bool, #[deluxe(default = None)] pub relation: Option, + #[deluxe(default = false)] + pub defaultable: bool, } #[derive(deluxe::ParseMetaItem, Clone, Debug)] @@ -45,6 +47,7 @@ pub struct GeormField { pub ty: syn::Type, pub id: bool, pub relation: Option, + pub defaultable: bool, } impl GeormField { @@ -53,13 +56,42 @@ impl GeormField { let ty = field.clone().ty; let attrs: GeormFieldAttributes = deluxe::extract_attributes(field).expect("Could not extract attributes from field"); - let GeormFieldAttributes { id, relation } = attrs; + let GeormFieldAttributes { + id, + relation, + defaultable, + } = attrs; + + // Validate that defaultable is not used on Option fields + if defaultable && Self::is_option_type(&ty) { + panic!( + "Field '{}' is already an Option and cannot be marked as defaultable. \ + Remove the #[georm(defaultable)] attribute.", + ident + ); + } + Self { ident, field: field.to_owned(), id, ty, relation, + defaultable, + } + } + + /// Check if a type is Option + fn is_option_type(ty: &syn::Type) -> bool { + match ty { + syn::Type::Path(type_path) => { + if let Some(segment) = type_path.path.segments.last() { + segment.ident == "Option" + } else { + false + } + } + _ => false, } } } diff --git a/georm-macros/src/georm/mod.rs b/georm-macros/src/georm/mod.rs index 32f3570..62fa5eb 100644 --- a/georm-macros/src/georm/mod.rs +++ b/georm-macros/src/georm/mod.rs @@ -1,6 +1,7 @@ use ir::GeormField; use quote::quote; +mod defaultable_struct; mod ir; mod relationships; mod trait_implementation; @@ -51,9 +52,12 @@ pub fn georm_derive_macro2( let (fields, id) = extract_georm_field_attrs(&mut ast)?; let relationships = relationships::derive_relationships(&ast, &struct_attrs, &fields, &id); let trait_impl = trait_implementation::derive_trait(&ast, &struct_attrs.table, &fields, &id); + let defaultable_struct = + defaultable_struct::derive_defaultable_struct(&ast, &struct_attrs, &fields); let code = quote! { #relationships #trait_impl + #defaultable_struct }; Ok(code) } diff --git a/src/defaultable.rs b/src/defaultable.rs new file mode 100644 index 0000000..4094ee9 --- /dev/null +++ b/src/defaultable.rs @@ -0,0 +1,10 @@ +pub trait Defaultable { + /// Creates an entity in the database. + /// + /// # Errors + /// Returns any error the database may have encountered + fn create( + &self, + pool: &sqlx::PgPool, + ) -> impl std::future::Future> + Send; +} diff --git a/src/entity.rs b/src/entity.rs new file mode 100644 index 0000000..3f7759d --- /dev/null +++ b/src/entity.rs @@ -0,0 +1,92 @@ +pub trait Georm { + /// 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>> + Send + where + Self: Sized; + + /// Find the entiy in the database based on its identifier. + /// + /// # Errors + /// Returns any error Postgres may have encountered + fn find( + pool: &sqlx::PgPool, + id: &Id, + ) -> impl std::future::Future>> + Send + where + Self: Sized; + + /// Create the entity in the database. + /// + /// # Errors + /// Returns any error Postgres may have encountered + fn create( + &self, + pool: &sqlx::PgPool, + ) -> impl std::future::Future> + Send + where + Self: Sized; + + /// Update an entity with a matching identifier in the database. + /// + /// # Errors + /// Returns any error Postgres may have encountered + fn update( + &self, + pool: &sqlx::PgPool, + ) -> impl std::future::Future> + Send + where + Self: Sized; + + /// Update an entity with a matching identifier in the database if + /// it exists, create it otherwise. + /// + /// # Errors + /// Returns any error Postgres may have encountered + fn create_or_update( + &self, + pool: &sqlx::PgPool, + ) -> impl ::std::future::Future> + where + Self: Sized, + { + async { + if Self::find(pool, self.get_id()).await?.is_some() { + self.update(pool).await + } else { + self.create(pool).await + } + } + } + + /// Delete the entity from the database if it exists. + /// + /// # Returns + /// Returns the amount of rows affected by the deletion. + /// + /// # Errors + /// Returns any error Postgres may have encountered + fn delete( + &self, + pool: &sqlx::PgPool, + ) -> impl std::future::Future> + Send; + + /// Delete any entity with the identifier `id`. + /// + /// # Returns + /// Returns the amount of rows affected by the deletion. + /// + /// # Errors + /// Returns any error Postgres may have encountered + fn delete_by_id( + pool: &sqlx::PgPool, + id: &Id, + ) -> impl std::future::Future> + Send; + + /// Returns the identifier of the entity. + fn get_id(&self) -> &Id; +} diff --git a/src/georm.rs b/src/georm.rs new file mode 100644 index 0000000..3f7759d --- /dev/null +++ b/src/georm.rs @@ -0,0 +1,92 @@ +pub trait Georm { + /// 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>> + Send + where + Self: Sized; + + /// Find the entiy in the database based on its identifier. + /// + /// # Errors + /// Returns any error Postgres may have encountered + fn find( + pool: &sqlx::PgPool, + id: &Id, + ) -> impl std::future::Future>> + Send + where + Self: Sized; + + /// Create the entity in the database. + /// + /// # Errors + /// Returns any error Postgres may have encountered + fn create( + &self, + pool: &sqlx::PgPool, + ) -> impl std::future::Future> + Send + where + Self: Sized; + + /// Update an entity with a matching identifier in the database. + /// + /// # Errors + /// Returns any error Postgres may have encountered + fn update( + &self, + pool: &sqlx::PgPool, + ) -> impl std::future::Future> + Send + where + Self: Sized; + + /// Update an entity with a matching identifier in the database if + /// it exists, create it otherwise. + /// + /// # Errors + /// Returns any error Postgres may have encountered + fn create_or_update( + &self, + pool: &sqlx::PgPool, + ) -> impl ::std::future::Future> + where + Self: Sized, + { + async { + if Self::find(pool, self.get_id()).await?.is_some() { + self.update(pool).await + } else { + self.create(pool).await + } + } + } + + /// Delete the entity from the database if it exists. + /// + /// # Returns + /// Returns the amount of rows affected by the deletion. + /// + /// # Errors + /// Returns any error Postgres may have encountered + fn delete( + &self, + pool: &sqlx::PgPool, + ) -> impl std::future::Future> + Send; + + /// Delete any entity with the identifier `id`. + /// + /// # Returns + /// Returns the amount of rows affected by the deletion. + /// + /// # Errors + /// Returns any error Postgres may have encountered + fn delete_by_id( + pool: &sqlx::PgPool, + id: &Id, + ) -> impl std::future::Future> + Send; + + /// Returns the identifier of the entity. + fn get_id(&self) -> &Id; +} diff --git a/src/lib.rs b/src/lib.rs index caa6b30..905bd44 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -267,6 +267,76 @@ //! | link.from | Column of the linking table referring to this entity | N/A | //! | link.to | Column of the linking table referring to the remote entity | N/A | //! +//! ## Defaultable Fields +//! +//! Georm supports defaultable fields for entities where some fields have database +//! defaults or are auto-generated (like serial IDs). When you mark fields as +//! `defaultable`, Georm generates a companion struct that makes these fields +//! optional during entity creation. +//! +//! ```ignore +//! #[derive(sqlx::FromRow, Georm)] +//! #[georm(table = "posts")] +//! pub struct Post { +//! #[georm(id, defaultable)] +//! id: i32, // Auto-generated serial +//! title: String, // Required field +//! #[georm(defaultable)] +//! published: bool, // Has database default +//! #[georm(defaultable)] +//! created_at: chrono::DateTime, // Has database default +//! author_id: i32, // Required field +//! } +//! ``` +//! +//! This generates a `PostDefault` struct where defaultable fields become `Option`: +//! +//! ```ignore +//! // Generated automatically by the macro +//! pub struct PostDefault { +//! pub id: Option, // Can be None for auto-generation +//! pub title: String, // Required field stays the same +//! pub published: Option, // Can be None to use database default +//! pub created_at: Option>, // Can be None +//! pub author_id: i32, // Required field stays the same +//! } +//! +//! impl Defaultable for PostDefault { +//! async fn create(&self, pool: &sqlx::PgPool) -> sqlx::Result; +//! } +//! ``` +//! +//! ### Usage Example +//! +//! ```ignore +//! use georm::{Georm, Defaultable}; +//! +//! // Create a post with some fields using database defaults +//! let post_default = PostDefault { +//! id: None, // Let database auto-generate +//! title: "My Blog Post".to_string(), +//! published: None, // Use database default (e.g., false) +//! created_at: None, // Use database default (e.g., NOW()) +//! author_id: 42, +//! }; +//! +//! // Create the entity in the database +//! let created_post = post_default.create(&pool).await?; +//! println!("Created post with ID: {}", created_post.id); +//! ``` +//! +//! ### Rules and Limitations +//! +//! - **Option fields cannot be marked as defaultable**: If a field is already +//! `Option`, 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. +//! - **Only generates when needed**: The defaultable struct is only generated if +//! at least one field is marked as defaultable. +//! //! ## Limitations //! ### Database //! @@ -282,95 +352,7 @@ pub use georm_macros::Georm; -pub trait Georm { - /// 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>> + Send - where - Self: Sized; - - /// Find the entiy in the database based on its identifier. - /// - /// # Errors - /// Returns any error Postgres may have encountered - fn find( - pool: &sqlx::PgPool, - id: &Id, - ) -> impl std::future::Future>> + Send - where - Self: Sized; - - /// Create the entity in the database. - /// - /// # Errors - /// Returns any error Postgres may have encountered - fn create( - &self, - pool: &sqlx::PgPool, - ) -> impl std::future::Future> + Send - where - Self: Sized; - - /// Update an entity with a matching identifier in the database. - /// - /// # Errors - /// Returns any error Postgres may have encountered - fn update( - &self, - pool: &sqlx::PgPool, - ) -> impl std::future::Future> + Send - where - Self: Sized; - - /// Update an entity with a matching identifier in the database if - /// it exists, create it otherwise. - /// - /// # Errors - /// Returns any error Postgres may have encountered - fn create_or_update( - &self, - pool: &sqlx::PgPool, - ) -> impl ::std::future::Future> - where - Self: Sized, - { - async { - if Self::find(pool, self.get_id()).await?.is_some() { - self.update(pool).await - } else { - self.create(pool).await - } - } - } - - /// Delete the entity from the database if it exists. - /// - /// # Returns - /// Returns the amount of rows affected by the deletion. - /// - /// # Errors - /// Returns any error Postgres may have encountered - fn delete( - &self, - pool: &sqlx::PgPool, - ) -> impl std::future::Future> + Send; - - /// Delete any entity with the identifier `id`. - /// - /// # Returns - /// Returns the amount of rows affected by the deletion. - /// - /// # Errors - /// Returns any error Postgres may have encountered - fn delete_by_id( - pool: &sqlx::PgPool, - id: &Id, - ) -> impl std::future::Future> + Send; - - /// Returns the identifier of the entity. - fn get_id(&self) -> &Id; -} +mod georm; +pub use georm::Georm; +mod defaultable; +pub use defaultable::Defaultable; diff --git a/tests/defaultable_struct.rs b/tests/defaultable_struct.rs new file mode 100644 index 0000000..e4d52d9 --- /dev/null +++ b/tests/defaultable_struct.rs @@ -0,0 +1,61 @@ +use georm::Georm; + +// Test struct with defaultable fields using existing table structure +#[derive(Georm)] +#[georm(table = "authors")] +struct TestAuthor { + #[georm(id, defaultable)] + pub id: i32, + pub name: String, + pub biography_id: Option, // Don't mark Option fields as defaultable +} + +// Test struct with only ID defaultable +#[derive(Georm)] +#[georm(table = "authors")] +struct MinimalDefaultable { + #[georm(id, defaultable)] + pub id: i32, + pub name: String, + pub biography_id: Option, +} + +#[test] +fn defaultable_struct_should_exist() { + // This test will compile only if TestAuthorDefault struct exists + let _author_default = TestAuthorDefault { + id: Some(1), // Should be Option since ID is defaultable + name: "Test Author".to_string(), // Should remain String + biography_id: None, // Should remain Option + }; +} + +#[test] +fn minimal_defaultable_struct_should_exist() { + // MinimalDefaultableDefault should exist because ID is marked as defaultable + let _minimal_default = MinimalDefaultableDefault { + id: None, // Should be Option + name: "testuser".to_string(), // Should remain String + biography_id: None, // Should remain Option + }; +} + +#[test] +fn defaultable_fields_can_be_none() { + let _author_default = TestAuthorDefault { + id: None, // Can be None since it's defaultable (auto-generated) + name: "Test Author".to_string(), + biography_id: None, // Can remain None + }; +} + +#[test] +fn field_visibility_is_preserved() { + let _author_default = TestAuthorDefault { + id: Some(1), // pub + name: "Test".to_string(), // pub + biography_id: Some(1), // pub, Option + }; + + // This test ensures field visibility is preserved in generated struct +}