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