serega 0.35.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.
@@ -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
data/lib/serega/config.rb CHANGED
@@ -23,7 +23,6 @@ class Serega
23
23
  delegate
24
24
  default
25
25
  preload
26
- preload_path
27
26
  batch
28
27
  ].freeze,
29
28
  serialize_keys: %i[context many].freeze,
@@ -116,27 +115,26 @@ class Serega
116
115
  end
117
116
 
118
117
  # Returns :hide_by_default config option
119
- # @return [Boolean, Array<Symbol>] Current :hide_by_default config option
118
+ # @return [Boolean, Symbol] Current :hide_by_default config option
120
119
  def hide_by_default
121
120
  opts.fetch(:hide_by_default)
122
121
  end
123
122
 
124
123
  # Sets :hide_by_default config option
125
124
  #
126
- # @param value [Boolean, Array<Symbol>] false, true, or array of :preload/:batch symbols
125
+ # @param value [Boolean, Symbol] Accepted values:
126
+ # - false (default) — nothing is hidden by default
127
+ # - true — all attributes are hidden by default
128
+ # - :auto — hides attributes that declare :preload or :batch
127
129
  #
128
- # @return [Boolean, Array<Symbol>] New :hide_by_default config option
130
+ # @return [Boolean, Symbol] New :hide_by_default config option
129
131
  def hide_by_default=(value)
130
132
  opts[:hide_by_default] =
131
133
  case value
132
- when true, false
133
- value
134
- when Array
135
- invalid = value - %i[preload batch]
136
- raise SeregaError, "Must have true, false, or an Array of [:preload, :batch], #{value.inspect} provided" if invalid.any?
134
+ when true, false, :auto
137
135
  value
138
136
  else
139
- raise SeregaError, "Must have true, false, or an Array of [:preload, :batch], #{value.inspect} provided"
137
+ raise SeregaError, "Must have true, false, or :auto, #{value.inspect} provided"
140
138
  end
141
139
  end
142
140
 
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Serega
4
+ #
5
+ # Builds Data objects from serialized hashes.
6
+ # Used by `#to_data` / `.to_data`.
7
+ #
8
+ class SeregaDataBuilder
9
+ # SeregaDataBuilder class methods
10
+ module SeregaDataBuilderClassMethods
11
+ #
12
+ # Converts serialized Hash/Array result into a tree of Ruby Data objects
13
+ # following the serializer's plan structure.
14
+ #
15
+ # @param serializer [Serega] Serializer instance carrying the plan
16
+ # @param serialized [Hash, Array, nil] Serialized output from `#to_h`
17
+ #
18
+ # @return [Data, Array<Data>, nil] Serialization result as Data object(s)
19
+ #
20
+ def call(serializer, serialized)
21
+ build(serialized, serializer.plan)
22
+ end
23
+
24
+ private
25
+
26
+ def build(serialized, plan)
27
+ case serialized
28
+ when Array then serialized.map { |item| hash_to_data(item, plan) }
29
+ when Hash then hash_to_data(serialized, plan)
30
+ else serialized
31
+ end
32
+ end
33
+
34
+ def hash_to_data(hash, plan)
35
+ hash_data = hash.to_h do |key, value|
36
+ child_plan = plan.points_hash.fetch(key).child_plan
37
+ value = build(value, child_plan) if child_plan
38
+ [key, value]
39
+ end
40
+
41
+ build_data_object(plan, hash_data)
42
+ end
43
+
44
+ def build_data_object(plan, hash_data)
45
+ plan.data_class.new(**hash_data)
46
+ end
47
+ end
48
+
49
+ extend SeregaHelpers::SerializerClassHelper
50
+ extend SeregaDataBuilderClassMethods
51
+ end
52
+ end
@@ -34,7 +34,13 @@ class Serega
34
34
  def serialize(object)
35
35
  return if object.nil?
36
36
 
