serega 0.37.2 → 0.39.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +124 -26
  3. data/VERSION +1 -1
  4. data/lib/serega/attribute.rb +3 -3
  5. data/lib/serega/attribute_normalizer.rb +87 -14
  6. data/lib/serega/attribute_value_resolvers/batch.rb +4 -0
  7. data/lib/serega/config.rb +48 -1
  8. data/lib/serega/engine/level.rb +57 -0
  9. data/lib/serega/engine/level_queue.rb +54 -0
  10. data/lib/serega/{batch → engine}/loader.rb +1 -1
  11. data/lib/serega/object_serializer.rb +61 -56
  12. data/lib/serega/plan.rb +0 -9
  13. data/lib/serega/plan_point.rb +56 -5
  14. data/lib/serega/plugins/activerecord_preloads/activerecord_preloads.rb +21 -1
  15. data/lib/serega/plugins/explicit_many_option/explicit_many_option.rb +3 -2
  16. data/lib/serega/plugins/explicit_many_option/validations/check_opt_many.rb +6 -4
  17. data/lib/serega/plugins/if/if.rb +9 -10
  18. data/lib/serega/plugins/presenter/presenter.rb +101 -5
  19. data/lib/serega/plugins/root/root.rb +1 -1
  20. data/lib/serega/utils/collection_detector.rb +26 -0
  21. data/lib/serega/validations/attribute/check_block.rb +20 -55
  22. data/lib/serega/validations/attribute/check_opt_base_serializer.rb +37 -0
  23. data/lib/serega/validations/attribute/check_opt_batch.rb +6 -6
  24. data/lib/serega/validations/attribute/check_opt_const.rb +3 -4
  25. data/lib/serega/validations/attribute/check_opt_delegate.rb +3 -4
  26. data/lib/serega/validations/attribute/check_opt_many.rb +8 -7
  27. data/lib/serega/validations/attribute/check_opt_method.rb +3 -4
  28. data/lib/serega/validations/attribute/check_opt_serializer.rb +4 -1
  29. data/lib/serega/validations/attribute/check_opt_value.rb +3 -4
  30. data/lib/serega/validations/check_attribute_params.rb +8 -7
  31. data/lib/serega.rb +33 -29
  32. metadata +6 -5
  33. data/lib/serega/batch/attribute_loader.rb +0 -82
  34. data/lib/serega/batch/attribute_loaders.rb +0 -117
  35. data/lib/serega/batch/level.rb +0 -48
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fe536c6d684f24508a5153d1fc679cd451d2360ab3d752ff2ba08a2249b264c9
4
- data.tar.gz: 0110de5fca4df251b1101daaba232bf1fcace80b6290bb95e35a8c892fc1ce1b
3
+ metadata.gz: a223242ae8bce14755fa2cbb444ff78728d95fccb0ce3077c212706e39e4ddcc
4
+ data.tar.gz: b8e1d3bdf11a57091cfd22eb31bb017979382df4b2166b05f746d064dc486f6e
5
5
  SHA512:
6
- metadata.gz: 5a7f60a35e6791337f15772efeb2dfa0363ff96bb99f815c849c1fb5b40125854802b39ef492bea078f6bb614171b29b8f0e5409e421c52e6f24bf39eabfffdd
7
- data.tar.gz: 2d3adb93d754ed3fb42581e5a45493e30e3f1fbb1cda8c68956e5cc047be42417f8cd487074cae5ce11fe72dbfa633ba32ccdc52dd002de17f349b7532c10f4f
6
+ metadata.gz: d815a4a17625533c34659451851c33806bd4d1e0bf862945b2118d52315726184322c55d989378cf8ac7f9b4d04a392dfa344af196491b984ddc1d094d15c063
7
+ data.tar.gz: ecf2be1cff661bfe8e99103748276969f72442b04b8308dd136d137dc88f28dfb543ad73a5e0880a31eb6f6c4a6a51e6ccab307a92f867551ff4b19b56d38f73
data/README.md CHANGED
@@ -78,8 +78,24 @@ class UserSerializer < Serega
78
78
  # serialized object
79
79
  attribute :first_name, method: :old_first_name
80
80
 
