omni_service 0.1.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,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Structured validation/operation error.
4
+ #
5
+ # Attributes:
6
+ # - component: the component that produced the error
7
+ # - code: symbolic error code (e.g., :blank, :not_found, :invalid)
8
+ # - message: human-readable message (optional)
9
+ # - path: array of keys/indices to error location (e.g., [:author, :email])
10
+ # - tokens: interpolation values for message templates (e.g., { min: 5 })
11
+ #
12
+ # Components return failures that are converted to Error:
13
+ # - Failure(:code) => Error(code: :code)
14
+ # - Failure("message") => Error(message: "message")
15
+ # - Failure({ code:, path:, message:, tokens: }) => Error(...)
16
+ # - Failure([...]) => multiple Errors
17
+ #
18
+ # @example Simple error
19
+ # Failure(:not_found)
20
+ # # => Error(code: :not_found, path: [])
21
+ #
22
+ # @example Error with path
23
+ # Failure([{ code: :blank, path: [:title] }])
24
+ # # => Error(code: :blank, path: [:title])
25
+ #
26
+ # @example Error with tokens for i18n
27
+ # Failure([{ code: :too_short, path: [:body], tokens: { min: 100 } }])
28
+ #
29
+ class OmniService::Error < Dry::Struct
30
+ attribute :component, OmniService::Types::Interface(:call)
31
+ attribute :message, OmniService::Types::Strict::String.optional
32
+ attribute :code, OmniService::Types::Strict::Symbol.optional
33
+ attribute :path, OmniService::Types::Coercible::Array.of(OmniService::Types::Symbol | OmniService::Types::Integer)
34
+ attribute :tokens, OmniService::Types::Hash.map(OmniService::Types::Symbol, OmniService::Types::Any)
35
+
36
+ def self.process(component, failure)
37
+ case failure
38
+ in Array
39
+ failure.flat_map { |error| process(component, error) }
40
+ in OmniService::Error
41
+ failure.new(component:)
42
+ in String
43
+ [build(component, message: failure)]
44
+ in Symbol
45
+ [build(component, code: failure)]
46
+ in Hash
47
+ [build(component, **failure)]
48
+ else
49
+ raise "Invalid failure `#{failure.inspect}` returned by `#{component.inspect}`"
50
+ end
51
+ end
52
+
53
+ def self.build(component, **)
54
+ new(message: nil, code: nil, path: [], tokens: {}, **, component:)
55
+ end
56
+ end
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Finds multiple entities by IDs via repository#get_many.
4
+ # Accepts array of IDs or nested structures with IDs.
5
+ # Returns deduplicated entities; reports not-found errors with array indices.
6
+ #
7
+ # Options:
8
+ # - :with - param key for IDs array (default: :"#{context_key.singularize}_ids")
9
+ # - :by - column mapping with nested paths: { id: [:items, :product_id] }
10
+ # - :repository - single repo or Hash for polymorphic lookup
11
+ # - :type - path to type discriminator for polymorphic lookup
12
+ #
13
+ # Flags:
14
+ # - :nullable - skip nil values in array instead of reporting errors
15
+ # - :omittable - allow missing param key, returns Success({})
16
+ #
17
+ # @example Basic bulk lookup
18
+ # FindMany.new(:posts, repository: post_repo)
19
+ # # params: { post_ids: [1, 2, 3] } => Success(posts: [<Post>, <Post>, <Post>])
20
+ #
21
+ # @example Nested IDs in array of hashes
22
+ # FindMany.new(:products, repository: repo, by: { id: [:items, :product_id] })
23
+ # # params: { items: [{ product_id: 1 }, { product_id: [2, 3] }] }
24
+ # # => Success(products: [<Product>, <Product>, <Product>])
25
+ #
26
+ # @example Error reporting with indices
27
+ # # params: { post_ids: [1, 999, 3] } where 999 doesn't exist
28
+ # # => Failure([{ code: :not_found, path: [:post_ids, 1] }])
29
+ #
30
+ # @example Polymorphic lookup
31
+ # FindMany.new(:attachments, repository: { 'Image' => img_repo, 'Video' => vid_repo },
32
+ # by: { id: [:files, :id] }, type: [:files, :type])
33
+ # # params: { files: [{ type: 'Image', id: 1 }, { type: 'Video', id: 2 }] }
34
+ #
35
+ # @example Nullable for optional associations
36
+ # FindMany.new(:tags, repository: repo, nullable: true)
37
+ # # params: { tag_ids: [1, nil, 2] } => Success(tags: [<Tag>, <Tag>])
38
+ #
39
+ class OmniService::FindMany
40
+ extend Dry::Initializer
41
+ include Dry::Core::Constants
42
+ include Dry::Monads[:result]
43
+ include OmniService::Inspect.new(:context_key, :repository, :lookup, :omittable)
44
+
45
+ PRIMARY_KEY = :id
46
+
47
+ Reference = Class.new(Dry::Struct) do
48
+ attribute :path, OmniService::Types::Array.of(OmniService::Types::Symbol | OmniService::Types::Integer)
49
+ attribute :value, OmniService::Types::Any
50
+
51
+ def undefined?
52
+ Undefined.equal?(value)
53
+ end
54
+
55
+ def missing_id_path
56
+ path if undefined?
57
+ end
58
+
59
+ def missing_type_path; end
60
+
61
+ def normalized_value
62
+ Array.wrap(value)
63
+ end
64
+ end
65
+
66
+ PolymorphicReference = Class.new(Dry::Struct) do
67
+ attribute :type, Reference
68
+ attribute :id, Reference
69
+
70
+ delegate :path, :missing_id_path, to: :id
71
+
72
+ def undefined?
73
+ type.undefined? || id.undefined?
74
+ end
75
+
76
+ def missing_type_path
77
+ type.missing_id_path unless id.value.nil?
78
+ end
79
+ end
80
+
81
+ param :context_key, OmniService::Types::Symbol
82
+ option :repository, OmniService::Types::Interface(:get_many) |
83
+ OmniService::Types::Hash.map(OmniService::Types::String, OmniService::Types::Interface(:get_many))
84
+ option :type, OmniService::Types::Coercible::Array.of(OmniService::Types::Symbol), default: proc { :type }
85
+ option :with, OmniService::Types::Symbol, default: proc { :"#{context_key.to_s.singularize}_ids" }
86
+ option :by, OmniService::Types::Symbol |
87
+ OmniService::Types::Array.of(OmniService::Types::Symbol) |
88
+ OmniService::Types::Hash.map(OmniService::Types::Symbol, OmniService::Types::Symbol |
89
+ OmniService::Types::Array.of(OmniService::Types::Symbol)), optional: true
90
+ option :omittable, OmniService::Types::Bool, default: proc { false }
91
+ option :nullable, OmniService::Types::Bool, default: proc { false }
92
+
93
+ def call(params, **context)
94
+ return Success({}) if already_found?(context)
95
+
96
+ references, *missing_paths = references(params)
97
+ no_errors = missing_paths.all?(&:empty?)
98
+
99
+ if references.values.all?(&:empty?) && no_errors
100
+ Success({})
101
+ elsif !no_errors
102
+ missing_keys_result(*missing_paths)
103
+ else
104
+ find(references)
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def already_found?(context)
111
+ context[context_key].respond_to?(:to_ary)
112
+ end
113
+
114
+ def missing_keys_result(missing_paths, invalid_type_paths)
115
+ if omittable || missing_paths.all?(&:empty?) && invalid_type_paths.empty?
116
+ Success({})
117
+ else
118
+ Failure(
119
+ missing_paths.map { |path| { code: :missing, path: } } +
120
+ invalid_type_paths.map { |path| { code: :included, path:, tokens: { allowed_values: repository.keys } } }
121
+ )
122
+ end
123
+ end
124
+
125
+ def references(params)
126
+ result = pointers.index_with do |pointer|
127
+ undefined_references, references = pointer_references(params, *pointer).partition(&:undefined?)
128
+
129
+ [references, missing_paths(undefined_references), invalid_type_paths(references)]
130
+ end
131
+
132
+ [result.transform_values(&:first), result.values.flat_map(&:second), result.values.flat_map(&:third)]
133
+ end
134
+
135
+ def pointer_references(params, segment = nil, *pointer, path: [], type_reference: Undefined)
136
+ return [id_reference(params, path, type_reference)] unless segment
137
+
138
+ case params
139
+ when Array
140
+ params.flat_map.with_index do |value, index|
141
+ pointer_references(value, segment, *pointer, path: [*path, index], type_reference:)
142
+ end
143
+ when Hash
144
+ hash_pointer_references(params, segment, *pointer, path:, type_reference:)
145
+ else
146
+ []
147
+ end
148
+ end
149
+
150
+ def hash_pointer_references(params, segment, *pointer, path:, type_reference:)
151
+ type_reference = type_reference(params, path) if polymorphic? && path.grep(Symbol) == type[..-2]
152
+ pointer_references(
153
+ params.key?(segment) ? params[segment] : Undefined,
154
+ *pointer,
155
+ path: [*path, segment],
156
+ type_reference:
157
+ )
158
+ end
159
+
160
+ def type_reference(params, path)
161
+ Reference.new(path: [*path, type.last], value: params.key?(type.last) ? params[type.last] : Undefined)
162
+ end
163
+
164
+ def id_reference(params, path, type_reference)
165
+ id_reference = Reference.new(path:, value: params)
166
+ polymorphic? ? PolymorphicReference.new(type: type_reference, id: id_reference) : id_reference
167
+ end
168
+
169
+ def missing_paths(undefined_references)
170
+ missing_id_paths = omittable ? [] : undefined_references.filter_map(&:missing_id_path)
171
+ missing_type_paths = undefined_references.filter_map(&:missing_type_path)
172
+
173
+ missing_id_paths + missing_type_paths
174
+ end
175
+
176
+ def invalid_type_paths(references)
177
+ if polymorphic?
178
+ references.filter_map { |reference| reference.type.path unless repository.key?(reference.type.value) }
179
+ else
180
+ []
181
+ end
182
+ end
183
+
184
+ def find(references)
185
+ # TODO: use all columns/pointer somehow
186
+ pointer = pointers.first
187
+ column = columns.first
188
+
189
+ if polymorphic?
190
+ type_references = references[pointer].group_by { |reference| reference.type.value }
191
+ result, not_found_paths = fetch_polymorphic(type_references, column)
192
+ else
193
+ result, not_found_paths = fetch(references[pointer], column, repository)
194
+ end
195
+
196
+ find_result(result, not_found_paths)
197
+ end
198
+
199
+ def fetch(references, column, repository)
200
+ ids = references.flat_map(&:normalized_value).uniq
201
+ result = repository.get_many(column => ids).to_a
202
+ not_found_paths = not_found_paths(result.index_by(&column), references)
203
+ [result, not_found_paths]
204
+ end
205
+
206
+ def fetch_polymorphic(type_references, column)
207
+ type_references
208
+ .map { |(type, references)| fetch(references.map(&:id), column, repository[type]) }
209
+ .transpose.map { |a| a.flatten(1) }
210
+ end
211
+
212
+ def not_found_paths(entities_index, references)
213
+ references.flat_map do |reference|
214
+ if reference.value.is_a?(Array)
215
+ reference.value.filter_map.with_index { |v, i| [*reference.path, i] if missing_value?(entities_index, v) }
216
+ else
217
+ missing_value?(entities_index, reference.value) ? [reference.path] : []
218
+ end
219
+ end
220
+ end
221
+
222
+ def missing_value?(entities_index, value)
223
+ !(entities_index.key?(value) || (nullable && value.nil?))
224
+ end
225
+
226
+ def find_result(entities, not_found_paths)
227
+ if not_found_paths.empty?
228
+ Success(context_key => entities.compact)
229
+ else
230
+ not_found_failure(not_found_paths)
231
+ end
232
+ end
233
+
234
+ def not_found_failure(paths)
235
+ Failure(paths.map { |path| { code: :not_found, path: } })
236
+ end
237
+
238
+ def lookup
239
+ @lookup ||= columns.zip(pointers).to_h
240
+ end
241
+
242
+ def pointers
243
+ @pointers ||= if by.is_a?(Hash)
244
+ by.values
245
+ else
246
+ Array.wrap(by || with)
247
+ end.map { |path| Array.wrap(path) }
248
+ end
249
+
250
+ def columns
251
+ @columns ||= if by.is_a?(Hash)
252
+ by.keys
253
+ else
254
+ Array.wrap(by || PRIMARY_KEY)
255
+ end
256
+ end
257
+
258
+ def polymorphic?
259
+ repository.is_a?(Hash)
260
+ end
261
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Finds a single entity by ID or attributes via repository#get_one.
4
+ # Skips lookup if entity already exists in context.
5
+ #
6
+ # Options:
7
+ # - :with - param key for lookup (default: :"#{context_key}_id")
8
+ # - :by - column mapping, supports nested paths: { id: [:deep, :post_id] }
9
+ # - :repository - single repo or Hash for polymorphic lookup
10
+ # - :type - path to type discriminator for polymorphic lookup
11
+ #
12
+ # Flags:
13
+ # - :nullable - allow nil param value, returns Success(key: nil)
14
+ # - :omittable - allow missing param key, returns Success({})
15
+ # - :skippable - return Success({}) when entity not found instead of Failure
16
+ #
17
+ # @example Basic lookup
18
+ # FindOne.new(:post, repository: post_repo)
19
+ # # params: { post_id: 123 } => Success(post: <Post>)
20
+ #
21
+ # @example Custom lookup key
22
+ # FindOne.new(:post, repository: post_repo, with: :slug)
23
+ # # params: { slug: 'hello' } => get_one(id: 'hello')
24
+ #
25
+ # @example Nested param path
26
+ # FindOne.new(:post, repository: post_repo, by: { id: [:data, :post_id] })
27
+ # # params: { data: { post_id: 123 } } => get_one(id: 123)
28
+ #
29
+ # @example Multi-column lookup
30
+ # FindOne.new(:post, repository: post_repo, by: [:author_id, :slug])
31
+ # # params: { author_id: 1, slug: 'hi' } => get_one(author_id: 1, slug: 'hi')
32
+ #
33
+ # @example Polymorphic lookup
34
+ # FindOne.new(:comment, repository: { 'Post' => post_repo, 'Article' => article_repo })
35
+ # # params: { comment_id: 1, comment_type: 'Post' } => post_repo.get_one(id: 1)
36
+ #
37
+ # @example Nullable association (for clearing)
38
+ # FindOne.new(:category, repository: repo, nullable: true)
39
+ # # params: { category_id: nil } => Success(category: nil)
40
+ #
41
+ # @example Omittable for partial updates
42
+ # FindOne.new(:category, repository: repo, omittable: true)
43
+ # # params: {} => Success({}) - doesn't touch association
44
+ #
45
+ # @example Skippable for soft-deleted lookups
46
+ # FindOne.new(:post, repository: repo, skippable: true)
47
+ # # params: { post_id: 999 } (not found) => Success({})
48
+ #
49
+ class OmniService::FindOne
50
+ extend Dry::Initializer
51
+ include Dry::Monads[:result]
52
+ include OmniService::Inspect.new(:context_key, :repository, :lookup, :omittable, :nullable)
53
+
54
+ PRIMARY_KEY = :id
55
+
56
+ param :context_key, OmniService::Types::Symbol
57
+ option :repository, OmniService::Types::Interface(:get_one) |
58
+ OmniService::Types::Hash.map(OmniService::Types::String, OmniService::Types::Interface(:get_one))
59
+ option :type, OmniService::Types::Coercible::Array.of(OmniService::Types::Symbol), default: proc { :"#{context_key}_type" }
60
+ option :with, OmniService::Types::Symbol, default: proc { :"#{context_key}_id" }
61
+ option :by, OmniService::Types::Symbol |
62
+ OmniService::Types::Array.of(OmniService::Types::Symbol) |
63
+ OmniService::Types::Hash.map(OmniService::Types::Symbol, OmniService::Types::Symbol |
64
+ OmniService::Types::Array.of(OmniService::Types::Symbol)), optional: true
65
+ option :omittable, OmniService::Types::Bool, default: proc { false }
66
+ option :nullable, OmniService::Types::Bool, default: proc { false }
67
+ option :skippable, OmniService::Types::Bool, default: proc { false }
68
+
69
+ def call(params, **context)
70
+ return Success({}) if already_found?(context)
71
+
72
+ missing_keys = missing_keys(params, pointers)
73
+ return missing_keys_result(missing_keys, context) unless missing_keys.empty?
74
+
75
+ values = values(params)
76
+ return Success(context_key => nil) if nullable && values.all?(&:nil?)
77
+
78
+ repository = resolve_repository(params)
79
+ return repository_failure(params, values) unless repository
80
+
81
+ find(values, repository:)
82
+ end
83
+
84
+ private
85
+
86
+ def already_found?(context)
87
+ nullable ? context.key?(context_key) : !context[context_key].nil?
88
+ end
89
+
90
+ def missing_keys(params, paths)
91
+ paths.reject do |path|
92
+ deep_object = path.one? ? params : params.dig(*path[..-2])
93
+ deep_object.is_a?(Hash) && deep_object.key?(path.last)
94
+ end
95
+ end
96
+
97
+ def missing_keys_result(missing_keys, context)
98
+ if nil_in_context?(context)
99
+ not_found_failure
100
+ elsif omittable && missing_keys.size == pointers.size
101
+ Success({})
102
+ else
103
+ missing_keys_failure(missing_keys)
104
+ end
105
+ end
106
+
107
+ def nil_in_context?(context)
108
+ !nullable && context.key?(context_key) && context[context_key].nil?
109
+ end
110
+
111
+ def values(params)
112
+ pointers.map { |pointer| params.dig(*pointer) }
113
+ end
114
+
115
+ def resolve_repository(params)
116
+ if repository.is_a?(Hash)
117
+ repository[params.dig(*type)]
118
+ else
119
+ repository
120
+ end
121
+ end
122
+
123
+ def repository_failure(params, values)
124
+ missing_keys = missing_keys(params, [type])
125
+
126
+ if values.all?(&:nil?)
127
+ not_found_failure
128
+ elsif missing_keys.empty?
129
+ Failure([{ code: :included, path: type, tokens: { allowed_values: repository.keys } }])
130
+ else
131
+ missing_keys_failure(missing_keys)
132
+ end
133
+ end
134
+
135
+ def find(values, repository:)
136
+ result = repository.get_one(**columns.zip(values).to_h)
137
+
138
+ if result.nil?
139
+ skippable ? Success({}) : not_found_failure
140
+ else
141
+ Success(context_key => result)
142
+ end
143
+ end
144
+
145
+ def missing_keys_failure(paths)
146
+ Failure(paths.map { |path| { code: :missing, path: } })
147
+ end
148
+
149
+ def not_found_failure
150
+ Failure(pointers.map { |pointer| { code: :not_found, path: pointer } })
151
+ end
152
+
153
+ def lookup
154
+ @lookup ||= columns.zip(pointers).to_h
155
+ end
156
+
157
+ def pointers
158
+ @pointers ||= if by.is_a?(Hash)
159
+ by.values
160
+ else
161
+ Array.wrap(by || with)
162
+ end.map { |path| Array.wrap(path) }
163
+ end
164
+
165
+ def columns
166
+ @columns ||= if by.is_a?(Hash)
167
+ by.keys
168
+ else
169
+ Array.wrap(by || PRIMARY_KEY)
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Instance-level utility methods included via OmniService::Convenience.
4
+ #
5
+ # @example Converting external failures to OmniService errors
6
+ # def call(params, **)
7
+ # result = external_service.validate(params)
8
+ # yield process_errors(result, path_prefix: [:external])
9
+ # # Failure from external becomes: [{ path: [:external, :field], code: :invalid }]
10
+ # end
11
+ #
12
+ module OmniService::Helpers
13
+ def process_errors(result, path_prefix: [])
14
+ return result if result.success?
15
+
16
+ Failure(OmniService::Error.process(self, result.failure).map do |error|
17
+ error.new(path: [*path_prefix, *error.path])
18
+ end)
19
+ end
20
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Module factory for custom pretty_print output.
4
+ # Generates a module that defines pretty_print showing specified attributes.
5
+ #
6
+ # @example Adding inspection to a class
7
+ # class MyComponent
8
+ # include OmniService::Inspect.new(:name, :options)
9
+ # # Now `pp my_component` shows: #<MyComponent name=..., options=...>
10
+ # end
11
+ #
12
+ class OmniService::Inspect < Module
13
+ extend Dry::Initializer
14
+
15
+ param :attributes, OmniService::Types::Coercible::Array.of(OmniService::Types::Symbol), reader: false
16
+ param :value_methods, OmniService::Types::Hash.map(OmniService::Types::Symbol, OmniService::Types::Symbol)
17
+
18
+ def initialize(*attributes, **kwargs)
19
+ super(attributes.flatten(1), kwargs)
20
+
21
+ define_pretty_print(@attributes, @value_methods)
22
+ end
23
+
24
+ private
25
+
26
+ def define_pretty_print(attributes, value_methods)
27
+ define_method(:pretty_print) do |pp|
28
+ object_group_method = self.class.name ? :object_group : :object_address_group
29
+ pp.public_send(object_group_method, self) do
30
+ pp.seplist(attributes, -> { pp.text ',' }) do |name|
31
+ pp.breakable ' '
32
+ pp.group(1) do
33
+ pp.text name.to_s
34
+ pp.text '='
35
+ pp.pp __send__(value_methods[name] || name)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Scopes component execution under a namespace key.
4
+ #
5
+ # Behavior:
6
+ # - Extracts params from params[namespace] (disable with shared_params: true)
7
+ # - Merges context[namespace] into component context with priority
8
+ # - Wraps returned context under namespace key
9
+ # - Prefixes error paths with namespace
10
+ #
11
+ # @example Nested author creation
12
+ # # params: { title: 'Hello', author: { name: 'John', email: 'j@test.com' } }
13
+ # namespace(:author, create_author)
14
+ # # Inner receives: { name: 'John', email: 'j@test.com' }
15
+ # # Result context: { author: { author: <Author> } }
16
+ # # Error paths: [:author, :email] instead of [:email]
17
+ #
18
+ # @example Shared params for multiple processors
19
+ # parallel(
20
+ # namespace(:cache, warm_cache, shared_params: true),
21
+ # namespace(:search, index_search, shared_params: true)
22
+ # )
23
+ # # Both receive full params; results namespaced separately
24
+ #
25
+ # @example Sequential namespace accumulation
26
+ # sequence(
27
+ # namespace(:author, validate_author), # => { author: { validated: true } }
28
+ # namespace(:author, create_author) # => { author: { validated: true, author: <Author> } }
29
+ # )
30
+ #
31
+ class OmniService::Namespace
32
+ extend Dry::Initializer
33
+ include Dry::Monads[:result]
34
+ include Dry::Equalizer(:namespace, :component, :shared_params)
35
+ include OmniService::Inspect.new(:namespace, :component, :shared_params)
36
+ include OmniService::Strict
37
+
38
+ param :namespace, OmniService::Types::Symbol
39
+ param :component, OmniService::Types::Interface(:call)
40
+ option :shared_params, OmniService::Types::Bool, default: -> { false }
41
+
42
+ delegate :signature, to: :component_wrapper
43
+
44
+ def call(*params, **context)
45
+ inner_params = prepare_params(params)
46
+ inner_context = prepare_context(context)
47
+ inner_result = component_wrapper.call(*inner_params, **inner_context)
48
+
49
+ transform_result(inner_result, params:, context:, inner_context_keys: inner_context.keys)
50
+ end
51
+
52
+ private
53
+
54
+ def component_wrapper
55
+ @component_wrapper ||= OmniService::Component.wrap(component)
56
+ end
57
+
58
+ def transform_result(inner_result, params:, context:, inner_context_keys:)
59
+ merged_context = build_merged_context(inner_result, context:, inner_context_keys:)
60
+
61
+ if inner_result.success?
62
+ inner_result.merge(params:, context: merged_context)
63
+ else
64
+ inner_result.merge(params:, context: merged_context, errors: prefix_errors(inner_result.errors))
65
+ end
66
+ end
67
+
68
+ def build_merged_context(inner_result, context:, inner_context_keys:)
69
+ returned_keys = inner_result.context.keys - inner_context_keys
70
+ inner_returned_context = inner_result.context.slice(*returned_keys)
71
+
72
+ existing_namespaced = context[namespace].is_a?(Hash) ? context[namespace] : {}
73
+ new_namespaced = existing_namespaced.merge(inner_returned_context)
74
+
75
+ context.except(namespace).merge(wrap_context(new_namespaced))
76
+ end
77
+
78
+ def prepare_params(params)
79
+ return params if shared_params || params.empty?
80
+
81
+ params_count = component_wrapper.signature[0]
82
+ return params if params_count.zero?
83
+
84
+ params.each_with_index.map { |param, index| extract_namespaced_param(param, index, params_count) }
85
+ end
86
+
87
+ def extract_namespaced_param(param, index, params_count)
88
+ return param unless index < params_count && param.key?(namespace)
89
+
90
+ param[namespace] || {}
91
+ end
92
+
93
+ def prepare_context(context)
94
+ namespaced = context[namespace]
95
+ base = context.except(namespace)
96
+
97
+ namespaced.is_a?(Hash) ? base.merge(namespaced) : base
98
+ end
99
+
100
+ def wrap_context(inner_context)
101
+ return {} if inner_context.empty?
102
+
103
+ { namespace => inner_context }
104
+ end
105
+
106
+ def prefix_errors(errors)
107
+ errors.map do |error|
108
+ error.new(path: [namespace, *error.path])
109
+ end
110
+ end
111
+ end