37
- array?(object, many) ? serialize_array(object) : serialize_object(object)
37
+ if array?(object, many)
38
+ batch_loaders.add_objects(plan, object.to_a) if plan.batch?
39
+ serialize_array(object)
40
+ else
41
+ batch_loaders.add_objects(plan, [object]) if plan.batch?
42
+ serialize_object(object)
43
+ end
38
44
  end
39
45
 
40
46
  private
data/lib/serega/plan.rb CHANGED
@@ -27,6 +27,18 @@ class Serega
27
27
  cached_plan_for(opts, max_cache_size)
28
28
  end
29
29
 
30
+ #
31
+ # Returns (and caches) the Data class for the given set of field names.
32
+ # Uses the Array as cache key so the same Data class is reused across
33
+ # all plan instances with identical fields.
34
+ #
35
+ # @param point_names [Array<Symbol>] Attribute names for the Data members
36
+ # @return [Class] Subclass of Data
37
+ #
38
+ def data_class_for(point_names)
39
+ (@data_classes ||= {})[point_names] ||= Data.define(*point_names)
40
+ end
41
+
30
42
  private
31
43
 
32
44
  def cached_plan_for(opts, max_cache_size)
@@ -65,9 +77,9 @@ class Serega
65
77
  # @return [Array<SeregaPlanPoint>] points to serialize
66
78
  attr_reader :points
67
79
 
68
- # Shows if plan includes batch points
69
- # @return [Array<SeregaPlanPoint>] points to serialize
70
- attr_reader :has_batch_points
80
+ # Named serialization points
81
+ # @return [Hash] Named points
82
+ attr_reader :points_hash
71
83
 
72
84
  #
73
85
  # Instantiate new serialization plan
@@ -84,9 +96,9 @@ class Serega
84
96
  # @return [SeregaPlan] Serialization plan
85
97
  #
86
98
  def initialize(parent_plan_point, modifiers)
87
- @has_batch_points = false # should be before assigning points, generated points can change this attribute
88
99
  @parent_plan_point = parent_plan_point
89
100
  @points = attributes_points(modifiers)
101
+ @points_hash = points.to_h { |point| [point.name, point] }
90
102
  end
91
103
 
92
104
  #
@@ -97,14 +109,21 @@ class Serega
97
109
  end
98
110
 
99
111
  #
100
- # Marks current plan and top-level plans as `has_batch_points`
101
- # It is needed to initialize Batch Attribute Loaders when serialization starts
112
+ # @return [Boolean] whether any point of this plan is batch loaded
102
113
  #
103
- def mark_as_has_batch_points
104
- return if has_batch_points
114
+ def batch?
115
+ return @batch if defined?(@batch)
105
116
 
106
- @has_batch_points = true
107
- parent_plan_point.plan.mark_as_has_batch_points if parent_plan_point # rubocop:disable Style/SafeNavigation
117
+ @batch = points.any?(&:batch?)
118
+ end
119
+
120
+ # Returns the Data class whose members match this plan's serialized fields.
121
+ # Delegates to the class-level cache so identical field sets share one Data class.
122
+ #
123
+ # @return [Class] Subclass of Data
124
+ #
125
+ def data_class
126
+ @data_class ||= self.class.data_class_for(point_names)
108
127
  end
109
128
 
110
129
  private
@@ -130,6 +149,10 @@ class Serega
130
149
 
131
150
  points.freeze
132
151
  end
152
+
153
+ def point_names
154
+ @point_names ||= points.map(&:name)
155
+ end
133
156
  end
134
157
 
135
158
  extend ClassMethods
@@ -25,14 +25,6 @@ class Serega
25
25
  # @return [Hash] Attributes to serialize
26
26
  attr_reader :modifiers
27
27
 
28
- # Shows preloads for nested attributes
29
- # @return [Hash] preloads for nested attributes
30
- attr_reader :preloads
31
-
32
- # Shows preloads_path for current attribute
33
- # @return [Array<Symbol>, nil] preloads path for current attribute
34
- attr_reader :preloads_path
35
-
36
28
  #
