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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e6b15fe5bee4f60e8369a839ecad908c35bdbba34559e324f71c3a8404586ad
4
- data.tar.gz: 279dc9030eea091d806fa9e02178783d02063c6efc731bc601a48a68e50f8281
3
+ metadata.gz: dd6f188d345ad88e1da91277955947e32f1b7dfc9b0d0f840b4164bd0ad17930
4
+ data.tar.gz: a087113263a3ec551fa4d0c2ba3ab529172ff8252a5782fcc495a838a9f30e8c
5
5
  SHA512:
6
- metadata.gz: a3c414e415b48127c4f86ee41b7b5d0eed7730ea7316c356afa533f539acd42bdf70c08e1c64f78ddd7ba444da47f4069dbdf8716d42592a8d1b15ec38ca7ff4
7
- data.tar.gz: 2fdab00d88f76ae116af03e14d883346797cf60c21ab0569823f6dd869f3efe368db1b9b2d1e41548619e1666de8acf38d555647a2e29270f0c35e6e512d7aa8
6
+ metadata.gz: 278094b098ec368126dd078903d4a523dd2344dadee05195f0712a47daaab186638a385c45f822b993d35e4aef3f5bf56f2ec5b3722b348efa1e0f553277887a
7
+ data.tar.gz: fbb5ce904eb704669e8a59990b7a2c959e2559f265f6a38ce9be5b1aff19b4363bbdd4fb982ac8e465aa483c883ebc91e18c3c0fd7c641a316048da304fc9a29
@@ -6,7 +6,7 @@ jobs:
6
6
  strategy:
7
7
  fail-fast: false
8
8
  matrix:
9
- ruby: [ '2.4', '2.5', '2.6', '2.7', '3.0', '3.2' ]
9
+ ruby: [ '2.4', '2.5', '2.6', '2.7', '3.0', '3.2', '3.3', '3.4' ]
10
10
 
11
11
  name: Ruby ${{ matrix.ruby }}
12
12
  steps:
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
- ![RSpec](https://github.com/maxim/portrayal/workflows/RSpec/badge.svg)
1
+ [![Gem Version](https://badge.fury.io/rb/portrayal.svg)](https://badge.fury.io/rb/portrayal) ![RSpec](https://github.com/maxim/portrayal/workflows/RSpec/badge.svg)
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 (~130 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.
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
- { name: name, age: age, favorite_fruit: favorite_fruit, address: address } ==
64
- { name: other.name, age: other.age, favorite_fruit: other.favorite_fruit, address: other.address }
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
- { street: street, city: city } == { street: other.street, city: other.city }
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
- ### Schema
357
+ ### Introspection
340
358
 
341
- Every class that has at least one keyword defined in it automatically receives a class method called `portrayal`. This method is a schema of your object with some additional helpers.
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
- The fact that we keep these portrayal structs read-only (nothing stops you from adding writers, but I will personally frown upon you), all of the responsibility of building them shifts into constructors. This is a good thing, because good constructors clearly define their dependencies, as well as giving us ample room for performing coercion.
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 also depend on one another to successively break down dependnecies into essential parts. This is similar to how in functional languages one can use recursion and pattern matching.
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 to take care of enforcing required keyword arguments, and setting keyword argument defaults. It actually generates standard ruby keyword arguments for you behind the scenes. You can even see the code by checking `YourClass.portrayal.definition_of_initialize`.
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.definition_of_initialize
452
- => "def initialize(street:,city:,postcode:,country: self.class.portrayal.call_default(:country)); @street = street; @city = city; @postcode = postcode; @country = country end"
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
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env bash
2
+ # Count LOC in the project for pasting into README.
2
3
  find lib -name '*.rb' | xargs wc -l
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
@@ -1,13 +1,12 @@
1
- module Portrayal
2
- class Default
3
- attr_reader :value
1
+ class Portrayal::Default
2
+ attr_reader :value
3
+ protected :value
4
4
 
5
- def initialize(value)
6
- @value = value
7
- @callable = value.is_a?(Proc) && !value.lambda?
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
@@ -1,79 +1,86 @@
1
1
  require 'portrayal/default'
2
2
 
3
- module Portrayal
4
- class Schema
5
- attr_reader :schema
6
-
7
- DEFINITION_OF_OBJECT_ENHANCEMENTS = <<~RUBY.freeze
8
- def eql?(other); self.class == other.class && self == other end
9
- def hash; [self.class, self.class.portrayal.attributes(self)].hash end
10
- def deconstruct; self.class.portrayal.attributes(self).values end
11
-
12
- def deconstruct_keys(keys)
13
- keys ||= self.class.portrayal.keywords
14
- keys &= self.class.portrayal.keywords
15
- Hash[keys.map { |k| [k, send(k)] }]
16
- end
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
- def add_keyword(name, default)
56
- name = name.to_sym
57
- @schema.delete(name) # Forcing keyword to be added at the end of the hash.
58
- @schema[name] = default.equal?(NULL) ? nil : Default.new(default)
59
- end
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
- def initialize_dup(other)
62
- super; @schema = other.schema.transform_values(&:dup)
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
- def definition_of_initialize
66
- init_args = @schema.map { |name, default|
67
- "#{name}:#{default && " self.class.portrayal[:#{name}]"}"
68
- }.join(', ')
69
-
70
- init_assigns = @schema.keys.map { |name|
71
- "@#{name} = #{name}.is_a?(::Portrayal::Default) ? " \
72
- "(#{name}.call? ? instance_exec(&#{name}.value) : #{name}.value) : " \
73
- "#{name}"
74
- }.join('; ')
75
-
76
- "def initialize(#{init_args}); #{init_assigns} end"
77
- end
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
@@ -1,3 +1,3 @@
1
1
  module Portrayal
2
- VERSION = '0.8.0'
2
+ VERSION = '0.9.1'
3
3
  end
data/lib/portrayal.rb CHANGED
@@ -2,32 +2,20 @@ require 'portrayal/version'
2
2
  require 'portrayal/schema'
3
3
 
4
4
  module Portrayal
5
- NULL = :_portrayal_value_not_set
5
+ attr_reader :portrayal
6
+ def self.extended(c); c.instance_variable_set(:@portrayal, Schema.new) end
6
7
 
7
- def keyword(name, default: NULL, define: nil, &block)
8
- unless respond_to?(:portrayal)
9
- class << self
10
- attr_reader :portrayal
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
- class_eval(portrayal.definition_of_initialize)
25
-
26
- if block_given?
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
@@ -24,4 +24,5 @@ Gem::Specification.new do |spec|
24
24
  spec.add_development_dependency 'rake', '~> 13.0'
25
25
  spec.add_development_dependency 'rspec', '~> 3.12'
26
26
  spec.add_development_dependency 'pry', '~> 0.14'
27
+ spec.add_development_dependency 'benchmark-ips', '~> 2.11'
27
28
  end
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.8.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: 2023-01-28 00:00:00.000000000 Z
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.4.2
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: []