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