portrayal 0.8.0 → 0.9.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/rspec.yml +1 -1
- data/CHANGELOG.md +20 -0
- data/README.md +103 -41
- data/bin/bench +81 -0
- data/bin/loc +1 -0
- data/bin/module +17 -0
- data/lib/portrayal/default.rb +9 -10
- data/lib/portrayal/schema.rb +78 -71
- data/lib/portrayal/version.rb +1 -1
- data/lib/portrayal.rb +11 -23
- data/portrayal.gemspec +1 -0
- metadata +19 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: dd6f188d345ad88e1da91277955947e32f1b7dfc9b0d0f840b4164bd0ad17930
|
4
|
+
data.tar.gz: a087113263a3ec551fa4d0c2ba3ab529172ff8252a5782fcc495a838a9f30e8c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 278094b098ec368126dd078903d4a523dd2344dadee05195f0712a47daaab186638a385c45f822b993d35e4aef3f5bf56f2ec5b3722b348efa1e0f553277887a
|
7
|
+
data.tar.gz: fbb5ce904eb704669e8a59990b7a2c959e2559f265f6a38ce9be5b1aff19b4363bbdd4fb982ac8e465aa483c883ebc91e18c3c0fd7c641a316048da304fc9a29
|
data/.github/workflows/rspec.yml
CHANGED
data/CHANGELOG.md
CHANGED
@@ -2,6 +2,26 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
2
2
|
|
3
3
|
## Unreleased
|
4
4
|
|
5
|
+
## 0.9.1 - 2025-01-28
|
6
|
+
|
7
|
+
* Add aliases for all redefined methods to suppress method redefinition warnings.
|
8
|
+
* Add `+''` to mutated strings to suppress Ruby 3.4 frozen string literal warnings.
|
9
|
+
|
10
|
+
## 0.9.0 - 2023-05-06
|
11
|
+
|
12
|
+
None of these changes should break anything for you, only speed things up, unless you're doing something very weird.
|
13
|
+
|
14
|
+
* Rewrite internals to improve runtime performance. No longer depend on `portrayal.attributes`, instead generating ruby code that references keywords literally (much more efficient).
|
15
|
+
* All attr_readers and other methods such as `eql?`, `==`, `freeze`, etc are now included as a module, rather than class_eval'ed into your class. This lets you use `super` when overriding them.
|
16
|
+
* Class method `portrayal` now appears when you call `extend Portrayal`, and not after the first `keyword` declaration. (Instance methods are still added upon `keyword`.)
|
17
|
+
* Remove `portrayal[]` shortcut that accessed `portrayal.schema` (use `portrayal.schema[]` directly instead).
|
18
|
+
* Remove `portrayal.render_initialize`.
|
19
|
+
* Add `portrayal.module`, which is the module included in your struct.
|
20
|
+
* Add `portrayal.render_module_code`, which renders the code for the module.
|
21
|
+
* Bring back class comparison to `==` (reverses a change in 0.3.0). Upon further research, it seems class comparison is always necessary.
|
22
|
+
* Methods `==`, `eql?`, `hash`, `initialize_dup`, `initialize_clone`, and `freeze` now operate on @instance @variables, not reader method return values.
|
23
|
+
* Methods `deconstruct` and `deconstruct_keys` now quietly exclude private/protected keywords.
|
24
|
+
|
5
25
|
## 0.8.0 - 2023-01-27
|
6
26
|
|
7
27
|
* Add pattern matching support (`#deconstruct` and `#deconstruct_keys`).
|
data/README.md
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-

