1use proc_macro::TokenStream;
15use proc_macro2::TokenStream as TokenStream2;
16use quote::{format_ident, quote};
17use syn::{parse_macro_input, Data, DeriveInput, Fields, Lit, Meta};
18
19#[proc_macro_derive(RustioAdmin, attributes(rustio))]
20pub fn derive_rustio_admin(input: TokenStream) -> TokenStream {
21 let input = parse_macro_input!(input as DeriveInput);
22 expand(input)
23 .unwrap_or_else(|e| e.to_compile_error())
24 .into()
25}
26
27fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
28 let struct_name = &input.ident;
29 let fields = struct_fields(&input)?;
30
31 let admin_name = plural_snake(&struct_name.to_string());
32 let display_name = humanise(&plural_snake(&struct_name.to_string()));
33 let singular = struct_name.to_string();
34
35 let mut field_metas = Vec::new();
36 let mut display_value_arms = Vec::new();
37 let mut from_form_parses = Vec::new();
38 let mut from_form_fields = Vec::new();
39 let mut update_tuples = Vec::new();
40
41 for f in fields {
42 let fname = f.ident.as_ref().unwrap();
43 let fname_str = fname.to_string();
44 let kind = classify_type(&f.ty)?;
45 let kind = if matches!(kind, FieldKind::DateTime) && is_auto_timestamp_name(&fname_str) {
52 FieldKind::DateTimeAuto
53 } else {
54 kind
55 };
56 let editable = fname_str != "id" && kind != FieldKind::DateTimeAuto;
57
58 let type_variant = kind.field_type_ident();
59 let relation = parse_relation_attr(&f.attrs, &fname_str)?;
60 let relation_tokens = match &relation {
61 Some((target, display)) => {
62 let display_tok = match display {
63 Some(d) => quote! { ::std::option::Option::Some(#d) },
64 None => quote! { ::std::option::Option::None },
65 };
66 quote! {
67 ::std::option::Option::Some(::rustio_core::admin::AdminRelation {
68 target_model: #target,
69 display_field: #display_tok,
70 multi: false,
77 })
78 }
79 }
80 None => quote! { ::std::option::Option::None },
81 };
82
83 field_metas.push(quote! {
84 ::rustio_core::admin::AdminField {
85 name: #fname_str,
86 label: #fname_str,
87 field_type: ::rustio_core::admin::FieldType::#type_variant,
88 editable: #editable,
89 relation: #relation_tokens,
90 choices: ::std::option::Option::None,
96 }
97 });
98
99 let display_arm = match kind {
101 FieldKind::String => quote! {
102 out.push((#fname_str.to_string(), self.#fname.clone()));
103 },
104 FieldKind::OptionalString => quote! {
105 out.push((#fname_str.to_string(), match &self.#fname {
113 Some(v) => v.clone(),
114 None => String::new(),
115 }));
116 },
117 FieldKind::I32 | FieldKind::I64 => quote! {
118 out.push((#fname_str.to_string(), self.#fname.to_string()));
119 },
120 FieldKind::OptionalI64 => quote! {
121 out.push((#fname_str.to_string(), match &self.#fname {
122 Some(v) => v.to_string(),
123 None => String::new(),
124 }));
125 },
126 FieldKind::Bool => quote! {
127 out.push((#fname_str.to_string(), if self.#fname { "true".to_string() } else { "false".to_string() }));
128 },
129 FieldKind::DateTime | FieldKind::DateTimeAuto => quote! {
130 out.push((#fname_str.to_string(), self.#fname.format("%Y-%m-%dT%H:%M").to_string()));
142 },
143 };
144 display_value_arms.push(display_arm);
145
146 if fname_str == "id" {
148 from_form_fields.push(quote! { #fname: 0 });
149 continue;
150 }
151
152 let humanised_label = humanise_field(&fname_str);
157 let required_msg = format!("{humanised_label} is required.");
158 let number_msg = format!("{humanised_label} must be a number.");
159 let date_invalid_msg = format!("{humanised_label} is not a valid date.");
160
161 match kind {
162 FieldKind::String => {
163 from_form_parses.push(quote! {
168 let #fname = match form.get(#fname_str).map(str::trim) {
169 Some(v) if !v.is_empty() => v.to_string(),
170 _ => { errors.push(#required_msg.to_string()); String::new() }
171 };
172 });
173 from_form_fields.push(quote! { #fname });
174 }
175 FieldKind::OptionalString => {
176 from_form_parses.push(quote! {
179 let #fname: Option<String> = form
180 .get(#fname_str)
181 .map(|s| s.trim().to_string())
182 .filter(|s| !s.is_empty());
183 });
184 from_form_fields.push(quote! { #fname });
185 }
186 FieldKind::I32 => {
187 from_form_parses.push(quote! {
188 let #fname: i32 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
189 Some(v) => v,
190 None => { errors.push(#number_msg.to_string()); 0 }
191 };
192 });
193 from_form_fields.push(quote! { #fname });
194 }
195 FieldKind::I64 => {
196 from_form_parses.push(quote! {
197 let #fname: i64 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
198 Some(v) => v,
199 None => { errors.push(#number_msg.to_string()); 0 }
200 };
201 });
202 from_form_fields.push(quote! { #fname });
203 }
204 FieldKind::OptionalI64 => {
205 from_form_parses.push(quote! {
211 let #fname: Option<i64> = match form.get(#fname_str).map(str::trim) {
212 None | Some("") => None,
213 Some(raw) => match raw.parse::<i64>() {
214 Ok(n) => Some(n),
215 Err(_) => {
216 errors.push(#number_msg.to_string());
217 None
218 }
219 },
220 };
221 });
222 from_form_fields.push(quote! { #fname });
223 }
224 FieldKind::Bool => {
225 from_form_parses.push(quote! {
226 let #fname: bool = form.bool_flag(#fname_str);
227 });
228 from_form_fields.push(quote! { #fname });
229 }
230 FieldKind::DateTime => {
231 from_form_parses.push(quote! {
232 let #fname = match form.get(#fname_str) {
233 Some(raw) if !raw.is_empty() => {
234 match ::chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M") {
235 Ok(dt) => ::chrono::DateTime::<::chrono::Utc>::from_naive_utc_and_offset(dt, ::chrono::Utc),
236 Err(_) => { errors.push(#date_invalid_msg.to_string()); ::chrono::Utc::now() }
237 }
238 }
239 _ => { errors.push(#required_msg.to_string()); ::chrono::Utc::now() }
240 };
241 });
242 from_form_fields.push(quote! { #fname });
243 }
244 FieldKind::DateTimeAuto => {
245 from_form_parses.push(quote! {
247 let #fname = ::chrono::Utc::now();
248 });
249 from_form_fields.push(quote! { #fname });
250 }
251 }
252
253 update_tuples.push(quote! {
254 (#fname_str, self.#fname.clone().into())
255 });
256 }
257
258 let object_label_expr = find_label_field(fields)
259 .map(|n| {
260 let id = format_ident!("{n}");
261 quote! { self.#id.clone().to_string() }
262 })
263 .unwrap_or_else(|| quote! { format!("#{}", self.id) });
264
265 Ok(quote! {
266 impl ::rustio_core::admin::AdminModel for #struct_name {
267 const ADMIN_NAME: &'static str = #admin_name;
268 const DISPLAY_NAME: &'static str = #display_name;
269 const SINGULAR_NAME: &'static str = #singular;
270 const FIELDS: &'static [::rustio_core::admin::AdminField] = &[
271 #(#field_metas),*
272 ];
273
274 fn display_values(&self) -> ::std::vec::Vec<(::std::string::String, ::std::string::String)> {
275 let mut out = ::std::vec::Vec::new();
276 #(#display_value_arms)*
277 out
278 }
279
280 fn from_form(form: &::rustio_core::http::FormData) -> ::std::result::Result<Self, ::std::vec::Vec<::std::string::String>>
281 where
282 Self: Sized,
283 {
284 let mut errors: ::std::vec::Vec<::std::string::String> = ::std::vec::Vec::new();
285 #(#from_form_parses)*
286 if !errors.is_empty() {
287 return Err(errors);
288 }
289 Ok(Self { #(#from_form_fields),* })
290 }
291
292 fn object_label(&self) -> ::std::string::String {
293 #object_label_expr
294 }
295
296 fn id(&self) -> i64 {
297 self.id
298 }
299
300 fn values_to_update(&self) -> ::std::vec::Vec<(&'static str, ::rustio_core::orm::Value)> {
301 ::std::vec![#(#update_tuples),*]
302 }
303 }
304 })
305}
306
307fn struct_fields(input: &DeriveInput) -> syn::Result<&syn::punctuated::Punctuated<syn::Field, syn::Token![,]>> {
308 let data = match &input.data {
309 Data::Struct(s) => s,
310 _ => {
311 return Err(syn::Error::new_spanned(
312 &input.ident,
313 "RustioAdmin can only derive on structs",
314 ))
315 }
316 };
317 match &data.fields {
318 Fields::Named(named) => Ok(&named.named),
319 _ => Err(syn::Error::new_spanned(
320 &input.ident,
321 "RustioAdmin requires a struct with named fields",
322 )),
323 }
324}
325
326#[derive(Debug, PartialEq, Clone, Copy)]
327enum FieldKind {
328 I32,
329 I64,
330 Bool,
331 String,
332 DateTime,
333 DateTimeAuto,
334 OptionalString,
335 OptionalI64,
336}
337
338impl FieldKind {
339 fn field_type_ident(&self) -> proc_macro2::Ident {
340 match self {
341 FieldKind::I32 => format_ident!("I32"),
342 FieldKind::I64 => format_ident!("I64"),
343 FieldKind::Bool => format_ident!("Bool"),
344 FieldKind::String => format_ident!("String"),
345 FieldKind::DateTime | FieldKind::DateTimeAuto => format_ident!("DateTime"),
346 FieldKind::OptionalString => format_ident!("OptionalString"),
347 FieldKind::OptionalI64 => format_ident!("OptionalI64"),
348 }
349 }
350}
351
352fn is_auto_timestamp_name(name: &str) -> bool {
358 matches!(name, "created_at" | "updated_at")
359}
360
361fn humanise_field(s: &str) -> String {
366 let mut out = String::with_capacity(s.len());
367 let mut next_upper = true;
368 for ch in s.chars() {
369 if ch == '_' {
370 out.push(' ');
371 next_upper = true;
372 } else if next_upper {
373 out.push(ch.to_ascii_uppercase());
374 next_upper = false;
375 } else {
376 out.push(ch);
377 }
378 }
379 out
380}
381
382fn classify_type(ty: &syn::Type) -> syn::Result<FieldKind> {
383 let as_string = quote! { #ty }.to_string().replace(' ', "");
384 let kind = match as_string.as_str() {
385 "i32" => FieldKind::I32,
386 "i64" => FieldKind::I64,
387 "bool" => FieldKind::Bool,
388 "String" => FieldKind::String,
389 "DateTime<Utc>" | "chrono::DateTime<chrono::Utc>" => FieldKind::DateTime,
390 "Option<String>" => FieldKind::OptionalString,
391 "Option<i64>" => FieldKind::OptionalI64,
392 other => {
393 return Err(syn::Error::new_spanned(
394 ty,
395 format!("unsupported field type for RustioAdmin: {other}"),
396 ))
397 }
398 };
399 Ok(kind)
400}
401
402fn parse_relation_attr(
403 attrs: &[syn::Attribute],
404 field_name: &str,
405) -> syn::Result<Option<(String, Option<String>)>> {
406 for attr in attrs {
407 if !attr.path().is_ident("rustio") {
408 continue;
409 }
410 let mut target: Option<String> = None;
411 let mut display: Option<String> = None;
412 attr.parse_nested_meta(|m| {
413 if m.path.is_ident("belongs_to") {
414 let value = m.value()?;
415 let lit: Lit = value.parse()?;
416 if let Lit::Str(s) = lit {
417 target = Some(s.value());
418 }
419 Ok(())
420 } else if m.path.is_ident("display") {
421 let value = m.value()?;
422 let lit: Lit = value.parse()?;
423 if let Lit::Str(s) = lit {
424 display = Some(s.value());
425 }
426 Ok(())
427 } else {
428 Err(m.error(format!("unknown rustio attribute for field `{field_name}`")))
429 }
430 })?;
431 if let Some(t) = target {
432 return Ok(Some((t, display)));
433 }
434 if display.is_some() {
435 return Err(syn::Error::new_spanned(
436 attr,
437 "`display` requires `belongs_to` alongside it",
438 ));
439 }
440 }
441 let _ = std::marker::PhantomData::<Meta>;
443 Ok(None)
444}
445
446fn plural_snake(camel: &str) -> String {
447 let snake = camel_to_snake(camel);
448 if snake.ends_with('s') {
449 snake
450 } else {
451 format!("{snake}s")
452 }
453}
454
455fn camel_to_snake(s: &str) -> String {
456 let mut out = String::new();
457 for (i, c) in s.chars().enumerate() {
458 if c.is_ascii_uppercase() && i > 0 {
459 out.push('_');
460 }
461 out.push(c.to_ascii_lowercase());
462 }
463 out
464}
465
466fn humanise(snake: &str) -> String {
467 let mut chars = snake.chars();
469 let mut out = String::new();
470 if let Some(first) = chars.next() {
471 out.push(first.to_ascii_uppercase());
472 }
473 for c in chars {
474 if c == '_' {
475 out.push(' ');
476 } else {
477 out.push(c);
478 }
479 }
480 out
481}
482
483fn find_label_field(fields: &syn::punctuated::Punctuated<syn::Field, syn::Token![,]>) -> Option<String> {
484 let names = ["name", "title", "full_name", "label", "email"];
499 for candidate in names {
500 if fields
501 .iter()
502 .any(|f| f.ident.as_ref().map(|i| i == candidate).unwrap_or(false))
503 {
504 return Some(candidate.to_string());
505 }
506 }
507 None
508}
509
510#[proc_macro_derive(RustioModel, attributes(rustio))]
547pub fn derive_rustio_model(input: TokenStream) -> TokenStream {
548 let input = parse_macro_input!(input as DeriveInput);
549 rustio_model::expand(input)
550 .unwrap_or_else(|e| e.to_compile_error())
551 .into()
552}
553
554mod rustio_model {
555 use super::*;
556 use syn::{
557 parse::{Parse, ParseStream},
558 Attribute, GenericArgument, LitStr, PathArguments, Token, Type,
559 };
560
561 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
566 enum RustTypeKind {
567 I32,
568 I64,
569 F64,
570 Bool,
571 String,
572 DateTimeUtc,
573 JsonValue,
574 Decimal,
575 Uuid,
576 }
577
578 impl RustTypeKind {
579 fn to_token(self) -> TokenStream2 {
580 match self {
581 RustTypeKind::I32 => quote! { ::rustio_core::contract::RustType::I32 },
582 RustTypeKind::I64 => quote! { ::rustio_core::contract::RustType::I64 },
583 RustTypeKind::F64 => quote! { ::rustio_core::contract::RustType::F64 },
584 RustTypeKind::Bool => quote! { ::rustio_core::contract::RustType::Bool },
585 RustTypeKind::String => quote! { ::rustio_core::contract::RustType::String },
586 RustTypeKind::DateTimeUtc => {
587 quote! { ::rustio_core::contract::RustType::DateTimeUtc }
588 }
589 RustTypeKind::JsonValue => {
590 quote! { ::rustio_core::contract::RustType::JsonValue }
591 }
592 RustTypeKind::Decimal => quote! { ::rustio_core::contract::RustType::Decimal },
593 RustTypeKind::Uuid => quote! { ::rustio_core::contract::RustType::Uuid },
594 }
595 }
596 }
597
598 #[derive(Default)]
600 struct FieldAttr {
601 sql: String,
602 searchable: bool,
603 filterable: bool,
604 sortable: bool,
605 readonly: bool,
606 widget: Option<String>,
607 label: Option<String>,
608 references: Option<String>,
609 }
610
611 pub(super) fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
614 if !input.generics.params.is_empty() {
618 return Err(syn::Error::new_spanned(
619 &input.ident,
620 "RustioModel does not support generic structs (yet)",
621 ));
622 }
623 let struct_name = &input.ident;
624 let fields = match &input.data {
625 Data::Struct(ds) => match &ds.fields {
626 Fields::Named(f) => &f.named,
627 _ => {
628 return Err(syn::Error::new_spanned(
629 struct_name,
630 "RustioModel requires a named-field struct (no tuple structs)",
631 ));
632 }
633 },
634 _ => {
635 return Err(syn::Error::new_spanned(
636 struct_name,
637 "RustioModel can only be derived on structs",
638 ));
639 }
640 };
641
642 let table = parse_table_attr(&input.attrs)?;
644
645 let mut column_exprs = Vec::new();
647 let mut primary_key: Option<String> = None;
648 let mut has_searchable = false;
655
656 for field in fields {
657 let field_name = field
658 .ident
659 .as_ref()
660 .expect("named struct fields have idents")
661 .to_string();
662 let field_attr = parse_field_attr(&field.attrs)?;
663 if field_attr.sql.is_empty() {
664 return Err(syn::Error::new_spanned(
665 field,
666 format!("field `{field_name}` is missing the required `#[rustio(sql = \"...\")]` attribute"),
667 ));
668 }
669
670 let (kind, nullable) = classify(&field.ty)?;
671 validate_field_rules(&field_name, &field_attr.sql, kind, &field.ty)?;
672
673 let sql_upper = field_attr.sql.to_uppercase();
674 let is_pk = sql_upper.contains("PRIMARY KEY");
675 if is_pk {
676 if let Some(prev) = &primary_key {
677 return Err(syn::Error::new_spanned(
678 field,
679 format!(
680 "more than one field declares PRIMARY KEY: `{prev}` and `{field_name}`"
681 ),
682 ));
683 }
684 primary_key = Some(field_name.clone());
685 }
686
687 if field_attr.searchable {
688 has_searchable = true;
689 }
690
691 column_exprs.push(build_column_expr(&field_name, &field_attr, kind, nullable, is_pk));
692 }
693
694 let pk = primary_key.ok_or_else(|| {
695 syn::Error::new_spanned(
696 struct_name,
697 "RustioModel requires at least one field whose `sql = \"...\"` declares PRIMARY KEY",
698 )
699 })?;
700
701 let schema_init = if has_searchable {
726 quote! {
727 ::rustio_core::contract::ModelSchema::new(#table, __COLS, #pk)
728 .with_search_index(#table)
729 }
730 } else {
731 quote! {
732 ::rustio_core::contract::ModelSchema::new(#table, __COLS, #pk)
733 }
734 };
735
736 Ok(quote! {
737 impl ::rustio_core::contract::HasSchema for #struct_name {
738 const SCHEMA: ::rustio_core::contract::ModelSchema = {
739 const __COLS: &[::rustio_core::contract::ModelColumn] = &[
740 #(#column_exprs),*
741 ];
742 #schema_init
743 };
744 }
745 })
746 }
747
748 fn classify(ty: &Type) -> syn::Result<(RustTypeKind, bool)> {
752 if let Some(inner) = unwrap_option(ty) {
753 let (k, _) = classify(inner)?;
754 return Ok((k, true));
755 }
756
757 let path = match ty {
758 Type::Path(tp) => tp,
759 _ => {
760 return Err(syn::Error::new_spanned(
761 ty,
762 "RustioModel: unsupported type shape (need a simple path type)",
763 ));
764 }
765 };
766
767 let last = path
768 .path
769 .segments
770 .last()
771 .ok_or_else(|| syn::Error::new_spanned(ty, "RustioModel: empty type path"))?;
772 let name = last.ident.to_string();
773
774 if name == "DateTime" {
776 if let PathArguments::AngleBracketed(args) = &last.arguments {
777 let mut got_utc = false;
778 for arg in &args.args {
779 if let GenericArgument::Type(Type::Path(tp)) = arg {
780 if tp
781 .path
782 .segments
783 .last()
784 .map(|s| s.ident == "Utc")
785 .unwrap_or(false)
786 {
787 got_utc = true;
788 }
789 }
790 }
791 if got_utc {
792 return Ok((RustTypeKind::DateTimeUtc, false));
793 }
794 }
795 return Err(syn::Error::new_spanned(
796 ty,
797 "RustioModel: only `DateTime<Utc>` is supported (Type Rule #2). Other timezone parameters are not accepted.",
798 ));
799 }
800
801 if name == "NaiveDateTime" {
803 return Err(syn::Error::new_spanned(
804 ty,
805 "RustioModel: `NaiveDateTime` is forbidden (Type Rule #2) — use `chrono::DateTime<chrono::Utc>` for all timestamp columns",
806 ));
807 }
808
809 let kind = match name.as_str() {
813 "i32" => RustTypeKind::I32,
814 "i64" => RustTypeKind::I64,
815 "f64" => RustTypeKind::F64,
816 "bool" => RustTypeKind::Bool,
817 "String" => RustTypeKind::String,
818 "Value" => RustTypeKind::JsonValue, "Decimal" => RustTypeKind::Decimal, "Uuid" => RustTypeKind::Uuid, other => {
822 return Err(syn::Error::new_spanned(
823 ty,
824 format!(
825 "RustioModel: unsupported field type `{other}`. \
826 Supported: i32, i64, f64, bool, String, \
827 DateTime<Utc>, serde_json::Value, \
828 rust_decimal::Decimal, uuid::Uuid \
829 (and Option<T> for any of the above)."
830 ),
831 ));
832 }
833 };
834 Ok((kind, false))
835 }
836
837 fn unwrap_option(ty: &Type) -> Option<&Type> {
840 let path = match ty {
841 Type::Path(tp) => &tp.path,
842 _ => return None,
843 };
844 let last = path.segments.last()?;
845 if last.ident != "Option" {
846 return None;
847 }
848 let args = match &last.arguments {
849 PathArguments::AngleBracketed(a) => a,
850 _ => return None,
851 };
852 for arg in &args.args {
853 if let GenericArgument::Type(t) = arg {
854 return Some(t);
855 }
856 }
857 None
858 }
859
860 fn validate_field_rules(
863 name: &str,
864 sql: &str,
865 kind: RustTypeKind,
866 ty: &Type,
867 ) -> syn::Result<()> {
868 let sql_upper = sql.to_uppercase();
869
870 if name == "id" && kind != RustTypeKind::I64 {
872 return Err(syn::Error::new_spanned(
873 ty,
874 "Type Rule #1: field `id` must be `i64` (mapped to BIGINT/BIGSERIAL). \
875 Using a smaller integer type for IDs silently truncates at 2.1B rows.",
876 ));
877 }
878
879 let has_numeric_token = sql_upper
884 .split(|c: char| !c.is_alphanumeric())
885 .any(|t| t == "NUMERIC" || t == "DECIMAL");
886 if has_numeric_token && kind != RustTypeKind::Decimal {
887 return Err(syn::Error::new_spanned(
888 ty,
889 "Type Rule #3: NUMERIC/DECIMAL columns must pair with \
890 `rust_decimal::Decimal`. Using `f64` (or any other type) \
891 for money loses precision under arithmetic.",
892 ));
893 }
894
895 Ok(())
896 }
897
898 fn build_column_expr(
902 name: &str,
903 attr: &FieldAttr,
904 kind: RustTypeKind,
905 nullable: bool,
906 is_pk: bool,
907 ) -> TokenStream2 {
908 let name_lit = LitStr::new(name, proc_macro2::Span::call_site());
909 let sql_lit = LitStr::new(&attr.sql, proc_macro2::Span::call_site());
910 let kind_token = kind.to_token();
911
912 let mut expr = quote! {
913 ::rustio_core::contract::ModelColumn::new(#name_lit, #sql_lit, #kind_token)
914 };
915 if nullable {
916 expr = quote! { #expr.nullable() };
917 }
918 if is_pk {
919 expr = quote! { #expr.primary_key() };
920 }
921
922 let s = attr.searchable;
942 let f = attr.filterable;
943 let so = attr.sortable;
944 let r = attr.readonly;
945 let flags_expr = if !s && !f && !so && !r {
946 quote! { ::rustio_core::contract::SchemaFlags::empty() }
947 } else {
948 let mut mutations = Vec::new();
949 if s {
950 mutations.push(quote! { __f.searchable = true; });
951 }
952 if f {
953 mutations.push(quote! { __f.filterable = true; });
954 }
955 if so {
956 mutations.push(quote! { __f.sortable = true; });
957 }
958 if r {
959 mutations.push(quote! { __f.readonly = true; });
960 }
961 quote! {
962 {
963 let mut __f = ::rustio_core::contract::SchemaFlags::empty();
964 #(#mutations)*
965 __f
966 }
967 }
968 };
969 expr = quote! { #expr.with_flags(#flags_expr) };
970
971 if let Some(label) = &attr.label {
972 let l = LitStr::new(label, proc_macro2::Span::call_site());
973 expr = quote! { #expr.with_label(#l) };
974 }
975 if let Some(widget) = &attr.widget {
976 let w = LitStr::new(widget, proc_macro2::Span::call_site());
977 expr = quote! { #expr.with_widget(#w) };
978 }
979 let _ = &attr.references;
987
988 expr
989 }
990
991 fn parse_table_attr(attrs: &[Attribute]) -> syn::Result<String> {
993 for attr in attrs {
994 if !attr.path().is_ident("rustio") {
995 continue;
996 }
997 let parsed: TableAttr = attr.parse_args()?;
998 return Ok(parsed.table);
999 }
1000 Err(syn::Error::new(
1001 proc_macro2::Span::call_site(),
1002 "RustioModel requires a `#[rustio(table = \"...\")]` attribute on the struct",
1003 ))
1004 }
1005
1006 fn parse_field_attr(attrs: &[Attribute]) -> syn::Result<FieldAttr> {
1009 let mut out = FieldAttr::default();
1010 for attr in attrs {
1011 if !attr.path().is_ident("rustio") {
1012 continue;
1013 }
1014 let parsed: FieldAttrTokens = attr.parse_args()?;
1015 for entry in parsed.entries {
1020 match entry {
1021 AttrEntry::Sql(s) => out.sql = s,
1022 AttrEntry::Searchable => out.searchable = true,
1023 AttrEntry::Filterable => out.filterable = true,
1024 AttrEntry::Sortable => out.sortable = true,
1025 AttrEntry::Readonly => out.readonly = true,
1026 AttrEntry::Widget(s) => out.widget = Some(s),
1027 AttrEntry::Label(s) => out.label = Some(s),
1028 AttrEntry::References(s) => out.references = Some(s),
1029 }
1030 }
1031 }
1032 Ok(out)
1033 }
1034
1035 struct TableAttr {
1038 table: String,
1039 }
1040 impl Parse for TableAttr {
1041 fn parse(input: ParseStream) -> syn::Result<Self> {
1042 let key: syn::Ident = input.parse()?;
1043 if key != "table" {
1044 return Err(syn::Error::new(
1045 key.span(),
1046 "expected `table = \"...\"` on the struct",
1047 ));
1048 }
1049 input.parse::<Token![=]>()?;
1050 let value: LitStr = input.parse()?;
1051 Ok(Self { table: value.value() })
1054 }
1055 }
1056
1057 enum AttrEntry {
1058 Sql(String),
1059 Searchable,
1060 Filterable,
1061 Sortable,
1062 Readonly,
1063 Widget(String),
1064 Label(String),
1065 References(String),
1066 }
1067
1068 struct FieldAttrTokens {
1069 entries: Vec<AttrEntry>,
1070 }
1071 impl Parse for FieldAttrTokens {
1072 fn parse(input: ParseStream) -> syn::Result<Self> {
1073 let mut entries = Vec::new();
1074 loop {
1075 if input.is_empty() {
1076 break;
1077 }
1078 let key: syn::Ident = input.parse()?;
1079 let key_str = key.to_string();
1080 let entry = match key_str.as_str() {
1081 "sql" => {
1082 input.parse::<Token![=]>()?;
1083 AttrEntry::Sql(input.parse::<LitStr>()?.value())
1084 }
1085 "searchable" => AttrEntry::Searchable,
1086 "filterable" => AttrEntry::Filterable,
1087 "sortable" => AttrEntry::Sortable,
1088 "readonly" => AttrEntry::Readonly,
1089 "widget" => {
1090 input.parse::<Token![=]>()?;
1091 AttrEntry::Widget(input.parse::<LitStr>()?.value())
1092 }
1093 "label" => {
1094 input.parse::<Token![=]>()?;
1095 AttrEntry::Label(input.parse::<LitStr>()?.value())
1096 }
1097 "references" => {
1098 input.parse::<Token![=]>()?;
1099 AttrEntry::References(input.parse::<LitStr>()?.value())
1100 }
1101 other => {
1102 return Err(syn::Error::new(
1103 key.span(),
1104 format!(
1105 "unknown rustio attribute `{other}`. \
1106 Known: sql, searchable, filterable, sortable, \
1107 readonly, widget, label, references."
1108 ),
1109 ));
1110 }
1111 };
1112 entries.push(entry);
1113 if input.is_empty() {
1114 break;
1115 }
1116 input.parse::<Token![,]>()?;
1117 }
1118 Ok(Self { entries })
1119 }
1120 }
1121}