Skip to content

Commit 684f859

Browse files
authored
Merge pull request #35 from SOFware/void
Add Void objects for non-singleton null object instances
2 parents a30ce59 + 0e89194 commit 684f859

File tree

3 files changed

+402
-0
lines changed

3 files changed

+402
-0
lines changed

README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,56 @@ class User < ApplicationRecord
6868
end
6969
```
7070

71+
### Void Objects
72+
73+
While `Null` objects are singletons (one instance per model), `Void` objects are instantiable null objects that allow creating multiple instances with different attribute values.
74+
75+
Define a void object for the model:
76+
77+
```ruby
78+
class Product < ApplicationRecord
79+
Void([:name] => "Unknown Product") do
80+
def display_name
81+
"Product: #{name}"
82+
end
83+
end
84+
end
85+
```
86+
87+
Create instances with custom attributes:
88+
89+
```ruby
90+
product1 = Product.void(name: "Widget")
91+
product2 = Product.void(name: "Gadget")
92+
93+
product1.name # => "Widget"
94+
product2.name # => "Gadget"
95+
```
96+
97+
Each call to `.void` returns a new instance:
98+
99+
```ruby
100+
Product.void.object_id != Product.void.object_id # => true
101+
```
102+
103+
Instance attributes override defaults:
104+
105+
```ruby
106+
product = Product.void(name: "Custom")
107+
product.name # => "Custom" (overrides default "Unknown Product")
108+
109+
default_product = Product.void
110+
default_product.name # => "Unknown Product" (uses default)
111+
```
112+
113+
Void objects support the same features as Null objects:
114+
- Callable defaults (lambdas/procs)
115+
- Custom methods via block syntax
116+
- Association handling
117+
- All ActiveRecord query methods (`null?`, `persisted?`, etc.)
118+
119+
Use `Null` when you need a single shared null object instance. Use `Void` when you need multiple null object instances with different attribute values.
120+
71121
## Development
72122

73123
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.

lib/activerecord/null.rb

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,112 @@ def initialize_attribute_methods
9999
inherit.define_singleton_method(:null) { null_class.instance }
100100
end
101101

102+
# Define a Void class for the given class.
103+
# Unlike Null, Void objects are not singletons and can be instantiated
104+
# multiple times with different attribute values.
105+
#
106+
# @example
107+
# class Product < ApplicationRecord
108+
# Void do
109+
# def display_name = "Product: #{name}"
110+
# end
111+
# end
112+
#
113+
# product1 = Product.void(name: "Widget")
114+
# product2 = Product.void(name: "Gadget")
115+
#
116+
# @param inherit [Class] The class from which the Void object inherits attributes
117+
# @param assignments [Hash] The default attributes to assign to void objects
118+
def Void(inherit = self, assignments = {}, &)
119+
if inherit.is_a?(Hash)
120+
assignments = inherit
121+
inherit = self
122+
end
123+
124+
void_class = Class.new do
125+
include ::ActiveRecord::Null::Mimic
126+
127+
mimics inherit
128+
129+
# Store default assignments for merging with instance attributes
130+
@_void_assignments = assignments
131+
132+
class << self
133+
attr_reader :_void_assignments
134+
135+
def method_missing(method, ...)
136+
mimic_model_class.respond_to?(method) ? mimic_model_class.send(method, ...) : super
137+
end
138+
139+
def respond_to_missing?(method, include_private = false)
140+
mimic_model_class.respond_to?(method, include_private) || super
141+
end
142+
end
143+
144+
# Initialize a new void instance with optional attribute overrides
145+
def initialize(attributes = {})
146+
@_instance_attributes = attributes
147+
initialize_attribute_methods
148+
end
149+
150+
private
151+
152+
def initialize_attribute_methods
153+
# Only initialize if table exists
154+
return unless self.class.mimic_model_class.table_exists?
155+
156+
# Get default assignments from class
157+
void_assignments = self.class._void_assignments
158+
159+
# Define custom assignment methods with instance overrides
160+
if void_assignments.any?
161+
void_assignments.each do |attributes, default_value|
162+
attributes.each do |attr|
163+
attr_sym = attr.to_sym
164+
next if respond_to?(attr_sym) # Skip if already defined
165+
166+
define_singleton_method(attr_sym) do
167+
if @_instance_attributes.key?(attr_sym)
168+
@_instance_attributes[attr_sym]
169+
elsif default_value.is_a?(Proc)
170+
instance_exec(&default_value)
171+
else
172+
default_value
173+
end
174+
end
175+
end
176+
end
177+
end
178+
179+
# Define database attributes
180+
nil_assignments = self.class.mimic_model_class.attribute_names
181+
182+
# Remove custom assignments from database attributes
183+
if void_assignments.any?
184+
void_assignments.each do |attributes, _|
185+
nil_assignments -= attributes.map(&:to_s)
186+
end
187+
end
188+
189+
# Define remaining database attributes with instance override support
190+
nil_assignments.each do |attr|
191+
attr_sym = attr.to_sym
192+
next if respond_to?(attr_sym) # Skip if already defined
193+
194+
define_singleton_method(attr_sym) do
195+
@_instance_attributes.key?(attr_sym) ? @_instance_attributes[attr_sym] : nil
196+
end
197+
end
198+
end
199+
end
200+
201+
void_class.class_eval(&) if block_given?
202+
203+
inherit.const_set(:Void, void_class)
204+
205+
inherit.define_singleton_method(:void) { |attributes = {}| void_class.new(attributes) }
206+
end
207+
102208
def self.extended(base)
103209
base.define_method(:null?) { false }
104210
end

0 commit comments

Comments
 (0)