serega 0.40.1 → 0.42.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: edecae0c35540be687e394092e3b0a158fa1c2aeff4710ff5ce8e17effba1eb2
4
- data.tar.gz: 7e63d238aad501f31be388824bb6b594e9f7988e17c49e4fc478d9353993c63b
3
+ metadata.gz: 4ec97ed9e6fc97612c5094991de19b1c5d43a44168e5a29593bdf75f77e5f200
4
+ data.tar.gz: 54d42defad67c2ea46bac516870caacb36ce38643826137900b46a8661001cc3
5
5
  SHA512:
6
- metadata.gz: 506607362308145ffcc0a34474d76d8b0a0edb7fbb570deb1fb973c71c396aab65a1b7b408069d31c8a5e15f8c8146316f835194c53e4a68315fe62eb4992421
7
- data.tar.gz: 6d5b5ebe921a36b197b4829721af89eb92f5e47522101d126cffc8c72e30ad2679a6f9084df2685cf2250d772c5b0974b76861260b2eb19c6f53660fba16f5d4
6
+ metadata.gz: 5c9f5c91fc97ae0b08578b6fb7c98c3718194094af81fb6175cd3854f602b8daa450204cd75b054cd9ee62586aed08f120b32d209a4817fb5bfdb74c6b83bd55
7
+ data.tar.gz: bd05febbd13a095a4e00f0336514c540c0781eac2707e29e8d9c64e6b4bc0152a7b717e05b93bfdca969e01b1fed4eeeeb52a330b3e9c7a820986b0b30e5b9e0
data/README.md CHANGED
@@ -19,7 +19,8 @@ It has some great features:
19
19
  - Secure from malicious queries with [depth_limit][depth_limit] plugin
