serega 0.41.0 → 0.50.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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +123 -1205
  3. data/VERSION +1 -1
  4. data/lib/serega/attribute.rb +186 -131
  5. data/lib/serega/attribute_normalizer.rb +164 -345
  6. data/lib/serega/attribute_normalizers/batch.rb +45 -0
  7. data/lib/serega/attribute_normalizers/block.rb +81 -0
  8. data/lib/serega/attribute_normalizers/delegate.rb +34 -0
  9. data/lib/serega/attribute_normalizers/hash_access.rb +37 -0
  10. data/lib/serega/attribute_normalizers/preloads.rb +46 -0
  11. data/lib/serega/attribute_value_resolvers/batch.rb +5 -54
  12. data/lib/serega/attribute_value_resolvers/const.rb +2 -24
  13. data/lib/serega/attribute_value_resolvers/delegate.rb +17 -28
  14. data/lib/serega/attribute_value_resolvers/hash_access.rb +34 -52
  15. data/lib/serega/attribute_value_resolvers/keyword.rb +10 -25
  16. data/lib/serega/attribute_values.rb +46 -0
  17. data/lib/serega/batch_loader.rb +37 -0
  18. data/lib/serega/conditions.rb +56 -0
  19. data/lib/serega/config.rb +106 -145
  20. data/lib/serega/engine/pulls.rb +64 -0
  21. data/lib/serega/engine/run.rb +106 -0
  22. data/lib/serega/errors.rb +36 -8
  23. data/lib/serega/formatter.rb +98 -0
  24. data/lib/serega/plan/cache.rb +96 -0
  25. data/lib/serega/plan/per_ractor_cache.rb +23 -0
  26. data/lib/serega/plan.rb +101 -128
  27. data/lib/serega/plan_point.rb +44 -145
  28. data/lib/serega/plugins/activerecord_preloads/activerecord_preloads.rb +14 -100
  29. data/lib/serega/plugins/activerecord_preloads/lib/preloader.rb +8 -75
  30. data/lib/serega/plugins/context_metadata/context_metadata.rb +40 -123
  31. data/lib/serega/plugins/metadata/check_params.rb +80 -0
  32. data/lib/serega/plugins/metadata/meta_attribute.rb +40 -79
  33. data/lib/serega/plugins/metadata/metadata.rb +40 -168
  34. data/lib/serega/plugins/root/root.rb +91 -235
  35. data/lib/serega/plugins.rb +16 -49
  36. data/lib/serega/presenter.rb +108 -0
  37. data/lib/serega/result_builder.rb +68 -0
  38. data/lib/serega/result_code.rb +203 -0
  39. data/lib/serega/shareable.rb +52 -0
  40. data/lib/serega/source_group.rb +187 -0
  41. data/lib/serega/utils/collection_detector.rb +19 -13
  42. data/lib/serega/utils/deep_merge.rb +26 -0
  43. data/lib/serega/utils/enum_deep_dup.rb +10 -16
  44. data/lib/serega/utils/enum_deep_freeze.rb +8 -14
  45. data/lib/serega/utils/method_signature.rb +6 -15
  46. data/lib/serega/utils/parse_string_modifiers.rb +63 -0
  47. data/lib/serega/utils/ractor_local.rb +36 -0
  48. data/lib/serega/utils/serialized_attribute_error.rb +60 -20
  49. data/lib/serega/utils/to_hash.rb +5 -26
  50. data/lib/serega/validations/attribute/check_block.rb +3 -15
  51. data/lib/serega/validations/attribute/check_name.rb +4 -15
  52. data/lib/serega/validations/attribute/check_opt_base_serializer.rb +3 -15
  53. data/lib/serega/validations/attribute/check_opt_batch.rb +26 -56
  54. data/lib/serega/validations/attribute/check_opt_condition.rb +55 -0
  55. data/lib/serega/validations/attribute/check_opt_delegate.rb +10 -63
  56. data/lib/serega/validations/attribute/check_opt_format.rb +36 -0
  57. data/lib/serega/validations/attribute/check_opt_hash_access.rb +11 -59
  58. data/lib/serega/validations/attribute/check_opt_hide.rb +3 -14
  59. data/lib/serega/validations/attribute/check_opt_many.rb +18 -20
  60. data/lib/serega/validations/attribute/check_opt_method.rb +3 -25
  61. data/lib/serega/validations/attribute/check_opt_preload.rb +3 -26
  62. data/lib/serega/validations/attribute/check_opt_serializer.rb +4 -18
  63. data/lib/serega/validations/attribute/check_opt_value.rb +3 -21
  64. data/lib/serega/validations/check_attribute_params.rb +92 -71
  65. data/lib/serega/validations/check_batch_loader_params.rb +43 -62
  66. data/lib/serega/validations/check_formatter.rb +80 -0
  67. data/lib/serega/validations/check_serialize_keywords.rb +23 -0
  68. data/lib/serega/validations/initiate/check_modifiers.rb +14 -20
  69. data/lib/serega/validations/utils/check_allowed_keys.rb +3 -13
  70. data/lib/serega/validations/utils/check_hash_access_value.rb +44 -0
  71. data/lib/serega/validations/utils/check_opt_is_bool.rb +3 -13
  72. data/lib/serega/validations/utils/check_opt_is_hash.rb +3 -13
  73. data/lib/serega/validations/utils/check_opt_is_string_or_symbol.rb +3 -13
  74. data/lib/serega/version.rb +1 -4
  75. data/lib/serega.rb +480 -372
  76. data/sig/serega/plugins.rbs +49 -0
  77. data/sig/serega.rbs +142 -0
  78. metadata +43 -39
  79. data/lib/serega/data_builder.rb +0 -54
  80. data/lib/serega/engine/level.rb +0 -59
  81. data/lib/serega/engine/level_queue.rb +0 -56
  82. data/lib/serega/engine/loader.rb +0 -70
  83. data/lib/serega/helpers/serializer_class_helper.rb +0 -19
  84. data/lib/serega/object_serializer.rb +0 -118
  85. data/lib/serega/plugins/camel_case/camel_case.rb +0 -213
  86. data/lib/serega/plugins/depth_limit/depth_limit.rb +0 -206
  87. data/lib/serega/plugins/explicit_many_option/explicit_many_option.rb +0 -69
  88. data/lib/serega/plugins/explicit_many_option/validations/check_opt_many.rb +0 -38
  89. data/lib/serega/plugins/formatters/formatters.rb +0 -322
  90. data/lib/serega/plugins/if/if.rb +0 -287
  91. data/lib/serega/plugins/if/validations/check_opt_if.rb +0 -72
  92. data/lib/serega/plugins/if/validations/check_opt_if_value.rb +0 -77
  93. data/lib/serega/plugins/if/validations/check_opt_unless.rb +0 -72
  94. data/lib/serega/plugins/if/validations/check_opt_unless_value.rb +0 -77
  95. data/lib/serega/plugins/metadata/validations/check_block.rb +0 -47
  96. data/lib/serega/plugins/metadata/validations/check_opt_const.rb +0 -39
  97. data/lib/serega/plugins/metadata/validations/check_opt_hide_empty.rb +0 -35
  98. data/lib/serega/plugins/metadata/validations/check_opt_hide_nil.rb +0 -35
  99. data/lib/serega/plugins/metadata/validations/check_opt_value.rb +0 -61
  100. data/lib/serega/plugins/metadata/validations/check_opts.rb +0 -59
  101. data/lib/serega/plugins/metadata/validations/check_path.rb +0 -50
  102. data/lib/serega/plugins/presenter/presenter.rb +0 -242
  103. data/lib/serega/plugins/string_modifiers/parse_string_modifiers.rb +0 -111
  104. data/lib/serega/plugins/string_modifiers/string_modifiers.rb +0 -63
  105. data/lib/serega/utils/symbol_name.rb +0 -39
  106. data/lib/serega/validations/attribute/check_opt_const.rb +0 -40
  107. data/lib/serega/validations/check_initiate_params.rb +0 -57
  108. data/lib/serega/validations/check_serialize_params.rb +0 -54
