serega 0.38.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +198 -28
  3. data/VERSION +1 -1
  4. data/lib/serega/attribute.rb +3 -3
  5. data/lib/serega/attribute_normalizer.rb +140 -9
  6. data/lib/serega/attribute_value_resolvers/batch.rb +4 -0
  7. data/lib/serega/attribute_value_resolvers/hash_access.rb +116 -0
  8. data/lib/serega/config.rb +107 -1
  9. data/lib/serega/object_serializer.rb +3 -3
  10. data/lib/serega/plugins/activerecord_preloads/activerecord_preloads.rb +2 -0
  11. data/lib/serega/plugins/explicit_many_option/explicit_many_option.rb +3 -2
  12. data/lib/serega/plugins/explicit_many_option/validations/check_opt_many.rb +6 -4
  13. data/lib/serega/plugins/presenter/presenter.rb +99 -2
  14. data/lib/serega/plugins/root/root.rb +1 -1
  15. data/lib/serega/utils/collection_detector.rb +26 -0
  16. data/lib/serega/validations/attribute/check_block.rb +20 -55
  17. data/lib/serega/validations/attribute/check_opt_base_serializer.rb +37 -0
  18. data/lib/serega/validations/attribute/check_opt_batch.rb +6 -6
  19. data/lib/serega/validations/attribute/check_opt_const.rb +3 -4
  20. data/lib/serega/validations/attribute/check_opt_delegate.rb +45 -5
  21. data/lib/serega/validations/attribute/check_opt_hash_access.rb +78 -0
  22. data/lib/serega/validations/attribute/check_opt_many.rb +8 -7
  23. data/lib/serega/validations/attribute/check_opt_method.rb +3 -4
  24. data/lib/serega/validations/attribute/check_opt_serializer.rb +4 -1
  25. data/lib/serega/validations/attribute/check_opt_value.rb +3 -4
  26. data/lib/serega/validations/check_attribute_params.rb +9 -7
  27. data/lib/serega.rb +22 -6
  28. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 384da177845e2385a0100d23147cea0034860b148cd46738dc3d6ec7ea7d7eda
4
- data.tar.gz: f08d482a83cb0cde30ae4c03e924f50be6aaabea24d76564bc47a7a1eadf4f98
3
+ metadata.gz: 42da0ff66dc5eeb282fbb72144f78bcfc8ded1aa8609122ded10093f6c83dd96
4
+ data.tar.gz: f9fb2a2628756540cd08a5449a21883b0cf28ec45db8f461ee0944fd0f3812e9
5
5
  SHA512:
6
- metadata.gz: c9f2f95d8a74ce4951f7acef89a1cf603fcba2f15af50d246f85d58e1456b06c03b749fef620c741f8e2482999c6f7471fb5dff9a904b275f79f039999ab7fdc
7
- data.tar.gz: 69c1b8f898f81317310e875a8771db096b3d4613b34769024d66fe801554e53094fd59e3cfea923ef81bb5d837a31ef28ceadc825003c95a4a2340f6b0b0bf74
6
+ metadata.gz: 1355573b7cb8d30ffbf844be46c03c4a5233fa9c190fa5e03fd39c0d1a396b9f7a19e7e2d4cf3afbd0445cd1d8dfc8ebbd1df6d77d91d338c0ede561c836fb99
7
+ data.tar.gz: 90ccd454b970598f9921ec1a6c9feab325a373d5628f884515141d34a3a72e7012273e37aa903046e064876f92bd956783ef3a9b93732ee23a8bcc8bec154ee3
data/README.md CHANGED
@@ -26,6 +26,7 @@ It has some great features:
26
26
  keeping the code dry
27
27
  - Conditional attributes - ([if][if] plugin)
28
28
  - Auto camelCase keys - [camel_case][camel_case] plugin
29
+ - Serializing Hash records - [hash_access][hash_access] attribute option
29
30
 
30
31
  ## Installation
31
32
 
