@@ -9,7 +9,7 @@ use quote::quote;
99use std:: collections:: HashSet ;
1010use std:: path:: Path ;
1111use syn:: punctuated:: Punctuated ;
12- use syn:: { Ident , LitStr , Token , Type , bracketed, parenthesized, parse:: Parse , parse:: ParseStream } ;
12+ use syn:: { bracketed, parenthesized, parse:: Parse , parse:: ParseStream , Ident , LitStr , Token , Type } ;
1313
1414use crate :: metadata:: StructMetadata ;
1515use crate :: parser:: {
@@ -469,6 +469,20 @@ 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+ /// - `partial` (bare) = all fields become Option<T>
474+ /// - `partial = ["field1", "field2"]` = only listed fields become Option<T>
475+ /// Fields already Option<T> are left unchanged.
476+ pub partial : Option < PartialMode > ,
477+ }
478+
479+ /// Mode for the `partial` keyword in schema_type!
480+ #[ derive( Clone , Debug ) ]
481+ pub enum PartialMode {
482+ /// All fields become Option<T>
483+ All ,
484+ /// Only listed fields become Option<T>
485+ Fields ( Vec < String > ) ,
472486}
473487
474488/// Helper struct to parse an add field: ("field_name": Type)
@@ -533,6 +547,7 @@ impl Parse for SchemaTypeInput {
533547 let mut rename = None ;
534548 let mut add = None ;
535549 let mut derive_clone = true ;
550+ let mut partial = None ;
536551
537552 // Parse optional parameters
538553 while input. peek ( Token ! [ , ] ) {
@@ -583,11 +598,27 @@ impl Parse for SchemaTypeInput {
583598 let value: syn:: LitBool = input. parse ( ) ?;
584599 derive_clone = value. value ( ) ;
585600 }
601+ "partial" => {
602+ if input. peek ( Token ! [ =] ) {
603+ // partial = ["field1", "field2"]
604+ input. parse :: < Token ! [ =] > ( ) ?;
605+ let content;
606+ let _ = bracketed ! ( content in input) ;
607+ let fields: Punctuated < LitStr , Token ! [ , ] > =
608+ content. parse_terminated ( |input| input. parse :: < LitStr > ( ) , Token ! [ , ] ) ?;
609+ partial = Some ( PartialMode :: Fields (
610+ fields. into_iter ( ) . map ( |s| s. value ( ) ) . collect ( ) ,
611+ ) ) ;
612+ } else {
613+ // bare `partial` — all fields
614+ partial = Some ( PartialMode :: All ) ;
615+ }
616+ }
586617 _ => {
587618 return Err ( syn:: Error :: new (
588619 ident. span ( ) ,
589620 format ! (
590- "unknown parameter: `{}`. Expected `omit`, `pick`, `rename`, `add`, or `clone `" ,
621+ "unknown parameter: `{}`. Expected `omit`, `pick`, `rename`, `add`, `clone`, or `partial `" ,
591622 ident_str
592623 ) ,
593624 ) ) ;
@@ -611,6 +642,7 @@ impl Parse for SchemaTypeInput {
611642 rename,
612643 add,
613644 derive_clone,
645+ partial,
614646 } )
615647 }
616648}
@@ -719,12 +751,36 @@ pub fn generate_schema_type_code(
719751 }
720752 }
721753
754+ // Validate partial fields exist (when specific fields are listed)
755+ if let Some ( PartialMode :: Fields ( ref partial_fields) ) = input. partial {
756+ for field in partial_fields {
757+ if !source_field_names. contains ( field) {
758+ return Err ( syn:: Error :: new_spanned (
759+ & input. source_type ,
760+ format ! (
761+ "partial field `{}` does not exist in type `{}`. Available fields: {:?}" ,
762+ field,
763+ source_type_name,
764+ source_field_names. iter( ) . collect:: <Vec <_>>( )
765+ ) ,
766+ ) ) ;
767+ }
768+ }
769+ }
770+
722771 // Build omit set (use Rust field names)
723772 let omit_set: HashSet < String > = input. omit . clone ( ) . unwrap_or_default ( ) . into_iter ( ) . collect ( ) ;
724773
725774 // Build pick set (use Rust field names)
726775 let pick_set: HashSet < String > = input. pick . clone ( ) . unwrap_or_default ( ) . into_iter ( ) . collect ( ) ;
727776
777+ // Build partial set
778+ let partial_all = matches ! ( input. partial, Some ( PartialMode :: All ) ) ;
779+ let partial_set: HashSet < String > = match & input. partial {
780+ Some ( PartialMode :: Fields ( fields) ) => fields. iter ( ) . cloned ( ) . collect ( ) ,
781+ _ => HashSet :: new ( ) ,
782+ } ;
783+
728784 // Build rename map: source_field_name -> new_field_name
729785 let rename_map: std:: collections:: HashMap < String , String > = input
730786 . rename
@@ -743,8 +799,8 @@ pub fn generate_schema_type_code(
743799 // Generate new struct with filtered fields
744800 let new_type_name = & input. new_type ;
745801 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 ( ) ;
802+ // Track field mappings for From impl: (new_field_ident, source_field_ident, wrapped_in_option )
803+ let mut field_mappings: Vec < ( syn:: Ident , syn:: Ident , bool ) > = Vec :: new ( ) ;
748804
749805 if let syn:: Fields :: Named ( fields_named) = & parsed_struct. fields {
750806 for field in & fields_named. named {
@@ -764,8 +820,15 @@ pub fn generate_schema_type_code(
764820 continue ;
765821 }
766822
767- // Get field components
768- let field_ty = & field. ty ;
823+ // Get field components, applying partial wrapping if needed
824+ let original_ty = & field. ty ;
825+ let should_wrap_option = ( partial_all || partial_set. contains ( & rust_field_name) )
826+ && !is_option_type ( original_ty) ;
827+ let field_ty: Box < dyn quote:: ToTokens > = if should_wrap_option {
828+ Box :: new ( quote ! { Option <#original_ty> } )
829+ } else {
830+ Box :: new ( quote ! { #original_ty } )
831+ } ;
769832 let vis = & field. vis ;
770833 let source_field_ident = field. ident . clone ( ) . unwrap ( ) ;
771834
@@ -811,7 +874,7 @@ pub fn generate_schema_type_code(
811874 } ) ;
812875
813876 // Track mapping: new field name <- source field name
814- field_mappings. push ( ( new_field_ident, source_field_ident) ) ;
877+ field_mappings. push ( ( new_field_ident, source_field_ident, should_wrap_option ) ) ;
815878 } else {
816879 // No rename, keep field with only serde attrs
817880 let field_ident = field. ident . clone ( ) . unwrap ( ) ;
@@ -822,7 +885,7 @@ pub fn generate_schema_type_code(
822885 } ) ;
823886
824887 // Track mapping: same name
825- field_mappings. push ( ( field_ident. clone ( ) , field_ident) ) ;
888+ field_mappings. push ( ( field_ident. clone ( ) , field_ident, should_wrap_option ) ) ;
826889 }
827890 }
828891 }
@@ -849,8 +912,12 @@ pub fn generate_schema_type_code(
849912 let from_impl = if input. add . is_none ( ) {
850913 let field_assignments: Vec < _ > = field_mappings
851914 . iter ( )
852- . map ( |( new_ident, source_ident) | {
853- quote ! { #new_ident: source. #source_ident }
915+ . map ( |( new_ident, source_ident, wrapped) | {
916+ if * wrapped {
917+ quote ! { #new_ident: Some ( source. #source_ident) }
918+ } else {
919+ quote ! { #new_ident: source. #source_ident }
920+ }
854921 } )
855922 . collect ( ) ;
856923
@@ -1051,6 +1118,119 @@ mod tests {
10511118 assert_eq ! ( add[ 0 ] . 0 , "tags" ) ;
10521119 }
10531120
1121+ // Tests for `partial` parameter
1122+
1123+ #[ test]
1124+ fn test_parse_schema_type_input_with_partial_all ( ) {
1125+ let tokens = quote:: quote!( UpdateUser from User , partial) ;
1126+ let input: SchemaTypeInput = syn:: parse2 ( tokens) . unwrap ( ) ;
1127+ assert ! ( matches!( input. partial, Some ( PartialMode :: All ) ) ) ;
1128+ }
1129+
1130+ #[ test]
1131+ fn test_parse_schema_type_input_with_partial_fields ( ) {
1132+ let tokens = quote:: quote!( UpdateUser from User , partial = [ "name" , "email" ] ) ;
1133+ let input: SchemaTypeInput = syn:: parse2 ( tokens) . unwrap ( ) ;
1134+ match input. partial {
1135+ Some ( PartialMode :: Fields ( fields) ) => {
1136+ assert_eq ! ( fields, vec![ "name" , "email" ] ) ;
1137+ }
1138+ _ => panic ! ( "Expected PartialMode::Fields" ) ,
1139+ }
1140+ }
1141+
1142+ #[ test]
1143+ fn test_parse_schema_type_input_with_pick_and_partial ( ) {
1144+ let tokens = quote:: quote!( UpdateUser from User , pick = [ "name" , "email" ] , partial) ;
1145+ let input: SchemaTypeInput = syn:: parse2 ( tokens) . unwrap ( ) ;
1146+ assert_eq ! ( input. pick. unwrap( ) , vec![ "name" , "email" ] ) ;
1147+ assert ! ( matches!( input. partial, Some ( PartialMode :: All ) ) ) ;
1148+ }
1149+
1150+ #[ test]
1151+ fn test_parse_schema_type_input_with_pick_and_partial_fields ( ) {
1152+ let tokens =
1153+ quote:: quote!( UpdateUser from User , pick = [ "name" , "email" ] , partial = [ "name" ] ) ;
1154+ let input: SchemaTypeInput = syn:: parse2 ( tokens) . unwrap ( ) ;
1155+ assert_eq ! ( input. pick. unwrap( ) , vec![ "name" , "email" ] ) ;
1156+ match input. partial {
1157+ Some ( PartialMode :: Fields ( fields) ) => {
1158+ assert_eq ! ( fields, vec![ "name" ] ) ;
1159+ }
1160+ _ => panic ! ( "Expected PartialMode::Fields" ) ,
1161+ }
1162+ }
1163+
1164+ #[ test]
1165+ fn test_generate_schema_type_code_with_partial_all ( ) {
1166+ let storage = vec ! [ create_test_struct_metadata(
1167+ "User" ,
1168+ "pub struct User { pub id: i32, pub name: String, pub bio: Option<String> }" ,
1169+ ) ] ;
1170+
1171+ let tokens = quote:: quote!( UpdateUser from User , partial) ;
1172+ let input: SchemaTypeInput = syn:: parse2 ( tokens) . unwrap ( ) ;
1173+ let result = generate_schema_type_code ( & input, & storage) ;
1174+
1175+ assert ! ( result. is_ok( ) ) ;
1176+ let output = result. unwrap ( ) . to_string ( ) ;
1177+ // id and name should be wrapped in Option, bio already Option stays unchanged
1178+ assert ! ( output. contains( "Option < i32 >" ) ) ;
1179+ assert ! ( output. contains( "Option < String >" ) ) ;
1180+ }
1181+
1182+ #[ test]
1183+ fn test_generate_schema_type_code_with_partial_fields ( ) {
1184+ let storage = vec ! [ create_test_struct_metadata(
1185+ "User" ,
1186+ "pub struct User { pub id: i32, pub name: String, pub email: String }" ,
1187+ ) ] ;
1188+
1189+ let tokens = quote:: quote!( UpdateUser from User , partial = [ "name" ] ) ;
1190+ let input: SchemaTypeInput = syn:: parse2 ( tokens) . unwrap ( ) ;
1191+ let result = generate_schema_type_code ( & input, & storage) ;
1192+
1193+ assert ! ( result. is_ok( ) ) ;
1194+ let output = result. unwrap ( ) . to_string ( ) ;
1195+ // name should be Option<String>, but id and email should remain unwrapped
1196+ assert ! ( output. contains( "UpdateUser" ) ) ;
1197+ }
1198+
1199+ #[ test]
1200+ fn test_generate_schema_type_code_partial_nonexistent_field ( ) {
1201+ let storage = vec ! [ create_test_struct_metadata(
1202+ "User" ,
1203+ "pub struct User { pub id: i32, pub name: String }" ,
1204+ ) ] ;
1205+
1206+ let tokens = quote:: quote!( UpdateUser from User , partial = [ "nonexistent" ] ) ;
1207+ let input: SchemaTypeInput = syn:: parse2 ( tokens) . unwrap ( ) ;
1208+ let result = generate_schema_type_code ( & input, & storage) ;
1209+
1210+ assert ! ( result. is_err( ) ) ;
1211+ let err = result. unwrap_err ( ) . to_string ( ) ;
1212+ assert ! ( err. contains( "does not exist" ) ) ;
1213+ assert ! ( err. contains( "nonexistent" ) ) ;
1214+ }
1215+
1216+ #[ test]
1217+ fn test_generate_schema_type_code_partial_from_impl_wraps_some ( ) {
1218+ let storage = vec ! [ create_test_struct_metadata(
1219+ "User" ,
1220+ "pub struct User { pub id: i32, pub name: String }" ,
1221+ ) ] ;
1222+
1223+ let tokens = quote:: quote!( UpdateUser from User , partial) ;
1224+ let input: SchemaTypeInput = syn:: parse2 ( tokens) . unwrap ( ) ;
1225+ let result = generate_schema_type_code ( & input, & storage) ;
1226+
1227+ assert ! ( result. is_ok( ) ) ;
1228+ let output = result. unwrap ( ) . to_string ( ) ;
1229+ // From impl should wrap values in Some()
1230+ assert ! ( output. contains( "Some (source . id)" ) ) ;
1231+ assert ! ( output. contains( "Some (source . name)" ) ) ;
1232+ }
1233+
10541234 // =========================================================================
10551235 // Tests for generate_schema_code() - success paths
10561236 // =========================================================================
0 commit comments