wholeable 0.0.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7170fd4f95494351863b4ef31203f38ec10ae4d7a589691fb50e38ca813cbbb6
4
- data.tar.gz: 020a94f04704a611cf55a371830d3ee4a8f7cfccb11e9ab3c31c782b830145b7
3
+ metadata.gz: bea89015c8fa8dc330ce5b460023efcf2ba0d7f5c588b3bcc7109c60cbd5194b
4
+ data.tar.gz: 2448e7f7134b72e46d5a66390051b13707a3fce2e3880bdea29db2e931e1a45c
5
5
  SHA512:
6
- metadata.gz: 6d307cf89e4a8ba694c20bdd4fb5afc92fd93fce6a98bc0dd77df7c58f9bf5cd3da11d96be41479d176806a66ae3b2864d3a6d9078d855ffeaf29ac8be1ca675
7
- data.tar.gz: 9e4a70b92a5cb656a8ea4aaf874eff0050166811c295cf91d0fc305974a2c73604cefb80bc3eac4c868971ffeabee32de835f76c9e40d745988ca66e9ba84fe4
6
+ metadata.gz: 21bbd3f36b3e649a22127e7ad9648a5d75922fd970e2db40feb50e14c606d34fc1ac4cdb14c31d2d8246aabc747f7e296e28ab08e3bfa801351e590c495b4f53
7
+ data.tar.gz: e80fb760af6edb13340786236f53869c5b22a2a57e641cc08bab633ee5187125f179583b1b33ec2d6a893b3228da97928691c7e268e3fca420ed333726773ee6
checksums.yaml.gz.sig CHANGED
Binary file
data/README.adoc CHANGED
@@ -23,9 +23,10 @@ toc::[]
23
23
  * Ensures equality (i.e. `#==` and `#eql?`) is determined by attribute values and not object identity (i.e. `#equal?`).
24
24
  * Allows you to compare two objects of same or different types and see their differences.
25
25
  * Provides {pattern_matching_link}.
26
- * Automatically defines public attribute readers (i.e. `.attr_reader`) based on provided keys.
26
+ * Provides inheritance so you can subclass and add attributes or provide additional behavior.
27
+ * Automatically defines public attribute readers (i.e. `.attr_reader`) if _immutable_ (default) or public attribute readers and writers (i.e. `.attr_accessor`) if _mutable_.
27
28
  * Ensures object inspection (i.e. `#inspect`) shows all registered attributes.
28
- * Ensures object is frozen upon initialization.
29
+ * Ensures object is frozen upon initialization by default.
29
30
 
30
31
  == Requirements
31
32
 
@@ -78,9 +79,12 @@ class Person
78
79
  end
79
80
  end
80
81
 
81
- jill = Person.new name: "Jill Smith", email: "jill@example.com"
82
- jill_two = Person.new name: "Jill Smith", email: "jill@example.com"
83
- jack = Person.new name: "Jack Smith", email: "jack@example.com"
82
+ jill = Person[name: "Jill Smith", email: "jill@example.com"]
83
+ jill_two = Person[name: "Jill Smith", email: "jill@example.com"]
84
+ jack = Person[name: "Jack Smith", email: "jack@example.com"]
85
+
86
+ Person.members # [:name, :email]
87
+ jill.members # [:name, :email]
84
88
 
85
89
  jill.name # "Jill Smith"
86
90
  jill.email # "jill@example.com"
@@ -122,25 +126,217 @@ jack.to_a # ["Jack Smith", "jack@example.com"]
122
126
  jill.to_h # {:name=>"Jill Smith", :email=>"jill@example.com"}
123
127
  jack.to_h # {:name=>"Jack Smith", :email=>"jack@example.com"}
124
128
 
129
+ jill.to_s # "#<Person @name=\"Jill Smith\", @email=\"jill@example.com\">"
130
+ jill_two.to_s # "#<Person @name=\"Jill Smith\", @email=\"jill@example.com\">"
131
+ jack.to_s # "#<Person @name=\"Jack Smith\", @email=\"jack@example.com\">"
132
+
125
133
  jill.with name: "Sue" # #<Person @name="Sue", @email="jill@example.com">