37
29
  # Initializes plan point
38
30
  #
@@ -97,9 +89,6 @@ class Serega
97
89
 
98
90
  def set_normalized_vars
99
91
  @child_plan = prepare_child_plan
100
- @preloads = prepare_preloads
101
- @preloads_path = prepare_preloads_path
102
- plan.mark_as_has_batch_points if batch?
103
92
  end
104
93
 
105
94
  def prepare_child_plan
@@ -109,14 +98,6 @@ class Serega
109
98
 
110
99
  serializer::SeregaPlan.new(self, fields)
111
100
  end
112
-
113
- def prepare_preloads
114
- SeregaUtils::PreloadsConstructor.call(child_plan)
115
- end
116
-
117
- def prepare_preloads_path
118
- attribute.preloads_path
119
- end
120
101
  end
121
102
 
122
103
  extend SeregaHelpers::SerializerClassHelper
@@ -7,9 +7,8 @@ class Serega
7
7
  #
8
8
  # Automatically preloads associations to serialized objects
9
9
  #
10
- # It takes all defined preloads from serialized attributes (including attributes from serialized relations),
11
- # merges them into single associations hash and then uses ActiveRecord::Associations::Preloader
12
- # to preload all associations.
10
+ # Every association declared with `:preload` is loaded once during serialization using
11
+ # ActiveRecord::Associations::Preloader, so there are no N+1 queries.
13
12
  #
14
13
  # @example
15
14
  # class AppSerializer < Serega
@@ -39,7 +38,7 @@ class Serega
39
38
  # attribute :downloads_count, preload: :downloads, value: proc { |album| album.downloads.count }
40
39
  # end
41
40
  #
42
- # UserSerializer.to_h(user) # => preloads {users_stats: {}, albums: { downloads: {} }}
41
+ # UserSerializer.to_h(user)
43
42
  #
44
43
  module ActiverecordPreloads
45
44
  #
@@ -72,62 +71,18 @@ class Serega
72
71
  #
73
72
  def self.load_plugin(serializer_class, **_opts)
74
73
  require_relative "lib/preloader"
75
- require_relative "lib/active_record_objects"
76
-
77
- serializer_class.include(InstanceMethods)
78
- serializer_class::SeregaBatchAttributeLoader.include(BatchAttributeLoaderInstanceMethods)
79
74
  end
80
75
 
81
76
  #
82
- # Overrides SeregaBatch::AttributeLoader class instance methods
77
+ # Registers the ActiveRecord preload handler
83
78
  #
84
- module BatchAttributeLoaderInstanceMethods
85
- private
86
-
87
- # Preloads associations to batch-generated data
88
- def load_one(serializer_class, batch_loader_name, context)
89
- batch_loaded_data = super
90
-
91
- preloads = point.preloads
92
- return batch_loaded_data if preloads.empty?
93
-
94
- ar_objects = ActiveRecordObjects.call(batch_loaded_data)
95
- Preloader.preload(ar_objects, preloads)
96
-
97
- batch_loaded_data
98
- end
99
- end
100
-
79
+ # @param serializer_class [Class<Serega>] Current serializer class
80
+ # @param _opts [Hash] Plugin options
101
81
  #
102
- # Overrides Serega class instance methods
82
+ # @return [void]
103
83
  #
104
- module InstanceMethods
105
- #
106
- # Preloads associations to object
107
- #
108
- # @param object [Object] Any object
109
- # @return provided object
110
- #
111
- def preload_associations_to(object)
112
- return object if object.nil? || (object.is_a?(Array) && object.empty?)
113
-
114
- preloads = preloads() # `preloads()` method comes from :preloads plugin
115
- return object if preloads.empty?
116
-
117
- Preloader.preload(object, preloads)
118
- object
119
- end
120
-
121
- private
122
-
123
- #
124
- # Override original #serialize method
125
- # Preloads associations to object before serialization
126
- #
127
- def serialize(object, _opts)
128
- preload_associations_to(object)
129
- super
130
- end
84
+ def self.after_load_plugin(serializer_class, **_opts)
85
+ serializer_class.preload_with { |objects, preloads| Preloader.preload(objects, preloads) }
131
86
  end
