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
10fn 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 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 let is_paged = read_action.paged.is_some();
98
99 let has_user_arguments = !read_action.arguments.is_empty();
108 let needs_arguments_struct = has_user_arguments || is_paged;
109
110 let (arguments_type, arguments_struct) = if needs_arguments_struct {
111 let args_name = Ident::new(&format!("{action_name}Arguments"), action.name.span());
112 let user_arg_fields = read_action.arguments.iter().map(|arg| {
113 let name = &arg.name;
114 let ty = &arg.ty;
115 quote::quote! { pub #name: #ty }
116 });
117
118 let paged_fields = if is_paged {
120 quote::quote! {
121 pub page: Option<u32>,
122 pub per_page: Option<u32>,
123 }
124 } else {
125 quote::quote! {}
126 };
127
128 (
129 quote::quote! { #args_name },
130 quote::quote! {
131 #[derive(::std::fmt::Debug, cinderblock_core::serde::Deserialize)]
132 struct #args_name {
133 #(#user_arg_fields,)*
134 #paged_fields
135 }
136 },
137 )
138 } else {
139 (quote::quote! { () }, quote::quote! {})
140 };
141
142 let paged_impl = if let Some(paged_config) = &read_action.paged {
148 let args_name = Ident::new(&format!("{action_name}Arguments"), action.name.span());
149
150 let default_per_page = match paged_config.default_per_page {
151 Some(n) => quote::quote! { #n },
152 None => quote::quote! { cinderblock_core::DEFAULT_PER_PAGE },
153 };
154
155 let per_page_body = if let Some(max) = paged_config.max_per_page {
158 quote::quote! {
159 self.per_page.unwrap_or(#default_per_page).min(#max)
160 }
161 } else {
162 quote::quote! {
163 self.per_page.unwrap_or(#default_per_page)
164 }
165 };
166
167 quote::quote! {
168 impl cinderblock_core::Paged for #args_name {
169 fn page(&self) -> u32 {
170 self.page.unwrap_or(1)
171 }
172
173 fn per_page(&self) -> u32 {
174 #per_page_body
175 }
176 }
177 }
178 } else {
179 quote::quote! {}
180 };
181
182 let response_type = if is_paged {
187 quote::quote! { cinderblock_core::PaginatedResult<#ident> }
188 } else {
189 quote::quote! { Vec<#ident> }
190 };
191
192 let data_layer_block = if data_layer_specified {
207 quote::quote! { }
208 } else if is_paged {
209 let filters = read_action.filters.iter().map(|filter| {
211 let field = &filter.field;
212 let op = match filter.op {
213 cinderblock_extension_api::ReadFilterOperation::Eq => quote::quote! { == },
214 };
215 match &filter.value {
216 ReadFilterValue::Literal(expr) => {
217 quote::quote! {
218 row.#field #op #expr &&
219 }
220 }
221 ReadFilterValue::Arg(arg_name) => {
222 let arg_decl = read_action
223 .arguments
224 .iter()
225 .find(|a| a.name == *arg_name)
226 .expect("arg reference validated during parsing");
227 if is_option_type(&arg_decl.ty) {
228 quote::quote! {
229 args.#arg_name.as_ref().map_or(true, |v| row.#field #op *v) &&
230 }
231 } else {
232 quote::quote! {
233 row.#field #op args.#arg_name &&
234 }
235 }
236 }
237 }
238 });
239
240 quote::quote! {
241 impl cinderblock_core::data_layer::in_memory::InMemoryPagedReadAction for #action_name {
242 fn filter(row: &Self::Output, args: &Self::Arguments) -> bool {
243 #(#filters)* true
244 }
245 }
246
247 impl cinderblock_core::data_layer::in_memory::InMemoryPerformRead for #action_name {
248 fn execute(
249 all: impl Iterator<Item = Self::Output>,
250 args: &Self::Arguments,
251 ) -> Self::Response {
252 use cinderblock_core::Paged;
253
254 let filtered: Vec<Self::Output> = all
255 .filter(|row| <Self as cinderblock_core::data_layer::in_memory::InMemoryPagedReadAction>::filter(row, args))
256 .collect();
257
258 let total = filtered.len() as u64;
259 let page = args.page();
260 let per_page = args.per_page();
261 let total_pages = if total == 0 { 1 } else { ((total as u32).saturating_add(per_page - 1)) / per_page };
262
263 let skip = ((page.saturating_sub(1)) as usize) * (per_page as usize);
264 let data: Vec<Self::Output> = filtered
265 .into_iter()
266 .skip(skip)
267 .take(per_page as usize)
268 .collect();
269
270 cinderblock_core::PaginatedResult {
271 data,
272 meta: cinderblock_core::PaginationMeta {
273 page,
274 per_page,
275 total,
276 total_pages,
277 },
278 }
279 }
280 }
281 }
282 } else {
283 let filters = read_action.filters.iter().map(|filter| {
294 let field = &filter.field;
295
296 let op = match filter.op {
297 cinderblock_extension_api::ReadFilterOperation::Eq => quote::quote! { == },
298 };
299
300 match &filter.value {
301 ReadFilterValue::Literal(expr) => {
302 quote::quote! {
303 row.#field #op #expr &&
304 }
305 }
306 ReadFilterValue::Arg(arg_name) => {
307 let arg_decl = read_action
310 .arguments
311 .iter()
312 .find(|a| a.name == *arg_name)
313 .expect("arg reference validated during parsing");
314
315 if is_option_type(&arg_decl.ty) {
316 quote::quote! {
318 args.#arg_name.as_ref().map_or(true, |v| row.#field #op *v) &&
319 }
320 } else {
321 quote::quote! {
322 row.#field #op args.#arg_name &&
323 }
324 }
325 }
326 }
327 });
328
329 quote::quote! {
330 impl cinderblock_core::data_layer::in_memory::InMemoryReadAction for #action_name {
331 fn filter(row: &Self::Output, args: &Self::Arguments) -> bool {
332 #(#filters)* true
333 }
334 }
335
336 impl cinderblock_core::data_layer::in_memory::InMemoryPerformRead for #action_name {
337 fn execute(
338 all: impl Iterator<Item = Self::Output>,
339 args: &Self::Arguments,
340 ) -> Self::Response {
341 all.filter(|row| <Self as cinderblock_core::data_layer::in_memory::InMemoryReadAction>::filter(row, args))
342 .collect()
343 }
344 }
345 }
346 };
347
348 quote::quote! {
349 #arguments_struct
350
351 #paged_impl
352
353 struct #action_name;
354
355 impl cinderblock_core::ReadAction for #action_name {
356 type Output = #ident;
357
358 type Arguments = #arguments_type;
359
360 type Response = #response_type;
361 }
362
363 #data_layer_block
364 }
365 }
366 ResourceActionInputKind::Create { accept } => {
367 let action_name = convert_case::ccase!(pascal, action.name.to_string());
368 let action_name = Ident::new(&action_name, action.name.span());
369 let input_name = Ident::new(&format!("{action_name}Input"), action.name.span());
370
371 let attributes = input.attributes.iter().filter(|attr| attr.writable.value());
372
373 let (present, mut missing_names) = match accept {
374 Accept::Default => (
375 attributes
376 .map(|attr| (attr.name.to_string(), attr))
377 .collect::<HashMap<_, _>>(),
378 HashMap::new(),
379 ),
380 Accept::Only(idents) => {
381 let idents = idents
382 .iter()
383 .map(|ident| ident.to_string())
384 .collect::<HashSet<_>>();
385
386 attributes.fold(
387 (HashMap::new(), HashMap::new()),
388 |(mut present, mut missing), attr| {
389 if idents.contains(&attr.name.to_string()) {
390 present.insert(attr.name.to_string(), attr);
391 } else {
392 missing.insert(attr.name.to_string(), attr);
393 }
394 (present, missing)
395 },
396 )
397 }
398 };
399
400 input
401 .attributes
402 .iter()
403 .filter(|attr| {
404 !attr.writable.value() || !present.contains_key(&attr.name.to_string())
405 })
406 .for_each(|attr| {
407 missing_names.insert(attr.name.to_string(), attr);
408 });
409
410 let attributes = present.values().map(|attr| attr.to_field_definition());
411
412 let missing_names = missing_names.values().map(|attr| attr.to_default());
413
414 let present_names = present.values().map(|attr| attr.name.clone());
415
416 quote::quote! {
417 #[derive(::std::fmt::Debug)]
418 struct #action_name;
419
420 #[derive(::std::fmt::Debug, cinderblock_core::serde::Deserialize)]
421 struct #input_name {
422 #(pub #attributes),*
423 }
424
425 impl cinderblock_core::Create<#action_name> for #ident {
426 type Input = #input_name;
427
428 fn from_create_input(input: Self::Input) -> Self {
429 #ident {
430 #(#present_names: input.#present_names,)*
432
433 #(#missing_names),*
435 }
436 }
437 }
438 }
439 }
440 ResourceActionInputKind::Update(update) => {
441 let action_name = convert_case::ccase!(pascal, action.name.to_string());
442 let action_name = Ident::new(&action_name, action.name.span());
443 let input_name = Ident::new(&format!("{action_name}Input"), action.name.span());
444
445 let attributes = input.attributes.iter().filter(|attr| attr.writable.value());
446
447 let present = match &update.accept {
448 Accept::Default => attributes.collect::<Vec<_>>(),
449 Accept::Only(idents) => {
450 let idents = idents
451 .iter()
452 .map(|ident| ident.to_string())
453 .collect::<HashSet<_>>();
454
455 attributes
456 .filter(|attr| idents.contains(&attr.name.to_string()))
457 .collect()
458 }
459 };
460
461 let field_definitions = present.iter().map(|attr| attr.to_field_definition());
462
463 let field_assignments = present.iter().map(|attr| {
468 let name = &attr.name;
469 quote::quote! { self.#name = input.#name; }
470 });
471
472 let change_ref_calls =
479 update
480 .changes
481 .iter()
482 .enumerate()
483 .filter_map(|(i, change)| match change {
484 UpdateChange::ChangeRef(closure) => {
485 let param = closure
486 .inputs
487 .first()
488 .expect("change_ref closure must have exactly one parameter");
489 let body = &closure.body;
490 let binding = Ident::new(&format!("change_ref_{i}"), param.span());
491 Some(quote::quote! {
492 let #binding = |#param: &mut Self| #body;
493 #binding(self);
494 })
495 }
496 UpdateChange::Change(_) => None,
498 });
499
500 quote::quote! {
501 #[derive(::std::fmt::Debug)]
502 struct #action_name;
503
504 #[derive(::std::fmt::Debug, cinderblock_core::serde::Deserialize)]
505 struct #input_name {
506 #(pub #field_definitions),*
507 }
508
509 impl cinderblock_core::Update<#action_name> for #ident {
510 type Input = #input_name;
511
512 fn apply_update_input(&mut self, input: Self::Input) {
513 #(#field_assignments)*
514 #(#change_ref_calls)*
515 }
516 }
517 }
518 }
519 ResourceActionInputKind::Destroy => {
520 let action_name = convert_case::ccase!(pascal, action.name.to_string());
526 let action_name = Ident::new(&action_name, action.name.span());
527
528 quote::quote! {
529 #[derive(::std::fmt::Debug)]
530 struct #action_name;
531
532 impl cinderblock_core::Destroy<#action_name> for #ident {}
533 }
534 }
535 });
536
537 let name_segments = input.name.iter().map(|segment| segment.to_string());
538
539 let data_layer_path = input.data_layer.map_or_else(
544 || quote::quote! { cinderblock_core::data_layer::in_memory::InMemoryDataLayer },
545 |path| quote::quote! { #path },
546 );
547
548 let extension_calls = input.extensions.iter().map(|ext| {
556 let path = &ext.path;
557 let config_tokens = &ext.config_tokens;
558
559 quote::quote! {
560 #path::__resource_extension! {
561 { #raw_tokens }
562
563 config = {
564 #config_tokens
565 }
566 }
567 }
568 });
569
570 quote::quote! {
571 #[derive(::std::fmt::Debug, ::std::clone::Clone, cinderblock_core::serde::Serialize, cinderblock_core::serde::Deserialize)]
572 struct #ident {
573 #(#fields),*
574 }
575
576 impl cinderblock_core::Resource for #ident {
577 type PrimaryKey = #primary_key_type;
578
579 type DataLayer = #data_layer_path;
580
581 const NAME: &'static [&'static str] = &[#(#name_segments),*];
582
583 const PRIMARY_KEY_GENERATED: bool = #primary_key_generated;
584
585 fn primary_key(&self) -> &Self::PrimaryKey {
586 #primary_key_value
587 }
588 }
589
590 #(#actions)*
591
592 #(#extension_calls)*
593 }
594 .into()
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600 use assert2::{assert, check};
601 use cinderblock_extension_api::ResourceActionInput;
602 use quote::quote;
603
604 fn parse_resource(tokens: proc_macro2::TokenStream) -> ResourceMacroInput {
605 let result = syn::parse2::<ResourceMacroInput>(tokens);
606 assert!(let Ok(input) = result);
607 input
608 }
609
610 #[test]
611 fn minimal_resource_with_one_simple_attribute() {
612 let input = parse_resource(quote! {
613 name = Foo;
614
615 attributes {
616 id String;
617 }
618 });
619
620 check!(input.name.len() == 1);
621 check!(input.name[0] == "Foo");
622
623 check!(input.attributes.len() == 1);
624 let attr = &input.attributes[0];
625 check!(attr.name == "id");
626 check!(!attr.primary_key.value());
627 check!(!attr.generated.value());
628 check!(attr.writable.value());
629 check!(attr.default.is_none());
630
631 check!(input.actions.is_empty());
632 }
633
634 #[test]
635 fn dotted_name_parses_into_multiple_segments() {
636 let input = parse_resource(quote! {
637 name = Helpdesk.Support.Ticket;
638
639 attributes {
640 id String;
641 }
642 });
643
644 check!(input.name.len() == 3);
645 check!(input.name[0] == "Helpdesk");
646 check!(input.name[1] == "Support");
647 check!(input.name[2] == "Ticket");
648 }
649
650 #[test]
651 fn attribute_with_options_block() {
652 let input = parse_resource(quote! {
653 name = Ticket;
654
655 attributes {
656 ticket_id Uuid {
657 primary_key true;
658 writable false;
659 default || uuid::Uuid::new_v4();
660 }
661 }
662 });
663
664 check!(input.attributes.len() == 1);
665 let attr = &input.attributes[0];
666 check!(attr.name == "ticket_id");
667 check!(attr.primary_key.value());
668 check!(!attr.writable.value());
669 check!(attr.default.is_some());
670 }
671
672 #[test]
673 fn attribute_with_generated_flag() {
674 let input = parse_resource(quote! {
675 name = Item;
676
677 attributes {
678 item_id Uuid {
679 primary_key true;
680 generated true;
681 }
682 }
683 });
684
685 let attr = &input.attributes[0];
686 check!(attr.generated.value());
687 check!(attr.primary_key.value());
688 check!(attr.writable.value());
690 }
691
692 #[test]
693 fn multiple_attributes_mixed_simple_and_complex() {
694 let input = parse_resource(quote! {
695 name = Order;
696
697 attributes {
698 order_id Uuid {
699 primary_key true;
700 writable false;
701 }
702 item_name String;
703 quantity u32;
704 }
705 });
706
707 check!(input.attributes.len() == 3);
708
709 check!(input.attributes[0].name == "order_id");
710 check!(input.attributes[0].primary_key.value());
711 check!(!input.attributes[0].writable.value());
712
713 check!(input.attributes[1].name == "item_name");
714 check!(!input.attributes[1].primary_key.value());
715 check!(input.attributes[1].writable.value());
716
717 check!(input.attributes[2].name == "quantity");
718 check!(!input.attributes[2].primary_key.value());
719 check!(input.attributes[2].writable.value());
720 }
721
722 #[test]
723 fn actions_block_with_simple_create() {
724 let input = parse_resource(quote! {
725 name = Ticket;
726
727 attributes {
728 id String;
729 }
730
731 actions {
732 create open;
733 }
734 });
735
736 check!(input.actions.len() == 1);
737 check!(input.actions[0].name == "open");
738 check!(let ResourceActionInputKind::Create { accept: Accept::Default } = &input.actions[0].kind);
739 }
740
741 #[test]
742 fn action_with_accept_list() {
743 let input = parse_resource(quote! {
744 name = Ticket;
745
746 attributes {
747 id String;
748 }
749
750 actions {
751 create assign {
752 accept [subject];
753 };
754 }
755 });
756
757 check!(input.actions.len() == 1);
758 check!(input.actions[0].name == "assign");
759 assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = &input.actions[0].kind);
760 check!(idents.len() == 1);
761 check!(idents[0] == "subject");
762 }
763
764 #[test]
765 fn no_actions_block_omitted() {
766 let input = parse_resource(quote! {
767 name = Simple;
768
769 attributes {
770 id u64;
771 }
772 });
773
774 check!(input.actions.is_empty());
775 }
776
777 #[test]
778 fn full_helpdesk_example() {
779 let input = parse_resource(quote! {
780 name = Helpdesk.Support.Ticket;
781
782 attributes {
783 ticket_id Uuid {
784 primary_key true;
785 writable false;
786 default || uuid::Uuid::new_v4();
787 }
788
789 subject String;
790
791 status TicketStatus;
792 }
793
794 actions {
795 create open;
796
797 create assign {
798 accept [subject];
799 };
800 }
801 });
802
803 check!(input.name.len() == 3);
804 check!(input.name[0] == "Helpdesk");
805 check!(input.name[1] == "Support");
806 check!(input.name[2] == "Ticket");
807
808 check!(input.attributes.len() == 3);
809
810 let ticket_id = &input.attributes[0];
811 check!(ticket_id.name == "ticket_id");
812 check!(ticket_id.primary_key.value());
813 check!(!ticket_id.writable.value());
814 check!(ticket_id.default.is_some());
815
816 let subject = &input.attributes[1];
817 check!(subject.name == "subject");
818 check!(!subject.primary_key.value());
819 check!(subject.writable.value());
820 check!(subject.default.is_none());
821
822 let status = &input.attributes[2];
823 check!(status.name == "status");
824 check!(!status.primary_key.value());
825 check!(status.writable.value());
826
827 check!(input.actions.len() == 2);
828
829 check!(input.actions[0].name == "open");
830 check!(let ResourceActionInputKind::Create { accept: Accept::Default } = &input.actions[0].kind);
831
832 check!(input.actions[1].name == "assign");
833 assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = &input.actions[1].kind);
834 check!(idents.len() == 1);
835 check!(idents[0] == "subject");
836 }
837
838 #[test]
839 fn parse_simple_create_action() {
840 assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
841 create open;
842 }));
843
844 check!(action.name == "open");
845 check!(let ResourceActionInputKind::Create { accept: Accept::Default } = action.kind);
846 }
847
848 #[test]
849 fn parse_create_action_with_multiple_accept_idents() {
850 assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
851 create bulk_insert {
852 accept [name, email, age];
853 }
854 }));
855
856 check!(action.name == "bulk_insert");
857 assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = action.kind);
858 let names: Vec<String> = idents.iter().map(|i| i.to_string()).collect();
859 check!(names == vec!["name", "email", "age"]);
860 }
861
862 #[test]
863 fn unknown_action_kind_produces_error() {
864 let result = syn::parse2::<ResourceActionInput>(quote! {
865 frobnicate foo;
866 });
867
868 assert!(let Err(err) = result);
869 let msg = err.to_string();
870 check!(msg.contains("Unexpected action kind"));
871 check!(msg.contains("frobnicate"));
872 }
873
874 #[test]
875 fn unknown_attribute_option_produces_error() {
876 let result = syn::parse2::<ResourceMacroInput>(quote! {
877 name = Thing;
878
879 attributes {
880 id String {
881 bogus true;
882 }
883 }
884 });
885
886 assert!(let Err(err) = result);
887 let msg = err.to_string();
888 check!(msg.contains("Unexpected attribute key"));
889 check!(msg.contains("bogus"));
890 }
891
892 #[test]
893 fn missing_semicolon_after_name_produces_error() {
894 let result = syn::parse2::<ResourceMacroInput>(quote! {
895 name = Foo
896
897 attributes {
898 id String;
899 }
900 });
901
902 check!(let Err(_) = result);
903 }
904
905 #[test]
906 fn parse_simple_update_action_with_default_accept() {
907 assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
908 update close;
909 }));
910
911 check!(action.name == "close");
912 assert!(let ResourceActionInputKind::Update(update) = &action.kind);
913 check!(let Accept::Default = update.accept);
914 check!(update.changes.is_empty());
915 }
916
917 #[test]
918 fn parse_update_action_with_accept_and_change_ref() {
919 assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
920 update close {
921 accept [];
922 change_ref |resource| {
923 resource.status = TicketStatus::Closed;
924 };
925 }
926 }));
927
928 check!(action.name == "close");
929 assert!(let ResourceActionInputKind::Update(update) = &action.kind);
930 assert!(let Accept::Only(idents) = &update.accept);
931 check!(idents.is_empty());
932 check!(update.changes.len() == 1);
933 check!(let UpdateChange::ChangeRef(_) = &update.changes[0]);
934 }
935
936 #[test]
937 fn parse_update_action_with_accept_fields() {
938 assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
939 update reassign {
940 accept [subject, status];
941 }
942 }));
943
944 check!(action.name == "reassign");
945 assert!(let ResourceActionInputKind::Update(update) = &action.kind);
946 assert!(let Accept::Only(idents) = &update.accept);
947 let names: Vec<String> = idents.iter().map(|i| i.to_string()).collect();
948 check!(names == vec!["subject", "status"]);
949 check!(update.changes.is_empty());
950 }
951}