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_get = read_action.get;
100 let is_paged = read_action.paged.is_some();
101 let has_loads = !read_action.load.is_empty();
102
103 if is_get {
110 return quote::quote! {
111 struct #action_name;
112
113 impl cinderblock_core::ReadAction for #action_name {
114 type Output = #ident;
115 type Arguments = <#ident as cinderblock_core::Resource>::PrimaryKey;
116 type Response = #ident;
117 }
118 };
119 }
120
121 let has_user_arguments = !read_action.arguments.is_empty();
130 let needs_arguments_struct = has_user_arguments || is_paged;
131
132 let (arguments_type, arguments_struct) = if needs_arguments_struct {
133 let args_name = Ident::new(&format!("{action_name}Arguments"), action.name.span());
134 let user_arg_fields = read_action.arguments.iter().map(|arg| {
135 let name = &arg.name;
136 let ty = &arg.ty;
137 quote::quote! { pub #name: #ty }
138 });
139
140 let paged_fields = if is_paged {
142 quote::quote! {
143 pub page: Option<u32>,
144 pub per_page: Option<u32>,
145 }
146 } else {
147 quote::quote! {}
148 };
149
150 (
151 quote::quote! { #args_name },
152 quote::quote! {
153 #[derive(::std::fmt::Debug, cinderblock_core::serde::Deserialize)]
154 pub struct #args_name {
155 #(#user_arg_fields,)*
156 #paged_fields
157 }
158 },
159 )
160 } else {
161 (quote::quote! { () }, quote::quote! {})
162 };
163
164 let paged_impl = if let Some(paged_config) = &read_action.paged {
170 let args_name = Ident::new(&format!("{action_name}Arguments"), action.name.span());
171
172 let default_per_page = match paged_config.default_per_page {
173 Some(n) => quote::quote! { #n },
174 None => quote::quote! { cinderblock_core::DEFAULT_PER_PAGE },
175 };
176
177 let per_page_body = if let Some(max) = paged_config.max_per_page {
180 quote::quote! {
181 self.per_page.unwrap_or(#default_per_page).min(#max)
182 }
183 } else {
184 quote::quote! {
185 self.per_page.unwrap_or(#default_per_page)
186 }
187 };
188
189 quote::quote! {
190 impl cinderblock_core::Paged for #args_name {
191 fn page(&self) -> u32 {
192 self.page.unwrap_or(1)
193 }
194
195 fn per_page(&self) -> u32 {
196 #per_page_body
197 }
198 }
199 }
200 } else {
201 quote::quote! {}
202 };
203
204 let loaded_relations: Vec<&RelationDecl> = read_action
214 .load
215 .iter()
216 .map(|name| {
217 relations
218 .iter()
219 .find(|r| r.name == *name)
220 .expect("load reference validated during parsing")
221 })
222 .collect();
223
224 let response_wrapper = if has_loads {
225 let wrapper_name =
226 Ident::new(&format!("{action_name}Response"), action.name.span());
227
228 let relation_fields = loaded_relations.iter().map(|rel| {
229 let rel_name = &rel.name;
230 let rel_ty = &rel.ty;
231 match rel.kind {
232 RelationKind::BelongsTo => quote::quote! {
233 pub #rel_name: #rel_ty
234 },
235 RelationKind::HasMany => quote::quote! {
236 pub #rel_name: Vec<#rel_ty>
237 },
238 }
239 });
240
241 quote::quote! {
242 #[derive(::std::fmt::Debug, ::std::clone::Clone, cinderblock_core::serde::Serialize)]
243 pub struct #wrapper_name {
244 #[serde(flatten)]
245 pub base: #ident,
246 #(#relation_fields),*
247 }
248 }
249 } else {
250 quote::quote! {}
251 };
252
253 let response_type = if has_loads {
261 let wrapper_name =
262 Ident::new(&format!("{action_name}Response"), action.name.span());
263 quote::quote! { Vec<#wrapper_name> }
264 } else if is_paged {
265 quote::quote! { cinderblock_core::PaginatedResult<#ident> }
266 } else {
267 quote::quote! { Vec<#ident> }
268 };
269
270 let build_filters = |read_action: &cinderblock_extension_api::ActionRead| {
275 read_action.filters.iter().map(|filter| {
276 let field = &filter.field;
277 let op = match filter.op {
278 cinderblock_extension_api::ReadFilterOperation::Eq => quote::quote! { == },
279 };
280 match &filter.value {
281 ReadFilterValue::Literal(expr) => {
282 quote::quote! {
283 row.#field #op #expr &&
284 }
285 }
286 ReadFilterValue::Arg(arg_name) => {
287 let arg_decl = read_action
288 .arguments
289 .iter()
290 .find(|a| a.name == *arg_name)
291 .expect("arg reference validated during parsing");
292 if is_option_type(&arg_decl.ty) {
293 quote::quote! {
294 args.#arg_name.as_ref().map_or(true, |v| row.#field #op *v) &&
295 }
296 } else {
297 quote::quote! {
298 row.#field #op args.#arg_name &&
299 }
300 }
301 }
302 }
303 }).collect::<Vec<_>>()
304 };
305
306 let build_order_sort = |read_action: &cinderblock_extension_api::ActionRead| -> Option<proc_macro2::TokenStream> {
315 if read_action.orders.is_empty() {
316 return None;
317 }
318
319 let mut clauses = read_action.orders.iter();
320 let first = clauses.next().unwrap();
321 let first_field = &first.field;
322 let first_cmp = match first.direction {
323 OrderDirection::Asc => quote::quote! { a.#first_field.cmp(&b.#first_field) },
324 OrderDirection::Desc => quote::quote! { b.#first_field.cmp(&a.#first_field) },
325 };
326
327 let rest: Vec<_> = clauses.map(|clause| {
328 let field = &clause.field;
329 match clause.direction {
330 OrderDirection::Asc => quote::quote! { .then_with(|| a.#field.cmp(&b.#field)) },
331 OrderDirection::Desc => quote::quote! { .then_with(|| b.#field.cmp(&a.#field)) },
332 }
333 }).collect();
334
335 Some(quote::quote! { #first_cmp #(#rest)* })
336 };
337
338 let data_layer_block = if data_layer_specified {
347 quote::quote! { }
348 } else if has_loads {
349 let filters = build_filters(read_action);
358 let order_sort = build_order_sort(read_action);
359 let order_sort_block = order_sort.as_ref().map(|cmp_body| {
360 quote::quote! { base_rows.sort_by(|a, b| #cmp_body); }
361 }).unwrap_or_default();
362
363 let relation_loads = loaded_relations.iter().map(|rel| {
373 let rel_ty = &rel.ty;
374 let source_attr = &rel.source_attribute;
375 let map_name = Ident::new(
376 &format!("{}_map", rel.name),
377 rel.name.span(),
378 );
379
380 match rel.kind {
381 RelationKind::BelongsTo => {
382 quote::quote! {
386 let all_related: Vec<#rel_ty> = dl.load_all::<#rel_ty>().await;
387 let #map_name: ::std::collections::HashMap<String, #rel_ty> = all_related
388 .into_iter()
389 .map(|r| {
390 use cinderblock_core::Resource;
391 (r.primary_key().to_string(), r)
392 })
393 .collect();
394 }
395 }
396 RelationKind::HasMany => {
397 quote::quote! {
402 let all_related: Vec<#rel_ty> = dl.load_all::<#rel_ty>().await;
403 let mut #map_name: ::std::collections::HashMap<String, Vec<#rel_ty>> =
404 ::std::collections::HashMap::new();
405 for r in all_related {
406 let key = r.#source_attr.to_string();
407 #map_name.entry(key).or_default().push(r);
408 }
409 }
410 }
411 }
412 });
413
414 let wrapper_name =
419 Ident::new(&format!("{action_name}Response"), action.name.span());
420
421 let relation_field_inits = loaded_relations.iter().map(|rel| {
422 let rel_name = &rel.name;
423 let source_attr = &rel.source_attribute;
424 let map_name = Ident::new(
425 &format!("{}_map", rel.name),
426 rel.name.span(),
427 );
428
429 match rel.kind {
430 RelationKind::BelongsTo => {
431 let rel_ty = &rel.ty;
432 let rel_name_str = rel.name.to_string();
433 quote::quote! {
434 #rel_name: #map_name
435 .get(&row.#source_attr.to_string())
436 .cloned()
437 .ok_or_else(|| {
438 cinderblock_core::ListError::DataLayer(
439 format!(
440 "belongs_to relation `{}` of type `{}`: no record found for FK value `{}`",
441 #rel_name_str,
442 ::std::any::type_name::<#rel_ty>(),
443 row.#source_attr,
444 ).into(),
445 )
446 })?
447 }
448 }
449 RelationKind::HasMany => {
450 quote::quote! {
451 #rel_name: {
452 use cinderblock_core::Resource;
453 #map_name
454 .get(&row.primary_key().to_string())
455 .cloned()
456 .unwrap_or_default()
457 }
458 }
459 }
460 }
461 });
462
463 quote::quote! {
464 impl cinderblock_core::PerformRead<#action_name> for cinderblock_core::data_layer::in_memory::InMemoryDataLayer {
465 async fn read(&self, args: &<#action_name as cinderblock_core::ReadAction>::Arguments) -> Result<<#action_name as cinderblock_core::ReadAction>::Response, cinderblock_core::ListError> {
466 let dl = self;
467
468 let mut base_rows: Vec<#ident> = dl.load_all::<#ident>().await
470 .into_iter()
471 .filter(|row| { #(#filters)* true })
472 .collect();
473
474 #order_sort_block
476
477 #(#relation_loads)*
479
480 let results: Result<Vec<#wrapper_name>, cinderblock_core::ListError> = base_rows
482 .into_iter()
483 .map(|row| -> Result<#wrapper_name, cinderblock_core::ListError> {
484 Ok(#wrapper_name {
485 #(#relation_field_inits,)*
486 base: row,
487 })
488 })
489 .collect();
490
491 results
492 }
493 }
494 }
495 } else if is_paged {
496 let filters = build_filters(read_action);
497 let order_sort = build_order_sort(read_action);
498 let order_sort_block = order_sort.as_ref().map(|cmp_body| {
499 quote::quote! { filtered.sort_by(|a, b| #cmp_body); }
500 }).unwrap_or_default();
501
502 quote::quote! {
503 impl cinderblock_core::data_layer::in_memory::InMemoryPagedReadAction for #action_name {
504 fn filter(row: &Self::Output, args: &Self::Arguments) -> bool {
505 #(#filters)* true
506 }
507 }
508
509 impl cinderblock_core::data_layer::in_memory::InMemoryPerformRead for #action_name {
510 fn execute(
511 all: impl Iterator<Item = Self::Output>,
512 args: &Self::Arguments,
513 ) -> Self::Response {
514 use cinderblock_core::Paged;
515
516 let mut filtered: Vec<Self::Output> = all
517 .filter(|row| <Self as cinderblock_core::data_layer::in_memory::InMemoryPagedReadAction>::filter(row, args))
518 .collect();
519
520 #order_sort_block
521
522 let total = filtered.len() as u64;
523 let page = args.page();
524 let per_page = args.per_page();
525 let total_pages = if total == 0 { 1 } else { ((total as u32).saturating_add(per_page - 1)) / per_page };
526
527 let skip = ((page.saturating_sub(1)) as usize) * (per_page as usize);
528 let data: Vec<Self::Output> = filtered
529 .into_iter()
530 .skip(skip)
531 .take(per_page as usize)
532 .collect();
533
534 cinderblock_core::PaginatedResult {
535 data,
536 meta: cinderblock_core::PaginationMeta {
537 page,
538 per_page,
539 total,
540 total_pages,
541 },
542 }
543 }
544 }
545 }
546 } else {
547 let filters = build_filters(read_action);
558 let order_sort = build_order_sort(read_action);
559
560 let execute_body = if let Some(cmp_body) = order_sort {
561 quote::quote! {
562 let mut results: Vec<Self::Output> = all
563 .filter(|row| <Self as cinderblock_core::data_layer::in_memory::InMemoryReadAction>::filter(row, args))
564 .collect();
565 results.sort_by(|a, b| #cmp_body);
566 results
567 }
568 } else {
569 quote::quote! {
570 all.filter(|row| <Self as cinderblock_core::data_layer::in_memory::InMemoryReadAction>::filter(row, args))
571 .collect()
572 }
573 };
574
575 quote::quote! {
576 impl cinderblock_core::data_layer::in_memory::InMemoryReadAction for #action_name {
577 fn filter(row: &Self::Output, args: &Self::Arguments) -> bool {
578 #(#filters)* true
579 }
580 }
581
582 impl cinderblock_core::data_layer::in_memory::InMemoryPerformRead for #action_name {
583 fn execute(
584 all: impl Iterator<Item = Self::Output>,
585 args: &Self::Arguments,
586 ) -> Self::Response {
587 #execute_body
588 }
589 }
590 }
591 };
592
593 quote::quote! {
594 #arguments_struct
595
596 #paged_impl
597
598 #response_wrapper
599
600 pub struct #action_name;
601
602 impl cinderblock_core::ReadAction for #action_name {
603 type Output = #ident;
604
605 type Arguments = #arguments_type;
606
607 type Response = #response_type;
608 }
609
610 #data_layer_block
611 }
612 }
613 ResourceActionInputKind::Create { accept } => {
614 let action_name = convert_case::ccase!(pascal, action.name.to_string());
615 let action_name = Ident::new(&action_name, action.name.span());
616 let input_name = Ident::new(&format!("{action_name}Input"), action.name.span());
617
618 let attributes = input.attributes.iter().filter(|attr| attr.writable.value());
619
620 let (present, mut missing_names) = match accept {
621 Accept::Default => (
622 attributes
623 .map(|attr| (attr.name.to_string(), attr))
624 .collect::<HashMap<_, _>>(),
625 HashMap::new(),
626 ),
627 Accept::Only(idents) => {
628 let idents = idents
629 .iter()
630 .map(|ident| ident.to_string())
631 .collect::<HashSet<_>>();
632
633 attributes.fold(
634 (HashMap::new(), HashMap::new()),
635 |(mut present, mut missing), attr| {
636 if idents.contains(&attr.name.to_string()) {
637 present.insert(attr.name.to_string(), attr);
638 } else {
639 missing.insert(attr.name.to_string(), attr);
640 }
641 (present, missing)
642 },
643 )
644 }
645 };
646
647 input
648 .attributes
649 .iter()
650 .filter(|attr| {
651 !attr.writable.value() || !present.contains_key(&attr.name.to_string())
652 })
653 .for_each(|attr| {
654 missing_names.insert(attr.name.to_string(), attr);
655 });
656
657 let attributes = present.values().map(|attr| attr.to_field_definition());
658
659 let missing_names = missing_names.values().map(|attr| attr.to_default());
660
661 let present_names = present.values().map(|attr| attr.name.clone());
662
663 quote::quote! {
664 #[derive(::std::fmt::Debug)]
665 pub struct #action_name;
666
667 #[derive(::std::fmt::Debug, cinderblock_core::serde::Deserialize)]
668 pub struct #input_name {
669 #(pub #attributes),*
670 }
671
672 impl cinderblock_core::Create<#action_name> for #ident {
673 type Input = #input_name;
674
675 fn from_create_input(input: Self::Input) -> Self {
676 #ident {
677 #(#present_names: input.#present_names,)*
679
680 #(#missing_names),*
682 }
683 }
684 }
685 }
686 }
687 ResourceActionInputKind::Update(update) => {
688 let action_name = convert_case::ccase!(pascal, action.name.to_string());
689 let action_name = Ident::new(&action_name, action.name.span());
690 let input_name = Ident::new(&format!("{action_name}Input"), action.name.span());
691
692 let attributes = input.attributes.iter().filter(|attr| attr.writable.value());
693
694 let present = match &update.accept {
695 Accept::Default => attributes.collect::<Vec<_>>(),
696 Accept::Only(idents) => {
697 let idents = idents
698 .iter()
699 .map(|ident| ident.to_string())
700 .collect::<HashSet<_>>();
701
702 attributes
703 .filter(|attr| idents.contains(&attr.name.to_string()))
704 .collect()
705 }
706 };
707
708 let field_definitions = present.iter().map(|attr| attr.to_field_definition());
709
710 let field_assignments = present.iter().map(|attr| {
715 let name = &attr.name;
716 quote::quote! { self.#name = input.#name; }
717 });
718
719 let change_ref_calls =
726 update
727 .changes
728 .iter()
729 .enumerate()
730 .filter_map(|(i, change)| match change {
731 UpdateChange::ChangeRef(closure) => {
732 let param = closure
733 .inputs
734 .first()
735 .expect("change_ref closure must have exactly one parameter");
736 let body = &closure.body;
737 let binding = Ident::new(&format!("change_ref_{i}"), param.span());
738 Some(quote::quote! {
739 let #binding = |#param: &mut Self| #body;
740 #binding(self);
741 })
742 }
743 UpdateChange::Change(_) => None,
745 });
746
747 quote::quote! {
748 #[derive(::std::fmt::Debug)]
749 pub struct #action_name;
750
751 #[derive(::std::fmt::Debug, cinderblock_core::serde::Deserialize)]
752 pub struct #input_name {
753 #(pub #field_definitions),*
754 }
755
756 impl cinderblock_core::Update<#action_name> for #ident {
757 type Input = #input_name;
758
759 fn apply_update_input(&mut self, input: Self::Input) {
760 #(#field_assignments)*
761 #(#change_ref_calls)*
762 }
763 }
764 }
765 }
766 ResourceActionInputKind::Destroy => {
767 let action_name = convert_case::ccase!(pascal, action.name.to_string());
773 let action_name = Ident::new(&action_name, action.name.span());
774
775 quote::quote! {
776 #[derive(::std::fmt::Debug)]
777 pub struct #action_name;
778
779 impl cinderblock_core::Destroy<#action_name> for #ident {}
780 }
781 }
782 });
783
784 let name_segments: Vec<String> = input
785 .name
786 .iter()
787 .map(|segment| segment.to_string())
788 .collect();
789 let resource_name_literal = name_segments.join(".");
790
791 let data_layer_path = input.data_layer.map_or_else(
796 || quote::quote! { cinderblock_core::data_layer::in_memory::InMemoryDataLayer },
797 |path| quote::quote! { #path },
798 );
799
800 let extension_calls = input.extensions.iter().map(|ext| {
808 let path = &ext.path;
809 let config_tokens = &ext.config_tokens;
810
811 quote::quote! {
812 #path::__resource_extension! {
813 { #raw_tokens }
814
815 config = {
816 #config_tokens
817 }
818 }
819 }
820 });
821
822 quote::quote! {
823 #[derive(::std::fmt::Debug, ::std::clone::Clone, cinderblock_core::serde::Serialize, cinderblock_core::serde::Deserialize)]
824 pub struct #ident {
825 #(#fields),*
826 }
827
828 impl cinderblock_core::Resource for #ident {
829 type PrimaryKey = #primary_key_type;
830
831 type DataLayer = #data_layer_path;
832
833 const NAME: &'static [&'static str] = &[#(#name_segments),*];
834
835 const RESOURCE_NAME: &'static str = #resource_name_literal;
836
837 const PRIMARY_KEY_GENERATED: bool = #primary_key_generated;
838
839 fn primary_key(&self) -> &Self::PrimaryKey {
840 #primary_key_value
841 }
842 }
843
844 #(#actions)*
845
846 #(#extension_calls)*
847 }
848 .into()
849}
850
851#[cfg(test)]
852mod tests {
853 use super::*;
854 use assert2::{assert, check};
855 use cinderblock_extension_api::ResourceActionInput;
856 use quote::quote;
857
858 fn parse_resource(tokens: proc_macro2::TokenStream) -> ResourceMacroInput {
859 let result = syn::parse2::<ResourceMacroInput>(tokens);
860 assert!(let Ok(input) = result);
861 input
862 }
863
864 #[test]
865 fn minimal_resource_with_one_simple_attribute() {
866 let input = parse_resource(quote! {
867 name = Foo;
868
869 attributes {
870 id String;
871 }
872 });
873
874 check!(input.name.len() == 1);
875 check!(input.name[0] == "Foo");
876
877 check!(input.attributes.len() == 1);
878 let attr = &input.attributes[0];
879 check!(attr.name == "id");
880 check!(!attr.primary_key.value());
881 check!(!attr.generated.value());
882 check!(attr.writable.value());
883 check!(attr.default.is_none());
884
885 check!(input.actions.is_empty());
886 }
887
888 #[test]
889 fn dotted_name_parses_into_multiple_segments() {
890 let input = parse_resource(quote! {
891 name = Helpdesk.Support.Ticket;
892
893 attributes {
894 id String;
895 }
896 });
897
898 check!(input.name.len() == 3);
899 check!(input.name[0] == "Helpdesk");
900 check!(input.name[1] == "Support");
901 check!(input.name[2] == "Ticket");
902 }
903
904 #[test]
905 fn attribute_with_options_block() {
906 let input = parse_resource(quote! {
907 name = Ticket;
908
909 attributes {
910 ticket_id Uuid {
911 primary_key true;
912 writable false;
913 default || uuid::Uuid::new_v4();
914 }
915 }
916 });
917
918 check!(input.attributes.len() == 1);
919 let attr = &input.attributes[0];
920 check!(attr.name == "ticket_id");
921 check!(attr.primary_key.value());
922 check!(!attr.writable.value());
923 check!(attr.default.is_some());
924 }
925
926 #[test]
927 fn attribute_with_generated_flag() {
928 let input = parse_resource(quote! {
929 name = Item;
930
931 attributes {
932 item_id Uuid {
933 primary_key true;
934 generated true;
935 }
936 }
937 });
938
939 let attr = &input.attributes[0];
940 check!(attr.generated.value());
941 check!(attr.primary_key.value());
942 check!(attr.writable.value());
944 }
945
946 #[test]
947 fn multiple_attributes_mixed_simple_and_complex() {
948 let input = parse_resource(quote! {
949 name = Order;
950
951 attributes {
952 order_id Uuid {
953 primary_key true;
954 writable false;
955 }
956 item_name String;
957 quantity u32;
958 }
959 });
960
961 check!(input.attributes.len() == 3);
962
963 check!(input.attributes[0].name == "order_id");
964 check!(input.attributes[0].primary_key.value());
965 check!(!input.attributes[0].writable.value());
966
967 check!(input.attributes[1].name == "item_name");
968 check!(!input.attributes[1].primary_key.value());
969 check!(input.attributes[1].writable.value());
970
971 check!(input.attributes[2].name == "quantity");
972 check!(!input.attributes[2].primary_key.value());
973 check!(input.attributes[2].writable.value());
974 }
975
976 #[test]
977 fn actions_block_with_simple_create() {
978 let input = parse_resource(quote! {
979 name = Ticket;
980
981 attributes {
982 id String;
983 }
984
985 actions {
986 create open;
987 }
988 });
989
990 check!(input.actions.len() == 1);
991 check!(input.actions[0].name == "open");
992 check!(let ResourceActionInputKind::Create { accept: Accept::Default } = &input.actions[0].kind);
993 }
994
995 #[test]
996 fn action_with_accept_list() {
997 let input = parse_resource(quote! {
998 name = Ticket;
999
1000 attributes {
1001 id String;
1002 }
1003
1004 actions {
1005 create assign {
1006 accept [subject];
1007 };
1008 }
1009 });
1010
1011 check!(input.actions.len() == 1);
1012 check!(input.actions[0].name == "assign");
1013 assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = &input.actions[0].kind);
1014 check!(idents.len() == 1);
1015 check!(idents[0] == "subject");
1016 }
1017
1018 #[test]
1019 fn no_actions_block_omitted() {
1020 let input = parse_resource(quote! {
1021 name = Simple;
1022
1023 attributes {
1024 id u64;
1025 }
1026 });
1027
1028 check!(input.actions.is_empty());
1029 }
1030
1031 #[test]
1032 fn full_helpdesk_example() {
1033 let input = parse_resource(quote! {
1034 name = Helpdesk.Support.Ticket;
1035
1036 attributes {
1037 ticket_id Uuid {
1038 primary_key true;
1039 writable false;
1040 default || uuid::Uuid::new_v4();
1041 }
1042
1043 subject String;
1044
1045 status TicketStatus;
1046 }
1047
1048 actions {
1049 create open;
1050
1051 create assign {
1052 accept [subject];
1053 };
1054 }
1055 });
1056
1057 check!(input.name.len() == 3);
1058 check!(input.name[0] == "Helpdesk");
1059 check!(input.name[1] == "Support");
1060 check!(input.name[2] == "Ticket");
1061
1062 check!(input.attributes.len() == 3);
1063
1064 let ticket_id = &input.attributes[0];
1065 check!(ticket_id.name == "ticket_id");
1066 check!(ticket_id.primary_key.value());
1067 check!(!ticket_id.writable.value());
1068 check!(ticket_id.default.is_some());
1069
1070 let subject = &input.attributes[1];
1071 check!(subject.name == "subject");
1072 check!(!subject.primary_key.value());
1073 check!(subject.writable.value());
1074 check!(subject.default.is_none());
1075
1076 let status = &input.attributes[2];
1077 check!(status.name == "status");
1078 check!(!status.primary_key.value());
1079 check!(status.writable.value());
1080
1081 check!(input.actions.len() == 2);
1082
1083 check!(input.actions[0].name == "open");
1084 check!(let ResourceActionInputKind::Create { accept: Accept::Default } = &input.actions[0].kind);
1085
1086 check!(input.actions[1].name == "assign");
1087 assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = &input.actions[1].kind);
1088 check!(idents.len() == 1);
1089 check!(idents[0] == "subject");
1090 }
1091
1092 #[test]
1093 fn parse_simple_create_action() {
1094 assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1095 create open;
1096 }));
1097
1098 check!(action.name == "open");
1099 check!(let ResourceActionInputKind::Create { accept: Accept::Default } = action.kind);
1100 }
1101
1102 #[test]
1103 fn parse_create_action_with_multiple_accept_idents() {
1104 assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1105 create bulk_insert {
1106 accept [name, email, age];
1107 }
1108 }));
1109
1110 check!(action.name == "bulk_insert");
1111 assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = action.kind);
1112 let names: Vec<String> = idents.iter().map(|i| i.to_string()).collect();
1113 check!(names == vec!["name", "email", "age"]);
1114 }
1115
1116 #[test]
1117 fn unknown_action_kind_produces_error() {
1118 let result = syn::parse2::<ResourceActionInput>(quote! {
1119 frobnicate foo;
1120 });
1121
1122 assert!(let Err(err) = result);
1123 let msg = err.to_string();
1124 check!(msg.contains("Unexpected action kind"));
1125 check!(msg.contains("frobnicate"));
1126 }
1127
1128 #[test]
1129 fn unknown_attribute_option_produces_error() {
1130 let result = syn::parse2::<ResourceMacroInput>(quote! {
1131 name = Thing;
1132
1133 attributes {
1134 id String {
1135 bogus true;
1136 }
1137 }
1138 });
1139
1140 assert!(let Err(err) = result);
1141 let msg = err.to_string();
1142 check!(msg.contains("Unexpected attribute key"));
1143 check!(msg.contains("bogus"));
1144 }
1145
1146 #[test]
1147 fn missing_semicolon_after_name_produces_error() {
1148 let result = syn::parse2::<ResourceMacroInput>(quote! {
1149 name = Foo
1150
1151 attributes {
1152 id String;
1153 }
1154 });
1155
1156 check!(let Err(_) = result);
1157 }
1158
1159 #[test]
1160 fn parse_simple_update_action_with_default_accept() {
1161 assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1162 update close;
1163 }));
1164
1165 check!(action.name == "close");
1166 assert!(let ResourceActionInputKind::Update(update) = &action.kind);
1167 check!(let Accept::Default = update.accept);
1168 check!(update.changes.is_empty());
1169 }
1170
1171 #[test]
1172 fn parse_update_action_with_accept_and_change_ref() {
1173 assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1174 update close {
1175 accept [];
1176 change_ref |resource| {
1177 resource.status = TicketStatus::Closed;
1178 };
1179 }
1180 }));
1181
1182 check!(action.name == "close");
1183 assert!(let ResourceActionInputKind::Update(update) = &action.kind);
1184 assert!(let Accept::Only(idents) = &update.accept);
1185 check!(idents.is_empty());
1186 check!(update.changes.len() == 1);
1187 check!(let UpdateChange::ChangeRef(_) = &update.changes[0]);
1188 }
1189
1190 #[test]
1191 fn parse_update_action_with_accept_fields() {
1192 assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
1193 update reassign {
1194 accept [subject, status];
1195 }
1196 }));
1197
1198 check!(action.name == "reassign");
1199 assert!(let ResourceActionInputKind::Update(update) = &action.kind);
1200 assert!(let Accept::Only(idents) = &update.accept);
1201 let names: Vec<String> = idents.iter().map(|i| i.to_string()).collect();
1202 check!(names == vec!["subject", "status"]);
1203 check!(update.changes.is_empty());
1204 }
1205}