@@ -1,58 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Serega
4
- # Plugins are stored here
4
+ # Built-in features, turned on with Serega::ClassMethods#plugin.
5
+ #
6
+ # See [Guides](https://github.com/andreyruby/serega/blob/master/docs/README.md).
5
7
  module SeregaPlugins
6
- @plugins = {}
8
+ # Plugin module names, by plugin name
9
+ MODULES = { # :nodoc:
10
+ root: :Root,
11
+ metadata: :Metadata,
12
+ context_metadata: :ContextMetadata,
13
+ activerecord_preloads: :ActiverecordPreloads
14
+ }.freeze
7
15
 
8
16
  class << self
9
- #
10
- # Registers given plugin to be able to load it using symbol name.
11
- #
12
- # @param name [Symbol] Plugin name
13
- # @param mod [Module] Plugin module
14
- #
15
- # @example Register plugin
16
- # SeregaPlugins.register_plugin(:plugin_name, PluginModule)
17
- #
18
- # @return [void]
19
- #
20
- # @private
21
- def register_plugin(name, mod)
22
- @plugins[name] = mod
23
- end
24
-
25
- #
26
- # Loads plugin code and returns plugin core module.
27
- #
28
- # @param name [Symbol, Module] plugin name or plugin itself
29
- #
30
- # @raise [SeregaError] Raises SeregaError when plugin was not found
31
- #
32
- # @example Find plugin when providing name
33
- # SeregaPlugins.find_plugin(:presenter) # => SeregaPlugins::Presenter
34
- #
35
- # @example Find plugin when providing plugin itself
36
- # SeregaPlugins.find_plugin(Presenter) # => Presenter
37
- #
38
- # @return [Class<Module>] Plugin core module
39
- #
40
- # @private
41
- def find_plugin(name)
42
- return name if name.is_a?(Module)
43
- return @plugins[name] if @plugins.key?(name)
44
-
45
- require_plugin(name)
46
-
47
- @plugins[name] || raise(SeregaError, "Plugin '#{name}' did not register itself correctly")
48
- end
49
-
50
- private
51
-
52
- def require_plugin(name)
53
- require "serega/plugins/#{name}/#{name}"
54
- rescue LoadError
55
- raise SeregaError, "Plugin '#{name}' does not exist"
17
+ # Loads the code of the plugin and returns its module. Raises
18
+ # SeregaError when the plugin does not exist.
19
+ def find(name) # :nodoc:
20
+ module_name = MODULES.fetch(name) { raise SeregaError, "Plugin #{name.inspect} does not exist" }
21
+ require_relative "plugins/#{name}/#{name}"
22
+ const_get(module_name)
56
23
  end
