lab42_data_class 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d8b97ca60e61ad4b3ec85555649e5538ca35ef1018bcdf2a1cd9f86d4183718
4
- data.tar.gz: 7076ca2be4a3a8e5428b2fa0fa14523c416d491d14a8ba254842405332215dba
3
+ metadata.gz: 2ae30c490e9b798403214f49c4cbc43564bf47b741878f9aa174e27dc17a85b8
4
+ data.tar.gz: 2e7ab04142e8eb38206ba14bd03464465844b307087708d382e253fc40dd8f1b
5
5
  SHA512:
6
- metadata.gz: c74e746f2f71ac1cbd89a10566b5655e0cb4e2e9d6ff6031e147bb833b8af91b5e1251e89b0ed826401a1915e3654b8543d065138fc705491bf1c684d85d4b97
7
- data.tar.gz: bf15dd5127834637d6c9e0e78cbaf4cb50e75afd03b3ba4d763e186ad65f00df9076892bd151bebad30d4e1d8d49b287225e00ef0ccf6eed3e7965ba1f47af62
6
+ metadata.gz: f40faa136f73115809cea4f73d722188ea62e9299319313b1ffcbcc852c710de0c399c3d1c54dcdb1ae1de57173baabfb66607f636ff62b747f133774e4c205a
7
+ data.tar.gz: 294c975087b12a3cab2731eb78840d735dbdeac923f1c2edb8d5beaf598848caf18d571e3129b42dac0ad2d276657507f4df10268a41863ffb2150ea11df5d8a
data/README.md CHANGED
@@ -1,4 +1,5 @@
1
1
 
