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
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 data_layer_block = if data_layer_specified {
296 quote::quote! { }
297 } else if has_loads {
298 let filters = build_filters(read_action);
306
307 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 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 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 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 let base_rows: Vec<#ident> = dl.load_all::<#ident>().await
412 .into_iter()
413 .filter(|row| { #(#filters)* true })
414 .collect();
415
416 #(#relation_loads)*
418
419 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 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 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 #(#present_names: input.#present_names,)*
598
599 #(#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 let field_assignments = present.iter().map(|attr| {
634 let name = &attr.name;
635 quote::quote! { self.#name = input.#name; }
636 });
637
638 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 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 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 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 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 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}