ormada_derive/
lib.rs

1//! Proc macro for #[derive(OrmadaModel)]
2//!
3//! This crate provides a derive macro that automatically generates
4//! Model-based create/update operations with auto field handling.
5
6// Proc macros are allowed to use patterns that would be problematic in regular code
7// This is standard practice for code generation
8#![allow(clippy::unwrap_used)]
9#![allow(clippy::expect_used)]
10#![allow(clippy::panic)]
11#![allow(clippy::too_many_lines)]
12#![allow(clippy::uninlined_format_args)]
13#![allow(clippy::doc_markdown)]
14#![allow(clippy::disallowed_methods)]
15#![allow(clippy::option_if_let_else)]
16#![allow(clippy::use_self)]
17#![allow(clippy::ref_option)]
18#![allow(clippy::needless_pass_by_value)]
19#![allow(clippy::missing_panics_doc)]
20#![allow(clippy::missing_errors_doc)]
21#![allow(clippy::module_name_repetitions)]
22#![allow(clippy::redundant_clone)]
23#![allow(clippy::single_char_pattern)]
24#![allow(clippy::unnecessary_wraps)]
25#![allow(clippy::struct_excessive_bools)]
26#![allow(clippy::explicit_iter_loop)]
27#![allow(clippy::collection_is_never_read)]
28#![allow(clippy::suspicious_doc_comments)]
29#![allow(clippy::while_let_on_iterator)]
30#![allow(clippy::manual_while_let_some)]
31#![allow(clippy::unused_peekable)]
32#![allow(dead_code)]
33#![allow(unused_variables)]
34#![allow(unused_must_use)]
35
36use proc_macro::TokenStream;
37use quote::quote;
38use syn::{parse_macro_input, Data, DeriveInput, Fields};
39
40mod atomic;
41mod model;
42mod projection;
43mod relations;
44mod schema;
45
46/// Check if a field has a specific `sea_orm` attribute
47fn has_sea_orm_attribute(field: &syn::Field, attr_name: &str) -> bool {
48    for attr in &field.attrs {
49        if attr.path().is_ident("sea_orm") {
50            // Simple string-based check for attribute presence
51            let meta_str = quote::quote!(#attr).to_string();
52            if meta_str.contains(attr_name) {
53                return true;
54            }
55        }
56    }
57    false
58}
59
60/// Check if a field has a specific ormada attribute
61fn has_ergorm_attribute(field: &syn::Field, attr_name: &str) -> bool {
62    for attr in &field.attrs {
63        if attr.path().is_ident("ormada") {
64            // Simple string-based check for attribute presence
65            let meta_str = quote::quote!(#attr).to_string();
66            if meta_str.contains(attr_name) {
67                return true;
68            }
69        }
70    }
71    false
72}
73
74/// Derive macro for Ormada Model-based operations
75#[proc_macro_derive(OrmadaModel, attributes(ormada))]
76pub fn derive_ormada_model(input: TokenStream) -> TokenStream {
77    let input = parse_macro_input!(input as DeriveInput);
78
79    let struct_name = &input.ident;
80
81    // Extract fields from the struct
82    let fields = match &input.data {
83        Data::Struct(data) => match &data.fields {
84            Fields::Named(fields) => &fields.named,
85            _ => {
86                return syn::Error::new_spanned(
87                    struct_name,
88                    "OrmadaModel can only be derived for structs with named fields",
89                )
90                .to_compile_error()
91                .into();
92            }
93        },
94        _ => {
95            return syn::Error::new_spanned(
96                struct_name,
97                "OrmadaModel can only be derived for structs",
98            )
99            .to_compile_error()
100            .into();
101        }
102    };
103
104    // Categorize fields
105    let mut primary_key = None;
106    let mut auto_now_add_fields = Vec::new(); // Set on create only
107    let mut auto_now_fields = Vec::new(); // Set on create AND update
108    let mut all_fields = Vec::new();
109
110    for field in fields {
111        let field_name = field.ident.as_ref().unwrap();
112        let field_ty = &field.ty;
113
114        all_fields.push((field_name, field_ty));
115
116        // Detect primary key from #[sea_orm(primary_key)]
117        if has_sea_orm_attribute(field, "primary_key") {
118            primary_key = Some(field_name);
119            continue;
120        }
121
122        // Detect auto fields from #[ormada(...)] attributes
123        if has_ergorm_attribute(field, "auto_now_add") {
124            auto_now_add_fields.push(field_name);
125            continue;
126        }
127
128        if has_ergorm_attribute(field, "auto_now") {
129            auto_now_fields.push(field_name);
130        }
131    }
132
133    // Primary key is required
134    if primary_key.is_none() {
135        return syn::Error::new_spanned(
136            struct_name,
137            "Model must have a field marked with #[sea_orm(primary_key)]",
138        )
139        .to_compile_error()
140        .into();
141    }
142    let primary_key = primary_key.unwrap();
143
144    // Note: Type-specific column traits can't be implemented on enum variants
145    // For now, we'll keep the generic ColumnExt trait approach
146    // This is still type-safe at compile time, just not at the method level
147
148    // Generate ActiveModel field assignments for create
149    let create_field_assignments: Vec<_> = all_fields
150        .iter()
151        .map(|(field_name, _)| {
152            if Some(*field_name) == Some(primary_key) {
153                // If ID is default/zero, let DB handle it (NotSet)
154                // Otherwise use the provided ID
155                // We need fully qualified Default call to avoid ambiguity with PartialEq
156                let field_ty = all_fields.iter().find(|(n, _)| n == field_name).unwrap().1;
157                quote! {
158                    #field_name: if model.#field_name == <#field_ty as Default>::default() {
159                        sea_orm::ActiveValue::NotSet
160                    } else {
161                        sea_orm::ActiveValue::Set(model.#field_name)
162                    }
163                }
164            } else if auto_now_add_fields.contains(field_name)
165                || auto_now_fields.contains(field_name)
166            {
167                quote! { #field_name: sea_orm::ActiveValue::Set(now) }
168            } else {
169                quote! { #field_name: sea_orm::ActiveValue::Set(model.#field_name) }
170            }
171        })
172        .collect();
173
174    // Generate ActiveModel field assignments for save (update all fields)
175    let save_field_assignments: Vec<_> = all_fields
176        .iter()
177        .map(|(field_name, _)| {
178            if Some(*field_name) == Some(primary_key) {
179                // Primary key must be Set for update to work
180                quote! { #field_name: sea_orm::ActiveValue::Set(self.#field_name) }
181            } else if auto_now_fields.contains(field_name) {
182                // auto_now fields will be set below with current timestamp
183                quote! { #field_name: sea_orm::ActiveValue::Set(now) }
184            } else {
185                // All other fields: update with current value
186                quote! { #field_name: sea_orm::ActiveValue::Set(self.#field_name) }
187            }
188        })
189        .collect();
190
191    // Generate ActiveModel field assignments for update (only auto_now fields)
192    let update_auto_fields = auto_now_fields.iter().map(|field_name| {
193        quote! {
194            active_model.#field_name = sea_orm::ActiveValue::Set({
195                let now: sea_orm::prelude::DateTimeWithTimeZone = chrono::Utc::now().into();
196                now
197            });
198        }
199    });
200
201    // Parse relations
202    let relation_infos = relations::parse_relations(&input);
203
204    // ALWAYS generate ModelWithRelations (needed even for entities without relations)
205    let model_with_relations = relations::generate_model_with_relations(fields, &relation_infos);
206    let from_impl = relations::generate_from_impl(fields, &relation_infos);
207
208    // ALWAYS generate WithRelationsTrait (needed for accessor methods to work)
209    let field_refs: Vec<&syn::Field> = fields.iter().collect();
210    let trait_impl = relations::generate_trait_impl(&relation_infos, &field_refs);
211
212    // Generate HasRelation implementations (compile-time typed relations)
213    let has_relation_impls = relations::generate_has_relation_impls(&relation_infos);
214
215    let expanded = quote! {
216        // ===== RELATION MODELS =====
217        #model_with_relations
218        #from_impl
219        #trait_impl
220
221        // ===== DJANGO ENTITY TRAIT =====
222        impl ::ormada::traits::OrmadaEntity for Entity {
223            fn to_active_model_for_create(model: Model) -> ::core::result::Result<ActiveModel, ::ormada::error::OrmadaError> {
224                let now = ::chrono::Utc::now().fixed_offset();
225                ::core::result::Result::Ok(ActiveModel {
226                    #(#create_field_assignments,)*
227                })
228            }
229
230            async fn save_model<'a, C: ::sea_orm::ConnectionTrait>(
231                db: &'a C,
232                model: Model,
233            ) -> Result<Model, ormada::error::OrmadaError> {
234                model.save(db).await
235            }
236        }
237
238        // ===== UPDATE OPERATION =====
239        impl Model {
240            /// Save (update) this model (Ormada-style: updates ALL fields)
241            ///
242            /// All model fields are updated in the database.
243            /// Fields marked with #[ormada(auto_now)] are automatically set to the current timestamp.
244            ///
245            /// This follows Ormada's behavior where .save() updates all fields,
246            /// not just modified ones.
247            pub async fn save<'a, C: ::sea_orm::ConnectionTrait>(
248                self,
249                db: &'a C,
250            ) -> Result<Self, ormada::error::OrmadaOrmError> {
251                use sea_orm::Set;
252                let now = ::chrono::Utc::now().fixed_offset();
253
254                // Create ActiveModel with ALL fields marked as Set (to be updated)
255                let mut active_model = ActiveModel {
256                    #(#save_field_assignments,)*
257                };
258
259                // Override auto_now fields with current timestamp
260                #(#update_auto_fields)*
261
262                use sea_orm::ActiveModelTrait;
263                Ok(active_model.update(db).await?)
264            }
265        }
266
267        // ===== RELATION LOADING =====
268        #has_relation_impls
269    };
270
271    TokenStream::from(expanded)
272}
273
274/// Attribute macro for atomic transactions
275///
276/// Wraps the function body in a transaction.
277///
278/// # Usage
279///
280/// ```rust,ignore
281/// #[atomic(db)]
282/// async fn create_user(db: &DatabaseConnection, name: String) -> Result<(), OrmadaError> {
283///     // This code runs inside a transaction!
284///     // 'db' is shadowed by the transaction handle
285///     let user = User::objects(db).create(name).await?;
286///     Ok(())
287/// }
288/// ```
289#[proc_macro_attribute]
290pub fn atomic(args: TokenStream, input: TokenStream) -> TokenStream {
291    atomic::impl_atomic(args, input)
292}
293
294/// Attribute macro for defining Ormada models with clean syntax
295///
296/// This macro transforms a simple struct definition into a full `SeaORM` entity
297/// with all the necessary derives and boilerplate.
298///
299/// # Model Attributes
300///
301/// - `table = "table_name"` - **(required)** Database table name
302/// - `ordering = "field"` - Default ordering for queries
303/// - `hooks = true` - Enable custom lifecycle hooks (see below)
304///
305/// # Usage
306///
307/// ```rust,ignore
308/// use ormada::prelude::*;
309///
310/// #[ormada_model(table = "books")]
311/// struct Book {
312///     #[primary_key]
313///     id: i32,
314///
315///     #[max_length(200)]
316///     #[index]
317///     title: String,
318///
319///     #[foreign_key(Author, on_delete = Cascade)]
320///     author_id: i32,
321///
322///     #[auto_now_add]
323///     created_at: DateTimeWithTimeZone,
324///
325///     #[auto_now]
326///     updated_at: DateTimeWithTimeZone,
327/// }
328/// ```
329///
330/// # Lifecycle Hooks
331///
332/// By default, an empty `LifecycleHooks` implementation is auto-generated.
333/// To provide custom hooks, use `hooks = true`:
334///
335/// ```rust,ignore
336/// #[ormada_model(table = "books", hooks = true)]
337/// struct Book { /* fields */ }
338///
339/// #[async_trait]
340/// impl LifecycleHooks for book::Model {
341///     async fn before_save(&mut self) -> Result<(), OrmadaError> {
342///         // Custom logic before save
343///         Ok(())
344///     }
345/// }
346/// ```
347///
348/// # Field Attributes
349///
350/// ## Primary Key
351/// - `#[primary_key]` - Mark field as primary key
352/// - `#[primary_key(auto_increment = false)]` - Control auto-increment
353///
354/// ## Relationships
355/// - `#[foreign_key(Model)]` - Many-to-One relationship (use Model type, not Entity)
356/// - `#[foreign_key(Model, on_delete = Cascade)]` - FK with ON DELETE behavior
357/// - `#[one_to_one(Model)]` - One-to-One relationship
358/// - `#[one_to_one(Model, on_delete = Cascade)]` - 1:1 with ON DELETE behavior
359/// - `#[many_to_many(Model, through = JoinModel)]` - Many-to-Many with intermediate table
360///
361/// ## Indexing
362/// - `#[index]` / `#[index(name = "idx_name")]` - Create index
363/// - `#[unique]` / `#[unique(name = "uniq_name")]` - Unique constraint
364///
365/// ## Validation
366/// - `#[max_length(n)]` - String max length validation
367/// - `#[min_length(n)]` - String min length validation
368/// - `#[range(min = n, max = m)]` - Numeric range validation
369///
370/// ## Timestamps
371/// - `#[auto_now]` - Auto-update timestamp on save
372/// - `#[auto_now_add]` - Auto-set timestamp on creation
373///
374/// ## Other
375/// - `#[soft_delete]` - Mark field for soft delete (must be `Option<DateTimeWithTimeZone>`)
376/// - `#[skip_serializing]` - Skip field when serializing
377/// - `#[skip_deserializing]` - Skip field when deserializing
378#[proc_macro_attribute]
379pub fn ormada_model(attr: TokenStream, item: TokenStream) -> TokenStream {
380    match model::impl_ormada_model(attr.into(), item.into()) {
381        Ok(tokens) => tokens.into(),
382        Err(err) => err.to_compile_error().into(),
383    }
384}
385
386/// Attribute macro for defining type-safe projections with compile-time validation
387///
388/// Provides a type-safe alternative to JSON-based `values()` queries.
389/// Validates that all non-computed fields exist on the model at compile time.
390///
391/// # Usage
392///
393/// ```rust,ignore
394/// use ormada::prelude::*;
395///
396/// // Simple projection (all fields must exist on Book)
397/// #[ergorm_projection(model = Book)]
398/// struct BookSummary {
399///     title: String,
400///     price: f64,
401/// }
402///
403/// // With computed fields (for aggregations)
404/// #[ergorm_projection(model = Book)]
405/// struct AuthorBookStats {
406///     author_id: i32,           // Validated
407///     #[computed]
408///     book_count: i64,          // Not validated (computed by DB)
409///     #[computed]
410///     avg_price: Option<f64>,   // Not validated (computed by DB)
411/// }
412///
413/// // Query usage
414/// let summaries: Vec<BookSummary> = Book::objects(db)
415///     .filter(Book::Published.eq(true))
416///     .project::<BookSummary>()
417///     .await?;
418/// ```
419///
420/// # Field Attributes
421///
422/// - `#[computed]` - Mark field as computed (e.g., aggregations). These fields
423///   are not validated against the model and must be provided by the query
424///   (e.g., via `.annotate()` for aggregations).
425#[proc_macro_attribute]
426pub fn ergorm_projection(attr: TokenStream, item: TokenStream) -> TokenStream {
427    match projection::generate_projection(attr.into(), item.into()) {
428        Ok(tokens) => tokens.into(),
429        Err(err) => err.to_compile_error().into(),
430    }
431}
432
433/// Attribute macro for defining schema in migration files
434///
435/// This macro is used in migration files to define schema snapshots.
436/// It uses the same syntax as `#[ormada_model]` but is purely declarative -
437/// no runtime code is generated. The CLI parses these definitions to
438/// generate SQL migrations.
439///
440/// # Usage
441///
442/// ```rust,ignore
443/// use ormada::migration::prelude::*;
444///
445/// // Initial migration - full schema definition
446/// pub mod m001_initial {
447///     use super::*;
448///
449///     #[ormada_schema(table = "books", migration = "001_initial")]
450///     pub struct Book {
451///         #[primary_key]
452///         pub id: i32,
453///
454///         #[max_length(200)]
455///         pub title: String,
456///
457///         #[foreign_key(Author)]
458///         pub author_id: i32,
459///     }
460/// }
461///
462/// // Delta migration - extends previous schema
463/// pub mod m002_add_isbn {
464///     use super::*;
465///
466///     #[ormada_schema(table = "books", migration = "002", after = "001", extends = Book)]
467///     pub struct Book {
468///         // Only new fields - inherited fields are implicit
469///         #[index]
470///         pub isbn: String,
471///     }
472/// }
473/// ```
474///
475/// # Attributes
476///
477/// - `table = "name"` - **(required)** Database table name
478/// - `migration = "id"` - Migration identifier (usually matches filename)
479/// - `after = "id"` - Migration this depends on (for ordering)
480/// - `extends = Entity` - Extend schema from previous migration (for deltas)
481/// - `migrate = false` - Exclude from migration generation
482///
483/// # Field Attributes
484///
485/// Same as `#[ormada_model]`:
486/// - `#[primary_key]` - Mark as primary key
487/// - `#[foreign_key(Entity)]` - Foreign key relationship
488/// - `#[index]` - Create index on column
489/// - `#[unique]` - Unique constraint
490/// - `#[max_length(n)]` - String max length
491/// - `#[default(value)]` - Default value
492/// - `#[nullable]` - Allow NULL values
493///
494/// Additional for migrations:
495/// - `#[rename(from = "old", to = "new")]` - Rename column
496/// - `#[drop]` - Drop column (use `()` type)
497#[proc_macro_attribute]
498pub fn ormada_schema(attr: TokenStream, item: TokenStream) -> TokenStream {
499    match schema::impl_ormada_schema(attr.into(), item.into()) {
500        Ok(tokens) => tokens.into(),
501        Err(err) => err.to_compile_error().into(),
502    }
503}
504
505/// Attribute macro for data migrations
506///
507/// Marks an async function as a data migration that runs after schema changes.
508/// The function receives a database connection and uses the standard Ormada ORM API.
509///
510/// # Usage
511///
512/// ```rust,ignore
513/// use ormada::migration::prelude::*;
514/// use crate::models::Author;
515///
516/// #[ormada_data_migration(migration = "003", after = "002")]
517/// async fn populate_emails(db: &DatabaseConnection) -> Result<(), OrmadaError> {
518///     // Use the same Ormada ORM API as your application code!
519///     Author::objects(db)
520///         .filter(Author::Email.is_null())
521///         .update_all(|author| {
522///             author.email = format!("{}@example.com", author.name.to_lowercase());
523///         })
524///         .await?;
525///
526///     Ok(())
527/// }
528/// ```
529///
530/// # Attributes
531///
532/// - `migration = "id"` - **(required)** Migration identifier
533/// - `after = "id"` - Migration this depends on
534#[proc_macro_attribute]
535pub fn ormada_data_migration(attr: TokenStream, item: TokenStream) -> TokenStream {
536    match schema::impl_ormada_data_migration(attr.into(), item.into()) {
537        Ok(tokens) => tokens.into(),
538        Err(err) => err.to_compile_error().into(),
539    }
540}