132
87
  end
133
88
 
@@ -14,7 +14,7 @@ class Serega
14
14
  # @return [Object] provided object with preloaded associations
15
15
  #
16
16
  def preload(object, preloads)
17
- return object if object.nil? || (object.is_a?(Array) && object.empty?) || preloads.empty?
17
+ return object if object.nil? || (object.is_a?(Array) && object.empty?)
18
18
 
19
19
  preload_handler = handlers.find { |handler| handler.fit?(object) }
20
20
  raise SeregaError, "Can't preload #{preloads.inspect} to #{object.inspect}" unless preload_handler
@@ -69,6 +69,7 @@ class Serega
69
69
  serializer_class::SeregaPlanPoint.include(PlanPointInstanceMethods)
70
70
  serializer_class::CheckAttributeParams.include(CheckAttributeParamsInstanceMethods)
71
71
  serializer_class::SeregaObjectSerializer.include(ObjectSerializerInstanceMethods)
72
+ serializer_class::SeregaDataBuilder.extend(DataBuilderClassMethods)
72
73
  end
73
74
 
74
75
  #
@@ -215,6 +216,7 @@ class Serega
215
216
  when "1" then condition.call(object)
216
217
  when "2" then condition.call(object, context)
217
218
  when "1_ctx" then condition.call(object, ctx: context)
219
+ when "2_ctx" then condition.call(object, context, ctx: context)
218
220
  else # "0"
219
221
  condition.call
220
222
  end
@@ -239,6 +241,27 @@ class Serega
239
241
  end
240
242
  end
241
243
 
244
+ #
245
+ # SeregaDataBuilder additional/patched class methods
246
+ #
247
+ # Overrides `build_data_object` to handle plans where some keys were skipped
248
+ # by `:if`/`:unless`/`:if_value`/`:unless_value` conditions, so the Data class
249
+ # is built from the actually-present keys rather than the full plan.
250
+ #
251
+ # @see Serega::SeregaDataBuilder
252
+ #
253
+ module DataBuilderClassMethods
254
+ private
255
+
256
+ def build_data_object(plan, hash_data)
257
+ if hash_data.size < plan.points.size
258
+ plan.class.data_class_for(hash_data.keys).new(**hash_data)
259
+ else
260
+ super
261
+ end
262
+ end
263
+ end
264
+
242
265
  #
243
266
  # SeregaObjectSerializer additional/patched class methods
244
267
  #
@@ -47,6 +47,8 @@ class Serega
47
47
  true
48
48
  when "1_ctx" # (object, :ctx)
49
49
  true
50
+ when "2_ctx" # (object, context, :ctx)
51
+ true
50
52
  else
51
53
  false
52
54
  end
@@ -55,10 +57,11 @@ class Serega
55
57
  def signature_error
56
58
  <<~ERROR.strip
57
59
  Invalid attribute option :if parameters, valid parameters signatures:
58
- - () # no parameters
59
- - (object) # one positional parameter
60
- - (object, context) # two positional parameters
61
- - (object, :ctx) # one positional parameter and :ctx keyword
60
+ - () # no parameters
61
+ - (object) # one positional parameter
62
+ - (object, context) # two positional parameters
63
+ - (object, :ctx) # one positional parameter and :ctx keyword
64
+ - (object, context, :ctx) # two positional parameters and :ctx keyword
62
65
  ERROR
63
66
  end
64
67
  end
@@ -52,6 +52,8 @@ class Serega
52
52
  true
53
53
  when "1_ctx" # (value, :ctx)
54
54
  true
55
+ when "2_ctx" # (value, context, :ctx)
56
+ true
55
57
  else
56
58
  false
57
59
  end
@@ -60,10 +62,11 @@ class Serega
60
62
  def signature_error