|
1
|
+
[](https://badge.fury.io/rb/portrayal) 
|
2
2
|
|
3
3
|
# Portrayal
|
4
4
|
|
@@ -8,7 +8,7 @@ Inspired by:
|
|
8
8
|
- Piotr Solnica's [virtus](https://github.com/solnic/virtus)
|
9
9
|
- Everything [Michel Martens](https://github.com/soveran)
|
10
10
|
|
11
|
-
Portrayal is a minimalist gem (~
|
11
|
+
Portrayal is a minimalist gem (~122 loc, no dependencies) for building struct-like classes. It provides a small yet powerful step up from plain ruby with its one and only `keyword` method.
|
12
12
|
|
13
13
|
```ruby
|
14
14
|
class Person < MySuperClass
|
@@ -38,6 +38,7 @@ When you call `keyword`:
|
|
38
38
|
* It defines `#hash` for hash equality
|
39
39
|
* It defines `#dup` and `#clone` that propagate to all keyword values
|
40
40
|
* It defines `#freeze` that propagates to all keyword values
|
41
|
+
* It defines `#deconstruct` and `#deconstruct_keys` for pattern matching
|
41
42
|
* It creates a nested class when you supply a block
|
42
43
|
* It inherits parent's superclass when creating a nested class
|
43
44
|
|
@@ -55,40 +56,49 @@ class Person < MySuperClass
|
|
55
56
|
@address = address
|
56
57
|
end
|
57
58
|
|
58
|
-
def eql?(other)
|
59
|
-
self.class == other.class && self == other
|
60
|
-
end
|
61
|
-
|
62
59
|
def ==(other)
|
63
|
-
|
64
|
-
|
60
|
+
self.class == other.class &&
|
61
|
+
@name == other.instance_variable_get('@name') &&
|
62
|
+
@age == other.instance_variable_get('@age') &&
|
63
|
+
@favorite_fruit == other.instance_variable_get('@favorite_fruit') &&
|
64
|
+
@address == other.instance_variable_get('@address')
|
65
65
|
end
|
66
66
|
|
67
|
+
alias eql? ==
|
68
|
+
|
67
69
|
def hash
|
68
|
-
[ self.class, { name: name, age: age, favorite_fruit: favorite_fruit, address: address } ].hash
|
70
|
+
[ self.class, { name: @name, age: @age, favorite_fruit: @favorite_fruit, address: @address } ].hash
|
69
71
|
end
|
70
72
|
|
71
73
|
def freeze
|
72
|
-
name.freeze
|
73
|
-
age.freeze
|
74
|
-
favorite_fruit.freeze
|
75
|
-
address.freeze
|
74
|
+
@name.freeze
|
75
|
+
@age.freeze
|
76
|
+
@favorite_fruit.freeze
|
77
|
+
@address.freeze
|
76
78
|
super
|
77
79
|
end
|
78
80
|
|
81
|
+
def deconstruct
|
82
|
+
[ name, age, favorite_fruit, address ]
|
83
|
+
end
|
84
|
+
|
85
|
+
def deconstruct_keys(*)
|
86
|
+
{ name: name, age: age, favorite_fruit: favorite_fruit, address: address }
|
87
|
+
end
|
88
|
+
|
79
89
|
def initialize_dup(source)
|
80
|
-
@name = source.name.dup
|
81
|
-
@age = source.age.dup
|
82
|
-
@favorite_fruit = source.favorite_fruit.dup
|
83
|
-
@address = source.address.dup
|
90
|
+
@name = source.instance_variable_get('@name').dup
|
91
|
+
@age = source.instance_variable_get('@age').dup
|
92
|
+
@favorite_fruit = source.instance_variable_get('@favorite_fruit').dup
|
93
|
+
@address = source.instance_variable_get('@address').dup
|
84
94
|
super
|
85
95
|
end
|
86
96
|
|
87
97
|
def initialize_clone(source)
|
88
|
-
@name = source.name.clone
|
89
|
-
@age = source.age.clone
|
90
|
-
@favorite_fruit = source.favorite_fruit.clone
|
91
|
-
@address = source.address.clone
|
98
|
+
@name = source.instance_variable_get('@name').clone
|
99
|
+
@age = source.instance_variable_get('@age').clone
|
100
|
+
@favorite_fruit = source.instance_variable_get('@favorite_fruit').clone
|
101
|
+
@address = source.instance_variable_get('@address').clone
|
92
102
|
super
|
93
103
|
end
|
94
104
|
|
@@ -105,33 +115,41 @@ class Person < MySuperClass
|
|
105
115
|
"#{street}, #{city}"
|
106
116
|
end
|
107
117
|
|
108
|
-
def eql?(other)
|
109
|
-
self.class == other.class && self == other
|
110
|
-
end
|
111
|
-
|
112
118
|
def ==(other)
|
113
|
-
|
119
|
+
self.class == other.class &&
|
120
|
+
@street == other.instance_variable_get('@street') &&
|
121
|
+
@city == other.instance_variable_get('@city')
|
114
122
|
end
|
115
123
|
|
124
|
+
alias eql? ==
|
125
|
+
|
116
126
|
def hash
|
117
|
-
[ self.class, { street: street, city: city } ].hash
|
127
|
+
[ self.class, { street: @street, city: @city } ].hash
|
118
128
|
end
|
119
129
|
|
120
130
|
def freeze
|
121
|
-
street.freeze
|
122
|
-
city.freeze
|
131
|
+
@street.freeze
|
132
|
+
@city.freeze
|
123
133
|
super
|
124
134
|
end
|
125
135
|
|
136
|
+
def deconstruct
|
137
|
+
[ street, city ]
|
138
|
+
end
|
139
|
+
|
140
|
+
def deconstruct_keys(*)
|
141
|
+
{ street: street, city: city }
|
142
|
+
end
|
143
|
+
|
126
144
|
def initialize_dup(source)
|
127
|
-
@street = source.street.dup
|
128
|
-
@city = source.city.dup
|
145
|
+
@street = source.instance_variable_get('@street').dup
|
146
|
+
@city = source.instance_variable_get('@city').dup
|
129
147
|
super
|
130
148
|
end
|
131
149
|
|
132
150
|
def initialize_clone(source)
|
133
|
-
@street = source.street.clone
|
134
|
-
@city = source.city.clone
|
151
|
+
@street = source.instance_variable_get('@street').clone
|
152
|
+
@city = source.instance_variable_get('@city').clone
|
135
153
|
super
|
136
154
|
end
|
137
155
|
end
|
@@ -336,9 +354,9 @@ else
|
|
336
354
|
end # => "matched"
|
337
355
|
```
|
338
356
|
|
339
|
-
###
|
357
|
+
### Introspection
|
340
358
|
|
341
|
-
Every class that
|
359
|
+
Every class that extends Portrayal receives a method called `portrayal`. This method is a schema of your object with some additional helpers.
|
342
360
|
|
343
361
|
#### `portrayal.keywords`
|
344
362
|
|
@@ -348,7 +366,7 @@ Get all keyword names.
|
|
348
366
|
Address.portrayal.keywords # => [:street, :city, :postcode, :country]
|
349
367
|
```
|
350
368
|
|
351
|
-
#### `portrayal.attributes`
|
369
|
+
#### `portrayal.attributes(object)`
|
352
370
|
|
353
371
|
Get all names + values as a hash.
|
354
372
|
|
@@ -371,12 +389,14 @@ Portrayal steps back from things like type enforcement, coercion, and writer met
|
|
371
389
|
|
372
390
|
#### Good Constructors
|
373
391
|
|
374
|
-
|
392
|
+
Since a portrayal object is read-only (nothing stops you from adding writers, but I will personally frown upon you), you must set all its values in a constructor. This is a good thing, because it lets us study, coerce, and validate all the passed-in arguments in one convenient place. We're assured that once instantiated, the object is valid. And of course we can have multiple constructors if needed. They serve as adapters for different kinds of input.
|
375
393
|
|
376
394
|
```ruby
|
377
395
|
class Address < ApplicationStruct
|
378
396
|
class << self
|
379
397
|
def from_form(params)
|
398
|
+
raise ArgumentError, 'invalid postcode' if params[:postcode] !~ /\A\d+\z/
|
399
|
+
|
380
400
|
new \
|
381
401
|
street: params[:street].to_s,
|
382
402
|
city: params[:city].to_s,
|
@@ -400,16 +420,18 @@ class Address < ApplicationStruct
|
|
400
420
|
end
|
401
421
|
```
|
402
422
|
|
403
|
-
Good constructors can
|
423
|
+
Good constructors can depend on one another to successively convert arguments into keywords. This is similar to how in functional languages one can use recursion and pattern matching.
|
404
424
|
|
405
425
|
```ruby
|
406
426
|
class Email < ApplicationStruct
|
407
427
|
class << self
|
428
|
+
# Extract parts of an email from JSON, and kick it over to from_parts.
|
408
429
|
def from_publishing_service_json(json)
|
409
430
|
subject, header, body, footer = *JSON.parse(json)
|
410
431
|
from_parts(subject: subject, header: header, body: body, footer: footer)
|
411
432
|
end
|
412
433
|
|
434
|
+
# Combine parts into the final keywords: subject and body.
|
413
435
|
def from_parts(subject:, header:, body:, footer:)
|
414
436
|
new(subject: subject, body: "#{header}#{body}#{footer}")
|
415
437
|
end
|
@@ -443,15 +465,55 @@ class Address < ApplicationStruct
|
|
443
465
|
end
|
444
466
|
```
|
445
467
|
|
468
|
+
If a particular constructor doesn't belong on your object (i.e. a 3rd party module is responsible for parsing its own data and producing your object) — you don't need to have a special constructor. Remember that each portrayal object comes with `.new`, which accepts every keyword directly. Let the module do all the parsing on its side and call `.new` with final values.
|
469
|
+
|
446
470
|
#### No Reinventing The Wheel
|
447
471
|
|
448
|
-
Portrayal leans on Ruby
|
472
|
+
Portrayal leans on Ruby's built-in features as much as possible. For initialize and default values it generates standard ruby keyword arguments. You can see all the code portrayal generates for your objects by running `YourClass.portrayal.render_module_code`.
|
449
473
|
|
450
474
|
```irb
|
451
|
-
Address.portrayal.
|
452
|
-
|
475
|
+
[1] pry(main)> puts Address.portrayal.render_module_code
|
476
|
+
attr_accessor :street, :city, :postcode, :country
|
477
|
+
protected :street=, :city=, :postcode=, :country=
|
478
|
+
def initialize(street:, city:, postcode:, country: self.class.portrayal.schema[:country]); @street = street.is_a?(::Portrayal::Default) ? street.(self) : street; @city = city.is_a?(::Portrayal::Default) ? city.(self) : city; @postcode = postcode.is_a?(::Portrayal::Default) ? postcode.(self) : postcode; @country = country.is_a?(::Portrayal::Default) ? country.(self) : country end
|
479
|
+
def hash; [self.class, {street: @street, city: @city, postcode: @postcode, country: @country}].hash end
|
480
|
+
def ==(other); self.class == other.class && @street == other.instance_variable_get('@street') && @city == other.instance_variable_get('@city') && @postcode == other.instance_variable_get('@postcode') && @country == other.instance_variable_get('@country') end
|
481
|
+
alias eql? ==
|
482
|
+
def freeze; @street.freeze; @city.freeze; @postcode.freeze; @country.freeze; super end
|
483
|
+
def initialize_dup(src); @street = src.instance_variable_get('@street').dup; @city = src.instance_variable_get('@city').dup; @postcode = src.instance_variable_get('@postcode').dup; @country = src.instance_variable_get('@country').dup; super end
|
484
|
+
def initialize_clone(src); @street = src.instance_variable_get('@street').clone; @city = src.instance_variable_get('@city').clone; @postcode = src.instance_variable_get('@postcode').clone; @country = src.instance_variable_get('@country').clone; super end
|
485
|
+
def deconstruct
|
486
|
+
public_syms = [:street, :city, :postcode, :country].select { |s| self.class.public_method_defined?(s) }
|
487
|
+
public_syms.map { |s| public_send(s) }
|
488
|
+
end
|
489
|
+
def deconstruct_keys(keys)
|
490
|
+
filtered_keys = [:street, :city, :postcode, :country].select {|s| self.class.public_method_defined?(s) }
|
491
|
+
filtered_keys &= keys if Array === keys
|
492
|
+
Hash[filtered_keys.map { |k| [k, public_send(k)] }]
|
493
|
+
end
|
494
|
+
alias initialize initialize
|
495
|
+
alias hash hash
|
496
|
+
alias == ==
|
497
|
+
alias eql? eql?
|
498
|
+
alias freeze freeze
|
499
|
+
alias initialize_dup initialize_dup
|
500
|
+
alias initialize_clone initialize_clone
|
501
|
+
alias deconstruct deconstruct
|
502
|
+
alias deconstruct_keys deconstruct_keys
|
503
|
+
alias street street; alias street= street=; alias city city; alias city= city=; alias postcode postcode; alias postcode= postcode=; alias country country; alias country= country=
|
453
504
|
```
|
454
505
|
|
506
|
+
#### Implementation decisions
|
507
|
+
|
508
|
+
Here are some key architectural decisions that took a lot of thinking. If you have good counter-arguments please make an issue, or contact me on [mastodon](https://ruby.social/@maxim) / [twitter](https://twitter.com/hakunin).
|
509
|
+
|
510
|
+
1. **Why do methods `#==`, `#eql?`, `#hash` rely on @instance @variables instead of calling reader methods?**
|
511
|
+
Portrayal makes a careful assumption on what most people would expect from object equality: a comparison of type and runtime state (which is what instance variables are). Portrayal avoids comparing object structure and method return values, because it's too situational whether they should participate in equality or not. If you have such a situation, you're welcome to redefine `==` in your class.
|
512
|
+
2. **Why do methods `clone` and `dup` copy @instance @variables instead of calling reader methods?**
|
513
|
+
As with the reason for `==`, when we clone an object, we want to clone its type and runtime state. Not the artifacts of its structure. It's too presumptious for a clone to assume that method outputs are authoritative. If objects are written deterministically, then by cloning their inner runtime state we should get the same reader method outputs anyway. If you are doing something else, you're welcome to redefine `initialize_clone`/`initialize_dup` in your class.
|
514
|
+
3. **Why does pattern matching (`deconstruct`/`deconstruct_keys`) call reader methods rather than reading @instance @variables?**
|
515
|
+
Unlike equality or object replication, in case of pattern matching we're no longer trying to figure out object's identity, rather we are now an external caller working directly with the values that an object exposes. That's why portrayal lets pattern matching depend on reader methods that get to decide how to expose data outwardly, while making a conscious effort to exclude private and protected readers. You're welcome to override `deconstruct` and `deconstruct_keys` in your class if you'd like to do something different.
|
516
|
+
|
455
517
|
## Development
|
456
518
|
|
457
519
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
data/bin/bench
ADDED
@@ -0,0 +1,81 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
# This file is a set of benchmarks directly or tangentially related to
|
4
|
+
# implementation decisions.
|
5
|
+
|
6
|
+
require 'bundler/setup'
|
7
|
+
require 'portrayal'
|
8
|
+
require 'benchmark/ips'
|
9
|
+
|
10
|
+
class Address1
|
11
|
+
extend Portrayal
|
12
|
+
|
13
|
+
keyword :street
|
14
|
+
keyword :city
|
15
|
+
keyword :postal_code
|
16
|
+
|
17
|
+
def ==(other)
|
18
|
+
self.class.portrayal.attributes(self) ==
|
19
|
+
other.class.portrayal.attributes(other)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
class Address2
|
24
|
+
extend Portrayal
|
25
|
+
|
26
|
+
keyword :street
|
27
|
+
keyword :city
|
28
|
+
keyword :postal_code
|
29
|
+
end
|
30
|
+
|
31
|
+
Address2.portrayal.module.module_eval <<-RUBY
|
32
|
+
def ==(o)
|
33
|
+
street == o.street && city == o.city && postal_code == o.postal_code
|
34
|
+
end
|
35
|
+
RUBY
|
36
|
+
|
37
|
+
class Address3
|
38
|
+
extend Portrayal
|
39
|
+
|
40
|
+
keyword :street
|
41
|
+
keyword :city
|
42
|
+
keyword :postal_code
|
43
|
+
|
44
|
+
def ==(o)
|
45
|
+
{ street: street, city: city, postal_code: postal_code } ==
|
46
|
+
{ street: o.street, city: o.city, postal_code: o.postal_code }
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
a1 = Address1.new(street: 'street', city: 'city', postal_code: 123)
|
51
|
+
a2 = Address2.new(street: 'street', city: 'city', postal_code: 123)
|
52
|
+
a3 = Address3.new(street: 'street', city: 'city', postal_code: 123)
|
53
|
+
|
54
|
+
|
55
|
+
Benchmark.ips do |x|
|
56
|
+
x.report("equality-via-hash-construction") {
|
57
|
+
a1 == a1
|
58
|
+
}
|
59
|
+
|
60
|
+
x.report("equality-via-boolean-expression") {
|
61
|
+
a2 == a2
|
62
|
+
}
|
63
|
+
|
64
|
+
x.report("equality-via-hash-literal") {
|
65
|
+
a3 == a3
|
66
|
+
}
|
67
|
+
|
68
|
+
x.compare!
|
69
|
+
end
|
70
|
+
|
71
|
+
Benchmark.ips do |x|
|
72
|
+
x.report("hash of literal Hash") {
|
73
|
+
{ foo: 'foo', bar: 'bar', baz: 'baz' }.hash
|
74
|
+
}
|
75
|
+
|
76
|
+
x.report("hash of literal Array") {
|
77
|
+
[ [:foo, 'foo'], [:bar, 'bar'], [:baz, 'baz'] ].hash
|
78
|
+
}
|
79
|
+
|
80
|
+
x.compare!
|
81
|
+
end
|
data/bin/loc
CHANGED
data/bin/module
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
# This is a demo of what module code looks like, for pasting into README.
|
4
|
+
|
5
|
+
require 'bundler/setup'
|
6
|
+
require 'portrayal'
|
7
|
+
|
8
|
+
class Address
|
9
|
+
extend Portrayal
|
10
|
+
|
11
|
+
keyword :street
|
12
|
+
keyword :city
|
13
|
+
keyword :postcode
|
14
|
+
keyword :country, default: nil
|
15
|
+
end
|
16
|
+
|
17
|
+
puts Address.portrayal.render_module_code
|
data/lib/portrayal/default.rb
CHANGED
@@ -1,13 +1,12 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
1
|
+
class Portrayal::Default
|
2
|
+
attr_reader :value
|
3
|
+
protected :value
|
4
4
|
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
end
|
9
|
-
|
10
|
-
def call?; @callable end
|
11
|
-
def initialize_dup(src); super; @value = src.value.dup end
|
5
|
+
def initialize(value)
|
6
|
+
@value = value
|
7
|
+
@callable = value.is_a?(Proc) && !value.lambda?
|
12
8
|
end
|
9
|
+
|
10
|
+
def call(obj); @callable ? obj.instance_exec(&value) : value end
|
11
|
+
def initialize_dup(src); @value = src.value.dup; super end
|
13
12
|
end
|
data/lib/portrayal/schema.rb
CHANGED
@@ -1,79 +1,86 @@
|
|
1
1
|
require 'portrayal/default'
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
def ==(other)
|
19
|
-
return super unless other.class.is_a?(Portrayal)
|
20
|
-
|
21
|
-
self.class.portrayal.attributes(self) ==
|
22
|
-
self.class.portrayal.attributes(other)
|
23
|
-
end
|
24
|
-
|
25
|
-
def freeze
|
26
|
-
self.class.portrayal.attributes(self).values.each(&:freeze)
|
27
|
-
super
|
28
|
-
end
|
29
|
-
|
30
|
-
def initialize_dup(source)
|
31
|
-
self.class.portrayal.attributes(source).each do |key, value|
|
32
|
-
instance_variable_set("@\#{key}", value.dup)
|
33
|
-
end
|
34
|
-
super
|
35
|
-
end
|
36
|
-
|
37
|
-
def initialize_clone(source)
|
38
|
-
self.class.portrayal.attributes(source).each do |key, value|
|
39
|
-
instance_variable_set("@\#{key}", value.clone)
|
40
|
-
end
|
41
|
-
super
|
42
|
-
end
|
43
|
-
RUBY
|
44
|
-
|
45
|
-
def initialize; @schema = {} end
|
46
|
-
def keywords; @schema.keys end
|
47
|
-
def [](name); @schema[name] end
|
48
|
-
|
49
|
-
def attributes(object)
|
50
|
-
Hash[object.class.portrayal.keywords.map { |k| [k, object.send(k)] }]
|
51
|
-
end
|
52
|
-
|
53
|
-
def camelize(string); string.to_s.gsub(/(?:^|_+)([^_])/) { $1.upcase } end
|
3
|
+
class Portrayal::Schema
|
4
|
+
attr_reader :schema, :module
|
5
|
+
NULL = Object.new.freeze
|
6
|
+
|
7
|
+
def initialize; @schema = {}; @module = Module.new end
|
8
|
+
def keywords; @schema.keys end
|
9
|
+
def attributes(obj); Hash[keywords.map { |k| [k, obj.send(k)] }] end
|
10
|
+
def camelize(string); string.to_s.gsub(/(?:^|_+)([^_])/) { $1.upcase } end
|
11
|
+
|
12
|
+
def initialize_dup(src)
|
13
|
+
@schema = src.schema.transform_values(&:dup)
|
14
|
+
@module = src.module.dup
|
15
|
+
super
|
16
|
+
end
|
54
17
|
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
18
|
+
def add_keyword(name, default)
|
19
|
+
name = name.to_sym
|
20
|
+
@schema.delete(name) # Forcing keyword to be added at the end of the hash.
|
21
|
+
@schema[name] = default.equal?(NULL) ? nil : Portrayal::Default.new(default)
|
22
|
+
@module.module_eval(render_module_code)
|
23
|
+
end
|
60
24
|
|
61
|
-
|
62
|
-
|
25
|
+
def render_module_code
|
26
|
+
args, inits, syms, hash, eqls, dups, clones, setters, freezes, aliases =
|
27
|
+
+'', +'', +'', +'', +'', +'', +'', +'', +'', +'' # + keeps string unfrozen
|
28
|
+
|
29
|
+
@schema.each do |k, default|
|
30
|
+
args << "#{k}:#{default && " self.class.portrayal.schema[:#{k}]"}, "
|
31
|
+
inits << "@#{k} = #{k}.is_a?(::Portrayal::Default) ? #{k}.(self) : #{k}; "
|
32
|
+
syms << ":#{k}, "
|
33
|
+
hash << "#{k}: @#{k}, "
|
34
|
+
eqls << "@#{k} == other.instance_variable_get('@#{k}') && "
|
35
|
+
dups << "@#{k} = src.instance_variable_get('@#{k}').dup; "
|
36
|
+
clones << "@#{k} = src.instance_variable_get('@#{k}').clone; "
|
37
|
+
setters << ":#{k}=, "
|
38
|
+
freezes << "@#{k}.freeze; "
|
39
|
+
aliases << "alias #{k} #{k}; alias #{k}= #{k}=; "
|
63
40
|
end
|
64
41
|
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
42
|
+
args.chomp!(', ') # key1:, key2: self.class.portrayal.schema[:key2]
|
43
|
+
inits.chomp!('; ') # Assignments in initialize
|
44
|
+
syms.chomp!(', ') # :key1, :key2
|
45
|
+
hash.chomp!(', ') # key1: @key1, key2: @key2
|
46
|
+
eqls.chomp!(' && ') # @key1 == other.instance_variable_get('@key1') &&
|
47
|
+
dups.chomp!('; ') # @key1 = src.instance_variable_get('@key1').dup;
|
48
|
+
clones.chomp!('; ') # @key1 = src.instance_variable_get('@key1').clone;
|
49
|
+
setters.chomp!(', ') # :key1=, :key2=
|
50
|
+
freezes.chomp!('; ') # @key1.freeze; @key2.freeze
|
51
|
+
aliases.chomp!('; ') # alias key1 key1; alias key1= key1=
|
52
|
+
|
53
|
+
# Aliases at the bottom help prevent method redefinition warnings.
|
54
|
+
# See https://bugs.ruby-lang.org/issues/17055 for details.
|
55
|
+
<<-RUBY
|
56
|
+
attr_accessor #{syms}
|
57
|
+
protected #{setters}
|
58
|
+
def initialize(#{args}); #{inits} end
|
59
|
+
def hash; [self.class, {#{hash}}].hash end
|
60
|
+
def ==(other); self.class == other.class && #{eqls} end
|
61
|
+
alias eql? ==
|
62
|
+
def freeze; #{freezes}; super end
|
63
|
+
def initialize_dup(src); #{dups}; super end
|
64
|
+
def initialize_clone(src); #{clones}; super end
|
65
|
+
def deconstruct
|
66
|
+
public_syms = [#{syms}].select { |s| self.class.public_method_defined?(s) }
|
67
|
+
public_syms.map { |s| public_send(s) }
|
68
|
+
end
|
69
|
+
def deconstruct_keys(keys)
|
70
|
+
filtered_keys = [#{syms}].select {|s| self.class.public_method_defined?(s) }
|
71
|
+
filtered_keys &= keys if Array === keys
|
72
|
+
Hash[filtered_keys.map { |k| [k, public_send(k)] }]
|
73
|
+
end
|
74
|
+
alias initialize initialize
|
75
|
+
alias hash hash
|
76
|
+
alias == ==
|
77
|
+
alias eql? eql?
|
78
|
+
alias freeze freeze
|
79
|
+
alias initialize_dup initialize_dup
|
80
|
+
alias initialize_clone initialize_clone
|
81
|
+
alias deconstruct deconstruct
|
82
|
+
alias deconstruct_keys deconstruct_keys
|
83
|
+
#{aliases}
|
84
|
+
RUBY
|
78
85
|
end
|
79
86
|
end
|
data/lib/portrayal/version.rb
CHANGED
data/lib/portrayal.rb
CHANGED
@@ -2,32 +2,20 @@ require 'portrayal/version'
|
|
2
2
|
require 'portrayal/schema'
|
3
3
|
|
4
4
|
module Portrayal
|
5
|
-
|
5
|
+
attr_reader :portrayal
|
6
|
+
def self.extended(c); c.instance_variable_set(:@portrayal, Schema.new) end
|
6
7
|
|
7
|
-
def
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
def inherited(base)
|
12
|
-
base.instance_variable_set('@portrayal', portrayal.dup)
|
13
|
-
end
|
14
|
-
end
|
15
|
-
|
16
|
-
@portrayal = Schema.new
|
17
|
-
class_eval(Schema::DEFINITION_OF_OBJECT_ENHANCEMENTS)
|
18
|
-
end
|
19
|
-
|
20
|
-
attr_accessor name
|
21
|
-
protected "#{name}="
|
8
|
+
def inherited(c)
|
9
|
+
c.instance_variable_set(:@portrayal, portrayal.dup)
|
10
|
+
c.include(c.portrayal.module)
|
11
|
+
end
|
22
12
|
|
13
|
+
def keyword(name, default: Schema::NULL, define: nil, &block)
|
14
|
+
include portrayal.module
|
23
15
|
portrayal.add_keyword(name, default)
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
kw_class = Class.new(superclass) { extend Portrayal }
|
28
|
-
const_set(define || portrayal.camelize(name), kw_class).class_eval(&block)
|
29
|
-
end
|
30
|
-
|
16
|
+
return name unless block_given?
|
17
|
+
nested = Class.new(superclass) { extend ::Portrayal }
|
18
|
+
const_set(define || portrayal.camelize(name), nested).class_eval(&block)
|
31
19
|
name
|
32
20
|
end
|
33
21
|
end
|
data/portrayal.gemspec
CHANGED
metadata
CHANGED
@@ -1,14 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: portrayal
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.9.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Max Chernyak
|
8
|
-
autorequire:
|
9
8
|
bindir: bin
|
10
9
|
cert_chain: []
|
11
|
-
date:
|
10
|
+
date: 2025-01-28 00:00:00.000000000 Z
|
12
11
|
dependencies:
|
13
12
|
- !ruby/object:Gem::Dependency
|
14
13
|
name: rake
|
@@ -52,6 +51,20 @@ dependencies:
|
|
52
51
|
- - "~>"
|
53
52
|
- !ruby/object:Gem::Version
|
54
53
|
version: '0.14'
|
54
|
+
- !ruby/object:Gem::Dependency
|
55
|
+
name: benchmark-ips
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - "~>"
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '2.11'
|
61
|
+
type: :development
|
62
|
+
prerelease: false
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - "~>"
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: '2.11'
|
55
68
|
description: Inspired by dry-initializer and virtus, portrayal is a minimalist gem
|
56
69
|
that takes a somewhat different approach to building struct-like classes. It steps
|
57
70
|
away from types, coersion, and writer methods in favor of encouraging well-designed
|
@@ -71,8 +84,10 @@ files:
|
|
71
84
|
- LICENSE.txt
|
72
85
|
- README.md
|
73
86
|
- Rakefile
|
87
|
+
- bin/bench
|
74
88
|
- bin/console
|
75
89
|
- bin/loc
|
90
|
+
- bin/module
|
76
91
|
- bin/setup
|
77
92
|
- lib/portrayal.rb
|
78
93
|
- lib/portrayal/default.rb
|
@@ -86,7 +101,6 @@ metadata:
|
|
86
101
|
homepage_uri: https://github.com/maxim/portrayal
|
87
102
|
source_code_uri: https://github.com/maxim/portrayal
|
88
103
|
changelog_uri: https://github.com/maxim/portrayal/blob/main/CHANGELOG.md
|
89
|
-
post_install_message:
|
90
104
|
rdoc_options: []
|
91
105
|
require_paths:
|
92
106
|
- lib
|
@@ -101,8 +115,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
101
115
|
- !ruby/object:Gem::Version
|
102
116
|
version: '0'
|
103
117
|
requirements: []
|
104
|
-
rubygems_version: 3.
|
105
|
-
signing_key:
|
118
|
+
rubygems_version: 3.6.3
|
106
119
|
specification_version: 4
|
107
120
|
summary: A minimal builder for struct-like classes
|
108
121
|
test_files: []
|