Skip to main content

cinderblock_core_macros/
lib.rs

1use core::iter::Iterator;
2use std::collections::{HashMap, HashSet};
3
4use cinderblock_extension_api::{
5    Accept, OrderDirection, ReadFilterValue, RelationDecl, RelationKind, ResourceActionInputKind,
6    ResourceAttributeInput, ResourceMacroInput, UpdateChange,
7};
8use syn::{Ident, Type, spanned::Spanned};
9
10/// Checks whether a `syn::Type` is `Option<T>`.
11///
12/// We inspect the outermost path segment for the identifier `Option`. This
13/// handles both `Option<T>` and `std::option::Option<T>` (by checking the
14/// last segment).
15fn is_option_type(ty: &Type) -> bool {
16    if let Type::Path(type_path) = ty {
17        type_path
18            .path
19            .segments
20            .last()
21            .is_some_and(|seg| seg.ident == "Option")
22    } else {
23        false
24    }
25}
26
27#[proc_macro]
28pub fn resource(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
29    // Capture the raw input tokens before parsing so we can forward them
30    // verbatim to extension macros without a reconstruct roundtrip.
31    let raw_tokens: proc_macro2::TokenStream = item.clone().into();
32
33    let input = syn::parse_macro_input!(item as ResourceMacroInput);
34
35    let ident = input.name.last().expect("Missing name segment");
36
37    let fields = input
38        .attributes
39        .iter()
40        .map(ResourceAttributeInput::to_field_definition);
41
42    let primary_key_type = {
43        let fields = input
44            .attributes
45            .iter()
46            .filter(|attr| attr.primary_key.value())
47            .collect::<Vec<_>>();
48
49        match fields.len() {
50            0 => todo!("Support no primary keys"),
51            1 => {
52                let ty = fields[0].ty.clone();
53                quote::quote! { #ty }
54            }
55            _ => todo!("Support multiple primary keys"),
56        }
57    };
58
59    let primary_key_generated = {
60        let fields = input
61            .attributes
62            .iter()
63            .filter(|attr| attr.primary_key.value())
64            .collect::<Vec<_>>();
65
66        match fields.len() {
67            0 => todo!("Support no primary keys"),
68            1 => fields[0].generated.value(),
69            _ => todo!("Support multiple primary keys"),
70        }
71    };
72
73    let primary_key_value = {
74        let fields = input
75            .attributes
76            .iter()
77            .filter(|attr| attr.primary_key.value())
78            .collect::<Vec<_>>();
79
80        match fields.len() {
81            0 => todo!("Support no primary keys"),
82            1 => {
83                let ty = fields[0].name.clone();
84                quote::quote! { &self.#ty }
85            }
86            _ => todo!("Support multiple primary keys"),
87        }
88    };
89
90    let data_layer_specified = input.data_layer.is_some();
91
92    let relations = &input.relations;
93
94    let actions = input.actions.iter().map(|action| match &action.kind {
95        ResourceActionInputKind::Read(read_action) => {
96            let action_name = convert_case::ccase!(pascal, action.name.to_string());
97            let action_name = Ident::new(&action_name, action.name.span());
98
99            let is_get = read_action.get;
100            let is_paged = read_action.paged.is_some();
101            let has_loads = !read_action.load.is_empty();
102
103            // # Get-action fast path
104            //
105            // Get-actions use `PrimaryKey` as Arguments and the resource
106            // itself as Response. No Arguments struct, no filter codegen,
107            // no in-memory data layer traits — the blanket
108            // `PerformReadOne` impl on InMemoryDataLayer handles it.
109            if is_get {
110                return quote::quote! {
111                    struct #action_name;
112
113                    impl cinderblock_core::ReadAction for #action_name {
114                        type Output = #ident;
115                        type Arguments = <#ident as cinderblock_core::Resource>::PrimaryKey;
116                        type Response = #ident;
117                    }
118                };
119            }
120
121            // # Arguments struct generation
122            //
123            // When the read action declares arguments, we generate a dedicated
124            // `{ActionName}Arguments` struct with `Deserialize` so it can be
125            // populated from query parameters. When no arguments are declared,
126            // we use `()` as the arguments type — unless the action is paged,
127            // in which case we always need an Arguments struct to hold
128            // `page` and `per_page` fields.
129            let has_user_arguments = !read_action.arguments.is_empty();
130            let needs_arguments_struct = has_user_arguments || is_paged;
131
132            let (arguments_type, arguments_struct) = if needs_arguments_struct {
133                let args_name = Ident::new(&format!("{action_name}Arguments"), action.name.span());
134                let user_arg_fields = read_action.arguments.iter().map(|arg| {
135                    let name = &arg.name;
136                    let ty = &arg.ty;
137                    quote::quote! { pub #name: #ty }
138                });
139
140                // For paged actions, append `page` and `per_page` fields.
141                let paged_fields = if is_paged {
142                    quote::quote! {
143                        pub page: Option<u32>,
144                        pub per_page: Option<u32>,
145                    }
146                } else {
147                    quote::quote! {}
148                };
149
150                (
151                    quote::quote! { #args_name },
152                    quote::quote! {
153                        #[derive(::std::fmt::Debug, cinderblock_core::serde::Deserialize)]
154                        pub struct #args_name {
155                            #(#user_arg_fields,)*
156                            #paged_fields
157                        }
158                    },
159                )
160            } else {
161                (quote::quote! { () }, quote::quote! {})
162            };
163
164            // # Paged trait impl
165            //
166            // When the action is paged, generate a `Paged` impl on the
167            // Arguments struct that resolves defaults and clamping from
168            // the DSL config.
169            let paged_impl = if let Some(paged_config) = &read_action.paged {
170                let args_name = Ident::new(&format!("{action_name}Arguments"), action.name.span());
171
172                let default_per_page = match paged_config.default_per_page {
173                    Some(n) => quote::quote! { #n },
174                    None => quote::quote! { cinderblock_core::DEFAULT_PER_PAGE },
175                };
176
177                // Clamp per_page to max if configured, otherwise just use
178                // the resolved value directly.
179                let per_page_body = if let Some(max) = paged_config.max_per_page {
180                    quote::quote! {
181                        self.per_page.unwrap_or(#default_per_page).min(#max)
182                    }
183                } else {
184                    quote::quote! {
185                        self.per_page.unwrap_or(#default_per_page)
186                    }
187                };
188
189                quote::quote! {
190                    impl cinderblock_core::Paged for #args_name {
191                        fn page(&self) -> u32 {
192                            self.page.unwrap_or(1)
193                        }
194
195                        fn per_page(&self) -> u32 {
196                            #per_page_body
197                        }
198                    }
199                }
200            } else {
201                quote::quote! {}
202            };
203
204            // # Response wrapper struct generation
205            //
206            // When a read action declares `load [...]`, we generate a
207            // response wrapper struct that flattens the base resource and
208            // adds a field for each loaded relation. The wrapper is used as
209            // the `Response` element type instead of the raw resource.
210            //
211            // For `belongs_to`, the field type is the related resource.
212            // For `has_many`, the field type is `Vec<RelatedResource>`.
213            let loaded_relations: Vec<&RelationDecl> = read_action
214                .load
215                .iter()
216                .map(|name| {
217                    relations
218                        .iter()
219                        .find(|r| r.name == *name)
220                        .expect("load reference validated during parsing")
221                })
222                .collect();
223
224            let response_wrapper = if has_loads {
225                let wrapper_name =
226                    Ident::new(&format!("{action_name}Response"), action.name.span());
227
228                let relation_fields = loaded_relations.iter().map(|rel| {
229                    let rel_name = &rel.name;
230                    let rel_ty = &rel.ty;
231                    match rel.kind {
232                        RelationKind::BelongsTo => quote::quote! {
233                            pub #rel_name: #rel_ty
234                        },
235                        RelationKind::HasMany => quote::quote! {
236                            pub #rel_name: Vec<#rel_ty>
237                        },
238                    }
239                });
240
241                quote::quote! {
242                    #[derive(::std::fmt::Debug, ::std::clone::Clone, cinderblock_core::serde::Serialize)]
243                    pub struct #wrapper_name {
244                        #[serde(flatten)]
245                        pub base: #ident,
246                        #(#relation_fields),*
247                    }
248                }
249            } else {
250                quote::quote! {}
251            };
252
253            // # Response type
254            //
255            // Non-paged actions without `load` return `Vec<Output>`.
256            // Non-paged actions with `load` return `Vec<WrapperStruct>`.
257            // Paged actions without `load` return `PaginatedResult<Output>`.
258            //
259            // TODO: support paged + load combination
260            let response_type = if has_loads {
261                let wrapper_name =
262                    Ident::new(&format!("{action_name}Response"), action.name.span());
263                quote::quote! { Vec<#wrapper_name> }
264            } else if is_paged {
265                quote::quote! { cinderblock_core::PaginatedResult<#ident> }
266            } else {
267                quote::quote! { Vec<#ident> }
268            };
269
270            // # Filter codegen helper
271            //
272            // Builds the chain of boolean clauses for the row predicate.
273            // Used by both the plain and relation-loading code paths.
274            let build_filters = |read_action: &cinderblock_extension_api::ActionRead| {
275                read_action.filters.iter().map(|filter| {
276                    let field = &filter.field;
277                    let op = match filter.op {
278                        cinderblock_extension_api::ReadFilterOperation::Eq => quote::quote! { == },
279                    };
280                    match &filter.value {
281                        ReadFilterValue::Literal(expr) => {
282                            quote::quote! {
283                                row.#field #op #expr &&
284                            }
285                        }
286                        ReadFilterValue::Arg(arg_name) => {
287                            let arg_decl = read_action
288                                .arguments
289                                .iter()
290                                .find(|a| a.name == *arg_name)
291                                .expect("arg reference validated during parsing");
292                            if is_option_type(&arg_decl.ty) {
293                                quote::quote! {
294                                    args.#arg_name.as_ref().map_or(true, |v| row.#field #op *v) &&
295                                }
296                            } else {
297                                quote::quote! {
298                                    row.#field #op args.#arg_name &&
299                                }
300                            }
301                        }
302                    }
303                }).collect::<Vec<_>>()
304            };
305
306            // # Order comparator codegen helper
307            //
308            // Builds a sort_by closure body from the read action's `order`
309            // clauses. Each clause becomes a `.cmp()` call on the field,
310            // chained via `.then_with()` for compound ordering.
311            //
312            // Returns `None` when there are no order clauses, so callers
313            // can skip emitting sort code entirely.
314            let build_order_sort = |read_action: &cinderblock_extension_api::ActionRead| -> Option<proc_macro2::TokenStream> {
315                if read_action.orders.is_empty() {
316                    return None;
317                }
318
319                let mut clauses = read_action.orders.iter();
320                let first = clauses.next().unwrap();
321                let first_field = &first.field;
322                let first_cmp = match first.direction {
323                    OrderDirection::Asc  => quote::quote! { a.#first_field.cmp(&b.#first_field) },
324                    OrderDirection::Desc => quote::quote! { b.#first_field.cmp(&a.#first_field) },
325                };
326
327                let rest: Vec<_> = clauses.map(|clause| {
328                    let field = &clause.field;
329                    match clause.direction {
330                        OrderDirection::Asc  => quote::quote! { .then_with(|| a.#field.cmp(&b.#field)) },
331                        OrderDirection::Desc => quote::quote! { .then_with(|| b.#field.cmp(&a.#field)) },
332                    }
333                }).collect();
334
335                Some(quote::quote! { #first_cmp #(#rest)* })
336            };
337
338            // # In-memory data layer codegen
339            //
340            // When no custom data layer is specified, generate the
341            // InMemoryReadAction filter and either:
342            //   - `InMemoryPerformRead` (for actions without `load`), or
343            //   - A direct `PerformRead` impl (for actions with `load`)
344            //     that does the base query then loads relations from the
345            //     in-memory store.
346            let data_layer_block = if data_layer_specified {
347                quote::quote! { }
348            } else if has_loads {
349                // # Relation-loading PerformRead codegen
350                //
351                // For actions with `load`, we generate a direct `PerformRead`
352                // impl on `InMemoryDataLayer` (skipping the blanket) that:
353                //   1. Reads all base rows and applies filters
354                //   2. Sorts results according to `order` clauses
355                //   3. Loads each related resource type from the store
356                //   4. Assembles the response wrapper for each base row
357                let filters = build_filters(read_action);
358                let order_sort = build_order_sort(read_action);
359                let order_sort_block = order_sort.as_ref().map(|cmp_body| {
360                    quote::quote! { base_rows.sort_by(|a, b| #cmp_body); }
361                }).unwrap_or_default();
362
363                // Generate the relation loading code. For each loaded relation:
364                //
365                // - `belongs_to`: collect FK values from base rows, load all
366                //   destination resources, build a HashMap<PK, Resource>,
367                //   then look up each base row's FK.
368                //
369                // - `has_many`: collect PK values from base rows, load all
370                //   destination resources, group them by their FK field into
371                //   a HashMap<FK, Vec<Resource>>.
372                let relation_loads = loaded_relations.iter().map(|rel| {
373                    let rel_ty = &rel.ty;
374                    let source_attr = &rel.source_attribute;
375                    let map_name = Ident::new(
376                        &format!("{}_map", rel.name),
377                        rel.name.span(),
378                    );
379
380                    match rel.kind {
381                        RelationKind::BelongsTo => {
382                            // For belongs_to: the FK is on the base resource.
383                            // We load all destination resources and index them
384                            // by their primary key.
385                            quote::quote! {
386                                let all_related: Vec<#rel_ty> = dl.load_all::<#rel_ty>().await;
387                                let #map_name: ::std::collections::HashMap<String, #rel_ty> = all_related
388                                    .into_iter()
389                                    .map(|r| {
390                                        use cinderblock_core::Resource;
391                                        (r.primary_key().to_string(), r)
392                                    })
393                                    .collect();
394                            }
395                        }
396                        RelationKind::HasMany => {
397                            // For has_many: the FK is on the related resource,
398                            // pointing back to the base resource's PK. We load
399                            // all related resources and group them by the FK
400                            // field value.
401                            quote::quote! {
402                                let all_related: Vec<#rel_ty> = dl.load_all::<#rel_ty>().await;
403                                let mut #map_name: ::std::collections::HashMap<String, Vec<#rel_ty>> =
404                                    ::std::collections::HashMap::new();
405                                for r in all_related {
406                                    let key = r.#source_attr.to_string();
407                                    #map_name.entry(key).or_default().push(r);
408                                }
409                            }
410                        }
411                    }
412                });
413
414                // # Wrapper assembly for each base row
415                //
416                // For each filtered base row, look up the loaded relations
417                // and construct the response wrapper struct.
418                let wrapper_name =
419                    Ident::new(&format!("{action_name}Response"), action.name.span());
420
421                let relation_field_inits = loaded_relations.iter().map(|rel| {
422                    let rel_name = &rel.name;
423                    let source_attr = &rel.source_attribute;
424                    let map_name = Ident::new(
425                        &format!("{}_map", rel.name),
426                        rel.name.span(),
427                    );
428
429                    match rel.kind {
430                        RelationKind::BelongsTo => {
431                            let rel_ty = &rel.ty;
432                            let rel_name_str = rel.name.to_string();
433                            quote::quote! {
434                                #rel_name: #map_name
435                                    .get(&row.#source_attr.to_string())
436                                    .cloned()
437                                    .ok_or_else(|| {
438                                        cinderblock_core::ListError::DataLayer(
439                                            format!(
440                                                "belongs_to relation `{}` of type `{}`: no record found for FK value `{}`",
441                                                #rel_name_str,
442                                                ::std::any::type_name::<#rel_ty>(),
443                                                row.#source_attr,
444                                            ).into(),
445                                        )
446                                    })?
447                            }
448                        }
449                        RelationKind::HasMany => {
450                            quote::quote! {
451                                #rel_name: {
452                                    use cinderblock_core::Resource;
453                                    #map_name
454                                        .get(&row.primary_key().to_string())
455                                        .cloned()
456                                        .unwrap_or_default()
457                                }
458                            }
459                        }
460                    }
461                });
462
463                quote::quote! {
464                    impl cinderblock_core::PerformRead<#action_name> for cinderblock_core::data_layer::in_memory::InMemoryDataLayer {
465                        async fn read(&self, args: &<#action_name as cinderblock_core::ReadAction>::Arguments) -> Result<<#action_name as cinderblock_core::ReadAction>::Response, cinderblock_core::ListError> {
466                            let dl = self;
467
468                            // Step 1: Load and filter base rows
469                            let mut base_rows: Vec<#ident> = dl.load_all::<#ident>().await
470                                .into_iter()
471                                .filter(|row| { #(#filters)* true })
472                                .collect();
473
474                            // Step 1b: Sort base rows if order clauses declared
475                            #order_sort_block
476
477                            // Step 2: Load related resources
478                            #(#relation_loads)*
479
480                            // Step 3: Assemble response wrappers
481                            let results: Result<Vec<#wrapper_name>, cinderblock_core::ListError> = base_rows
482                                .into_iter()
483                                .map(|row| -> Result<#wrapper_name, cinderblock_core::ListError> {
484                                    Ok(#wrapper_name {
485                                        #(#relation_field_inits,)*
486                                        base: row,
487                                    })
488                                })
489                                .collect();
490
491                            results
492                        }
493                    }
494                }
495            } else if is_paged {
496                let filters = build_filters(read_action);
497                let order_sort = build_order_sort(read_action);
498                let order_sort_block = order_sort.as_ref().map(|cmp_body| {
499                    quote::quote! { filtered.sort_by(|a, b| #cmp_body); }
500                }).unwrap_or_default();
501
502                quote::quote! {
503                    impl cinderblock_core::data_layer::in_memory::InMemoryPagedReadAction for #action_name {
504                        fn filter(row: &Self::Output, args: &Self::Arguments) -> bool {
505                            #(#filters)* true
506                        }
507                    }
508
509                    impl cinderblock_core::data_layer::in_memory::InMemoryPerformRead for #action_name {
510                        fn execute(
511                            all: impl Iterator<Item = Self::Output>,
512                            args: &Self::Arguments,
513                        ) -> Self::Response {
514                            use cinderblock_core::Paged;
515
516                            let mut filtered: Vec<Self::Output> = all
517                                .filter(|row| <Self as cinderblock_core::data_layer::in_memory::InMemoryPagedReadAction>::filter(row, args))
518                                .collect();
519
520                            #order_sort_block
521
522                            let total = filtered.len() as u64;
523                            let page = args.page();
524                            let per_page = args.per_page();
525                            let total_pages = if total == 0 { 1 } else { ((total as u32).saturating_add(per_page - 1)) / per_page };
526
527                            let skip = ((page.saturating_sub(1)) as usize) * (per_page as usize);
528                            let data: Vec<Self::Output> = filtered
529                                .into_iter()
530                                .skip(skip)
531                                .take(per_page as usize)
532                                .collect();
533
534                            cinderblock_core::PaginatedResult {
535                                data,
536                                meta: cinderblock_core::PaginationMeta {
537                                    page,
538                                    per_page,
539                                    total,
540                                    total_pages,
541                                },
542                            }
543                        }
544                    }
545                }
546            } else {
547                // # Filter codegen for InMemoryReadAction
548                //
549                // Each filter becomes a boolean clause AND'd together. Literal
550                // values are emitted directly; `arg(name)` references access
551                // the corresponding field on the args struct.
552                //
553                // For `Option<T>` argument types, the filter clause is
554                // conditional: when `None`, the clause is skipped (evaluates
555                // to `true`). This lets optional arguments act as "filter if
556                // provided" semantics.
557                let filters = build_filters(read_action);
558                let order_sort = build_order_sort(read_action);
559
560                let execute_body = if let Some(cmp_body) = order_sort {
561                    quote::quote! {
562                        let mut results: Vec<Self::Output> = all
563                            .filter(|row| <Self as cinderblock_core::data_layer::in_memory::InMemoryReadAction>::filter(row, args))
564                            .collect();
565                        results.sort_by(|a, b| #cmp_body);
566                        results
567                    }
568                } else {
569                    quote::quote! {
570                        all.filter(|row| <Self as cinderblock_core::data_layer::in_memory::InMemoryReadAction>::filter(row, args))
571                            .collect()
572                    }
573                };
574
575                quote::quote! {
576                    impl cinderblock_core::data_layer::in_memory::InMemoryReadAction for #action_name {
577                        fn filter(row: &Self::Output, args: &Self::Arguments) -> bool {
578                            #(#filters)* true
579                        }
580                    }
581
582                    impl cinderblock_core::data_layer::in_memory::InMemoryPerformRead for #action_name {
583                        fn execute(
584                            all: impl Iterator<Item = Self::Output>,
585                            args: &Self::Arguments,
586                        ) -> Self::Response {
587                            #execute_body
588                        }
589                    }
590                }
591            };
592
593            quote::quote! {
594                #arguments_struct
595
596                #paged_impl
597
598                #response_wrapper
599
600                pub struct #action_name;
601
602                impl cinderblock_core::ReadAction for #action_name {
603                    type Output = #ident;
604
605                    type Arguments = #arguments_type;
606
607                    type Response = #response_type;
608                }
609
610                #data_layer_block
611            }
612        }
613        ResourceActionInputKind::Create { accept } => {
614            let action_name = convert_case::ccase!(pascal, action.name.to_string());
615            let action_name = Ident::new(&action_name, action.name.span());
616            let input_name = Ident::new(&format!("{action_name}Input"), action.name.span());
617
618            let attributes = input.attributes.iter().filter(|attr| attr.writable.value());
619
620            let (present, mut missing_names) = match accept {
621                Accept::Default => (
622                    attributes
623                        .map(|attr| (attr.name.to_string(), attr))
624                        .collect::<HashMap<_, _>>(),
625                    HashMap::new(),
626                ),
627                Accept::Only(idents) => {
628                    let idents = idents
629                        .iter()
630                        .map(|ident| ident.to_string())
631                        .collect::<HashSet<_>>();
632
633                    attributes.fold(
634                        (HashMap::new(), HashMap::new()),
635                        |(mut present, mut missing), attr| {
636                            if idents.contains(&attr.name.to_string()) {
637                                present.insert(attr.name.to_string(), attr);
638                            } else {
639                                missing.insert(attr.name.to_string(), attr);
640                            }
641                            (present, missing)
642                        },
643                    )
644                }
645            };
646
647            input
648                .attributes
649                .iter()
650                .filter(|attr| {
651                    !attr.writable.value() || !present.contains_key(&attr.name.to_string())
652                })
653                .for_each(|attr| {
654                    missing_names.insert(attr.name.to_string(), attr);
655                });
656
657            let attributes = present.values().map(|attr| attr.to_field_definition());
658
659            let missing_names = missing_names.values().map(|attr| attr.to_default());
660
661            let present_names = present.values().map(|attr| attr.name.clone());
662
663            quote::quote! {
664                #[derive(::std::fmt::Debug)]
665                pub struct #action_name;
666
667                #[derive(::std::fmt::Debug, cinderblock_core::serde::Deserialize)]
668                pub struct #input_name {
669                    #(pub #attributes),*
670                }
671
672                impl cinderblock_core::Create<#action_name> for #ident {
673                    type Input = #input_name;
674
675                    fn from_create_input(input: Self::Input) -> Self {
676                        #ident {
677                            // Iterate over attributes in #action_name.
678                            #(#present_names: input.#present_names,)*
679
680                            // All types attributes not present in #action_name should use default
681                            #(#missing_names),*
682                        }
683                    }
684                }
685            }
686        }
687        ResourceActionInputKind::Update(update) => {
688            let action_name = convert_case::ccase!(pascal, action.name.to_string());
689            let action_name = Ident::new(&action_name, action.name.span());
690            let input_name = Ident::new(&format!("{action_name}Input"), action.name.span());
691
692            let attributes = input.attributes.iter().filter(|attr| attr.writable.value());
693
694            let present = match &update.accept {
695                Accept::Default => attributes.collect::<Vec<_>>(),
696                Accept::Only(idents) => {
697                    let idents = idents
698                        .iter()
699                        .map(|ident| ident.to_string())
700                        .collect::<HashSet<_>>();
701
702                    attributes
703                        .filter(|attr| idents.contains(&attr.name.to_string()))
704                        .collect()
705                }
706            };
707
708            let field_definitions = present.iter().map(|attr| attr.to_field_definition());
709
710            // # Field assignment generation
711            //
712            // Each accepted field from the input struct gets assigned onto `self`
713            // in the generated `apply_update_input` method.
714            let field_assignments = present.iter().map(|attr| {
715                let name = &attr.name;
716                quote::quote! { self.#name = input.#name; }
717            });
718
719            // # Change closure generation
720            //
721            // Each `change_ref` closure is emitted as a typed closure bound to a
722            // variable, then called with `self`. We inject `&mut Self` as the
723            // parameter type so field access resolves without the user needing
724            // to annotate the type in the DSL.
725            let change_ref_calls =
726                update
727                    .changes
728                    .iter()
729                    .enumerate()
730                    .filter_map(|(i, change)| match change {
731                        UpdateChange::ChangeRef(closure) => {
732                            let param = closure
733                                .inputs
734                                .first()
735                                .expect("change_ref closure must have exactly one parameter");
736                            let body = &closure.body;
737                            let binding = Ident::new(&format!("change_ref_{i}"), param.span());
738                            Some(quote::quote! {
739                                let #binding = |#param: &mut Self| #body;
740                                #binding(self);
741                            })
742                        }
743                        // TODO: support `change` (by-value) variant
744                        UpdateChange::Change(_) => None,
745                    });
746
747            quote::quote! {
748                #[derive(::std::fmt::Debug)]
749                pub struct #action_name;
750
751                #[derive(::std::fmt::Debug, cinderblock_core::serde::Deserialize)]
752                pub struct #input_name {
753                    #(pub #field_definitions),*
754                }
755
756                impl cinderblock_core::Update<#action_name> for #ident {
757                    type Input = #input_name;
758
759                    fn apply_update_input(&mut self, input: Self::Input) {
760                        #(#field_assignments)*
761                        #(#change_ref_calls)*
762                    }
763                }
764            }
765        }
766        ResourceActionInputKind::Destroy => {
767            // # Destroy action codegen
768            //
769            // Destroy actions only need a marker struct and a `Destroy<A>` impl.
770            // No input struct is generated — the primary key comes from the
771            // URL path at the HTTP layer.
772            let action_name = convert_case::ccase!(pascal, action.name.to_string());
773            let action_name = Ident::new(&action_name, action.name.span());
774
775            quote::quote! {
776                #[derive(::std::fmt::Debug)]
777                pub struct #action_name;
778
779                impl cinderblock_core::Destroy<#action_name> for #ident {}
780            }
781        }
782    });
783
784    let name_segments: Vec<String> = input
785        .name
786        .iter()
787        .map(|segment| segment.to_string())
788        .collect();
789    let resource_name_literal = name_segments.join(".");
790
791    // # Data layer selection
792    //
793    // If the user specified `data_layer = some::Path;` in the DSL, use that
794    // path. Otherwise default to the built-in in-memory data layer.
795    let data_layer_path = input.data_layer.map_or_else(
796        || quote::quote! { cinderblock_core::data_layer::in_memory::InMemoryDataLayer },
797        |path| quote::quote! { #path },
798    );
799
800    // # Extension forwarding
801    //
802    // For each declared extension, we forward the raw DSL tokens (captured
803    // before parsing) inside a braced group, followed by a `config = { ... }`
804    // block containing the extension-specific configuration. This avoids a
805    // parse-then-reconstruct roundtrip — the extension macro receives the
806    // exact tokens the user wrote.
807    let extension_calls = input.extensions.iter().map(|ext| {
808        let path = &ext.path;
809        let config_tokens = &ext.config_tokens;
810
811        quote::quote! {
812            #path::__resource_extension! {
813                { #raw_tokens }
814
815                config = {
816                    #config_tokens
817                }
818            }
819        }
820    });
821
822    quote::quote! {
823        #[derive(::std::fmt::Debug, ::std::clone::Clone, cinderblock_core::serde::Serialize, cinderblock_core::serde::Deserialize)]
824        pub struct #ident {
825            #(#fields),*
826        }
827
828        impl cinderblock_core::Resource for #ident {
829            type PrimaryKey = #primary_key_type;
830
831            type DataLayer = #data_layer_path;
832
833            const NAME: &'static [&'static str] = &[#(#name_segments),*];
834
835            const RESOURCE_NAME: &'static str = #resource_name_literal;
836
837            const PRIMARY_KEY_GENERATED: bool = #primary_key_generated;
838
839            fn primary_key(&self) -> &Self::PrimaryKey {
840                #primary_key_value
841            }
842        }
843
844        #(#actions)*
845
846        #(#extension_calls)*
847    }
848    .into()
849}
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854    use assert2::{assert, check};
855    use cinderblock_extension_api::ResourceActionInput;
856    use quote::quote;
857
858    fn parse_resource(tokens: proc_macro2::TokenStream) -> ResourceMacroInput {
859        let result = syn::parse2::<ResourceMacroInput>(tokens);
860        assert!(let Ok(input) = result);
861        input
862    }
863
864    #[test]
865    fn minimal_resource_with_one_simple_attribute() {
866        let input = parse_resource(quote! {
867            name = Foo;
868
869            attributes {
870                id String;
871            }
872        });
873
874        check!(input.name.len() == 1);
875        check!(input.name[0] == "Foo");
876
877        check!(input.attributes.len() == 1);
878        let attr = &input.attributes[0];
879        check!(attr.name == "id");
880        check!(!attr.primary_key.value());
881        check!(!attr.generated.value());
882        check!(attr.writable.value());
883        check!(attr.default.is_none());
884
885        check!(input.actions.is_empty());
886    }
887
888    #[test]
889    fn dotted_name_parses_into_multiple_segments() {
890        let input = parse_resource(quote! {
891            name = Helpdesk.Support.Ticket;
892
893            attributes {
894                id String;
895            }
896        });
897
898        check!(input.name.len() == 3);
899        check!(input.name[0] == "Helpdesk");
900        check!(input.name[1] == "Support");
901        check!(input.name[2] == "Ticket");
902    }
903
904    #[test]
905    fn attribute_with_options_block() {
906        let input = parse_resource(quote! {
907            name = Ticket;
908
909            attributes {
910                ticket_id Uuid {
911                    primary_key true;
912                    writable false;
913                    default || uuid::Uuid::new_v4();
914                }
915            }
916        });
917
918        check!(input.attributes.len() == 1);
919        let attr = &input.attributes[0];
920        check!(attr.name == "ticket_id");
921        check!(attr.primary_key.value());
922        check!(!attr.writable.value());
923        check!(attr.default.is_some());
924    }
925
926    #[test]
927    fn attribute_with_generated_flag() {
928        let input = parse_resource(quote! {
929            name = Item;
930
931            attributes {
932                item_id Uuid {
933                    primary_key true;
934                    generated true;
935                }
936            }
937        });
938
939        let attr = &input.attributes[0];
940        check!(attr.generated.value());
941        check!(attr.primary_key.value());
942        // writable should still be the default (true) since it wasn't set.
943        check!(attr.writable.value());
944    }
945
946    #[test]
947    fn multiple_attributes_mixed_simple_and_complex() {
948        let input = parse_resource(quote! {
949            name = Order;
950
951            attributes {
952                order_id Uuid {
953                    primary_key true;
954                    writable false;
955                }
956                item_name String;
957                quantity u32;
958            }
959        });
960
961        check!(input.attributes.len() == 3);
962
963        check!(input.attributes[0].name == "order_id");
964        check!(input.attributes[0].primary_key.value());
965        check!(!input.attributes[0].writable.value());
966
967        check!(input.attributes[1].name == "item_name");
968        check!(!input.attributes[1].primary_key.value());
969        check!(input.attributes[1].writable.value());
970
971        check!(input.attributes[2].name == "quantity");
972        check!(!input.attributes[2].primary_key.value());
973        check!(input.attributes[2].writable.value());
974    }
975
976    #[test]
977    fn actions_block_with_simple_create() {
978        let input = parse_resource(quote! {
979            name = Ticket;
980
981            attributes {
982                id String;
983            }
984
985            actions {
986                create open;
987            }
988        });
989
990        check!(input.actions.len() == 1);
991        check!(input.actions[0].name == "open");
992        check!(let ResourceActionInputKind::Create { accept: Accept::Default } = &input.actions[0].kind);
993    }
994
995    #[test]
996    fn action_with_accept_list() {
997        let input = parse_resource(quote! {
998            name = Ticket;
999
1000            attributes {
1001                id String;
1002            }
1003
1004            actions {
1005                create assign {
1006                    accept [subject];
1007                };
1008            }
1009        });
1010
1011        check!(input.actions.len() == 1);
1012        check!(input.actions[0].name == "assign");
1013        assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = &input.actions[0].kind);
1014        check!(idents.len() == 1);
1015        check!(idents[0] == "subject");
1016    }
1017
1018    #[test]
1019    fn no_actions_block_omitted() {
1020        let input = parse_resource(quote! {
1021            name = Simple;
1022
1023            attributes {
1024                id u64;
1025            }
1026        });
1027
1028        check!(input.actions.is_empty());
1029    }
1030
1031    #[test]
1032    fn full_helpdesk_example() {
1033        let input = parse_resource(quote! {
1034            name = Helpdesk.Support.Ticket;
1035
1036            attributes {
1037                ticket_id Uuid {
1038                    primary_key true;
1039                    writable false;
1040                    default || uuid::Uuid::new_v4();
1041                }
1042
1043                subject String;
1044
1045                status TicketStatus;
1046            }
1047
1048            actions {
1049                create open;
1050
1051                create assign {
1052                    accept [subject];
1053                };
1054            }
1055        });
1056
1057        check!(input.name.len() == 3);
1058        check!(input.name[0] == "Helpdesk");
1059        check!(input.name[1] == "Support");
1060        check!(input.name[2] == "Ticket");
1061
1062        check!(input.attributes.len() == 3);
1063
1064        let ticket_id = &input.attributes[0];
1065        check!(ticket_id.name == "ticket_id");
1066        check!(ticket_id.primary_key.value());
1067        check!(!ticket_id.writable.value());
1068        check!(ticket_id.default.is_some());
1069
1070        let subject = &input.attributes[1];
1071        check!(subject.name == "subject");
1072        check!(!subject.primary_key.value());
1073        check!(subject.writable.value());
1074        check!(subject.default.is_none());
1075
1076        let status = &input.attributes[2];
1077        check!(status.name == "status");
1078        check!(!status.primary_key.value());
1079        check!(status.writable.value());
1080
1081        check!(input.actions.len() == 2);
1082
1083        check!(input.actions[0].name == "open");
1084        check!(let ResourceActionInputKind::Create { accept: Accept::Default } = &input.actions[0].kind);
1085
1086        check!(input.actions[1].name == "assign");
1087        assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = &input.actions[1].kind);
1088        check!(idents.len() == 1);
1089        check!(idents[0] == "subject");
1090    }
1091
1092    #[test]
1093    fn parse_simple_create_action() {
1094        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1095            create open;
1096        }));
1097
1098        check!(action.name == "open");
1099        check!(let ResourceActionInputKind::Create { accept: Accept::Default } = action.kind);
1100    }
1101
1102    #[test]
1103    fn parse_create_action_with_multiple_accept_idents() {
1104        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1105            create bulk_insert {
1106                accept [name, email, age];
1107            }
1108        }));
1109
1110        check!(action.name == "bulk_insert");
1111        assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = action.kind);
1112        let names: Vec<String> = idents.iter().map(|i| i.to_string()).collect();
1113        check!(names == vec!["name", "email", "age"]);
1114    }
1115
1116    #[test]
1117    fn unknown_action_kind_produces_error() {
1118        let result = syn::parse2::<ResourceActionInput>(quote! {
1119            frobnicate foo;
1120        });
1121
1122        assert!(let Err(err) = result);
1123        let msg = err.to_string();
1124        check!(msg.contains("Unexpected action kind"));
1125        check!(msg.contains("frobnicate"));
1126    }
1127
1128    #[test]
1129    fn unknown_attribute_option_produces_error() {
1130        let result = syn::parse2::<ResourceMacroInput>(quote! {
1131            name = Thing;
1132
1133            attributes {
1134                id String {
1135                    bogus true;
1136                }
1137            }
1138        });
1139
1140        assert!(let Err(err) = result);
1141        let msg = err.to_string();
1142        check!(msg.contains("Unexpected attribute key"));
1143        check!(msg.contains("bogus"));
1144    }
1145
1146    #[test]
1147    fn missing_semicolon_after_name_produces_error() {
1148        let result = syn::parse2::<ResourceMacroInput>(quote! {
1149            name = Foo
1150
1151            attributes {
1152                id String;
1153            }
1154        });
1155
1156        check!(let Err(_) = result);
1157    }
1158
1159    #[test]
1160    fn parse_simple_update_action_with_default_accept() {
1161        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1162            update close;
1163        }));
1164
1165        check!(action.name == "close");
1166        assert!(let ResourceActionInputKind::Update(update) = &action.kind);
1167        check!(let Accept::Default = update.accept);
1168        check!(update.changes.is_empty());
1169    }
1170
1171    #[test]
1172    fn parse_update_action_with_accept_and_change_ref() {
1173        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1174            update close {
1175                accept [];
1176                change_ref |resource| {
1177                    resource.status = TicketStatus::Closed;
1178                };
1179            }
1180        }));
1181
1182        check!(action.name == "close");
1183        assert!(let ResourceActionInputKind::Update(update) = &action.kind);
1184        assert!(let Accept::Only(idents) = &update.accept);
1185        check!(idents.is_empty());
1186        check!(update.changes.len() == 1);
1187        check!(let UpdateChange::ChangeRef(_) = &update.changes[0]);
1188    }
1189
1190    #[test]
1191    fn parse_update_action_with_accept_fields() {
1192        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1193            update reassign {
1194                accept [subject, status];
1195            }
1196        }));
1197
1198        check!(action.name == "reassign");
1199        assert!(let ResourceActionInputKind::Update(update) = &action.kind);
1200        assert!(let Accept::Only(idents) = &update.accept);
1201        let names: Vec<String> = idents.iter().map(|i| i.to_string()).collect();
1202        check!(names == vec!["subject", "status"]);
1203        check!(update.changes.is_empty());
1204    }
1205}