126
134
  jill.with bad: "!" # unknown keyword: :bad (ArgumentError)
127
135
  ----
128
136
 
129
137
  As you can see, object equality is determined by the object's values and _not_ by the object's identity. When you include `Wholeable` along with a list of keys, the following happens:
130
138
 
131
- . The corresponding _public_ `attr_reader` for each key is created which saves you time and reduces double entry when implementing your whole value object.
132
- . The `#to_a` and `#to_h` methods are added for convenience in order to play nice with {data_link} and {structs_link}.
139
+ . The corresponding _public_ `attr_reader` (or `attr_accessor` if mutable) for each key is created which saves you time and reduces double entry when implementing your whole value object.
140
+ . The `#to_a`, `#to_h`, and `#to_s` methods are added for convenience and to be compatible with {data_link} and {structs_link}.
133
141
  . The `#deconstruct` and `#deconstruct_keys` aliases are created so you can leverage {pattern_matching_link}.
134
142
  . The `#==`, `#eql?`, `#hash`, `#inspect`, and `#with` methods are added to provide whole value behavior.
135
143
  . The object is immediately frozen after initialization to ensure your instance is _immutable_ by default.
136
144
 
145
+ === Initialization
146
+
147
+ As shown above, you can create an instance of your whole value object by using `.[]`. Example:
148
+
149
+ [source,ruby]
150
+ ----
151
+ Person[name: "Jill Smith", email: "jill@example.com"]
152
+ ----
153
+
154
+ Alternatively, you can create new instances using `.new`. Example:
155
+
156
+ [source,ruby]
157
+ ----
158
+ Person.new name: "Jill Smith", email: "jill@example.com"
159
+ ----
160
+
161
+ Both methods work but use `.[]` when supplying arguments and `.new` when you don't have any arguments.
162
+
163
+ === Mutability
164
+
165
+ All whole value objects are frozen by default. You can change behavior by specifying whether instances should be mutable by passing `kind: :mutable` as a keyword argument. Example:
166
+
167
+ [source,ruby]
168
+ ----
169
+ class Person
170
+ include Wholeable[:name, :email, kind: :mutable]
171
+
172
+ def initialize name: "Jill", email: "jill@example.com"
173
+ @name = name
174
+ @email = email
175
+ end
176
+ end
177
+
178
+ jill = Person.new
179
+ jill.frozen? # false
180
+ ----
181
+
182
+ When your object is mutable, you'll also have access to setter methods in addition to the normal getter methods. Example:
183
+
184
+ [source,ruby]
185
+ ----
186
+ jill.name # "Jill"
187
+ jill.name = "Jayne"
188
+ jill.name # "Jayne"
189
+ ----
190
+
191
+ You can also make your object immutable by using `kind: :immutable` but this is default behavior and redundant. Any invalid kind (example: `kind: :bogus`) will be ignored and default to being immutable.
192
+
193
+ === Inheritance
194
+
195
+ Unlike {data_link} or {structs_link}, you can subclass a whole value object. Example:
196
+
197
+ [source,ruby]
198
+ ----
199
+ class Person
200
+ include Wholeable[:name]
201
+
202
+ def initialize name:
203
+ @name = name
204
+ end
205
+ end
206
+
207
+ class Contact < Person
208
+ include Wholeable[:email]
209
+
210
+ def initialize(email:, **)
211
+ super(**)
212
+ @email = email
213
+ end
214
+ end
215
+
216
+ contact = Contact[name: "Jill Smith", email: "jill@example.com"]
217
+
218
+ contact.to_h # {name: "Jill Smith", email: "jill@example.com"}
219
+ contact.frozen? # true
220
+ ----
221
+
222
+ Notice `Contact` inherits from `Person` while only defining the attributes that make it unique. You don't need to redefine the same attributes found in the superclass as that would be redundant and defeat the purpose of subclassing in the first place.
223
+
224
+ When subclassing, each subclass has access to the same attributes defined by the superclass no matter how deep your ancestry is. This does mean you must pass the remaining attributes to the superclass via the double splat.
225
+
226
+ Mutability is honored but is specific to each object in the ancestry. In other words, if the entire ancestry is immutable then no object can mutate an attribute defined in the ancestry. The same applies if the entire ancestry is mutable except, now, any child can mutate any attribute previously defined by the ancestry. Any attribute that is mutated is only mutated specific to the subclass as is standard inheritance behavior.
227
+
228
+ If your ancestry is a mixed (immutable and mutable) then behavior is specific to each child in the ancestry. This means a mutable child won't make the entire ancestry mutable, only the child will be mutable. Best practice is to architect your ancestry so immutability or mutability is the same across all objects. To illustrate, here's an example with an immutable parent and mutable child:
229
+
230
+ [source,ruby]
231
+ ----
232
+ class Parent
233
+ include Wholeable[:one]
234
+
235
+ def initialize one: 1
236
+ @one = one
237
+ end
238
+ end
239
+
240
+ class Child < Parent
241
+ include Wholeable[:two, kind: :mutable]
242
+
243
+ def initialize(two: 2, **)
244
+ super(**)
245
+ @two = two
246
+ end
247
+ end
248
+
249
+ child = Child.new
250
+
251
+ child.one = 100 # NoMethodError
252
+ child.two = 200 # 200
253
+ child.frozen? # false
254
+ ----
255
+
256
+ Notice, when attempting to mutate the `one` attribute, you get a `NoMethodError`. This is because `#one=` is defined by the _immutable_ parent while `#two=` is defined on the _mutable_ child.
257
+
258
+ If you the flip mutability of your ancestry, you can make your parent mutable while the child immutable for different behavior. Example:
259
+
260
+ [source,ruby]
261
+ ----
262
+ class Parent
263
+ include Wholeable[:one, kind: :mutable]
264
+
265
+ def initialize one: 1
266
+ @one = one
267
+ end
268
+ end
269
+
270
+ class Child < Parent
271
+ include Wholeable[:two]
272
+
273
+ def initialize(two: 2, **)
274
+ super(**)
275
+ @two = two
276
+ end
277
+ end
278
+
279
+ child = Child.new
280
+
281
+ child.one = 100 # FrozenError
282
+ child.two = 200 # NoMethodError
283
+ child.frozen? # true
284
+ ----
285
+
286
+ In this case, you get a `FrozenError` for `#one=` because the parent is _mutable_ and defined the `#one=` method but the child is _immutable_ which caused the associated attribute to be frozen. On the other hand, the `#two=` method is never defined by the subclass due to being immutable and so you you get a: `NoMethodError`.
287
+
288
+ _Again, if using inheritance, ensure immutability or mutability remains consistent throughout the entire ancestry._
289
+
137
290
  == Caveats
