diff --git a/Cargo.lock b/Cargo.lock index 32ebedc..0131bf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -136,6 +136,19 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "bigdecimal" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a22f228ab7a1b23027ccc6c350b72868017af7ea8356fbdf19f8d991c690013" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1021,6 +1034,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -1515,6 +1538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ "base64", + "bigdecimal", "bytes", "chrono", "crc", @@ -1589,6 +1613,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64", + "bigdecimal", "bitflags 2.9.1", "byteorder", "bytes", @@ -1632,6 +1657,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64", + "bigdecimal", "bitflags 2.9.1", "byteorder", "chrono", @@ -1649,6 +1675,7 @@ dependencies = [ "log", "md-5", "memchr", + "num-bigint", "once_cell", "rand 0.8.5", "serde", diff --git a/Cargo.toml b/Cargo.toml index 972a0a4..2976b62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ georm-macros = { version = "=0.2.1", path = "georm-macros" } [workspace.dependencies.sqlx] version = "0.8.6" default-features = false -features = ["postgres", "runtime-tokio", "macros", "migrate"] +features = ["postgres", "runtime-tokio", "macros", "migrate", "bigdecimal"] [dependencies] sqlx = { workspace = true } diff --git a/README.md b/README.md index 6570f21..6608eca 100644 --- a/README.md +++ b/README.md @@ -181,9 +181,13 @@ let user_role = UserRole::find(pool, &id).await?; **Note**: Relationships are not yet supported for entities with composite primary keys. -### Defaultable Fields +### Defaultable and Generated Fields -For fields with database defaults or auto-generated values, use the `defaultable` attribute: +Georm provides three attributes for handling fields with database-managed values: + +#### `#[georm(defaultable)]` - Optional Override Fields + +For fields with database defaults that can be manually overridden: ```rust #[derive(Georm)] @@ -200,22 +204,74 @@ pub struct Post { } ``` -This generates a `PostDefault` struct for easier creation: +#### `#[georm(generated)]` - Generated by Default + +For PostgreSQL `GENERATED BY DEFAULT` columns that can be overridden but are typically auto-generated: + +```rust +#[derive(Georm)] +#[georm(table = "products")] +pub struct Product { + #[georm(id, generated_always)] + pub id: i32, // GENERATED ALWAYS AS IDENTITY + #[georm(generated)] + pub sku_number: i32, // GENERATED BY DEFAULT AS IDENTITY + pub name: String, + pub price: sqlx::types::BigDecimal, +} +``` + +#### `#[georm(generated_always)]` - Always Generated + +For PostgreSQL `GENERATED ALWAYS` columns that are strictly managed by the database: + +```rust +#[derive(Georm)] +#[georm(table = "products")] +pub struct Product { + #[georm(id, generated_always)] + pub id: i32, // GENERATED ALWAYS AS IDENTITY + pub name: String, + pub price: sqlx::types::BigDecimal, + pub discount_percent: i32, + #[georm(generated_always)] + pub final_price: Option, // GENERATED ALWAYS AS (expression) STORED +} +``` + +#### Generated Structs and Behavior + +Both `defaultable` and `generated` fields create a companion `Default` struct: ```rust use georm::Defaultable; -let post_default = PostDefault { - id: None, // Let database auto-generate - title: "My Post".to_string(), - published: None, // Use database default - created_at: None, // Use database default (NOW()) - author_id: 42, +let product_default = ProductDefault { + name: "Laptop".to_string(), + price: BigDecimal::from(999), + discount_percent: 10, + sku_number: None, // Let database auto-generate + // Note: generated_always fields are excluded from this struct }; -let created_post = post_default.create(pool).await?; +let created_product = product_default.create(pool).await?; ``` +#### Key Differences + +| Attribute | INSERT Behavior | UPDATE Behavior | Use Case | +|--------------------|------------------------------------|-----------------|----------------------------------------------| +| `defaultable` | Optional (can override defaults) | Included | Fields with database defaults | +| `generated` | Optional (can override generation) | Included | `GENERATED BY DEFAULT` columns | +| `generated_always` | **Excluded** (always generated) | **Excluded** | `GENERATED ALWAYS` columns, computed columns | + +#### Important Notes + +- **`generated_always` fields are completely excluded** from INSERT and UPDATE statements to prevent database errors +- **`generated` and `generated_always` cannot be used together** on the same field +- **`generated` fields behave like `defaultable` fields** but are semantically distinct for future enhancements +- **Option types cannot be marked as `defaultable`** to prevent `Option>` situations + ### Relationships Georm supports comprehensive relationship modeling with two approaches: field-level relationships for foreign keys and struct-level relationships for reverse lookups. @@ -512,6 +568,8 @@ post_default.create(pool).await?; ```rust #[georm(id)] // Mark as primary key #[georm(defaultable)] // Mark as defaultable field +#[georm(generated)] // Mark as generated by default field +#[georm(generated_always)] // Mark as always generated field #[georm(relation = { /* ... */ })] // Define relationship ``` diff --git a/georm-macros/src/georm/composite_keys.rs b/georm-macros/src/georm/composite_keys.rs index 612e24e..e635359 100644 --- a/georm-macros/src/georm/composite_keys.rs +++ b/georm-macros/src/georm/composite_keys.rs @@ -37,7 +37,7 @@ fn generate_struct( let fields: Vec = fields .iter() .filter_map(|field| { - if field.id { + if field.is_id { Some(field_to_code(field)) } else { None @@ -56,7 +56,7 @@ 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 georm_id_fields: Vec<&GeormField> = fields.iter().filter(|field| field.is_id).collect(); let id_fields: Vec = georm_id_fields .iter() .map(|field| IdField { diff --git a/georm-macros/src/georm/defaultable_struct.rs b/georm-macros/src/georm/defaultable_struct.rs index cb6be3b..0402d5a 100644 --- a/georm-macros/src/georm/defaultable_struct.rs +++ b/georm-macros/src/georm/defaultable_struct.rs @@ -9,6 +9,8 @@ //! or something similar. The type `Default` implements the //! `Defaultable` trait. +use crate::georm::ir::GeneratedType; + use super::ir::{GeormField, GeormStructAttributes}; use quote::quote; @@ -19,7 +21,7 @@ fn create_defaultable_field(field: &GeormField) -> proc_macro2::TokenStream { // If the field is marked as defaultable, wrap it in Option // Otherwise, keep the original type - let field_type = if field.defaultable { + let field_type = if field.is_defaultable_behavior() { quote! { Option<#ty> } } else { quote! { #ty } @@ -41,13 +43,25 @@ fn generate_defaultable_trait_impl( // Find the ID field let id_field = fields .iter() - .find(|field| field.id) + .find(|field| field.is_id) .expect("Must have an ID field"); let id_type = &id_field.ty; + // Remove always generated fields + let fields: Vec<&GeormField> = fields + .iter() + .filter(|field| !matches!(field.generated_type, GeneratedType::Always)) + .collect(); + // 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(); + let non_defaultable_fields: Vec<_> = fields + .iter() + .filter(|f| !f.is_defaultable_behavior()) + .collect(); + let defaultable_fields: Vec<_> = fields + .iter() + .filter(|f| f.is_defaultable_behavior()) + .collect(); // Build static parts for non-defaultable fields let static_field_names: Vec = non_defaultable_fields @@ -119,7 +133,7 @@ pub fn derive_defaultable_struct( fields: &[GeormField], ) -> proc_macro2::TokenStream { // Only generate if there are defaultable fields - if fields.iter().all(|field| !field.defaultable) { + if fields.iter().all(|field| !field.is_defaultable_behavior()) { return quote! {}; } @@ -127,8 +141,13 @@ pub fn derive_defaultable_struct( 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 defaultable_fields: Vec = fields + .iter() + .flat_map(|field| match field.generated_type { + GeneratedType::Always => None, + _ => Some(create_defaultable_field(field)), + }) + .collect(); let trait_impl = generate_defaultable_trait_impl( struct_name, diff --git a/georm-macros/src/georm/ir/mod.rs b/georm-macros/src/georm/ir/mod.rs index e9f1d69..9d6876c 100644 --- a/georm-macros/src/georm/ir/mod.rs +++ b/georm-macros/src/georm/ir/mod.rs @@ -27,6 +27,10 @@ struct GeormFieldAttributes { pub relation: Option, #[deluxe(default = false)] pub defaultable: bool, + #[deluxe(default = false)] + pub generated: bool, + #[deluxe(default = false)] + pub generated_always: bool, } #[derive(deluxe::ParseMetaItem, Clone, Debug)] @@ -40,14 +44,22 @@ pub struct O2ORelationship { pub name: String, } +#[derive(Debug, Clone)] +pub enum GeneratedType { + None, + ByDefault, // #[georm(generated)] - BY DEFAULT behaviour + Always, // #[georm(generated_always)] - ALWAYS behaviour +} + #[derive(Clone, Debug)] pub struct GeormField { pub ident: syn::Ident, pub field: syn::Field, pub ty: syn::Type, - pub id: bool, + pub is_id: bool, + pub is_defaultable: bool, + pub generated_type: GeneratedType, pub relation: Option, - pub defaultable: bool, } impl GeormField { @@ -60,24 +72,42 @@ impl GeormField { id, relation, defaultable, + generated, + generated_always, } = 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. \ + "Field '{}' is already an Option and cannot be marked as defaultable.\ Remove the #[georm(defaultable)] attribute.", ident ); } + if generated && generated_always { + panic!( + "Field '{}' cannot have both the #[georm(generated)] and \ + #[georm(generated_always)] attributes at the same time. Remove one\ + of them before continuing.", + ident + ); + } + Self { ident, field: field.to_owned(), - id, + is_id: id, ty, relation, - defaultable, + is_defaultable: defaultable, + generated_type: if generated_always { + GeneratedType::Always + } else if generated { + GeneratedType::ByDefault + } else { + GeneratedType::None + }, } } @@ -94,6 +124,26 @@ impl GeormField { _ => false, } } + + /// Check if field should be excluded from INSERT statements + pub fn exclude_from_insert(&self) -> bool { + matches!(self.generated_type, GeneratedType::Always) + } + + /// Check if field should be excluded from UPDATE statements + pub fn exclude_from_update(&self) -> bool { + matches!(self.generated_type, GeneratedType::Always) + } + + /// Check if field should behave like a defaultable field + pub fn is_defaultable_behavior(&self) -> bool { + self.is_defaultable || matches!(self.generated_type, GeneratedType::ByDefault) + } + + /// Check if field is any type of generated field + pub fn is_any_generated(&self) -> bool { + !matches!(self.generated_type, GeneratedType::None) + } } impl From<&GeormField> for proc_macro2::TokenStream { diff --git a/georm-macros/src/georm/mod.rs b/georm-macros/src/georm/mod.rs index 63c82b5..682f444 100644 --- a/georm-macros/src/georm/mod.rs +++ b/georm-macros/src/georm/mod.rs @@ -24,7 +24,7 @@ fn extract_georm_field_attrs(ast: &mut syn::DeriveInput) -> deluxe::Result = fields .clone() .into_iter() - .filter(|field| field.id) + .filter(|field| field.is_id) .collect(); if identifiers.is_empty() { Err(syn::Error::new_spanned( diff --git a/georm-macros/src/georm/traits/create.rs b/georm-macros/src/georm/traits/create.rs index 774d9e8..7d3cd36 100644 --- a/georm-macros/src/georm/traits/create.rs +++ b/georm-macros/src/georm/traits/create.rs @@ -1,23 +1,30 @@ use crate::georm::GeormField; use quote::quote; -pub fn generate_create_query(table: &str, fields: &[GeormField]) -> proc_macro2::TokenStream { - let inputs: Vec = (1..=fields.len()).map(|num| format!("${num}")).collect(); - let create_string = format!( - "INSERT INTO {table} ({}) VALUES ({}) RETURNING *", - fields - .iter() - .map(|f| f.ident.to_string()) - .collect::>() - .join(", "), - inputs.join(", ") +pub fn generate_create_query(table_name: &str, fields: &[GeormField]) -> proc_macro2::TokenStream { + let insert_fields: Vec<&GeormField> = fields + .iter() + .filter(|field| !field.exclude_from_insert()) + .collect(); + let field_names: Vec = insert_fields + .iter() + .map(|field| field.ident.to_string()) + .collect(); + let field_idents: Vec = insert_fields + .iter() + .map(|field| field.ident.clone()) + .collect(); + let placeholders: Vec = (1..=insert_fields.len()).map(|i| format!("${i}")).collect(); + let query = format!( + "INSERT INTO {table_name} ({}) VALUES ({}) RETURNING *", + field_names.join(", "), + placeholders.join(", ") ); - let field_idents: Vec = fields.iter().map(|f| f.ident.clone()).collect(); quote! { async fn create(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result { ::sqlx::query_as!( Self, - #create_string, + #query, #(self.#field_idents),* ) .fetch_one(pool) diff --git a/georm-macros/src/georm/traits/mod.rs b/georm-macros/src/georm/traits/mod.rs index 493bab4..0a98c54 100644 --- a/georm-macros/src/georm/traits/mod.rs +++ b/georm-macros/src/georm/traits/mod.rs @@ -53,7 +53,7 @@ pub fn derive_trait( let get_all = find::generate_find_all_query(table); let find_query = find::generate_find_query(table, id); let create_query = create::generate_create_query(table, fields); - let update_query = update::generate_update_query(table, fields, id); + let update_query = update::generate_update_query(table, fields); let upsert_query = upsert::generate_upsert_query(table, fields, id); let delete_query = delete::generate_delete_query(table, id); quote! { diff --git a/georm-macros/src/georm/traits/update.rs b/georm-macros/src/georm/traits/update.rs index 0059139..0ade52a 100644 --- a/georm-macros/src/georm/traits/update.rs +++ b/georm-macros/src/georm/traits/update.rs @@ -1,45 +1,39 @@ -use crate::georm::{GeormField, IdType}; +use crate::georm::GeormField; use quote::quote; -pub fn generate_update_query( - table: &str, - fields: &[GeormField], - id: &IdType, -) -> proc_macro2::TokenStream { - let non_id_fields: Vec = fields +pub fn generate_update_query(table_name: &str, fields: &[GeormField]) -> proc_macro2::TokenStream { + let update_fields: Vec<&GeormField> = fields .iter() - .filter_map(|f| if f.id { None } else { Some(f.ident.clone()) }) + .filter(|field| !field.is_id && !field.exclude_from_update()) .collect(); - let update_columns = non_id_fields + let update_idents: Vec = update_fields + .iter() + .map(|field| field.ident.clone()) + .collect(); + let id_fields: Vec<&GeormField> = fields.iter().filter(|field| field.is_id).collect(); + let id_idents: Vec = id_fields.iter().map(|f| f.ident.clone()).collect(); + let set_clauses: Vec = update_fields .iter() .enumerate() - .map(|(i, field)| format!("{} = ${}", field, i + 1)) - .collect::>() - .join(", "); - 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 *"); + .map(|(i, field)| format!("{} = ${}", field.ident, i + 1)) + .collect(); + let where_clauses: Vec = id_fields + .iter() + .enumerate() + .map(|(i, field)| format!("{} = ${}", field.ident, update_fields.len() + i + 1)) + .collect(); + let query = format!( + "UPDATE {table_name} SET {} WHERE {} RETURNING *", + set_clauses.join(", "), + where_clauses.join(" AND ") + ); quote! { async fn update(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result { ::sqlx::query_as!( - Self, #update_string, #(self.#all_fields),* + Self, + #query, + #(self.#update_idents),*, + #(self.#id_idents),* ) .fetch_one(pool) .await diff --git a/georm-macros/src/georm/traits/upsert.rs b/georm-macros/src/georm/traits/upsert.rs index a44b288..711e3e5 100644 --- a/georm-macros/src/georm/traits/upsert.rs +++ b/georm-macros/src/georm/traits/upsert.rs @@ -1,4 +1,4 @@ -use crate::georm::{GeormField, IdType}; +use crate::georm::{GeormField, IdType, ir::GeneratedType}; use quote::quote; pub fn generate_upsert_query( @@ -6,6 +6,10 @@ pub fn generate_upsert_query( fields: &[GeormField], id: &IdType, ) -> proc_macro2::TokenStream { + let fields: Vec<&GeormField> = fields + .iter() + .filter(|field| !matches!(field.generated_type, GeneratedType::Always)) + .collect(); let inputs: Vec = (1..=fields.len()).map(|num| format!("${num}")).collect(); let columns = fields .iter() @@ -26,7 +30,7 @@ pub fn generate_upsert_query( // For ON CONFLICT DO UPDATE, exclude the ID field from updates let update_assignments = fields .iter() - .filter(|f| !f.id) + .filter(|f| !f.is_id) .map(|f| format!("{} = EXCLUDED.{}", f.ident, f.ident)) .collect::>() .join(", "); diff --git a/migrations/20250807202945_generated-columns.down.sql b/migrations/20250807202945_generated-columns.down.sql new file mode 100644 index 0000000..39a3c0e --- /dev/null +++ b/migrations/20250807202945_generated-columns.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS products; diff --git a/migrations/20250807202945_generated-columns.up.sql b/migrations/20250807202945_generated-columns.up.sql new file mode 100644 index 0000000..72cb496 --- /dev/null +++ b/migrations/20250807202945_generated-columns.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE products ( + -- strictly autogenerated + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + -- auto-generated but allows manual override + sku_number INTEGER GENERATED BY DEFAULT AS IDENTITY, + + name VARCHAR(100) NOT NULL, + price DECIMAL(10, 2) NOT NULL, + discount_percent INTEGER DEFAULT 0 NOT NULL, + + final_price DECIMAL(10, 2) GENERATED ALWAYS AS (price * (1 - discount_percent / 100.0)) STORED +); diff --git a/src/lib.rs b/src/lib.rs index c80fcb5..2049b20 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -124,9 +124,13 @@ //! yet define relationships (one-to-one, one-to-many, many-to-many) //! - **ID struct naming**: Generated ID struct follows pattern `{EntityName}Id` (not customizable) //! -//! ## Defaultable Fields +//! ## Defaultable and Generated Fields //! -//! Use `#[georm(defaultable)]` for fields with database defaults or auto-generated values: +//! Georm provides three attributes for handling fields with database-managed values: +//! +//! ### `#[georm(defaultable)]` - Optional Override Fields +//! +//! For fields with database defaults that can be manually overridden: //! //! ```ignore //! #[derive(Georm)] @@ -145,21 +149,58 @@ //! } //! ``` //! -//! This generates a companion `PostDefault` struct where defaultable fields become `Option`: +//! ### `#[georm(generated)]` - Generated by Default +//! +//! For PostgreSQL `GENERATED BY DEFAULT` columns that can be overridden but are typically auto-generated: //! //! ```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(crate) internal_note: Option, // Visibility preserved -//! pub author_id: i32, // Required field stays the same +//! #[derive(Georm)] +//! #[georm(table = "products")] +//! pub struct Product { +//! #[georm(id, generated_always)] +//! id: i32, // GENERATED ALWAYS AS IDENTITY +//! #[georm(generated)] +//! sku_number: i32, // GENERATED BY DEFAULT AS IDENTITY +//! name: String, +//! price: sqlx::types::BigDecimal, +//! discount_percent: i32, +//! } +//! ``` +//! +//! ### `#[georm(generated_always)]` - Always Generated +//! +//! For PostgreSQL `GENERATED ALWAYS` columns that are strictly managed by the database: +//! +//! ```ignore +//! #[derive(Georm)] +//! #[georm(table = "products")] +//! pub struct Product { +//! #[georm(id, generated_always)] +//! id: i32, // GENERATED ALWAYS AS IDENTITY +//! name: String, +//! price: sqlx::types::BigDecimal, +//! discount_percent: i32, +//! #[georm(generated_always)] +//! final_price: Option, // GENERATED ALWAYS AS (expression) STORED +//! } +//! ``` +//! +//! ### Generated Structs and Behavior +//! +//! Both `defaultable` and `generated` fields create a companion `Default` struct where these fields become `Option`: +//! +//! ```ignore +//! // Generated automatically by the macro for the Product example above +//! pub struct ProductDefault { +//! pub name: String, // Required field stays the same +//! pub price: sqlx::types::BigDecimal, // Required field stays the same +//! pub discount_percent: i32, // Required field stays the same +//! pub sku_number: Option, // Can be None for auto-generation +//! // Note: generated_always fields are completely excluded from this struct //! } //! -//! impl Defaultable for PostDefault { -//! async fn create(&self, pool: &sqlx::PgPool) -> sqlx::Result; +//! impl Defaultable for ProductDefault { +//! async fn create(&self, pool: &sqlx::PgPool) -> sqlx::Result; //! } //! ``` //! @@ -168,32 +209,43 @@ //! ```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()) -//! internal_note: Some("Draft".to_string()), -//! author_id: 42, +//! // Create a product with auto-generated values +//! let product_default = ProductDefault { +//! name: "Laptop".to_string(), +//! price: sqlx::types::BigDecimal::from(999), +//! discount_percent: 10, +//! sku_number: None, // Let database auto-generate +//! // Note: id and final_price are excluded (generated_always) //! }; //! -//! // Create the entity in the database (instance method on PostDefault) -//! let created_post = post_default.create(&pool).await?; -//! println!("Created post with ID: {}", created_post.id); +//! // Create the entity in the database (instance method on ProductDefault) +//! let created_product = product_default.create(&pool).await?; +//! println!("Created product with ID: {}", created_product.id); +//! println!("Final price: ${}", created_product.final_price.unwrap_or_default()); //! ``` //! -//! ### Defaultable Rules and Limitations +//! ### Key Differences //! -//! - **Option fields cannot be marked as defaultable**: If a field is already +//! | Attribute | INSERT Behavior | UPDATE Behavior | Use Case | +//! |--------------------|------------------------------------|-----------------|----------------------------------------------| +//! | `defaultable` | Optional (can override defaults) | Included | Fields with database defaults | +//! | `generated` | Optional (can override generation) | Included | `GENERATED BY DEFAULT` columns | +//! | `generated_always` | **Excluded** (always generated) | **Excluded** | `GENERATED ALWAYS` columns, computed columns | +//! +//! ### Rules and Limitations +//! +//! - **`generated_always` fields are completely excluded** from INSERT and UPDATE statements to prevent database errors +//! - **`generated` and `generated_always` cannot be used together** on the same field - this causes a compile-time error +//! - **`generated` fields behave like `defaultable` fields** but are semantically distinct for future enhancements +//! - **Option fields cannot be marked as `defaultable`**: If a field is already //! `Option`, you cannot mark it with `#[georm(defaultable)]`. This prevents //! `Option>` types and causes a compile-time error. //! - **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. +//! - **ID fields can be defaultable or generated**: It's common to mark ID fields as defaultable +//! or generated 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. +//! at least one field is marked as defaultable or generated. //! //! ## Relationships //! @@ -518,6 +570,15 @@ //! #[georm(defaultable)] // Error: would create Option> //! optional_field: Option, //! } +//! +//! // ❌ Compile error: Cannot use both generated attributes on same field +//! #[derive(Georm)] +//! #[georm(table = "invalid")] +//! pub struct Invalid { +//! #[georm(id, generated, generated_always)] // Error: conflicting attributes +//! id: i32, +//! name: String, +//! } //! ``` //! //! ## Attribute Reference @@ -538,6 +599,8 @@ //! ```ignore //! #[georm(id)] // Mark as primary key (required on at least one field) //! #[georm(defaultable)] // Mark as defaultable field (database default/auto-generated) +//! #[georm(generated)] // Mark as generated by default field (GENERATED BY DEFAULT) +//! #[georm(generated_always)] // Mark as always generated field (GENERATED ALWAYS) //! #[georm(relation = { /* ... */ })] // Define foreign key relationship //! ``` //! diff --git a/tests/fixtures/generated.sql b/tests/fixtures/generated.sql new file mode 100644 index 0000000..95ae59f --- /dev/null +++ b/tests/fixtures/generated.sql @@ -0,0 +1,8 @@ +INSERT INTO products (name, price, discount_percent) +VALUES ('Laptop', 999.99, 10); + +INSERT INTO products (sku_number, name, price, discount_percent) +VALUES (5000, 'Mouse', 29.99, 5); + +INSERT INTO products (name, price) +VALUES ('Keyboard', 79.99); diff --git a/tests/generated.rs b/tests/generated.rs new file mode 100644 index 0000000..5ca39a4 --- /dev/null +++ b/tests/generated.rs @@ -0,0 +1,64 @@ +use georm::{Defaultable, Georm}; +use sqlx::types::BigDecimal; + +mod models; +use models::{Product, ProductDefault}; + +#[sqlx::test()] +async fn create_without_generated_values(pool: sqlx::PgPool) -> sqlx::Result<()> { + let base = ProductDefault { + name: "Desktop".to_owned(), + price: BigDecimal::from(2000), + discount_percent: 5, + sku_number: None, + }; + let result = base.create(&pool).await?; + assert_eq!(BigDecimal::from(1900), result.final_price.unwrap()); + Ok(()) +} + +#[sqlx::test()] +async fn create_with_manual_generated_value(pool: sqlx::PgPool) -> sqlx::Result<()> { + let base = ProductDefault { + name: "Monitor".to_owned(), + price: BigDecimal::from(750), + discount_percent: 10, + sku_number: Some(12345), + }; + let result = base.create(&pool).await?; + assert_eq!(12345, result.sku_number); + assert_eq!("Monitor", result.name); + Ok(()) +} + +#[sqlx::test(fixtures("generated"))] +async fn update_does_not_change_generated_always_field(pool: sqlx::PgPool) -> sqlx::Result<()> { + let products = Product::find_all(&pool).await?; + dbg!(&products); + let mut product = products.first().unwrap().clone(); + let original_final_price = product.clone().final_price; + product.name = "Gaming Laptop".to_owned(); + product.final_price = Some(BigDecimal::from(1000000)); + dbg!(&product); + let updated = product.update(&pool).await?; + assert_eq!(original_final_price, updated.final_price); + assert_eq!("Gaming Laptop", updated.name); + Ok(()) +} + +#[sqlx::test(fixtures("generated"))] +async fn upsert_handles_generated_fields(pool: sqlx::PgPool) -> sqlx::Result<()> { + let product = Product::find_by_name("Laptop".to_owned(), &pool).await?; + let mut modified_product = product.clone(); + modified_product.price = BigDecimal::from(1200); + + let upserted = modified_product.create_or_update(&pool).await?; + + // price is updated + assert_eq!(upserted.price, BigDecimal::from(1200)); + // final_price is re-calculated by the DB + // 1200 * (1 - 10 / 100.0) = 1080 + assert_eq!(BigDecimal::from(1080), upserted.final_price.unwrap()); + + Ok(()) +} diff --git a/tests/models.rs b/tests/models.rs index b4e819f..1ab9814 100644 --- a/tests/models.rs +++ b/tests/models.rs @@ -1,4 +1,5 @@ use georm::Georm; +use sqlx::types::BigDecimal; #[derive(Debug, Georm, PartialEq, Eq, Default)] #[georm( @@ -105,3 +106,26 @@ pub struct UserRole { #[georm(defaultable)] pub assigned_at: chrono::DateTime, } + +#[derive(Debug, Georm, PartialEq, Default, Clone)] +#[georm(table = "products")] +pub struct Product { + #[georm(id, generated_always)] + pub id: i32, + #[georm(generated)] + pub sku_number: i32, + pub name: String, + pub price: BigDecimal, + pub discount_percent: i32, + #[georm(generated_always)] + pub final_price: Option, // Apparently this can be null ? +} + +impl Product { + #[allow(dead_code)] + pub async fn find_by_name(name: String, pool: &sqlx::PgPool) -> ::sqlx::Result { + ::sqlx::query_as!(Self, "SELECT * FROM products WHERE name = $1", name) + .fetch_one(pool) + .await + } +}