feat: Add generated and generated_always attributes

This commit introduces support for PostgreSQL generated columns by
adding two new field attributes to the `Georm` derive macro:
`#[georm(generated)]` and `#[georm(generated_always)]`.

The `#[georm(generated_always)]` attribute is for fields that are
always generated by the database, such as `GENERATED ALWAYS AS
IDENTITY` columns or columns with a `GENERATED ALWAYS AS (expression)
STORED` clause. These fields are now excluded from `INSERT` and
`UPDATE` statements, preventing accidental writes and ensuring data
integrity at compile time.

The `#[georm(generated)]` attribute is for fields that have a default
value generated by the database but can also be manually overridden,
such as `GENERATED BY DEFAULT AS IDENTITY` columns. These fields
behave similarly to `#[georm(defaultable)]` fields, allowing them to
be omitted from `INSERT` statements to use the database-generated
value.

For now, the behaviour is the same between `#[georm(generated)]` and
`#[georm(defaultable)]`, but the addition of the former now will be
useful for future features.

Key changes:
- Added `generated` and `generated_always` attributes to
  `GeormFieldAttributes`.
- Introduced `GeneratedType` enum in the IR to represent the different
  generation strategies.
- Modified the `create` and `update` query generation to exclude
  fields marked with `#[georm(generated_always)]`.
- Integrated `#[georm(generated)]` fields with the existing
  defaultable struct logic.
- Added validation to prevent conflicting attribute usage, namely
  `#[georm(generated)]` and `#[georm(generated_always)]` on the same
  field.

Implements #3
This commit is contained in:
Lucien Cartier-Tilet 2025-08-07 20:27:45 +02:00
parent 545dfa066d
commit 3307aa679d
17 changed files with 434 additions and 103 deletions

27
Cargo.lock generated
View File

@ -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",

View File

@ -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 }

View File

@ -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<sqlx::types::BigDecimal>, // GENERATED ALWAYS AS (expression) STORED
}
```
#### Generated Structs and Behavior
Both `defaultable` and `generated` fields create a companion `<Entity>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<Option<T>>` 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
```

View File

@ -37,7 +37,7 @@ fn generate_struct(
let fields: Vec<proc_macro2::TokenStream> = 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<IdField> = georm_id_fields
.iter()
.map(|field| IdField {

View File

@ -9,6 +9,8 @@
//! or something similar. The type `<StructName>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<T>
// 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<String> = 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<proc_macro2::TokenStream> =
fields.iter().map(create_defaultable_field).collect();
let defaultable_fields: Vec<proc_macro2::TokenStream> = 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,

View File

@ -27,6 +27,10 @@ struct GeormFieldAttributes {
pub relation: Option<O2ORelationship>,
#[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<O2ORelationship>,
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<T> fields
if defaultable && Self::is_option_type(&ty) {
panic!(
"Field '{}' is already an Option<T> and cannot be marked as defaultable. \
"Field '{}' is already an Option<T> 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 {

View File

@ -24,7 +24,7 @@ fn extract_georm_field_attrs(ast: &mut syn::DeriveInput) -> deluxe::Result<Vec<G
let identifiers: Vec<GeormField> = fields
.clone()
.into_iter()
.filter(|field| field.id)
.filter(|field| field.is_id)
.collect();
if identifiers.is_empty() {
Err(syn::Error::new_spanned(

View File

@ -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<String> = (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::<Vec<String>>()
.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<String> = insert_fields
.iter()
.map(|field| field.ident.to_string())
.collect();
let field_idents: Vec<syn::Ident> = insert_fields
.iter()
.map(|field| field.ident.clone())
.collect();
let placeholders: Vec<String> = (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<syn::Ident> = fields.iter().map(|f| f.ident.clone()).collect();
quote! {
async fn create(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
::sqlx::query_as!(
Self,
#create_string,
#query,
#(self.#field_idents),*
)
.fetch_one(pool)

View File

@ -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! {

View File

@ -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<syn::Ident> = 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<syn::Ident> = 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<syn::Ident> = id_fields.iter().map(|f| f.ident.clone()).collect();
let set_clauses: Vec<String> = update_fields
.iter()
.enumerate()
.map(|(i, field)| format!("{} = ${}", field, i + 1))
.collect::<Vec<String>>()
.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::<Vec<String>>()
.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<String> = 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<Self> {
::sqlx::query_as!(
Self, #update_string, #(self.#all_fields),*
Self,
#query,
#(self.#update_idents),*,
#(self.#id_idents),*
)
.fetch_one(pool)
.await

View File

@ -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<String> = (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::<Vec<String>>()
.join(", ");

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS products;

View File

@ -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
);

View File

@ -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<T>`:
//! ### `#[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<i32>, // Can be None for auto-generation
//! pub title: String, // Required field stays the same
//! pub published: Option<bool>, // Can be None to use database default
//! pub created_at: Option<chrono::DateTime<chrono::Utc>>, // Can be None
//! pub(crate) internal_note: Option<String>, // 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<sqlx::types::BigDecimal>, // GENERATED ALWAYS AS (expression) STORED
//! }
//! ```
//!
//! ### Generated Structs and Behavior
//!
//! Both `defaultable` and `generated` fields create a companion `<Entity>Default` struct where these fields become `Option<T>`:
//!
//! ```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<i32>, // Can be None for auto-generation
//! // Note: generated_always fields are completely excluded from this struct
//! }
//!
//! impl Defaultable<i32, Post> for PostDefault {
//! async fn create(&self, pool: &sqlx::PgPool) -> sqlx::Result<Post>;
//! impl Defaultable<i32, Product> for ProductDefault {
//! async fn create(&self, pool: &sqlx::PgPool) -> sqlx::Result<Product>;
//! }
//! ```
//!
@ -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<T>`, you cannot mark it with `#[georm(defaultable)]`. This prevents
//! `Option<Option<T>>` 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<Option<String>>
//! optional_field: Option<String>,
//! }
//!
//! // ❌ 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
//! ```
//!

8
tests/fixtures/generated.sql vendored Normal file
View File

@ -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);

64
tests/generated.rs Normal file
View File

@ -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(())
}

View File

@ -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<chrono::Utc>,
}
#[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<BigDecimal>, // Apparently this can be null ?
}
impl Product {
#[allow(dead_code)]
pub async fn find_by_name(name: String, pool: &sqlx::PgPool) -> ::sqlx::Result<Self> {
::sqlx::query_as!(Self, "SELECT * FROM products WHERE name = $1", name)
.fetch_one(pool)
.await
}
}