1use core::iter::Iterator;
2use std::collections::{HashMap, HashSet};
3
4use cinderblock_extension_api::{
5 Accept, OrderDirection, ReadFilterValue, RelationDecl, RelationKind, ResourceActionInputKind,
6 ResourceAttributeInput, ResourceMacroInput, UpdateChange,
7};
8use syn::{Ident, Type, spanned::Spanned};
9
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 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 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 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 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 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 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 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 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 let build_order_sort = |read_action: &cinderblock_extension_api::ActionRead| -> Option<proc_macro2::TokenStream> {
296 if read_action.orders.is_empty() {
297 return None;
298 }
299
300 let mut clauses = read_action.orders.iter();
301 let first = clauses.next().unwrap();
302 let first_field = &first.field;
303 let first_cmp = match first.direction {
304 OrderDirection::Asc => quote::quote! { a.#first_field.cmp(&b.#first_field) },
305 OrderDirection::Desc => quote::quote! { b.#first_field.cmp(&a.#first_field) },
306 };
307
308 let rest: Vec<_> = clauses.map(|clause| {
309 let field = &clause.field;
310 match clause.direction {
311 OrderDirection::Asc => quote::quote! { .then_with(|| a.#field.cmp(&b.#field)) },
312 OrderDirection::Desc => quote::quote! { .then_with(|| b.#field.cmp(&a.#field)) },
313 }
314 }).collect();
315
316 Some(quote::quote! { #first_cmp #(#rest)* })
317 };
318
319 let data_layer_block = if data_layer_specified {
328 quote::quote! { }
329 } else if has_loads {
330 let filters = build_filters(read_action);
339 let order_sort = build_order_sort(read_action);
340 let order_sort_block = order_sort.as_ref().map(|cmp_body| {
341 quote::quote! { base_rows.sort_by(|a, b| #cmp_body); }
342 }).unwrap_or_default();
343
344 let relation_loads = loaded_relations.iter().map(|rel| {
354 let rel_ty = &rel.ty;
355 let source_attr = &rel.source_attribute;
356 let map_name = Ident::new(
357 &format!("{}_map", rel.name),
358 rel.name.span(),
359 );
360
361 match rel.kind {
362 RelationKind::BelongsTo => {
363 quote::quote! {
367 let all_related: Vec<#rel_ty> = dl.load_all::<#rel_ty>().await;
368 let #map_name: ::std::collections::HashMap<String, #rel_ty> = all_related
369 .into_iter()
370 .map(|r| {
371 use cinderblock_core::Resource;
372 (r.primary_key().to_string(), r)
373 })
374 .collect();
375 }
376 }
377 RelationKind::HasMany => {
378 quote::quote! {
383 let all_related: Vec<#rel_ty> = dl.load_all::<#rel_ty>().await;
384 let mut #map_name: ::std::collections::HashMap<String, Vec<#rel_ty>> =
385 ::std::collections::HashMap::new();
386 for r in all_related {
387 let key = r.#source_attr.to_string();
388 #map_name.entry(key).or_default().push(r);
389 }
390 }
391 }
392 }
393 });
394
395 let wrapper_name =
400 Ident::new(&format!("{action_name}Response"), action.name.span());
401
402 let relation_field_inits = loaded_relations.iter().map(|rel| {
403 let rel_name = &rel.name;
404 let source_attr = &rel.source_attribute;
405 let map_name = Ident::new(
406 &format!("{}_map", rel.name),
407 rel.name.span(),
408 );
409
410 match rel.kind {
411 RelationKind::BelongsTo => {
412 let rel_ty = &rel.ty;
413 let rel_name_str = rel.name.to_string();
414 quote::quote! {
415 #rel_name: #map_name
416 .get(&row.#source_attr.to_string())
417 .cloned()
418 .ok_or_else(|| {
419 cinderblock_core::ListError::DataLayer(
420 format!(
421 "belongs_to relation `{}` of type `{}`: no record found for FK value `{}`",
422 #rel_name_str,
423 ::std::any::type_name::<#rel_ty>(),
424 row.#source_attr,
425 ).into(),
426 )
427 })?
428 }
429 }
430 RelationKind::HasMany => {
431 quote::quote! {
432 #rel_name: {
433 use cinderblock_core::Resource;
434 #map_name
435 .get(&row.primary_key().to_string())
436 .cloned()
437 .unwrap_or_default()
438 }
439 }
440 }
441 }
442 });
443
444 quote::quote! {
445 impl cinderblock_core::PerformRead<#action_name> for cinderblock_core::data_layer::in_memory::InMemoryDataLayer {
446 async fn read(&self, args: &<#action_name as cinderblock_core::ReadAction>::Arguments) -> Result<<#action_name as cinderblock_core::ReadAction>::Response, cinderblock_core::ListError> {
447 let dl = self;
448
449 let mut base_rows: Vec<#ident> = dl.load_all::<#ident>().await
451 .into_iter()
452 .filter(|row| { #(#filters)* true })
453 .collect();
454
455 #order_sort_block
457
458 #(#relation_loads)*
460
461 let results: Result<Vec<#wrapper_name>, cinderblock_core::ListError> = base_rows
463 .into_iter()
464 .map(|row| -> Result<#wrapper_name, cinderblock_core::ListError> {
465 Ok(#wrapper_name {
466 #(#relation_field_inits,)*
467 base: row,
468 })
469 })
470 .collect();
471
472 results
473 }
474 }
475 }
476 } else if is_paged {
477 let filters = build_filters(read_action);
478 let order_sort = build_order_sort(read_action);
479 let order_sort_block = order_sort.as_ref().map(|cmp_body| {
480 quote::quote! { filtered.sort_by(|a, b| #cmp_body); }
481 }).unwrap_or_default();
482
483 quote::quote! {
484 impl cinderblock_core::data_layer::in_memory::InMemoryPagedReadAction for #action_name {
485 fn filter(row: &Self::Output, args: &Self::Arguments) -> bool {
486 #(#filters)* true
487 }
488 }
489
490 impl cinderblock_core::data_layer::in_memory::InMemoryPerformRead for #action_name {
491 fn execute(
492 all: impl Iterator<Item = Self::Output>,
493 args: &Self::Arguments,
494 ) -> Self::Response {
495 use cinderblock_core::Paged;
496
497 let mut filtered: Vec<Self::Output> = all
498 .filter(|row| <Self as cinderblock_core::data_layer::in_memory::InMemoryPagedReadAction>::filter(row, args))
499 .collect();
500
501 #order_sort_block
502
503 let total = filtered.len() as u64;
504 let page = args.page();
505 let per_page = args.per_page();
506 let total_pages = if total == 0 { 1 } else { ((total as u32).saturating_add(per_page - 1)) / per_page };
507
508 let skip = ((page.saturating_sub(1)) as usize) * (per_page as usize);
509 let data: Vec<Self::Output> = filtered
510 .into_iter()
511 .skip(skip)
512 .take(per_page as usize)
513 .collect();
514
515 cinderblock_core::PaginatedResult {
516 data,
517 meta: cinderblock_core::PaginationMeta {
518 page,
519 per_page,
520 total,
521 total_pages,
522 },
523 }
524 }
525 }
526 }
527 } else {
528 let filters = build_filters(read_action);
539 let order_sort = build_order_sort(read_action);
540
541 let execute_body = if let Some(cmp_body) = order_sort {
542 quote::quote! {
543 let mut results: Vec<Self::Output> = all
544 .filter(|row| <Self as cinderblock_core::data_layer::in_memory::InMemoryReadAction>::filter(row, args))
545 .collect();
546 results.sort_by(|a, b| #cmp_body);
547 results
548 }
549 } else {
550 quote::quote! {
551 all.filter(|row| <Self as cinderblock_core::data_layer::in_memory::InMemoryReadAction>::filter(row, args))
552 .collect()
553 }
554 };
555
556 quote::quote! {
557 impl cinderblock_core::data_layer::in_memory::InMemoryReadAction for #action_name {
558 fn filter(row: &Self::Output, args: &Self::Arguments) -> bool {
559 #(#filters)* true
560 }
561 }
562
563 impl cinderblock_core::data_layer::in_memory::InMemoryPerformRead for #action_name {
564 fn execute(
565 all: impl Iterator<Item = Self::Output>,
566 args: &Self::Arguments,
567 ) -> Self::Response {
568 #execute_body
569 }
570 }
571 }
572 };
573
574 quote::quote! {
575 #arguments_struct
576
577 #paged_impl
578
579 #response_wrapper
580
581 struct #action_name;
582
583 impl cinderblock_core::ReadAction for #action_name {
584 type Output = #ident;
585
586 type Arguments = #arguments_type;
587
588 type Response = #response_type;
589 }
590
591 #data_layer_block
592 }
593 }
594 ResourceActionInputKind::Create { accept } => {
595 let action_name = convert_case::ccase!(pascal, action.name.to_string());
596 let action_name = Ident::new(&action_name, action.name.span());
597 let input_name = Ident::new(&format!("{action_name}Input"), action.name.span());
598
599 let attributes = input.attributes.iter().filter(|attr| attr.writable.value());
600
601 let (present, mut missing_names) = match accept {
602 Accept::Default => (
603 attributes
604 .map(|attr| (attr.name.to_string(), attr))
605 .collect::<HashMap<_, _>>(),
606 HashMap::new(),
607 ),
608 Accept::Only(idents) => {
609 let idents = idents
610 .iter()
611 .map(|ident| ident.to_string())
612 .collect::<HashSet<_>>();
613
614 attributes.fold(
615 (HashMap::new(), HashMap::new()),
616 |(mut present, mut missing), attr| {
617 if idents.contains(&attr.name.to_string()) {
618 present.insert(attr.name.to_string(), attr);
619 } else {
620 missing.insert(attr.name.to_string(), attr);
621 }
622 (present, missing)
623 },
624 )
625 }
626 };
627
628 input
629 .attributes
630 .iter()
631 .filter(|attr| {
632 !attr.writable.value() || !present.contains_key(&attr.name.to_string())
633 })
634 .for_each(|attr| {
635 missing_names.insert(attr.name.to_string(), attr);
636 });
637
638 let attributes = present.values().map(|attr| attr.to_field_definition());
639
640 let missing_names = missing_names.values().map(|attr| attr.to_default());
641
642 let present_names = present.values().map(|attr| attr.name.clone());
643
644 quote::quote! {
645 #[derive(::std::fmt::Debug)]
646 struct #action_name;
647
648 #[derive(::std::fmt::Debug, cinderblock_core::serde::Deserialize)]
649 struct #input_name {
650 #(pub #attributes),*
651 }
652
653 impl cinderblock_core::Create<#action_name> for #ident {
654 type Input = #input_name;
655
656 fn from_create_input(input: Self::Input) -> Self {
657 #ident {
658 #(#present_names: input.#present_names,)*
660
661 #(#missing_names),*
663 }
664 }
665 }
666 }
667 }
668 ResourceActionInputKind::Update(update) => {
669 let action_name = convert_case::ccase!(pascal, action.name.to_string());
670 let action_name = Ident::new(&action_name, action.name.span());
671 let input_name = Ident::new(&format!("{action_name}Input"), action.name.span());
672
673 let attributes = input.attributes.iter().filter(|attr| attr.writable.value());
674
675 let present = match &update.accept {
676 Accept::Default => attributes.collect::<Vec<_>>(),
677 Accept::Only(idents) => {
678 let idents = idents
679 .iter()
680 .map(|ident| ident.to_string())
681 .collect::<HashSet<_>>();
682
683 attributes
684 .filter(|attr| idents.contains(&attr.name.to_string()))
685 .collect()
686 }
687 };
688
689 let field_definitions = present.iter().map(|attr| attr.to_field_definition());
690
691 let field_assignments = present.iter().map(|attr| {
696 let name = &attr.name;
697 quote::quote! { self.#name = input.#name; }
698 });
699
700 let change_ref_calls =
707 update
708 .changes
709 .iter()
710 .enumerate()
711 .filter_map(|(i, change)| match change {
712 UpdateChange::ChangeRef(closure) => {
713 let param = closure
714 .inputs
715 .first()
716 .expect("change_ref closure must have exactly one parameter");
717 let body = &closure.body;
718 let binding = Ident::new(&format!("change_ref_{i}"), param.span());
719 Some(quote::quote! {
720 let #binding = |#param: &mut Self| #body;
721 #binding(self);
722 })
723 }
724 UpdateChange::Change(_) => None,
726 });
727
728 quote::quote! {
729 #[derive(::std::fmt::Debug)]
730 struct #action_name;
731
732 #[derive(::std::fmt::Debug, cinderblock_core::serde::Deserialize)]
733 struct #input_name {
734 #(pub #field_definitions),*
735 }
736
737 impl cinderblock_core::Update<#action_name> for #ident {
738 type Input = #input_name;
739
740 fn apply_update_input(&mut self, input: Self::Input) {
741 #(#field_assignments)*
742 #(#change_ref_calls)*
743 }
744 }
745 }
746 }
747 ResourceActionInputKind::Destroy => {
748 let action_name = convert_case::ccase!(pascal, action.name.to_string());
754 let action_name = Ident::new(&action_name, action.name.span());
755
756 quote::quote! {
757 #[derive(::std::fmt::Debug)]
758 struct #action_name;
759
760 impl cinderblock_core::Destroy<#action_name> for #ident {}
761 }
762 }
763 });
764
765 let name_segments: Vec<String> = input
766 .name
767 .iter()
768 .map(|segment| segment.to_string())
769 .collect();
770 let resource_name_literal = name_segments.join(".");
771
772 let data_layer_path = input.data_layer.map_or_else(
777 || quote::quote! { cinderblock_core::data_layer::in_memory::InMemoryDataLayer },
778 |path| quote::quote! { #path },
779 );
780
781 let extension_calls = input.extensions.iter().map(|ext| {
789 let path = &ext.path;
790 let config_tokens = &ext.config_tokens;
791
792 quote::quote! {
793 #path::__resource_extension! {
794 { #raw_tokens }
795
796 config = {
797 #config_tokens
798 }
799 }
800 }
801 });
802
803 quote::quote! {
804 #[derive(::std::fmt::Debug, ::std::clone::Clone, cinderblock_core::serde::Serialize, cinderblock_core::serde::Deserialize)]
805 struct #ident {
806 #(#fields),*
807 }
808
809 impl cinderblock_core::Resource for #ident {
810 type PrimaryKey = #primary_key_type;
811
812 type DataLayer = #data_layer_path;
813
814 const NAME: &'static [&'static str] = &[#(#name_segments),*];
815
816 const RESOURCE_NAME: &'static str = #resource_name_literal;
817
818 const PRIMARY_KEY_GENERATED: bool = #primary_key_generated;
819
820 fn primary_key(&self) -> &Self::PrimaryKey {
821 #primary_key_value
822 }
823 }
824
825 #(#actions)*
826
827 #(#extension_calls)*
828 }
829 .into()
830}
831
832#[cfg(test)]
833mod tests {
834 use super::*;
835 use assert2::{assert, check};
836 use cinderblock_extension_api::ResourceActionInput;
837 use quote::quote;
838
839 fn parse_resource(tokens: proc_macro2::TokenStream) -> ResourceMacroInput {
840 let result = syn::parse2::<ResourceMacroInput>(tokens);
841 assert!(let Ok(input) = result);
842 input
843 }
844
845 #[test]
846 fn minimal_resource_with_one_simple_attribute() {
847 let input = parse_resource(quote! {
848 name = Foo;
849
850 attributes {
851 id String;
852 }
853 });
854
855 check!(input.name.len() == 1);
856 check!(input.name[0] == "Foo");
857
858 check!(input.attributes.len() == 1);
859 let attr = &input.attributes[0];
860 check!(attr.name == "id");
861 check!(!attr.primary_key.value());
862 check!(!attr.generated.value());
863 check!(attr.writable.value());
864 check!(attr.default.is_none());
865
866 check!(input.actions.is_empty());
867 }
868
869 #[test]
870 fn dotted_name_parses_into_multiple_segments() {
871 let input = parse_resource(quote! {
872 name = Helpdesk.Support.Ticket;
873
874 attributes {
875 id String;
876 }
877 });
878
879 check!(input.name.len() == 3);
880 check!(input.name[0] == "Helpdesk");
881 check!(input.name[1] == "Support");
882 check!(input.name[2] == "Ticket");
883 }
884
885 #[test]
886 fn attribute_with_options_block() {
887 let input = parse_resource(quote! {
888 name = Ticket;
889
890 attributes {
891 ticket_id Uuid {
892 primary_key true;
893 writable false;
894 default || uuid::Uuid::new_v4();
895 }
896 }
897 });
898
899 check!(input.attributes.len() == 1);
900 let attr = &input.attributes[0];
901 check!(attr.name == "ticket_id");
902 check!(attr.primary_key.value());
903 check!(!attr.writable.value());
904 check!(attr.default.is_some());
905 }
906
907 #[test]
908 fn attribute_with_generated_flag() {
909 let input = parse_resource(quote! {
910 name = Item;
911
912 attributes {
913 item_id Uuid {
914 primary_key true;
915 generated true;
916 }
917 }
918 });
919
920 let attr = &input.attributes[0];
921 check!(attr.generated.value());
922 check!(attr.primary_key.value());
923 check!(attr.writable.value());
925 }
926
927 #[test]
928 fn multiple_attributes_mixed_simple_and_complex() {
929 let input = parse_resource(quote! {
930 name = Order;
931
932 attributes {
933 order_id Uuid {
934 primary_key true;
935 writable false;
936 }
937 item_name String;
938 quantity u32;
939 }
940 });
941
942 check!(input.attributes.len() == 3);
943
944 check!(input.attributes[0].name == "order_id");
945 check!(input.attributes[0].primary_key.value());
946 check!(!input.attributes[0].writable.value());
947
948 check!(input.attributes[1].name == "item_name");
949 check!(!input.attributes[1].primary_key.value());
950 check!(input.attributes[1].writable.value());
951
952 check!(input.attributes[2].name == "quantity");
953 check!(!input.attributes[2].primary_key.value());
954 check!(input.attributes[2].writable.value());
955 }
956
957 #[test]
958 fn actions_block_with_simple_create() {
959 let input = parse_resource(quote! {
960 name = Ticket;
961
962 attributes {
963 id String;
964 }
965
966 actions {
967 create open;
968 }
969 });
970
971 check!(input.actions.len() == 1);
972 check!(input.actions[0].name == "open");
973 check!(let ResourceActionInputKind::Create { accept: Accept::Default } = &input.actions[0].kind);
974 }
975
976 #[test]
977 fn action_with_accept_list() {
978 let input = parse_resource(quote! {
979 name = Ticket;
980
981 attributes {
982 id String;
983 }
984
985 actions {
986 create assign {
987 accept [subject];
988 };
989 }
990 });
991
992 check!(input.actions.len() == 1);
993 check!(input.actions[0].name == "assign");
994 assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = &input.actions[0].kind);
995 check!(idents.len() == 1);
996 check!(idents[0] == "subject");
997 }
998
999 #[test]
1000 fn no_actions_block_omitted() {
1001 let input = parse_resource(quote! {
1002 name = Simple;
1003
1004 attributes {
1005 id u64;
1006 }
1007 });
1008
1009 check!(input.actions.is_empty());
1010 }
1011
1012 #[test]
1013 fn full_helpdesk_example() {
1014 let input = parse_resource(quote! {
1015 name = Helpdesk.Support.Ticket;
1016
1017 attributes {
1018 ticket_id Uuid {
1019 primary_key true;
1020 writable false;
1021 default || uuid::Uuid::new_v4();
1022 }
1023
1024 subject String;
1025
1026 status TicketStatus;
1027 }
1028
1029 actions {
1030 create open;
1031
1032 create assign {
1033 accept [subject];
1034 };
1035 }
1036 });
1037
1038 check!(input.name.len() == 3);
1039 check!(input.name[0] == "Helpdesk");
1040 check!(input.name[1] == "Support");
1041 check!(input.name[2] == "Ticket");
1042
1043 check!(input.attributes.len() == 3);
1044
1045 let ticket_id = &input.attributes[0];
1046 check!(ticket_id.name == "ticket_id");
1047 check!(ticket_id.primary_key.value());
1048 check!(!ticket_id.writable.value());
1049 check!(ticket_id.default.is_some());
1050
1051 let subject = &input.attributes[1];
1052 check!(subject.name == "subject");
1053 check!(!subject.primary_key.value());
1054 check!(subject.writable.value());
1055 check!(subject.default.is_none());
1056
1057 let status = &input.attributes[2];
1058 check!(status.name == "status");
1059 check!(!status.primary_key.value());
1060 check!(status.writable.value());
1061
1062 check!(input.actions.len() == 2);
1063
1064 check!(input.actions[0].name == "open");
1065 check!(let ResourceActionInputKind::Create { accept: Accept::Default } = &input.actions[0].kind);
1066
1067 check!(input.actions[1].name == "assign");
1068 assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = &input.actions[1].kind);
1069 check!(idents.len() == 1);
1070 check!(idents[0] == "subject");
1071 }
1072
1073 #[test]
1074 fn parse_simple_create_action() {
1075 assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1076 create open;
1077 }));
1078
1079 check!(action.name == "open");
1080 check!(let ResourceActionInputKind::Create { accept: Accept::Default } = action.kind);
1081 }
1082
1083 #[test]
1084 fn parse_create_action_with_multiple_accept_idents() {
1085 assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1086 create bulk_insert {
1087 accept [name, email, age];
1088 }
1089 }));
1090
1091 check!(action.name == "bulk_insert");
1092 assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = action.kind);
1093 let names: Vec<String> = idents.iter().map(|i| i.to_string()).collect();
1094 check!(names == vec!["name", "email", "age"]);
1095 }
1096
1097 #[test]
1098 fn unknown_action_kind_produces_error() {
1099 let result = syn::parse2::<ResourceActionInput>(quote! {
1100 frobnicate foo;
1101 });
1102
1103 assert!(let Err(err) = result);
1104 let msg = err.to_string();
1105 check!(msg.contains("Unexpected action kind"));
1106 check!(msg.contains("frobnicate"));
1107 }
1108
1109 #[test]
1110 fn unknown_attribute_option_produces_error() {
1111 let result = syn::parse2::<ResourceMacroInput>(quote! {
1112 name = Thing;
1113
1114 attributes {
1115 id String {
1116 bogus true;
1117 }
1118 }
1119 });
1120
1121 assert!(let Err(err) = result);
1122 let msg = err.to_string();
1123 check!(msg.contains("Unexpected attribute key"));
1124 check!(msg.contains("bogus"));
1125 }
1126
1127 #[test]
1128 fn missing_semicolon_after_name_produces_error() {
1129 let result = syn::parse2::<ResourceMacroInput>(quote! {
1130 name = Foo
1131
1132 attributes {
1133 id String;
1134 }
1135 });
1136
1137 check!(let Err(_) = result);
1138 }
1139
1140 #[test]
1141 fn parse_simple_update_action_with_default_accept() {
1142 assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1143 update close;
1144 }));
1145
1146 check!(action.name == "close");
1147 assert!(let ResourceActionInputKind::Update(update) = &action.kind);
1148 check!(let Accept::Default = update.accept);
1149 check!(update.changes.is_empty());
1150 }
1151
1152 #[test]
1153 fn parse_update_action_with_accept_and_change_ref() {
1154 assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1155 update close {
1156 accept [];
1157 change_ref |resource| {
1158 resource.status = TicketStatus::Closed;
1159 };
1160 }
1161 }));
1162
1163 check!(action.name == "close");
1164 assert!(let ResourceActionInputKind::Update(update) = &action.kind);
1165 assert!(let Accept::Only(idents) = &update.accept);
1166 check!(idents.is_empty());
1167 check!(update.changes.len() == 1);
1168 check!(let UpdateChange::ChangeRef(_) = &update.changes[0]);
1169 }
1170
1171 #[test]
1172 fn parse_update_action_with_accept_fields() {
1173 assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1174 update reassign {
1175 accept [subject, status];
1176 }
1177 }));
1178
1179 check!(action.name == "reassign");
1180 assert!(let ResourceActionInputKind::Update(update) = &action.kind);
1181 assert!(let Accept::Only(idents) = &update.accept);
1182 let names: Vec<String> = idents.iter().map(|i| i.to_string()).collect();
1183 check!(names == vec!["subject", "status"]);
1184 check!(update.changes.is_empty());
1185 }
1186}