81
- # Block is used to define attribute value
82
- attribute(:first_name) { |user| user.profile&.first_name }
81
+ # Attribute blocks below require a base serializer for nested serializers.
82
+ # See the "Defining a nested serializer with a block" section below.
83
+ config.base_serializer = Serega
84
+
85
+ # Method :itself is handy to serialize the same object with a nested set of
86
+ # attributes. Note: with the :presenter plugin the serialized value will be
87
+ # the Presenter instance itself; use `method: :__getobj__` to serialize the
88
+ # original unwrapped object instead.
89
+ attribute :statistics, method: :itself do
90
+ attribute :likes_count
91
+ attribute :comments_count
92
+ end
93
+
94
+ # Block defines a nested serializer for the attribute value.
95
+ # See the "Defining a nested serializer with a block" section below.
96
+ attribute :author do
97
+ attribute :name
98
+ end
83
99
 
84
100
  # Option :value can be used with a Proc or callable object to define
85
101
  # attribute value
@@ -114,13 +130,13 @@ class UserSerializer < Serega
114
130
 
115
131
  # Option `:many` specifies a has_many relationship. It is optional.
116
132
  # If not specified, it is defined during serialization by checking
117
- # `object.is_a?(Enumerable)`
133
+ # `object.is_a?(Enumerable) && !object.is_a?(Hash) && !object.is_a?(Struct)`
118
134
  # Also the `:many` changes the default value from `nil` to `[]`.
119
135
  attribute :posts, serializer: PostSerializer, many: true
120
136
 
121
137
  # Option `:preload` allows to specify associations to preload to
122
138
  # attribute value
123
- attribute(:email, preload: :emails) { |user| user.emails.find(&:verified?) }
139
+ attribute :email, preload: :emails, value: proc { |user| user.emails.find(&:verified?) }
124
140
 
125
141
  # Options `:if, :unless, :if_value and :unless_value` can be specified
126
142
  # when `:if` plugin is enabled. They hide the attribute key and value from the
@@ -139,6 +155,70 @@ class UserSerializer < Serega
139
155
  end
140
156
  ```
141
157
 
158
+ ### Defining a nested serializer with a block
159
+
160
+ An attribute block defines an anonymous nested serializer. The attribute value
161
+ is found as usual — by the attribute name or the `:method`, `:value`,
162
+ `:delegate`, `:batch` option — and is serialized by this nested serializer.
163
+
164
+ The nested serializer is a regular subclass of an explicitly chosen **base
165
+ serializer** — usually a settings-only serializer holding your plugins and
166
+ configuration. Choose it with the `base_serializer:` attribute option or the
167
+ `config.base_serializer` setting (attribute option wins; an error is raised
168
+ when none is set):
169
+
170
+ ```ruby
171
+ class AppSerializer < Serega
172
+ plugin :activerecord_preloads
173
+
174
+ # Nested serializers defined with attribute blocks will inherit from
175
+ # AppSerializer
176
+ config.base_serializer = self
177
+ end
178
+
179
+ class UserSerializer < AppSerializer
180
+ attribute :first_name
181
+
182
+ # Serializes user.author with a nested serializer inherited from AppSerializer
183
+ attribute :author do
184
+ attribute :name
185
+ end
186
+
187
+ # Serializes the user itself, exposing only counters
188
+ attribute :statistics, method: :itself, base_serializer: StatsBaseSerializer do
189
+ attribute :likes_count
190
+ attribute :comments_count
191
+ end
192
+ end
193
+
194
+ UserSerializer.to_h(user)
195
+ # => {first_name: "Bruce", author: {name: "Bob"}, statistics: {likes_count: 10, comments_count: 3}}
196
+ ```
197
+
198
+ The nested serializer inherits everything the base serializer has — plugins,
199
+ config, attributes, batch loaders, the preload handler — through the regular
200
+ inheritance mechanism. Any serializer can be used as a base: if it has
201
+ attributes, they are serialized by the nested serializer too, together with
202
+ the attributes defined in the block. Anything the base does not provide can
203
+ be declared inside the block:
204
+
205
+ ```ruby
206
+ attribute :statistics, method: :itself do
207
+ batch(:stats) { |users| Stats.for_users(users) } # => { user_id => stat }
208
+
209
+ attribute :likes_count, batch: :stats, value: proc { |user, batches:| batches[:stats][user.id].likes }
210
+ end
211
+ ```
212
+
213
+ Things to keep in mind:
214
+
215
+ - The nested serializer is created at the moment the attribute is defined.
216
+ Changes made to the base serializer later do not affect already defined
217
+ nested serializers.
218
+ - Errors raised while serializing nested attributes are reported with a
219
+ readable serializer label, for example:
220
+ `(when serializing 'comments_count' attribute in UserSerializer.<statistics>)`.
221
+
142
222
  ### Serializing
143
223
 
144
224
  We can serialize objects using class method `.call` aliased as `.to_h` and
@@ -183,15 +263,6 @@ serializer.to_h(user1)
183
263
  serializer.to_h(user2)
184
264
  ```
