serega 0.36.0 → 0.37.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 461e4c9b6b9dcaf1c93cc490c8a4270bcc8ae576eed523ef14ee3bc1e6be0944
4
- data.tar.gz: 7ac9f86f0952070deb5c6a5a812ec9384d9cadab9f783fa5ae5e705ff6cd3116
3
+ metadata.gz: cf4c5f1a34256084ebedcba18d08dcdc30f9a3090ca3b555a7ee91fc8e0dfc44
4
+ data.tar.gz: 05022337ad49d098c50f25c267f33a2db6c210b169f3243b88c010a5f9bc519e
5
5
  SHA512:
6
- metadata.gz: addb468352c4adc023c4f04e30dc6533fefbf7faba9755e02cd243e9f4631c30a0b3b4dd65b160a0f5c012e76717e2d67ddf3c0ac712e5cd0b3008ea3cd8df7d
7
- data.tar.gz: da936fb8dd2fac9bafe4e670da795f3b3075d591b3c88411b306898d9a59f944354dc8b69941e19b7b2012bda6a6111a25f9dd0347d5ad8c76b56f310c0353ec
6
+ metadata.gz: a9b5a2797086500903904617124810a4401f0a223ea75ba88d78c6cf3cba90157e9ff666f54fd76183fbbed4ab54d7be33704fd7c45665537e825f5f0f67222c
7
+ data.tar.gz: 150fcf62d92d01076c61d38a4189a31d789904fc643c2ce06c569280099d598582e3e07b7ff32a3482e0754d47045123dcf7f01baf7f7f65afebe2c1465e3c93
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Serega Ruby Serializer
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/serega.svg)](https://badge.fury.io/rb/serega)
4
- [![GitHub Actions](https://github.com/aglushkov/serega/actions/workflows/main.yml/badge.svg?event=push)](https://github.com/aglushkov/serega/actions/workflows/main.yml)
4
+ [![GitHub Actions][build-badge]][build]
5
5
 
6
6
  The Serega Ruby Serializer provides easy and powerful DSL to describe your
7
7
  objects and serialize them to Hash or JSON.
@@ -428,28 +428,38 @@ end
428
428
 
429
429
  ## Preloads
430
430
 
431
- Serega includes built-in preloads functionality that allows you to define
432
- `:preloads` to attributes and then merge preloads from serialized attributes
433
- into a single associations hash.
431
+ Serega includes built-in preloads functionality that lets you declare
432
+ `:preload` on attributes. It is used by the `:activerecord_preloads` plugin:
433
+ enable it and every association you declare is loaded once during
434
+ serialization, automatically, with no N+1.
434
435
 
435
436
  Configuration options:
436
437
 
437
438
  - `config.auto_preload` - default `false` (use `true` or `{ has_delegate_option: true, has_serializer_option: true }`)
438
- - `config.hide_by_default` - default `false` (use `[:preload]` to hide attributes with preloads)
439
+ - `config.hide_by_default` - default `false` (use `:auto` to hide attributes with preloads or batch loaders)
439
440
 
440
441
  These options are extremely useful if you want to forget about finding
441
442
  preloads manually.
442
443
 
443
- Preloads can be disabled with the `preload: false` attribute option.
444
- Automatically added preloads can be overwritten with the manually specified
445
- `preload: :xxx` option.
444
+ The `:preload` value is passed to the preload handler exactly as written — a
445
+ Symbol, Array, Hash, or any custom value your ORM understands. Preloads can be
446
+ disabled with `preload: false` (or `preload: nil`); `preload: true` is not a
447
+ valid value. Automatically added preloads can be overwritten with the manually
448
+ specified `preload: :xxx` option.
449
+
450
+ The actual loading is performed by a handler registered with `preload_with`. The
451
+ `:activerecord_preloads` plugin registers one for you; to preload with another
452
+ ORM — or to attach data to plain non-ORM objects by your own rules — register
453
+ your own (see [Custom preloading](#custom-preloading)). Declaring `:preload`
454
+ without a registered handler raises an error, so a preload never silently does
455
+ nothing.
446
456
 
447
457
  For some examples, **please read the comments in the code below**
448
458
 
449
459
  ```ruby
450
460
  class AppSerializer < Serega
451
461
  config.auto_preload = true
452
- config.hide_by_default = [:preload]
462
+ config.hide_by_default = :auto
453
463
  end
454
464
 
455
465
  class UserSerializer < AppSerializer
@@ -473,36 +483,30 @@ class AlbumSerializer < AppSerializer
473
483
  attribute :images_count, delegate: { to: :album_stats }
474
484
  end
475
485
 
476
- # By default, preloads are empty, as we specify `hide_by_default = [:preload]`
477
- # so attributes with preloads will be skipped and nothing will be preloaded
478
- UserSerializer.new.preloads
479
- # => {}
480
-
481
- UserSerializer.new(with: :followers_count).preloads
482
- # => {:user_stats=>{}}
483
-
484
- UserSerializer.new(with: %i[followers_count comments_count]).preloads
485
- # => {:user_stats=>{}}
486
-
487
- UserSerializer.new(
488
- with: [:followers_count, :comments_count, { albums: :images_count }]
489
- ).preloads
490
- # => {:user_stats=>{}, :albums=>{:album_stats=>{}}}
486
+ # With `hide_by_default = :auto`, attributes that declare a preload are hidden
487
+ # unless explicitly requested with `:with`. When requested, their associations
488
+ # are preloaded automatically. For example:
489
+ #
490
+ # UserSerializer.to_h(users, with: [:followers_count, { albums: :images_count }])
491
+ #
492
+ # preloads `:user_stats` and `:albums` on the users, and `:album_stats` on the
493
+ # albums.
491
494
  ```
492
495
 
493
496
  ---
494
497
 
495
- ### SPECIFIC CASE #1: Serializing the same object in association
498
+ ### SPECIFIC CASE: Serializing the same object in association
496
499
 
497
500
  For example, you show your current user as "user" and use the same user object
498
501
  to serialize "user_stats". `UserStatSerializer` relies on user fields and any
499
- other user associations. You should specify `preload: nil` to preload
500
- `UserStatSerializer` nested associations to the "user" object.
502
+ other user associations. Specify `preload: nil` so that nothing is preloaded for
503
+ this attribute on the "user" object — the associations declared in
504
+ `UserStatSerializer` are still preloaded onto the same user object.
501
505
 
502
506
  ```ruby
503
507
  class AppSerializer < Serega
504
508
  config.auto_preload = true
505
- config.hide_by_default = [:preload]
509
+ config.hide_by_default = :auto
506
510
  end
507
511
 
508
512
  class UserSerializer < AppSerializer
@@ -514,69 +518,47 @@ class UserSerializer < AppSerializer
514
518
  end
515
519
  ```
516
520
 
517
- ### SPECIFIC CASE #2: Serializing multiple associations as a single relation
521
+ ---
518
522
 
519
- For example, "user" has two relations - "new_profile" and "old_profile". Also
520
- profiles have the "avatar" association. And you decided to serialize profiles in
521
- one array. You can specify `preload_path: [[:new_profile], [:old_profile]]` to
522
- achieve this:
523
+ ### Custom preloading
523
524
 
524
- ```ruby
525
- class AppSerializer < Serega
526
- config.auto_preload = true
527
- end
528
-
529
- class UserSerializer < AppSerializer
530
- attribute :username
531
- attribute :profiles,
532
- serializer: 'ProfileSerializer',
533
- value: proc { |user| [user.new_profile, user.old_profile] },
534
- preload: [:new_profile, :old_profile],
535
- preload_path: [[:new_profile], [:old_profile]] # <--- like here
536
- end
525
+ The `:activerecord_preloads` plugin works out of the box, but the preload
526
+ mechanism itself is not tied to ActiveRecord — or to ORMs at all. Register a
527
+ handler with `preload_with` and it is called once per preloaded attribute with
528
+ the gathered objects and that attribute's `:preload` value, before the
529
+ attribute is serialized:
537
530
 
538
- class ProfileSerializer < AppSerializer
539
- attribute :avatar, serializer: 'AvatarSerializer'
540
- end
531
+ ```ruby
532
+ class UserSerializer < Serega
533
+ # `objects` is the gathered users; `preloads` is this attribute's
534
+ # `:preload` value (`:posts`). `MyORM.preload` does the eager loading.
535
+ preload_with { |objects, preloads| MyORM.preload(objects, preloads) }
541
536
 
542
- class AvatarSerializer < AppSerializer
537
+ attribute :name
538
+ attribute :posts, serializer: PostSerializer, preload: :posts
543
539
  end
544
-
545
- UserSerializer.new.preloads
546
- # => {:new_profile=>{:avatar=>{}}, :old_profile=>{:avatar=>{}}}
547
- ```
548
-
549
- ### SPECIFIC CASE #3: Preload association through another association
550
-
551
- ```ruby
552
- attribute :image,
553
- preload: { attachment: :blob }, # <--------- like this one
554
- value: proc { |record| record.attachment },
555
- serializer: ImageSerializer,
556
- preload_path: [:attachment] # or preload_path: [:attachment, :blob]
557
540
  ```
558
541
 
559
- In this case, we don't know if preloads defined in ImageSerializer, should be
560
- preloaded to `attachment` or `blob`, so `preload_path` must be specified manually.
561
- You can specify `preload_path: nil` if you are sure that there are no preloads
562
- inside ImageSerializer.
542
+ The handler can be a block or any callable taking `(objects, preloads)`. It is
543
+ inherited by child serializers and can be overridden per subclass. The
544
+ `:preload` value reaches the handler untouched, so you can pass whatever your
545
+ handler expects — not necessarily something an ORM understands. The gathered
546
+ objects can be plain Ruby objects too: the handler decides where the data comes
547
+ from (another ORM, an HTTP API, a cache) and how to attach it to the objects,
548
+ by whatever rules you define.
563
549
 
564
550
  ---
565
551
 
566
- 📌 The built-in preloads functionality only allows to group preloads together
567
- in single Hash, but they should be preloaded manually.
568
-
569
- There is the [activerecord_preloads][activerecord_preloads] plugin that can be used to preload these associations automatically.
570
-
571
552
  ## Plugins
572
553
 
573
554
  ### Plugin :activerecord_preloads
574
555
 
575
556
  Automatically preloads associations to serialized objects.
576
557
 
577
- It takes all defined preloads from serialized attributes (including attributes
578
- from serialized associations), merges them into a single associations hash, and then
579
- uses ActiveRecord::Associations::Preloader to preload associations to objects.
558
+ Every association you declare with `:preload` is loaded once during
559
+ serialization using `ActiveRecord::Associations::Preloader`, so there are no
560
+ N+1 queries. The plugin does this by registering a [`preload_with`](#custom-preloading)
561
+ handler; to use a different ORM, register your own instead.
580
562
 
581
563
  ```ruby
582
564
  class AppSerializer < Serega
@@ -599,11 +581,10 @@ class AlbumSerializer < AppSerializer
599
581
  end
600
582
 
601
583
  UserSerializer.to_h(user)
602
- # => preloads {users_stats: {}, albums: { downloads: {} }}
584
+ # preloads :user_stats and :albums on the user; the AlbumSerializer level
585
+ # preloads :downloads on the albums
603
586
  ```
604
587
 
605
- For testing purposes preloading can be done manually with `#preload_associations_to(obj)` instance method
606
-
607
588
  ### Plugin :root
608
589
 
609
590
  Allows to add root key to your serialized data
@@ -1028,3 +1009,5 @@ The gem is available as open source under the terms of the [MIT License](https:/
1028
1009
  [root]: #plugin-root
1029
1010
  [string_modifiers]: #plugin-string_modifiers
1030
1011
  [if]: #plugin-if
1012
+ [build-badge]: https://github.com/aglushkov/serega/actions/workflows/main.yml/badge.svg?event=push
1013
+ [build]: https://github.com/aglushkov/serega/actions/workflows/main.yml
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.36.0
1
+ 0.37.0
@@ -33,10 +33,6 @@ class Serega
33
33
  # @return [Hash, nil] Attribute :preloads option
34
34
  attr_reader :preloads
35
35
 
36
- # Attribute :preloads_path option
37
- # @return [Array, nil] Attribute :preloads_path option
38
- attr_reader :preloads_path
39
-
40
36
  # Batch loader names required to detect attribute value
41
37
  # @return [Array<Symbol>] Batch loader names
42
38
  attr_reader :batch_loaders
@@ -143,7 +139,6 @@ class Serega
143
139
  @hide = normalizer.hide
144
140
  @serializer = normalizer.serializer
145
141
  @preloads = normalizer.preloads
146
- @preloads_path = normalizer.preloads_path
147
142
  @batch_loaders = normalizer.batch_loaders
148
143
  end
149
144
  end
@@ -9,6 +9,10 @@ class Serega
9
9
  # AttributeNormalizer instance methods
10
10
  #
11
11
  module AttributeNormalizerInstanceMethods
12
+ # Identity loader that routes relation/preload attributes through the batch
13
+ # mechanism. Its result is never read — the attribute's value block still
14
+ # computes the value.
15
+ AUTO_BATCH_LOADER = proc { |records| records.to_h { |record| [record, record] } }
12
16
  # Attribute initial params
13
17
  # @return [Hash] Attribute initial params
14
18
  attr_reader :init_name, :init_opts, :init_block
@@ -119,17 +123,6 @@ class Serega
119
123
  @preloads = prepare_preloads
120
124
  end
121
125
 
122
- #
123
- # Shows normalized preloads_path for current attribute
124
- #
125
- # @return [Array] normalized preloads_path of current attribute
126
- #
127
- def preloads_path
128
- return @preloads_path if instance_variable_defined?(:@preloads_path)
129
-
130
- @preloads_path = prepare_preloads_path
131
- end
132
-
133
126
  # Shows specified batch loaders names
134
127
  # @return [Array<Symbol>] specified serializer
135
128
  #
@@ -158,14 +151,8 @@ class Serega
158
151
  return hide if (hide == true) || (hide == false)
159
152
 
160
153
  hide_setting = config.hide_by_default
161
-
162
- case hide_setting
163
- when true
164
- return true
165
- when Array
166
- return true if hide_setting.include?(:preload) && preloads # hide when attribute has `:preload` option
167
- return true if hide_setting.include?(:batch) && batch_loaders.any? # hide when attribute has `:batch` option
168
- end
154
+ return true if hide_setting == true
155
+ return true if hide_setting == :auto && (preloads || init_opts.key?(:batch))
169
156
 
170
157
  # Return nil for undefined value which means "not hide" but allows
171
158
  # to change this value by plugins
@@ -207,10 +194,16 @@ class Serega
207
194
 
208
195
  def prepare_batch_loaders
209
196
  batch_opt = init_opts[:batch]
197
+ return explicit_batch_loaders(batch_opt) if batch_opt
198
+ return FROZEN_EMPTY_ARRAY unless serializer || preloads
210
199
 
211
- if batch_opt.nil?
212
- []
213
- elsif batch_opt == true || batch_opt.respond_to?(:call)
200
+ loader_name = :"__auto_batch_#{name}__"
201
+ self.class.serializer_class.batch(loader_name, AUTO_BATCH_LOADER)
202
+ [loader_name]
203
+ end
204
+
205
+ def explicit_batch_loaders(batch_opt)
206
+ if batch_opt == true || batch_opt.respond_to?(:call)
214
207
  [name]
215
208
  elsif batch_opt.is_a?(Symbol)
216
209
  [batch_opt]
@@ -237,64 +230,29 @@ class Serega
237
230
  AttributeValueResolvers::DelegateResolver.get(delegate_to, key_method_name, allow_nil)
238
231
  end
239
232
 
240
- # Prepares preloads
241
- # @return [Hash,nil]
242
- # - Nil means no need to add preloads and nested preloads
243
- # - Hash with any keys will add preloads
244
- # - Empty hash will skip only top-level preloads, but will allow to load nested preloads
233
+ # Prepares preloads for this attribute.
234
+ #
235
+ # The value is passed through as provided (Symbol, Array, Hash, or any
236
+ # custom value an ORM understands) it is the data handed to the
237
+ # serializer's `preload_with` handler.
238
+ #
239
+ # @return [Object, nil] preloads as provided, or nil when the attribute has none
245
240
  def prepare_preloads
246
- preload = init_opts[:preload]
241
+ # Explicit preload option (false or nil disables preloading)
242
+ return init_opts[:preload] || nil if init_opts.key?(:preload)
247
243
 
248
- # Handle explicit preload option
249
- if init_opts.key?(:preload)
250
- return nil unless preload # return nil when false or nil
251
- return SeregaUtils::FormatUserPreloads.call(preload)
252
- end
253
-
254
- # Auto-preload for delegate
244
+ # Auto-preload the delegated association
255
245
  if config.auto_preload.fetch(:has_delegate_option) && init_opts[:delegate]
256
- delegate_to = init_opts[:delegate][:to]
257
- return SeregaUtils::FormatUserPreloads.call(delegate_to)
246
+ return init_opts[:delegate][:to]
258
247
  end
259
248
 
260
- # Auto-preload for serializer
249
+ # Auto-preload the nested serializer's association
261
250
  if config.auto_preload.fetch(:has_serializer_option) && init_opts[:serializer] && !init_opts.key?(:batch)
262
- return SeregaUtils::FormatUserPreloads.call(method_name)
251
+ return method_name
263
252
  end
264
253
 
265
254
  nil
266
255
  end
267
-
268
- def prepare_preloads_path
269
- path = init_opts.fetch(:preload_path) { default_preload_path(preloads) }
270
-
271
- if path && path[0].is_a?(Array)
272
- prepare_many_preload_paths(path)
273
- else
274
- prepare_one_preload_path(path)
275
- end
276
- end
277
-
278
- def prepare_one_preload_path(path)
279
- return unless path
280
-
281
- case path
282
- when Array
283
- path.map(&:to_sym).freeze
284
- else
285
- [path.to_sym].freeze
286
- end
287
- end
288
-
289
- def prepare_many_preload_paths(paths)
290
- paths.map { |path| prepare_one_preload_path(path) }.freeze
291
- end
292
-
293
- def default_preload_path(preloads)
294
- return FROZEN_EMPTY_ARRAY if !preloads || preloads.empty?
295
-
296
- [preloads.keys.first]
297
- end
298
256
  end
299
257
 
300
258
  extend Serega::SeregaHelpers::SerializerClassHelper
@@ -10,9 +10,10 @@ class Serega
10
10
  # Instance methods for AttributeLoader
11
11
  module InstanceMethods
12
12
  # @param point [SeregaPlanPoint]
13
- def initialize(point)
13
+ # @param level [SeregaBatch::Level] Shared objects and results for this point's plan level
14
+ def initialize(point, level)
14
15
  @point = point
15
- @objects = []
16
+ @level = level
16
17
  @serialized_object_attachers = []
17
18
  end
18
19
 
@@ -21,35 +22,51 @@ class Serega
21
22
  # @param attacher [Proc] Proc that attaches found values to originated attributes
22
23
  # @return [void]
23
24
  def store(object, attacher)
24
- objects << object
25
25
  serialized_object_attachers << [object, attacher]
26
26
  end
27
27
 
28
- # Loads serialized values for all stored objects for current attribute
29
- # @param context [Hash] Current serialization context
28
+ # Preloads this attribute's associations onto the level's objects, once for
29
+ # the attribute (not per named batch loader), via the serializer's registered
30
+ # `preload_with` handler. Raises when `:preload` is declared but no handler
31
+ # exists, so a declared preload never silently does nothing.
30
32
  # @return [void]
31
- def load_all(context)
32
- batches = {}
33
- serializer_class = point.class.serializer_class
33
+ def preload_associations
34
+ preloads = point.attribute.preloads
35
+ return unless preloads
34
36
 
35
- point.batch_loaders.each do |batch_loader_name|
36
- batches[batch_loader_name] ||= load_one(serializer_class, batch_loader_name, context)
37
+ handler = point.class.serializer_class.preload_with
38
+ unless handler
39
+ raise SeregaError, "The :preload option requires a preload handler. Register one with `preload_with` (the :activerecord_preloads plugin does this for you)."
37
40
  end
38
41
 
42
+ handler.call(level.objects, preloads)
43
+ rescue => error
44
+ reraise_with_serialized_attribute_details(error)
45
+ end
46
+
47
+ # Loads a single named batch for this level's objects. Caching across
48
+ # attributes that reuse the loader is handled by the Level.
49
+ # @param loader [SeregaBatch::Loader] Named batch loader
50
+ # @return [Object] Loaded batch values
51
+ def load_batch(loader)
52
+ level.load(loader)
53
+ rescue => error
54
+ reraise_with_serialized_attribute_details(error)
55
+ end
56
+
57
+ # Attaches already loaded batch values to every stored object.
58
+ # @param batches [Hash] Loaded values grouped by batch loader name
59
+ # @return [void]
60
+ def attach(batches)
39
61
  serialized_object_attachers.each do |object, attacher|
40
62
  attacher.call(object, batches)
41
63
  end
42
64
  end
43
65
 
44
- private
66
+ attr_reader :point
67
+ attr_reader :level
45
68
 
46
- # Patched in:
47
- # - plugin :activerecord_preloads (preloads associations to found AR objects)
48
- def load_one(serializer_class, batch_loader_name, context)
49
- serializer_class.batch_loaders[batch_loader_name].load(objects, context)
50
- rescue => error
51
- reraise_with_serialized_attribute_details(error)
52
- end
69
+ private
53
70
 
54
71
  def reraise_with_serialized_attribute_details(error)
55
72
  raise error.exception(<<~MESSAGE.strip)
@@ -58,8 +75,6 @@ class Serega
58
75
  MESSAGE
59
76
  end
60
77
 
61
- attr_reader :point
62
- attr_reader :objects
63
78
  attr_reader :serialized_object_attachers
64
79
  end
65
80
 
@@ -13,9 +13,24 @@ class Serega
13
13
  #
14
14
  # Initializes new batch loaders
15
15
  #
16
- def initialize
17
- @point_batch_loaders = []
16
+ # @param context [Hash] Serialization context, constant for the whole run
17
+ #
18
+ def initialize(context)
19
+ @context = context
20
+ @attribute_loaders = []
18
21
  @point_index = {}.compare_by_identity
22
+ @levels = {}.compare_by_identity
23
+ end
24
+
25
+ # Gathers a chunk of objects serialized at a plan level, so every attribute at
26
+ # that level shares one set of objects to batch load against.
27
+ #
28
+ # @param plan [SeregaPlan] Plan serializing these objects
29
+ # @param objects [Array] Objects being serialized at this level
30
+ #
31
+ # @return [void]
32
+ def add_objects(plan, objects)
33
+ level_for(plan).add_objects(objects)
19
34
  end
20
35
 
21
36
  # Remembers data for batch serialization:
@@ -26,34 +41,68 @@ class Serega
26
41
  #
27
42
  # @return [void]
28
43
  def remember(point, object, attacher)
29
- point_batch_loader = point_index[point]
44
+ attribute_loader = point_index[point]
30
45
 
31
- unless point_batch_loader
46
+ unless attribute_loader
32
47
  serializer_class = point.class.serializer_class
33
- point_batch_loader = serializer_class::SeregaBatchAttributeLoader.new(point)
34
- point_batch_loaders << point_batch_loader
35
- point_index[point] = point_batch_loader
48
+ attribute_loader = serializer_class::SeregaBatchAttributeLoader.new(point, level_for(point.plan))
49
+ attribute_loaders << attribute_loader
50
+ point_index[point] = attribute_loader
36
51
  end
37
52
 
38
- point_batch_loader.store(object, attacher)
53
+ attribute_loader.store(object, attacher)
39
54
  end
40
55
 
41
56
  #
42
- # Loads all registered batches and removes them from registered list
57
+ # Loads all registered batches and attaches loaded values.
43
58
  #
44
- def load_all(context)
45
- point_batch_loaders.each do |point_batch_loader|
46
- point_batch_loader.load_all(context)
59
+ # Iterates over each loader including loaders added during iteration
60
+ # (child serializers discovered inside a batch flush add new loaders).
61
+ def load_all
62
+ i = 0
63
+ while i < attribute_loaders.size
64
+ attribute_loader = attribute_loaders[i]
65
+ attribute_loader.attach(load_batches(attribute_loader))
66
+ i += 1
47
67
  end
48
68
  end
49
69
 
50
70
  private
51
71
 
52
- # keeps all point_batch_loaders list
53
- attr_reader :point_batch_loaders
72
+ # Serialization context shared by every level
73
+ attr_reader :context
54
74
 
55
- # keeps tracking of already added serializers
75
+ # keeps all AttributeLoader instances
76
+ attr_reader :attribute_loaders
77
+
78
+ # keeps tracking of already added points
56
79
  attr_reader :point_index
80
+
81
+ # keeps one Level per plan (compare_by_identity: plan instance identifies a level)
82
+ attr_reader :levels
83
+
84
+ def level_for(plan)
85
+ levels[plan] ||= Level.new(context)
86
+ end
87
+
88
+ # Builds the { batch_loader_name => loaded_values } hash for one attribute.
89
+ #
90
+ # Loading is delegated to the attribute's Level, which loads each named batch
91
+ # once for the whole level: attributes sharing a loader over the same objects
92
+ # reuse a single result, deeper levels load separately.
93
+ def load_batches(attribute_loader)
94
+ attribute_loader.preload_associations
95
+
96
+ point = attribute_loader.point
97
+ serializer_class = point.class.serializer_class
98
+
99
+ batches = {}
100
+ point.batch_loaders.each do |batch_loader_name|
101
+ loader = serializer_class.batch_loaders[batch_loader_name]
102
+ batches[batch_loader_name] = attribute_loader.load_batch(loader)
103
+ end
104
+ batches
105
+ end
57
106
  end
58
107
  end
59
108
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Serega
4
+ #
5
+ # Batch feature main module
6
+ #
7
+ module SeregaBatch
8
+ #
9
+ # Objects serialized at one plan level, together with their loaded batch results.
10
+ #
11
+ # Every attribute serialized at the same level shares one Level, so a named batch
12
+ # loader reused by several of them runs once for the whole set of objects. The same
13
+ # loader over a different level (deeper nesting) uses a different Level and loads
14
+ # again.
15
+ #
16
+ class Level
17
+ # @return [Array] Objects gathered for this level
18
+ attr_reader :objects
19
+
20
+ # @param context [Hash] Serialization context
21
+ def initialize(context)
22
+ @context = context
23
+ @objects = []
24
+ @results = {}.compare_by_identity
25
+ end
26
+
27
+ # Adds a chunk of serialized objects to this level.
28
+ # @param objects [Array] Objects being serialized at this level
29
+ # @return [void]
30
+ def add_objects(objects)
31
+ @objects.concat(objects)
32
+ end
33
+
34
+ # Loads and returns values for the given batch loader, once per loader.
35
+ #
36
+ # Freezes the gathered objects on the first load: all of a level's objects
37
+ # are added before any of its batches load, so sealing the set here guards
38
+ # against a later stray `add_objects` and against a loader mutating it.
39
+ #
40
+ # @param loader [SeregaBatch::Loader] Named batch loader
41
+ # @return [Object] Loaded values
42
+ def load(loader)
43
+ @objects.freeze
44
+ @results[loader] ||= loader.load(@objects, @context)
45
+ end
46
+ end
47
+ end
48
+ end