138
291
 
139
- Whole values can be broken via the following:
292
+ Whole values can be broken via the following situations:
140
293
 
141
- * *Duplication*: Sending the `#dup` message will cause your whole value object to be unfrozen. This might be desired in certain situations but make sure to refreeze afterwards.
142
294
  * *Post Attributes*: Adding additional attributes after what is defined when including `Wholeable` will break your whole value object. To prevent this, let Wholeable manage this for you (easiest). Otherwise (harder), you can manually override `#==`, `#eql?`, `#hash`, `#inspect`, `#to_a`, and `#to_h` behavior at which point you don't need Wholeable anymore.
143
- * *Deep Freezing*: The automatic freezing of your instances is shallow and will not deeply freeze nested attributes. This behavior mimics the behavior of {data_link} objects.
295
+ * *Deep Freezing*: The automatic freezing of your instances is shallow and will not deep freeze nested attributes. This behavior mimics the behavior of {data_link} objects.
296
+
297
+ == Performance
298
+
299
+ The performance of this gem is good but definitely slower than native support for {data_link} and {structs_link} because they are written in C. To illustrate, here's a micro benchmark for comparison:
300
+
301
+ ----
302
+ INITIALIZATION
303
+
304
+ ruby 3.3.5 (2024-09-03 revision ef084cc8f4) +YJIT [arm64-darwin23.6.0]
305
+ Warming up --------------------------------------
306
+ Data 470.027k i/100ms
307
+ Struct 422.010k i/100ms
308
+ Whole 805.945k i/100ms
309
+ Calculating -------------------------------------
310
+ Data 4.750M (± 1.1%) i/s (210.53 ns/i) - 23.971M in 5.047225s
311
+ Struct 4.579M (± 1.1%) i/s (218.38 ns/i) - 23.211M in 5.069228s
312
+ Whole 9.408M (± 1.2%) i/s (106.29 ns/i) - 47.551M in 5.055033s
313
+
314
+ Comparison:
315
+ Whole: 9407938.7 i/s - 1.60x slower
316
+ Data: 4750013.8 i/s - 3.17x slower
317
+ Struct: 4579253.1 i/s - 3.28x slower
318
+
319
+ BEHAVIOR
320
+
321
+ ruby 3.3.5 (2024-09-03 revision ef084cc8f4) +YJIT [arm64-darwin23.6.0]
322
+ Warming up --------------------------------------
323
+ Data 129.006k i/100ms
324
+ Struct 129.832k i/100ms
325
+ Wholeable 78.861k i/100ms
326
+ Calculating -------------------------------------
327
+ Data 1.336M (± 3.6%) i/s (748.33 ns/i) - 6.708M in 5.027517s
328
+ Struct 1.341M (± 1.7%) i/s (745.89 ns/i) - 6.751M in 5.037050s
329
+ Wholeable 816.232k (± 1.9%) i/s (1.23 μs/i) - 4.101M in 5.025751s
330
+
331
+ Comparison:
332
+ Struct: 1340687.5 i/s
333
+ Data: 1336304.1 i/s - same-ish: difference falls within error
334
+ Wholeable: 816232.0 i/s - 1.64x slower
335
+ ----
336
+
337
+ While the above isn't bad, you can definitely see this gem is slower than Ruby's own native objects when interacting with it despite being faster upon initialization.
338
+
339
+ Default to using {data_link} or {structs_link} but, if you find yourself needing a whole value object with more behavior than what a `Data` or `Struct` can provide, then this gem is a good solution.
144
340
 
