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, ResourceActionInputKind, ResourceAttributeInput, ResourceMacroInput,
6    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 actions = input.actions.iter().map(|action| match &action.kind {
93        ResourceActionInputKind::Read(read_action) => {
94            let action_name = convert_case::ccase!(pascal, action.name.to_string());
95            let action_name = Ident::new(&action_name, action.name.span());
96
97            // # Arguments struct generation
98            //
99            // When the read action declares arguments, we generate a dedicated
100            // `{ActionName}Arguments` struct with `Deserialize` so it can be
101            // populated from query parameters. When no arguments are declared,
102            // we use `()` as the arguments type.
103            let has_arguments = !read_action.arguments.is_empty();
104
105            let (arguments_type, arguments_struct) = if has_arguments {
106                let args_name = Ident::new(&format!("{action_name}Arguments"), action.name.span());
107                let arg_fields = read_action.arguments.iter().map(|arg| {
108                    let name = &arg.name;
109                    let ty = &arg.ty;
110                    quote::quote! { pub #name: #ty }
111                });
112
113                (
114                    quote::quote! { #args_name },
115                    quote::quote! {
116                        #[derive(::std::fmt::Debug, cinderblock_core::serde::Deserialize)]
117                        struct #args_name {
118                            #(#arg_fields),*
119                        }
120                    },
121                )
122            } else {
123                (quote::quote! { () }, quote::quote! {})
124            };
125
126            // Setup InMemoryReadAction if no data layer is provided
127            let data_layer_block = if data_layer_specified {
128                quote::quote! { }
129            } else {
130                // # Filter codegen for InMemoryReadAction
131                //
132                // Each filter becomes a boolean clause AND'd together. Literal
133                // values are emitted directly; `arg(name)` references access
134                // the corresponding field on the args struct.
135                //
136                // For `Option<T>` argument types, the filter clause is
137                // conditional: when `None`, the clause is skipped (evaluates
138                // to `true`). This lets optional arguments act as "filter if
139                // provided" semantics.
140                let filters = read_action.filters.iter().map(|filter| {
141                    let field = &filter.field;
142
143                    let op = match filter.op {
144                        cinderblock_extension_api::ReadFilterOperation::Eq => quote::quote! { == },
145                    };
146
147                    match &filter.value {
148                        ReadFilterValue::Literal(expr) => {
149                            quote::quote! {
150                                row.#field #op #expr &&
151                            }
152                        }
153                        ReadFilterValue::Arg(arg_name) => {
154                            // Check if the argument type is Option<T> by
155                            // looking it up in the declared arguments.
156                            let arg_decl = read_action
157                                .arguments
158                                .iter()
159                                .find(|a| a.name == *arg_name)
160                                .expect("arg reference validated during parsing");
161
162                            if is_option_type(&arg_decl.ty) {
163                                // Optional arg: skip the filter when None
164                                quote::quote! {
165                                    args.#arg_name.as_ref().map_or(true, |v| row.#field #op *v) &&
166                                }
167                            } else {
168                                quote::quote! {
169                                    row.#field #op args.#arg_name &&
170                                }
171                            }
172                        }
173                    }
174                });
175
176                quote::quote! {
177                    impl cinderblock_core::data_layer::in_memory::InMemoryReadAction for #action_name {
178                        fn filter(row: &Self::Output, args: &Self::Arguments) -> bool {
179                            #(#filters)* true
180                        }
181                    }
182                }
183            };
184
185            quote::quote! {
186                #arguments_struct
187
188                struct #action_name;
189
190                impl cinderblock_core::ReadAction for #action_name {
191                    type Output = #ident;
192
193                    type Arguments = #arguments_type;
194                }
195
196                #data_layer_block
197            }
198        }
199        ResourceActionInputKind::Create { accept } => {
200            let action_name = convert_case::ccase!(pascal, action.name.to_string());
201            let action_name = Ident::new(&action_name, action.name.span());
202            let input_name = Ident::new(&format!("{action_name}Input"), action.name.span());
203
204            let attributes = input.attributes.iter().filter(|attr| attr.writable.value());
205
206            let (present, mut missing_names) = match accept {
207                Accept::Default => (
208                    attributes
209                        .map(|attr| (attr.name.to_string(), attr))
210                        .collect::<HashMap<_, _>>(),
211                    HashMap::new(),
212                ),
213                Accept::Only(idents) => {
214                    let idents = idents
215                        .iter()
216                        .map(|ident| ident.to_string())
217                        .collect::<HashSet<_>>();
218
219                    attributes.fold(
220                        (HashMap::new(), HashMap::new()),
221                        |(mut present, mut missing), attr| {
222                            if idents.contains(&attr.name.to_string()) {
223                                present.insert(attr.name.to_string(), attr);
224                            } else {
225                                missing.insert(attr.name.to_string(), attr);
226                            }
227                            (present, missing)
228                        },
229                    )
230                }
231            };
232
233            input
234                .attributes
235                .iter()
236                .filter(|attr| {
237                    !attr.writable.value() || !present.contains_key(&attr.name.to_string())
238                })
239                .for_each(|attr| {
240                    missing_names.insert(attr.name.to_string(), attr);
241                });
242
243            let attributes = present.values().map(|attr| attr.to_field_definition());
244
245            let missing_names = missing_names.values().map(|attr| attr.to_default());
246
247            let present_names = present.values().map(|attr| attr.name.clone());
248
249            quote::quote! {
250                #[derive(::std::fmt::Debug)]
251                struct #action_name;
252
253                #[derive(::std::fmt::Debug, cinderblock_core::serde::Deserialize)]
254                struct #input_name {
255                    #(pub #attributes),*
256                }
257
258                impl cinderblock_core::Create<#action_name> for #ident {
259                    type Input = #input_name;
260
261                    fn from_create_input(input: Self::Input) -> Self {
262                        #ident {
263                            // Iterate over attributes in #action_name.
264                            #(#present_names: input.#present_names,)*
265
266                            // All types attributes not present in #action_name should use default
267                            #(#missing_names),*
268                        }
269                    }
270                }
271            }
272        }
273        ResourceActionInputKind::Update(update) => {
274            let action_name = convert_case::ccase!(pascal, action.name.to_string());
275            let action_name = Ident::new(&action_name, action.name.span());
276            let input_name = Ident::new(&format!("{action_name}Input"), action.name.span());
277
278            let attributes = input.attributes.iter().filter(|attr| attr.writable.value());
279
280            let present = match &update.accept {
281                Accept::Default => attributes.collect::<Vec<_>>(),
282                Accept::Only(idents) => {
283                    let idents = idents
284                        .iter()
285                        .map(|ident| ident.to_string())
286                        .collect::<HashSet<_>>();
287
288                    attributes
289                        .filter(|attr| idents.contains(&attr.name.to_string()))
290                        .collect()
291                }
292            };
293
294            let field_definitions = present.iter().map(|attr| attr.to_field_definition());
295
296            // # Field assignment generation
297            //
298            // Each accepted field from the input struct gets assigned onto `self`
299            // in the generated `apply_update_input` method.
300            let field_assignments = present.iter().map(|attr| {
301                let name = &attr.name;
302                quote::quote! { self.#name = input.#name; }
303            });
304
305            // # Change closure generation
306            //
307            // Each `change_ref` closure is emitted as a typed closure bound to a
308            // variable, then called with `self`. We inject `&mut Self` as the
309            // parameter type so field access resolves without the user needing
310            // to annotate the type in the DSL.
311            let change_ref_calls =
312                update
313                    .changes
314                    .iter()
315                    .enumerate()
316                    .filter_map(|(i, change)| match change {
317                        UpdateChange::ChangeRef(closure) => {
318                            let param = closure
319                                .inputs
320                                .first()
321                                .expect("change_ref closure must have exactly one parameter");
322                            let body = &closure.body;
323                            let binding = Ident::new(&format!("change_ref_{i}"), param.span());
324                            Some(quote::quote! {
325                                let #binding = |#param: &mut Self| #body;
326                                #binding(self);
327                            })
328                        }
329                        // TODO: support `change` (by-value) variant
330                        UpdateChange::Change(_) => None,
331                    });
332
333            quote::quote! {
334                #[derive(::std::fmt::Debug)]
335                struct #action_name;
336
337                #[derive(::std::fmt::Debug, cinderblock_core::serde::Deserialize)]
338                struct #input_name {
339                    #(pub #field_definitions),*
340                }
341
342                impl cinderblock_core::Update<#action_name> for #ident {
343                    type Input = #input_name;
344
345                    fn apply_update_input(&mut self, input: Self::Input) {
346                        #(#field_assignments)*
347                        #(#change_ref_calls)*
348                    }
349                }
350            }
351        }
352        ResourceActionInputKind::Destroy => {
353            // # Destroy action codegen
354            //
355            // Destroy actions only need a marker struct and a `Destroy<A>` impl.
356            // No input struct is generated — the primary key comes from the
357            // URL path at the HTTP layer.
358            let action_name = convert_case::ccase!(pascal, action.name.to_string());
359            let action_name = Ident::new(&action_name, action.name.span());
360
361            quote::quote! {
362                #[derive(::std::fmt::Debug)]
363                struct #action_name;
364
365                impl cinderblock_core::Destroy<#action_name> for #ident {}
366            }
367        }
368    });
369
370    let name_segments = input.name.iter().map(|segment| segment.to_string());
371
372    // # Data layer selection
373    //
374    // If the user specified `data_layer = some::Path;` in the DSL, use that
375    // path. Otherwise default to the built-in in-memory data layer.
376    let data_layer_path = input.data_layer.map_or_else(
377        || quote::quote! { cinderblock_core::data_layer::in_memory::InMemoryDataLayer },
378        |path| quote::quote! { #path },
379    );
380
381    // # Extension forwarding
382    //
383    // For each declared extension, we forward the raw DSL tokens (captured
384    // before parsing) inside a braced group, followed by a `config = { ... }`
385    // block containing the extension-specific configuration. This avoids a
386    // parse-then-reconstruct roundtrip — the extension macro receives the
387    // exact tokens the user wrote.
388    let extension_calls = input.extensions.iter().map(|ext| {
389        let path = &ext.path;
390        let config_tokens = &ext.config_tokens;
391
392        quote::quote! {
393            #path::__resource_extension! {
394                { #raw_tokens }
395
396                config = {
397                    #config_tokens
398                }
399            }
400        }
401    });
402
403    quote::quote! {
404        #[derive(::std::fmt::Debug, ::std::clone::Clone, cinderblock_core::serde::Serialize, cinderblock_core::serde::Deserialize)]
405        struct #ident {
406            #(#fields),*
407        }
408
409        impl cinderblock_core::Resource for #ident {
410            type PrimaryKey = #primary_key_type;
411
412            type DataLayer = #data_layer_path;
413
414            const NAME: &'static [&'static str] = &[#(#name_segments),*];
415
416            const PRIMARY_KEY_GENERATED: bool = #primary_key_generated;
417
418            fn primary_key(&self) -> &Self::PrimaryKey {
419                #primary_key_value
420            }
421        }
422
423        #(#actions)*
424
425        #(#extension_calls)*
426    }
427    .into()
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use assert2::{assert, check};
434    use cinderblock_extension_api::ResourceActionInput;
435    use quote::quote;
436
437    fn parse_resource(tokens: proc_macro2::TokenStream) -> ResourceMacroInput {
438        let result = syn::parse2::<ResourceMacroInput>(tokens);
439        assert!(let Ok(input) = result);
440        input
441    }
442
443    #[test]
444    fn minimal_resource_with_one_simple_attribute() {
445        let input = parse_resource(quote! {
446            name = Foo;
447
448            attributes {
449                id String;
450            }
451        });
452
453        check!(input.name.len() == 1);
454        check!(input.name[0] == "Foo");
455
456        check!(input.attributes.len() == 1);
457        let attr = &input.attributes[0];
458        check!(attr.name == "id");
459        check!(!attr.primary_key.value());
460        check!(!attr.generated.value());
461        check!(attr.writable.value());
462        check!(attr.default.is_none());
463
464        check!(input.actions.is_empty());
465    }
466
467    #[test]
468    fn dotted_name_parses_into_multiple_segments() {
469        let input = parse_resource(quote! {
470            name = Helpdesk.Support.Ticket;
471
472            attributes {
473                id String;
474            }
475        });
476
477        check!(input.name.len() == 3);
478        check!(input.name[0] == "Helpdesk");
479        check!(input.name[1] == "Support");
480        check!(input.name[2] == "Ticket");
481    }
482
483    #[test]
484    fn attribute_with_options_block() {
485        let input = parse_resource(quote! {
486            name = Ticket;
487
488            attributes {
489                ticket_id Uuid {
490                    primary_key true;
491                    writable false;
492                    default || uuid::Uuid::new_v4();
493                }
494            }
495        });
496
497        check!(input.attributes.len() == 1);
498        let attr = &input.attributes[0];
499        check!(attr.name == "ticket_id");
500        check!(attr.primary_key.value());
501        check!(!attr.writable.value());
502        check!(attr.default.is_some());
503    }
504
505    #[test]
506    fn attribute_with_generated_flag() {
507        let input = parse_resource(quote! {
508            name = Item;
509
510            attributes {
511                item_id Uuid {
512                    primary_key true;
513                    generated true;
514                }
515            }
516        });
517
518        let attr = &input.attributes[0];
519        check!(attr.generated.value());
520        check!(attr.primary_key.value());
521        // writable should still be the default (true) since it wasn't set.
522        check!(attr.writable.value());
523    }
524
525    #[test]
526    fn multiple_attributes_mixed_simple_and_complex() {
527        let input = parse_resource(quote! {
528            name = Order;
529
530            attributes {
531                order_id Uuid {
532                    primary_key true;
533                    writable false;
534                }
535                item_name String;
536                quantity u32;
537            }
538        });
539
540        check!(input.attributes.len() == 3);
541
542        check!(input.attributes[0].name == "order_id");
543        check!(input.attributes[0].primary_key.value());
544        check!(!input.attributes[0].writable.value());
545
546        check!(input.attributes[1].name == "item_name");
547        check!(!input.attributes[1].primary_key.value());
548        check!(input.attributes[1].writable.value());
549
550        check!(input.attributes[2].name == "quantity");
551        check!(!input.attributes[2].primary_key.value());
552        check!(input.attributes[2].writable.value());
553    }
554
555    #[test]
556    fn actions_block_with_simple_create() {
557        let input = parse_resource(quote! {
558            name = Ticket;
559
560            attributes {
561                id String;
562            }
563
564            actions {
565                create open;
566            }
567        });
568
569        check!(input.actions.len() == 1);
570        check!(input.actions[0].name == "open");
571        check!(let ResourceActionInputKind::Create { accept: Accept::Default } = &input.actions[0].kind);
572    }
573
574    #[test]
575    fn action_with_accept_list() {
576        let input = parse_resource(quote! {
577            name = Ticket;
578
579            attributes {
580                id String;
581            }
582
583            actions {
584                create assign {
585                    accept [subject];
586                };
587            }
588        });
589
590        check!(input.actions.len() == 1);
591        check!(input.actions[0].name == "assign");
592        assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = &input.actions[0].kind);
593        check!(idents.len() == 1);
594        check!(idents[0] == "subject");
595    }
596
597    #[test]
598    fn no_actions_block_omitted() {
599        let input = parse_resource(quote! {
600            name = Simple;
601
602            attributes {
603                id u64;
604            }
605        });
606
607        check!(input.actions.is_empty());
608    }
609
610    #[test]
611    fn full_helpdesk_example() {
612        let input = parse_resource(quote! {
613            name = Helpdesk.Support.Ticket;
614
615            attributes {
616                ticket_id Uuid {
617                    primary_key true;
618                    writable false;
619                    default || uuid::Uuid::new_v4();
620                }
621
622                subject String;
623
624                status TicketStatus;
625            }
626
627            actions {
628                create open;
629
630                create assign {
631                    accept [subject];
632                };
633            }
634        });
635
636        check!(input.name.len() == 3);
637        check!(input.name[0] == "Helpdesk");
638        check!(input.name[1] == "Support");
639        check!(input.name[2] == "Ticket");
640
641        check!(input.attributes.len() == 3);
642
643        let ticket_id = &input.attributes[0];
644        check!(ticket_id.name == "ticket_id");
645        check!(ticket_id.primary_key.value());
646        check!(!ticket_id.writable.value());
647        check!(ticket_id.default.is_some());
648
649        let subject = &input.attributes[1];
650        check!(subject.name == "subject");
651        check!(!subject.primary_key.value());
652        check!(subject.writable.value());
653        check!(subject.default.is_none());
654
655        let status = &input.attributes[2];
656        check!(status.name == "status");
657        check!(!status.primary_key.value());
658        check!(status.writable.value());
659
660        check!(input.actions.len() == 2);
661
662        check!(input.actions[0].name == "open");
663        check!(let ResourceActionInputKind::Create { accept: Accept::Default } = &input.actions[0].kind);
664
665        check!(input.actions[1].name == "assign");
666        assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = &input.actions[1].kind);
667        check!(idents.len() == 1);
668        check!(idents[0] == "subject");
669    }
670
671    #[test]
672    fn parse_simple_create_action() {
673        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
674            create open;
675        }));
676
677        check!(action.name == "open");
678        check!(let ResourceActionInputKind::Create { accept: Accept::Default } = action.kind);
679    }
680
681    #[test]
682    fn parse_create_action_with_multiple_accept_idents() {
683        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
684            create bulk_insert {
685                accept [name, email, age];
686            }
687        }));
688
689        check!(action.name == "bulk_insert");
690        assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = action.kind);
691        let names: Vec<String> = idents.iter().map(|i| i.to_string()).collect();
692        check!(names == vec!["name", "email", "age"]);
693    }
694
695    #[test]
696    fn unknown_action_kind_produces_error() {
697        let result = syn::parse2::<ResourceActionInput>(quote! {
698            frobnicate foo;
699        });
700
701        assert!(let Err(err) = result);
702        let msg = err.to_string();
703        check!(msg.contains("Unexpected action kind"));
704        check!(msg.contains("frobnicate"));
705    }
706
707    #[test]
708    fn unknown_attribute_option_produces_error() {
709        let result = syn::parse2::<ResourceMacroInput>(quote! {
710            name = Thing;
711
712            attributes {
713                id String {
714                    bogus true;
715                }
716            }
717        });
718
719        assert!(let Err(err) = result);
720        let msg = err.to_string();
721        check!(msg.contains("Unexpected attribute key"));
722        check!(msg.contains("bogus"));
723    }
724
725    #[test]
726    fn missing_semicolon_after_name_produces_error() {
727        let result = syn::parse2::<ResourceMacroInput>(quote! {
728            name = Foo
729
730            attributes {
731                id String;
732            }
733        });
734
735        check!(let Err(_) = result);
736    }
737
738    #[test]
739    fn parse_simple_update_action_with_default_accept() {
740        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
741            update close;
742        }));
743
744        check!(action.name == "close");
745        assert!(let ResourceActionInputKind::Update(update) = &action.kind);
746        check!(let Accept::Default = update.accept);
747        check!(update.changes.is_empty());
748    }
749
750    #[test]
751    fn parse_update_action_with_accept_and_change_ref() {
752        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
753            update close {
754                accept [];
755                change_ref |resource| {
756                    resource.status = TicketStatus::Closed;
757                };
758            }
759        }));
760
761        check!(action.name == "close");
762        assert!(let ResourceActionInputKind::Update(update) = &action.kind);
763        assert!(let Accept::Only(idents) = &update.accept);
764        check!(idents.is_empty());
765        check!(update.changes.len() == 1);
766        check!(let UpdateChange::ChangeRef(_) = &update.changes[0]);
767    }
768
769    #[test]
770    fn parse_update_action_with_accept_fields() {
771        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
772            update reassign {
773                accept [subject, status];
774            }
775        }));
776
777        check!(action.name == "reassign");
778        assert!(let ResourceActionInputKind::Update(update) = &action.kind);
779        assert!(let Accept::Only(idents) = &update.accept);
780        let names: Vec<String> = idents.iter().map(|i| i.to_string()).collect();
781        check!(names == vec!["subject", "status"]);
782        check!(update.changes.is_empty());
783    }
784}