lab42_data_class 0.5.1 → 0.7.1

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: 70b03224373a4bdfe219cfb5d71d735f4a5b4d02c57531ea0f80a1729e542ce5
4
- data.tar.gz: b04020b335831a2c8fb8070bb17056874bb9103c5dda7b0e313da93c8afd80a0
3
+ metadata.gz: 8fdfa61b721a7e006b42dd59c3dc61c4f3442faaa0d50feeaa5ed4b20ea5838c
4
+ data.tar.gz: fa5d7ab0e6133c398caaaa1762e75a5820123e67088acc4750c36e9246ff2dc0
5
5
  SHA512:
6
- metadata.gz: c360a0eb554b9356c92e0a395d09a2dd29dc76f64d2a805ef6ac8e1699a057bb01fc8a41be4bf2f34f3e3ec5d9eff336817541bc59d39f7d94c74131303e2f0d
7
- data.tar.gz: 63395d4ede760ce63e7d92753ef370b2da666bafec309dee43f8c8549e768cc653d1976742441b583fd09a1a85535cc783834998ce494b903f2a40d380073a46
6
+ metadata.gz: 53af67931648a621cd0ff18912ce191dc2d0a005eacfd4df741bbdf8a58a9c695e168055129c76be08efcb4810d3910a5df362512996c8d2375da6ff17b61be8
7
+ data.tar.gz: c65a655f447f154b1a4e7584393fe3058403ef53cf5651a51fd39e5af7ebbd88768e90dc9b8d1a7d582316a35d2786abff6c6a04cc2f445f8d725657773c9a21
data/README.md CHANGED
@@ -8,17 +8,38 @@
8
8
 
9
9
  # Lab42::DataClass
10
10
 
11
-
12
11
  An Immutable DataClass for Ruby
13
12
 
14
- Exposes a class factory function `Kernel::DataClass` and a class
15
- modifer `Module#dataclass`, also creates two _tuple_ classes, `Pair` and
16
- `Triple`
13
+ Exposes a class factory function `Kernel::DataClass` and a module `Lab42::DataClass` which can
14
+ extend classes to become _Data Classes_.
15
+
16
+ Also exposes two _tuple_ classes, `Pair` and `Triple`
17
+
18
+ ## Synopsis
19
+
20
+ Having immutable Objects has many well known advantages that I will not ponder upon in detail here.
21
+
22
+ One advantage which is of particular interest though is that, as every, _modification_ is in fact the
23
+ creation of a new object **strong contraints** on the data can **easily** be maintained, and this
24
+ library makes that available to the user.
25
+
26
+ Therefore we can summarise the features (or not so features, that is for you to decide and you to chose to use or not):
27
+
28
+ - Immutable with an Interface à la `OpenStruct`
29
+ - Attributes are predefined and can have **default values**
30
+ - Construction with _keyword arguments_, **exclusively**
31
+ - Conversion to `Hash` instances (if you must)
32
+ - Pattern matching exactly like `Hash` instances
33
+ - Possibility to impose **strong constraints** on attributes
34
+ - Predefined constraints and concise syntax for constraints
35
+ - Possibility to impose **arbitrary validation** (constraints on the whole object)
36
+ - Declaration of **dependent attributes** which are memoized (thank you _Immutability_)
37
+ - Inheritance with **mixin of other dataclasses** (multiple if you must)
17
38
 
18
39
  ## Usage
19
40
 
20
41
  ```sh
21
- gem install lab42_data_class
42
+ gem install lab42_data_class
22
43
  ```
23
44
 
24
45
  With bundler
@@ -33,424 +54,129 @@ In your code
33
54
  require 'lab42/data_class'
