operations 0.6.3 → 0.7.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 +4 -4
- data/.rubocop_todo.yml +14 -3
- data/CHANGELOG.md +10 -1
- data/README.md +93 -29
- data/lib/operations/command.rb +21 -37
- data/lib/operations/contract/messages_resolver.rb +1 -1
- data/lib/operations/form/attribute.rb +6 -5
- data/lib/operations/form/base.rb +264 -0
- data/lib/operations/form/builder.rb +34 -37
- data/lib/operations/form/deprecated_legacy_model_map_implementation.rb +31 -0
- data/lib/operations/form.rb +62 -160
- data/lib/operations/inspect.rb +33 -0
- data/lib/operations/result.rb +9 -18
- data/lib/operations/version.rb +1 -1
- data/lib/operations.rb +5 -2
- data/operations.gemspec +7 -7
- metadata +10 -7
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 37feed93ae3f90b8e08f2ab960faf4195c8891e6bc4d60a78d03666c5c035de3
|
4
|
+
data.tar.gz: 29b2bad869d32e4bf22f188eb4c57a199300a315431f373d5cf988f15f63ab8b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 186e9327e813bbf7ddd5d5aea52b5bf00cc7a62a9c8b235b7fd25f36585a2468192922df3ff9435d7320b164807e31acf73523eaf9b106c67de6f2f27da19400
|
7
|
+
data.tar.gz: 43d61db8dbe71a4f9269755a6fc73c46214ba3baddeed80e650a7ae2ced43c08fb361dd19cef005954504970c06bfa573a1a55f760e7ac711c68c19f4e34601b
|
data/.rubocop_todo.yml
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# This configuration was generated by
|
2
2
|
# `rubocop --auto-gen-config`
|
3
|
-
# on
|
3
|
+
# on 2024-05-05 08:51:48 UTC using RuboCop version 1.59.0.
|
4
4
|
# The point is for the user to remove these configuration records
|
5
5
|
# one by one as the offenses are removed from the code base.
|
6
6
|
# Note that changes in the inspected code, or installation of new
|
@@ -19,12 +19,23 @@ Gemspec/DevelopmentDependencies:
|
|
19
19
|
Metrics/AbcSize:
|
20
20
|
Max: 28
|
21
21
|
|
22
|
-
# Offense count:
|
22
|
+
# Offense count: 1
|
23
23
|
# Configuration parameters: CountComments, CountAsOne.
|
24
24
|
Metrics/ClassLength:
|
25
|
-
Max:
|
25
|
+
Max: 145
|
26
26
|
|
27
27
|
# Offense count: 1
|
28
|
+
# Configuration parameters: CountComments, CountAsOne.
|
29
|
+
Metrics/ModuleLength:
|
30
|
+
Max: 142
|
31
|
+
|
32
|
+
# Offense count: 2
|
28
33
|
# Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
|
29
34
|
Metrics/ParameterLists:
|
30
35
|
Max: 7
|
36
|
+
|
37
|
+
# Offense count: 1
|
38
|
+
# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames.
|
39
|
+
RSpec/VerifiedDoubles:
|
40
|
+
Exclude:
|
41
|
+
- 'spec/operations/form_spec.rb'
|
data/CHANGELOG.md
CHANGED
@@ -1,6 +1,15 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
-
## [0.
|
3
|
+
## [0.7.0](https://github.com/BookingSync/operations/tree/main)
|
4
|
+
|
5
|
+
### Added
|
6
|
+
|
7
|
+
- Implement new forms system detaching it from operations [\#47](https://github.com/BookingSync/operations/pull/47) ([pyromaniac](https://github.com/pyromaniac))
|
8
|
+
|
9
|
+
### Improvements
|
10
|
+
|
11
|
+
- Better inspect for all the opjects [\#45](https://github.com/BookingSync/operations/pull/45) ([pyromaniac](https://github.com/pyromaniac))
|
12
|
+
- Use #to_hash instead of #as_json [\#44](https://github.com/BookingSync/operations/pull/44) ([pyromaniac](https://github.com/pyromaniac))
|
4
13
|
|
5
14
|
### Fixes
|
6
15
|
|
data/README.md
CHANGED
@@ -262,7 +262,7 @@ end
|
|
262
262
|
|
263
263
|
The operation body can be any callable object (respond to the `call` method), even a lambda. But it is always better to define it as a class since there might be additional instance methods and [dependency injections](#dependency-injection).
|
264
264
|
|
265
|
-
In any event, the operation body should return a Dry::
|
265
|
+
In any event, the operation body should return a Dry::Monads::Result instance. In case of a Failure, it will be converted into an `Operation::Result#error` and in case of Success(), its content will be merged into the operation context.
|
266
266
|
|
267
267
|
**Important:** since the Success result payload is merged inside of a context, it is supposed to be a hash.
|
268
268
|
|
@@ -305,7 +305,7 @@ end
|
|
305
305
|
|
306
306
|
Dependency injection can be used to provide IO clients with the operation. It could be DB repositories or API clients. The best way is to use Dry::Initializer for it since it provides the ability to define acceptable types.
|
307
307
|
|
308
|
-
If you still prefer to use ActiveRecord, it is worth creating a wrapper around it providing Dry::
|
308
|
+
If you still prefer to use ActiveRecord, it is worth creating a wrapper around it providing Dry::Monads-compatible interfaces.
|
309
309
|
|
310
310
|
```ruby
|
311
311
|
class ActiveRecordRepository
|
@@ -858,31 +858,38 @@ While we normally recommend using frontend-backend separation, it is still possi
|
|
858
858
|
```ruby
|
859
859
|
class PostsController < ApplicationController
|
860
860
|
def edit
|
861
|
-
@
|
862
|
-
{ post_id: params[:id] },
|
863
|
-
current_user: current_user
|
864
|
-
)
|
861
|
+
@post_update_form = Post::Update.default_form.build(params, current_user: current_user)
|
865
862
|
|
866
|
-
respond_with @
|
863
|
+
respond_with @post_update_form
|
867
864
|
end
|
868
865
|
|
869
866
|
def update
|
870
|
-
|
871
|
-
|
872
|
-
|
873
|
-
|
874
|
-
|
867
|
+
@post_update_form = Post::Update.default_form.persist(params, current_user: current_user)
|
868
|
+
|
869
|
+
respond_with @post_update_form, location: edit_post_url(@post_update_form.operation_result.context[:post])
|
870
|
+
end
|
871
|
+
end
|
872
|
+
```
|
873
|
+
|
874
|
+
Where the form class is defined this way:
|
875
|
+
|
876
|
+
```ruby
|
877
|
+
class Post::Update
|
878
|
+
def self.default
|
879
|
+
@default ||= Operations::Command.new(...)
|
880
|
+
end
|
875
881
|
|
876
|
-
|
882
|
+
def self.default_form
|
883
|
+
@default_form ||= Operations::Form.new(default)
|
877
884
|
end
|
878
885
|
end
|
879
886
|
```
|
880
887
|
|
881
|
-
|
888
|
+
Then, the form can be used as any other form object:
|
882
889
|
|
883
890
|
```erb
|
884
891
|
# views/posts/edit.html.erb
|
885
|
-
<%= form_for @
|
892
|
+
<%= form_for @post_update_form, url: post_url(@post_update_form.operation_result.context[:post]) do |f| %>
|
886
893
|
<%= f.input :title %>
|
887
894
|
<%= f.text_area :body %>
|
888
895
|
<% end %>
|
@@ -892,16 +899,16 @@ In cases when we need to populate the form data, it is possible to pass `form_hy
|
|
892
899
|
|
893
900
|
```ruby
|
894
901
|
class Post::Update
|
895
|
-
def self.
|
896
|
-
@
|
897
|
-
|
898
|
-
|
902
|
+
def self.default_form
|
903
|
+
@default_form ||= Operations::Form.new(
|
904
|
+
default,
|
905
|
+
hydrator: Post::Update::Hydrator.new
|
899
906
|
)
|
900
907
|
end
|
901
908
|
end
|
902
909
|
|
903
910
|
class Post::Update::Hydrator
|
904
|
-
def call(form_class, params, post:, **)
|
911
|
+
def call(form_class, params, post:, **_context)
|
905
912
|
value_attributes = form_class.attributes.keys - %i[post_id]
|
906
913
|
data = value_attributes.index_with { |name| post.public_send(name) }
|
907
914
|
|
@@ -912,23 +919,80 @@ end
|
|
912
919
|
|
913
920
|
The general idea here is to figure out attributes we have in the contract (those attributes are also defined automatically in a generated form class) and then fetch those attributes from the model and merge them with the params provided within the request.
|
914
921
|
|
915
|
-
Also, in the case of, say, [simple_form](https://github.com/heartcombo/simple_form), we need to provide additional attributes information, like data type. It is possible to do this with
|
922
|
+
Also, in the case of, say, [simple_form](https://github.com/heartcombo/simple_form), we need to provide additional attributes information, like data type. It is possible to do this with `model_map:` option:
|
916
923
|
|
917
924
|
```ruby
|
918
925
|
class Post::Update
|
919
|
-
def self.
|
920
|
-
@
|
921
|
-
|
922
|
-
|
923
|
-
|
924
|
-
[%r{.+}] => "Post"
|
925
|
-
}
|
926
|
+
def self.default_form
|
927
|
+
@default_form ||= Operations::Form.new(
|
928
|
+
default,
|
929
|
+
model_map: Post::Update::ModelMap.new,
|
930
|
+
hydrator: Post::Update::Hydrator.new
|
926
931
|
)
|
927
932
|
end
|
928
933
|
end
|
934
|
+
|
935
|
+
class Post::Update::ModelMap
|
936
|
+
def call(_path)
|
937
|
+
Post
|
938
|
+
end
|
939
|
+
end
|
929
940
|
```
|
930
941
|
|
931
|
-
|
942
|
+
In forms, params input is already transformed to extract the nested data with the form name. `form_for @post_update_form` will generate the form that send params nested under the `params[:post_update_form]` key. By default operation forms extract this form data and send it to the operation at the top level, so `{ id: 42, post_update_form: { title: "Post Title" } }` params will be sent to the operation as `{ id: 42, title: "Post Title" }`. Strong params are also accepted by the form, though they are being converted with `to_unsafe_hash`.
|
943
|
+
|
944
|
+
It is possible to add more params transfomations to the form in cases when operation contract is different from the params structure:
|
945
|
+
|
946
|
+
```ruby
|
947
|
+
class Post::Update
|
948
|
+
def self.default_form
|
949
|
+
@default_form ||= Operations::Form.new(
|
950
|
+
default,
|
951
|
+
model_name: "post_update_form", # form name can be customized
|
952
|
+
params_transformations: [
|
953
|
+
ParamsMap.new(id: :post_id),
|
954
|
+
NestedAttributes.new(:sections)
|
955
|
+
]
|
956
|
+
)
|
957
|
+
end
|
958
|
+
|
959
|
+
contract do
|
960
|
+
required(:post_id).filled(:integer)
|
961
|
+
optional(:title).filled(:string)
|
962
|
+
optional(:sections).array(:hash) do
|
963
|
+
optional(:id).filled(:integer)
|
964
|
+
optional(:content).filled(:string)
|
965
|
+
optional(:_destroy).filled(:bool)
|
966
|
+
end
|
967
|
+
end
|
968
|
+
end
|
969
|
+
|
970
|
+
# This will transform `{ id: 42, title: "Post Title" }` params to `{ post_id: 42, title: "Post Title" }`
|
971
|
+
class ParamsMap
|
972
|
+
extend Dry::Initializer
|
973
|
+
|
974
|
+
param :params_map
|
975
|
+
|
976
|
+
def call(_form_class, params, **_context)
|
977
|
+
params.transform_keys { |key| params_map[key] || key }
|
978
|
+
end
|
979
|
+
end
|
980
|
+
|
981
|
+
# And this will transform nested attributes hash from the form to an array acceptable by the operation:
|
982
|
+
# from
|
983
|
+
# `{ id: 42, sections_attributes: { '0' => { id: 1, content: "First paragraph" }, 'new' => { content: 'New Paragraph' } } }`
|
984
|
+
# into
|
985
|
+
# `{ id: 42, sections: [{ id: 1, content: "First paragraph" }, { content: 'New Paragraph' }] }`
|
986
|
+
class NestedAttributes
|
987
|
+
extend Dry::Initializer
|
988
|
+
|
989
|
+
param :name, Types::Coercible::Symbol
|
990
|
+
|
991
|
+
def call(_form_class, params, **_context)
|
992
|
+
params[name] = params[:"#{name}_attrbutes"].values
|
993
|
+
end
|
994
|
+
end
|
995
|
+
```
|
932
996
|
|
933
997
|
## Development
|
934
998
|
|
data/lib/operations/command.rb
CHANGED
@@ -125,15 +125,14 @@ require "operations/components/on_failure"
|
|
125
125
|
# which contains all the artifacts and the information about the errors
|
126
126
|
# should they ever happen.
|
127
127
|
class Operations::Command
|
128
|
-
UNDEFINED = Object.new.freeze
|
129
|
-
EMPTY_HASH = {}.freeze
|
130
128
|
COMPONENTS = %i[contract policies idempotency preconditions operation on_success on_failure].freeze
|
131
129
|
FORM_HYDRATOR = ->(_form_class, params, **_context) { params }
|
132
130
|
|
131
|
+
extend Dry::Initializer
|
132
|
+
include Dry::Core::Constants
|
133
133
|
include Dry::Monads[:result]
|
134
134
|
include Dry::Monads::Do.for(:call_monad, :callable_monad, :validate_monad, :execute_operation)
|
135
135
|
include Dry::Equalizer(*COMPONENTS)
|
136
|
-
extend Dry::Initializer
|
137
136
|
|
138
137
|
# Provides message and meaningful sentry context for failed operations
|
139
138
|
class OperationFailed < StandardError
|
@@ -158,17 +157,14 @@ class Operations::Command
|
|
158
157
|
option :preconditions, Operations::Types::Array.of(Operations::Types.Interface(:call)), default: -> { [] }
|
159
158
|
option :on_success, Operations::Types::Array.of(Operations::Types.Interface(:call)), default: -> { [] }
|
160
159
|
option :on_failure, Operations::Types::Array.of(Operations::Types.Interface(:call)), default: -> { [] }
|
161
|
-
option :form_model_map, Operations::
|
162
|
-
|
163
|
-
|
164
|
-
),
|
165
|
-
Operations::Types::String
|
166
|
-
), default: proc { {} }
|
167
|
-
option :form_base, Operations::Types::Class, default: proc { ::Operations::Form }
|
168
|
-
option :form_class, Operations::Types::Class.optional, default: proc {}
|
160
|
+
option :form_model_map, Operations::Form::DeprecatedLegacyModelMapImplementation::TYPE, default: proc { {} }
|
161
|
+
option :form_base, Operations::Types::Class, default: proc { ::Operations::Form::Base }
|
162
|
+
option :form_class, Operations::Types::Class.optional, default: proc {}, reader: false
|
169
163
|
option :form_hydrator, Operations::Types.Interface(:call), default: proc { FORM_HYDRATOR }
|
170
164
|
option :configuration, Operations::Configuration, default: proc { Operations.default_config }
|
171
165
|
|
166
|
+
include Operations::Inspect.new(dry_initializer.attributes(self).keys)
|
167
|
+
|
172
168
|
# A short-cut to initialize operation by convention:
|
173
169
|
#
|
174
170
|
# Namespace::OperationName - operation
|
@@ -194,16 +190,15 @@ class Operations::Command
|
|
194
190
|
end
|
195
191
|
|
196
192
|
def initialize(
|
197
|
-
operation, policy:
|
193
|
+
operation, policy: Undefined, policies: [Undefined],
|
198
194
|
precondition: nil, preconditions: [], after: [], **options
|
199
195
|
)
|
200
196
|
policies_sum = Array.wrap(policy) + policies
|
201
|
-
result_policies = policies_sum - [
|
197
|
+
result_policies = policies_sum - [Undefined] unless policies_sum == [Undefined, Undefined]
|
202
198
|
options[:policies] = result_policies if result_policies
|
203
199
|
|
204
200
|
preconditions.push(precondition) if precondition.present?
|
205
201
|
super(operation, preconditions: preconditions, on_success: after, **options)
|
206
|
-
@form_class ||= build_form_class
|
207
202
|
end
|
208
203
|
|
209
204
|
# Instantiates a new command with the given fields updated.
|
@@ -276,32 +271,21 @@ class Operations::Command
|
|
276
271
|
validate(*args, **kwargs).success?
|
277
272
|
end
|
278
273
|
|
279
|
-
def
|
280
|
-
attributes = self.class.dry_initializer.attributes(self)
|
281
|
-
|
282
|
-
pp.object_group(self) do
|
283
|
-
pp.seplist(attributes.keys, -> { pp.text "," }) do |name|
|
284
|
-
pp.breakable " "
|
285
|
-
pp.group(1) do
|
286
|
-
pp.text name.to_s
|
287
|
-
pp.text " = "
|
288
|
-
pp.pp send(name)
|
289
|
-
end
|
290
|
-
end
|
291
|
-
end
|
292
|
-
end
|
293
|
-
|
294
|
-
def as_json(*)
|
274
|
+
def to_hash
|
295
275
|
{
|
296
|
-
**
|
297
|
-
**
|
298
|
-
configuration: configuration
|
276
|
+
**main_components_to_hash,
|
277
|
+
**form_components_to_hash,
|
278
|
+
configuration: configuration
|
299
279
|
}
|
300
280
|
end
|
301
281
|
|
282
|
+
def form_class
|
283
|
+
@form_class ||= build_form_class
|
284
|
+
end
|
285
|
+
|
302
286
|
private
|
303
287
|
|
304
|
-
def
|
288
|
+
def main_components_to_hash
|
305
289
|
{
|
306
290
|
operation: operation.class.name,
|
307
291
|
contract: contract.class.name,
|
@@ -313,7 +297,7 @@ class Operations::Command
|
|
313
297
|
}
|
314
298
|
end
|
315
299
|
|
316
|
-
def
|
300
|
+
def form_components_to_hash
|
317
301
|
{
|
318
302
|
form_model_map: form_model_map,
|
319
303
|
form_base: form_base.name,
|
@@ -401,9 +385,9 @@ class Operations::Command
|
|
401
385
|
.new(base_class: form_base)
|
402
386
|
.build(
|
403
387
|
key_map: contract.class.schema.key_map,
|
388
|
+
model_map: Operations::Form::DeprecatedLegacyModelMapImplementation.new(form_model_map),
|
404
389
|
namespace: operation.class,
|
405
|
-
class_name: form_class_name
|
406
|
-
model_map: form_model_map
|
390
|
+
class_name: form_class_name
|
407
391
|
)
|
408
392
|
end
|
409
393
|
|
@@ -6,6 +6,6 @@ class Operations::Contract::MessagesResolver < Dry::Validation::Messages::Resolv
|
|
6
6
|
def call(message:, meta: Dry::Schema::EMPTY_HASH, **rest)
|
7
7
|
meta = meta.merge(code: message) if message.is_a?(Symbol)
|
8
8
|
|
9
|
-
super
|
9
|
+
super
|
10
10
|
end
|
11
11
|
end
|
@@ -5,14 +5,15 @@
|
|
5
5
|
# legacy UI.
|
6
6
|
class Operations::Form::Attribute
|
7
7
|
extend Dry::Initializer
|
8
|
-
include Dry::Equalizer(:name, :collection, :
|
8
|
+
include Dry::Equalizer(:name, :collection, :model_name, :form)
|
9
|
+
include Operations::Inspect.new(:name, :collection, :model_name, :form)
|
9
10
|
|
10
11
|
param :name, type: Operations::Types::Coercible::Symbol
|
11
|
-
option :collection, type: Operations::Types::Bool,
|
12
|
-
option :form, type: Operations::Types::Class, optional: true
|
12
|
+
option :collection, type: Operations::Types::Bool, default: proc { false }
|
13
13
|
option :model_name,
|
14
|
-
type: Operations::Types::String | Operations::Types.Instance(Class).constrained(lt: ActiveRecord::Base),
|
15
|
-
|
14
|
+
type: (Operations::Types::String | Operations::Types.Instance(Class).constrained(lt: ActiveRecord::Base)).optional,
|
15
|
+
default: proc {}
|
16
|
+
option :form, type: Operations::Types::Class.optional, default: proc {}
|
16
17
|
|
17
18
|
def model_type
|
18
19
|
@model_type ||= owning_model.type_for_attribute(string_name) if model_name
|
@@ -0,0 +1,264 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This class implements Rails form object compatibility layer
|
4
|
+
# It is possible to configure form object attributes automatically
|
5
|
+
# basing on Dry Schema user {Operations::Form::Builder}
|
6
|
+
# @example
|
7
|
+
#
|
8
|
+
# class AuthorForm < Operations::Form::Base
|
9
|
+
# attribute :name
|
10
|
+
# end
|
11
|
+
#
|
12
|
+
# class PostForm < Operations::Form::Base
|
13
|
+
# attribute :title
|
14
|
+
# attribute :tags, collection: true
|
15
|
+
# attribute :author, form: AuthorForm
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
# PostForm.new({ tags: ["foobar"], author: { name: "Batman" } })
|
19
|
+
# # => #<PostForm attributes={:title=>nil, :tags=>["foobar"], :author=>#<AuthorForm attributes={:name=>"Batman"}>}>
|
20
|
+
#
|
21
|
+
# @see Operations::Form::Builder
|
22
|
+
class Operations::Form::Base
|
23
|
+
BUILD_ASSOCIATION_PREFIX = "build_"
|
24
|
+
NESTED_ATTRIBUTES_SUFFIX = "_attributes="
|
25
|
+
|
26
|
+
# :nodoc:
|
27
|
+
module ClassMethods
|
28
|
+
def self.extended(base)
|
29
|
+
base.singleton_class.include Operations::Inspect.new(:attributes)
|
30
|
+
|
31
|
+
base.extend Dry::Initializer
|
32
|
+
base.include Dry::Equalizer(:attributes, :errors)
|
33
|
+
base.include Operations::Inspect.new(:attributes, :errors)
|
34
|
+
|
35
|
+
base.param :data,
|
36
|
+
type: Operations::Types::Hash.map(Operations::Types::Symbol, Operations::Types::Any),
|
37
|
+
default: proc { {} },
|
38
|
+
reader: :private
|
39
|
+
base.option :messages,
|
40
|
+
type: Operations::Types::Hash.map(
|
41
|
+
Operations::Types::Nil | Operations::Types::Coercible::Symbol,
|
42
|
+
Operations::Types::Any
|
43
|
+
),
|
44
|
+
default: proc { {} },
|
45
|
+
reader: :private
|
46
|
+
base.option :operation_result, default: proc {}
|
47
|
+
|
48
|
+
base.class_attribute :attributes, instance_accessor: false, default: {}
|
49
|
+
base.class_attribute :primary_key, instance_accessor: false, default: :id
|
50
|
+
|
51
|
+
base.define_method :initialize do |*args, **kwargs|
|
52
|
+
args.empty? && kwargs.present? ? super(kwargs, **{}) : super(*args, **kwargs)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def attribute(name, **options)
|
57
|
+
attribute = Operations::Form::Attribute.new(name, **options)
|
58
|
+
|
59
|
+
self.attributes = attributes.merge(
|
60
|
+
attribute.name => attribute
|
61
|
+
)
|
62
|
+
end
|
63
|
+
|
64
|
+
def human_attribute_name(name, options = {})
|
65
|
+
if attributes[name.to_sym]
|
66
|
+
attributes[name.to_sym].model_human_name(options)
|
67
|
+
else
|
68
|
+
name.to_s.humanize
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def validators_on(name)
|
73
|
+
attributes[name.to_sym]&.model_validators || []
|
74
|
+
end
|
75
|
+
|
76
|
+
def model_name
|
77
|
+
@model_name ||= ActiveModel::Name.new(self)
|
78
|
+
end
|
79
|
+
|
80
|
+
def reflect_on_association(...); end
|
81
|
+
end
|
82
|
+
|
83
|
+
# :nodoc:
|
84
|
+
module InstanceMethods
|
85
|
+
def type_for_attribute(name)
|
86
|
+
self.class.attributes[name.to_sym].model_type
|
87
|
+
end
|
88
|
+
|
89
|
+
def localized_attr_name_for(name, locale)
|
90
|
+
self.class.attributes[name.to_sym].model_localized_attr_name(locale)
|
91
|
+
end
|
92
|
+
|
93
|
+
def has_attribute?(name) # rubocop:disable Naming/PredicateName
|
94
|
+
self.class.attributes.key?(name.to_sym)
|
95
|
+
end
|
96
|
+
|
97
|
+
def attributes
|
98
|
+
self.class.attributes.keys.to_h do |name|
|
99
|
+
[name, read_attribute(name)]
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def assigned_attributes
|
104
|
+
(self.class.attributes.keys & data.keys).to_h do |name|
|
105
|
+
[name, read_attribute(name)]
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
# For now we gracefully return nil for unknown methods
|
110
|
+
def method_missing(name, *args, **kwargs)
|
111
|
+
build_attribute_name = build_attribute_name(name)
|
112
|
+
build_attribute = self.class.attributes[build_attribute_name]
|
113
|
+
plural_build_attribute = self.class.attributes[build_attribute_name.to_s.pluralize.to_sym]
|
114
|
+
|
115
|
+
if has_attribute?(name)
|
116
|
+
read_attribute(name)
|
117
|
+
elsif build_attribute&.form
|
118
|
+
build_attribute.form.new(*args, **kwargs)
|
119
|
+
elsif plural_build_attribute&.form
|
120
|
+
plural_build_attribute.form.new(*args, **kwargs)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def respond_to_missing?(name, *)
|
125
|
+
has_attribute?(name) ||
|
126
|
+
build_nested_form?(build_attribute_name(name)) ||
|
127
|
+
self.class.attributes[nested_attribute_name(name)]&.form
|
128
|
+
end
|
129
|
+
|
130
|
+
def model_name
|
131
|
+
self.class.model_name
|
132
|
+
end
|
133
|
+
|
134
|
+
def persisted?
|
135
|
+
!has_attribute?(self.class.primary_key) || read_attribute(self.class.primary_key).present?
|
136
|
+
end
|
137
|
+
|
138
|
+
def new_record?
|
139
|
+
!persisted?
|
140
|
+
end
|
141
|
+
|
142
|
+
def _destroy
|
143
|
+
Operations::Types::Params::Bool.call(read_attribute(:_destroy)) { false }
|
144
|
+
end
|
145
|
+
alias_method :marked_for_destruction?, :_destroy
|
146
|
+
|
147
|
+
# Probably can be always nil, it is used in automated URL derival.
|
148
|
+
# We can make it work later but it will require additional concepts.
|
149
|
+
def to_key
|
150
|
+
nil
|
151
|
+
end
|
152
|
+
|
153
|
+
def errors
|
154
|
+
@errors ||= ActiveModel::Errors.new(self).tap do |errors|
|
155
|
+
self.class.attributes.each do |name, attribute|
|
156
|
+
add_messages(errors, name, messages[name])
|
157
|
+
add_messages_to_collection(errors, name, messages[name]) if attribute.collection
|
158
|
+
end
|
159
|
+
|
160
|
+
add_messages(errors, :base, messages[nil])
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def valid?
|
165
|
+
errors.empty?
|
166
|
+
end
|
167
|
+
|
168
|
+
def read_attribute(name)
|
169
|
+
cached_attribute(name) do |value, attribute|
|
170
|
+
if attribute.collection && attribute.form
|
171
|
+
wrap_collection([name], value, attribute.form)
|
172
|
+
elsif attribute.form
|
173
|
+
wrap_object([name], value, attribute.form)
|
174
|
+
elsif attribute.collection
|
175
|
+
value.nil? ? [] : value
|
176
|
+
else
|
177
|
+
value
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
alias_method :read_attribute_for_validation, :read_attribute
|
182
|
+
|
183
|
+
def to_hash
|
184
|
+
{
|
185
|
+
attributes: attributes,
|
186
|
+
errors: errors
|
187
|
+
}
|
188
|
+
end
|
189
|
+
|
190
|
+
private
|
191
|
+
|
192
|
+
def add_messages(errors, key, messages)
|
193
|
+
return unless messages.is_a?(Array)
|
194
|
+
|
195
|
+
messages.each do |message|
|
196
|
+
message = message[:text] if message.is_a?(Hash) && message.key?(:text)
|
197
|
+
errors.add(key, message)
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
def add_messages_to_collection(errors, key, messages)
|
202
|
+
return unless messages.is_a?(Hash)
|
203
|
+
|
204
|
+
read_attribute(key).size.times do |i|
|
205
|
+
add_messages(errors, "#{key}[#{i}]", messages[i])
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
def cached_attribute(name)
|
210
|
+
name = name.to_sym
|
211
|
+
return unless self.class.attributes.key?(name)
|
212
|
+
|
213
|
+
nested_name = :"#{name}_attributes"
|
214
|
+
value = data.key?(nested_name) ? data[nested_name] : data[name]
|
215
|
+
|
216
|
+
(@attributes_cache ||= {})[name] ||= yield(value, self.class.attributes[name])
|
217
|
+
end
|
218
|
+
|
219
|
+
def wrap_collection(path, collection, form)
|
220
|
+
collection = [] if collection.nil?
|
221
|
+
|
222
|
+
case collection
|
223
|
+
when Hash
|
224
|
+
collection.values.map.with_index do |data, i|
|
225
|
+
wrap_object(path + [i], data, form)
|
226
|
+
end
|
227
|
+
when Array
|
228
|
+
collection.map.with_index do |data, i|
|
229
|
+
wrap_object(path + [i], data, form)
|
230
|
+
end
|
231
|
+
else
|
232
|
+
collection
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
def wrap_object(path, data, form)
|
237
|
+
data = {} if data.nil?
|
238
|
+
|
239
|
+
if data.is_a?(Hash)
|
240
|
+
nested_messages = messages.dig(*path)
|
241
|
+
nested_messages = {} unless nested_messages.is_a?(Hash)
|
242
|
+
form.new(data, messages: nested_messages)
|
243
|
+
else
|
244
|
+
data
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
def build_attribute_name(name)
|
249
|
+
name.to_s.delete_prefix(BUILD_ASSOCIATION_PREFIX).to_sym if name.to_s.start_with?(BUILD_ASSOCIATION_PREFIX)
|
250
|
+
end
|
251
|
+
|
252
|
+
def nested_attribute_name(name)
|
253
|
+
name.to_s.delete_suffix(NESTED_ATTRIBUTES_SUFFIX).to_sym if name.to_s.end_with?(NESTED_ATTRIBUTES_SUFFIX)
|
254
|
+
end
|
255
|
+
|
256
|
+
def build_nested_form?(name)
|
257
|
+
!!(self.class.attributes[name]&.form ||
|
258
|
+
self.class.attributes[name.to_s.pluralize.to_sym]&.form)
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
extend ClassMethods
|
263
|
+
include InstanceMethods
|
264
|
+
end
|
@@ -1,9 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
# Traverses the passed {Dry::Schema::KeyMap} and generates
|
4
|
-
# {Operations::Form} classes on the fly. Handles nested structures.
|
4
|
+
# {Operations::Form::Base} classes on the fly. Handles nested structures.
|
5
5
|
#
|
6
|
-
# @see Operations::Form
|
6
|
+
# @see Operations::Form::Base
|
7
7
|
class Operations::Form::Builder
|
8
8
|
extend Dry::Initializer
|
9
9
|
|
@@ -11,38 +11,49 @@ class Operations::Form::Builder
|
|
11
11
|
|
12
12
|
option :base_class, Operations::Types::Instance(Class)
|
13
13
|
|
14
|
-
def build(key_map:,
|
14
|
+
def build(key_map:, model_map:, namespace: nil, class_name: nil, model_name: nil)
|
15
15
|
return namespace.const_get(class_name) if namespace && class_name && namespace.const_defined?(class_name)
|
16
16
|
|
17
|
-
traverse(key_map, namespace, class_name,
|
17
|
+
traverse(key_map, model_map, namespace, class_name, model_name, [])
|
18
18
|
end
|
19
19
|
|
20
20
|
private
|
21
21
|
|
22
|
-
def traverse(key_map, namespace, class_name,
|
22
|
+
def traverse(key_map, model_map, namespace, class_name, model_name, path)
|
23
23
|
form = Class.new(base_class)
|
24
|
-
namespace.const_set(class_name, form) if namespace && class_name
|
24
|
+
namespace.const_set(class_name, form) if namespace&.name && class_name
|
25
|
+
define_model_name(form, model_name) if model_name && !form.name
|
25
26
|
|
26
|
-
key_map.each
|
27
|
-
|
27
|
+
key_map.each { |key| define_attribute(form, model_map, key, path) }
|
28
|
+
form
|
29
|
+
end
|
28
30
|
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
form.attribute(key.name, form: nested_form, collection: true, **model_name(key_path, model_map))
|
33
|
-
when Dry::Schema::Key::Hash
|
34
|
-
traverse_hash(form, key, model_map, path)
|
35
|
-
when Dry::Schema::Key
|
36
|
-
form.attribute(key.name, **model_name(key_path, model_map))
|
37
|
-
else
|
38
|
-
raise "Unknown key_map key: #{key.class}"
|
39
|
-
end
|
31
|
+
def define_model_name(form, model_name)
|
32
|
+
form.define_singleton_method :model_name do
|
33
|
+
@model_name ||= ActiveModel::Name.new(self, nil, model_name)
|
40
34
|
end
|
35
|
+
end
|
41
36
|
|
42
|
-
|
37
|
+
def define_attribute(form, model_map, key, path)
|
38
|
+
case key
|
39
|
+
when Dry::Schema::Key::Array
|
40
|
+
traverse_array(form, model_map, key, path)
|
41
|
+
when Dry::Schema::Key::Hash
|
42
|
+
traverse_hash(form, model_map, key, path)
|
43
|
+
when Dry::Schema::Key
|
44
|
+
form.attribute(key.name, model_name: model_map&.call(path + [key.name]))
|
45
|
+
else
|
46
|
+
raise "Unknown key_map key: #{key.class}"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def traverse_array(form, model_map, key, path)
|
51
|
+
key_path = path + [key.name]
|
52
|
+
nested_form = traverse(key.member, model_map, form, key.name.to_s.underscore.classify, key.name.to_s, key_path)
|
53
|
+
form.attribute(key.name, form: nested_form, collection: true, model_name: model_map&.call(key_path))
|
43
54
|
end
|
44
55
|
|
45
|
-
def traverse_hash(form,
|
56
|
+
def traverse_hash(form, model_map, hash_key, path)
|
46
57
|
nested_attributes_suffix = hash_key.name.match?(NESTED_ATTRIBUTES_SUFFIX)
|
47
58
|
nested_attributes_collection = hash_key.members.all?(Dry::Schema::Key::Hash) &&
|
48
59
|
hash_key.members.map(&:members).uniq.size == 1
|
@@ -55,8 +66,8 @@ class Operations::Form::Builder
|
|
55
66
|
form.define_method :"#{hash_key.name}=", proc { |attributes| attributes } if nested_attributes_suffix
|
56
67
|
|
57
68
|
key_path = path + [name]
|
58
|
-
nested_form = traverse(members, form, name.underscore.camelize,
|
59
|
-
form.attribute(name, form: nested_form, collection: collection,
|
69
|
+
nested_form = traverse(members, model_map, form, name.underscore.camelize, name.to_s.singularize, key_path)
|
70
|
+
form.attribute(name, form: nested_form, collection: collection, model_name: model_map&.call(key_path))
|
60
71
|
end
|
61
72
|
|
62
73
|
def specify_form_attributes(hash_key, nested_attributes_suffix, nested_attributes_collection)
|
@@ -68,18 +79,4 @@ class Operations::Form::Builder
|
|
68
79
|
[hash_key.name, hash_key.members, false]
|
69
80
|
end
|
70
81
|
end
|
71
|
-
|
72
|
-
def model_name(path, model_map)
|
73
|
-
_, model_name = model_map.find do |pathspec, _model|
|
74
|
-
path.size == pathspec.size && path.zip(pathspec).all? do |slug, pattern|
|
75
|
-
pattern.is_a?(Regexp) ? pattern.match?(slug) : slug == pattern
|
76
|
-
end
|
77
|
-
end
|
78
|
-
|
79
|
-
if model_name
|
80
|
-
{ model_name: model_name }
|
81
|
-
else
|
82
|
-
{}
|
83
|
-
end
|
84
|
-
end
|
85
82
|
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Traverses the passed {Dry::Schema::KeyMap} and generates
|
4
|
+
# {Operations::Form::Base} classes on the fly. Handles nested structures.
|
5
|
+
#
|
6
|
+
# This class will be removed along with Operations::Command#form_model_map
|
7
|
+
# option removal along with the rest of form-related stuff.
|
8
|
+
#
|
9
|
+
# Don't use this class in you code, instead, copy it and modify to you liking.
|
10
|
+
#
|
11
|
+
# @see Operations::Form::Base
|
12
|
+
class Operations::Form::DeprecatedLegacyModelMapImplementation
|
13
|
+
extend Dry::Initializer
|
14
|
+
|
15
|
+
TYPE = Operations::Types::Hash.map(
|
16
|
+
Operations::Types::Coercible::Array.of(
|
17
|
+
Operations::Types::String | Operations::Types::Symbol | Operations::Types.Instance(Regexp)
|
18
|
+
),
|
19
|
+
Operations::Types::String
|
20
|
+
)
|
21
|
+
|
22
|
+
param :model_map_hash, TYPE, default: proc { {} }
|
23
|
+
|
24
|
+
def call(path)
|
25
|
+
model_map_hash.find do |pathspec, _model|
|
26
|
+
path.size == pathspec.size && path.zip(pathspec).all? do |slug, pattern|
|
27
|
+
pattern.is_a?(Regexp) ? pattern.match?(slug) : slug == pattern
|
28
|
+
end
|
29
|
+
end&.second
|
30
|
+
end
|
31
|
+
end
|
data/lib/operations/form.rb
CHANGED
@@ -1,194 +1,96 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
#
|
4
|
-
#
|
5
|
-
#
|
3
|
+
# Configures and defines a form object factory.
|
4
|
+
# Forms can be defined on top of commants and used in the user-facing controllers.
|
5
|
+
# Form objects are Rails-specific and support everything that is needed for Rails'
|
6
|
+
# form rendering helpers. They are designed to replace direct usage of ActiveRecord
|
7
|
+
# models in controllers and views and act as an integration bridge from Rails
|
8
|
+
# application to the Operations framework.
|
9
|
+
#
|
6
10
|
# @example
|
7
11
|
#
|
8
|
-
#
|
9
|
-
#
|
10
|
-
#
|
12
|
+
# command = Operations::Command.new(...)
|
13
|
+
# form = Operations::Form.new(command)
|
14
|
+
#
|
15
|
+
# @form_object = form.build(params)
|
11
16
|
#
|
12
|
-
#
|
13
|
-
# attribute :title
|
14
|
-
# attribute :tags, collection: true
|
15
|
-
# attribute :author, form: AuthorForm
|
16
|
-
# end
|
17
|
+
# form_for @form_object, url: ...
|
17
18
|
#
|
18
|
-
#
|
19
|
-
#
|
19
|
+
# @form_object = form.persist(params)
|
20
|
+
# respond_with @form_object
|
20
21
|
#
|
21
|
-
# @see Operations::Form::Builder
|
22
22
|
class Operations::Form
|
23
|
-
|
24
|
-
include Dry::Equalizer(:
|
25
|
-
|
26
|
-
param :data,
|
27
|
-
type: Operations::Types::Hash.map(Operations::Types::Symbol, Operations::Types::Any),
|
28
|
-
default: proc { {} },
|
29
|
-
reader: :private
|
30
|
-
option :messages,
|
31
|
-
type: Operations::Types::Hash.map(
|
32
|
-
Operations::Types::Nil | Operations::Types::Coercible::Symbol,
|
33
|
-
Operations::Types::Any
|
34
|
-
),
|
35
|
-
default: proc { {} },
|
36
|
-
reader: :private
|
37
|
-
|
38
|
-
class_attribute :attributes, instance_accessor: false, default: {}
|
39
|
-
|
40
|
-
def self.attribute(name, **options)
|
41
|
-
attribute = Operations::Form::Attribute.new(name, **options)
|
42
|
-
|
43
|
-
self.attributes = attributes.merge(
|
44
|
-
attribute.name => attribute
|
45
|
-
)
|
46
|
-
end
|
47
|
-
|
48
|
-
def self.human_attribute_name(name, options = {})
|
49
|
-
if attributes[name.to_sym]
|
50
|
-
attributes[name.to_sym].model_human_name(options)
|
51
|
-
else
|
52
|
-
name.to_s.humanize
|
53
|
-
end
|
54
|
-
end
|
23
|
+
include Dry::Core::Constants
|
24
|
+
include Dry::Equalizer(:command, :model_map, :params_transformations, :hydrator, :form_class)
|
25
|
+
include Operations::Inspect.new(:model_name, :model_map, :params_transformations, :hydrator, :form_class)
|
55
26
|
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
def
|
61
|
-
|
62
|
-
end
|
27
|
+
# We need to make deprecated inheritance from Operations::Form act exactly the
|
28
|
+
# same way as from Operations::Form::Base. In order to do this, we are encapsulating all the
|
29
|
+
# inheritable functionality in 2 modules and removing methods defined in Operations::Form
|
30
|
+
# from the result class.
|
31
|
+
def self.inherited(subclass)
|
32
|
+
super
|
63
33
|
|
64
|
-
|
65
|
-
self.class.attributes[name.to_sym].model_localized_attr_name(locale)
|
66
|
-
end
|
34
|
+
return unless self == Operations::Form
|
67
35
|
|
68
|
-
|
69
|
-
|
70
|
-
end
|
36
|
+
ActiveSupport::Deprecation.new.warn("Inheritance from Operations::Form is deprecated and will be " \
|
37
|
+
"removed in 1.0.0. Please inherit from Operations::Form::Base instead")
|
71
38
|
|
72
|
-
|
73
|
-
|
74
|
-
[name, read_attribute(name)]
|
39
|
+
(Operations::Form.instance_methods - Object.instance_methods).each do |method|
|
40
|
+
subclass.undef_method(method)
|
75
41
|
end
|
76
|
-
end
|
77
|
-
|
78
|
-
def assigned_attributes
|
79
|
-
(self.class.attributes.keys & data.keys).to_h do |name|
|
80
|
-
[name, read_attribute(name)]
|
81
|
-
end
|
82
|
-
end
|
83
|
-
|
84
|
-
def method_missing(name, *)
|
85
|
-
read_attribute(name)
|
86
|
-
end
|
87
42
|
|
88
|
-
|
89
|
-
|
43
|
+
subclass.extend Operations::Form::Base::ClassMethods
|
44
|
+
subclass.prepend Operations::Form::Base::InstanceMethods
|
90
45
|
end
|
91
46
|
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
end
|
47
|
+
include Dry::Initializer.define(lambda do
|
48
|
+
param :command, type: Operations::Types.Interface(:operation, :contract, :call)
|
49
|
+
option :model_name, type: Operations::Types::String.optional, default: proc {}, reader: false
|
50
|
+
option :model_map, type: Operations::Types.Interface(:call).optional, default: proc {}
|
51
|
+
option :params_transformations, type: Operations::Types::Coercible::Array.of(Operations::Types.Interface(:call)),
|
52
|
+
default: proc { [] }
|
53
|
+
option :hydrator, type: Operations::Types.Interface(:call).optional, default: proc {}
|
54
|
+
option :base_class, type: Operations::Types::Class, default: proc { ::Operations::Form::Base }
|
55
|
+
end)
|
101
56
|
|
102
|
-
|
103
|
-
|
104
|
-
def to_key
|
105
|
-
nil
|
57
|
+
def build(params = EMPTY_HASH, **context)
|
58
|
+
instantiate_form(command.callable(transform_params(params, **context), **context))
|
106
59
|
end
|
107
60
|
|
108
|
-
def
|
109
|
-
|
110
|
-
self.class.attributes.each do |name, attribute|
|
111
|
-
add_messages(errors, name, messages[name])
|
112
|
-
add_messages_to_collection(errors, name, messages[name]) if attribute.collection
|
113
|
-
end
|
114
|
-
|
115
|
-
add_messages(errors, :base, messages[nil])
|
116
|
-
end
|
61
|
+
def persist(params = EMPTY_HASH, **context)
|
62
|
+
instantiate_form(command.call(transform_params(params, **context), **context))
|
117
63
|
end
|
118
64
|
|
119
|
-
def
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
def read_attribute(name)
|
124
|
-
cached_attribute(name) do |value, attribute|
|
125
|
-
if attribute.collection && attribute.form
|
126
|
-
wrap_collection([name], value, attribute.form)
|
127
|
-
elsif attribute.form
|
128
|
-
wrap_object([name], value, attribute.form)
|
129
|
-
elsif attribute.collection
|
130
|
-
value.nil? ? [] : value
|
131
|
-
else
|
132
|
-
value
|
133
|
-
end
|
134
|
-
end
|
65
|
+
def form_class
|
66
|
+
@form_class ||= Operations::Form::Builder.new(base_class: base_class)
|
67
|
+
.build(key_map: key_map, model_map: model_map, model_name: model_name)
|
135
68
|
end
|
136
69
|
|
137
70
|
private
|
138
71
|
|
139
|
-
def
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
end
|
146
|
-
end
|
147
|
-
|
148
|
-
def add_messages_to_collection(errors, key, messages)
|
149
|
-
return unless messages.is_a?(Hash)
|
150
|
-
|
151
|
-
read_attribute(key).size.times do |i|
|
152
|
-
add_messages(errors, "#{key}[#{i}]", messages[i])
|
72
|
+
def transform_params(params, **context)
|
73
|
+
params = params.to_unsafe_hash if params.respond_to?(:to_unsafe_hash)
|
74
|
+
params = params.deep_symbolize_keys
|
75
|
+
params = params.merge(params[form_class.model_name.param_key.to_sym] || {})
|
76
|
+
params_transformations.inject(params) do |value, transformation|
|
77
|
+
transformation.call(form_class, value, **context)
|
153
78
|
end
|
154
79
|
end
|
155
80
|
|
156
|
-
def
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
(@attributes_cache ||= {})[name] ||= yield(value, self.class.attributes[name])
|
81
|
+
def instantiate_form(operation_result)
|
82
|
+
form_class.new(
|
83
|
+
hydrator&.call(form_class, operation_result.params, **operation_result.context) || {},
|
84
|
+
messages: operation_result.errors.to_h,
|
85
|
+
operation_result: operation_result
|
86
|
+
)
|
164
87
|
end
|
165
88
|
|
166
|
-
def
|
167
|
-
|
168
|
-
|
169
|
-
case collection
|
170
|
-
when Hash
|
171
|
-
collection.values.map.with_index do |data, i|
|
172
|
-
wrap_object(path + [i], data, form)
|
173
|
-
end
|
174
|
-
when Array
|
175
|
-
collection.map.with_index do |data, i|
|
176
|
-
wrap_object(path + [i], data, form)
|
177
|
-
end
|
178
|
-
else
|
179
|
-
collection
|
180
|
-
end
|
89
|
+
def key_map
|
90
|
+
@key_map ||= command.contract.schema.key_map
|
181
91
|
end
|
182
92
|
|
183
|
-
def
|
184
|
-
|
185
|
-
|
186
|
-
if data.is_a?(Hash)
|
187
|
-
nested_messages = messages.dig(*path)
|
188
|
-
nested_messages = {} unless nested_messages.is_a?(Hash)
|
189
|
-
form.new(data, messages: nested_messages)
|
190
|
-
else
|
191
|
-
data
|
192
|
-
end
|
93
|
+
def model_name
|
94
|
+
@model_name ||= ("#{command.operation.class.name.underscore}_form" if command.operation.class.name)
|
193
95
|
end
|
194
96
|
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Configures inspect/pretty_print methods on object.
|
4
|
+
class Operations::Inspect < Module
|
5
|
+
extend Dry::Initializer
|
6
|
+
|
7
|
+
param :attributes, Operations::Types::Coercible::Array.of(Operations::Types::Symbol), reader: false
|
8
|
+
param :value_methods, Operations::Types::Hash.map(Operations::Types::Symbol, Operations::Types::Symbol)
|
9
|
+
|
10
|
+
def initialize(*attributes, **kwargs)
|
11
|
+
super(attributes.flatten(1), kwargs)
|
12
|
+
|
13
|
+
define_pretty_print(@attributes, @value_methods)
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def define_pretty_print(attributes, value_methods)
|
19
|
+
define_method(:pretty_print) do |pp|
|
20
|
+
object_group_method = self.class.name ? :object_group : :object_address_group
|
21
|
+
pp.public_send(object_group_method, self) do
|
22
|
+
pp.seplist(attributes, -> { pp.text "," }) do |name|
|
23
|
+
pp.breakable " "
|
24
|
+
pp.group(1) do
|
25
|
+
pp.text name.to_s
|
26
|
+
pp.text "="
|
27
|
+
pp.pp __send__(value_methods[name] || name)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
data/lib/operations/result.rb
CHANGED
@@ -6,9 +6,9 @@
|
|
6
6
|
# execution).
|
7
7
|
# Also able to spawn a form object basing on the operation params and errors.
|
8
8
|
class Operations::Result
|
9
|
+
extend Dry::Initializer
|
9
10
|
include Dry::Monads[:result]
|
10
11
|
include Dry::Equalizer(:operation, :component, :params, :context, :on_success, :errors)
|
11
|
-
extend Dry::Initializer
|
12
12
|
|
13
13
|
option :operation, type: Operations::Types::Instance(Operations::Command), optional: true
|
14
14
|
option :component, type: Operations::Types::Symbol.enum(*Operations::Command::COMPONENTS)
|
@@ -19,6 +19,8 @@ class Operations::Result
|
|
19
19
|
option :errors, type: Operations::Types.Interface(:call) | Operations::Types::Instance(Dry::Validation::MessageSet),
|
20
20
|
default: proc { Dry::Validation::MessageSet.new([]).freeze }
|
21
21
|
|
22
|
+
include Operations::Inspect.new(dry_initializer.attributes(self).keys)
|
23
|
+
|
22
24
|
# Instantiates a new result with the given fields updated
|
23
25
|
def merge(**changes)
|
24
26
|
self.class.new(**self.class.dry_initializer.attributes(self), **changes)
|
@@ -77,31 +79,20 @@ class Operations::Result
|
|
77
79
|
)
|
78
80
|
end
|
79
81
|
|
80
|
-
def
|
81
|
-
|
82
|
-
|
83
|
-
pp.object_group(self) do
|
84
|
-
pp.seplist(attributes.keys, -> { pp.text "," }) do |name|
|
85
|
-
pp.breakable " "
|
86
|
-
pp.group(1) do
|
87
|
-
pp.text name.to_s
|
88
|
-
pp.text " = "
|
89
|
-
pp.pp send(name)
|
90
|
-
end
|
91
|
-
end
|
92
|
-
end
|
82
|
+
def as_json(options = {})
|
83
|
+
to_hash(**options.slice(:include_command))
|
93
84
|
end
|
94
85
|
|
95
|
-
def
|
86
|
+
def to_hash(include_command: false)
|
96
87
|
hash = {
|
97
88
|
component: component,
|
98
89
|
params: params,
|
99
|
-
context:
|
90
|
+
context: context_to_hash,
|
100
91
|
on_success: on_success.as_json,
|
101
92
|
on_failure: on_failure.as_json,
|
102
93
|
errors: errors(full: true).to_h
|
103
94
|
}
|
104
|
-
hash[:command] = operation
|
95
|
+
hash[:command] = operation&.to_hash if include_command
|
105
96
|
hash
|
106
97
|
end
|
107
98
|
|
@@ -112,7 +103,7 @@ class Operations::Result
|
|
112
103
|
(errors.map { |error| error.meta[:code] } & names).present?
|
113
104
|
end
|
114
105
|
|
115
|
-
def
|
106
|
+
def context_to_hash
|
116
107
|
context.transform_values do |context_value|
|
117
108
|
next context_value.class.name unless context_value.respond_to?(:id)
|
118
109
|
|
data/lib/operations/version.rb
CHANGED
data/lib/operations.rb
CHANGED
@@ -11,15 +11,18 @@ require "active_model/naming"
|
|
11
11
|
require "after_commit_everywhere"
|
12
12
|
require "operations/version"
|
13
13
|
require "operations/types"
|
14
|
+
require "operations/inspect"
|
14
15
|
require "operations/configuration"
|
15
16
|
require "operations/contract"
|
16
17
|
require "operations/contract/messages_resolver"
|
17
18
|
require "operations/convenience"
|
18
|
-
require "operations/command"
|
19
|
-
require "operations/result"
|
20
19
|
require "operations/form"
|
20
|
+
require "operations/form/base"
|
21
21
|
require "operations/form/attribute"
|
22
22
|
require "operations/form/builder"
|
23
|
+
require "operations/form/deprecated_legacy_model_map_implementation"
|
24
|
+
require "operations/command"
|
25
|
+
require "operations/result"
|
23
26
|
|
24
27
|
# The root gem module
|
25
28
|
module Operations
|
data/operations.gemspec
CHANGED
@@ -29,14 +29,14 @@ Gem::Specification.new do |spec|
|
|
29
29
|
|
30
30
|
spec.add_development_dependency "appraisal"
|
31
31
|
spec.add_development_dependency "database_cleaner-active_record"
|
32
|
-
spec.add_development_dependency "sqlite3"
|
32
|
+
spec.add_development_dependency "sqlite3", "~> 1.4"
|
33
33
|
|
34
|
-
spec.
|
35
|
-
spec.
|
36
|
-
spec.
|
37
|
-
spec.
|
38
|
-
spec.
|
39
|
-
spec.
|
34
|
+
spec.add_dependency "activerecord", ">= 5.2.0"
|
35
|
+
spec.add_dependency "activesupport", ">= 5.2.0"
|
36
|
+
spec.add_dependency "after_commit_everywhere"
|
37
|
+
spec.add_dependency "dry-monads"
|
38
|
+
spec.add_dependency "dry-struct"
|
39
|
+
spec.add_dependency "dry-validation"
|
40
40
|
spec.metadata = {
|
41
41
|
"rubygems_mfa_required" => "true"
|
42
42
|
}
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: operations
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.7.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Arkadiy Zabazhanov
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2024-07-28 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: appraisal
|
@@ -42,16 +42,16 @@ dependencies:
|
|
42
42
|
name: sqlite3
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
44
44
|
requirements:
|
45
|
-
- - "
|
45
|
+
- - "~>"
|
46
46
|
- !ruby/object:Gem::Version
|
47
|
-
version: '
|
47
|
+
version: '1.4'
|
48
48
|
type: :development
|
49
49
|
prerelease: false
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
51
51
|
requirements:
|
52
|
-
- - "
|
52
|
+
- - "~>"
|
53
53
|
- !ruby/object:Gem::Version
|
54
|
-
version: '
|
54
|
+
version: '1.4'
|
55
55
|
- !ruby/object:Gem::Dependency
|
56
56
|
name: activerecord
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
@@ -180,7 +180,10 @@ files:
|
|
180
180
|
- lib/operations/convenience.rb
|
181
181
|
- lib/operations/form.rb
|
182
182
|
- lib/operations/form/attribute.rb
|
183
|
+
- lib/operations/form/base.rb
|
183
184
|
- lib/operations/form/builder.rb
|
185
|
+
- lib/operations/form/deprecated_legacy_model_map_implementation.rb
|
186
|
+
- lib/operations/inspect.rb
|
184
187
|
- lib/operations/result.rb
|
185
188
|
- lib/operations/test_helpers.rb
|
186
189
|
- lib/operations/types.rb
|
@@ -206,7 +209,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
206
209
|
- !ruby/object:Gem::Version
|
207
210
|
version: '0'
|
208
211
|
requirements: []
|
209
|
-
rubygems_version: 3.4.
|
212
|
+
rubygems_version: 3.4.19
|
210
213
|
signing_key:
|
211
214
|
specification_version: 4
|
212
215
|
summary: Operations framework
|