operations 0.0.1 → 0.6.3
Sign up to get free protection for your applications and to get access to all the features.
- 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 +30 -0
- data/Appraisals +8 -0
- data/CHANGELOG.md +17 -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 +413 -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
|