Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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. |
Expand Down
18 changes: 18 additions & 0 deletions lib/minitwin/class_methods/caches.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 11 additions & 2 deletions lib/minitwin/class_methods/dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions lib/minitwin/serialization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions sig/generated/minitwin/class_methods/caches.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion sig/generated/minitwin/class_methods/dsl.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,15 @@ 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
# @rbs setter: Proc
# @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
Expand Down
20 changes: 20 additions & 0 deletions test/class_methods/dsl/properties_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
63 changes: 63 additions & 0 deletions test/serialization_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down