diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index 38bdad1..61e8c38 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -4,9 +4,9 @@ services: restart: unless-stopped container_name: georm-backend-db environment: - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_USER: ${DB_USER} - POSTGRES_DB: ${DB_NAME} + POSTGRES_PASSWORD: georm + POSTGRES_USER: georm + POSTGRES_DB: georm ports: - 127.0.0.1:5432:5432 volumes: diff --git a/georm-macros/src/georm/defaultable_struct.rs b/georm-macros/src/georm/defaultable_struct.rs new file mode 100644 index 0000000..e4cc7f6 --- /dev/null +++ b/georm-macros/src/georm/defaultable_struct.rs @@ -0,0 +1,144 @@ +//! 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; + + // Separate defaultable and non-defaultable fields + let non_defaultable_fields: Vec<_> = fields.iter().filter(|f| !f.defaultable).collect(); + let defaultable_fields: Vec<_> = fields.iter().filter(|f| f.defaultable).collect(); + + // Build static parts for non-defaultable fields + let static_field_names: Vec = non_defaultable_fields.iter().map(|f| f.ident.to_string()).collect(); + let static_field_idents: Vec<&syn::Ident> = non_defaultable_fields.iter().map(|f| &f.ident).collect(); + + // Generate field checks for defaultable fields + let mut field_checks = Vec::new(); + let mut bind_checks = Vec::new(); + + for field in &defaultable_fields { + let field_name = field.ident.to_string(); + let field_ident = &field.ident; + + field_checks.push(quote! { + if self.#field_ident.is_some() { + dynamic_fields.push(#field_name); + } + }); + + bind_checks.push(quote! { + if let Some(ref value) = self.#field_ident { + query_builder = query_builder.bind(value); + } + }); + } + + quote! { + impl ::georm::Defaultable<#id_type, #struct_name> for #defaultable_struct_name { + async fn create(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#struct_name> { + let mut dynamic_fields = Vec::new(); + + #(#field_checks)* + + let mut all_fields = vec![#(#static_field_names),*]; + all_fields.extend(dynamic_fields); + + let placeholders: Vec = (1..=all_fields.len()) + .map(|i| format!("${}", i)) + .collect(); + + let query = format!( + "INSERT INTO {} ({}) VALUES ({}) RETURNING *", + #table, + all_fields.join(", "), + placeholders.join(", ") + ); + + let mut query_builder = ::sqlx::query_as::<_, #struct_name>(&query); + + // Bind non-defaultable fields first + #(query_builder = query_builder.bind(&self.#static_field_idents);)* + + // Then bind defaultable fields that have values + #(#bind_checks)* + + query_builder.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..3dab110 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,34 @@ 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 from_row_impl = generate_from_row_impl(&ast, &fields); let code = quote! { #relationships #trait_impl + #defaultable_struct + #from_row_impl }; Ok(code) } + +fn generate_from_row_impl( + ast: &syn::DeriveInput, + fields: &[GeormField], +) -> proc_macro2::TokenStream { + let struct_name = &ast.ident; + let field_idents: Vec<&syn::Ident> = fields.iter().map(|f| &f.ident).collect(); + let field_names: Vec = fields.iter().map(|f| f.ident.to_string()).collect(); + + quote! { + impl<'r> ::sqlx::FromRow<'r, ::sqlx::postgres::PgRow> for #struct_name { + fn from_row(row: &'r ::sqlx::postgres::PgRow) -> ::sqlx::Result { + use ::sqlx::Row; + Ok(Self { + #(#field_idents: row.try_get(#field_names)?),* + }) + } + } + } +} 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..4dc9076 --- /dev/null +++ b/tests/defaultable_struct.rs @@ -0,0 +1,519 @@ +use georm::Georm; + +// Test struct with defaultable fields using existing table structure +#[derive(Georm, Debug)] +#[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 struct with multiple defaultable fields +#[derive(Georm)] +#[georm(table = "authors")] +struct MultiDefaultable { + #[georm(id, defaultable)] + pub id: i32, + #[georm(defaultable)] + 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 +} + +mod defaultable_tests { + use super::*; + use georm::Defaultable; + use sqlx::PgPool; + + #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] + async fn test_create_entity_from_defaultable_with_id(pool: PgPool) { + // Test creating entity from defaultable struct with explicit ID + let author_default = TestAuthorDefault { + id: Some(999), + name: "John Doe".to_string(), + biography_id: None, + }; + + let created_author = author_default.create(&pool).await.unwrap(); + + assert_eq!(created_author.id, 999); + assert_eq!(created_author.name, "John Doe"); + assert_eq!(created_author.biography_id, None); + } + + #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] + async fn test_create_entity_from_defaultable_without_id(pool: PgPool) { + // Test creating entity from defaultable struct with auto-generated ID + let author_default = TestAuthorDefault { + id: None, // Let database generate the ID + name: "Jane Smith".to_string(), + biography_id: None, + }; + + let created_author = author_default.create(&pool).await.unwrap(); + + // ID should be auto-generated (positive value) + assert!(created_author.id > 0); + assert_eq!(created_author.name, "Jane Smith"); + assert_eq!(created_author.biography_id, None); + } + + #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] + async fn test_create_entity_from_minimal_defaultable(pool: PgPool) { + // Test creating entity from minimal defaultable struct + let minimal_default = MinimalDefaultableDefault { + id: None, + name: "Alice Wonder".to_string(), + biography_id: Some(1), // Reference existing biography + }; + + let created_author = minimal_default.create(&pool).await.unwrap(); + + assert!(created_author.id > 0); + assert_eq!(created_author.name, "Alice Wonder"); + assert_eq!(created_author.biography_id, Some(1)); + } + + #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] + async fn test_create_multiple_entities_from_defaultable(pool: PgPool) { + // Test creating multiple entities to ensure ID generation works properly + let author1_default = TestAuthorDefault { + id: None, + name: "Author One".to_string(), + biography_id: None, + }; + + let author2_default = TestAuthorDefault { + id: None, + name: "Author Two".to_string(), + biography_id: None, + }; + + let created_author1 = author1_default.create(&pool).await.unwrap(); + let created_author2 = author2_default.create(&pool).await.unwrap(); + + // Both should have unique IDs + assert!(created_author1.id > 0); + assert!(created_author2.id > 0); + assert_ne!(created_author1.id, created_author2.id); + + assert_eq!(created_author1.name, "Author One"); + assert_eq!(created_author2.name, "Author Two"); + } + + #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] + async fn test_multiple_defaultable_fields_all_none(pool: PgPool) { + // Test with multiple defaultable fields all set to None + let multi_default = MultiDefaultableDefault { + id: None, + name: None, // This should use database default or be handled gracefully + biography_id: None, + }; + + let result = multi_default.create(&pool).await; + + // This might fail if database doesn't have a default for name + // That's expected behavior - test documents the current behavior + match result { + Ok(created) => { + assert!(created.id > 0); + // If successful, name should have some default value + }, + Err(e) => { + // Expected if no database default for name column + assert!(e.to_string().contains("null") || e.to_string().contains("NOT NULL")); + } + } + } + + #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] + async fn test_multiple_defaultable_fields_mixed(pool: PgPool) { + // Test with some defaultable fields set and others None + let multi_default = MultiDefaultableDefault { + id: None, // Let database generate + name: Some("Explicit Name".to_string()), // Explicit value + biography_id: Some(1), // Reference existing biography + }; + + let created = multi_default.create(&pool).await.unwrap(); + + assert!(created.id > 0); + assert_eq!(created.name, "Explicit Name"); + assert_eq!(created.biography_id, Some(1)); + } + + #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] + async fn test_multiple_defaultable_fields_all_explicit(pool: PgPool) { + // Test with all defaultable fields having explicit values + let multi_default = MultiDefaultableDefault { + id: Some(888), + name: Some("All Explicit".to_string()), + biography_id: None, + }; + + let created = multi_default.create(&pool).await.unwrap(); + + assert_eq!(created.id, 888); + assert_eq!(created.name, "All Explicit"); + assert_eq!(created.biography_id, None); + } + + #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] + async fn test_error_duplicate_id(pool: PgPool) { + // Test error handling for duplicate ID constraint violation + let author1 = TestAuthorDefault { + id: Some(777), + name: "First Author".to_string(), + biography_id: None, + }; + + let author2 = TestAuthorDefault { + id: Some(777), // Same ID - should cause constraint violation + name: "Second Author".to_string(), + biography_id: None, + }; + + // First creation should succeed + let _created1 = author1.create(&pool).await.unwrap(); + + // Second creation should fail due to duplicate key + let result2 = author2.create(&pool).await; + assert!(result2.is_err()); + + let error = result2.unwrap_err(); + let error_str = error.to_string(); + assert!(error_str.contains("duplicate") || error_str.contains("unique") || error_str.contains("UNIQUE")); + } + + #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] + async fn test_error_invalid_foreign_key(pool: PgPool) { + // Test error handling for invalid foreign key reference + let author_default = TestAuthorDefault { + id: None, + name: "Test Author".to_string(), + biography_id: Some(99999), // Non-existent biography ID + }; + + let result = author_default.create(&pool).await; + + // This should fail if there's a foreign key constraint + // If no constraint exists, it will succeed (documents current behavior) + match result { + Ok(created) => { + // No foreign key constraint - this is valid behavior + assert!(created.id > 0); + assert_eq!(created.biography_id, Some(99999)); + }, + Err(e) => { + // Foreign key constraint violation + let error_str = e.to_string(); + assert!(error_str.contains("foreign") || error_str.contains("constraint") || error_str.contains("violates")); + } + } + } + + #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] + async fn test_error_connection_handling(pool: PgPool) { + // Test behavior with a closed/invalid pool + // Note: This is tricky to test without actually closing the pool + // Instead, we test with extremely long string that might cause issues + let author_default = TestAuthorDefault { + id: None, + name: "A".repeat(10000), // Very long string - might hit database limits + biography_id: None, + }; + + let result = author_default.create(&pool).await; + + // This documents current behavior - might succeed or fail depending on DB limits + match result { + Ok(created) => { + assert!(created.id > 0); + assert_eq!(created.name.len(), 10000); + }, + Err(e) => { + // Some kind of database limit hit + assert!(!e.to_string().is_empty()); + } + } + } + + mod sql_validation_tests { + use super::*; + + + #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] + async fn test_sql_generation_no_defaultable_fields(pool: PgPool) { + // Test SQL generation when no defaultable fields have None values + let author_default = TestAuthorDefault { + id: Some(100), + name: "Test Name".to_string(), + biography_id: Some(1), + }; + + // Capture the SQL by creating a custom query that logs the generated SQL + // Since we can't directly inspect the generated SQL from the macro, + // we test the behavior indirectly by ensuring all fields are included + let created = author_default.create(&pool).await.unwrap(); + + // Verify all fields were properly inserted + assert_eq!(created.id, 100); + assert_eq!(created.name, "Test Name"); + assert_eq!(created.biography_id, Some(1)); + + // Verify the record exists in database with all expected values + let found: TestAuthor = sqlx::query_as!( + TestAuthor, + "SELECT id, name, biography_id FROM authors WHERE id = $1", + 100 + ) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(found.id, 100); + assert_eq!(found.name, "Test Name"); + assert_eq!(found.biography_id, Some(1)); + } + + #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] + async fn test_sql_generation_with_defaultable_none(pool: PgPool) { + // Test SQL generation when defaultable fields are None (should be excluded) + let author_default = TestAuthorDefault { + id: None, // This should be excluded from INSERT + name: "Auto ID Test".to_string(), + biography_id: None, + }; + + let created = author_default.create(&pool).await.unwrap(); + + // ID should be auto-generated (not explicitly set) + assert!(created.id > 0); + assert_eq!(created.name, "Auto ID Test"); + assert_eq!(created.biography_id, None); + + // Verify the generated ID is actually from database auto-increment + // by checking it's different from any manually set values + assert_ne!(created.id, 100); // Different from previous test + } + + #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] + async fn test_sql_generation_mixed_defaultable_fields(pool: PgPool) { + // Test SQL with multiple defaultable fields where some are None + let multi_default = MultiDefaultableDefault { + id: None, // Should be excluded + name: Some("Explicit Name".to_string()), // Should be included + biography_id: Some(1), // Should be included + }; + + let created = multi_default.create(&pool).await.unwrap(); + + // Verify the mixed field inclusion worked correctly + assert!(created.id > 0); // Auto-generated + assert_eq!(created.name, "Explicit Name"); // Explicitly set + assert_eq!(created.biography_id, Some(1)); // Explicitly set + } + + #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] + async fn test_placeholder_ordering_consistency(pool: PgPool) { + // Test that placeholders are ordered correctly when fields are dynamically included + // Create multiple records with different field combinations + + // First: only non-defaultable fields + let record1 = MultiDefaultableDefault { + id: None, + name: None, + biography_id: Some(1), + }; + + // Second: all fields explicit + let record2 = MultiDefaultableDefault { + id: Some(201), + name: Some("Full Record".to_string()), + biography_id: Some(1), + }; + + // Third: mixed combination + let record3 = MultiDefaultableDefault { + id: None, + name: Some("Mixed Record".to_string()), + biography_id: None, + }; + + // All should succeed with correct placeholder ordering + let result1 = record1.create(&pool).await; + let result2 = record2.create(&pool).await; + let result3 = record3.create(&pool).await; + + // Handle record1 based on whether name has a database default + match result1 { + Ok(created1) => { + assert!(created1.id > 0); + assert_eq!(created1.biography_id, Some(1)); + }, + Err(_) => { + // Expected if name field has no database default + } + } + + let created2 = result2.unwrap(); + assert_eq!(created2.id, 201); + assert_eq!(created2.name, "Full Record"); + assert_eq!(created2.biography_id, Some(1)); + + let created3 = result3.unwrap(); + assert!(created3.id > 0); + assert_eq!(created3.name, "Mixed Record"); + assert_eq!(created3.biography_id, None); + } + + #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] + async fn test_field_inclusion_logic(pool: PgPool) { + // Test that the field inclusion logic works correctly + // by creating records that should result in different SQL queries + + let minimal = TestAuthorDefault { + id: None, + name: "Minimal".to_string(), + biography_id: None, + }; + + let maximal = TestAuthorDefault { + id: Some(300), + name: "Maximal".to_string(), + biography_id: Some(1), + }; + + let created_minimal = minimal.create(&pool).await.unwrap(); + let created_maximal = maximal.create(&pool).await.unwrap(); + + // Minimal should have auto-generated ID, explicit name, NULL biography_id + assert!(created_minimal.id > 0); + assert_eq!(created_minimal.name, "Minimal"); + assert_eq!(created_minimal.biography_id, None); + + // Maximal should have all explicit values + assert_eq!(created_maximal.id, 300); + assert_eq!(created_maximal.name, "Maximal"); + assert_eq!(created_maximal.biography_id, Some(1)); + + // Verify they are different records + assert_ne!(created_minimal.id, created_maximal.id); + } + + #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] + async fn test_returning_clause_functionality(pool: PgPool) { + // Test that the RETURNING * clause works correctly with dynamic fields + let author_default = TestAuthorDefault { + id: None, // Should be populated by RETURNING clause + name: "Return Test".to_string(), + biography_id: None, + }; + + let created = author_default.create(&pool).await.unwrap(); + + // Verify RETURNING clause populated all fields correctly + assert!(created.id > 0); // Database-generated ID returned + assert_eq!(created.name, "Return Test"); // Explicit value returned + assert_eq!(created.biography_id, None); // NULL value returned correctly + + // Double-check by querying the database directly + let verified: TestAuthor = sqlx::query_as!( + TestAuthor, + "SELECT id, name, biography_id FROM authors WHERE id = $1", + created.id + ) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(verified.id, created.id); + assert_eq!(verified.name, created.name); + assert_eq!(verified.biography_id, created.biography_id); + } + + #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] + async fn test_query_parameter_binding_order(pool: PgPool) { + // Test that query parameters are bound in the correct order + // This is critical for the dynamic SQL generation + + // Create a record where the parameter order matters + let test_record = MultiDefaultableDefault { + id: Some(400), // This should be bound first (if included) + name: Some("Param Order Test".to_string()), // This should be bound second (if included) + biography_id: Some(1), // This should be bound last + }; + + let created = test_record.create(&pool).await.unwrap(); + + // Verify all parameters were bound correctly + assert_eq!(created.id, 400); + assert_eq!(created.name, "Param Order Test"); + assert_eq!(created.biography_id, Some(1)); + + // Test with different parameter inclusion order + let test_record2 = MultiDefaultableDefault { + id: None, // Excluded - should not affect parameter order + name: Some("No ID Test".to_string()), // Should be bound first now + biography_id: Some(1), // Should be bound second now + }; + + let created2 = test_record2.create(&pool).await.unwrap(); + + assert!(created2.id > 0); // Auto-generated + assert_eq!(created2.name, "No ID Test"); + assert_eq!(created2.biography_id, Some(1)); + } + } +} diff --git a/tests/models.rs b/tests/models.rs index 0e128cb..44a0745 100644 --- a/tests/models.rs +++ b/tests/models.rs @@ -1,6 +1,6 @@ use georm::Georm; -#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)] +#[derive(Debug, Georm, PartialEq, Eq, Default)] #[georm( table = "biographies", one_to_one = [{ @@ -13,7 +13,7 @@ pub struct Biography { pub content: String, } -#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)] +#[derive(Debug, Georm, PartialEq, Eq, Default)] #[georm(table = "authors")] pub struct Author { #[georm(id)] @@ -35,7 +35,7 @@ impl Ord for Author { } } -#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)] +#[derive(Debug, Georm, PartialEq, Eq, Default)] #[georm( table = "books", one_to_many = [ @@ -68,7 +68,7 @@ impl Ord for Book { } } -#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq)] +#[derive(Debug, Georm, PartialEq, Eq)] #[georm(table = "reviews")] pub struct Review { #[georm(id)] @@ -78,7 +78,7 @@ pub struct Review { pub review: String, } -#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq)] +#[derive(Debug, Georm, PartialEq, Eq)] #[georm( table = "genres", many_to_many = [{