61
63
  <<~ERROR.strip
62
64
  Invalid attribute option :if_value parameters, valid parameters signatures:
63
- - () # no parameters
64
- - (value) # one positional parameter
65
- - (value, context) # two positional parameters
66
- - (value, :ctx) # one positional parameter and :ctx keyword
65
+ - () # no parameters
66
+ - (value) # one positional parameter
67
+ - (value, context) # two positional parameters
68
+ - (value, :ctx) # one positional parameter and :ctx keyword
69
+ - (value, context, :ctx) # two positional parameters and :ctx keyword
67
70
  ERROR
68
71
  end
69
72
  end
@@ -47,6 +47,8 @@ class Serega
47
47
  true
48
48
  when "1_ctx" # (object, :ctx)
49
49
  true
50
+ when "2_ctx" # (object, context, :ctx)
51
+ true
50
52
  else
51
53
  false
52
54
  end
@@ -55,10 +57,11 @@ class Serega
55
57
  def signature_error
56
58
  <<~ERROR.strip
57
59
  Invalid attribute option :unless parameters, valid parameters signatures:
58
- - () # no parameters
59
- - (object) # one positional parameter
60
- - (object, context) # two positional parameters
61
- - (object, :ctx) # one positional parameter and :ctx keyword
60
+ - () # no parameters
61
+ - (object) # one positional parameter
62
+ - (object, context) # two positional parameters
63
+ - (object, :ctx) # one positional parameter and :ctx keyword
64
+ - (object, context, :ctx) # two positional parameters and :ctx keyword
62
65
  ERROR
63
66
  end
64
67
  end
@@ -52,6 +52,8 @@ class Serega
52
52
  true
53
53
  when "1_ctx" # (value, :ctx)
54
54
  true
55
+ when "2_ctx" # (value, context, :ctx)
56
+ true
55
57
  else
56
58
  false
57
59
  end
@@ -60,10 +62,11 @@ class Serega
60
62
  def signature_error
61
63
  <<~ERROR.strip
62
64
  Invalid attribute option :unless_value parameters, valid parameters signatures:
63
- - () # no parameters
64
- - (value) # one positional parameter
65
- - (value, context) # two positional parameters
66
- - (value, :ctx) # one positional parameter and :ctx keyword
65
+ - () # no parameters
66
+ - (value) # one positional parameter
67
+ - (value, context) # two positional parameters
68
+ - (value, :ctx) # one positional parameter and :ctx keyword
69
+ - (value, context, :ctx) # two positional parameters and :ctx keyword
67
70
  ERROR
68
71
  end
69
72
  end
@@ -92,6 +92,7 @@ class Serega
92
92
  serializer_class.extend(ClassMethods)
93
93
  serializer_class.include(InstanceMethods)
94
94
  serializer_class::SeregaConfig.include(ConfigInstanceMethods)
95
+ serializer_class::SeregaDataBuilder.extend(DataBuilderClassMethods)
95
96
  end
96
97
 
97
98
  #
@@ -214,6 +215,22 @@ class Serega
214
215
  # @see Serega
215
216
  #
216
217
  module InstanceMethods
218
+ #
219
+ # Serializes provided object to a tree of Ruby Data objects.
220
+ # When a root key is configured, the result is wrapped in an outer Data object
221
+ # whose members include the root key plus any metadata keys.
222
+ #
223
+ # @param object [Object] Serialized object
224
+ # @param opts [Hash, nil] Serializing options (`:root`, `:many`, `:context`, etc.)
225
+ #
226
+ # @return [Data, Array<Data>, nil] Serialization result as Data object(s)
227
+ #
228
+ def to_data(object, opts = nil)
229
+ opts = prepare_initial_serialization_opts(object, opts)
230
+ serialized_data = serialize(object, opts)
231
+ self.class::SeregaDataBuilder.call(self, serialized_data, opts)
232
+ end
233
+
217
234
  private
218
235
 
219
236
  def serialize(object, opts)
