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:
2025-08-07 20:27:45 +02:00
parent 545dfa066d
commit 3307aa679d
17 changed files with 434 additions and 103 deletions

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(", ");