34
55
  ```
35
56
 
57
+ ## Speculations (literate specs)
36
58
 
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
- ```
59
+ The following specs are executed with the [speculate about](https://github.com/RobertDober/speculate_about) gem.
61
60
 
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
61
+ Given that we have imported the `Lab42` namespace
68
62
  ```ruby
69
- expect(my_instance.to_h).to eq(name: "robert", email: nil)
63
+ DataClass = Lab42::DataClass
70
64
  ```
71
65
 
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
- ```
66
+ ## Context: Data Classes
82
67
 
83
- #### Context: Immutable → Cloning
68
+ ### Basic Use Case
84
69
 
85
- Given
70
+ Given a simple Data Class
86
71
  ```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
72
+ class SimpleDataClass
73
+ extend DataClass
74
+ attributes :a, :b
109
75
  end
110
- let(:my_instance) { my_data_class.new(value: 42) }
111
76
  ```
112
77
 
113
- Then I have defined a method on my dataclass
78
+ And an instance of it
114
79
  ```ruby
115
- expect(my_instance.show).to eq("<42>")
80
+ let(:simple_instance) { SimpleDataClass.new(a: 1, b: 2) }
116
81
  ```
117
82
 
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?`
83
+ Then we access the fields
127
84
  ```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
85
+ expect(simple_instance.a).to eq(1)
86
+ expect(simple_instance.b).to eq(2)
132
87
  ```
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
88
 
141
- Then we still get frozen instances
89
+ And we convert to a hash
142
90
  ```ruby
143
- expect(instance1).to be_frozen
91
+ expect(simple_instance.to_h).to eq(a: 1, b: 2)
144
92
  ```
145
93
 
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
94
+ And we can derive new instances
154
95
  ```ruby
155
- let :token do
156
- ->(*a, **k) do
157
- DataClass(*a, **(k.merge(text: "")))
158
- end
159
- end
96
+ new_instance = simple_instance.merge(b: 3)
97
+ expect(new_instance.to_h).to eq(a: 1, b: 3)
98
+ expect(simple_instance.to_h).to eq(a: 1, b: 2)
160
99
  ```
161
100
 
162
- Then we have reused the `token` successfully
163
- ```ruby
164
- empty = token.()
165
- integer = token.(:value)
166
- boolean = token.(value: false)
101
+ For detailed speculations please see [here](speculations/DATA_CLASSES.md)
167
102
 
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
- ```
103
+ ## Context: `DataClass` function
172
104
 
173
- #### Context: Mixing in a module can be used of course
105
+ As seen in the speculations above it seems appropriate to declare a `Class` and
106
+ extend it as we will add quite some code for constraints, derived attributes and validations.
174
107
 
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
108
+ However a more concise _Factory Function_ might still be very useful in some use cases...
192
109
 
193
- A `DataClass` object behaves like the result of it's `to_h` in pattern matching
110
+ Enter `Kernel::DataClass` **The Function**
194
111
 
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
- ```
112
+ ### Context: Just Attributes
201
113
 
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
- ```
114
+ If there are no _Constraints_, _Derived Attributes_, _Validation_ or _Inheritance_ this concise syntax
115
+ might easily be preferred by many:
240
116
 
241
- And all the associated methods
117
+ Given some example instances like these
242
118
  ```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")
119
+ let(:my_data_class) { DataClass(:name, email: nil) }
120
+ let(:my_instance) { my_data_class.new(name: "robert") }
311
121
  ```
312
122
 
313
- And therefore defaultless attributes cannot have a constraint that is violated by a nil value
123
+ Then we can access its fields
314
124
  ```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}/)
125
+ expect(my_instance.name).to eq("robert")
126
+ expect(my_instance[:email]).to be_nil
321
127
  ```
322
128
 
323
- And defining constraints for undefined attributes is not the best of ideas
129
+ But we cannot access undefined fields
324
130
  ```ruby
325
- expect { DataClass(a: 1).with_constraint(b: -> {true}) }
326
- .to raise_error(ArgumentError, "constraints cannot be defined for undefined attributes [:b]")
131
+ expect{ my_instance.undefined }.to raise_error(NoMethodError)
327
132
  ```
328
133
 
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`
134
+ And this is even true for the `[]` syntax
336
135
  ```ruby
337
- let(:constraint_error) { Lab42::DataClass::ConstraintError }
338
- let(:positive) { DataClass(:value) }
136
+ expect{ my_instance[:undefined] }.to raise_error(KeyError)
339
137
  ```
340
138
 
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`
139
+ And we need to provide values to fields without defaults
346
140
  ```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)
141
+ expect{ my_data_class.new(email: "some@mail.org") }
142
+ .to raise_error(ArgumentError, "missing initializers for [:name]")
351
143
  ```
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`
144
+ And we can extract the values
357
145
  ```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)
146
+ expect(my_instance.to_h).to eq(name: "robert", email: nil)
362
147
  ```
363
148
 
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
- ```
149
+ #### Context: Immutable self
375
150
 
376
- And also with a `Range`
151
+ Then `my_instance` is frozen:
377
152
  ```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)
153
+ expect(my_instance).to be_frozen
382
154
  ```
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
155
+ And we cannot even mute `my_instance` by means of metaprogramming
389
156
  ```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)
157
+ expect{ my_instance.instance_variable_set("@x", nil) }.to raise_error(FrozenError)
394
158
  ```
395
159
 
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
- ```
160
+ #### Context: Immutable Cloning
406
161
 
407
- Or we can use methods to implement it
162
+ Given
408
163
  ```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)
164
+ let(:other_instance) { my_instance.merge(email: "robert@mail.provider") }
413
165
  ```
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
166
+ Then we have a new instance with the old instance unchanged
421
167
  ```ruby
422
- let(:point) { DataClass(:x, :y).validate{ |point| point.x > point.y } }
423
- let(:validation_error) { Lab42::DataClass::ValidationError }
168
+ expect(other_instance.to_h).to eq(name: "robert", email: "robert@mail.provider")
169
+ expect(my_instance.to_h).to eq(name: "robert", email: nil)
424
170
  ```
425
-
426
- Then we will get a `ValidationError` if we construct a point left of the main diagonal
171
+ And the new instance is frozen again
427
172
  ```ruby