@@ -230,6 +247,69 @@ class Serega
230
247
  (opts.fetch(:many) { object.is_a?(Enumerable) }) ? root.many : root.one
231
248
  end
232
249
  end
250
+
251
+ #
252
+ # SeregaDataBuilder additional/patched class methods
253
+ #
254
+ # Overrides `call` to handle the root key: the serialized result is
255
+ # unwrapped from the root key, converted to a Data tree by the base
256
+ # implementation, and then re-wrapped together with any metadata keys
257
+ # in an outer Data object. Metadata values (arbitrary hashes/arrays)
258
+ # are recursively converted to Data objects via `deep_hash_to_data`.
259
+ #
260
+ # When the root key is `nil` (disabled), delegates directly to the base.
261
+ #
262
+ # @see Serega::SeregaDataBuilder
263
+ #
264
+ module DataBuilderClassMethods
265
+ #
266
+ # @param serializer [Serega] Serializer instance carrying the plan
267
+ # @param serialized_result [Hash, Array, nil] Full serialized output (possibly wrapped under root key)
268
+ # @param serialization_opts [Hash] Serialization options used to determine the root key
269
+ #
270
+ # @return [Data, Array<Data>, nil] Serialization result as Data object(s)
271
+ #
272
+ def call(serializer, serialized_result, serialization_opts)
273
+ root_key = resolve_root_key(serializer, serialization_opts)
274
+ return super(serializer, serialized_result) unless root_key
275
+
276
+ root_data = super(serializer, serialized_result.fetch(root_key))
277
+ build_data_objects(serialized_result, root_key, root_data)
278
+ end
279
+
280
+ private
281
+
282
+ def resolve_root_key(serializer, serialization_opts)
283
+ if serialization_opts.key?(:root)
284
+ serialization_opts[:root]
285
+ else
286
+ config_root = serializer.class.config.root
287
+ serialization_opts[:many] ? config_root.many : config_root.one
288
+ end
289
+ end
290
+
291
+ def build_data_objects(serialized_result, root_key, root_data)
292
+ converted_hash =
293
+ serialized_result.to_h do |key, val|
294
+ value = (key == root_key) ? root_data : deep_hash_to_data(val)
295
+ [key, value]
296
+ end
297
+
298
+ Data.define(*converted_hash.keys).new(**converted_hash)
299
+ end
300
+
301
+ def deep_hash_to_data(value)
302
+ case value
303
+ when Hash
304
+ converted = value.transform_values { |nested| deep_hash_to_data(nested) }
305
+ Data.define(*value.keys).new(**converted)
306
+ when Array
307
+ value.map { |item| deep_hash_to_data(item) }
308
+ else
309
+ value
310
+ end
311
+ end
312
+ end
233
313
  end
234
314
 
235
315
  register_plugin(Root.plugin_name, Root)
@@ -19,6 +19,17 @@ class Serega
19
19
  def call(opts)
20
20
  return unless opts.key?(:preload)
21
21
 
22
+ check_opt_preload(opts)
23
+ check_usage_with_other_params(opts)
24
+ end
25
+
26
+ private
27
+
28
+ def check_opt_preload(opts)
29
+ raise SeregaError, "Option :preload value can not be `true`" if opts[:preload] == true
30
+ end
31
+
32
+ def check_usage_with_other_params(opts)
22
33
  raise SeregaError, "Option :preload can not be used together with option :const" if opts.key?(:const)
23
34
  end
24
35
  end
@@ -65,7 +65,6 @@ class Serega
65
65
  Attribute::CheckOptMethod.call(opts, block)
66
66
  Attribute::CheckOptMany.call(opts)
67
67
  Attribute::CheckOptPreload.call(opts)
68
- Attribute::CheckOptPreloadPath.call(opts)
69
68
  Attribute::CheckOptSerializer.call(opts)
70
69
  Attribute::CheckOptValue.call(opts, block)
71
70
  Attribute::CheckOptBatch.call(self.class.serializer_class, opts, block)