2
+
2
3
  [![Issue Count](https://codeclimate.com/github/RobertDober/lab42_data_class/badges/issue_count.svg)](https://codeclimate.com/github/RobertDober/lab42_data_class)
3
4
  [![CI](https://github.com/robertdober/lab42_data_class/workflows/CI/badge.svg)](https://github.com/robertdober/lab42_data_class/actions)
4
5
  [![Coverage Status](https://coveralls.io/repos/github/RobertDober/lab42_data_class/badge.svg?branch=main)](https://coveralls.io/github/RobertDober/lab42_data_class?branch=main)
@@ -11,9 +12,31 @@
11
12
 
12
13
  An Immutable DataClass for Ruby
13
14
 
14
- Exposes a class factory function `Kernel::DataClass` and a class
15
- modifer `Module#dataclass', also creates two _tuple_ classes, `Pair` and
16
- `Triple`
15
+ Exposes a class factory function `Kernel::DataClass` and a module `Lab42::DataClass` which can
16
+ extend classes to become _Data Classes_.
17
+
18
+ Also exposes two _tuple_ classes, `Pair` and `Triple`
19
+
20
+ ## Synopsis
21
+
22
+ Having immutable Objects has many well known advantages that I will not ponder upon in detail here.
23
+
24
+ One advantage which is of particular interest though is that, as every, _modification_ is in fact the
25
+ creation of a new object **strong contraints** on the data can **easily** be maintained, and this
26
+ library makes that available to the user.
27
+
28
+ Therefore we can summarise the features (or not so features, that is for you to decide and you to chose to use or not):
29
+
30
+ - Immutable with an Interface à la `OpenStruct`
31
+ - Attributes are predefined and can have **default values**
32
+ - Construction with _keyword arguments_, **exclusively**
33
+ - Conversion to `Hash` instances (if you must)
34
+ - Pattern matching exactly like `Hash` instances
35
+ - Possibility to impose **strong constraints** on attributes
36
+ - Predefined constraints and concise syntax for constraints
37
+ - Possibility to impose **arbitrary validation** (constraints on the whole object)
38
+ - Declaration of **dependent attributes** which are memoized (thank you _Immutability_)
39
+ - Inheritance with **mixin of other dataclasses** (multiple if you must)
17
40
 
18
41
  ## Usage
19
42
 
@@ -33,424 +56,129 @@ In your code
33
56
  require 'lab42/data_class'
34
57
  ```
35
58
 
59
+ ## Speculations (literate specs)
36
60
 
37
- ## So what does it do?
38
-
39
- Well let us [speculate about](https://github.com/RobertDober/speculate_about) it to find out:
40
-
41
- ## Context `DataClass`
42
-
43
- ### Context: `DataClass` function
44
-
45
- Given
46
- ```ruby
47
- let(:my_data_class) { DataClass(:name, email: nil) }
48
- let(:my_instance) { my_data_class.new(name: "robert") }
49
- ```
50
-
51
- Then we can access its fields
52
- ```ruby
53
- expect(my_instance.name).to eq("robert")
54
- expect(my_instance.email).to be_nil
55
- ```
56
-
57
- But we cannot access undefined fields
58
- ```ruby
59
- expect{ my_instance.undefined }.to raise_error(NoMethodError)
60
- ```
61
+ The following specs are executed with the [speculate about](https://github.com/RobertDober/speculate_about) gem.
61
62
 
62
- And we need to provide values to fields without defaults
63
- ```ruby
64
- expect{ my_data_class.new(email: "some@mail.org") }
65
- .to raise_error(ArgumentError, "missing initializers for [:name]")
66
- ```
67
- And we can extract the values
63
+ Given that we have imported the `Lab42` namespace
68
64
  ```ruby
69
- expect(my_instance.to_h).to eq(name: "robert", email: nil)
65
+ DataClass = Lab42::DataClass
70
66
  ```
71
67
 
72
- #### Context: Immutable → self
73
-
74
- Then `my_instance` is frozen:
75
- ```ruby
76
- expect(my_instance).to be_frozen
77
- ```
78
- And we cannot even mute `my_instance` by means of metaprogramming
79
- ```ruby
80
- expect{ my_instance.instance_variable_set("@x", nil) }.to raise_error(FrozenError)
81
- ```
68
+ ## Context: Data Classes
82
69
 
83
- #### Context: Immutable → Cloning
70
+ ### Basic Use Case
84
71
 
85
- Given
72
+ Given a simple Data Class
86
73
  ```ruby
87
- let(:other_instance) { my_instance.merge(email: "robert@mail.provider") }
88
- ```
89
- Then we have a new instance with the old instance unchanged
90
- ```ruby
91
- expect(other_instance.to_h).to eq(name: "robert", email: "robert@mail.provider")
92
- expect(my_instance.to_h).to eq(name: "robert", email: nil)
93
- ```
94
- And the new instance is frozen again
95
- ```ruby
96
- expect(other_instance).to be_frozen
97
- ```
98
-
99
- ### Context: Defining behavior with blocks
100
-
101
- Given
102
- ```ruby
103
- let :my_data_class do
104
- DataClass :value, prefix: "<", suffix: ">" do
105
- def show
106
- [prefix, value, suffix].join
107
- end
108
- end
74
+ class SimpleDataClass
75
+ extend DataClass
76
+ attributes :a, :b
109
77
  end
110
- let(:my_instance) { my_data_class.new(value: 42) }
111
78
  ```
112
79
 
113
- Then I have defined a method on my dataclass
80
+ And an instance of it
114
81
  ```ruby
115
- expect(my_instance.show).to eq("<42>")
82
+ let(:simple_instance) { SimpleDataClass.new(a: 1, b: 2) }
116
83
  ```
117
84
 
118
- ### Context: Equality
119
-
120
- Given two instances of a DataClass
121
- ```ruby
122
- let(:data_class) { DataClass :a }
123
- let(:instance1) { data_class.new(a: 1) }
124
- let(:instance2) { data_class.new(a: 1) }
125
- ```
126
- Then they are equal in the sense of `==` and `eql?`
85
+ Then we access the fields
127
86
  ```ruby
128
- expect(instance1).to eq(instance2)
129
- expect(instance2).to eq(instance1)
130
- expect(instance1 == instance2).to be_truthy
131
- expect(instance2 == instance1).to be_truthy
87
+ expect(simple_instance.a).to eq(1)
88
+ expect(simple_instance.b).to eq(2)
132
89
  ```
133
- But not in the sense of `equal?`, of course
134
- ```ruby
135
- expect(instance1).not_to be_equal(instance2)
136
- expect(instance2).not_to be_equal(instance1)
137
- ```
138
-
139
- #### Context: Immutability of `dataclass` modified classes
140
90
 
141
- Then we still get frozen instances
91
+ And we convert to a hash
142
92
  ```ruby
143
- expect(instance1).to be_frozen
93
+ expect(simple_instance.to_h).to eq(a: 1, b: 2)
144
94
  ```
145
95
 
146
- ### Context: Inheritance
147
-
148
- ... is a no, we do not want inheritance although we **like** code reuse, how to do it then?
149
-
150
- Well there shall be many different possibilities, depending on your style, use case and
151
- context, here is just one example:
152
-
153
- Given a class factory
96
+ And we can derive new instances
154
97
  ```ruby
155
- let :token do
156
- ->(*a, **k) do
157
- DataClass(*a, **(k.merge(text: "")))
158
- end
159
- end
98
+ new_instance = simple_instance.merge(b: 3)
99
+ expect(new_instance.to_h).to eq(a: 1, b: 3)
100
+ expect(simple_instance.to_h).to eq(a: 1, b: 2)
160
101
  ```
161
102
 
162
- Then we have reused the `token` successfully
163
- ```ruby
164
- empty = token.()
165
- integer = token.(:value)
166
- boolean = token.(value: false)
103
+ For detailed speculations please see [here](speculations/DATA_CLASSES.md)
167
104
 
168
- expect(empty.new.to_h).to eq(text: "")
169
- expect(integer.new(value: -1).to_h).to eq(text: "", value: -1)
170
- expect(boolean.new.value).to eq(false)
171
- ```
105
+ ## Context: `DataClass` function
172
106
 
173
- #### Context: Mixing in a module can be used of course
107
+ As seen in the speculations above it seems appropriate to declare a `Class` and
108
+ extend it as we will add quite some code for constraints, derived attributes and validations.
174
109
 
175
- Given a behavior like
176
- ```ruby
177
- module Humanize
178
- def humanize
179
- "my value is #{value}"
180
- end
181
- end
182
-
183
- let(:class_level) { DataClass(value: 1).include(Humanize) }
184
- ```
185
-
186
- Then we can access the included method
187
- ```ruby
188
- expect(class_level.new.humanize).to eq("my value is 1")
189
- ```
190
-
191
- ### Context: Pattern Matching
110
+ However a more concise _Factory Function_ might still be very useful in some use cases...
192
111
 
193
- A `DataClass` object behaves like the result of it's `to_h` in pattern matching
112
+ Enter `Kernel::DataClass` **The Function**
194
113
 
195
- Given
196
- ```ruby
197
- let(:numbers) { DataClass(:name, values: []) }
198
- let(:odds) { numbers.new(name: "odds", values: (1..4).map{ _1 + _1 + 1}) }
199
- let(:evens) { numbers.new(name: "evens", values: (1..4).map{ _1 + _1}) }
200
- ```
114
+ ### Context: Just Attributes
201
115
 
202
- Then we can match accordingly
203
- ```ruby
204
- match = case odds
205
- in {name: "odds", values: [1, *]}
206
- :not_really
207
- in {name: "evens"}
208
- :still_naaah
209
- in {name: "odds", values: [hd, *]}
210
- hd
211
- else
212
- :strange
213
- end
214
- expect(match).to eq(3)
215
- ```
216
-
217
- And in `in` expressions
218
- ```ruby
219
- evens => {values: [_, second, *]}
220
- expect(second).to eq(4)
221
- ```
222
-
223
- #### Context: In Case Statements
224
-
225
- Given a nice little dataclass `Box`
226
- ```ruby
227
- let(:box) { DataClass content: nil }
228
- ```
229
-
230
- Then we can also use it in a case statement
231
- ```ruby
232
- value = case box.new
233
- when box
234
- 42
235
- else
236
- 0
237
- end
238
- expect(value).to eq(42)
239
- ```
116
+ If there are no _Constraints_, _Derived Attributes_, _Validation_ or _Inheritance_ this concise syntax
117
+ might easily be preferred by many:
240
118
 
241
- And all the associated methods
119
+ Given some example instances like these
242
120
  ```ruby
243
- expect(box.new).to be_a(box)
244
- expect(box === box.new).to be_truthy
245
- ```
246
-
247
- ### Context: Behaving like a `Proc`
248
-
249
- It is useful to be able to filter heterogeneous lists of `DataClass` instances by means of `&to_proc`, therefore
250
-
251
- Given two different `DataClass` objects
252
- ```ruby
253
- let(:class1) { DataClass :value }
254
- let(:class2) { DataClass :value }
255
- ```
256
-
257
- And a list of instances
258
- ```ruby
259
- let(:list) {[class1.new(value: 1), class2.new(value: 2), class1.new(value: 3)]}
260
- ```
261
-
262
- Then we can filter
263
- ```ruby
264
- expect(list.filter(&class2)).to eq([class2.new(value: 2)])
265
- ```
266
-
267
- ### Context: Behaving like a `Hash`
268
-
269
- We have already seen the `to_h` method, however if we want to pass an instance of `DataClass` as
270
- keyword parameters we need an implementation of `to_hash`, which of course is just an alias
271
-
272
- Given this keyword method
273
- ```ruby
274
- def extract_value(value:, **others)
275
- [value, others]
276
- end
277
- ```
278
- And this `DataClass`:
279
- ```ruby
280
- let(:my_class) { DataClass(value: 1, base: 2) }
281
- ```
282
-
283
- Then we can pass it as keyword arguments
284
- ```ruby
285
- expect(extract_value(**my_class.new)).to eq([1, base: 2])
286
- ```
287
-
288
- ### Context: Constraints
289
-
290
- Values of attributes of a `DataClass` can have constraints
291
-
292
- Given a `DataClass` with constraints
293
- ```ruby
294
- let :switch do
295
- DataClass(on: false).with_constraint(on: -> { [false, true].member? _1 })
296
- end
297
- ```
298
-
299
- Then boolean values are acceptable
300
- ```ruby
301
- expect{ switch.new }.not_to raise_error
302
- expect(switch.new.merge(on: true).on).to eq(true)
303
- ```
304
-
305
- But we can neither construct or merge with non boolean values
306
- ```ruby
307
- expect{ switch.new(on: nil) }
308
- .to raise_error(Lab42::DataClass::ConstraintError, "value nil is not allowed for attribute :on")
309
- expect{ switch.new.merge(on: 42) }
310
- .to raise_error(Lab42::DataClass::ConstraintError, "value 42 is not allowed for attribute :on")
121
+ let(:my_data_class) { DataClass(:name, email: nil) }
122
+ let(:my_instance) { my_data_class.new(name: "robert") }
311
123
  ```
312
124
 
313
- And therefore defaultless attributes cannot have a constraint that is violated by a nil value
125
+ Then we can access its fields
314
126
  ```ruby
315
- error_head = "constraint error during validation of default value of attribute :value"
316
- error_body = " undefined method `>' for nil:NilClass"
317
- error_message = [error_head, error_body].join("\n")
318
-
319
- expect{ DataClass(value: nil).with_constraint(value: -> { _1 > 0 }) }
320
- .to raise_error(Lab42::DataClass::ConstraintError, /#{error_message}/)
127
+ expect(my_instance.name).to eq("robert")
128
+ expect(my_instance[:email]).to be_nil
321
129
  ```
322
130
 
323
- And defining constraints for undefined attributes is not the best of ideas
131
+ But we cannot access undefined fields
324
132
  ```ruby
325
- expect { DataClass(a: 1).with_constraint(b: -> {true}) }
326
- .to raise_error(ArgumentError, "constraints cannot be defined for undefined attributes [:b]")
133
+ expect{ my_instance.undefined }.to raise_error(NoMethodError)
327
134
  ```
328
135
 
329
-
330
- #### Context: Convenience Constraints
331
-
332
- Often repeating patterns are implemented as non lambda constraints, depending on the type of a constraint
333
- it is implicitly converted to a lambda as specified below:
334
-
335
- Given a shortcut for our `ConstraintError`
136
+ And this is even true for the `[]` syntax
336
137
  ```ruby
337
- let(:constraint_error) { Lab42::DataClass::ConstraintError }
338
- let(:positive) { DataClass(:value) }
138
+ expect{ my_instance[:undefined] }.to raise_error(KeyError)
339
139
  ```
340
140
 
341
- ##### Symbols
342
-
343
- ... are sent to the value of the attribute, this is not very surprising of course ;)
344
-
345
- Then a first implementation of `Positive`
141
+ And we need to provide values to fields without defaults
346
142
  ```ruby
347
- positive_by_symbol = positive.with_constraint(value: :positive?)
348
-
349
- expect(positive_by_symbol.new(value: 1).value).to eq(1)
350
- expect{positive_by_symbol.new(value: 0)}.to raise_error(constraint_error)
143
+ expect{ my_data_class.new(email: "some@mail.org") }
144
+ .to raise_error(ArgumentError, "missing initializers for [:name]")
351
145
  ```
352
-
353
- ##### Arrays
354
-
355
- ... are also sent to the value of the attribute, this time we can provide paramaters
356
- And we can implement a different form of `Positive`
146
+ And we can extract the values
357
147
  ```ruby
358
- positive_by_ary = positive.with_constraint(value: [:>, 0])
359
-
360
- expect(positive_by_ary.new(value: 1).value).to eq(1)
361
- expect{positive_by_ary.new(value: 0)}.to raise_error(constraint_error)
148
+ expect(my_instance.to_h).to eq(name: "robert", email: nil)
362
149
  ```
363
150
 
364
- If however we are interested in membership we have to wrap the `Array` into a `Set`
365
-
366
- ##### Membership
367
-
368
- And this works with a `Set`
369
- ```ruby
370
- positive_by_set = positive.with_constraint(value: Set.new([*1..10]))
371
-
372
- expect(positive_by_set.new(value: 1).value).to eq(1)
373
- expect{positive_by_set.new(value: 0)}.to raise_error(constraint_error)
374
- ```
151
+ #### Context: Immutable self
375
152
 
376
- And also with a `Range`
153
+ Then `my_instance` is frozen:
377
154
  ```ruby
378
- positive_by_range = positive.with_constraint(value: 1..Float::INFINITY)
379
-
380
- expect(positive_by_range.new(value: 1).value).to eq(1)
381
- expect{positive_by_range.new(value: 0)}.to raise_error(constraint_error)
155
+ expect(my_instance).to be_frozen
382
156
  ```
383
-
384
- ##### Regexen
385
-
386
- This seems quite obvious, and of course it works
387
-
388
- Then we can also have a regex based constraint
157
+ And we cannot even mute `my_instance` by means of metaprogramming
389
158
  ```ruby
390
- vowel = DataClass(:word).with_constraint(word: /[aeiou]/)
391
-
392
- expect(vowel.new(word: "alpha").word).to eq("alpha")
393
- expect{vowel.new(word: "krk")}.to raise_error(constraint_error)
159
+ expect{ my_instance.instance_variable_set("@x", nil) }.to raise_error(FrozenError)
394
160
  ```
395
161
 
396
- ##### Other callable objects as constraints
397
-
398
-
399
- Then we can also use instance methods to implement our `Positive`
400
- ```ruby
401
- positive_by_instance_method = positive.with_constraint(value: Fixnum.instance_method(:positive?))
402
-
403
- expect(positive_by_instance_method.new(value: 1).value).to eq(1)
404
- expect{positive_by_instance_method.new(value: 0)}.to raise_error(constraint_error)
405
- ```
162
+ #### Context: Immutable Cloning
406
163
 
407
- Or we can use methods to implement it
164
+ Given
408
165
  ```ruby
409
- positive_by_method = positive.with_constraint(value: 0.method(:<))
410
-
411
- expect(positive_by_method.new(value: 1).value).to eq(1)
412
- expect{positive_by_method.new(value: 0)}.to raise_error(constraint_error)
166
+ let(:other_instance) { my_instance.merge(email: "robert@mail.provider") }
413
167
  ```
414
-
415
- #### Context: Global Constraints aka __Validations__
416
-
417
- So far we have only speculated about constraints concerning one attribute, however sometimes we want
418
- to have arbitrary constraints which can only be calculated by access to more attributes
419
-
420
- Given a `Point` DataClass
168
+ Then we have a new instance with the old instance unchanged
421
169
  ```ruby
422
- let(:point) { DataClass(:x, :y).validate{ |point| point.x > point.y } }
423
- let(:validation_error) { Lab42::DataClass::ValidationError }
170
+ expect(other_instance.to_h).to eq(name: "robert", email: "robert@mail.provider")
171
+ expect(my_instance.to_h).to eq(name: "robert", email: nil)
424
172
  ```
425
-
426
- Then we will get a `ValidationError` if we construct a point left of the main diagonal
173
+ And the new instance is frozen again
427
174
  ```ruby
428
- expect{ point.new(x: 0, y: 1) }
429
- .to raise_error(validation_error)
175
+ expect(other_instance).to be_frozen
430
176
  ```
431
177
 
432
- But as validation might need more than the default values we will not execute them at compile time
433
- ```ruby
434
- expect{ DataClass(x: 0, y: 0).validate{ |inst| inst.x > inst.y } }
435
- .to_not raise_error
436
- ```
178
+ For speculations how to add all the other features to the _Factory Function_ syntax please
179
+ look [here](speculations/FACTORY_FUNCTION.md)
437
180
 
438
- And we can name validations to get better error messages
439
- ```ruby
440
- better_point = DataClass(:x, :y).validate(:too_left){ |point| point.x > point.y }
441
- ok_point = better_point.new(x: 1, y: 0)
442
- expect{ ok_point.merge(y: 1) }
443
- .to raise_error(validation_error, "too_left")
444
- ```
445
181
 
446
- And remark how bad unnamed validation errors might be
447
- ```ruby
448
- error_message_rgx = %r{
449
- \#<Proc:0x[0-9a-f]+ \s .* spec/speculations/README_spec\.rb: \d+ > \z
450
- }x
451
- expect{ point.new(x: 0, y: 1) }
452
- .to raise_error(validation_error, error_message_rgx)
453
- ```
454
182
 
455
183
  ## Context: `Pair` and `Triple`
456
184
 
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab42
4
+ module DataClass
5
+ class DuplicateDefinitionError < RuntimeError
6
+ end
7
+ end
8
+ end
9
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kernel
4
+ def DataClass(*args, **kwds, &blk)
5
+ proxy = Lab42::DataClass::Proxy.new(*args, **kwds, &blk)
6
+ proxy.define_class!
7
+ end
8
+
9
+ def Pair(first, second)
10
+ Lab42::Pair.new(first, second)
11
+ end
12
+
13
+ def Triple(first, second, third)
14
+ Lab42::Triple.new(first, second, third)
15
+ end
16
+ end
17
+ # SPDX-License-Identifier: Apache-2.0
@@ -19,6 +19,8 @@ module Lab42
19
19
  -> { constraint.match?(_1) }
20
20
  when UnboundMethod
21
21
  -> { constraint.bind(_1).() }
22
+ when Module
23
+ -> { constraint === _1 }
22
24
  else
23
25
  _make_member_constraint(constraint)
24
26
  end
@@ -23,6 +23,16 @@ module Lab42
23
23
  end
24
24
  end
25
25
 
26
+ def define_constraints(constraints)
27
+ errors = constraints.map(&define_constraint).compact
28
+ unless errors.empty?
29
+ raise ArgumentError,
30
+ "constraints cannot be defined for undefined attributes #{errors.inspect}"
31
+ end
32
+
33
+ check_constraints_against_defaults(constraints)
34
+ end
35
+
26
36
  private
27
37
 
28
38
  def _check_constraint_against_default
@@ -34,7 +44,7 @@ module Lab42
34
44
  end
35
45
 
36
46
  def _check_constraint_against_default_value(attr, value, constraint)
37
- unless constraint.(value)
47
+ unless Maker.make_constraint(constraint).(value)
38
48
  "default value #{value.inspect} is not allowed for attribute #{attr.inspect}"
39
49
  end
40
50
  rescue StandardError => e
@@ -68,13 +78,7 @@ module Lab42
68
78
  proxy = self
69
79
  ->(*) do
70
80
  define_method :with_constraint do |**constraints|
71
- errors = constraints.map(&proxy.define_constraint).compact
72
- unless errors.empty?
73
- raise ArgumentError,
74
- "constraints cannot be defined for undefined attributes #{errors.inspect}"
75
- end
76
-
77
- proxy.check_constraints_against_defaults(constraints)
81
+ proxy.define_constraints(constraints)
78
82
  self
79
83
  end
80
84
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab42
4
+ module DataClass
5
+ class Proxy
6
+ module Derived
7
+ private
8
+ def _define_derived
9
+ proxy = self
10
+ ->(*) do
11
+ define_method :derive do |att_name, &blk|
12
+ proxy.define_derived_attribute(att_name, &blk)
13
+ self
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab42
4
+ module DataClass
5
+ class Proxy
6
+ module Memos
7
+ def all_attributes
8
+ @__all_attributes__ ||= members&.union(Set.new(derived_attributes.keys))
9
+ end
10
+
11
+ def constraints
12
+ @__constraints__ ||= Hash.new { |h, k| h[k] = [] }
13
+ end
14
+
15
+ def defaults
16
+ @__defaults__ ||= {}
17
+ end
18
+
19
+ def derived_attributes
20
+ @__derived_attributes__ ||= {}
21
+ end
22
+
23
+ def members
24
+ @__members__ ||= unless (positionals + defaults.keys).empty?
25
+ Set.new(positionals + defaults.keys)
26
+ end
27
+ end
28
+
29
+ def positionals
30
+ @__positionals__ ||= []
31
+ end
32
+
33
+ def validations
34
+ @__validations__ ||= []
35
+ end
36
+
37
+ private
38
+
39
+ def _missing_initializers
40
+ @___missing_initializers__ ||=
41
+ positionals - actual_params.keys
42
+ end
43
+
44
+ def _illegal_initializers
45
+ @___illegal_initializers__ ||=
46
+ actual_params.keys - positionals - defaults.keys
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ # SPDX-License-Identifier: Apache-2.0
@@ -2,58 +2,89 @@
2
2
 
3
3
  require 'set'
4
4
  require_relative 'proxy/constraints'
5
+ require_relative 'proxy/derived'
6
+ require_relative 'proxy/memos'
5
7
  require_relative 'proxy/validations'
6
8
  require_relative 'proxy/mixin'
7
9
  module Lab42
8
10
  module DataClass
9
11
  class Proxy
10
- include Constraints, Validations
12
+ include Constraints, Derived, Memos, Validations
11
13
 
12
- attr_reader :actual_params, :all_params, :block, :constraints, :defaults, :klass, :members, :positionals
14
+ attr_reader :actual_params, :block, :klass, :klass_defined
15
+
16
+ def self.from_parent(parent, klass)
17
+ new(klass).tap do |proxy|
18
+ proxy.positionals.push(*parent.positionals)
19
+ proxy.defaults.update(parent.defaults)
20
+ proxy.constraints.update(parent.constraints)
21
+ proxy.validations.push(*parent.validations)
22
+ end
23
+ end
24
+
25
+ def access(data_class_instance, key)
26
+ if all_attributes.member?(key)
27
+ data_class_instance.send(key)
28
+ else
29
+ raise KeyError, "#{key} is not an attribute of #{data_class_instance}"
30
+ end
31
+ end
13
32
 
14
33
  def check!(**params)
15
34
  @actual_params = params
16
- @all_params = defaults.merge(params)
17
35
  raise ArgumentError, "missing initializers for #{_missing_initializers}" unless _missing_initializers.empty?
18
36
  raise ArgumentError, "illegal initializers #{_illegal_initializers}" unless _illegal_initializers.empty?
19
37
 
20
- _check_constraints!(all_params)
38
+ _check_constraints!(defaults.merge(params))
21
39
  end
22
40
 
23
41
  def define_class!
42
+ return if @klass_defined
43
+
44
+ @klass_defined = true
24
45
  klass.module_eval(&_define_attr_reader)
25
- klass.module_eval(&_define_initializer)
46
+ klass.module_eval(&_define_initializer) if Class === klass
26
47
  _define_methods
27
48
  klass.include(Mixin)
28
49
  klass.module_eval(&block) if block
29
50
  klass
30
51
  end
31
52
 
53
+ def define_derived_attribute(name, &blk)
54
+ positionals.delete(name)
55
+ defaults.delete(name)
56
+ derived_attributes.update(name => true) do |_key, _old,|
57
+ raise DuplicateDefinitionError, "Redefinition of derived attribute #{name}"
58
+ end
59
+ klass.module_eval(&_define_derived_attribute(name, &blk))
60
+ end
61
+
32
62
  def init(data_class, **params)
33
63
  _init(data_class, defaults.merge(params))
34
64
  end
35
65
 
36
66
  def to_hash(data_class_instance)
37
- members
38
- .map { [_1, data_class_instance.instance_variable_get("@#{_1}")] }
39
- .to_h
67
+ all_attributes
68
+ .inject({}) { |result, (k, _)| result.merge(k => data_class_instance[k]) }
40
69
  end
41
70
 
42
- def validations
43
- @__validations__ ||= []
71
+ def update!(with_positionals, with_keywords)
72
+ positionals.push(*with_positionals)
73
+ defaults.update(with_keywords)
44
74
  end
45
75
 
46
76
  private
47
77
  def initialize(*args, **kwds, &blk)
48
- @klass = Class.new
49
-
50
- @constraints = Hash.new { |h, k| h[k] = [] }
78
+ @klass = if Module === args.first
79
+ args.shift
80
+ else
81
+ Class.new
82
+ end
51
83
 
84
+ @klass_defined = false
52
85
  @block = blk
53
- @defaults = kwds
54
- @members = Set.new(args + kwds.keys)
55
- # TODO: Check for all symbols and no duplicates ⇒ v0.5.1
56
- @positionals = args
86
+ defaults.update(kwds)
87
+ positionals.push(*args)
57
88
  end
58
89
 
59
90
  def _define_attr_reader
@@ -63,6 +94,12 @@ module Lab42
63
94
  end
64
95
  end
65
96
 
97
+ def _define_derived_attribute(name, &blk)
98
+ ->(*) do
99
+ define_method(name) { blk.call(self) }
100
+ end
101
+ end
102
+
66
103
  def _define_freezing_constructor
67
104
  ->(*) do
68
105
  define_method :new do |*a, **p, &b|
@@ -92,8 +129,8 @@ module Lab42
92
129
  end
93
130
 
94
131
  def _define_methods
95
- class << klass; self end
96
- .tap { |singleton| _define_singleton_methods(singleton) }
132
+ _define_singleton_methods(klass.singleton_class)
133
+ klass.module_eval(&_define_access)
97
134
  klass.module_eval(&_define_to_h)
98
135
  klass.module_eval(&_define_merge)
99
136
  end
@@ -102,9 +139,19 @@ module Lab42
102
139
  singleton.module_eval(&_define_freezing_constructor)
103
140
  singleton.module_eval(&_define_to_proc)
104
141
  singleton.module_eval(&_define_with_constraint)
142
+ singleton.module_eval(&_define_derived)
105
143
  singleton.module_eval(&_define_with_validations)
106
144
  end
107
145
 
146
+ def _define_access
147
+ proxy = self
148
+ ->(*) do
149
+ define_method :[] do |key|
150
+ proxy.access(self, key)
151
+ end
152
+ end
153
+ end
154
+
108
155
  def _define_to_h
109
156
  proxy = self
110
157
  ->(*) do
@@ -128,16 +175,6 @@ module Lab42
128
175
  data_class_instance.instance_variable_set("@#{key}", value)
129
176
  end
130
177
  end
131
-
132
- def _missing_initializers
133
- @___missing_initializers__ ||=
134
- positionals - actual_params.keys
135
- end
136
-
137
- def _illegal_initializers
138
- @___illegal_initializers__ ||=
139
- actual_params.keys - positionals - defaults.keys
140
- end
141
178
  end
142
179
  end
143
180
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Lab42
4
4
  module DataClass
5
- VERSION = "0.5.0"
5
+ VERSION = "0.7.0"
6
6
  end
7
7
  end
8
8
  # SPDX-License-Identifier: Apache-2.0
@@ -1,24 +1,49 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative './data_class/constraint_error'
4
+ require_relative './data_class/duplicate_definition_error'
5
+ require_relative './data_class/kernel'
4
6
  require_relative './data_class/validation_error'
5
7
  require_relative './data_class/proxy'
6
8
  require_relative './pair'
7
9
  require_relative './triple'
8
10
 
9
- module Kernel
10
- def DataClass(*args, **kwds, &blk)
11
- proxy = Lab42::DataClass::Proxy.new(*args, **kwds, &blk)
12
- proxy.define_class!
13
- end
11
+ module Lab42
12
+ module DataClass
13
+ def self.extended(extendee)
14
+ base_proxy =
15
+ extendee
16
+ .ancestors
17
+ .grep(self)
18
+ .drop(1)
19
+ .first
20
+ &.__data_class_proxy__
14
21
 
15
- def Pair(first, second)
16
- Lab42::Pair.new(first, second)
17
- end
22
+ proxy = base_proxy ? Proxy.from_parent(base_proxy, extendee) : Proxy.new(extendee)
23
+
24
+ extendee.module_eval do
25
+ define_singleton_method(:__data_class_proxy__){ proxy }
26
+ end
27
+ end
28
+
29
+ def attributes(*args, **kwds)
30
+ __data_class_proxy__.tap do |proxy|
31
+ proxy.update!(args, kwds)
32
+ proxy.define_class!
33
+ end
34
+ end
18
35
 
19
- def Triple(first, second, third)
20
- Lab42::Triple.new(first, second, third)
36
+ def derive(att_name, &blk)
37
+ __data_class_proxy__.define_derived_attribute(att_name, &blk)
38
+ __data_class_proxy__.define_class!
39
+ end
40
+
41
+ def constraint(member, constraint = nil, &block)
42
+ raise ArgumentError, "must not provide constraint (2nd argument) and a block" if block && constraint
43
+
44
+ __data_class_proxy__.define_constraints(member => constraint || block)
45
+ __data_class_proxy__.define_class!
46
+ end
21
47
  end
22
48
  end
23
-
24
49
  # SPDX-License-Identifier: Apache-2.0
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lab42_data_class
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Dober
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-02-23 00:00:00.000000000 Z
11
+ date: 2022-02-27 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  An Immutable DataClass for Ruby
@@ -25,9 +25,13 @@ files:
25
25
  - README.md
26
26
  - lib/lab42/data_class.rb
27
27
  - lib/lab42/data_class/constraint_error.rb
28
+ - lib/lab42/data_class/duplicate_definition_error.rb
29
+ - lib/lab42/data_class/kernel.rb
28
30
  - lib/lab42/data_class/proxy.rb
29
31
  - lib/lab42/data_class/proxy/constraints.rb
30
32
  - lib/lab42/data_class/proxy/constraints/maker.rb
33
+ - lib/lab42/data_class/proxy/derived.rb
34
+ - lib/lab42/data_class/proxy/memos.rb
31
35
  - lib/lab42/data_class/proxy/mixin.rb
32
36
  - lib/lab42/data_class/proxy/validations.rb
33
37
  - lib/lab42/data_class/validation_error.rb