Skip to content

Commit ae8cce3

Browse files
authored
Merge pull request #59 from dev-five-git/remain-serde
schema serde
2 parents f2b7c95 + 8adee6a commit ae8cce3

4 files changed

Lines changed: 196 additions & 12 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"changes":{"crates/vespera_core/Cargo.toml":"Patch","crates/vespera_macro/Cargo.toml":"Patch","crates/vespera/Cargo.toml":"Patch"},"note":"Remain serde by schema_type macro","date":"2026-01-30T09:35:47.612796500Z"}

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vespera_macro/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ pub fn schema(input: TokenStream) -> TokenStream {
171171
/// - `pick = [...]`: List of field names to include (excludes all others)
172172
/// - `omit = [...]`: List of field names to exclude
173173
/// - `clone = bool`: Whether to derive Clone (default: true)
174+
/// - `partial`: Make all fields `Option<T>` (fields already `Option<T>` are unchanged)
175+
/// - `partial = [...]`: Make only listed fields `Option<T>`
174176
///
175177
/// Note: `omit` and `pick` cannot be used together.
176178
///

crates/vespera_macro/src/schema_macro.rs

Lines changed: 190 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,21 @@ pub struct SchemaTypeInput {
469469
pub add: Option<Vec<(String, Type)>>,
470470
/// Whether to derive Clone (default: true)
471471
pub derive_clone: bool,
472+
/// Fields to wrap in `Option<T>` for partial updates.
473+
///
474+
/// - `partial` (bare) = all fields become `Option<T>`
475+
/// - `partial = ["field1", "field2"]` = only listed fields become `Option<T>`
476+
/// - Fields already `Option<T>` are left unchanged.
477+
pub partial: Option<PartialMode>,
478+
}
479+
480+
/// Mode for the `partial` keyword in schema_type!
481+
#[derive(Clone, Debug)]
482+
pub enum PartialMode {
483+
/// All fields become Option<T>
484+
All,
485+
/// Only listed fields become Option<T>
486+
Fields(Vec<String>),
472487
}
473488

474489
/// Helper struct to parse an add field: ("field_name": Type)
@@ -533,6 +548,7 @@ impl Parse for SchemaTypeInput {
533548
let mut rename = None;
534549
let mut add = None;
535550
let mut derive_clone = true;
551+
let mut partial = None;
536552

537553
// Parse optional parameters
538554
while input.peek(Token![,]) {
@@ -583,11 +599,27 @@ impl Parse for SchemaTypeInput {
583599
let value: syn::LitBool = input.parse()?;
584600
derive_clone = value.value();
585601
}
602+
"partial" => {
603+
if input.peek(Token![=]) {
604+
// partial = ["field1", "field2"]
605+
input.parse::<Token![=]>()?;
606+
let content;
607+
let _ = bracketed!(content in input);
608+
let fields: Punctuated<LitStr, Token![,]> =
609+
content.parse_terminated(|input| input.parse::<LitStr>(), Token![,])?;
610+
partial = Some(PartialMode::Fields(
611+
fields.into_iter().map(|s| s.value()).collect(),
612+
));
613+
} else {
614+
// bare `partial` — all fields
615+
partial = Some(PartialMode::All);
616+
}
617+
}
586618
_ => {
587619
return Err(syn::Error::new(
588620
ident.span(),
589621
format!(
590-
"unknown parameter: `{}`. Expected `omit`, `pick`, `rename`, `add`, or `clone`",
622+
"unknown parameter: `{}`. Expected `omit`, `pick`, `rename`, `add`, `clone`, or `partial`",
591623
ident_str
592624
),
593625
));
@@ -611,6 +643,7 @@ impl Parse for SchemaTypeInput {
611643
rename,
612644
add,
613645
derive_clone,
646+
partial,
614647
})
615648
}
616649
}
@@ -719,12 +752,36 @@ pub fn generate_schema_type_code(
719752
}
720753
}
721754

755+
// Validate partial fields exist (when specific fields are listed)
756+
if let Some(PartialMode::Fields(ref partial_fields)) = input.partial {
757+
for field in partial_fields {
758+
if !source_field_names.contains(field) {
759+
return Err(syn::Error::new_spanned(
760+
&input.source_type,
761+
format!(
762+
"partial field `{}` does not exist in type `{}`. Available fields: {:?}",
763+
field,
764+
source_type_name,
765+
source_field_names.iter().collect::<Vec<_>>()
766+
),
767+
));
768+
}
769+
}
770+
}
771+
722772
// Build omit set (use Rust field names)
723773
let omit_set: HashSet<String> = input.omit.clone().unwrap_or_default().into_iter().collect();
724774

