diff --git a/.changepacks/changepack_log_EOqDZl3jRWhOYS6fCfVK9.json b/.changepacks/changepack_log_EOqDZl3jRWhOYS6fCfVK9.json new file mode 100644 index 0000000..b290977 --- /dev/null +++ b/.changepacks/changepack_log_EOqDZl3jRWhOYS6fCfVK9.json @@ -0,0 +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"} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index f6380c4..22af57c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3073,7 +3073,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.25" +version = "0.1.26" dependencies = [ "axum", "axum-extra", @@ -3086,7 +3086,7 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.25" +version = "0.1.26" dependencies = [ "rstest", "serde", @@ -3095,7 +3095,7 @@ dependencies = [ [[package]] name = "vespera_macro" -version = "0.1.25" +version = "0.1.26" dependencies = [ "anyhow", "insta", diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index b243380..9733a90 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -171,6 +171,8 @@ pub fn schema(input: TokenStream) -> TokenStream { /// - `pick = [...]`: List of field names to include (excludes all others) /// - `omit = [...]`: List of field names to exclude /// - `clone = bool`: Whether to derive Clone (default: true) +/// - `partial`: Make all fields `Option` (fields already `Option` are unchanged) +/// - `partial = [...]`: Make only listed fields `Option` /// /// Note: `omit` and `pick` cannot be used together. /// diff --git a/crates/vespera_macro/src/schema_macro.rs b/crates/vespera_macro/src/schema_macro.rs index 4608bfe..dc5381f 100644 --- a/crates/vespera_macro/src/schema_macro.rs +++ b/crates/vespera_macro/src/schema_macro.rs @@ -469,6 +469,21 @@ pub struct SchemaTypeInput { pub add: Option>, /// Whether to derive Clone (default: true) pub derive_clone: bool, + /// Fields to wrap in `Option` for partial updates. + /// + /// - `partial` (bare) = all fields become `Option` + /// - `partial = ["field1", "field2"]` = only listed fields become `Option` + /// - Fields already `Option` are left unchanged. + pub partial: Option, +} + +/// Mode for the `partial` keyword in schema_type! +#[derive(Clone, Debug)] +pub enum PartialMode { + /// All fields become Option + All, + /// Only listed fields become Option + Fields(Vec), } /// Helper struct to parse an add field: ("field_name": Type) @@ -533,6 +548,7 @@ impl Parse for SchemaTypeInput { let mut rename = None; let mut add = None; let mut derive_clone = true; + let mut partial = None; // Parse optional parameters while input.peek(Token![,]) { @@ -583,11 +599,27 @@ impl Parse for SchemaTypeInput { let value: syn::LitBool = input.parse()?; derive_clone = value.value(); } + "partial" => { + if input.peek(Token![=]) { + // partial = ["field1", "field2"] + input.parse::()?; + let content; + let _ = bracketed!(content in input); + let fields: Punctuated = + content.parse_terminated(|input| input.parse::(), Token![,])?; + partial = Some(PartialMode::Fields( + fields.into_iter().map(|s| s.value()).collect(), + )); + } else { + // bare `partial` — all fields + partial = Some(PartialMode::All); + } + } _ => { return Err(syn::Error::new( ident.span(), format!( - "unknown parameter: `{}`. Expected `omit`, `pick`, `rename`, `add`, or `clone`", + "unknown parameter: `{}`. Expected `omit`, `pick`, `rename`, `add`, `clone`, or `partial`", ident_str ), )); @@ -611,6 +643,7 @@ impl Parse for SchemaTypeInput { rename, add, derive_clone, + partial, }) } } @@ -719,12 +752,36 @@ pub fn generate_schema_type_code( } } + // Validate partial fields exist (when specific fields are listed) + if let Some(PartialMode::Fields(ref partial_fields)) = input.partial { + for field in partial_fields { + if !source_field_names.contains(field) { + return Err(syn::Error::new_spanned( + &input.source_type, + format!( + "partial field `{}` does not exist in type `{}`. Available fields: {:?}", + field, + source_type_name, + source_field_names.iter().collect::>() + ), + )); + } + } + } + // Build omit set (use Rust field names) let omit_set: HashSet = input.omit.clone().unwrap_or_default().into_iter().collect(); // Build pick set (use Rust field names) let pick_set: HashSet = input.pick.clone().unwrap_or_default().into_iter().collect(); + // Build partial set + let partial_all = matches!(input.partial, Some(PartialMode::All)); + let partial_set: HashSet = match &input.partial { + Some(PartialMode::Fields(fields)) => fields.iter().cloned().collect(), + _ => HashSet::new(), + }; + // Build rename map: source_field_name -> new_field_name let rename_map: std::collections::HashMap = input .rename @@ -743,8 +800,8 @@ pub fn generate_schema_type_code( // Generate new struct with filtered fields let new_type_name = &input.new_type; let mut field_tokens = Vec::new(); - // Track field mappings for From impl: (new_field_ident, source_field_ident) - let mut field_mappings: Vec<(syn::Ident, syn::Ident)> = Vec::new(); + // Track field mappings for From impl: (new_field_ident, source_field_ident, wrapped_in_option) + let mut field_mappings: Vec<(syn::Ident, syn::Ident, bool)> = Vec::new(); if let syn::Fields::Named(fields_named) = &parsed_struct.fields { for field in &fields_named.named { @@ -764,8 +821,15 @@ pub fn generate_schema_type_code( continue; } - // Get field components - let field_ty = &field.ty; + // Get field components, applying partial wrapping if needed + let original_ty = &field.ty; + let should_wrap_option = (partial_all || partial_set.contains(&rust_field_name)) + && !is_option_type(original_ty); + let field_ty: Box = if should_wrap_option { + Box::new(quote! { Option<#original_ty> }) + } else { + Box::new(quote! { #original_ty }) + }; let vis = &field.vis; let source_field_ident = field.ident.clone().unwrap(); @@ -811,7 +875,7 @@ pub fn generate_schema_type_code( }); // Track mapping: new field name <- source field name - field_mappings.push((new_field_ident, source_field_ident)); + field_mappings.push((new_field_ident, source_field_ident, should_wrap_option)); } else { // No rename, keep field with only serde attrs let field_ident = field.ident.clone().unwrap(); @@ -822,7 +886,7 @@ pub fn generate_schema_type_code( }); // Track mapping: same name - field_mappings.push((field_ident.clone(), field_ident)); + field_mappings.push((field_ident.clone(), field_ident, should_wrap_option)); } } } @@ -849,8 +913,12 @@ pub fn generate_schema_type_code( let from_impl = if input.add.is_none() { let field_assignments: Vec<_> = field_mappings .iter() - .map(|(new_ident, source_ident)| { - quote! { #new_ident: source.#source_ident } + .map(|(new_ident, source_ident, wrapped)| { + if *wrapped { + quote! { #new_ident: Some(source.#source_ident) } + } else { + quote! { #new_ident: source.#source_ident } + } }) .collect(); @@ -1051,6 +1119,119 @@ mod tests { assert_eq!(add[0].0, "tags"); } + // Tests for `partial` parameter + + #[test] + fn test_parse_schema_type_input_with_partial_all() { + let tokens = quote::quote!(UpdateUser from User, partial); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + assert!(matches!(input.partial, Some(PartialMode::All))); + } + + #[test] + fn test_parse_schema_type_input_with_partial_fields() { + let tokens = quote::quote!(UpdateUser from User, partial = ["name", "email"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + match input.partial { + Some(PartialMode::Fields(fields)) => { + assert_eq!(fields, vec!["name", "email"]); + } + _ => panic!("Expected PartialMode::Fields"), + } + } + + #[test] + fn test_parse_schema_type_input_with_pick_and_partial() { + let tokens = quote::quote!(UpdateUser from User, pick = ["name", "email"], partial); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.pick.unwrap(), vec!["name", "email"]); + assert!(matches!(input.partial, Some(PartialMode::All))); + } + + #[test] + fn test_parse_schema_type_input_with_pick_and_partial_fields() { + let tokens = + quote::quote!(UpdateUser from User, pick = ["name", "email"], partial = ["name"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.pick.unwrap(), vec!["name", "email"]); + match input.partial { + Some(PartialMode::Fields(fields)) => { + assert_eq!(fields, vec!["name"]); + } + _ => panic!("Expected PartialMode::Fields"), + } + } + + #[test] + fn test_generate_schema_type_code_with_partial_all() { + let storage = vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String, pub bio: Option }", + )]; + + let tokens = quote::quote!(UpdateUser from User, partial); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let output = result.unwrap().to_string(); + // id and name should be wrapped in Option, bio already Option stays unchanged + assert!(output.contains("Option < i32 >")); + assert!(output.contains("Option < String >")); + } + + #[test] + fn test_generate_schema_type_code_with_partial_fields() { + let storage = vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String, pub email: String }", + )]; + + let tokens = quote::quote!(UpdateUser from User, partial = ["name"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let output = result.unwrap().to_string(); + // name should be Option, but id and email should remain unwrapped + assert!(output.contains("UpdateUser")); + } + + #[test] + fn test_generate_schema_type_code_partial_nonexistent_field() { + let storage = vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]; + + let tokens = quote::quote!(UpdateUser from User, partial = ["nonexistent"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("does not exist")); + assert!(err.contains("nonexistent")); + } + + #[test] + fn test_generate_schema_type_code_partial_from_impl_wraps_some() { + let storage = vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]; + + let tokens = quote::quote!(UpdateUser from User, partial); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let output = result.unwrap().to_string(); + // From impl should wrap values in Some() + assert!(output.contains("Some (source . id)")); + assert!(output.contains("Some (source . name)")); + } + // ========================================================================= // Tests for generate_schema_code() - success paths // =========================================================================