operations 0.0.1 → 0.6.2
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/.github/workflows/ci.yml +33 -0
- data/.gitignore +4 -0
- data/.rspec +0 -2
- data/.rubocop.yml +21 -0
- data/.rubocop_todo.yml +36 -0
- data/Appraisals +8 -0
- data/CHANGELOG.md +11 -0
- data/Gemfile +8 -2
- data/README.md +910 -5
- data/Rakefile +3 -1
- data/gemfiles/rails.5.2.gemfile +14 -0
- data/gemfiles/rails.6.0.gemfile +14 -0
- data/gemfiles/rails.6.1.gemfile +14 -0
- data/gemfiles/rails.7.0.gemfile +14 -0
- data/gemfiles/rails.7.1.gemfile +14 -0
- data/lib/operations/command.rb +412 -0
- data/lib/operations/components/base.rb +79 -0
- data/lib/operations/components/callback.rb +55 -0
- data/lib/operations/components/contract.rb +20 -0
- data/lib/operations/components/idempotency.rb +70 -0
- data/lib/operations/components/on_failure.rb +16 -0
- data/lib/operations/components/on_success.rb +35 -0
- data/lib/operations/components/operation.rb +37 -0
- data/lib/operations/components/policies.rb +42 -0
- data/lib/operations/components/prechecks.rb +38 -0
- data/lib/operations/components/preconditions.rb +45 -0
- data/lib/operations/components.rb +5 -0
- data/lib/operations/configuration.rb +15 -0
- data/lib/operations/contract/messages_resolver.rb +11 -0
- data/lib/operations/contract.rb +39 -0
- data/lib/operations/convenience.rb +102 -0
- data/lib/operations/form/attribute.rb +42 -0
- data/lib/operations/form/builder.rb +85 -0
- data/lib/operations/form.rb +194 -0
- data/lib/operations/result.rb +122 -0
- data/lib/operations/test_helpers.rb +71 -0
- data/lib/operations/types.rb +6 -0
- data/lib/operations/version.rb +3 -1
- data/lib/operations.rb +42 -2
- data/operations.gemspec +20 -4
- metadata +164 -9
- data/.travis.yml +0 -6
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "operations/components/prechecks"
|
4
|
+
|
5
|
+
# Contains logic to handle idempotency checks.
|
6
|
+
#
|
7
|
+
# Idempotency checks are used to skip operation execution in
|
8
|
+
# certain conditions.
|
9
|
+
#
|
10
|
+
# An idempotency check returns a Result monad. If it returns
|
11
|
+
# a Failure, the operation body is skipped but the operation
|
12
|
+
# is considered successful. The value or failure will be merged
|
13
|
+
# to the result context in order to enrich it (the failure should
|
14
|
+
# contain something that operation body would return normally
|
15
|
+
# to mimic a proper operation call result).
|
16
|
+
#
|
17
|
+
# Component logs the failed check with `error_reporter`.
|
18
|
+
class Operations::Components::Idempotency < Operations::Components::Prechecks
|
19
|
+
def call(params, context)
|
20
|
+
failure, failed_check = process_callables(params, context)
|
21
|
+
|
22
|
+
if failure
|
23
|
+
new_result = result(
|
24
|
+
params: params,
|
25
|
+
context: context.merge(failure.failure)
|
26
|
+
)
|
27
|
+
|
28
|
+
report_failure(new_result, failed_check)
|
29
|
+
|
30
|
+
Failure(new_result)
|
31
|
+
else
|
32
|
+
Success(result(
|
33
|
+
params: params,
|
34
|
+
context: context
|
35
|
+
))
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def process_callables(params, context)
|
42
|
+
failed_check = nil
|
43
|
+
failure = nil
|
44
|
+
|
45
|
+
callable.each do |entry|
|
46
|
+
result = entry.call(params, **context)
|
47
|
+
|
48
|
+
case result
|
49
|
+
when Failure
|
50
|
+
failed_check = entry
|
51
|
+
failure = result
|
52
|
+
break
|
53
|
+
when Success
|
54
|
+
next
|
55
|
+
else
|
56
|
+
raise "Unrecognized result of an idempotency check. Expected Result monad, got #{result.class}"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
[failure, failed_check]
|
61
|
+
end
|
62
|
+
|
63
|
+
def report_failure(result, failed_check)
|
64
|
+
info_reporter&.call(
|
65
|
+
"Idempotency check failed",
|
66
|
+
result: result.as_json(include_command: true),
|
67
|
+
failed_check: failed_check.inspect
|
68
|
+
)
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "operations/components/callback"
|
4
|
+
|
5
|
+
# `on_failure` callbacks are called if a command have failed on a stage
|
6
|
+
# other than the operation itself or contract. I.e. on policies/preconditions.
|
7
|
+
class Operations::Components::OnFailure < Operations::Components::Callback
|
8
|
+
def call(operation_result)
|
9
|
+
callback_context = operation_result.context.merge(operation_failure: operation_result.errors.to_h)
|
10
|
+
results = callable.map do |entry|
|
11
|
+
call_entry(entry, operation_result, **callback_context)
|
12
|
+
end
|
13
|
+
|
14
|
+
maybe_report_failure(:on_failure, operation_result.merge(on_failure: results))
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "operations/components/callback"
|
4
|
+
|
5
|
+
# `on_success` callbacks are called when command was successful and implemented
|
6
|
+
# to be executed outside the outermost DB transcation (this is configurable
|
7
|
+
# but by default AfterCommitEverywhere gem is used).
|
8
|
+
# It there is a wrapping transaction (in cases when command is called inside
|
9
|
+
# of another command), the inner command result will have empty `on_success`
|
10
|
+
# component (since the callbacks will happen when the wparring command is finished).
|
11
|
+
class Operations::Components::OnSuccess < Operations::Components::Callback
|
12
|
+
option :after_commit, type: Operations::Types.Interface(:call)
|
13
|
+
|
14
|
+
def call(operation_result)
|
15
|
+
callback_result = after_commit.call do
|
16
|
+
call_entries(operation_result)
|
17
|
+
end
|
18
|
+
|
19
|
+
if callback_result.is_a?(Operations::Result)
|
20
|
+
callback_result
|
21
|
+
else
|
22
|
+
operation_result
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def call_entries(operation_result)
|
29
|
+
results = callable.map do |entry|
|
30
|
+
call_entry(entry, operation_result, **operation_result.context)
|
31
|
+
end
|
32
|
+
|
33
|
+
maybe_report_failure(:on_success, operation_result.merge(on_success: results))
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "operations/components/base"
|
4
|
+
|
5
|
+
# Wraps operation component call to adapt to the further processing.
|
6
|
+
class Operations::Components::Operation < Operations::Components::Base
|
7
|
+
PARAMS_FIRST_SIGNATURES = [[:params], [:_params], [:_]].freeze
|
8
|
+
|
9
|
+
def call(params, context)
|
10
|
+
arg_names = call_args(callable, types: %i[req opt])
|
11
|
+
|
12
|
+
operation_result = if PARAMS_FIRST_SIGNATURES.include?(arg_names)
|
13
|
+
callable.call(params, **context)
|
14
|
+
else
|
15
|
+
context_args = context.values_at(*arg_names)
|
16
|
+
callable.call(*context_args, **params)
|
17
|
+
end
|
18
|
+
|
19
|
+
extended_result(operation_result, params: params, context: context)
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def extended_result(operation_result, params:, context:)
|
25
|
+
result = result(params: params, context: context)
|
26
|
+
|
27
|
+
if operation_result.failure?
|
28
|
+
result.merge(errors: errors(normalize_failure(operation_result.failure)))
|
29
|
+
elsif operation_result.value!.is_a?(Hash)
|
30
|
+
result.merge(context: context.merge(operation_result.value!))
|
31
|
+
elsif operation_result.value!.is_a?(Dry::Monads::Unit)
|
32
|
+
result
|
33
|
+
else
|
34
|
+
raise "Unexpected operation body result. Expected Hash, got #{operation_result.value!.class}"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "operations/components/prechecks"
|
4
|
+
|
5
|
+
# We are looking for the first policy failure to return because
|
6
|
+
# it does not make sense to check for all policy failures. One is
|
7
|
+
# more than enough to know that we are not allowed to call the operation.
|
8
|
+
#
|
9
|
+
# If policy returns `false` then generic `:unauthorized` error
|
10
|
+
# code will be used. In case of `Failure` monad - the error code depends
|
11
|
+
# on the failure internal value. It can be a String, Symbol or even
|
12
|
+
# a Hash containing `:error` key.
|
13
|
+
#
|
14
|
+
# Successful policies return either `true` or `Success` monad.
|
15
|
+
class Operations::Components::Policies < Operations::Components::Prechecks
|
16
|
+
def call(params, context)
|
17
|
+
first_failure = callable.lazy.filter_map do |entry|
|
18
|
+
result_failure(entry.call(**context), entry)
|
19
|
+
end.first
|
20
|
+
|
21
|
+
result(
|
22
|
+
params: params,
|
23
|
+
context: context,
|
24
|
+
errors: errors(normalize_failure([first_failure].compact))
|
25
|
+
)
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def result_failure(result, entry)
|
31
|
+
case result
|
32
|
+
when true, Success
|
33
|
+
nil
|
34
|
+
when Failure
|
35
|
+
result.failure
|
36
|
+
when false
|
37
|
+
:unauthorized
|
38
|
+
else
|
39
|
+
raise "Unexpected policy result: #{result} for #{entry}"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "operations/components/base"
|
4
|
+
|
5
|
+
# Contains common logic for policies and preconditions.
|
6
|
+
class Operations::Components::Prechecks < Operations::Components::Base
|
7
|
+
param :callable, type: Operations::Types::Array.of(Operations::Types.Interface(:call))
|
8
|
+
|
9
|
+
def callable?(context)
|
10
|
+
(required_context - context.keys).empty?
|
11
|
+
end
|
12
|
+
|
13
|
+
def required_context
|
14
|
+
@required_context ||= required_kwargs | context_keys
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def context_keys
|
20
|
+
keys = callable.flat_map do |entry|
|
21
|
+
if entry.respond_to?(:context_key)
|
22
|
+
[entry.context_key]
|
23
|
+
elsif entry.respond_to?(:context_keys)
|
24
|
+
entry.context_keys
|
25
|
+
else
|
26
|
+
[]
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
keys.map(&:to_sym)
|
31
|
+
end
|
32
|
+
|
33
|
+
def required_kwargs
|
34
|
+
callable.flat_map do |entry|
|
35
|
+
call_args(entry, types: %i[keyreq])
|
36
|
+
end.uniq
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "operations/components/prechecks"
|
4
|
+
|
5
|
+
# We check all the precondition failures to return all the codes to
|
6
|
+
# the user at once. This provides a better UX, user is able to fix
|
7
|
+
# everything at once instead of getting messages one by one. This is
|
8
|
+
# similar to the idea of validations.
|
9
|
+
#
|
10
|
+
# Precondition can return a Symbol - it will be used as an error code.
|
11
|
+
# If String is returned - it will be used as a message itself. Please
|
12
|
+
# avoid returning string, use i18n instead. Hash with `:error` key
|
13
|
+
# will be also treated as a failure ans used accordingly. Also, `Failure`
|
14
|
+
# monad gets unwrapped and the value follows the rules above. Also, it is
|
15
|
+
# possible to return an array of failures.
|
16
|
+
#
|
17
|
+
# Successful preconditions returns either nil or an empty array or a
|
18
|
+
# `Success` monad.
|
19
|
+
class Operations::Components::Preconditions < Operations::Components::Prechecks
|
20
|
+
def call(params, context)
|
21
|
+
failures = callable.flat_map do |entry|
|
22
|
+
results = Array.wrap(entry.call(**context))
|
23
|
+
results.filter_map { |result| result_failure(result) }
|
24
|
+
end
|
25
|
+
|
26
|
+
result(
|
27
|
+
params: params,
|
28
|
+
context: context,
|
29
|
+
errors: errors(normalize_failure(failures))
|
30
|
+
)
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def result_failure(result)
|
36
|
+
case result
|
37
|
+
when nil, Success
|
38
|
+
nil
|
39
|
+
when Failure
|
40
|
+
result.failure
|
41
|
+
else
|
42
|
+
result
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dry-struct"
|
4
|
+
|
5
|
+
# The framework's configuration shared between all the commands.
|
6
|
+
#
|
7
|
+
# @see Operations.default_config
|
8
|
+
class Operations::Configuration < Dry::Struct
|
9
|
+
schema schema.strict
|
10
|
+
|
11
|
+
attribute :info_reporter?, Operations::Types.Interface(:call).optional
|
12
|
+
attribute :error_reporter?, Operations::Types.Interface(:call).optional
|
13
|
+
attribute :transaction, Operations::Types.Interface(:call)
|
14
|
+
attribute :after_commit, Operations::Types.Interface(:call)
|
15
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Patching the default messages resolver to append `:code` meta
|
4
|
+
# to every message produced.
|
5
|
+
class Operations::Contract::MessagesResolver < Dry::Validation::Messages::Resolver
|
6
|
+
def call(message:, meta: Dry::Schema::EMPTY_HASH, **rest)
|
7
|
+
meta = meta.merge(code: message) if message.is_a?(Symbol)
|
8
|
+
|
9
|
+
super(message: message, meta: meta, **rest)
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Just a base contract with i18n set up and a bunch of useful macro.
|
4
|
+
class Operations::Contract < Dry::Validation::Contract
|
5
|
+
option :message_resolver, default: -> { Operations::Contract::MessagesResolver.new(messages) }
|
6
|
+
|
7
|
+
# config.messages.backend = :i18n
|
8
|
+
config.messages.top_namespace = "operations"
|
9
|
+
|
10
|
+
def self.inherited(subclass)
|
11
|
+
super
|
12
|
+
|
13
|
+
return unless subclass.name
|
14
|
+
|
15
|
+
namespace = subclass.name.underscore.split("/")[0..-2].join("/")
|
16
|
+
subclass.config.messages.namespace = namespace
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.prepend_rule(...)
|
20
|
+
rule(...)
|
21
|
+
rules.unshift(rules.pop)
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.ensure_presence(context_key, field: "#{context_key}_id", optional: false)
|
25
|
+
rule do |context:|
|
26
|
+
next if context[context_key] || schema_error?(field)
|
27
|
+
|
28
|
+
if key?(field)
|
29
|
+
key(field).failure(:not_found)
|
30
|
+
elsif !optional
|
31
|
+
key(field).failure(:key?)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def call(input, **initial_context)
|
37
|
+
super(input, initial_context)
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This module helps to follow conventions. Work best with
|
4
|
+
# {Operations::Command.build}
|
5
|
+
#
|
6
|
+
# Unders the hood it defines classes in accordance to the
|
7
|
+
# nesting convenience. It is always possible to use this module
|
8
|
+
# along with the manually crafted components if necessary.
|
9
|
+
#
|
10
|
+
# @example
|
11
|
+
#
|
12
|
+
# class Namespace::OperationName
|
13
|
+
# extend Operations::Convenience
|
14
|
+
#
|
15
|
+
# contract do
|
16
|
+
# params { ... }
|
17
|
+
# rule(...) { ... }
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
# policy do |current_user, **|
|
21
|
+
# current_user.is_a?(Superuser) && ...
|
22
|
+
# end
|
23
|
+
#
|
24
|
+
# def call(context_value1, context_value2, **params)
|
25
|
+
# ...
|
26
|
+
# end
|
27
|
+
# end
|
28
|
+
#
|
29
|
+
# @see Operations::Command.build
|
30
|
+
#
|
31
|
+
# Also, if this class is used as a container to cache the command
|
32
|
+
# instance under some name, this module will provide a method missing
|
33
|
+
# to call the command with `#call!` method using the `#call` method
|
34
|
+
# as an interface.
|
35
|
+
#
|
36
|
+
# @example
|
37
|
+
#
|
38
|
+
# class Namespace::OperationName
|
39
|
+
# extend Operations::Convenience
|
40
|
+
#
|
41
|
+
# def self.default
|
42
|
+
# Operations::Command.new(...)
|
43
|
+
# end
|
44
|
+
# end
|
45
|
+
#
|
46
|
+
# # A normall command call
|
47
|
+
# Namespace::OperationName.default.call(...)
|
48
|
+
# # Raises exception in case of failure
|
49
|
+
# Namespace::OperationName.default.call!(...)
|
50
|
+
# # Acts exactly the same way as the previous one
|
51
|
+
# # but notice where the bang is.
|
52
|
+
# Namespace::OperationName.default!.call(...)
|
53
|
+
#
|
54
|
+
# This is especially convenient when you have a DSL that
|
55
|
+
# expects some object responding to `#call` method but you want
|
56
|
+
# to raise an exception. In this case you would just pass
|
57
|
+
# `Namespace::OperationName.default!` into it.
|
58
|
+
#
|
59
|
+
module Operations::Convenience
|
60
|
+
def self.extended(mod)
|
61
|
+
mod.include Dry::Monads[:result]
|
62
|
+
mod.include Dry::Monads::Do.for(:call)
|
63
|
+
mod.extend Dry::Initializer
|
64
|
+
end
|
65
|
+
|
66
|
+
def method_missing(name, *args, **kwargs, &block)
|
67
|
+
name_without_suffix = name.to_s.delete_suffix("!").to_sym
|
68
|
+
if name.to_s.end_with?("!") && respond_to?(name_without_suffix)
|
69
|
+
public_send(name_without_suffix, *args, **kwargs, &block).method(:call!)
|
70
|
+
else
|
71
|
+
super
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def respond_to_missing?(name, *)
|
76
|
+
(name.to_s.end_with?("!") && respond_to?(name.to_s.delete_suffix("!").to_sym)) || super
|
77
|
+
end
|
78
|
+
|
79
|
+
def contract(prefix = nil, from: OperationContract, &block)
|
80
|
+
contract = Class.new(from)
|
81
|
+
contract.config.messages.namespace = name.underscore
|
82
|
+
contract.class_eval(&block)
|
83
|
+
const_set("#{prefix.to_s.camelize}Contract", contract)
|
84
|
+
end
|
85
|
+
|
86
|
+
%w[policy precondition callback].each do |kind|
|
87
|
+
define_method kind do |prefix = nil, from: Object, &block|
|
88
|
+
raise ArgumentError.new("Please provide either a superclass or a block for #{kind}") unless from || block
|
89
|
+
|
90
|
+
klass = Class.new(from)
|
91
|
+
|
92
|
+
if from == Object
|
93
|
+
klass.extend(Dry::Initializer)
|
94
|
+
klass.include(Dry::Monads[:result])
|
95
|
+
end
|
96
|
+
|
97
|
+
klass.define_method(:call, &block) if block
|
98
|
+
|
99
|
+
const_set("#{prefix.to_s.camelize}#{kind.camelize}", klass)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# The main purpose is to infer attribute properties from the
|
4
|
+
# related model. We need it to automate form rendering for the
|
5
|
+
# legacy UI.
|
6
|
+
class Operations::Form::Attribute
|
7
|
+
extend Dry::Initializer
|
8
|
+
include Dry::Equalizer(:name, :collection, :form, :model_name)
|
9
|
+
|
10
|
+
param :name, type: Operations::Types::Coercible::Symbol
|
11
|
+
option :collection, type: Operations::Types::Bool, optional: true, default: proc { false }
|
12
|
+
option :form, type: Operations::Types::Class, optional: true
|
13
|
+
option :model_name,
|
14
|
+
type: Operations::Types::String | Operations::Types.Instance(Class).constrained(lt: ActiveRecord::Base),
|
15
|
+
optional: true
|
16
|
+
|
17
|
+
def model_type
|
18
|
+
@model_type ||= owning_model.type_for_attribute(string_name) if model_name
|
19
|
+
end
|
20
|
+
|
21
|
+
def model_human_name(options = {})
|
22
|
+
owning_model.human_attribute_name(string_name, options) if model_name
|
23
|
+
end
|
24
|
+
|
25
|
+
def model_validators
|
26
|
+
@model_validators ||= model_name ? owning_model.validators_on(string_name) : []
|
27
|
+
end
|
28
|
+
|
29
|
+
def model_localized_attr_name(locale)
|
30
|
+
owning_model.localized_attr_name_for(string_name, locale) if model_name
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def owning_model
|
36
|
+
@owning_model ||= model_name.is_a?(String) ? model_name.constantize : model_name
|
37
|
+
end
|
38
|
+
|
39
|
+
def string_name
|
40
|
+
@string_name ||= name.to_s
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Traverses the passed {Dry::Schema::KeyMap} and generates
|
4
|
+
# {Operations::Form} classes on the fly. Handles nested structures.
|
5
|
+
#
|
6
|
+
# @see Operations::Form
|
7
|
+
class Operations::Form::Builder
|
8
|
+
extend Dry::Initializer
|
9
|
+
|
10
|
+
NESTED_ATTRIBUTES_SUFFIX = %r{_attributes\z}.freeze
|
11
|
+
|
12
|
+
option :base_class, Operations::Types::Instance(Class)
|
13
|
+
|
14
|
+
def build(key_map:, namespace:, class_name:, model_map:)
|
15
|
+
return namespace.const_get(class_name) if namespace && class_name && namespace.const_defined?(class_name)
|
16
|
+
|
17
|
+
traverse(key_map, namespace, class_name, model_map, [])
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def traverse(key_map, namespace, class_name, model_map, path)
|
23
|
+
form = Class.new(base_class)
|
24
|
+
namespace.const_set(class_name, form) if namespace && class_name
|
25
|
+
|
26
|
+
key_map.each do |key|
|
27
|
+
key_path = path + [key.name]
|
28
|
+
|
29
|
+
case key
|
30
|
+
when Dry::Schema::Key::Array
|
31
|
+
nested_form = traverse(key.member, form, key.name.to_s.underscore.classify, model_map, key_path)
|
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
|
40
|
+
end
|
41
|
+
|
42
|
+
form
|
43
|
+
end
|
44
|
+
|
45
|
+
def traverse_hash(form, hash_key, model_map, path)
|
46
|
+
nested_attributes_suffix = hash_key.name.match?(NESTED_ATTRIBUTES_SUFFIX)
|
47
|
+
nested_attributes_collection = hash_key.members.all?(Dry::Schema::Key::Hash) &&
|
48
|
+
hash_key.members.map(&:members).uniq.size == 1
|
49
|
+
|
50
|
+
name, members, collection = specify_form_attributes(
|
51
|
+
hash_key,
|
52
|
+
nested_attributes_suffix,
|
53
|
+
nested_attributes_collection
|
54
|
+
)
|
55
|
+
form.define_method "#{hash_key.name}=", proc { |attributes| attributes } if nested_attributes_suffix
|
56
|
+
|
57
|
+
key_path = path + [name]
|
58
|
+
nested_form = traverse(members, form, name.underscore.camelize, model_map, key_path)
|
59
|
+
form.attribute(name, form: nested_form, collection: collection, **model_name(key_path, model_map))
|
60
|
+
end
|
61
|
+
|
62
|
+
def specify_form_attributes(hash_key, nested_attributes_suffix, nested_attributes_collection)
|
63
|
+
if nested_attributes_suffix && !nested_attributes_collection
|
64
|
+
[hash_key.name.gsub(NESTED_ATTRIBUTES_SUFFIX, ""), hash_key.members, false]
|
65
|
+
elsif nested_attributes_suffix && nested_attributes_collection
|
66
|
+
[hash_key.name.gsub(NESTED_ATTRIBUTES_SUFFIX, ""), hash_key.members.first.members, true]
|
67
|
+
else
|
68
|
+
[hash_key.name, hash_key.members, false]
|
69
|
+
end
|
70
|
+
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
|
+
end
|