@@ -74,12 +75,29 @@ class UserSerializer < Serega
74
75
  # Regular attribute
75
76
  attribute :first_name
76
77
 
77
- # Option :method specifies the method that must be called on the
78
- # serialized object
78
+ # Option :method specifies the name used to read the attribute's value —
79
+ # a method called on the serialized object by default, or a Hash key when
80
+ # combined with :hash_access
79
81
  attribute :first_name, method: :old_first_name
80
82
 
81
- # Block is used to define attribute value
82
- attribute(:first_name) { |user| user.profile&.first_name }
83
+ # Attribute blocks below require a base serializer for nested serializers.
84
+ # See the "Defining a nested serializer with a block" section below.
85
+ config.base_serializer = Serega
86
+
87
+ # Method :itself is handy to serialize the same object with a nested set of
88
+ # attributes. Note: with the :presenter plugin the serialized value will be
89
+ # the Presenter instance itself; use `method: :__getobj__` to serialize the
90
+ # original unwrapped object instead.
91
+ attribute :statistics, method: :itself do
92
+ attribute :likes_count
93
+ attribute :comments_count
94
+ end
95
+
96
+ # Block defines a nested serializer for the attribute value.
97
+ # See the "Defining a nested serializer with a block" section below.
98
+ attribute :author do
99
+ attribute :name
100
+ end
83
101
 
84
102
  # Option :value can be used with a Proc or callable object to define
85
103
  # attribute value
@@ -114,13 +132,13 @@ class UserSerializer < Serega
114
132
 
115
133
  # Option `:many` specifies a has_many relationship. It is optional.
116
134
  # If not specified, it is defined during serialization by checking
117
- # `object.is_a?(Enumerable)`
135
+ # `object.is_a?(Enumerable) && !object.is_a?(Hash) && !object.is_a?(Struct)`
118
136
  # Also the `:many` changes the default value from `nil` to `[]`.
119
137
  attribute :posts, serializer: PostSerializer, many: true
120
138
 
121
139
  # Option `:preload` allows to specify associations to preload to
122
140
  # attribute value
123
- attribute(:email, preload: :emails) { |user| user.emails.find(&:verified?) }
141
+ attribute :email, preload: :emails, value: proc { |user| user.emails.find(&:verified?) }
124
142
 
125
143
  # Options `:if, :unless, :if_value and :unless_value` can be specified
126
144
  # when `:if` plugin is enabled. They hide the attribute key and value from the
@@ -139,6 +157,70 @@ class UserSerializer < Serega
139
157
  end
