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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +209 -0
- data/lib/omni_service/async.rb +106 -0
- data/lib/omni_service/collection.rb +78 -0
- data/lib/omni_service/component.rb +81 -0
- data/lib/omni_service/context.rb +29 -0
- data/lib/omni_service/convenience.rb +125 -0
- data/lib/omni_service/error.rb +56 -0
- data/lib/omni_service/find_many.rb +261 -0
- data/lib/omni_service/find_one.rb +172 -0
- data/lib/omni_service/helpers.rb +20 -0
- data/lib/omni_service/inspect.rb +41 -0
- data/lib/omni_service/namespace.rb +111 -0
- data/lib/omni_service/optional.rb +49 -0
- data/lib/omni_service/parallel.rb +70 -0
- data/lib/omni_service/params.rb +50 -0
- data/lib/omni_service/result.rb +107 -0
- data/lib/omni_service/sequence.rb +53 -0
- data/lib/omni_service/shortcut.rb +49 -0
- data/lib/omni_service/strict.rb +36 -0
- data/lib/omni_service/transaction.rb +127 -0
- data/lib/omni_service/version.rb +5 -0
- data/lib/omni_service.rb +78 -0
- metadata +154 -0
|
@@ -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
|