20
20
  - Solutions for N+1 problem (via built-in [batch loading](#batch-loading), [preloads][preloads] or
21
21
  [activerecord_preloads][activerecord_preloads] plugin)
22
- - Built-in object presenter ([presenter][presenter] plugin)
22
+ - Load serialized objects by ids ([prepare_initial_objects](#prepare-initial-objects))
23
+ - Built-in object presenter ([presenter][presenter])
23
24
  - Adding custom metadata (via [metadata][metadata] or
24
25
  [context_metadata][context_metadata] plugins)
25
26
  - Value formatters ([formatters][formatters] plugin) helps to transform
@@ -29,10 +30,51 @@ It has some great features:
29
30
  - Auto camelCase keys - [camel_case][camel_case] plugin
30
31
  - Serializing Hash records - [hash_access][hash_access] attribute option
31
32
 
33
+ ## Table of Contents
34
+
35
+ <!-- toc -->
36
+
37
+ - [Installation](#installation)
38
+ - [Usage](#usage)
39
+ - [Define serializers](#define-serializers)
40
+ - [Adding attributes](#adding-attributes)
41
+ - [Defining a nested serializer with a block](#defining-a-nested-serializer-with-a-block)
42
+ - [Serializing](#serializing)
43
+ - [Selecting Fields](#selecting-fields)
44
+ - [Using Context](#using-context)
45
+ - [Batch Loading](#batch-loading)
46
+ - [Prepare Initial Objects](#prepare-initial-objects)
47
+ - [Configuration](#configuration)
48
+ - [Preloads](#preloads)
49
+ - [Serializing the same object in association](#serializing-the-same-object-in-association)
50
+ - [Custom preloading](#custom-preloading)
51
+ - [Serializing Hash records](#serializing-hash-records)
52
+ - [Presenter](#presenter)
53
+ - [Plugins](#plugins)
54
+ - [Plugin :activerecord_preloads](#plugin-activerecord_preloads)
55
+ - [Plugin :root](#plugin-root)
56
+ - [Plugin :metadata](#plugin-metadata)
57
+ - [Plugin :context_metadata](#plugin-context_metadata)
58
+ - [Plugin :formatters](#plugin-formatters)
59
+ - [Plugin :string_modifiers](#plugin-string_modifiers)
60
+ - [Plugin :if](#plugin-if)
61
+ - [Plugin :camel_case](#plugin-camel_case)
62
+ - [Plugin :depth_limit](#plugin-depth_limit)
63
+ - [Plugin :explicit_many_option](#plugin-explicit_many_option)
64
+ - [Errors](#errors)
65
+ - [Release](#release)
66
+ - [Development](#development)
67
+ - [Contributing](#contributing)
68
+ - [License](#license)
69
+
70
+ <!-- tocstop -->
71
+
32
72
  ## Installation
33
73
 
34
74
  `bundle add serega`
35
75
 
76
+ ## Usage
77
+
36
78
  ### Define serializers
37
79
 
38
80
  Most apps should define **base serializer** with common plugins and settings to
@@ -86,8 +128,8 @@ class UserSerializer < Serega
86
128
  config.base_serializer = Serega
87
129
 
88
130
  # Method :itself is handy to serialize the same object with a nested set of
89
- # attributes. Note: with the :presenter plugin the serialized value will be
90
- # the Presenter instance itself; use `method: :__getobj__` to serialize the
131
+ # attributes. Note: with a custom presenter the serialized value will be
132
+ # the presenter instance itself; use `method: :__getobj__` to serialize the
91
133
  # original unwrapped object instead.
92
134
  attribute :statistics, method: :itself do
93
135
  attribute :likes_count
@@ -463,6 +505,77 @@ class UserSerializer < Serega
463
505
  end
464
506
  ```
465
507
 
508
+ ### Prepare Initial Objects
509
+
510
+ `prepare_initial_objects` replaces the serialized objects before serialization
511
+ starts, so a serializer can accept ids or other references and load the records
512
+ itself.
513
+
514
+ ```ruby
515
+ class UserSerializer < Serega
516
+ # OccamsRecord returns read-only records — faster and with a much lower
517
+ # memory footprint than ActiveRecord models
518
+ prepare_initial_objects { |user_ids| OccamsRecord.query(User.where(id: user_ids)).run }
519
+
520
+ attribute :first_name
521
+ end
522
+
523
+ UserSerializer.to_h(["17", "42"]) # => [{first_name: "Ann"}, {first_name: "Bob"}]
524
+ ```
525
+
526
+ The handler accepts the serialization context as a second positional or a
527
+ `:ctx` keyword argument, and can be provided as a callable value:
528
+
529
+ ```ruby
530
+ class UserSerializer < Serega
531
+ prepare_initial_objects { |ids, ctx| OccamsRecord.query(User.where(id: ids, account: ctx[:account])).run }
532
+ # or
533
+ prepare_initial_objects { |ids, ctx:| OccamsRecord.query(User.where(id: ids, account: ctx[:account])).run }
534
+ # or
535
+ prepare_initial_objects UsersLoader
536
+ end
537
+ ```
538
+
539
+ The handler runs once per serialization, before the `:many` option is detected,
540
+ so it can turn a single object into a collection and back:
541
+
542
+ ```ruby
543
+ class UserSerializer < Serega
544
+ prepare_initial_objects { |user_id| OccamsRecord.query(User.where(id: user_id)).run }
545
+
546
+ attribute :first_name
547
+ end
548
+
549
+ UserSerializer.to_h("17") # => [{first_name: "Ann"}] - an array, as the handler returned a collection
550
+ ```
551
+
552
+ A provided `:many` option is used as is. A handler returning `nil` serializes
553
+ `nil`, and errors raised inside the handler are not wrapped.
554
+
555
+ Declared preloads are applied to the prepared objects, so the handler pairs
556
+ with [preloads][preloads] and the
557
+ [activerecord_preloads][activerecord_preloads] plugin:
558
+
559
+ ```ruby
560
+ class UserSerializer < Serega
561
+ plugin :activerecord_preloads
562
+
563
+ prepare_initial_objects { |user_ids| User.where(id: user_ids) }
564
+
565
+ attribute :albums_count, preload: :albums, value: proc { |user| user.albums.size }
566
+ end
567
+ ```
568
+
569
+ ⚠️ The `:preload` option needs ActiveRecord objects. When the handler returns
570
+ anything else — OccamsRecord read-only records, Structs, Hashes, plain objects
571
+ — preloading raises `Serega::SeregaError` (`Can't preload ... to ...`) during
572
+ serialization. Load that data with [batch loading](#batch-loading) instead,
573
+ which works with any objects.
574
+
575
+ The handler runs for the serialized objects only, and not for objects of nested
576
+ serializers. It is inherited by subclasses, so declare it on concrete
577
+ serializers rather than on a base serializer shared by all of them.
578
+
466
579
  ## Configuration
467
580
 
468
581
  Here are the default options. Other options can be added with plugins.
@@ -592,7 +705,7 @@ end
592
705
 
593
706
  ---
594
707
 
595
- ### SPECIFIC CASE: Serializing the same object in association
708
+ ### Serializing the same object in association
596
709
 
597
710
  For example, you show your current user as "user" and use the same user object
598
711
  to serialize "user_stats". `UserStatSerializer` relies on user fields and any
@@ -736,6 +849,62 @@ attribute :city,
736
849
  }
737
850
  ```
738
851
 
852
+ ## Presenter
853
+
854
+ Moves computed attribute logic out of `:value` blocks and into a
855
+ `presenter do ... end` block, keeping serializers readable as schemas rather
856
+ than bags of lambdas.
857
+
858
+ Computed attributes can be written as inline blocks:
859
+
860
+ ```ruby
861
+ class UserSerializer < Serega
862
+ attribute :name, value: proc { |user| [user.first_name, user.last_name].compact.join(' ') }
863
+ attribute :role, value: proc { |user, ctx| user.id == ctx[:current_user_id] ? :self : :other }
864
+ end
865
+ ```
866
+
867
+ They can also be written as methods in a `presenter do ... end` block:
868
+
869
+ ```ruby
870
+ class UserSerializer < Serega
871
+ attribute :name
872
+ attribute :role
873
+
874
+ presenter do
875
+ def name
876
+ [first_name, last_name].compact.join(' ')
877
+ end
878
+
879
+ def role
880
+ id == __ctx__[:current_user_id] ? :self : :other
881
+ end
882
+ end
883
+ end
884
+
885
+ user = OpenStruct.new(id: 1, first_name: 'Bruce', last_name: 'Wayne')
886
+ UserSerializer.to_h(user, context: {current_user_id: 1})
887
+ # => {name: "Bruce Wayne", role: :self}
888
+ ```
889
+
890
+ Multiple `presenter` blocks accumulate. A child serializer runs its parent's
891
+ blocks followed by its own, so it gets the parent's presenter methods while its
892
+ own stay out of the parent. The parent's blocks are copied when the child class
893
+ is created, so a `presenter` block added to a parent afterwards does not reach it.
894
+
895
+ Presenter methods run on a `SimpleDelegator` wrapping the serialized object, so
896
+ every method of that object is available directly. Any method not defined in a
897
+ `presenter` block is delegated to the object on the first call, and a real
898
+ delegator method is defined for it — so all subsequent serializations call it
899
+ directly.
900
+
901
+ The wrapped object is accessible via `__getobj__` when you need an unambiguous
902
+ reference to it. The serialization context is accessible via `__ctx__`.
903
+
904
+ Objects are wrapped only when the serializer has presenter methods. A serializer
905
+ without them costs nothing — its attribute values, batch loaders and value
906
+ callables receive the raw objects.
907
+
739
908
  ## Plugins
740
909
 
741
910
  ### Plugin :activerecord_preloads
@@ -936,61 +1105,6 @@ class UserSerializer < Serega
936
1105
  end
937
1106
  ```
938
1107
 
939
- ### Plugin :presenter
940
-
941
- Moves computed attribute logic out of blocks and into a dedicated `Presenter` class,
942
- keeping serializers readable as schemas rather than bags of lambdas.
943
-
944
- Without the plugin, computed attributes live as inline blocks:
945
-
946
- ```ruby
947
- class UserSerializer < Serega
948
- attribute :name, value: proc { |u| [u.first_name, u.last_name].compact.join(' ') }
949
- attribute :role, value: proc { |u, ctx| u == ctx[:current_user] ? :self : :other }
950
- end
951
- ```
952
-
953
- With the plugin, they move into a `presenter do ... end` block:
954
-
955
- ```ruby
956
- class UserSerializer < Serega
957
- plugin :presenter
958
-
959
- attribute :name
960
- attribute :role
961
-
962
- presenter do
963
- def name
964
- [first_name, last_name].compact.join(' ')
965
- end
966
-
967
- def role
968
- id == __ctx__[:current_user_id] ? :self : :other
969
- end
970
- end
971
- end
972
- ```
973
-
974
- The block is evaluated inside the serializer's own `Presenter` class, so
975
- multiple `presenter` blocks accumulate.
976
-
977
- `Presenter` inherits from `SimpleDelegator`, so every method of the serialized
978
- object is available directly inside presenter methods. Any method not explicitly
979
- defined on `Presenter` is resolved via `method_missing` on the first call, which
980
- also defines a real delegator method — so all subsequent serializations call it
981
- directly, without going through `method_missing` again.
982
-
983
- The original wrapped object is accessible via `__getobj__` (standard
984
- `SimpleDelegator` API) when you need an unambiguous reference to it.
985
-
986
- The serialization context is accessible via the private method `__ctx__`.
987
-
988
- Objects are wrapped in the `Presenter` only when the serializer's `Presenter`
989
- class (or an inherited one) actually defines custom methods. Loading the
990
- plugin in a base serializer adds no overhead to serializers that don't
991
- customize their presenters — their attribute values, batch loaders and value
992
- callables keep receiving the raw objects.
993
-
994
1108
  ### Plugin :string_modifiers
995
1109
 
996
1110
  Allows `:only`, `:except` and `:with` to be given as a single comma-separated
@@ -1185,7 +1299,7 @@ The gem is available as open source under the terms of the [MIT License](https:/
1185
1299
  [hash_access]: #serializing-hash-records
1186
1300
  [metadata]: #plugin-metadata
1187
1301
  [preloads]: #preloads
1188
- [presenter]: #plugin-presenter
1302
+ [presenter]: #presenter
1189
1303
  [root]: #plugin-root
1190
1304
  [string_modifiers]: #plugin-string_modifiers
1191
1305
  [if]: #plugin-if
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.40.1
1
+ 0.42.0
data/lib/serega/config.rb CHANGED
@@ -33,7 +33,7 @@ class Serega
33
33
  delegate_default_allow_nil: false,
34
34
  max_cached_plans_per_serializer_count: 0,
35
35
  auto_preload: {has_delegate_option: false, has_serializer_option: false},
36
- auto_preload_excluded_methods: %i[itself].freeze,
36
+ auto_preload_excluded_methods: %i[itself __getobj__].freeze,
37
37
  hide_by_default: false,
38
38
  batch_id_option: :id,
39
39
  base_serializer: nil,
@@ -16,7 +16,7 @@ class Serega
16
16
  #
17
17
  # @private
18
18
  module InstanceMethods
19
- attr_reader :context, :plan, :many, :opts, :level_queue
19
+ attr_reader :context, :plan, :many, :opts, :level_queue, :presenter
20
20
 
21
21
  # @param plan [SeregaPlan] Serialization plan
22
22
  # @param context [Hash] Serialization context
@@ -30,6 +30,8 @@ class Serega
30
30
  @many = many
31
31
  @opts = opts
32
32
  @level_queue = opts[:level_queue]
33
+ # Looked up once here and reused for every enqueued chunk of the level.
34
+ @presenter = self.class.serializer_class.presenter
33
35
  end
34
36
 
35
37
  # Enqueues this level and returns its result container(s). The containers are
@@ -78,9 +80,14 @@ class Serega
78
80
  # containers. This is where objects enter their level, so every object a
79
81
  # point resolves against and a batch loader receives has the same shape.
80
82
  #
81
- # Patched in:
82
- # - plugin :presenter (wraps each object in a Presenter before enqueueing)
83
+ # Each object is wrapped in the serializer's presenter before it is enqueued,
84
+ # so the whole level value resolution and batch loaders alike — sees
85
+ # presenters. Serializers without a presenter enqueue the objects as they
86
+ # are — wrapping would only add overhead and break class checks
87
+ # (object.is_a?, Hash === object) without changing anything.
83
88
  def enqueue(objects)
89
+ objects = objects.map { |object| presenter.new(object, context) } if presenter
90
+
84
91
  level_queue.enqueue(self, objects)
85
92
  end
86
93
 
@@ -85,12 +85,18 @@ class Serega
85
85
  # Runs this point's declared preloads over the given objects using the
86
86
  # serializer's registered preload handler.
87
87
  #
88
+ # Presenters are unwrapped first, as preload handlers work with the
89
+ # serialized objects themselves.
90
+ #
88
91
  # @param objects [Array] objects serialized at this point's level
89
92
  # @return [void]
90
93
  def run_preloads(objects)
91
94
  return unless preloads
92
95
 
93
- handler = self.class.serializer_class.preload_with
96
+ serializer_class = self.class.serializer_class
97
+ objects = objects.map(&:__getobj__) if serializer_class.presenter
98
+
99
+ handler = serializer_class.preload_with
94
100
  unless handler
95
101
  raise SeregaError, "The :preload option requires a preload handler. Register one with `preload_with` (the :activerecord_preloads plugin does this for you)."
96
102
  end
@@ -88,28 +88,9 @@ class Serega
88
88
  # @private
89
89
  def self.after_load_plugin(serializer_class, **_opts)
90
90
  serializer_class.preload_with do |objects, preloads|
91
- Preloader.preload(ActiverecordPreloads.records(serializer_class, objects), preloads)
91
+ Preloader.preload(objects, preloads)
92
92
  end
93
93
  end
94
-
95
- #
96
- # The underlying records to preload onto. The :presenter plugin wraps every
97
- # serialized object in a SimpleDelegator, but ActiveRecord's Preloader needs
98
- # the real records, so unwrap them via #__getobj__ when presenter is used.
99
- # Objects are wrapped only when the Presenter class has custom methods.
100
- #
101
- # @param serializer_class [Class<Serega>] Current serializer class
102
- # @param objects [Array] objects serialized at the current level
103
- #
104
- # @return [Array] the underlying records
105
- #
106
- # @private
107
- def self.records(serializer_class, objects)
108
- return objects unless serializer_class.plugin_used?(:presenter)
109
- return objects unless serializer_class.custom_presenter?
110
-
111
- objects.map(&:__getobj__)
112
- end
113
94
  end
114
95
 
115
96
  register_plugin(ActiverecordPreloads.plugin_name, ActiverecordPreloads)
@@ -229,6 +229,8 @@ class Serega
229
229
  # @return [Data, Array<Data>, nil] Serialization result as Data object(s)
230
230
  #
231
231
  def to_data(object, opts = nil)
232
+ opts = normalize_serialization_opts(opts)
233
+ object = prepare_objects(object, opts[:context])
232
234
  opts = prepare_initial_serialization_opts(object, opts)
233
235
  serialized_data = serialize(object, opts)
234
236
  self.class::SeregaDataBuilder.call(self, serialized_data, opts)
@@ -30,10 +30,10 @@ class Serega
30
30
  # @raise [SeregaError] Raises SeregaError when plugin was not found
31
31
  #
32
32
  # @example Find plugin when providing name
33
- # SeregaPlugins.find_plugin(:presenter) # => SeregaPlugins::Presenter
33
+ # SeregaPlugins.find_plugin(:root) # => SeregaPlugins::Root
34
34
  #
35
35
  # @example Find plugin when providing plugin itself
36
- # SeregaPlugins.find_plugin(Presenter) # => Presenter
36
+ # SeregaPlugins.find_plugin(Root) # => Root
37
37
  #
38
38
  # @return [Class<Module>] Plugin core module
39
39
  #
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+ require "forwardable"
5
+
6
+ class Serega
7
+ #
8
+ # Wraps the serialized object, so computed attribute values can be defined as
9
+ # methods instead of `:value` callables.
10
+ #
11
+ # SeregaPresenter inherits from SimpleDelegator:
12
+ # - All methods of the serialized object are available directly inside presenter methods.
13
+ # - Methods not defined on SeregaPresenter are resolved via method_missing on the first call
14
+ # and then defined as real delegators, so subsequent calls skip method_missing entirely.
15
+ # - The original object is accessible via __getobj__ (standard SimpleDelegator API).
16
+ # - The serialization context is accessible via the private method __ctx__.
17
+ #
18
+ # The `presenter do ... end` block is evaluated inside the serializer's own
19
+ # SeregaPresenter class, so multiple blocks accumulate.
20
+ #
21
+ # @example
22
+ # class UserSerializer < Serega
23
+ # attribute :name
24
+ # attribute :role
25
+ #
26
+ # presenter do
27
+ # def name
28
+ # [first_name, last_name].compact.join(' ') # first_name/last_name delegated to object
29
+ # end
30
+ #
31
+ # def role
32
+ # id == __ctx__[:current_user_id] ? :self : :other
33
+ # end
34
+ # end
35
+ # end
36
+ #
37
+ # @private
38
+ class SeregaPresenter < SimpleDelegator
39
+ #
40
+ # @param object [Object] Serialized object to wrap
41
+ # @param ctx [Hash, nil] Serialization context
42
+ #
43
+ def initialize(object, ctx = nil)
44
+ super(object)
45
+ @__ctx__ = ctx
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :__ctx__
51
+
52
+ #
53
+ # Delegates all missing methods to serialized object.
54
+ #
55
+ # Creates delegator method after first #method_missing hit to improve
56
+ # performance of following serializations.
57
+ #
58
+ def method_missing(name, *_args, &_block) # rubocop:disable Style/MissingRespondToMissing -- base SimpleDelegator class has this method
59
+ super.tap do
60
+ self.class.def_delegator :__getobj__, name
61
+ end
62
+ end
63
+
64
+ extend Forwardable
65
+ end
66
+ end
data/lib/serega.rb CHANGED
@@ -62,6 +62,7 @@ require_relative "serega/validations/check_batch_loader_params"
62
62
  require_relative "serega/validations/check_serialize_params"
63
63
 
64
64
  require_relative "serega/config"
65
+ require_relative "serega/presenter"
65
66
  require_relative "serega/object_serializer"
66
67
  require_relative "serega/plan_point"
67
68
  require_relative "serega/plan"
@@ -70,6 +71,7 @@ require_relative "serega/plugins"
70
71
 
71
72
  class Serega
72
73
  @config = SeregaConfig.new
74
+ @presenter_class = nil
73
75
 
74
76
  # Validates `Serializer.attribute` params
75
77
  check_attribute_params_class = Class.new(SeregaValidations::CheckAttributeParams)
@@ -170,10 +172,17 @@ class Serega
170
172
  end
171
173
 
172
174
  #
173
- # Adds attribute
175
+ # Lists blocks given to `presenter`, in definition order
174
176
  #
175
- # Patched in:
176
- # - plugin :presenter (additionally adds method in Presenter class)
177
+ # @return [Array<Proc>] presenter blocks list
178
+ #
179
+ # @private
180
+ def presenter_blocks
181
+ @presenter_blocks ||= []
182
+ end
183
+
184
+ #
185
+ # Adds attribute
177
186
  #
178
187
  # @param name [Symbol] Attribute name. Attribute value will be found by executing `object.<name>`
179
188
  # @param opts [Hash] Options to serialize attribute
@@ -233,6 +242,30 @@ class Serega
233
242
  batch_loaders[batch_loader.name] = batch_loader
234
243
  end
235
244
 
245
+ #
246
+ # Defines presenter methods — evaluates the block inside the serializer's own
247
+ # presenter class. Multiple blocks accumulate.
248
+ #
249
+ # presenter do
250
+ # def name
251
+ # [first_name, last_name].compact.join(" ")
252
+ # end
253
+ # end
254
+ #
255
+ # @param block [Proc] Presenter methods
256
+ #
257
+ # @return [Class<SeregaPresenter>, nil] the serializer presenter class,
258
+ # nil when the serializer has none
259
+ #
260
+ def presenter(&block)
261
+ return @presenter_class unless block
262
+
263
+ @presenter_class ||= Class.new(SeregaPresenter)
264
+ @presenter_class.class_exec(&block)
265
+ presenter_blocks << block
266
+ @presenter_class
267
+ end
268
+
236
269
  #
237
270
  # Registers (or returns) the handler used to preload an attribute's
238
271
  # associations onto the records gathered during serialization.
@@ -265,6 +298,63 @@ class Serega
265
298
  @preload_with = handler
266
299
  end
267
300
 
301
+ #
302
+ # Registers (or returns) the handler that replaces the serialized objects
303
+ # before serialization starts.
304
+ #
305
+ # The handler is called once per serialization with the objects provided to
306
+ # `.call`/`.to_h`/`.to_data` and the serialization context. Its result is
307
+ # serialized instead of the provided objects, which allows to accept ids or
308
+ # other references and load the records in one place.
309
+ #
310
+ # The handler runs before the `:many` option is detected, so it may turn a
311
+ # single object into a collection and back. A provided `:many` serialization
312
+ # option is still used as is.
313
+ #
314
+ # The handler runs only for the serialized objects, and not for objects of
315
+ # nested serializers.
316
+ #
317
+ # @example with a block
318
+ # prepare_initial_objects { |user_ids| User.where(id: user_ids) }
319
+ #
320
+ # @example with a context
321
+ # prepare_initial_objects { |user_ids, ctx| User.where(id: user_ids, account: ctx[:account]) }
322
+ #
323
+ # @example with a keyword context
324
+ # prepare_initial_objects { |user_ids, ctx:| User.where(id: user_ids, account: ctx[:account]) }
325
+ #
326
+ # @example with a callable value
327
+ # prepare_initial_objects UsersLoader
328
+ #
329
+ # @param value [#call, nil] Handler accepting (objects), (objects, context) or (objects, ctx:)
330
+ # @param block [Proc] Handler accepting (objects), (objects, context) or (objects, ctx:)
331
+ #
332
+ # @return [#call, nil] The registered handler
333
+ #
334
+ def prepare_initial_objects(value = nil, &block)
335
+ return @prepare_initial_objects if value.nil? && block.nil?
336
+ raise SeregaError, "prepare_initial_objects accepts a single callable or a block, not both" if value && block
337
+
338
+ handler = value || block
339
+ raise SeregaError, "prepare_initial_objects value must be a Proc or respond to #call" if !handler.is_a?(Proc) && !handler.respond_to?(:call)
340
+
341
+ signature = SeregaUtils::MethodSignature.call(handler, pos_limit: 2, keyword_args: [:ctx])
342
+ raise SeregaError, prepare_initial_objects_signature_error unless %w[1 2 1_ctx].include?(signature)
343
+
344
+ @prepare_initial_objects_signature = signature
345
+ @prepare_initial_objects = handler
346
+ end
347
+
348
+ #
349
+ # Signature of the registered prepare_initial_objects handler
350
+ #
351
+ # @return [String, nil] Handler signature
352
+ #
353
+ # @private
354
+ def prepare_initial_objects_signature
355
+ @prepare_initial_objects_signature
356
+ end
357
+
268
358
  #
269
359
  # Serializes provided object to Hash
270
360
  #
@@ -321,7 +411,6 @@ class Serega
321
411
 
322
412
  # Patched in:
323
413
  # - plugin :metadata (defines MetaAttribute and copies meta_attributes to subclasses)
324
- # - plugin :presenter (defines Presenter)
325
414
  def inherited(subclass)
326
415
  config_class = Class.new(self::SeregaConfig)
327
416
  config_class.serializer_class = subclass
@@ -386,8 +475,25 @@ class Serega
386
475
  # Assign same preload handler
387
476
  subclass.preload_with(preload_with) if preload_with
388
477
 
478
+ # Assign same initial objects handler
479
+ subclass.prepare_initial_objects(prepare_initial_objects) if prepare_initial_objects
480
+
481
+ # Assign same presenter blocks
482
+ presenter_blocks.each do |presenter_block|
483
+ subclass.presenter(&presenter_block)
484
+ end
485
+
389
486
  super
390
487
  end
488
+
489
+ def prepare_initial_objects_signature_error
490
+ <<~ERR.strip
491
+ prepare_initial_objects handler arguments should have one of this signatures:
492
+ - (objects) # one argument
493
+ - (objects, ctx) # two arguments
494
+ - (objects, ctx:) # one argument and one :ctx keyword argument
495
+ ERR
496
+ end
391
497
  end
392
498
 
393
499
  #
@@ -436,6 +542,8 @@ class Serega
436
542
  # @return [Hash] Serialization result
437
543
  #
438
544
  def call(object, opts = nil)
545
+ opts = normalize_serialization_opts(opts)
546
+ object = prepare_objects(object, opts[:context])
439
547
  opts = prepare_initial_serialization_opts(object, opts)
440
548
  serialize(object, opts)
441
549
  end
@@ -458,6 +566,8 @@ class Serega
458
566
  # @return [Data] Serialization result
459
567
  #
460
568
  def to_data(object, opts = nil)
569
+ opts = normalize_serialization_opts(opts)
570
+ object = prepare_objects(object, opts[:context])
461
571
  opts = prepare_initial_serialization_opts(object, opts)
462
572
  serialized_data = serialize(object, opts)
463
573
  self.class::SeregaDataBuilder.call(self, serialized_data)
@@ -488,11 +598,26 @@ class Serega
488
598
  SeregaUtils::ToHash.call(value)
489
599
  end
490
600
 
491
- def prepare_initial_serialization_opts(object, opts)
601
+ def normalize_serialization_opts(opts)
492
602
  opts = opts ? opts.transform_keys(&:to_sym) : {}
493
603
  self.class::CheckSerializeParams.new(opts).validate unless opts.empty?
494
604
 
495
605
  opts[:context] ||= {}
606
+ opts
607
+ end
608
+
609
+ def prepare_objects(objects, context)
610
+ handler = self.class.prepare_initial_objects
611
+ return objects unless handler
612
+
613
+ case self.class.prepare_initial_objects_signature
614
+ when "1" then handler.call(objects)
615
+ when "2" then handler.call(objects, context)
616
+ else handler.call(objects, ctx: context) # "1_ctx"
617
+ end
618
+ end
619
+
620
+ def prepare_initial_serialization_opts(object, opts)
496
621
  opts[:level_queue] = SeregaEngine::LevelQueue.new
497
622
  opts[:many] = SeregaUtils::CollectionDetector.call(object) unless opts.key?(:many)
498
623
  opts[:plan] = plan
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: serega
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.40.1
4
+ version: 0.42.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrey
@@ -64,10 +64,10 @@ files:
64
64
  - lib/serega/plugins/metadata/validations/check_opt_value.rb
65
65
  - lib/serega/plugins/metadata/validations/check_opts.rb
66
66
  - lib/serega/plugins/metadata/validations/check_path.rb
67
- - lib/serega/plugins/presenter/presenter.rb
68
67
  - lib/serega/plugins/root/root.rb
69
68
  - lib/serega/plugins/string_modifiers/parse_string_modifiers.rb
70
69
  - lib/serega/plugins/string_modifiers/string_modifiers.rb
70
+ - lib/serega/presenter.rb
71
71
  - lib/serega/utils/collection_detector.rb
72
72
  - lib/serega/utils/enum_deep_dup.rb
73
73
  - lib/serega/utils/enum_deep_freeze.rb
@@ -1,242 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "delegate"
4
- require "forwardable"
5
-
6
- class Serega
7
- module SeregaPlugins
8
- #
9
- # Plugin :presenter — moves computed attribute logic into a dedicated Presenter class.
10
- #
11
- # Presenter inherits from SimpleDelegator:
12
- # - All methods of the serialized object are available directly inside presenter methods.
13
- # - Methods not defined on Presenter are resolved via method_missing on the first call
14
- # and then defined as real delegators, so subsequent calls skip method_missing entirely.
15
- # - The original object is accessible via __getobj__ (standard SimpleDelegator API).
16
- # - The serialization context is accessible via the private method __ctx__.
17
- #
18
- # The `presenter do ... end` block is evaluated inside the serializer's own
19
- # Presenter class, so multiple blocks accumulate.
20
- #
21
- # @example
22
- # class UserSerializer < Serega
23
- # plugin :presenter
24
- #
25
- # attribute :name
26
- # attribute :role
27
- #
28
- # presenter do
29
- # def name
30
- # [first_name, last_name].compact.join(' ') # first_name/last_name delegated to object
31
- # end
32
- #
33
- # def role
34
- # id == __ctx__[:current_user_id] ? :self : :other
35
- # end
36
- # end
37
- # end
38
- #
39
- module Presenter
40
- # @return [Symbol] Plugin name
41
- # @private
42
- def self.plugin_name
43
- :presenter
44
- end
45
-
46
- #
47
- # Applies plugin code to specific serializer
48
- #
49
- # @param serializer_class [Class<Serega>] Current serializer class
50
- # @param _opts [Hash] Plugin options
51
- #
52
- # @return [void]
53
- #
54
- # @private
55
- def self.load_plugin(serializer_class, **_opts)
56
- serializer_class.extend(ClassMethods)
57
- serializer_class::SeregaObjectSerializer.include(SeregaObjectSerializerInstanceMethods)
58
- end
59
-
60
- #
61
- # Runs callbacks after plugin was attached
62
- #
63
- # @param serializer_class [Class<Serega>] Current serializer class
64
- # @param _opts [Hash] Plugin options
65
- #
66
- # @return [void]
67
- #
68
- # @private
69
- def self.after_load_plugin(serializer_class, **_opts)
70
- presenter_class = Class.new(Presenter)
71
- presenter_class.serializer_class = serializer_class
72
- serializer_class.const_set(:Presenter, presenter_class)
73
-
74
- # The presenter's unwrap method returns the serialized object itself,
75
- # not an association — it must never be auto-preloaded.
76
- config = serializer_class.config
77
- config.auto_preload_excluded_methods = config.auto_preload_excluded_methods | [:__getobj__]
78
- end
79
-
80
- # Presenter class
81
- # @private
82
- class Presenter < SimpleDelegator
83
- # Presenter instance methods
84
- # @private
85
- module InstanceMethods
86
- #
87
- # @param object [Object] Serialized object to wrap
88
- # @param ctx [Hash, nil] Serialization context
89
- #
90
- def initialize(object, ctx = nil)
91
- super(object)
92
- @__ctx__ = ctx
93
- end
94
-
95
- private
96
-
97
- attr_reader :__ctx__
98
-
99
- #
100
- # Delegates all missing methods to serialized object.
101
- #
102
- # Creates delegator method after first #method_missing hit to improve
103
- # performance of following serializations.
104
- #
105
- def method_missing(name, *_args, &_block) # rubocop:disable Style/MissingRespondToMissing -- base SimpleDelegator class has this method
106
- super.tap do
107
- self.class.def_delegator :__getobj__, name
108
- end
109
- end
110
- end
111
-
112
- extend SeregaHelpers::SerializerClassHelper
113
- extend Forwardable
114
- include InstanceMethods
115
-
116
- # Tracks whether user code was added to the Presenter class.
117
- #
118
- # These singleton methods are defined after the base class body above,
119
- # so the plugin's own includes do not mark the base class as modified.
120
- # Lazy delegators defined by #method_missing do mark the class, but
121
- # they can appear only on presenters that are already wrapping.
122
- class << self
123
- #
124
- # Checks if this Presenter class (or an inherited one) was extended
125
- # with custom user code and therefore objects must be wrapped
126
- #
127
- # @return [Boolean] whether custom presenter methods were defined
128
- #
129
- def modified?
130
- return true if defined?(@modified)
131
- return false if equal?(Presenter) # the plugin's base class — the walk stops here
132
-
133
- superclass.modified?
134
- end
135
-
136
- # Marks the class as modified, then includes the module
137
- # @return [void]
138
- def include(*modules)
139
- @modified = true
140
- super
141
- end
142
-
143
- # Marks the class as modified, then prepends the module
144
- # @return [void]
145
- def prepend(*modules)
146
- @modified = true
147
- super
148
- end
149
-
150
- private
151
-
152
- def method_added(name)
153
- @modified = true
154
- super
155
- end
156
- end
157
- end
158
-
159
- #
160
- # Serega additional/patched class methods
161
- #
162
- # @see Serega
163
- #
164
- module ClassMethods
165
- #
166
- # Defines presenter methods — evaluates the block inside the
167
- # serializer's own Presenter class. Multiple blocks accumulate.
168
- #
169
- # presenter do
170
- # def name
171
- # [first_name, last_name].compact.join(" ")
172
- # end
173
- # end
174
- #
175
- # @return [void]
176
- #
177
- def presenter(&block)
178
- raise SeregaError, "Provide a block with presenter methods: `presenter do ... end`" unless block
179
-
180
- self::Presenter.class_exec(&block)
181
- nil
182
- end
183
-
184
- #
185
- # Checks if the serializer's Presenter class (or an inherited one) was
186
- # extended with custom user code. When it was not, serialized objects
187
- # are not wrapped in the Presenter at all.
188
- #
189
- # @return [Boolean] whether custom presenter methods were defined
190
- #
191
- # @private
192
- def custom_presenter?
193
- self::Presenter.modified?
194
- end
195
-
196
- private
197
-
198
- def inherited(subclass)
199
- super
200
-
201
- presenter_class = Class.new(self::Presenter)
202
- presenter_class.serializer_class = subclass
203
- subclass.const_set(:Presenter, presenter_class)
204
- end
205
- end
206
-
207
- #
208
- # SeregaObjectSerializer additional/patched class methods
209
- #
210
- # @see Serega::SeregaObjectSerializer
211
- #
212
- # @private
213
- module SeregaObjectSerializerInstanceMethods
214
- # The custom-presenter check is made once per object serializer here
215
- # and its result is reused for every enqueued chunk of the level.
216
- def initialize(**opts)
217
- super
218
- @wrap_in_presenter = self.class.serializer_class.custom_presenter?
219
- end
220
-
221
- private
222
-
223
- #
224
- # Wraps each serialized object in Presenter.new(object, ctx) before it is
225
- # enqueued, so the whole level — value resolution and batch loaders alike —
226
- # sees presenters. Objects are not wrapped when the Presenter class has
227
- # no custom methods — such wrapping would only add overhead and break
228
- # class checks (object.is_a?, Hash === object) without changing anything.
229
- #
230
- def enqueue(objects)
231
- return super unless @wrap_in_presenter
232
-
233
- presenter = self.class.serializer_class::Presenter
234
- presenters = objects.map { |object| presenter.new(object, context) }
235
- super(presenters)
236
- end
237
- end
238
- end
239
-
240
- register_plugin(Presenter.plugin_name, Presenter)
241
- end
242
- end