140
158
  ```
141
159
 
160
+ ### Defining a nested serializer with a block
161
+
162
+ An attribute block defines an anonymous nested serializer. The attribute value
163
+ is found as usual — by the attribute name or the `:method`, `:value`,
164
+ `:delegate`, `:batch` option — and is serialized by this nested serializer.
165
+
166
+ The nested serializer is a regular subclass of an explicitly chosen **base
167
+ serializer** — usually a settings-only serializer holding your plugins and
168
+ configuration. Choose it with the `base_serializer:` attribute option or the
169
+ `config.base_serializer` setting (attribute option wins; an error is raised
170
+ when none is set):
171
+
172
+ ```ruby
173
+ class AppSerializer < Serega
174
+ plugin :activerecord_preloads
175
+
176
+ # Nested serializers defined with attribute blocks will inherit from
177
+ # AppSerializer
178
+ config.base_serializer = self
179
+ end
180
+
181
+ class UserSerializer < AppSerializer
182
+ attribute :first_name
183
+
184
+ # Serializes user.author with a nested serializer inherited from AppSerializer
185
+ attribute :author do
186
+ attribute :name
187
+ end
188
+
189
+ # Serializes the user itself, exposing only counters
190
+ attribute :statistics, method: :itself, base_serializer: StatsBaseSerializer do
191
+ attribute :likes_count
192
+ attribute :comments_count
193
+ end
194
+ end
195
+
196
+ UserSerializer.to_h(user)
197
+ # => {first_name: "Bruce", author: {name: "Bob"}, statistics: {likes_count: 10, comments_count: 3}}
198
+ ```
199
+
200
+ The nested serializer inherits everything the base serializer has — plugins,
201
+ config, attributes, batch loaders, the preload handler — through the regular
202
+ inheritance mechanism. Any serializer can be used as a base: if it has
203
+ attributes, they are serialized by the nested serializer too, together with
204
+ the attributes defined in the block. Anything the base does not provide can
205
+ be declared inside the block:
206
+
207
+ ```ruby
208
+ attribute :statistics, method: :itself do
209
+ batch(:stats) { |users| Stats.for_users(users) } # => { user_id => stat }
210
+
211
+ attribute :likes_count, batch: :stats, value: proc { |user, batches:| batches[:stats][user.id].likes }
212
+ end
213
+ ```
214
+
215
+ Things to keep in mind:
216
+
217
+ - The nested serializer is created at the moment the attribute is defined.
218
+ Changes made to the base serializer later do not affect already defined
219
+ nested serializers.
220
+ - Errors raised while serializing nested attributes are reported with a
221
+ readable serializer label, for example:
222
+ `(when serializing 'comments_count' attribute in UserSerializer.<statistics>)`.
223
+
142
224
  ### Serializing
143
225
 
144
226
  We can serialize objects using class method `.call` aliased as `.to_h` and
@@ -183,15 +265,6 @@ serializer.to_h(user1)
183
265
  serializer.to_h(user2)
184
266
  ```
185
267
 
186
- ---
187
- ⚠️ When you serialize the `Struct` object, specify manually `many: false`. As Struct
188
- is Enumerable and we check `object.is_a?(Enumerable)` to detect if we should
189
- return array.
190
-
191
- ```ruby
192
- UserSerializer.to_h(user_struct, many: false)
193
- ```
194
-
195
268
  ### Selecting Fields
196
269
 
197
270
  By default, all attributes are serialized (except marked as `hide: true`).
@@ -312,9 +385,9 @@ current_user or any.
312
385
 
313
386
  ```ruby
314
387
  class UserSerializer < Serega
315
- attribute(:email) do |user, ctx|
388
+ attribute :email, value: proc { |user, ctx|
316
389
  user.email if ctx[:current_user] == user
317
- end
390
+ }
318
391
  end
319
392
 
320
393
  user = OpenStruct.new(email: 'email@example.com')
@@ -339,15 +412,18 @@ class UserSerializer < Serega
339
412
  batch :comments_count, ->(users) { Comment.where(user: users).group(:user_id).count }
340
413
  batch :comments_count, CommentsCountLoader # Example with callable class
341
414
 
342
- # Full attribute example
343
- attribute :comments_count, batch: { use: :comments_count },
344
- value: proc { |user, batch:| batch[:comments_count][user.id] }
415
+ # Use a loader by its name
416
+ attribute :comments_count, batch: :comments_count
345
417
 
346
- # Shorter version
347
- attribute :comments_count, batch: { use: :comments_count, id: :id } # Equivalent, id: :id is default
418
+ # Equivalent — when the attribute name matches the loader name
419
+ attribute :comments_count, batch: true
348
420
 
349
- # Shortest version
350
- attribute :comments_count, batch: true # Equivalent
421
+ # Equivalent — the default value resolution spelled out
422
+ attribute :comments_count, batch: :comments_count,
423
+ value: proc { |user, batches:| batches[:comments_count][user.id] }
424
+
425
+ # Hash form — needed only for sub-options, for example a custom `:id` method
426
+ attribute :comments_count, batch: { use: :comments_count, id: :uuid }
351
427
  end
352
428
  ```
353
429
 
@@ -382,7 +458,7 @@ class UserSerializer < Serega
382
458
  # Summarize likes
383
459
  attribute :likes_count,
