lab42_data_class 0.5.0 → 0.7.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: 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