mirror of
https://github.com/Phundrak/georm.git
synced 2025-11-30 19:03:59 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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! {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(", ");
|
||||
|
||||
Reference in New Issue
Block a user