diff --git a/USAGE.md b/USAGE.md index 748579b..b7c8356 100644 --- a/USAGE.md +++ b/USAGE.md @@ -288,6 +288,7 @@ summary.name #=> "Dana" | `type:` | A dry-types type for coercion on assignment (e.g. `Types::Params::Integer.lax`). Errors fall back to the raw value. | | `twin:` | Wraps the value in another twin class. Accepts a hash, a twin instance, or an object with `to_h`/`attributes`. | | `expose:` | `false` omits the property from `to_hash`/`to_json`. | +| `expose_nil:` | `true` keeps the property in `to_hash`/`to_json` even when its value is `nil`, without turning on the global `render_nil:`. Combining `expose_nil: true` with `expose: false` raises an `ArgumentError`. | | `readonly:` | `true` prevents assignment via `assign_hash` and `assign_params`. | | `getter:` | A lambda `-> { ... }` or a symbol `:method_name` that fully replaces the generated getter. The lambda runs in instance context; the symbol calls the named method on the instance. If the lambda or method accepts a parameter, the current raw property value is passed as the argument. | | `setter:` | A lambda `->(value) { ... }` that fully replaces the generated setter. Not allowed together with a block. | @@ -356,7 +357,7 @@ The leaf properties (`city`, `zip`) are accessible directly on the parent instan | Method | Description | |---|---| -| `to_hash(render_nil: false)` | Returns the twin as a Hash (or `HashWithIndifferentAccess` when ActiveSupport is available). Nested twins are serialized recursively. Unexposed properties are omitted. | +| `to_hash(render_nil: false)` | Returns the twin as a Hash (or `HashWithIndifferentAccess` when ActiveSupport is available). Nested twins are serialized recursively. Unexposed properties are omitted. `nil` values are dropped unless `render_nil: true` is passed, or the property was declared with `expose_nil: true`. | | `to_h` | Alias for `to_hash`. | | `to_json` | Delegates to `to_hash.to_json`. | | `attributes` | Returns a Hash keyed by the original setter names (before any `as:` aliasing). Includes protected readers. | diff --git a/lib/minitwin/class_methods/caches.rb b/lib/minitwin/class_methods/caches.rb index ec80e4c..48e6879 100644 --- a/lib/minitwin/class_methods/caches.rb +++ b/lib/minitwin/class_methods/caches.rb @@ -25,6 +25,8 @@ def invalidate_caches @allowed_attribute_keys_array = nil @setter_methods = nil @has_dynamic_aliases_cache = nil + @expose_nil_getters = nil + @expose_nil_property_keys = nil end def serializable_getters @@ -41,6 +43,22 @@ def serializable_getters end end + def expose_nil_property_keys + @expose_nil_property_keys ||= twin_class_hierarchy.each_with_object(Set.new) do |klass, keys| + klass.properties.each { |name, meta| keys << name if meta[:expose_nil] } + end + end + + def expose_nil_getters + keys = expose_nil_property_keys + @expose_nil_getters ||= + if keys.empty? + Set.new + else + serializable_getters.select { |m| keys.include?(instance_method(m).original_name) }.to_set + end + end + # Methods defined directly on the twin class hierarchy: this class and any # intermediate Minitwin subclasses, but not Minitwin itself. Methods mixed # in via modules (e.g. ActionView helpers, which include arg-taking methods diff --git a/lib/minitwin/class_methods/dsl.rb b/lib/minitwin/class_methods/dsl.rb index 7458654..a6de4de 100644 --- a/lib/minitwin/class_methods/dsl.rb +++ b/lib/minitwin/class_methods/dsl.rb @@ -70,6 +70,7 @@ def collection(name, validates: {}, default: [], as: nil, getter: nil, twin: nil # @rbs default: untyped # @rbs as: Symbol | Proc # @rbs expose: bool + # @rbs expose_nil: bool # @rbs readonly: bool # @rbs type: untyped # @rbs getter: Proc @@ -78,9 +79,16 @@ def collection(name, validates: {}, default: [], as: nil, getter: nil, twin: nil # @rbs on: Symbol # @rbs return: void def property( - name, validates: {}, default: nil, as: nil, expose: true, readonly: false, type: nil, getter: nil, setter: nil, - twin: nil, on: nil, **_opts, &block + name, validates: {}, default: nil, as: nil, expose: true, expose_nil: false, readonly: false, type: nil, + getter: nil, setter: nil, twin: nil, on: nil, **_opts, &block ) + if expose_nil && !expose + raise( + ArgumentError, # TODO: replace with DefinitionError once PR#4 is merged into main + "Property '#{name}' cannot combine `expose: false` with `expose_nil: true`." + ) + end + nested_class = nil ivar = Minitwin::Utils.ivar_name(name) @@ -128,6 +136,7 @@ def property( type: type, as: as, expose: expose, + expose_nil: expose_nil, readonly: readonly } properties[name.to_sym][:twin] = twin if twin diff --git a/lib/minitwin/serialization.rb b/lib/minitwin/serialization.rb index 6dc55ca..0d10fa7 100644 --- a/lib/minitwin/serialization.rb +++ b/lib/minitwin/serialization.rb @@ -16,10 +16,11 @@ def to_hash(render_nil: false) hash = Minitwin.hash_klass.new methods_to_serialize = self.class.send(:serializable_getters) + expose_nil_getters = self.class.send(:expose_nil_getters) methods_to_serialize.each do |method| value = send(method) - next if value.nil? && !render_nil + next if value.nil? && !render_nil && !expose_nil_getters.include?(method) hash[method] = transform_value_for_serialization(value) end @@ -28,6 +29,8 @@ def to_hash(render_nil: false) if instance_variable_defined?(ALIASES_VAR) aliases = instance_variable_get(ALIASES_VAR) if aliases && !aliases.empty? + expose_nil_keys = self.class.send(:expose_nil_property_keys) + aliases.each do |target_method, alias_method| # Skip nested proxy aliases at the top level; nested groups # serialize under their container key only. @@ -37,7 +40,7 @@ def to_hash(render_nil: false) # the alias method is overridden. Apply the same transformation rules # as above for nested twins and arrays. value = send(target_method) - next if value.nil? && !render_nil + next if value.nil? && !render_nil && !expose_nil_keys.include?(target_method) hash[alias_method] = transform_value_for_serialization(value) end diff --git a/sig/generated/minitwin/class_methods/caches.rbs b/sig/generated/minitwin/class_methods/caches.rbs index 8b1b52d..a3620d6 100644 --- a/sig/generated/minitwin/class_methods/caches.rbs +++ b/sig/generated/minitwin/class_methods/caches.rbs @@ -12,6 +12,13 @@ class Minitwin def serializable_getters: () -> untyped + # Declaration names of properties opting into nil serialization. + def expose_nil_property_keys: () -> untyped + + # Serialized method names of `expose_nil` properties. Keyed by the public + # method name so `as:` aliases resolve, mapping back via #original_name. + def expose_nil_getters: () -> untyped + # Methods defined directly on the twin class hierarchy: this class and any # intermediate Minitwin subclasses, but not Minitwin itself. Methods mixed # in via modules (e.g. ActionView helpers, which include arg-taking methods diff --git a/sig/generated/minitwin/class_methods/dsl.rbs b/sig/generated/minitwin/class_methods/dsl.rbs index 6c2b0ac..0fc6970 100644 --- a/sig/generated/minitwin/class_methods/dsl.rbs +++ b/sig/generated/minitwin/class_methods/dsl.rbs @@ -33,6 +33,7 @@ class Minitwin # @rbs default: untyped # @rbs as: Symbol | Proc # @rbs expose: bool + # @rbs expose_nil: bool # @rbs readonly: bool # @rbs type: untyped # @rbs getter: Proc @@ -40,7 +41,7 @@ class Minitwin # @rbs twin: untyped # @rbs on: Symbol # @rbs return: void - def property: (Symbol name, ?validates: Hash[Symbol, untyped], ?default: untyped, ?as: untyped, ?expose: bool, ?readonly: bool, ?type: untyped, ?getter: Proc, ?setter: Proc, ?twin: untyped, ?on: Symbol, **untyped _opts) ?{ (?) -> untyped } -> void + def property: (Symbol name, ?validates: Hash[Symbol, untyped], ?default: untyped, ?as: untyped, ?expose: bool, ?expose_nil: bool, ?readonly: bool, ?type: untyped, ?getter: Proc, ?setter: Proc, ?twin: untyped, ?on: Symbol, **untyped _opts) ?{ (?) -> untyped } -> void # @rbs name: Symbol # @rbs as: Symbol | Proc diff --git a/test/class_methods/dsl/properties_test.rb b/test/class_methods/dsl/properties_test.rb index ba8937e..124e478 100644 --- a/test/class_methods/dsl/properties_test.rb +++ b/test/class_methods/dsl/properties_test.rb @@ -202,6 +202,26 @@ class << self assert_equal 42, obj.count end + test "expose_nil is stored in the property metadata" do + klass = Class.new(Minitwin) do + property :always_there, expose_nil: true + property :plain + end + + assert klass.properties[:always_there][:expose_nil] + refute klass.properties[:plain][:expose_nil] + end + + test "expose_nil combined with expose false raises" do + error = assert_raises(ArgumentError) do + Class.new(Minitwin) do + property :internal, expose: false, expose_nil: true + end + end + + assert_equal "Property 'internal' cannot combine `expose: false` with `expose_nil: true`.", error.message + end + test "boolean property with question mark" do klass = Class.new(Minitwin) do property :active? diff --git a/test/serialization_test.rb b/test/serialization_test.rb index b6d08ce..188ad80 100644 --- a/test/serialization_test.rb +++ b/test/serialization_test.rb @@ -224,6 +224,69 @@ class EmployeeTwin < PersonTwin assert_nil hash[:value] end + test "expose_nil renders a nil property while other nil properties stay absent" do + klass = Class.new(Minitwin) do + property :name + property :value, expose_nil: true + end + + hash = klass.new.to_hash + + assert hash.key?(:value) + assert_nil hash[:value] + refute hash.key?(:name) + end + + test "expose_nil renders nil under a static alias" do + klass = Class.new(Minitwin) do + property :internal_name, as: :name, expose_nil: true + end + + hash = klass.new.to_hash + + assert hash.key?(:name) + assert_nil hash[:name] + end + + test "expose_nil renders nil under a dynamic alias" do + klass = Class.new(Minitwin) do + property :key + property :value, as: -> { key.to_sym }, expose_nil: true + end + + hash = klass.new(key: "alias").to_hash + + assert hash.key?(:alias) + assert_nil hash[:alias] + end + + test "expose_nil is honored for properties inherited from a parent twin" do + parent = Class.new(Minitwin) do + property :value, expose_nil: true + end + child = Class.new(parent) do + property :name + end + + hash = child.new(name: "test").to_hash + + assert hash.key?(:value) + assert_nil hash[:value] + end + + test "nested twins honor their own expose_nil without render_nil" do + klass = Class.new(Minitwin) do + property :sub_twin do + property :sub_property, expose_nil: true + end + end + + hash = klass.new(sub_twin: {}).to_hash + + assert hash[:sub_twin].key?(:sub_property) + assert_nil hash[:sub_twin][:sub_property] + end + test "to_json serializes properties" do klass = Class.new(Minitwin) do property :name