diff --git a/Cargo.toml b/Cargo.toml index 10895eb..74eaa89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ exclude = [ [workspace.package] authors = ["Antonio Yang "] -version = "0.12.5" +version = "0.12.6" edition = "2021" categories = ["development-tools"] keywords = ["struct", "patch", "macro", "derive", "overlay"] diff --git a/README.md b/README.md index 97e0ff5..778f357 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,39 @@ struct Amyloid { //} ``` +#### Case 4 - Avoid double-`Option` for `Option>` fields +By default, deriving `Patch` wraps every field in an `Option`, so a field typed +`Option>` becomes `Option>>` in the generated patch. When +this double wrapping is undesirable, annotate the field with +`#[patch(skip_wrap)]` to keep the original type in the patch. `None` in the +patch then means "no change" and `Some(v)` replaces the field — including +`Some(vec![])` to explicitly clear the vector. + +```rust +use struct_patch::Patch; + +#[derive(Default, Patch)] +struct Item { + #[patch(skip_wrap)] + tags: Option>, +} + +// Generated struct +// struct ItemPatch { +// tags: Option>, +// } + +let mut item = Item { tags: Some(vec!["a".into()]) }; + +// `None` leaves the field unchanged. +item.apply(ItemPatch { tags: None }); +assert_eq!(item.tags, Some(vec!["a".into()])); + +// `Some(vec![])` still applies and clears the list. +item.apply(ItemPatch { tags: Some(vec![]) }); +assert_eq!(item.tags, Some(vec![])); +``` + ## Documentation and Examples You can modify the patch structure by defining `#[patch(...)]`, `#[filler(...)]` or `#[complex(...)]`(catalyst feature), `#[catalyst(...)]`(catalyst feature) attributes on the original struct or fields. Two macros are provided for the catalyst feature because we need to handle the behaviors of two structs simultaneously — the catalyst itself and the product (complex). In general, the complex macro takes precedence over the catalyst macro when any conflict arises. @@ -173,6 +206,7 @@ Field attributes: - `#[patch(attribute(...))]`: add attributes to the field in the generated patch struct. - `#[patch(attribute(derive(...)))]`: add derives to the field in the generated patch struct. - `#[patch(empty_value = ...)]`: define a value as empty, so the corresponding field of patch will not wrapped by Option, and apply patch when the field is empty. +- `#[patch(skip_wrap)]`: keep the field type as-is in the patch struct (no extra `Option` wrapping). Useful when the field is already `Option<...>` (for example `Option>`) and you do not want a double-`Option` in the patch. With `skip_wrap`, `None` in the patch means "no change" and `Some(v)` sets the field to `Some(v)` (including `Some(vec![])` to clear the vector). Cannot be combined with `empty_value`. - `#[filler(extendable)]`: use the field of the struct for filler, the struct needs implement `Default`, `Extend`, `IntoIterator` and `is_empty`. - `#[filler(empty_value = ...)]`: define a value as empty, so the corresponding field of Filler will be applied, even the field is not `Option` or `Extendable`. - `#[complex(attribute(...))]`: add attributes to the field in the generated complex struct. (catalyst feature) diff --git a/complex-example/Cargo.toml b/complex-example/Cargo.toml index 52070d7..b038366 100644 --- a/complex-example/Cargo.toml +++ b/complex-example/Cargo.toml @@ -9,7 +9,7 @@ struct-patch = { path = "../lib", features = ["catalyst"] } [workspace.package] authors = ["Antonio Yang "] -version = "0.12.5" +version = "0.12.6" edition = "2021" categories = ["development-tools"] keywords = ["struct", "patch", "macro", "derive", "overlay"] diff --git a/derive/src/patch.rs b/derive/src/patch.rs index 0a18586..824c456 100644 --- a/derive/src/patch.rs +++ b/derive/src/patch.rs @@ -17,6 +17,7 @@ const ADDABLE: &str = "addable"; const ADD: &str = "add"; const NESTING: &str = "nesting"; const EMPTY_VALUE: &str = "empty_value"; +const SKIP_WRAP: &str = "skip_wrap"; pub(crate) struct Patch { visibility: syn::Visibility, @@ -27,6 +28,28 @@ pub(crate) struct Patch { fields: Vec, } +enum SpecialAttr { + None, + /// Field uses an explicit sentinel value instead of `Option` wrapping. + EmptyValue(Lit), + /// Field type is already `Option`; `None` means "no change", `Some(v)` applies the value. + SkipWrap, +} + +impl SpecialAttr { + fn is_empty(&self) -> bool { + matches!(self, SpecialAttr::None) + } + + fn empty_value(&self) -> Option<&Lit> { + if let SpecialAttr::EmptyValue(lit) = self { + Some(lit) + } else { + None + } + } +} + struct Field { ident: Option, ty: Type, @@ -36,8 +59,7 @@ struct Field { addable: Addable, #[cfg(feature = "nesting")] nesting: bool, - /// Define which value is empty - empty_value: Option, + special_attr: SpecialAttr, } impl Patch { @@ -61,99 +83,114 @@ impl Patch { #[cfg(not(feature = "nesting"))] let field_names = fields .iter() - .filter(|f| f.empty_value.is_none()) + .filter(|f| f.special_attr.is_empty()) .map(|f| f.ident.as_ref()) .collect::>(); #[cfg(not(feature = "nesting"))] let field_names_by_empty_value = fields .iter() - .filter(|f| f.empty_value.is_some()) + .filter(|f| matches!(f.special_attr, SpecialAttr::EmptyValue(_))) .map(|f| f.ident.as_ref()) .collect::>(); #[cfg(feature = "nesting")] let field_names = fields .iter() - .filter(|f| !f.nesting && f.empty_value.is_none()) + .filter(|f| !f.nesting && f.special_attr.is_empty()) .map(|f| f.ident.as_ref()) .collect::>(); #[cfg(feature = "nesting")] let field_names_by_empty_value = fields .iter() - .filter(|f| !f.nesting && f.empty_value.is_some()) + .filter(|f| !f.nesting && matches!(f.special_attr, SpecialAttr::EmptyValue(_))) .map(|f| f.ident.as_ref()) .collect::>(); let field_name_empty_values = fields .iter() - .filter_map(|f| f.empty_value.as_ref()) + .filter_map(|f| f.special_attr.empty_value()) + .collect::>(); + + // Fields with `#[patch(skip_wrap)]` — the patch keeps the original + // (already-`Option`) type, and `None` in the patch means "no change". + #[cfg(not(feature = "nesting"))] + let skip_wrap_field_names = fields + .iter() + .filter(|f| matches!(f.special_attr, SpecialAttr::SkipWrap)) + .map(|f| f.ident.as_ref()) + .collect::>(); + #[cfg(feature = "nesting")] + let skip_wrap_field_names = fields + .iter() + .filter(|f| matches!(f.special_attr, SpecialAttr::SkipWrap) && !f.nesting) + .map(|f| f.ident.as_ref()) .collect::>(); // Rename fields #[cfg(not(feature = "nesting"))] let renamed_field_names = fields .iter() - .filter(|f| f.retyped && f.empty_value.is_none()) + .filter(|f| f.retyped && f.special_attr.is_empty()) .map(|f| f.ident.as_ref()) .collect::>(); #[cfg(not(feature = "nesting"))] let renamed_field_names_by_empty_value = fields .iter() - .filter(|f| f.retyped && f.empty_value.is_some()) + .filter(|f| f.retyped && matches!(f.special_attr, SpecialAttr::EmptyValue(_))) .map(|f| f.ident.as_ref()) .collect::>(); #[cfg(feature = "nesting")] let renamed_field_names = fields .iter() - .filter(|f| f.retyped && !f.nesting && f.empty_value.is_none()) + .filter(|f| f.retyped && !f.nesting && f.special_attr.is_empty()) .map(|f| f.ident.as_ref()) .collect::>(); #[cfg(feature = "nesting")] let renamed_field_names_by_empty_value = fields .iter() - .filter(|f| f.retyped && !f.nesting && f.empty_value.is_some()) + .filter(|f| f.retyped && !f.nesting && matches!(f.special_attr, SpecialAttr::EmptyValue(_))) .map(|f| f.ident.as_ref()) .collect::>(); let renamed_field_name_empty_values = fields .iter() .filter(|f| f.retyped) - .filter_map(|f| f.empty_value.as_ref()) + .filter_map(|f| f.special_attr.empty_value()) .collect::>(); // Original fields #[cfg(not(feature = "nesting"))] let original_field_names = fields .iter() - .filter(|f| !f.retyped && f.empty_value.is_none()) + .filter(|f| !f.retyped && f.special_attr.is_empty()) .map(|f| f.ident.as_ref()) .collect::>(); #[cfg(not(feature = "nesting"))] let original_field_names_by_empty_value = fields .iter() - .filter(|f| !f.retyped && f.empty_value.is_some()) + .filter(|f| !f.retyped && matches!(f.special_attr, SpecialAttr::EmptyValue(_))) .map(|f| f.ident.as_ref()) .collect::>(); #[cfg(feature = "nesting")] let original_field_names = fields .iter() - .filter(|f| !f.retyped && !f.nesting && f.empty_value.is_none()) + .filter(|f| !f.retyped && !f.nesting && f.special_attr.is_empty()) .map(|f| f.ident.as_ref()) .collect::>(); #[cfg(feature = "nesting")] let original_field_names_by_empty_value = fields .iter() - .filter(|f| !f.retyped && !f.nesting && f.empty_value.is_some()) + .filter(|f| !f.retyped && !f.nesting && matches!(f.special_attr, SpecialAttr::EmptyValue(_))) .map(|f| f.ident.as_ref()) .collect::>(); #[cfg(not(feature = "nesting"))] let original_field_name_empty_values = fields .iter() .filter(|f| !f.retyped) - .filter_map(|f| f.empty_value.as_ref()) + .filter_map(|f| f.special_attr.empty_value()) .collect::>(); #[cfg(feature = "nesting")] let original_field_name_empty_values = fields .iter() .filter(|f| !f.retyped && !f.nesting) - .filter_map(|f| f.empty_value.as_ref()) + .filter_map(|f| f.special_attr.empty_value()) .collect::>(); // Nesting fields @@ -207,6 +244,11 @@ impl Patch { return false } )* + #( + if self.#skip_wrap_field_names.is_some() { + return false + } + )* #( if !self.#nesting_field_names.is_empty() { return false @@ -252,6 +294,9 @@ impl Patch { (true, true) => #original_field_name_empty_values, }, )* + #( + #skip_wrap_field_names: other.#skip_wrap_field_names.or(self.#skip_wrap_field_names), + )* #( #nesting_field_names: other.#nesting_field_names.merge(self.#nesting_field_names), )* @@ -266,7 +311,7 @@ impl Patch { let addable_handles = fields .iter() .map(|f| { - match (&f.addable, f.empty_value.is_some()) { + match (&f.addable, matches!(f.special_attr, SpecialAttr::EmptyValue(_))) { (Addable::AddTrait, true) => quote!( a + &b ), @@ -352,6 +397,16 @@ impl Patch { (true, true) => #original_field_name_empty_values, }, )* + #( + #skip_wrap_field_names: match (self.#skip_wrap_field_names, rhs.#skip_wrap_field_names) { + (Some(a), Some(b)) => { + #addable_handles + }, + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + }, + )* #( #nesting_field_names: self.#nesting_field_names + rhs.#nesting_field_names, )* @@ -431,6 +486,16 @@ impl Patch { (true, true) => #original_field_name_empty_values, }, )* + #( + #skip_wrap_field_names: match (self.#skip_wrap_field_names, rhs.#skip_wrap_field_names) { + (Some(a), Some(b)) => { + #addable_handles + }, + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + }, + )* #( #nesting_field_names: self.#nesting_field_names + rhs.#nesting_field_names, )* @@ -466,6 +531,11 @@ impl Patch { self.#original_field_names_by_empty_value = patch.#original_field_names_by_empty_value ; } )* + #( + if let Some(v) = patch.#skip_wrap_field_names { + self.#skip_wrap_field_names = Some(v); + } + )* #( self.#nesting_field_names.apply(patch.#nesting_field_names); )* @@ -485,6 +555,9 @@ impl Patch { #( #original_field_names_by_empty_value: self.#original_field_names_by_empty_value, )* + #( + #skip_wrap_field_names: self.#skip_wrap_field_names, + )* #( #nesting_field_names: self.#nesting_field_names.into_patch(), )* @@ -525,6 +598,14 @@ impl Patch { #original_field_name_empty_values }, )* + #( + #skip_wrap_field_names: if self.#skip_wrap_field_names != previous_struct.#skip_wrap_field_names { + self.#skip_wrap_field_names + } + else { + None + }, + )* #( #nesting_field_names: self.#nesting_field_names.into_patch_by_diff(previous_struct.#nesting_field_names), )* @@ -539,6 +620,9 @@ impl Patch { #( #field_names_by_empty_value: #field_name_empty_values, )* + #( + #skip_wrap_field_names: None, + )* #( #nesting_field_names: #nesting_field_types::new_empty_patch(), )* @@ -656,7 +740,7 @@ impl Field { attributes, #[cfg(feature = "nesting")] nesting, - empty_value, + special_attr, .. } = self; @@ -671,7 +755,7 @@ impl Field { match ident { #[cfg(not(feature = "nesting"))] Some(ident) => { - if empty_value.is_some() { + if !special_attr.is_empty() { Ok(quote! { #(#attributes)* pub #ident: #ty, @@ -695,7 +779,7 @@ impl Field { #(#attributes)* pub #ident: #patch_type, }) - } else if empty_value.is_some() { + } else if !special_attr.is_empty() { Ok(quote! { #(#attributes)* pub #ident: #ty, @@ -709,7 +793,7 @@ impl Field { } #[cfg(not(feature = "nesting"))] None => { - if empty_value.is_some() { + if !special_attr.is_empty() { Ok(quote! { #(#attributes)* pub #ty, @@ -733,7 +817,7 @@ impl Field { #(#attributes)* pub #patch_type, }) - } else if empty_value.is_some() { + } else if !special_attr.is_empty() { Ok(quote! { #(#attributes)* pub #ty, @@ -757,7 +841,7 @@ impl Field { let mut attributes = vec![]; let mut field_type = None; let mut skip = false; - let mut empty_value = None; + let mut special_attr = SpecialAttr::None; #[cfg(feature = "op")] let mut addable = Addable::Disable; @@ -829,18 +913,32 @@ impl Field { } EMPTY_VALUE => { // #[patch(empty_value = ...)] - if empty_value.is_some() { + if matches!(special_attr, SpecialAttr::EmptyValue(_)) { return Err(meta.error( "The empty value is already set, we can't defined more than once", )); } + if matches!(special_attr, SpecialAttr::SkipWrap) { + return Err(meta.error( + "`empty_value` and `skip_wrap` cannot be combined on the same field", + )); + } if let Some(lit) = crate::get_lit(path, &meta)? { - empty_value = Some(lit); + special_attr = SpecialAttr::EmptyValue(lit); } else { return Err(meta .error("empty_value needs a clear value to define what is empty")); } } + SKIP_WRAP => { + // #[patch(skip_wrap)] + if matches!(special_attr, SpecialAttr::EmptyValue(_)) { + return Err(meta.error( + "`skip_wrap` and `empty_value` cannot be combined on the same field", + )); + } + special_attr = SpecialAttr::SkipWrap; + } _ => { return Err(meta.error(format_args!( "unknown patch field attribute `{}`", @@ -864,7 +962,7 @@ impl Field { addable, #[cfg(feature = "nesting")] nesting, - empty_value, + special_attr, })) } } @@ -917,7 +1015,7 @@ mod tests { retyped: true, #[cfg(feature = "op")] addable: Addable::Disable, - empty_value: None, + special_attr: SpecialAttr::None, }, Field { ident: Some(syn::Ident::new("field3", Span::call_site())), @@ -926,7 +1024,7 @@ mod tests { retyped: false, #[cfg(feature = "op")] addable: Addable::Disable, - empty_value: Some(Lit::Bool(syn::LitBool::new(false, Span::call_site()))), + special_attr: SpecialAttr::EmptyValue(Lit::Bool(syn::LitBool::new(false, Span::call_site()))), }, ], }; diff --git a/lib/Cargo.toml b/lib/Cargo.toml index bf9f144..0564e25 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -12,7 +12,7 @@ readme.workspace = true rust-version.workspace = true [dependencies] -struct-patch-derive = { version = "=0.12.5", path = "../derive" } +struct-patch-derive = { version = "=0.12.6", path = "../derive" } [dev-dependencies] serde_json = "1.0" diff --git a/lib/examples/instance.rs b/lib/examples/instance.rs index 1db6560..147ef0b 100644 --- a/lib/examples/instance.rs +++ b/lib/examples/instance.rs @@ -6,6 +6,8 @@ struct Item { field_complete: bool, field_int: usize, field_string: String, + #[patch(skip_wrap)] + field_option_vec: Option>, } // Generated by Patch derive macro @@ -15,6 +17,8 @@ struct Item { // field_complete: Option, // field_int: Option, // field_string: Option, +// // `skip_wrap` keeps `Option>` as-is (no extra `Option` wrap). +// field_option_vec: Option>, // } fn main() { @@ -26,7 +30,7 @@ fn main() { assert_eq!( format!("{patch:?}"), - "ItemPatch { field_complete: None, field_int: Some(7), field_string: None }" + "ItemPatch { field_complete: None, field_int: Some(7), field_string: None, field_option_vec: None }" ); item.apply(patch); @@ -34,6 +38,38 @@ fn main() { assert!(!item.field_complete); assert_eq!(item.field_int, 7); assert_eq!(item.field_string, ""); + assert_eq!(item.field_option_vec, None); + + // `skip_wrap`: `None` in the patch means "no change". + let noop_patch = ItemPatch { + field_complete: None, + field_int: None, + field_string: None, + field_option_vec: None, + }; + item.field_option_vec = Some(vec![1, 2, 3]); + item.apply(noop_patch); + assert_eq!(item.field_option_vec, Some(vec![1, 2, 3])); + + // `skip_wrap`: `Some(vec)` in the patch replaces the field with `Some(vec)`. + let set_patch = ItemPatch { + field_complete: None, + field_int: None, + field_string: None, + field_option_vec: Some(vec![9, 9]), + }; + item.apply(set_patch); + assert_eq!(item.field_option_vec, Some(vec![9, 9])); + + // `skip_wrap`: `Some(vec![])` still applies and clears the vector. + let clear_patch = ItemPatch { + field_complete: None, + field_int: None, + field_string: None, + field_option_vec: Some(vec![]), + }; + item.apply(clear_patch); + assert_eq!(item.field_option_vec, Some(vec![])); #[cfg(feature = "op")] { @@ -41,21 +77,25 @@ fn main() { field_complete: None, field_int: None, field_string: Some("from another patch".into()), + field_option_vec: None, }; let new_item = item << another_patch; assert!(!new_item.field_complete); assert_eq!(new_item.field_int, 7); assert_eq!(new_item.field_string, "from another patch"); + assert_eq!(new_item.field_option_vec, Some(vec![])); let the_other_patch = ItemPatch { field_complete: Some(true), field_int: None, field_string: None, + field_option_vec: Some(vec![4, 2]), }; let final_item = new_item << the_other_patch; assert!(final_item.field_complete); assert_eq!(final_item.field_int, 7); assert_eq!(final_item.field_string, "from another patch"); + assert_eq!(final_item.field_option_vec, Some(vec![4, 2])); } } diff --git a/lib/src/traits.rs b/lib/src/traits.rs index 609e51c..3223e37 100644 --- a/lib/src/traits.rs +++ b/lib/src/traits.rs @@ -77,6 +77,37 @@ /// // data: Option, /// // } /// ``` +/// +/// ### `#[patch(skip_wrap)]` +/// Keep the field type as-is in the generated patch struct (no extra `Option` +/// wrapping). This is useful for fields that are already `Option<...>`, +/// typically `Option>`, where the default double-`Option` in the patch +/// is unwanted. With `skip_wrap`, `None` in the patch means "no change" and +/// `Some(v)` sets the field to `Some(v)` (including `Some(vec![])` to clear +/// the vector). Cannot be combined with `empty_value`. +/// ```rust +/// # use struct_patch::Patch; +/// #[derive(Default, Patch)] +/// struct Item { +/// #[patch(skip_wrap)] +/// tags: Option>, +/// } +/// +/// // Generated struct +/// // struct ItemPatch { +/// // tags: Option>, // not wrapped again +/// // } +/// +/// let mut item = Item { tags: Some(vec!["a".into()]) }; +/// +/// // `None` in the patch keeps the field unchanged. +/// item.apply(ItemPatch { tags: None }); +/// assert_eq!(item.tags, Some(vec!["a".into()])); +/// +/// // `Some(vec![])` still applies and clears the list. +/// item.apply(ItemPatch { tags: Some(vec![]) }); +/// assert_eq!(item.tags, Some(vec![])); +/// ``` pub trait Patch

{ /// Apply a patch fn apply(&mut self, patch: P); diff --git a/no-std-examples/Cargo.toml b/no-std-examples/Cargo.toml index f3a29e4..9c68913 100644 --- a/no-std-examples/Cargo.toml +++ b/no-std-examples/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "no-std-examples" authors = ["Antonio Yang "] -version = "0.12.5" +version = "0.12.6" edition = "2021" license = "MIT"