725775
// Build pick set (use Rust field names)
726776
let pick_set: HashSet<String> = input.pick.clone().unwrap_or_default().into_iter().collect();
727777

778+
// Build partial set
779+
let partial_all = matches!(input.partial, Some(PartialMode::All));
780+
let partial_set: HashSet<String> = match &input.partial {
781+
Some(PartialMode::Fields(fields)) => fields.iter().cloned().collect(),
782+
_ => HashSet::new(),
783+
};
784+
728785
// Build rename map: source_field_name -> new_field_name
729786
let rename_map: std::collections::HashMap<String, String> = input
730787
.rename
@@ -743,8 +800,8 @@ pub fn generate_schema_type_code(
743800
// Generate new struct with filtered fields
744801
let new_type_name = &input.new_type;
745802
let mut field_tokens = Vec::new();
746-
// Track field mappings for From impl: (new_field_ident, source_field_ident)
747-
let mut field_mappings: Vec<(syn::Ident, syn::Ident)> = Vec::new();
803+
// Track field mappings for From impl: (new_field_ident, source_field_ident, wrapped_in_option)
804+
let mut field_mappings: Vec<(syn::Ident, syn::Ident, bool)> = Vec::new();
748805

749806
if let syn::Fields::Named(fields_named) = &parsed_struct.fields {
750807
for field in &fields_named.named {
@@ -764,8 +821,15 @@ pub fn generate_schema_type_code(
764821
continue;
765822
}
766823

767-
// Get field components
768-
let field_ty = &field.ty;
824+
// Get field components, applying partial wrapping if needed
825+
let original_ty = &field.ty;
826+
let should_wrap_option = (partial_all || partial_set.contains(&rust_field_name))
827+
&& !is_option_type(original_ty);
828+
let field_ty: Box<dyn quote::ToTokens> = if should_wrap_option {
829+
Box::new(quote! { Option<#original_ty> })
830+
} else {
831+
Box::new(quote! { #original_ty })
832+
};
769833
let vis = &field.vis;
770834
let source_field_ident = field.ident.clone().unwrap();
771835

@@ -811,7 +875,7 @@ pub fn generate_schema_type_code(
811875
});
812876

813877
// Track mapping: new field name <- source field name
814-
field_mappings.push((new_field_ident, source_field_ident));
878+
field_mappings.push((new_field_ident, source_field_ident, should_wrap_option));
815879
} else {
816880
// No rename, keep field with only serde attrs
817881
let field_ident = field.ident.clone().unwrap();
@@ -822,7 +886,7 @@ pub fn generate_schema_type_code(
822886
});
823887

824888
// Track mapping: same name
825-
field_mappings.push((field_ident.clone(), field_ident));
889+
field_mappings.push((field_ident.clone(), field_ident, should_wrap_option));
826890
}
827891
}
828892
}
@@ -849,8 +913,12 @@ pub fn generate_schema_type_code(
849913
let from_impl = if input.add.is_none() {
850914
let field_assignments: Vec<_> = field_mappings
851915
.iter()
852-
.map(|(new_ident, source_ident)| {
853-
quote! { #new_ident: source.#source_ident }
916+
.map(|(new_ident, source_ident, wrapped)| {
917+
if *wrapped {
918+
quote! { #new_ident: Some(source.#source_ident) }
919+
} else {
920+
quote! { #new_ident: source.#source_ident }
921+
}
854922
})
855923
.collect();
856924

@@ -1051,6 +1119,119 @@ mod tests {
10511119
assert_eq!(add[0].0, "tags");
10521120
}
10531121

1122+
// Tests for `partial` parameter
1123+
1124+
#[test]
1125+
fn test_parse_schema_type_input_with_partial_all() {
1126+
let tokens = quote::quote!(UpdateUser from User, partial);
1127+
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
1128+
assert!(matches!(input.partial, Some(PartialMode::All)));
1129+
}
1130+
1131+
#[test]
1132+
fn test_parse_schema_type_input_with_partial_fields() {
1133+
let tokens = quote::quote!(UpdateUser from User, partial = ["name", "email"]);
1134+
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
1135+
match input.partial {
1136+
Some(PartialMode::Fields(fields)) => {
1137+
assert_eq!(fields, vec!["name", "email"]);
1138+
}
1139+
_ => panic!("Expected PartialMode::Fields"),
1140+
}
1141+
}
1142+
1143+
#[test]
1144+
fn test_parse_schema_type_input_with_pick_and_partial() {
1145+
let tokens = quote::quote!(UpdateUser from User, pick = ["name", "email"], partial);
1146+
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
1147+
assert_eq!(input.pick.unwrap(), vec!["name", "email"]);
1148+
assert!(matches!(input.partial, Some(PartialMode::All)));
1149+
}
1150+
1151+
#[test]
1152+
fn test_parse_schema_type_input_with_pick_and_partial_fields() {
1153+
let tokens =
1154+
quote::quote!(UpdateUser from User, pick = ["name", "email"], partial = ["name"]);
1155+
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
1156+
assert_eq!(input.pick.unwrap(), vec!["name", "email"]);
1157+
match input.partial {
1158+
Some(PartialMode::Fields(fields)) => {
1159+
assert_eq!(fields, vec!["name"]);
1160+
}
1161+
_ => panic!("Expected PartialMode::Fields"),
1162+
}
1163+
}
1164+
1165+
#[test]
1166+
fn test_generate_schema_type_code_with_partial_all() {
1167+
let storage = vec![create_test_struct_metadata(
1168+
"User",
1169+
"pub struct User { pub id: i32, pub name: String, pub bio: Option<String> }",
1170+
)];
1171+
1172+
let tokens = quote::quote!(UpdateUser from User, partial);
1173+
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
1174+
let result = generate_schema_type_code(&input, &storage);
1175+
1176+
assert!(result.is_ok());
1177+
let output = result.unwrap().to_string();
1178+
// id and name should be wrapped in Option, bio already Option stays unchanged
1179+
assert!(output.contains("Option < i32 >"));
1180+
assert!(output.contains("Option < String >"));
1181+
}
1182+
1183+
#[test]
1184+
fn test_generate_schema_type_code_with_partial_fields() {
1185+
let storage = vec![create_test_struct_metadata(
1186+
"User",
1187+
"pub struct User { pub id: i32, pub name: String, pub email: String }",
1188+
)];
1189+
1190+
let tokens = quote::quote!(UpdateUser from User, partial = ["name"]);
1191+
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
1192+
let result = generate_schema_type_code(&input, &storage);
1193+
1194+
assert!(result.is_ok());
1195+
let output = result.unwrap().to_string();
1196+
// name should be Option<String>, but id and email should remain unwrapped
1197+
assert!(output.contains("UpdateUser"));
1198+
}
1199+
1200+
#[test]
1201+
fn test_generate_schema_type_code_partial_nonexistent_field() {
1202+
let storage = vec![create_test_struct_metadata(
1203+
"User",
1204+
"pub struct User { pub id: i32, pub name: String }",
1205+
)];
1206+
1207+
let tokens = quote::quote!(UpdateUser from User, partial = ["nonexistent"]);
1208+
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
1209+
let result = generate_schema_type_code(&input, &storage);
1210+
1211+
assert!(result.is_err());
1212+
let err = result.unwrap_err().to_string();
1213+
assert!(err.contains("does not exist"));
1214+
assert!(err.contains("nonexistent"));
1215+
}
1216+
1217+
#[test]
1218+
fn test_generate_schema_type_code_partial_from_impl_wraps_some() {
1219+
let storage = vec![create_test_struct_metadata(
1220+
"User",
1221+
"pub struct User { pub id: i32, pub name: String }",
1222+
)];
1223+
1224+
let tokens = quote::quote!(UpdateUser from User, partial);
1225+
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
1226+
let result = generate_schema_type_code(&input, &storage);
1227+
1228+
assert!(result.is_ok());
1229+
let output = result.unwrap().to_string();
1230+
// From impl should wrap values in Some()
1231+
assert!(output.contains("Some (source . id)"));
1232+
assert!(output.contains("Some (source . name)"));
1233+
}
1234+
10541235
// =========================================================================
10551236
// Tests for generate_schema_code() - success paths
10561237
// =========================================================================

0 commit comments

Comments
 (0)