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, ReadFilterValue, RelationDecl, RelationKind, ResourceActionInputKind,
6    ResourceAttributeInput, ResourceMacroInput, UpdateChange,
7};
8use syn::{spanned::Spanned, Ident, Type};
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            // # In-memory data layer codegen
288            //
289            // When no custom data layer is specified, generate the
290            // InMemoryReadAction filter and either:
291            //   - `InMemoryPerformRead` (for actions without `load`), or
292            //   - A direct `PerformRead` impl (for actions with `load`)
293            //     that does the base query then loads relations from the
294            //     in-memory store.
295            let data_layer_block = if data_layer_specified {
296                quote::quote! { }
297            } else if has_loads {
298                // # Relation-loading PerformRead codegen
299                //
300                // For actions with `load`, we generate a direct `PerformRead`
301                // impl on `InMemoryDataLayer` (skipping the blanket) that:
302                //   1. Reads all base rows and applies filters
303                //   2. Loads each related resource type from the store
304                //   3. Assembles the response wrapper for each base row
305                let filters = build_filters(read_action);
306
307                // Generate the relation loading code. For each loaded relation:
308                //
309                // - `belongs_to`: collect FK values from base rows, load all
310                //   destination resources, build a HashMap<PK, Resource>,
311                //   then look up each base row's FK.
312                //
313                // - `has_many`: collect PK values from base rows, load all
314                //   destination resources, group them by their FK field into
315                //   a HashMap<FK, Vec<Resource>>.
316                let relation_loads = loaded_relations.iter().map(|rel| {
317                    let rel_ty = &rel.ty;
318                    let source_attr = &rel.source_attribute;
319                    let map_name = Ident::new(
320                        &format!("{}_map", rel.name),
321                        rel.name.span(),
322                    );
323
324                    match rel.kind {
325                        RelationKind::BelongsTo => {
326                            // For belongs_to: the FK is on the base resource.
327                            // We load all destination resources and index them
328                            // by their primary key.
329                            quote::quote! {
330                                let all_related: Vec<#rel_ty> = dl.load_all::<#rel_ty>().await;
331                                let #map_name: ::std::collections::HashMap<String, #rel_ty> = all_related
332                                    .into_iter()
333                                    .map(|r| {
334                                        use cinderblock_core::Resource;
335                                        (r.primary_key().to_string(), r)
336                                    })
337                                    .collect();
338                            }
339                        }
340                        RelationKind::HasMany => {
341                            // For has_many: the FK is on the related resource,
342                            // pointing back to the base resource's PK. We load
343                            // all related resources and group them by the FK
344                            // field value.
345                            quote::quote! {
346                                let all_related: Vec<#rel_ty> = dl.load_all::<#rel_ty>().await;
347                                let mut #map_name: ::std::collections::HashMap<String, Vec<#rel_ty>> =
348                                    ::std::collections::HashMap::new();
349                                for r in all_related {
350                                    let key = r.#source_attr.to_string();
351                                    #map_name.entry(key).or_default().push(r);
352                                }
353                            }
354                        }
355                    }
356                });
357
358                // # Wrapper assembly for each base row
359                //
360                // For each filtered base row, look up the loaded relations
361                // and construct the response wrapper struct.
362                let wrapper_name =
363                    Ident::new(&format!("{action_name}Response"), action.name.span());
364
365                let relation_field_inits = loaded_relations.iter().map(|rel| {
366                    let rel_name = &rel.name;
367                    let source_attr = &rel.source_attribute;
368                    let map_name = Ident::new(
369                        &format!("{}_map", rel.name),
370                        rel.name.span(),
371                    );
372
373                    match rel.kind {
374                        RelationKind::BelongsTo => {
375                            let rel_ty = &rel.ty;
376                            let rel_name_str = rel.name.to_string();
377                            quote::quote! {
378                                #rel_name: #map_name
379                                    .get(&row.#source_attr.to_string())
380                                    .cloned()
381                                    .ok_or_else(|| -> Box<dyn ::std::error::Error + Send + Sync> {
382                                        format!(
383                                            "belongs_to relation `{}` of type `{}`: no record found for FK value `{}`",
384                                            #rel_name_str,
385                                            ::std::any::type_name::<#rel_ty>(),
386                                            row.#source_attr,
387                                        ).into()
388                                    })?
389                            }
390                        }
391                        RelationKind::HasMany => {
392                            quote::quote! {
393                                #rel_name: {
394                                    use cinderblock_core::Resource;
395                                    #map_name
396                                        .get(&row.primary_key().to_string())
397                                        .cloned()
398                                        .unwrap_or_default()
399                                }
400                            }
401                        }
402                    }
403                });
404
405                quote::quote! {
406                    impl cinderblock_core::PerformRead<#action_name> for cinderblock_core::data_layer::in_memory::InMemoryDataLayer {
407                        async fn read(&self, args: &<#action_name as cinderblock_core::ReadAction>::Arguments) -> cinderblock_core::Result<<#action_name as cinderblock_core::ReadAction>::Response> {
408                            let dl = self;
409
410                            // Step 1: Load and filter base rows
411                            let base_rows: Vec<#ident> = dl.load_all::<#ident>().await
412                                .into_iter()
413                                .filter(|row| { #(#filters)* true })
414                                .collect();
415
416                            // Step 2: Load related resources
417                            #(#relation_loads)*
418
419                            // Step 3: Assemble response wrappers
420                            let results: cinderblock_core::Result<Vec<#wrapper_name>> = base_rows
421                                .into_iter()
422                                .map(|row| -> cinderblock_core::Result<#wrapper_name> {
423                                    Ok(#wrapper_name {
424                                        #(#relation_field_inits,)*
425                                        base: row,
426                                    })
427                                })
428                                .collect();
429
430                            results
431                        }
432                    }
433                }
434            } else if is_paged {
435                // # Filter codegen for InMemoryPagedReadAction
436                let filters = build_filters(read_action);
437
438                quote::quote! {
439                    impl cinderblock_core::data_layer::in_memory::InMemoryPagedReadAction for #action_name {
440                        fn filter(row: &Self::Output, args: &Self::Arguments) -> bool {
441                            #(#filters)* true
442                        }
443                    }
444
445                    impl cinderblock_core::data_layer::in_memory::InMemoryPerformRead for #action_name {
446                        fn execute(
447                            all: impl Iterator<Item = Self::Output>,
448                            args: &Self::Arguments,
449                        ) -> Self::Response {
450                            use cinderblock_core::Paged;
451
452                            let filtered: Vec<Self::Output> = all
453                                .filter(|row| <Self as cinderblock_core::data_layer::in_memory::InMemoryPagedReadAction>::filter(row, args))
454                                .collect();
455
456                            let total = filtered.len() as u64;
457                            let page = args.page();
458                            let per_page = args.per_page();
459                            let total_pages = if total == 0 { 1 } else { ((total as u32).saturating_add(per_page - 1)) / per_page };
460
461                            let skip = ((page.saturating_sub(1)) as usize) * (per_page as usize);
462                            let data: Vec<Self::Output> = filtered
463                                .into_iter()
464                                .skip(skip)
465                                .take(per_page as usize)
466                                .collect();
467
468                            cinderblock_core::PaginatedResult {
469                                data,
470                                meta: cinderblock_core::PaginationMeta {
471                                    page,
472                                    per_page,
473                                    total,
474                                    total_pages,
475                                },
476                            }
477                        }
478                    }
479                }
480            } else {
481                // # Filter codegen for InMemoryReadAction
482                //
483                // Each filter becomes a boolean clause AND'd together. Literal
484                // values are emitted directly; `arg(name)` references access
485                // the corresponding field on the args struct.
486                //
487                // For `Option<T>` argument types, the filter clause is
488                // conditional: when `None`, the clause is skipped (evaluates
489                // to `true`). This lets optional arguments act as "filter if
490                // provided" semantics.
491                let filters = build_filters(read_action);
492
493                quote::quote! {
494                    impl cinderblock_core::data_layer::in_memory::InMemoryReadAction for #action_name {
495                        fn filter(row: &Self::Output, args: &Self::Arguments) -> bool {
496                            #(#filters)* true
497                        }
498                    }
499
500                    impl cinderblock_core::data_layer::in_memory::InMemoryPerformRead for #action_name {
501                        fn execute(
502                            all: impl Iterator<Item = Self::Output>,
503                            args: &Self::Arguments,
504                        ) -> Self::Response {
505                            all.filter(|row| <Self as cinderblock_core::data_layer::in_memory::InMemoryReadAction>::filter(row, args))
506                                .collect()
507                        }
508                    }
509                }
510            };
511
512            quote::quote! {
513                #arguments_struct
514
515                #paged_impl
516
517                #response_wrapper
518
519                struct #action_name;
520
521                impl cinderblock_core::ReadAction for #action_name {
522                    type Output = #ident;
523
524                    type Arguments = #arguments_type;
525
526                    type Response = #response_type;
527                }
528
529                #data_layer_block
530            }
531        }
532        ResourceActionInputKind::Create { accept } => {
533            let action_name = convert_case::ccase!(pascal, action.name.to_string());
534            let action_name = Ident::new(&action_name, action.name.span());
535            let input_name = Ident::new(&format!("{action_name}Input"), action.name.span());
536
537            let attributes = input.attributes.iter().filter(|attr| attr.writable.value());
538
539            let (present, mut missing_names) = match accept {
540                Accept::Default => (
541                    attributes
542                        .map(|attr| (attr.name.to_string(), attr))
543                        .collect::<HashMap<_, _>>(),
544                    HashMap::new(),
545                ),
546                Accept::Only(idents) => {
547                    let idents = idents
548                        .iter()
549                        .map(|ident| ident.to_string())
550                        .collect::<HashSet<_>>();
551
552                    attributes.fold(
553                        (HashMap::new(), HashMap::new()),
554                        |(mut present, mut missing), attr| {
555                            if idents.contains(&attr.name.to_string()) {
556                                present.insert(attr.name.to_string(), attr);
557                            } else {
558                                missing.insert(attr.name.to_string(), attr);
559                            }
560                            (present, missing)
561                        },
562                    )
563                }
564            };
565
566            input
567                .attributes
568                .iter()
569                .filter(|attr| {
570                    !attr.writable.value() || !present.contains_key(&attr.name.to_string())
571                })
572                .for_each(|attr| {
573                    missing_names.insert(attr.name.to_string(), attr);
574                });
575
576            let attributes = present.values().map(|attr| attr.to_field_definition());
577
578            let missing_names = missing_names.values().map(|attr| attr.to_default());
579
580            let present_names = present.values().map(|attr| attr.name.clone());
581
582            quote::quote! {
583                #[derive(::std::fmt::Debug)]
584                struct #action_name;
585
586                #[derive(::std::fmt::Debug, cinderblock_core::serde::Deserialize)]
587                struct #input_name {
588                    #(pub #attributes),*
589                }
590
591                impl cinderblock_core::Create<#action_name> for #ident {
592                    type Input = #input_name;
593
594                    fn from_create_input(input: Self::Input) -> Self {
595                        #ident {
596                            // Iterate over attributes in #action_name.
597                            #(#present_names: input.#present_names,)*
598
599                            // All types attributes not present in #action_name should use default
600                            #(#missing_names),*
601                        }
602                    }
603                }
604            }
605        }
606        ResourceActionInputKind::Update(update) => {
607            let action_name = convert_case::ccase!(pascal, action.name.to_string());
608            let action_name = Ident::new(&action_name, action.name.span());
609            let input_name = Ident::new(&format!("{action_name}Input"), action.name.span());
610
611            let attributes = input.attributes.iter().filter(|attr| attr.writable.value());
612
613            let present = match &update.accept {
614                Accept::Default => attributes.collect::<Vec<_>>(),
615                Accept::Only(idents) => {
616                    let idents = idents
617                        .iter()
618                        .map(|ident| ident.to_string())
619                        .collect::<HashSet<_>>();
620
621                    attributes
622                        .filter(|attr| idents.contains(&attr.name.to_string()))
623                        .collect()
624                }
625            };
626
627            let field_definitions = present.iter().map(|attr| attr.to_field_definition());
628
629            // # Field assignment generation
630            //
631            // Each accepted field from the input struct gets assigned onto `self`
632            // in the generated `apply_update_input` method.
633            let field_assignments = present.iter().map(|attr| {
634                let name = &attr.name;
635                quote::quote! { self.#name = input.#name; }
636            });
637
638            // # Change closure generation
639            //
640            // Each `change_ref` closure is emitted as a typed closure bound to a
641            // variable, then called with `self`. We inject `&mut Self` as the
642            // parameter type so field access resolves without the user needing
643            // to annotate the type in the DSL.
644            let change_ref_calls =
645                update
646                    .changes
647                    .iter()
648                    .enumerate()
649                    .filter_map(|(i, change)| match change {
650                        UpdateChange::ChangeRef(closure) => {
651                            let param = closure
652                                .inputs
653                                .first()
654                                .expect("change_ref closure must have exactly one parameter");
655                            let body = &closure.body;
656                            let binding = Ident::new(&format!("change_ref_{i}"), param.span());
657                            Some(quote::quote! {
658                                let #binding = |#param: &mut Self| #body;
659                                #binding(self);
660                            })
661                        }
662                        // TODO: support `change` (by-value) variant
663                        UpdateChange::Change(_) => None,
664                    });
665
666            quote::quote! {
667                #[derive(::std::fmt::Debug)]
668                struct #action_name;
669
670                #[derive(::std::fmt::Debug, cinderblock_core::serde::Deserialize)]
671                struct #input_name {
672                    #(pub #field_definitions),*
673                }
674
675                impl cinderblock_core::Update<#action_name> for #ident {
676                    type Input = #input_name;
677
678                    fn apply_update_input(&mut self, input: Self::Input) {
679                        #(#field_assignments)*
680                        #(#change_ref_calls)*
681                    }
682                }
683            }
684        }
685        ResourceActionInputKind::Destroy => {
686            // # Destroy action codegen
687            //
688            // Destroy actions only need a marker struct and a `Destroy<A>` impl.
689            // No input struct is generated — the primary key comes from the
690            // URL path at the HTTP layer.
691            let action_name = convert_case::ccase!(pascal, action.name.to_string());
692            let action_name = Ident::new(&action_name, action.name.span());
693
694            quote::quote! {
695                #[derive(::std::fmt::Debug)]
696                struct #action_name;
697
698                impl cinderblock_core::Destroy<#action_name> for #ident {}
699            }
700        }
701    });
702
703    let name_segments = input.name.iter().map(|segment| segment.to_string());
704
705    // # Data layer selection
706    //
707    // If the user specified `data_layer = some::Path;` in the DSL, use that
708    // path. Otherwise default to the built-in in-memory data layer.
709    let data_layer_path = input.data_layer.map_or_else(
710        || quote::quote! { cinderblock_core::data_layer::in_memory::InMemoryDataLayer },
711        |path| quote::quote! { #path },
712    );
713
714    // # Extension forwarding
715    //
716    // For each declared extension, we forward the raw DSL tokens (captured
717    // before parsing) inside a braced group, followed by a `config = { ... }`
718    // block containing the extension-specific configuration. This avoids a
719    // parse-then-reconstruct roundtrip — the extension macro receives the
720    // exact tokens the user wrote.
721    let extension_calls = input.extensions.iter().map(|ext| {
722        let path = &ext.path;
723        let config_tokens = &ext.config_tokens;
724
725        quote::quote! {
726            #path::__resource_extension! {
727                { #raw_tokens }
728
729                config = {
730                    #config_tokens
731                }
732            }
733        }
734    });
735
736    quote::quote! {
737        #[derive(::std::fmt::Debug, ::std::clone::Clone, cinderblock_core::serde::Serialize, cinderblock_core::serde::Deserialize)]
738        struct #ident {
739            #(#fields),*
740        }
741
742        impl cinderblock_core::Resource for #ident {
743            type PrimaryKey = #primary_key_type;
744
745            type DataLayer = #data_layer_path;
746
747            const NAME: &'static [&'static str] = &[#(#name_segments),*];
748
749            const PRIMARY_KEY_GENERATED: bool = #primary_key_generated;
750
751            fn primary_key(&self) -> &Self::PrimaryKey {
752                #primary_key_value
753            }
754        }
755
756        #(#actions)*
757
758        #(#extension_calls)*
759    }
760    .into()
761}
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766    use assert2::{assert, check};
767    use cinderblock_extension_api::ResourceActionInput;
768    use quote::quote;
769
770    fn parse_resource(tokens: proc_macro2::TokenStream) -> ResourceMacroInput {
771        let result = syn::parse2::<ResourceMacroInput>(tokens);
772        assert!(let Ok(input) = result);
773        input
774    }
775
776    #[test]
777    fn minimal_resource_with_one_simple_attribute() {
778        let input = parse_resource(quote! {
779            name = Foo;
780
781            attributes {
782                id String;
783            }
784        });
785
786        check!(input.name.len() == 1);
787        check!(input.name[0] == "Foo");
788
789        check!(input.attributes.len() == 1);
790        let attr = &input.attributes[0];
791        check!(attr.name == "id");
792        check!(!attr.primary_key.value());
793        check!(!attr.generated.value());
794        check!(attr.writable.value());
795        check!(attr.default.is_none());
796
797        check!(input.actions.is_empty());
798    }
799
800    #[test]
801    fn dotted_name_parses_into_multiple_segments() {
802        let input = parse_resource(quote! {
803            name = Helpdesk.Support.Ticket;
804
805            attributes {
806                id String;
807            }
808        });
809
810        check!(input.name.len() == 3);
811        check!(input.name[0] == "Helpdesk");
812        check!(input.name[1] == "Support");
813        check!(input.name[2] == "Ticket");
814    }
815
816    #[test]
817    fn attribute_with_options_block() {
818        let input = parse_resource(quote! {
819            name = Ticket;
820
821            attributes {
822                ticket_id Uuid {
823                    primary_key true;
824                    writable false;
825                    default || uuid::Uuid::new_v4();
826                }
827            }
828        });
829
830        check!(input.attributes.len() == 1);
831        let attr = &input.attributes[0];
832        check!(attr.name == "ticket_id");
833        check!(attr.primary_key.value());
834        check!(!attr.writable.value());
835        check!(attr.default.is_some());
836    }
837
838    #[test]
839    fn attribute_with_generated_flag() {
840        let input = parse_resource(quote! {
841            name = Item;
842
843            attributes {
844                item_id Uuid {
845                    primary_key true;
846                    generated true;
847                }
848            }
849        });
850
851        let attr = &input.attributes[0];
852        check!(attr.generated.value());
853        check!(attr.primary_key.value());
854        // writable should still be the default (true) since it wasn't set.
855        check!(attr.writable.value());
856    }
857
858    #[test]
859    fn multiple_attributes_mixed_simple_and_complex() {
860        let input = parse_resource(quote! {
861            name = Order;
862
863            attributes {
864                order_id Uuid {
865                    primary_key true;
866                    writable false;
867                }
868                item_name String;
869                quantity u32;
870            }
871        });
872
873        check!(input.attributes.len() == 3);
874
875        check!(input.attributes[0].name == "order_id");
876        check!(input.attributes[0].primary_key.value());
877        check!(!input.attributes[0].writable.value());
878
879        check!(input.attributes[1].name == "item_name");
880        check!(!input.attributes[1].primary_key.value());
881        check!(input.attributes[1].writable.value());
882
883        check!(input.attributes[2].name == "quantity");
884        check!(!input.attributes[2].primary_key.value());
885        check!(input.attributes[2].writable.value());
886    }
887
888    #[test]
889    fn actions_block_with_simple_create() {
890        let input = parse_resource(quote! {
891            name = Ticket;
892
893            attributes {
894                id String;
895            }
896
897            actions {
898                create open;
899            }
900        });
901
902        check!(input.actions.len() == 1);
903        check!(input.actions[0].name == "open");
904        check!(let ResourceActionInputKind::Create { accept: Accept::Default } = &input.actions[0].kind);
905    }
906
907    #[test]
908    fn action_with_accept_list() {
909        let input = parse_resource(quote! {
910            name = Ticket;
911
912            attributes {
913                id String;
914            }
915
916            actions {
917                create assign {
918                    accept [subject];
919                };
920            }
921        });
922
923        check!(input.actions.len() == 1);
924        check!(input.actions[0].name == "assign");
925        assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = &input.actions[0].kind);
926        check!(idents.len() == 1);
927        check!(idents[0] == "subject");
928    }
929
930    #[test]
931    fn no_actions_block_omitted() {
932        let input = parse_resource(quote! {
933            name = Simple;
934
935            attributes {
936                id u64;
937            }
938        });
939
940        check!(input.actions.is_empty());
941    }
942
943    #[test]
944    fn full_helpdesk_example() {
945        let input = parse_resource(quote! {
946            name = Helpdesk.Support.Ticket;
947
948            attributes {
949                ticket_id Uuid {
950                    primary_key true;
951                    writable false;
952                    default || uuid::Uuid::new_v4();
953                }
954
955                subject String;
956
957                status TicketStatus;
958            }
959
960            actions {
961                create open;
962
963                create assign {
964                    accept [subject];
965                };
966            }
967        });
968
969        check!(input.name.len() == 3);
970        check!(input.name[0] == "Helpdesk");
971        check!(input.name[1] == "Support");
972        check!(input.name[2] == "Ticket");
973
974        check!(input.attributes.len() == 3);
975
976        let ticket_id = &input.attributes[0];
977        check!(ticket_id.name == "ticket_id");
978        check!(ticket_id.primary_key.value());
979        check!(!ticket_id.writable.value());
980        check!(ticket_id.default.is_some());
981
982        let subject = &input.attributes[1];
983        check!(subject.name == "subject");
984        check!(!subject.primary_key.value());
985        check!(subject.writable.value());
986        check!(subject.default.is_none());
987
988        let status = &input.attributes[2];
989        check!(status.name == "status");
990        check!(!status.primary_key.value());
991        check!(status.writable.value());
992
993        check!(input.actions.len() == 2);
994
995        check!(input.actions[0].name == "open");
996        check!(let ResourceActionInputKind::Create { accept: Accept::Default } = &input.actions[0].kind);
997
998        check!(input.actions[1].name == "assign");
999        assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = &input.actions[1].kind);
1000        check!(idents.len() == 1);
1001        check!(idents[0] == "subject");
1002    }
1003
1004    #[test]
1005    fn parse_simple_create_action() {
1006        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1007            create open;
1008        }));
1009
1010        check!(action.name == "open");
1011        check!(let ResourceActionInputKind::Create { accept: Accept::Default } = action.kind);
1012    }
1013
1014    #[test]
1015    fn parse_create_action_with_multiple_accept_idents() {
1016        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1017            create bulk_insert {
1018                accept [name, email, age];
1019            }
1020        }));
1021
1022        check!(action.name == "bulk_insert");
1023        assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = action.kind);
1024        let names: Vec<String> = idents.iter().map(|i| i.to_string()).collect();
1025        check!(names == vec!["name", "email", "age"]);
1026    }
1027
1028    #[test]
1029    fn unknown_action_kind_produces_error() {
1030        let result = syn::parse2::<ResourceActionInput>(quote! {
1031            frobnicate foo;
1032        });
1033
1034        assert!(let Err(err) = result);
1035        let msg = err.to_string();
1036        check!(msg.contains("Unexpected action kind"));
1037        check!(msg.contains("frobnicate"));
1038    }
1039
1040    #[test]
1041    fn unknown_attribute_option_produces_error() {
1042        let result = syn::parse2::<ResourceMacroInput>(quote! {
1043            name = Thing;
1044
1045            attributes {
1046                id String {
1047                    bogus true;
1048                }
1049            }
1050        });
1051
1052        assert!(let Err(err) = result);
1053        let msg = err.to_string();
1054        check!(msg.contains("Unexpected attribute key"));
1055        check!(msg.contains("bogus"));
1056    }
1057
1058    #[test]
1059    fn missing_semicolon_after_name_produces_error() {
1060        let result = syn::parse2::<ResourceMacroInput>(quote! {
1061            name = Foo
1062
1063            attributes {
1064                id String;
1065            }
1066        });
1067
1068        check!(let Err(_) = result);
1069    }
1070
1071    #[test]
1072    fn parse_simple_update_action_with_default_accept() {
1073        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1074            update close;
1075        }));
1076
1077        check!(action.name == "close");
1078        assert!(let ResourceActionInputKind::Update(update) = &action.kind);
1079        check!(let Accept::Default = update.accept);
1080        check!(update.changes.is_empty());
1081    }
1082
1083    #[test]
1084    fn parse_update_action_with_accept_and_change_ref() {
1085        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1086            update close {
1087                accept [];
1088                change_ref |resource| {
1089                    resource.status = TicketStatus::Closed;
1090                };
1091            }
1092        }));
1093
1094        check!(action.name == "close");
1095        assert!(let ResourceActionInputKind::Update(update) = &action.kind);
1096        assert!(let Accept::Only(idents) = &update.accept);
1097        check!(idents.is_empty());
1098        check!(update.changes.len() == 1);
1099        check!(let UpdateChange::ChangeRef(_) = &update.changes[0]);
1100    }
1101
1102    #[test]
1103    fn parse_update_action_with_accept_fields() {
1104        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1105            update reassign {
1106                accept [subject, status];
1107            }
1108        }));
1109
1110        check!(action.name == "reassign");
1111        assert!(let ResourceActionInputKind::Update(update) = &action.kind);
1112        assert!(let Accept::Only(idents) = &update.accept);
1113        let names: Vec<String> = idents.iter().map(|i| i.to_string()).collect();
1114        check!(names == vec!["subject", "status"]);
1115        check!(update.changes.is_empty());
1116    }
1117}