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