145
341
  == Development
146
342
 
@@ -3,29 +3,51 @@
3
3
  module Wholeable
4
4
  # Provides core equality behavior.
5
5
  class Builder < Module
6
- def initialize *keys
6
+ def self.add_aliases descendant
7
+ descendant.alias_method :deconstruct, :to_a
8
+ descendant.alias_method :deconstruct_keys, :to_h
9
+ descendant.alias_method :to_s, :inspect
10
+ end
11
+
12
+ def initialize *keys, kind: :immutable
7
13
  super()
8
14
  @keys = keys.uniq
9
- private_methods.grep(/\A(define)_/).sort.each { |method| __send__ method }
10
- freeze
15
+ @kind = kind
16
+ @members = []
17
+ setup
11
18
  end
12
19
 
13
20
  def included descendant
14
21
  super
22
+ coalesce_members descendant
15
23
 
16
- descendant.class_eval <<-READER, __FILE__, __LINE__ + 1
17
- def self.new(...) = super.freeze
24
+ descendant.class_eval <<-METHODS, __FILE__, __LINE__ + 1
25
+ def self.[](...) = new(...)
18
26
 
19
- attr_reader #{keys.map(&:inspect).join ", "}
20
- READER
27
+ def self.new(...) = #{mutable?} ? super.dup : super.freeze
21
28
 
22
- descendant.alias_method :deconstruct, :to_a
23
- descendant.alias_method :deconstruct_keys, :to_h
29
+ def self.members = #{members}
30
+
31
+ #{mutable? ? :attr_accessor : :attr_reader} #{keys.map(&:inspect).join ", "}
32
+ METHODS
33
+
34
+ self.class.add_aliases descendant
24
35
  end
25
36
 
26
37
  private
27
38
 