384
460
  batch: { use: [:facebook_likes, :twitter_likes] },
385
- value: { |user, batch:| batch[:facebook_likes][user.id] + batch[:twitter_likes][user.id] }
461
+ value: proc { |user, batches:| batches[:facebook_likes][user.id] + batches[:twitter_likes][user.id] }
386
462
  end
387
463
  ```
388
464
 
@@ -390,6 +466,13 @@ end
390
466
 
391
467
  Here are the default options. Other options can be added with plugins.
392
468
 
469
+ ⚠️ Attributes are prepared at the moment they are defined. If a config option
470
+ influences attributes (`auto_preload`, `hide_by_default`, `batch_id_option`,
471
+ formatters, etc.), changing it affects only attributes defined **after** the
472
+ change — including nested serializers defined with attribute blocks, which
473
+ are created at their definition point. Configure the serializer before
474
+ defining attributes.
475
+
393
476
  ```ruby
394
477
  class AppSerializer < Serega
395
478
  # With `activerecord_preloads` plugin it automatically adds `preload` option
@@ -413,6 +496,19 @@ class AppSerializer < Serega
413
496
  # proc { |object, batches:| batches[:counter][object.id] }
414
497
  config.batch_id_option = :id
415
498
 
499
+ # Defaults for the `hash_access:` attribute option — `default_mode` is
500
+ # what `hash_access: true` (and a Hash form omitting :mode) resolves to.
501
+ # See "Serializing Hash records".
502
+ config.hash_access.default_mode = :symbol # the default
503
+ config.hash_access.default_allow_missing_key = false # the default
504
+
505
+ # Parent class for nested serializers defined with attribute blocks.
506
+ # Usually a settings-only serializer, e.g. `config.base_serializer = self`
507
+ # in an application base serializer class. There is no default — an
508
+ # attribute block raises an error when no base serializer is chosen (it can
509
+ # also be provided per-attribute with the `base_serializer:` option).
510
+ config.base_serializer = self
511
+
416
512
  # Disable/enable validation of modifiers (`:with, :except, :only`)
417
513
  # By default, this validation is enabled.
418
514
  # After disabling, all requested incorrect attributes will be skipped.
@@ -576,6 +672,69 @@ end
576
672
 
577
673
  ---
578
674
 
675
+ ## Serializing Hash records
676
+
677
+ (objects should implement `#[]`, `#fetch`, and `#key?` - Hash already has them)
678
+
679
+ The `hash_access: <mode>` attribute option makes an attribute read its value from
680
+ Hash keys instead of calling a method.
681
+
682
+ Allowed `<mode>`:
683
+
684
+ - `:symbol` reads record[:name]
685
+ - `:string` reads record["name"]
686
+ - `true` shorthand for `config.hash_access.default_mode` (`:symbol` by default)
687
+
688
+ By default missing keys will raise `KeyError`.
689
+
690
+ `config.hash_access.default_allow_missing_key = true` will change `KeyError`
691
+ to return `nil` value instead. `config.hash_access.default_mode = <mode>`
692
+ changes what `true` (and a Hash form omitting `:mode`) resolves to.
693
+
694
+ ```ruby
695
+ class UserSerializer < Serega
696
+ config.hash_access.default_mode = :symbol # the default
697
+ config.hash_access.default_allow_missing_key = true # false by default
698
+
699
+ attribute :name, hash_access: true # reads record[:name] (config.hash_access.default_mode)
700
+ attribute :name, hash_access: :symbol # reads record[:name]
701
+ attribute :name, hash_access: :string # reads record["name"]
702
+
703
+ # Use long form if you need to override `allow_missing_key`
704
+ attribute :name, hash_access: { allow_missing_key: false }
705
+ attribute :name, hash_access: { mode: :symbol, allow_missing_key: false }
706
+ attribute :name, hash_access: { mode: :string, allow_missing_key: false }
707
+ end
708
+ ```
709
+
710
+ Delegated attributes configure hash access **per step**:
711
+
712
+ - `to_hash_access` configures reading `<mode>` for intermediate object
713
+ - `hash_access` configures reading `<mode>` for final key
714
+
715
+ ```ruby
716
+ # reads `record.address.city` (no hash access)
717
+ attribute :city, delegate: { to: :address } # record.address.city
718
+
719
+ # reads record.address[:city]
720
+ attribute :city, delegate: { to: :address, hash_access: :symbol }
721
+
722
+ # reads record[:address].city
723
+ attribute :city, delegate: { to: :address, to_hash_access: :symbol }
724
+
725
+ # reads record[:address][:city]
726
+ attribute :city, delegate: { to: :address, to_hash_access: :symbol, hash_access: :symbol }
727
+
728
+ # reads record["address"] and if exists returns record["address"][:city]
729
+ attribute :city,
730
+ delegate: {
731
+ to: :address,
732
+ allow_nil: true,
733
+ to_hash_access: { mode: :string, allow_missing_key: true },
734
+ hash_access: { mode: :symbol, allow_missing_key: false }
735
+ }
736
+ ```
737
+
579
738
  ## Plugins