185
265
 
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
266
  ### Selecting Fields
196
267
 
197
268
  By default, all attributes are serialized (except marked as `hide: true`).
@@ -312,9 +383,9 @@ current_user or any.
312
383
 
313
384
  ```ruby
314
385
  class UserSerializer < Serega
315
- attribute(:email) do |user, ctx|
386
+ attribute :email, value: proc { |user, ctx|
316
387
  user.email if ctx[:current_user] == user
317
- end
388
+ }
318
389
  end
319
390
 
320
391
  user = OpenStruct.new(email: 'email@example.com')
@@ -339,15 +410,18 @@ class UserSerializer < Serega
339
410
  batch :comments_count, ->(users) { Comment.where(user: users).group(:user_id).count }
340
411
  batch :comments_count, CommentsCountLoader # Example with callable class
341
412
 
342
- # Full attribute example
343
- attribute :comments_count, batch: { use: :comments_count },
344
- value: proc { |user, batch:| batch[:comments_count][user.id] }
413
+ # Use a loader by its name
414
+ attribute :comments_count, batch: :comments_count
345
415
 
346
- # Shorter version
347
- attribute :comments_count, batch: { use: :comments_count, id: :id } # Equivalent, id: :id is default
416
+ # Equivalent — when the attribute name matches the loader name
417
+ attribute :comments_count, batch: true
348
418
 
349
- # Shortest version
350
- attribute :comments_count, batch: true # Equivalent
419
+ # Equivalent — the default value resolution spelled out
420
+ attribute :comments_count, batch: :comments_count,
421
+ value: proc { |user, batches:| batches[:comments_count][user.id] }
422
+
423
+ # Hash form — needed only for sub-options, for example a custom `:id` method
424
+ attribute :comments_count, batch: { use: :comments_count, id: :uuid }
351
425
  end
352
426
  ```
353
427
 
@@ -382,7 +456,7 @@ class UserSerializer < Serega
382
456
  # Summarize likes
383
457
  attribute :likes_count,
384
458
  batch: { use: [:facebook_likes, :twitter_likes] },
385
- value: { |user, batch:| batch[:facebook_likes][user.id] + batch[:twitter_likes][user.id] }
459
+ value: proc { |user, batches:| batches[:facebook_likes][user.id] + batches[:twitter_likes][user.id] }
386
460
  end
387
461
  ```
388
462
 
@@ -390,6 +464,13 @@ end
390
464
 
391
465
  Here are the default options. Other options can be added with plugins.
392
466
 
467
+ ⚠️ Attributes are prepared at the moment they are defined. If a config option
468
+ influences attributes (`auto_preload`, `hide_by_default`, `batch_id_option`,
469
+ formatters, etc.), changing it affects only attributes defined **after** the
470
+ change — including nested serializers defined with attribute blocks, which
471
+ are created at their definition point. Configure the serializer before
472
+ defining attributes.
473
+
393
474
  ```ruby
394
475
  class AppSerializer < Serega
395
476
  # With `activerecord_preloads` plugin it automatically adds `preload` option
@@ -413,6 +494,13 @@ class AppSerializer < Serega
413
494
  # proc { |object, batches:| batches[:counter][object.id] }
414
495
  config.batch_id_option = :id
415
496
 