28
- attr_reader :keys
39
+ attr_reader :keys, :kind, :members
40
+
41
+ def setup
42
+ private_methods.grep(/\A(define)_/).sort.each { |method| __send__ method }
43
+ freeze
44
+ end
45
+
46
+ def coalesce_members descendant
47
+ members.replace(descendant.respond_to?(:members) ? (descendant.members + keys).uniq : keys)
48
+ end
49
+
50
+ def mutable? = kind == :mutable
29
51
 
30
52
  def define_diff
31
53
  define_method :diff do |other|
@@ -46,34 +68,36 @@ module Wholeable
46
68
  define_method(:==) { |other| other.is_a?(self.class) && hash == other.hash }
47
69
  end
48
70
 
49
- def define_hash local_keys = keys
71
+ def define_hash
50
72
  define_method :hash do
51
- local_keys.map { |key| public_send key }
52
- .prepend(self.class)
53
- .hash
73
+ members.map { |key| public_send key }
74
+ .prepend(self.class)
75
+ .hash
54
76
  end
55
77
  end
56
78
 
57
- def define_inspect local_keys = keys
79
+ def define_inspect
58
80
  define_method :inspect do
59
81
  klass = self.class
60
82
  name = klass.name || klass.inspect
61
83
 
62
- local_keys.map { |key| "@#{key}=#{public_send(key).inspect}" }
63
- .join(", ")
64
- .then { |pairs| "#<#{name} #{pairs}>" }
84
+ members.map { |key| "@#{key}=#{public_send(key).inspect}" }
85
+ .join(", ")
86
+ .then { |pairs| "#<#{name} #{pairs}>" }
65
87
  end
66
88
  end
67
89
 
68
- def define_to_a local_keys = keys
90
+ def define_members(local_members = members) = define_method(:members) { local_members }
91
+
92
+ def define_to_a
69
93
  define_method :to_a do
70
- local_keys.reduce([]) { |array, key| array.append public_send(key) }
94
+ members.reduce([]) { |collection, key| collection.append public_send(key) }
71
95
  end
72
96
  end
73
97
 
74
- def define_to_h local_keys = keys
98
+ def define_to_h
75
99
  define_method :to_h do
76
- local_keys.each.with_object({}) { |key, dictionary| dictionary[key] = public_send key }
100
+ members.each.with_object({}) { |key, attributes| attributes[key] = public_send key }
77
101
  end
78
102
  end
79
103
 
data/lib/wholeable.rb CHANGED
@@ -4,5 +4,5 @@ require "wholeable/builder"
4
4
 
5
5
  # Main namespace.
6
6
  module Wholeable
7
- def self.[](*) = Builder.new(*)
7
+ def self.[](*, **) = Builder.new(*, **)
8
8
  end
data/wholeable.gemspec CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gem::Specification.new do |spec|
4
4
  spec.name = "wholeable"
5
- spec.version = "0.0.0"
5
+ spec.version = "0.2.0"
6
6
  spec.authors = ["Brooke Kuhlmann"]
7
7
  spec.email = ["brooke@alchemists.io"]
8
8
  spec.homepage = "https://alchemists.io/projects/wholeable"
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wholeable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brooke Kuhlmann
@@ -35,7 +35,7 @@ cert_chain:
35
35
  3n5C8/6Zh9DYTkpcwPSuIfAga6wf4nXc9m6JAw8AuMLaiWN/r/2s4zJsUHYERJEu
36
36
  gZGm4JqtuSg8pYjPeIJxS960owq+SfuC+jxqmRA54BisFCv/0VOJi7tiJVY=
37
37
  -----END CERTIFICATE-----
38
- date: 2024-10-04 00:00:00.000000000 Z
38
+ date: 2024-11-09 00:00:00.000000000 Z
39
39
  dependencies: []
40
40
  description:
41
41
  email:
@@ -80,7 +80,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0'
82
82
  requirements: []
83
- rubygems_version: 3.5.21
83
+ rubygems_version: 3.5.23
84
84
  signing_key:
85
85
  specification_version: 4
86
86
  summary: Provides whole value object behavior.
metadata.gz.sig CHANGED
Binary file