580
739
 
581
740
  ### Plugin :activerecord_preloads
@@ -793,7 +952,7 @@ class UserSerializer < Serega
793
952
  end
794
953
  ```
795
954
 
796
- With the plugin, they move into a clean class:
955
+ With the plugin, they move into a `presenter do ... end` block:
797
956
 
798
957
  ```ruby
799
958
  class UserSerializer < Serega
@@ -802,7 +961,7 @@ class UserSerializer < Serega
802
961
  attribute :name
803
962
  attribute :role
804
963
 
805
- class Presenter
964
+ presenter do
806
965
  def name
807
966
  [first_name, last_name].compact.join(' ')
808
967
  end
@@ -814,6 +973,9 @@ class UserSerializer < Serega
814
973
  end
815
974
  ```
816
975
 
976
+ The block is evaluated inside the serializer's own `Presenter` class, so
977
+ multiple `presenter` blocks accumulate.
978
+
817
979
  `Presenter` inherits from `SimpleDelegator`, so every method of the serialized
818
980
  object is available directly inside presenter methods. Any method not explicitly
819
981
  defined on `Presenter` is resolved via `method_missing` on the first call, which
@@ -825,6 +987,12 @@ The original wrapped object is accessible via `__getobj__` (standard
825
987
 
826
988
  The serialization context is accessible via the private method `__ctx__`.
827
989
 
990
+ Objects are wrapped in the `Presenter` only when the serializer's `Presenter`
991
+ class (or an inherited one) actually defines custom methods. Loading the
992
+ plugin in a base serializer adds no overhead to serializers that don't
993
+ customize their presenters — their attribute values, batch loaders and value
994
+ callables keep receiving the raw objects.
995
+
828
996
  ### Plugin :string_modifiers
829
997
 
830
998
  Allows to specify modifiers as strings.
@@ -973,7 +1141,8 @@ end
973
1141
  ### Plugin :explicit_many_option
974
1142
 
975
1143
  The plugin requires adding a `:many` option when adding relationships
976
- (attributes with the `:serializer` option).
1144
+ (attributes with the `:serializer` option or a block defining a nested
1145
+ serializer).
977
1146
 
978
1147
  Adding this plugin makes it clearer to find if some relationship is an array or
979
1148
  a single object.
@@ -1030,6 +1199,7 @@ The gem is available as open source under the terms of the [MIT License](https:/
1030
1199
  [context_metadata]: #plugin-context_metadata
1031
1200
  [depth_limit]: #plugin-depth_limit
1032
1201
  [formatters]: #plugin-formatters
1202
+ [hash_access]: #serializing-hash-records
1033
1203
  [metadata]: #plugin-metadata
1034
1204
  [preloads]: #preloads
1035
1205
  [presenter]: #plugin-presenter
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.38.0
1
+ 0.40.0
@@ -45,10 +45,10 @@ class Serega
45
45
  # @option opts [Symbol] :method Object method name to fetch attribute value
46
46
  # @option opts [Hash] :delegate Allows to fetch value from nested object
47
47
  # @option opts [Boolean] :hide Specify `true` to not serialize this attribute by default
48
- # @option opts [Boolean] :many Specifies has_many relationship. By default is detected via object.is_a?(Enumerable)
48
+ # @option opts [Boolean] :many Specifies has_many relationship. By default is detected via object.is_a?(Enumerable) && !object.is_a?(Hash) && !object.is_a?(Struct)
49
49
  # @option opts [Proc, #call] :value Custom block or callable to find attribute value
50
50
  # @option opts [Serega, Proc] :serializer Relationship serializer class. Use `proc { MySerializer }` if serializers have cross references
51
- # @param block [Proc] Custom block to find attribute value
51
+ # @param block [Proc] Defines attributes of a nested anonymous serializer
52
52
  #
53
53
  def initialize(name:, opts: {}, block: nil)
54
54
  serializer_class = self.class.serializer_class
@@ -88,7 +88,7 @@ class Serega
88
88
  # @return [Object] Serialized attribute value
89
89
  #
90
90
  def value(object, context, batches: nil)
91
- # Signatires should match allowed signatures in CheckOptValue and CheckBlock
91
+ # Signatures should match allowed signatures in CheckOptValue
92
92
  result =
93
93
  case value_block_signature
94
94
  when "1" then value_block.call(object)
@@ -133,8 +133,7 @@ class Serega
133
133
  end
134
134
 
135
135
  def prepare_value_block
136
- init_block \
137
- || init_opts[:value] \
136
+ init_opts[:value] \
138
137
  || prepare_const_block \
139
138
  || prepare_delegate_block \
140
139
  || prepare_batch_loader_block \
@@ -175,7 +174,73 @@ class Serega
175
174
  end
176
175
 
177
176
  def prepare_serializer
178
- init_opts[:serializer]
177
+ block = init_block
178
+ return init_opts[:serializer] unless block
179
+
180
+ prepare_block_serializer(block)
181
+ end
182
+
183
+ # Builds an anonymous nested serializer from the attribute block.
184
+ #
185
+ # The serializer is a regular subclass of an explicitly chosen base —
186
+ # the :base_serializer attribute option or `config.base_serializer` —
187
+ # so it inherits everything the base has: plugins, config, attributes,
188
+ # batch loaders, the preload handler. Nested values can be any objects,
189
+ # which is why the base is chosen explicitly instead of inheriting from
190
+ # the current serializer. Its `inspect` is overridden to
191
+ # `CurrentSerializer.<attribute_name>` so error messages and debugging
192
+ # output point back to the defining attribute.
193
+ # Guards against cyclic definitions before building: inheriting the base
194
+ # serializer copies its attributes, and copying a block attribute builds
195
+ # its nested serializer — meeting the same block again while it is being
196
+ # built means the base serializer (transitively) contains the block
197
+ # attribute being built, so inheriting from it would recurse forever.
198
+ # The "in progress" mark is kept on the block itself — its identity is
199
+ # what defines the cycle (the same Proc object is shared by all copies
200
+ # of the attribute).
201
+ def prepare_block_serializer(block)
202
+ if block.instance_variable_defined?(:@serega_building_nested_serializer)
203
+ raise SeregaError,
204
+ "Can not define a nested serializer for attribute :#{name} —" \
205
+ " its base serializer #{block_base_serializer.inspect} (transitively)" \
206
+ " contains this same block attribute (cyclic definition)"
207
+ end
208
+
209
+ block.instance_variable_set(:@serega_building_nested_serializer, true)
210
+ begin
211
+ build_block_serializer(block)
212
+ ensure
213
+ block.remove_instance_variable(:@serega_building_nested_serializer)
214
+ end
215
+ end
216
+
217
+ def build_block_serializer(block)
218
+ label = "#{self.class.serializer_class.inspect}.<#{name}>"
219
+
220
+ serializer = Class.new(block_base_serializer) do
221
+ define_singleton_method(:inspect) { label }
222
+ define_singleton_method(:to_s) { label }
223
+ instance_exec(&block)
224
+ end
225
+
226
+ # An empty nested serializer means the block was intended as an
227
+ # old-style value block — raise the explaining error.
228
+ raise SeregaError, SeregaValidations::Attribute::CheckBlock::ERROR_MESSAGE if serializer.attributes.empty?
229
+
230
+ serializer
231
+ end
232
+
233
+ # Base class for the nested serializer. Must be chosen explicitly —
234
+ # usually a settings-only serializer holding plugins and configuration
235
+ # (e.g. `config.base_serializer = self` in an application base class).
236
+ def block_base_serializer
237
+ base_serializer = init_opts[:base_serializer] || config.base_serializer
238
+ return base_serializer if base_serializer
239
+
240
+ raise SeregaError,
241
+ "Attribute block requires a base serializer for the nested serializer." \
242
+ " Provide the `base_serializer: <SerializerClass>` attribute option" \
243
+ " or set `config.base_serializer = <SerializerClass>`"
179
244
  end
180
245
 
181
246
  def prepare_method_name
@@ -189,7 +254,10 @@ class Serega
189
254
  end
190
255
 
191
256
  def prepare_keyword_block
192
- AttributeValueResolvers::KeywordResolver.get(method_name)
257
+ mode, allow_nil = hash_access
258
+ return AttributeValueResolvers::KeywordResolver.get(method_name) unless mode
259
+
260
+ AttributeValueResolvers::HashAccessResolver.get(method_name, mode, allow_nil)
193
261
  end
194
262
 
195
263
  def prepare_batch_loader_block
@@ -226,15 +294,68 @@ class Serega
226
294
  init_opts.fetch(:default) { many ? FROZEN_EMPTY_ARRAY : nil }
227
295
  end
228
296
 
297
+ # `delegate: {to_hash_access: ...}` configures hash access for the
298
+ # intermediate (:to) read, `delegate: {hash_access: ...}` for the
299
+ # final (:method) read. A step without its sub-option keeps a plain
300
+ # method read.
229
301
  def prepare_delegate_block
230
302
  delegate = init_opts[:delegate]
231
303
  return unless delegate
232
304
 
233
305
  key_method_name = delegate[:method] || method_name
234
306
  delegate_to = delegate[:to]
307
+ delegate_allow_nil = delegate.fetch(:allow_nil) { config.delegate_default_allow_nil }
308
+
309
+ to_access = delegate[:to_hash_access]
310
+ final_access = delegate[:hash_access]
311
+ unless to_access || final_access
312
+ return AttributeValueResolvers::DelegateResolver.get(delegate_to, key_method_name, delegate_allow_nil)
313
+ end
235
314
 
236
- allow_nil = delegate.fetch(:allow_nil) { config.delegate_default_allow_nil }
237
- AttributeValueResolvers::DelegateResolver.get(delegate_to, key_method_name, allow_nil)
315
+ to_step = delegate_to_step(delegate_to, to_access)
316
+ final_step = delegate_final_step(key_method_name, final_access)
317
+ AttributeValueResolvers::HashAccessDelegateResolver.get(to_step, final_step, delegate_allow_nil)
318
+ end
319
+
320
+ def delegate_to_step(delegate_to, access)
321
+ return AttributeValueResolvers::Keyword.new(delegate_to) unless access
322
+
323
+ mode, allow_missing_key = parse_hash_access(access)
324
+ AttributeValueResolvers::HashAccessKeyword.new(delegate_to, mode, allow_missing_key)
325
+ end
326
+
327
+ def delegate_final_step(key_method_name, access)
328
+ return AttributeValueResolvers::Keyword.new(key_method_name) unless access
329
+
330
+ mode, allow_missing_key = parse_hash_access(access)
331
+ AttributeValueResolvers::HashAccessKeyword.new(key_method_name, mode, allow_missing_key)
332
+ end
333
+
334
+ # Resolves the :hash_access option of plain attributes
335
+ # @return [Array(Symbol, Boolean), nil] mode and allow_missing_key pair,
336
+ # or nil when hash access is not enabled for this attribute
337
+ def hash_access
338
+ option = init_opts[:hash_access]
339
+ return unless option
340
+
341
+ parse_hash_access(option)
342
+ end
343
+
344
+ # Resolves a :hash_access value (`true`, a Symbol mode or a
345
+ # `{mode:, allow_missing_key:}` Hash) into a mode and allow_missing_key
346
+ # pair. `true` and a Hash omitting :mode resolve to
347
+ # `config.hash_access.default_mode`.
348
+ def parse_hash_access(option)
349
+ defaults = config.hash_access
350
+
351
+ case option
352
+ when true then [defaults.default_mode, defaults.default_allow_missing_key]
353
+ when Symbol then [option, defaults.default_allow_missing_key]
354
+ else
355
+ mode = option.fetch(:mode) { defaults.default_mode }
356
+ allow_missing_key = option.fetch(:allow_missing_key) { defaults.default_allow_missing_key }
357
+ [mode, allow_missing_key]
358
+ end
238
359
  end
239
360
 
240
361
  # Prepares preloads for this attribute.
@@ -250,16 +371,26 @@ class Serega
250
371
 
251
372
  # Auto-preload the delegated association
252
373
  if config.auto_preload.fetch(:has_delegate_option) && init_opts[:delegate]
253
- return init_opts[:delegate][:to]
374
+ return auto_preload_value(init_opts[:delegate][:to])
254
375
  end
255
376
 
256
377
  # Auto-preload the nested serializer's association
257
- if config.auto_preload.fetch(:has_serializer_option) && init_opts[:serializer] && !init_opts.key?(:batch)
258
- return method_name
378
+ # (a block defines a nested serializer, same as the :serializer option)
379
+ if config.auto_preload.fetch(:has_serializer_option) && (init_opts[:serializer] || init_block) && !init_opts.key?(:batch)
380
+ return auto_preload_value(method_name)
259
381
  end
260
382
 
261
383
  nil
262
384
  end
385
+
386
+ # Skips auto-preloading of methods that return the serialized object
387
+ # itself (:itself by default) — they are not associations,
388
+ # so preloading them would fail or make no sense.
389
+ def auto_preload_value(preload_method)
390
+ return nil if config.auto_preload_excluded_methods.include?(preload_method.to_sym)
391
+
392
+ preload_method
393
+ end
263
394
  end
264
395
 
265
396
  extend Serega::SeregaHelpers::SerializerClassHelper
@@ -18,6 +18,7 @@ class Serega
18
18
  # It handles this cases:
19
19
  # - `attribute :foo, batch: true`
20
20
  # - `attribute :foo, batch: FooLoader`
21
+ # - `attribute :foo, batch: :foo_loader`
21
22
  # - `attribute :foo, batch: { id: :foo_id }`
22
23
  # - `attribute :foo, batch: { use: FooLoader, id: foo_id }`
23
24
  # - `attribute :foo, batch: { use: :foo_loader, id: foo_id }`
@@ -32,6 +33,9 @@ class Serega
32
33
  serializer_class.batch(attribute_name, batch_opt)
33
34
  batch_name = attribute_name
34
35
  batch_id_method = default_method
36
+ elsif batch_opt.is_a?(Symbol) || batch_opt.is_a?(String) # ex: `batch: :foo_loader`
37
+ batch_name = batch_opt.to_sym
38
+ batch_id_method = default_method
35
39
  else
36
40
  use = batch_opt[:use]
37
41
  batch_id_method = batch_opt[:id] || default_method