497
+ # Parent class for nested serializers defined with attribute blocks.
498
+ # Usually a settings-only serializer, e.g. `config.base_serializer = self`
499
+ # in an application base serializer class. There is no default — an
500
+ # attribute block raises an error when no base serializer is chosen (it can
501
+ # also be provided per-attribute with the `base_serializer:` option).
502
+ config.base_serializer = self
503
+
416
504
  # Disable/enable validation of modifiers (`:with, :except, :only`)
417
505
  # By default, this validation is enabled.
418
506
  # After disabling, all requested incorrect attributes will be skipped.
@@ -793,7 +881,7 @@ class UserSerializer < Serega
793
881
  end
794
882
  ```
795
883
 
796
- With the plugin, they move into a clean class:
884
+ With the plugin, they move into a `presenter do ... end` block:
797
885
 
798
886
  ```ruby
799
887
  class UserSerializer < Serega
@@ -802,7 +890,7 @@ class UserSerializer < Serega
802
890
  attribute :name
803
891
  attribute :role
804
892
 
805
- class Presenter
893
+ presenter do
806
894
  def name
807
895
  [first_name, last_name].compact.join(' ')
808
896
  end
@@ -814,6 +902,9 @@ class UserSerializer < Serega
814
902
  end
815
903
  ```
816
904
 
905
+ The block is evaluated inside the serializer's own `Presenter` class, so
906
+ multiple `presenter` blocks accumulate.
907
+
817
908
  `Presenter` inherits from `SimpleDelegator`, so every method of the serialized
818
909
  object is available directly inside presenter methods. Any method not explicitly
819
910
  defined on `Presenter` is resolved via `method_missing` on the first call, which
@@ -825,6 +916,12 @@ The original wrapped object is accessible via `__getobj__` (standard
825
916
 
826
917
  The serialization context is accessible via the private method `__ctx__`.
827
918
 
919
+ Objects are wrapped in the `Presenter` only when the serializer's `Presenter`
920
+ class (or an inherited one) actually defines custom methods. Loading the
921
+ plugin in a base serializer adds no overhead to serializers that don't
922
+ customize their presenters — their attribute values, batch loaders and value
923
+ callables keep receiving the raw objects.
924
+
828
925
  ### Plugin :string_modifiers
829
926
 
830
927
  Allows to specify modifiers as strings.
@@ -973,7 +1070,8 @@ end
973
1070
  ### Plugin :explicit_many_option
974
1071
 
975
1072
  The plugin requires adding a `:many` option when adding relationships
976
- (attributes with the `:serializer` option).
1073
+ (attributes with the `:serializer` option or a block defining a nested
1074
+ serializer).
977
1075
 
978
1076
  Adding this plugin makes it clearer to find if some relationship is an array or
979
1077
  a single object.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.37.2
1
+ 0.39.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)
@@ -109,9 +109,9 @@ class Serega
109
109
  end
110
110
 
111
111
  #
112
- # Shows normalized preloads for current attribute
112
+ # Preloads declared for this attribute, passed through as provided.
113
113
  #
114
- # @return [Hash, nil] normalized preloads of current attribute
114
+ # @return [Object, nil] declared preloads, or nil when the attribute has none
115
115
  #
116
116
  def preloads
117
117
  return @preloads if instance_variable_defined?(:@preloads)
@@ -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
@@ -199,16 +264,14 @@ class Serega
199
264
  AttributeValueResolvers::BatchResolver.get(self.class.serializer_class, name, batch_opt)
200
265
  end
201
266
 
267
+ # Batch loader names whose loaded data this attribute's value needs. Only
268
+ # explicit `:batch` attributes have any — everything else resolves its value
269
+ # from the record itself, so the list is empty (no loader to run).
202
270
  def prepare_batch_loaders
203
271
  batch_opt = init_opts[:batch]
204
272
  return explicit_batch_loaders(batch_opt) if batch_opt
205
- return FROZEN_EMPTY_ARRAY unless serializer || preloads
206
273
 
207
- # Relations and preloads only need to be routed through the batch phase
208
- # (so objects are gathered per level and preloads run) — there is nothing
209
- # to load, since the value comes from the attribute's own resolver. Mark
210
- # them with the reserved marker; the batch machinery skips it.
211
- [SeregaBatch::AUTO_BATCH_LOADER_NAME]
274
+ FROZEN_EMPTY_ARRAY
212
275
  end
213
276
 
214
277
  def explicit_batch_loaders(batch_opt)
@@ -252,16 +315,26 @@ class Serega
252
315
 
253
316
  # Auto-preload the delegated association
254
317
  if config.auto_preload.fetch(:has_delegate_option) && init_opts[:delegate]
255
- return init_opts[:delegate][:to]
318
+ return auto_preload_value(init_opts[:delegate][:to])
256
319
  end
257
320
 
258
321
  # Auto-preload the nested serializer's association
259
- if config.auto_preload.fetch(:has_serializer_option) && init_opts[:serializer] && !init_opts.key?(:batch)
260
- return method_name
322
+ # (a block defines a nested serializer, same as the :serializer option)
323
+ if config.auto_preload.fetch(:has_serializer_option) && (init_opts[:serializer] || init_block) && !init_opts.key?(:batch)
324
+ return auto_preload_value(method_name)
261
325
  end
262
326
 
263
327
  nil
264
328
  end
329
+
330
+ # Skips auto-preloading of methods that return the serialized object
331
+ # itself (:itself by default) — they are not associations,
332
+ # so preloading them would fail or make no sense.
333
+ def auto_preload_value(preload_method)
334
+ return nil if config.auto_preload_excluded_methods.include?(preload_method.to_sym)
335
+
336
+ preload_method
337
+ end
265
338
  end
266
339
 
267
340
  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
data/lib/serega/config.rb CHANGED
@@ -24,6 +24,7 @@ class Serega
24
24
  default
25
25
  preload
26
26
  batch
27
+ base_serializer
27
28
  ].freeze,
28
29
  serialize_keys: %i[context many].freeze,
29
30
  check_attribute_name: true,
@@ -31,8 +32,10 @@ class Serega
31
32
  delegate_default_allow_nil: false,
32
33
  max_cached_plans_per_serializer_count: 0,
33
34
  auto_preload: {has_delegate_option: false, has_serializer_option: false},
35
+ auto_preload_excluded_methods: %i[itself].freeze,
34
36
  hide_by_default: false,
35
- batch_id_option: :id
37
+ batch_id_option: :id,
38
+ base_serializer: nil
36
39
  }.freeze
37
40
  # :nocov:
38
41
 
@@ -114,6 +117,50 @@ class Serega
114
117
  opts[:delegate_default_allow_nil] = value
115
118
  end
116
119
 
120
+ # Returns :auto_preload_excluded_methods config option — methods that are
121
+ # never auto-preloaded, as they return the serialized object itself and
122
+ # not an association
123
+ # @return [Array<Symbol>] Current :auto_preload_excluded_methods config option
124
+ def auto_preload_excluded_methods
125
+ opts.fetch(:auto_preload_excluded_methods)
126
+ end
127
+
128
+ # Sets :auto_preload_excluded_methods config option — methods that are
129
+ # never auto-preloaded. Applies to the `:method` option of attributes
130
+ # with a serializer and to the `delegate: {to: ...}` option.
131
+ #
132
+ # @param value [Array<Symbol>] Method names to skip in auto-preload
133
+ #
134
+ # @return [Array<Symbol>] New :auto_preload_excluded_methods config option
135
+ def auto_preload_excluded_methods=(value)
136
+ unless value.is_a?(Array) && value.all?(Symbol)
137
+ raise SeregaError, "Must be an Array of Symbols, #{value.inspect} provided"
138
+ end
139
+
140
+ opts[:auto_preload_excluded_methods] = value
141
+ end
142
+
143
+ # Returns :base_serializer config option — the parent class for nested
144
+ # serializers defined with attribute blocks
145
+ # @return [Class, nil] Current :base_serializer config option
146
+ def base_serializer
147
+ opts.fetch(:base_serializer)
148
+ end
149
+
150
+ # Sets :base_serializer config option — the parent class for nested
151
+ # serializers defined with attribute blocks. Usually a settings-only
152
+ # serializer, e.g. `config.base_serializer = self` in an application
153
+ # base serializer class.
154
+ #
155
+ # @param value [Class] Serega or its subclass
156
+ #
157
+ # @return [Class] New :base_serializer config option
158
+ def base_serializer=(value)
159
+ raise SeregaError, "Must be a Serega subclass, #{value.inspect} provided" if !value.is_a?(Class) || !(value <= Serega)
160
+
161
+ opts[:base_serializer] = value
162
+ end
163
+
117
164
  # Returns :hide_by_default config option
118
165
  # @return [Boolean, Symbol] Current :hide_by_default config option
119
166
  def hide_by_default
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Serega
4
+ #
5
+ # Batch feature main module
6
+ #
7
+ module SeregaEngine
8
+ #
9
+ # One serialization level: all objects serialized under a single plan and the
10
+ # result containers they fill. Objects from different parents that share a plan
11
+ # (e.g. every user's posts) accumulate into one level, so a named batch loader
12
+ # or preload runs once over the whole set. A deeper level (its own plan) loads
13
+ # separately.
14
+ #
15
+ class Level
16
+ # @param serializer [SeregaObjectSerializer] serializer that resolves this level
17
+ def initialize(serializer)
18
+ @serializer = serializer
19
+ @objects = []
20
+ @containers = []
21
+ @results = {}.compare_by_identity
22
+ end
23
+
24
+ # @return [Array] Objects serialized at this level
25
+ attr_reader :objects
26
+
27
+ # @return [Array<Hash>] Result container per object (filled in place)
28
+ attr_reader :containers
29
+
30
+ # Accumulates a chunk of objects into this level, creating one empty result
31
+ # container per object. The containers are returned so the caller can hand
32
+ # them to its parent; they are filled in place later during #process.
33
+ #
34
+ # @param objects [Array] objects serialized at this level
35
+ # @return [Array<Hash>] the created containers, aligned with objects
36
+ def add(objects)
37
+ containers = Array.new(objects.size) { {} }
38
+ @objects.concat(objects)
39
+ @containers.concat(containers)
40
+ containers
41
+ end
42
+
43
+ # Resolves every attribute of this level onto the containers.
44
+ # @return [void]
45
+ def process
46
+ @serializer.process(self)
47
+ end
48
+
49
+ # Loads a named batch loader once for this level's objects.
50
+ # @param loader [SeregaEngine::Loader] Named batch loader
51
+ # @return [Object] Loaded values
52
+ def fetch(loader)
53
+ @results[loader] ||= loader.load(@objects, @serializer.context)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Serega
4
+ #
5
+ # Batch feature main module
6
+ #
7
+ module SeregaEngine
8
+ #
9
+ # Level-order queue of serialization levels for one serialization run.
10
+ #
11
+ # `#serialize` on the object serializer enqueues a level (objects + their result
12
+ # containers) instead of resolving values inline. `#run` then processes the
13
+ # queue: each level resolves its attributes and, for relations, enqueues child
14
+ # levels — which the loop picks up because it re-reads the size each iteration.
15
+ # Processing level-by-level lets a named batch loader and preloads run once per
16
+ # level over all of its objects.
17
+ #
18
+ class LevelQueue
19
+ def initialize
20
+ @levels = []
21
+ @levels_by_plan = {}.compare_by_identity
22
+ end
23
+
24
+ # Adds objects to the level for their plan (creating it on first use), so
25
+ # objects serialized under the same plan — even from different parents —
26
+ # share one level and load once. The level creates a result container per
27
+ # object and returns them for the caller to embed in its parent.
28
+ #
29
+ # @param serializer [SeregaObjectSerializer] serializer that resolves the level
30
+ # @param objects [Array] objects serialized at this level
31
+ #
32
+ # @return [Array<Hash>] the created result containers, aligned with objects
33
+ def enqueue(serializer, objects)
34
+ level = @levels_by_plan[serializer.plan]
35
+ unless level
36
+ level = Level.new(serializer)
37
+ @levels_by_plan[serializer.plan] = level
38
+ @levels << level
39
+ end
40
+ level.add(objects)
41
+ end
42
+
43
+ # Processes every level, including child levels enqueued while processing.
44
+ # @return [void]
45
+ def run
46
+ i = 0
47
+ while i < @levels.size
48
+ @levels[i].process
49
+ i += 1
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -4,7 +4,7 @@ class Serega
4
4
  #
5
5
  # Batch feature main module
6
6
  #
7
- module SeregaBatch
7
+ module SeregaEngine
8
8
  #
9
9
  # Batch loader
10
10
  #