57
24
  end
58
25
  end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ class Serega
6
+ # Wraps the object to serialize, so computed attribute values can be defined as
7
+ # methods instead of `:value` callables.
8
+ #
9
+ # SeregaPresenter inherits from SimpleDelegator:
10
+ # - All methods of the wrapped object are available directly inside presenter methods.
11
+ # - Methods not defined on SeregaPresenter are resolved via method_missing on the first call
12
+ # and then defined as real delegators, so subsequent calls skip method_missing entirely.
13
+ # - The original object is accessible via __getobj__ (standard SimpleDelegator API).
14
+ # - The serialization context is accessible via the private method __ctx__.
15
+ #
16
+ # The `presenter do ... end` block is evaluated inside the serializer's own
17
+ # SeregaPresenter class, so multiple blocks accumulate.
18
+ #
19
+ # class UserSerializer < Serega
20
+ # attribute :name
21
+ # attribute :role
22
+ #
23
+ # presenter do
24
+ # def name
25
+ # [first_name, last_name].compact.join(' ') # first_name/last_name delegated to object
26
+ # end
27
+ #
28
+ # def role
29
+ # id == __ctx__[:current_user_id] ? :self : :other
30
+ # end
31
+ # end
32
+ # end
33
+ class SeregaPresenter < SimpleDelegator # :nodoc:
34
+ # Method names of delegators defined with a compiled `def`
35
+ PLAIN_METHOD_NAME = /\A[a-z_][a-zA-Z0-9_]*[?!]?\z/
36
+ private_constant :PLAIN_METHOD_NAME
37
+
38
+ #
39
+ # Delegator to the method of the wrapped object with the name it is
40
+ # called by. Delegators with other names are its aliases.
41
+ #
42
+ module CalleeDelegator # :nodoc:
43
+ #
44
+ # Calls the method of the wrapped object with the name of the alias
45
+ #
46
+ def __serega_delegate__(...)
47
+ __getobj__.public_send(__callee__, ...)
48
+ end
49
+ end
50
+
51
+ # Includes into each presenter class its own `Delegators` module.
52
+ # Delegators to the methods of the wrapped object are defined there after the
53
+ # first #method_missing hit. Presenter methods override them and can call
54
+ # them via `super`.
55
+ def self.inherited(subclass)
56
+ super
57
+ delegators = Module.new
58
+ delegators.include(CalleeDelegator)
59
+ subclass.const_set(:Delegators, delegators)
60
+ subclass.include(delegators)
61
+ end
62
+
63
+ # Defines a method that delegates to the wrapped object in the
64
+ # presenter class `Delegators` module
65
+ def self.define_delegator(name)
66
+ delegators = self::Delegators
67
+
68
+ if PLAIN_METHOD_NAME.match?(name)
69
+ delegators.module_eval(<<~RUBY, __FILE__, __LINE__ + 1)
70
+ def #{name}(...)
71
+ __getobj__.#{name}(...)
72
+ end
73
+ RUBY
74
+ else
75
+ delegators.alias_method(name, :__serega_delegate__)
76
+ end
77
+ end
78
+
79
+ # Defines delegators for the method names that the presenter class does
80
+ # not define itself, thus presenter methods stay in front of them
81
+ def self.define_delegators(names)
82
+ names.each do |name|
83
+ define_delegator(name) unless method_defined?(name) || private_method_defined?(name)
84
+ end
85
+ end
86
+
87
+ def initialize(object, ctx = nil)
88
+ super(object)
89
+ @__ctx__ = ctx
90
+ end
91
+
92
+ private
93
+
94
+ attr_reader :__ctx__
95
+
96
+ #
97
+ # Delegates all missing methods to the wrapped object.
98
+ #
99
+ # Creates delegator method for public methods of the wrapped object after
100
+ # first #method_missing hit to improve performance of following serializations.
101
+ #
102
+ def method_missing(name, ...) # rubocop:disable Style/MissingRespondToMissing -- base SimpleDelegator class has this method
103
+ result = super
104
+ self.class.define_delegator(name) if __getobj__.respond_to?(name)
105
+ result
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Serega
4
+ #
5
+ # Builds the serialized objects (Hash, Struct or Data) of one plan in one
6
+ # serialization mode.
7
+ #
8
+ # The builder gets the code of its `#call` and `#call_one` methods from
9
+ # SeregaResultCode. `#call` builds the serialized objects of a source group
10
+ # in one loop.
11
+ #
12
+ class SeregaResultBuilder # :nodoc:
13
+ # Serialization mode
14
+ attr_reader :mode
15
+
16
+ # Makes a result builder and generates its methods:
17
+ #
18
+ # call(sources, context, batches, relations) -> Array<Hash, Struct, Data>
19
+ #
20
+ # - `batches` holds the loaded batches per point, or nil.
21
+ # - `relations` holds the relation values of all sources per relation
22
+ # point, or nil.
23
+ #
24
+ # Points without relations and batch loaders get a `#call_one` method
25
+ # too, which builds the serialized object of one source:
26
+ #
27
+ # call_one(source, context) -> Hash, Struct, Data
28
+ def initialize(serializer_class, mode, points)
29
+ @mode = mode
30
+ @points = points
31
+ @result_class = result_class(serializer_class)
32
+ # The generated code reads the `:const`, `:default` and formatter values from its constants
33
+ singleton_class.const_set(:SeregaAttributeValues, serializer_class::SeregaAttributeValues)
34
+ singleton_class.class_eval(SeregaResultCode.new(mode, points).to_s, SeregaUtils::SerializedAttributeError::GENERATED_CODE_FILE, 1)
35
+ end
36
+
37
+ private
38
+
39
+ def result_class(serializer_class)
40
+ return if mode == :hash
41
+
42
+ initialize_point = @points.find { |point| point.name == :initialize }
43
+ raise_member_error("Struct and Data can not have a member named initialize", initialize_point) if initialize_point
44
+
45
+ member_class(serializer_class, @points.map(&:name))
46
+ end
47
+
48
+ # Returns the Struct or Data class with the members. Raises a
49
+ # SeregaError for an attribute name that Ruby can not make a member.
50
+ def member_class(serializer_class, names)
51
+ (mode == :struct) ? serializer_class.struct_class_for(names) : serializer_class.data_class_for(names)
52
+ rescue ArgumentError, NameError => error
53
+ point = @points.find { |point| !member_name?(point.name) }
54
+ raise_member_error(error.message, point)
55
+ end
56
+
57
+ def member_name?(name)
58
+ (mode == :struct) ? Struct.new(name) : Data.define(name)
59
+ true
60
+ rescue ArgumentError, NameError
61
+ false
62
+ end
63
+
64
+ def raise_member_error(message, point)
65
+ SeregaUtils::SerializedAttributeError.call(SeregaError.new(message), point)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Serega
4
+ #
5
+ # Generates the Ruby code of the `SeregaResultBuilder#call` method of one
6
+ # plan in one serialization mode. The method reads all values of a source,
7
+ # then makes its serialized object in one step: a Hash literal,
8
+ # `Struct.new` or `Data.new`. The code reads plain attributes directly,
9
+ # for example `source.name`.
10
+ #
11
+ # Points without relations and batch loaders get a `#call_one(source,
12
+ # context)` method too. It returns the serialized object of one source,
13
+ # without the Array and the loop of `#call`.
14
+ #
15
+ # Generated code for a plan with `attribute :id` and
16
+ # `attribute :posts, serializer: PostSerializer` in the :hash mode:
17
+ #
18
+ # def call(sources, context, batches, relations)
19
+ # relation_values_1 = relations[@points[1]]
20
+ # size = sources.size
21
+ # serialized = Array.new(size)
22
+ # source_index = 0
23
+ # while source_index < size
24
+ # source = sources[source_index]
25
+ # value_0 =
26
+ # begin
27
+ # source.id
28
+ # rescue => error
29
+ # Serega::SeregaUtils::SerializedAttributeError.call(error, @points[0])
30
+ # end
31
+ # value_1 = relation_values_1[source_index]
32
+ # serialized[source_index] = {:id => value_0, :posts => value_1}
33
+ # source_index += 1
34
+ # end
35
+ # serialized
36
+ # end
37
+ #
38
+ class SeregaResultCode # :nodoc:
39
+ # Method names called directly in generated code
40
+ PLAIN_METHOD_NAME = /\A[a-z_][a-zA-Z0-9_]*[?!]?\z/
41
+
42
+ # Serialization mode
43
+ attr_reader :mode
44
+
45
+ def initialize(mode, points)
46
+ @mode = mode
47
+ @points = points
48
+ end
49
+
50
+ # Returns the code of the `#call` method, and of the `#call_one`
51
+ # method for points without relations and batch loaders. The methods
52
+ # have local variables for the points, and they have the point index
53
+ # in their names:
54
+ # - `attribute_N` and `batches_N` read values with `SeregaAttribute#value`.
55
+ # - `conditions_N` checks the conditions of a conditional attribute.
56
+ # - `relation_values_N` holds the relation values of all sources.
57
+ # - `value_N` holds the value of the current source.
58
+ def to_s
59
+ variables = variables_code.join("\n ")
60
+ values = values_code
61
+ code = call_code(variables, values)
62
+ return code unless @points.none? { |point| point.child_plan || !point.batch_loaders.empty? }
63
+
64
+ "#{code}\n#{call_one_code(variables, values)}"
65
+ end
66
+
67
+ private
68
+
69
+ def call_code(variables, values)
70
+ <<~RUBY
71
+ def call(sources, context, batches, relations)
72
+ #{variables}
73
+ size = sources.size
74
+ serialized = Array.new(size)
75
+ source_index = 0
76
+ while source_index < size
77
+ source = sources[source_index]
78
+ #{[*values, *build_serialized_code("serialized[source_index] = ")].join("\n").gsub("\n", "\n ")}
79
+ source_index += 1
80
+ end
81
+ serialized
82
+ end
83
+ RUBY
84
+ end
85
+
86
+ def call_one_code(variables, values)
87
+ <<~RUBY
88
+ def call_one(source, context)
89
+ #{variables}
90
+ #{[*values, *build_serialized_code("")].join("\n").gsub("\n", "\n ")}
91
+ end
92
+ RUBY
93
+ end
94
+
95
+ # Code that sets the local variables of a method: only the variables
96
+ # that the code of the points reads
97
+ def variables_code
98
+ point_variables = @points.each_with_index.flat_map { |point, index| point_variables_code(point, index) }
99
+ return point_variables if mode == :hash
100
+
101
+ ["result_class = @result_class", *point_variables]
102
+ end
103
+
104
+ def point_variables_code(point, index)
105
+ return ["relation_values_#{index} = relations[@points[#{index}]]"] if point.child_plan
106
+
107
+ variables = []
108
+ variables << "attribute_#{index} = @points[#{index}].attribute" unless point.attribute.value_method
109
+ variables << "batches_#{index} = batches[#{index}]" unless point.batch_loaders.empty?
110
+ variables << "conditions_#{index} = @points[#{index}].attribute.conditions" if point.conditional?
111
+ variables
112
+ end
113
+
114
+ # Code that reads all values of the source, one entry per point
115
+ def values_code
116
+ @points.each_with_index.map { |point, index| assign_value_code(point, index) }
117
+ end
118
+
119
+ # Code that assigns the value of the point to `value_N`. A conditional
120
+ # attribute gets SKIP when it fails its conditions. A conditional
121
+ # relation value is SKIP already.
122
+ def assign_value_code(point, index)
123
+ return "value_#{index} = #{read_value_code(point, index)}" if point.child_plan
124
+
125
+ read_code = rescued_read_code(point, index)
126
+ return "value_#{index} =\n#{read_code.gsub(/^/, " ")}" unless point.conditional?
127
+
128
+ <<~RUBY.chomp
129
+ value_#{index} =
130
+ if conditions_#{index}.satisfy?(source, context)
131
+ value_#{index} =
132
+ #{read_code.gsub(/^/, " ")}
133
+ conditions_#{index}.satisfy_value?(value_#{index}, context) ? value_#{index} : Serega::SeregaEngine::SKIP
134
+ else
135
+ Serega::SeregaEngine::SKIP
136
+ end
137
+ RUBY
138
+ end
139
+
140
+ # Code that reads the value of the point. An error raised there gets
141
+ # the attribute name. Only the rescue reads the point.
142
+ def rescued_read_code(point, index)
143
+ <<~RUBY.chomp
144
+ begin
145
+ #{read_value_code(point, index)}
146
+ rescue => error
147
+ Serega::SeregaUtils::SerializedAttributeError.call(error, @points[#{index}])
148
+ end
149
+ RUBY
150
+ end
151
+
152
+ # Code that reads the value of the point. A plain attribute reads its
153
+ # `:const`, `:default` and formatter values from the constants of
154
+ # SeregaAttributeValues.
155
+ def read_value_code(point, index)
156
+ return "relation_values_#{index}[source_index]" if point.child_plan
157
+
158
+ attribute = point.attribute
159
+ return attribute.value_code("source", "SeregaAttributeValues::") if attribute.value_method
160
+ return "attribute_#{index}.value(source, context)" if point.batch_loaders.empty?
161
+
162
+ "attribute_#{index}.value(source, context, batches: batches_#{index})"
163
+ end
164
+
165
+ # Code that builds the serialized object of the source. `assign` is the
166
+ # code that takes the serialized object, empty for the return value.
167
+ def build_serialized_code(assign)
168
+ return build_serialized_hash_code(assign) if mode == :hash
169
+
170
+ arguments = @points.each_index.map { |index| argument_code(index) }
171
+ ["#{assign}result_class.new(#{arguments.join(", ")})"]
172
+ end
173
+
174
+ # Code of the Struct or Data argument with the value of the point, nil
175
+ # for a skipped value
176
+ def argument_code(index)
177
+ return "value_#{index}" unless @points[index].conditional?
178
+
179
+ "(Serega::SeregaEngine::SKIP.equal?(value_#{index}) ? nil : value_#{index})"
180
+ end
181
+
182
+ # Code that builds the serialized Hash of the source, after `assign`.
183
+ # A Hash literal holds the values up to the first conditional value. Then
184
+ # the code adds the other values one by one, thus the Hash keys keep the
185
+ # order of the points.
186
+ def build_serialized_hash_code(assign)
187
+ literal_size = @points.index(&:conditional?) || @points.size
188
+ pairs = @points.first(literal_size).each_with_index.map { |point, index| "#{point.name.inspect} => value_#{index}" }
189
+ literal = "{#{pairs.join(", ")}}"
190
+ return ["#{assign}#{literal}"] if literal_size == @points.size
191
+
192
+ assigns = @points.each_with_index.drop(literal_size).map { |point, index| hash_assign_code(point, index) }
193
+ ["serialized_hash = #{literal}", *assigns, "#{assign}serialized_hash"]
194
+ end
195
+
196
+ # Code that adds the value of the point to the serialized Hash. It adds
197
+ # no key for a skipped value.
198
+ def hash_assign_code(point, index)
199
+ code = "serialized_hash[#{point.name.inspect}] = value_#{index}"
200
+ point.conditional? ? "#{code} unless Serega::SeregaEngine::SKIP.equal?(value_#{index})" : code
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Serega
4
+ #
5
+ # Makes a serializer shareable between Ractors
6
+ #
7
+ class SeregaShareable # :nodoc:
8
+ class << self
9
+ # Makes the serializer definitions shareable between Ractors, except the
10
+ # plans cache. A definition that can not be shared stays as it is.
11
+ def call(serializer_class)
12
+ reasons = []
13
+
14
+ serializer_class.attributes.each_value do |attribute|
15
+ share(attribute, reasons) do |message|
16
+ "Attribute :#{attribute.name} of #{serializer_class} can not be shared between Ractors (#{attribute.location}): #{message}"
17
+ end
18
+ end
19
+
20
+ serializer_class.batch_loaders.each_value do |loader|
21
+ share(loader, reasons) { |message| "Batch loader :#{loader.name} of #{serializer_class} can not be shared between Ractors: #{message}" }
22
+ end
23
+
24
+ serializer_class.formatters.each do |name, formatter|
25
+ share(formatter, reasons) { |message| "Formatter :#{name} of #{serializer_class} can not be shared between Ractors: #{message}" }
26
+ end
27
+
28
+ (serializer_class.instance_variables - [:@plan_cache]).each do |name|
29
+ value = serializer_class.instance_variable_get(name)
30
+ share(value, reasons) { |message| "#{serializer_class} can not be shared between Ractors: #{message}" }
31
+ end
32
+
33
+ share_constants(serializer_class::SeregaAttributeValues, reasons)
34
+ reasons.first
35
+ end
36
+
37
+ private
38
+
39
+ def share_constants(values_class, reasons)
40
+ values_class.constants(false).each do |name|
41
+ share(values_class.const_get(name, false), reasons) { |message| "#{values_class} can not be shared between Ractors: #{message}" }
42
+ end
43
+ end
44
+
45
+ def share(value, reasons)
46
+ Ractor.make_shareable(value)
47
+ rescue Ractor::Error, ArgumentError => error
48
+ reasons << -yield(error.message)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Serega
4
+ #
5
+ # All sources that one plan serializes in one run, for example the posts of
6
+ # all users. A source is an object to serialize. A serialized object is its
7
+ # output: a Hash, Struct or Data object.
8
+ #
9
+ # The group serializes its sources in two steps:
10
+ # - `#discover` runs the preloads, reads the relation sources and adds one
11
+ # child group per relation.
12
+ # - `#build` builds the serialized objects. It runs after the child groups
13
+ # are built, thus the relation values are ready.
14
+ #
15
+ class SeregaSourceGroup # :nodoc:
16
+ # Serialization run
17
+ attr_reader :run
18
+
19
+ # Serialization plan
20
+ attr_reader :plan
21
+
22
+ # Serialization context
23
+ attr_reader :context
24
+
25
+ # Sources (or their presenters)
26
+ attr_reader :sources
27
+
28
+ # Serialized object of each
29
+ # source, after #build
30
+ attr_reader :serialized
31
+
32
+ # Pulls from
33
+ # `SeregaEngine::Pulls.collect`, one per source of the parent group
34
+ attr_reader :pulls
35
+
36
+ # Each source is wrapped in the serializer's presenter, so value reading
37
+ # and batch loaders alike see presenters.
38
+ def initialize(run, plan, sources, pulls)
39
+ @run = run
40
+ @plan = plan
41
+ @context = run.context
42
+ presenter = plan.presenter
43
+ @sources = presenter ? sources.map { |source| presenter.new(source, @context) } : sources
44
+ @pulls = pulls
45
+ @child_groups = nil
46
+ @serialized = nil
47
+ @loaded_batches = nil
48
+ @attribute_values = nil
49
+ end
50
+
51
+ # Runs the preloads, and makes one child group per relation point, in the
52
+ # order of the relation points.
53
+ def discover
54
+ run_preloads
55
+
56
+ relation_points = plan.relation_points
57
+ return FROZEN_EMPTY_ARRAY if relation_points.empty?
58
+
59
+ @child_groups = relation_points.map { |point| child_group(point) }
60
+ end
61
+
62
+ # Builds the serialized objects.
63
+ def build
64
+ batches = plan.points.map { |point| batches_for(point) } if plan.batch_points?
65
+ @serialized = plan.result_builder(run.mode).call(@sources, @context, batches, relations)
66
+ end
67
+
68
+ private
69
+
70
+ # Relation values of each relation point, taken from its built child
71
+ # group, or nil without relation points
72
+ def relations
73
+ child_groups = @child_groups
74
+ return unless child_groups
75
+
76
+ relation_points = plan.relation_points
77
+ relations = {}
78
+ index = 0
79
+
80
+ while index < relation_points.size
81
+ child_group = child_groups[index]
82
+ relations[relation_points[index]] = SeregaEngine::Pulls.take!(child_group.serialized, child_group.pulls)
83
+ index += 1
84
+ end
85
+
86
+ relations
87
+ end
88
+
89
+ # Reads the relation sources of the point, and makes their child group
90
+ def child_group(point)
91
+ batches = batches_for(point)
92
+ child_sources = []
93
+ pulls =
94
+ if point.conditional?
95
+ relation_sources = read_conditional_relation_sources(point, batches)
96
+ SeregaEngine::Pulls.collect_conditional(child_sources, relation_sources, point.many, point.collection_classes)
97
+ else
98
+ relation_sources = read_relation_sources(point, batches)
99
+ SeregaEngine::Pulls.collect(child_sources, relation_sources, point.many, point.collection_classes)
100
+ end
101
+ run.new_source_group(point.child_plan, child_sources, pulls)
102
+ end
103
+
104
+ # Runs the preloads of all points with the serializer's preload handler.
105
+ # Preload handlers get the sources without presenters.
106
+ def run_preloads
107
+ points = plan.preload_points
108
+ return if points.empty?
109
+
110
+ serializer_class = plan.serializer_class
111
+ handler = serializer_class.preload_with
112
+ sources = plan.presenter ? @sources.map(&:__getobj__) : @sources
113
+
114
+ points.each do |point|
115
+ unless handler
116
+ raise SeregaError, "The :preload option requires a preload handler. Register one with `preload_with` (the :activerecord_preloads plugin does this for you)."
117
+ end
118
+
119
+ handler.call(sources, point.preloads)
120
+ rescue => error
121
+ SeregaUtils::SerializedAttributeError.call(error, point)
122
+ end
123
+ end
124
+
125
+ # Loads the batch loaders of the point, each once for all sources of
126
+ # the group. An attribute with an inline batch loader uses it, other
127
+ # attributes use the named batch loaders of the serializer. Returns nil
128
+ # when the point has no batch loaders.
129
+ def batches_for(point)
130
+ names = point.batch_loaders
131
+ return if names.empty?
132
+
133
+ inline_loader = point.attribute.inline_batch_loader
134
+ loaders = plan.serializer_class.batch_loaders
135
+ loaded_batches = (@loaded_batches ||= {}.compare_by_identity)
136
+ names.to_h do |name|
137
+ loader = inline_loader || loaders[name]
138
+ [name, loaded_batches[loader] ||= loader.load(@sources, @context)]
139
+ end
140
+ rescue => error
141
+ SeregaUtils::SerializedAttributeError.call(error, point)
142
+ end
143
+
144
+ # SeregaAttributeValues of the serializer: the methods that read
145
+ # attribute values with plain Ruby code
146
+ def attribute_values
147
+ @attribute_values ||= plan.serializer_class::SeregaAttributeValues.new
148
+ end
149
+
150
+ # Reads the relation source of every source: what the relation
151
+ # attribute returns, for example `user.posts`.
152
+ def read_relation_sources(point, batches)
153
+ attribute = point.attribute
154
+ value_method = attribute.value_method
155
+ context = @context
156
+
157
+ if value_method
158
+ values = attribute_values
159
+ @sources.map { |source| values.__send__(value_method, source) }
160
+ else
161
+ @sources.map { |source| attribute.value(source, context, batches: batches) }
162
+ end
163
+ rescue => error
164
+ SeregaUtils::SerializedAttributeError.call(error, point)
165
+ end
166
+
167
+ # Reads the relation source of every source of a conditional relation.
168
+ # A source failing the :if or :unless condition gets SKIP.
169
+ def read_conditional_relation_sources(point, batches)
170
+ attribute = point.attribute
171
+ value_method = attribute.value_method
172
+ values = attribute_values if value_method
173
+ conditions = attribute.conditions
174
+ context = @context
175
+
176
+ @sources.map do |source|
177
+ next SeregaEngine::SKIP unless conditions.satisfy?(source, context)
178
+
179
+ begin
180
+ value_method ? values.__send__(value_method, source) : attribute.value(source, context, batches: batches)
181
+ rescue => error
182
+ SeregaUtils::SerializedAttributeError.call(error, point)
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end