428
- expect{ point.new(x: 0, y: 1) }
429
- .to raise_error(validation_error)
173
+ expect(other_instance).to be_frozen
430
174
  ```
431
175
 
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
- ```
176
+ For speculations how to add all the other features to the _Factory Function_ syntax please
177
+ look [here](speculations/FACTORY_FUNCTION.md)
437
178
 
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
179
 
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
180
 
455
181
  ## Context: `Pair` and `Triple`
456
182
 
@@ -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
@@ -14,7 +14,7 @@ module Lab42
14
14
 
15
15
  def define_constraint
16
16
  ->((attr, constraint)) do
17
- if members.member?(attr)
17
+ if members!.member?(attr)
18
18
  constraints[attr] << Maker.make_constraint(constraint)
19
19
  nil
20
20
  else
@@ -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 UndefinedAttributeError,
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
@@ -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,56 @@
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 members!
30
+ @__members__ = Set.new(positionals + defaults.keys)
31
+ end
32
+
33
+ def positionals
34
+ @__positionals__ ||= []
35
+ end
36
+
37
+ def validations
38
+ @__validations__ ||= []
39
+ end
40
+
41
+ private
42
+
43
+ def _missing_initializers
44
+ @___missing_initializers__ ||=
45
+ positionals - actual_params.keys
46
+ end
47
+
48
+ def _illegal_initializers
49
+ @___illegal_initializers__ ||=
50
+ actual_params.keys - positionals - defaults.keys
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ # 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.inspect}"
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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab42
4
+ module DataClass
5
+ class UndefinedAttributeError < RuntimeError
6
+ end
7
+ end
8
+ end
9
+ # SPDX-License-Identifier: Apache-2.0
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Lab42
4
4
  module DataClass
5
- VERSION = "0.5.1"
5
+ VERSION = "0.7.1"
6
6
  end
7
7
  end
8
8
  # SPDX-License-Identifier: Apache-2.0
@@ -1,24 +1,50 @@
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'
6
+ require_relative './data_class/undefined_attribute_error'
4
7
  require_relative './data_class/validation_error'
5
8
  require_relative './data_class/proxy'
6
9
  require_relative './pair'
7
10
  require_relative './triple'
8
11
 
9
- module Kernel
10
- def DataClass(*args, **kwds, &blk)
11
- proxy = Lab42::DataClass::Proxy.new(*args, **kwds, &blk)
12
- proxy.define_class!
13
- end
12
+ module Lab42
13
+ module DataClass
14
+ def self.extended(extendee)
15
+ base_proxy =
16
+ extendee
17
+ .ancestors
18
+ .grep(self)
19
+ .drop(1)
20
+ .first
21
+ &.__data_class_proxy__
14
22
 
15
- def Pair(first, second)
16
- Lab42::Pair.new(first, second)
17
- end
23
+ proxy = base_proxy ? Proxy.from_parent(base_proxy, extendee) : Proxy.new(extendee)
24
+
25
+ extendee.module_eval do
26
+ define_singleton_method(:__data_class_proxy__){ proxy }
27
+ end
28
+ end
29
+
30
+ def attributes(*args, **kwds)
31
+ __data_class_proxy__.tap do |proxy|
32
+ proxy.update!(args, kwds)
33
+ proxy.define_class!
34
+ end
35
+ end
18
36
 
19
- def Triple(first, second, third)
20
- Lab42::Triple.new(first, second, third)
37
+ def derive(att_name, &blk)
38
+ __data_class_proxy__.define_derived_attribute(att_name, &blk)
39
+ __data_class_proxy__.define_class!
40
+ end
41
+
42
+ def constraint(member, constraint = nil, &block)
43
+ raise ArgumentError, "must not provide constraint (2nd argument) and a block" if block && constraint
44
+
45
+ __data_class_proxy__.define_constraints(member => constraint || block)
46
+ __data_class_proxy__.define_class!
47
+ end
21
48
  end
22
49
  end
23
-
24
50
  # 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.1
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Dober
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-02-24 00:00:00.000000000 Z
11
+ date: 2022-02-28 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  An Immutable DataClass for Ruby
@@ -25,11 +25,16 @@ 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
37
+ - lib/lab42/data_class/undefined_attribute_error.rb
33
38
  - lib/lab42/data_class/validation_error.rb
34
39
  - lib/lab42/data_class/version.rb
35
40
  - lib/lab42/eq_and_patterns.rb
@@ -39,7 +44,7 @@ homepage: https://github.com/robertdober/lab42_data_class
39
44
  licenses:
40
45
  - Apache-2.0
41
46
  metadata: {}
42
- post_install_message:
47
+ post_install_message:
43
48
  rdoc_options: []
44
49
  require_paths:
45
50
  - lib
@@ -55,7 +60,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
55
60
  version: '0'
56
61
  requirements: []
57
62
  rubygems_version: 3.3.3
58
- signing_key:
63
+ signing_key:
59
64
  specification_version: 4
60
65
  summary: Finally a dataclass in ruby
61
66
  test_files: []