bcdd-result 0.11.0 → 0.13.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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +16 -1
  3. data/CHANGELOG.md +97 -15
  4. data/README.md +508 -95
  5. data/Steepfile +4 -4
  6. data/examples/multiple_listeners/Rakefile +55 -0
  7. data/examples/multiple_listeners/app/models/account/member.rb +10 -0
  8. data/examples/multiple_listeners/app/models/account/owner_creation.rb +62 -0
  9. data/examples/multiple_listeners/app/models/account.rb +11 -0
  10. data/examples/multiple_listeners/app/models/user/creation.rb +67 -0
  11. data/examples/multiple_listeners/app/models/user/token/creation.rb +51 -0
  12. data/examples/multiple_listeners/app/models/user/token.rb +7 -0
  13. data/examples/multiple_listeners/app/models/user.rb +15 -0
  14. data/examples/multiple_listeners/config/boot.rb +16 -0
  15. data/examples/multiple_listeners/config/initializers/bcdd.rb +11 -0
  16. data/examples/multiple_listeners/config.rb +27 -0
  17. data/examples/multiple_listeners/db/setup.rb +61 -0
  18. data/examples/multiple_listeners/lib/bcdd/result/rollback_on_failure.rb +15 -0
  19. data/examples/multiple_listeners/lib/bcdd/result/transitions_record.rb +28 -0
  20. data/examples/multiple_listeners/lib/runtime_breaker.rb +11 -0
  21. data/examples/multiple_listeners/lib/transitions_listener/stdout.rb +54 -0
  22. data/examples/single_listener/Rakefile +92 -0
  23. data/examples/single_listener/app/models/account/member.rb +10 -0
  24. data/examples/single_listener/app/models/account/owner_creation.rb +62 -0
  25. data/examples/single_listener/app/models/account.rb +11 -0
  26. data/examples/single_listener/app/models/user/creation.rb +67 -0
  27. data/examples/single_listener/app/models/user/token/creation.rb +51 -0
  28. data/examples/single_listener/app/models/user/token.rb +7 -0
  29. data/examples/single_listener/app/models/user.rb +15 -0
  30. data/examples/single_listener/config/boot.rb +16 -0
  31. data/examples/single_listener/config/initializers/bcdd.rb +11 -0
  32. data/examples/single_listener/config.rb +23 -0
  33. data/examples/single_listener/db/setup.rb +49 -0
  34. data/examples/single_listener/lib/bcdd/result/rollback_on_failure.rb +15 -0
  35. data/examples/single_listener/lib/runtime_breaker.rb +11 -0
  36. data/examples/single_listener/lib/single_transitions_listener.rb +108 -0
  37. data/lib/bcdd/result/callable_and_then/caller.rb +49 -0
  38. data/lib/bcdd/result/callable_and_then/config.rb +15 -0
  39. data/lib/bcdd/result/callable_and_then/error.rb +11 -0
  40. data/lib/bcdd/result/callable_and_then.rb +9 -0
  41. data/lib/bcdd/result/config/switchers/features.rb +5 -1
  42. data/lib/bcdd/result/config.rb +15 -4
  43. data/lib/bcdd/result/context/callable_and_then.rb +39 -0
  44. data/lib/bcdd/result/context/expectations/mixin.rb +2 -2
  45. data/lib/bcdd/result/context/mixin.rb +3 -3
  46. data/lib/bcdd/result/context/success.rb +29 -7
  47. data/lib/bcdd/result/context.rb +34 -16
  48. data/lib/bcdd/result/contract/for_types.rb +1 -1
  49. data/lib/bcdd/result/contract/for_types_and_values.rb +2 -0
  50. data/lib/bcdd/result/error.rb +20 -11
  51. data/lib/bcdd/result/expectations/mixin.rb +3 -3
  52. data/lib/bcdd/result/expectations.rb +6 -6
  53. data/lib/bcdd/result/ignored_types.rb +14 -0
  54. data/lib/bcdd/result/mixin.rb +3 -3
  55. data/lib/bcdd/result/transitions/config.rb +26 -0
  56. data/lib/bcdd/result/transitions/listener.rb +51 -0
  57. data/lib/bcdd/result/transitions/listeners.rb +87 -0
  58. data/lib/bcdd/result/transitions/tracking/disabled.rb +4 -6
  59. data/lib/bcdd/result/transitions/tracking/enabled.rb +103 -24
  60. data/lib/bcdd/result/transitions/tracking.rb +8 -3
  61. data/lib/bcdd/result/transitions/tree.rb +36 -6
  62. data/lib/bcdd/result/transitions.rb +11 -14
  63. data/lib/bcdd/result/version.rb +1 -1
  64. data/lib/bcdd/result.rb +39 -22
  65. data/sig/bcdd/result/callable_and_then.rbs +60 -0
  66. data/sig/bcdd/result/config.rbs +3 -0
  67. data/sig/bcdd/result/context.rbs +65 -4
  68. data/sig/bcdd/result/error.rbs +9 -6
  69. data/sig/bcdd/result/expectations.rbs +4 -4
  70. data/sig/bcdd/result/ignored_types.rbs +9 -0
  71. data/sig/bcdd/result/transitions.rbs +107 -7
  72. data/sig/bcdd/result.rbs +10 -6
  73. metadata +48 -6
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Account
4
+ class OwnerCreation
5
+ include BCDD::Context.mixin
6
+ include BCDD::Result::RollbackOnFailure
7
+
8
+ def call(**input)
9
+ BCDD::Result.transitions(name: self.class.name) do
10
+ Given(input)
11
+ .and_then(:normalize_input)
12
+ .and_then(:validate_input)
13
+ .then { |result|
14
+ rollback_on_failure {
15
+ result
16
+ .and_then(:create_owner)
17
+ .and_then(:create_account)
18
+ .and_then(:link_owner_to_account)
19
+ }
20
+ }.and_expose(:account_owner_created, %i[user account])
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def normalize_input(**options)
27
+ uuid = String(options.fetch(:uuid) { ::SecureRandom.uuid }).strip.downcase
28
+
29
+ Continue(uuid:)
30
+ end
31
+
32
+ def validate_input(uuid:, owner:)
33
+ err = ::Hash.new { |hash, key| hash[key] = [] }
34
+
35
+ err[:uuid] << 'must be an UUID' unless uuid.match?(/\A[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}\z/i)
36
+ err[:owner] << 'must be a Hash' unless owner.is_a?(::Hash)
37
+
38
+ err.empty? ? Continue() : Failure(:invalid_input, **err)
39
+ end
40
+
41
+ def create_owner(owner:, **)
42
+ ::User::Creation.new.call(**owner).handle do |on|
43
+ on.success { |output| Continue(user: { record: output[:user], token: output[:token] }) }
44
+ on.failure { |output| Failure(:invalid_owner, **output) }
45
+ end
46
+ end
47
+
48
+ def create_account(uuid:, **)
49
+ ::RuntimeBreaker.try_to_interrupt(env: 'BREAK_ACCOUNT_CREATION')
50
+
51
+ account = ::Account.create(uuid:)
52
+
53
+ account.persisted? ? Continue(account:) : Failure(:invalid_record, **account.errors.messages)
54
+ end
55
+
56
+ def link_owner_to_account(account:, user:, **)
57
+ Member.create!(account:, user: user.fetch(:record), role: :owner)
58
+
59
+ Continue()
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Account < ActiveRecord::Base
4
+ has_many :memberships, inverse_of: :account, dependent: :destroy, class_name: '::Account::Member'
5
+ has_many :users, through: :memberships, inverse_of: :accounts
6
+
7
+ where_ownership = -> { where(account_members: {role: :owner}) }
8
+
9
+ has_one :ownership, where_ownership, dependent: nil, inverse_of: :account, class_name: '::Account::Member'
10
+ has_one :owner, through: :ownership, source: :user
11
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ class User
4
+ class Creation
5
+ include BCDD::Context.mixin
6
+ include BCDD::Result::RollbackOnFailure
7
+
8
+ def call(**input)
9
+ BCDD::Result.transitions(name: self.class.name) do
10
+ Given(input)
11
+ .and_then(:normalize_input)
12
+ .and_then(:validate_input)
13
+ .and_then(:validate_email_uniqueness)
14
+ .then { |result|
15
+ rollback_on_failure {
16
+ result
17
+ .and_then(:create_user)
18
+ .and_then(:create_user_token)
19
+ }
20
+ }
21
+ .and_expose(:user_created, %i[user token])
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def normalize_input(name:, email:, **options)
28
+ name = String(name).strip.gsub(/\s+/, ' ')
29
+ email = String(email).strip.downcase
30
+
31
+ uuid = String(options.fetch(:uuid) { ::SecureRandom.uuid }).strip.downcase
32
+
33
+ Continue(uuid:, name:, email:)
34
+ end
35
+
36
+ def validate_input(uuid:, name:, email:, password:, password_confirmation:)
37
+ err = ::Hash.new { |hash, key| hash[key] = [] }
38
+
39
+ err[:uuid] << 'must be an UUID' unless uuid.match?(/\A[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}\z/i)
40
+ err[:name] << 'must be present' if name.blank?
41
+ err[:email] << 'must be email' unless email.match?(::URI::MailTo::EMAIL_REGEXP)
42
+ err[:password] << 'must be present' if password.blank?
43
+ err[:password_confirmation] << 'must be present' if password_confirmation.blank?
44
+
45
+ err.empty? ? Continue() : Failure(:invalid_input, **err)
46
+ end
47
+
48
+ def validate_email_uniqueness(email:, **)
49
+ ::User.exists?(email:) ? Failure(:email_already_taken) : Continue()
50
+ end
51
+
52
+ def create_user(uuid:, name:, email:, password:, password_confirmation:)
53
+ ::RuntimeBreaker.try_to_interrupt(env: 'BREAK_USER_CREATION')
54
+
55
+ user = ::User.create(uuid:, name:, email:, password:, password_confirmation:)
56
+
57
+ user.persisted? ? Continue(user:) : Failure(:invalid_record, **user.errors.messages)
58
+ end
59
+
60
+ def create_user_token(user:, **)
61
+ Token::Creation.new.call(user: user).handle do |on|
62
+ on.success { |output| Continue(token: output[:token]) }
63
+ on.failure { raise 'Token creation failed' }
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ class User::Token
4
+ class Creation
5
+ include BCDD::Context.mixin
6
+
7
+ def call(**input)
8
+ BCDD::Result.transitions(name: self.class.name) do
9
+ Given(input)
10
+ .and_then(:normalize_input)
11
+ .and_then(:validate_input)
12
+ .and_then(:validate_token_existence)
13
+ .and_then(:create_token)
14
+ .and_expose(:token_created, %i[token])
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def normalize_input(**options)
21
+ Continue(executed_at: options.fetch(:executed_at) { ::Time.current })
22
+ end
23
+
24
+ def validate_input(user:, executed_at:)
25
+ err = ::Hash.new { |hash, key| hash[key] = [] }
26
+
27
+ err[:user] << 'must be a User' unless user.is_a?(::User)
28
+ err[:user] << 'must be persisted' unless user.try(:persisted?)
29
+ err[:executed_at] << 'must be a time' unless executed_at.is_a?(::Time)
30
+
31
+ err.empty? ? Continue() : Failure(:invalid_user, **err)
32
+ end
33
+
34
+ def validate_token_existence(user:, **)
35
+ user.token.nil? ? Continue() : Failure(:token_already_exists)
36
+ end
37
+
38
+ def create_token(user:, executed_at:, **)
39
+ ::RuntimeBreaker.try_to_interrupt(env: 'BREAK_USER_TOKEN_CREATION')
40
+
41
+ token = user.create_token(
42
+ access_token: ::SecureRandom.hex(24),
43
+ refresh_token: ::SecureRandom.hex(24),
44
+ access_token_expires_at: executed_at + 15.days,
45
+ refresh_token_expires_at: executed_at + 30.days
46
+ )
47
+
48
+ token.persisted? ? Continue(token:) : Failure(:token_creation_failed, **token.errors.messages)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class User::Token < ActiveRecord::Base
4
+ self.table_name = 'user_tokens'
5
+
6
+ belongs_to :user, inverse_of: :token
7
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class User < ActiveRecord::Base
4
+ has_secure_password
5
+
6
+ has_many :memberships, inverse_of: :user, dependent: :destroy, class_name: '::Account::Member'
7
+ has_many :accounts, through: :memberships, inverse_of: :users
8
+
9
+ where_ownership = -> { where(account_members: { role: :owner }) }
10
+
11
+ has_one :ownership, where_ownership, inverse_of: :user, class_name: '::Account::Member'
12
+ has_one :account, through: :ownership, inverse_of: :owner
13
+
14
+ has_one :token, inverse_of: :user, dependent: :destroy, class_name: '::User::Token'
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/inline'
4
+
5
+ $LOAD_PATH.unshift(__dir__)
6
+
7
+ gemfile do
8
+ source 'https://rubygems.org'
9
+
10
+ gem 'sqlite3', '~> 1.7'
11
+ gem 'bcrypt', '~> 3.1.20'
12
+ gem 'activerecord', '~> 7.1', '>= 7.1.3', require: 'active_record'
13
+ gem 'bcdd-result', path: '../../'
14
+ end
15
+
16
+ require 'active_support/all'
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ BCDD::Result.config.then do |config|
4
+ config.addon.enable!(:continue)
5
+
6
+ config.constant_alias.enable!('BCDD::Context')
7
+
8
+ config.pattern_matching.disable!(:nil_as_valid_value_checking)
9
+
10
+ # config.feature.disable!(:expectations) if Rails.env.production?
11
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/inline'
4
+
5
+ $LOAD_PATH.unshift(__dir__)
6
+
7
+ require_relative 'config/boot'
8
+ require_relative 'config/initializers/bcdd'
9
+
10
+ require 'db/setup'
11
+
12
+ require 'lib/bcdd/result/rollback_on_failure'
13
+ require 'lib/single_transitions_listener'
14
+ require 'lib/runtime_breaker'
15
+
16
+ require 'app/models/account'
17
+ require 'app/models/account/member'
18
+ require 'app/models/user'
19
+ require 'app/models/user/token'
20
+
21
+ require 'app/models/account/owner_creation'
22
+ require 'app/models/user/token/creation'
23
+ require 'app/models/user/creation'
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/all'
4
+
5
+ ActiveRecord::Base.establish_connection(
6
+ host: 'localhost',
7
+ adapter: 'sqlite3',
8
+ database: ':memory:'
9
+ )
10
+
11
+ ActiveRecord::Schema.define do
12
+ suppress_messages do
13
+ create_table :accounts do |t|
14
+ t.string :uuid, null: false, index: {unique: true}
15
+
16
+ t.timestamps
17
+ end
18
+
19
+ create_table :users do |t|
20
+ t.string :uuid, null: false, index: {unique: true}
21
+ t.string :name, null: false
22
+ t.string :email, null: false, index: {unique: true}
23
+ t.string :password_digest, null: false
24
+
25
+ t.timestamps
26
+ end
27
+
28
+ create_table :user_tokens do |t|
29
+ t.belongs_to :user, null: false, foreign_key: true, index: true
30
+ t.string :access_token, null: false
31
+ t.string :refresh_token, null: false
32
+ t.datetime :access_token_expires_at, null: false
33
+ t.datetime :refresh_token_expires_at, null: false
34
+
35
+ t.timestamps
36
+ end
37
+
38
+ create_table :account_members do |t|
39
+ t.integer :role, null: false, default: 0
40
+ t.belongs_to :user, null: false, foreign_key: true, index: true
41
+ t.belongs_to :account, null: false, foreign_key: true, index: true
42
+
43
+ t.timestamps
44
+
45
+ t.index %i[account_id role], unique: true, where: "(role = 0)"
46
+ t.index %i[account_id user_id], unique: true
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BCDD::Result::RollbackOnFailure
4
+ def rollback_on_failure(model: ::ActiveRecord::Base)
5
+ result = nil
6
+
7
+ model.transaction do
8
+ result = yield
9
+
10
+ raise ::ActiveRecord::Rollback if result.failure?
11
+ end
12
+
13
+ result
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuntimeBreaker
4
+ Interruption = Class.new(StandardError)
5
+
6
+ def self.try_to_interrupt(env:)
7
+ return unless String(ENV[env]).strip.start_with?(/1|t/)
8
+
9
+ raise Interruption, "Runtime breaker activated (#{env})"
10
+ end
11
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SingleTransitionsListener
4
+ include BCDD::Result::Transitions::Listener
5
+
6
+ # A listener will be initialized before the first transition, and it is discarded after the last one.
7
+ def initialize
8
+ @buffer = []
9
+ end
10
+
11
+ # This method will be called before each transition block.
12
+ # The parent transition block will be called first in the case of nested transition blocks.
13
+ #
14
+ # @param scope: {:id=>1, :name=>"SomeOperation", :desc=>"Optional description"}
15
+ def on_start(scope:)
16
+ scope => { id:, name:, desc: }
17
+
18
+ @buffer << [id, "##{id} #{name} - #{desc}".chomp('- ')]
19
+ end
20
+
21
+ # This method will wrap all the transitions in the same block.
22
+ # It can be used to perform an instrumentation (measure/report) of the transitions.
23
+ #
24
+ # @param scope: {:id=>1, :name=>"SomeOperation", :desc=>"Optional description"}
25
+ def around_transitions(scope:)
26
+ yield
27
+ end
28
+
29
+ # This method will wrap each and_then call.
30
+ # It can be used to perform an instrumentation (measure/report) of the and_then calls.
31
+ #
32
+ # @param scope: {:id=>1, :name=>"SomeOperation", :desc=>"Optional description"}
33
+ # @param and_then:
34
+ # {:type=>:block, :arg=>:some_injected_value}
35
+ # {:type=>:method, :arg=>:some_injected_value, :method_name=>:some_method_name}
36
+ def around_and_then(scope:, and_then:)
37
+ yield
38
+ end
39
+
40
+ # This method will be called after each result recording/tracking.
41
+ #
42
+ # @param record:
43
+ # {
44
+ # :root => {:id=>0, :name=>"RootOperation", :desc=>nil},
45
+ # :parent => {:id=>0, :name=>"RootOperation", :desc=>nil},
46
+ # :current => {:id=>1, :name=>"SomeOperation", :desc=>nil},
47
+ # :result => {:kind=>:success, :type=>:_continue_, :value=>{some: :thing}, :source=><MyProcess:0x0000000102fd6378>},
48
+ # :and_then => {:type=>:method, :arg=>nil, :method_name=>:some_method},
49
+ # :time => 2024-01-26 02:53:11.310431 UTC
50
+ # }
51
+ def on_record(record:)
52
+ record => { current: { id: }, result: { kind:, type: } }
53
+
54
+ method_name = record.dig(:and_then, :method_name)
55
+
56
+ @buffer << [id, " * #{kind}(#{type}) from method: #{method_name}".chomp('from method: ')]
57
+ end
58
+
59
+ MapNestedMessages = ->(transitions, buffer, hide_given_and_continue) do
60
+ ids_matrix = transitions.dig(:metadata, :ids_matrix)
61
+
62
+ messages = buffer.filter_map { |(id, msg)| "#{' ' * ids_matrix[id].last}#{msg}" if ids_matrix[id] }
63
+
64
+ messages.reject! { _1.match?(/\(_(given|continue)_\)/) } if hide_given_and_continue
65
+
66
+ messages
67
+ end
68
+
69
+ # This method will be called at the end of the transitions tracking.
70
+ #
71
+ # @param transitions:
72
+ # {
73
+ # :version => 1,
74
+ # :metadata => {
75
+ # :duration => 0,
76
+ # :trace_id => nil,
77
+ # :ids_tree => [0, [[1, []], [2, []]]],
78
+ # :ids_matrix => {0 => [0, 0], 1 => [1, 1], 2 => [2, 1]}
79
+ # },
80
+ # :records => [
81
+ # # ...
82
+ # ]
83
+ # }
84
+ def on_finish(transitions:)
85
+ messages = MapNestedMessages[transitions, @buffer, ENV['HIDE_GIVEN_AND_CONTINUE']]
86
+
87
+ puts messages.join("\n")
88
+ end
89
+
90
+ # This method will be called when an exception is raised during the transitions tracking.
91
+ #
92
+ # @param exception: Exception
93
+ # @param transitions: Hash
94
+ def before_interruption(exception:, transitions:)
95
+ messages = MapNestedMessages[transitions, @buffer, ENV['HIDE_GIVEN_AND_CONTINUE']]
96
+
97
+ puts messages.join("\n")
98
+
99
+ bc = ::ActiveSupport::BacktraceCleaner.new
100
+ bc.add_filter { |line| line.gsub(__dir__.sub('/lib', ''), '').sub(/\A\//, '')}
101
+ bc.add_silencer { |line| /lib\/bcdd\/result/.match?(line) }
102
+ bc.add_silencer { |line| line.include?(RUBY_VERSION) }
103
+
104
+ backtrace = bc.clean(exception.backtrace)
105
+
106
+ puts "\nException: #{exception.message} (#{exception.class}); Backtrace: #{backtrace.join(", ")}"
107
+ end
108
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BCDD::Result
4
+ class CallableAndThen::Caller
5
+ def self.call(source, value:, injected_value:, method_name:)
6
+ method = callable_method(source, method_name)
7
+
8
+ Transitions.tracking.record_and_then(method, injected_value) do
9
+ result =
10
+ if source.is_a?(::Proc)
11
+ call_proc!(source, value, injected_value)
12
+ else
13
+ call_method!(source, method, value, injected_value)
14
+ end
15
+
16
+ ensure_result_object(source, value, result)
17
+ end
18
+ end
19
+
20
+ def self.call_proc!(source, value, injected_value)
21
+ case source.arity
22
+ when 1 then source.call(value)
23
+ when 2 then source.call(value, injected_value)
24
+ else raise CallableAndThen::Error::InvalidArity.build(source: source, method: :call, arity: '1..2')
25
+ end
26
+ end
27
+
28
+ def self.call_method!(source, method, value, injected_value)
29
+ case method.arity
30
+ when 1 then source.send(method.name, value)
31
+ when 2 then source.send(method.name, value, injected_value)
32
+ else raise CallableAndThen::Error::InvalidArity.build(source: source, method: method.name, arity: '1..2')
33
+ end
34
+ end
35
+
36
+ def self.callable_method(source, method_name)
37
+ source.method(method_name || Config.instance.and_then!.default_method_name_to_call)
38
+ end
39
+
40
+ def self.ensure_result_object(source, _value, result)
41
+ return result if result.is_a?(::BCDD::Result)
42
+
43
+ raise Error::UnexpectedOutcome.build(outcome: result, origin: source)
44
+ end
45
+
46
+ private_class_method :new, :allocate
47
+ private_class_method :call_proc!, :call_method!, :callable_method, :ensure_result_object
48
+ end
49
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BCDD::Result
4
+ class CallableAndThen::Config
5
+ attr_accessor :default_method_name_to_call
6
+
7
+ def initialize
8
+ self.default_method_name_to_call = :call
9
+ end
10
+
11
+ def options
12
+ { default_method_name_to_call: default_method_name_to_call }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BCDD::Result
4
+ class CallableAndThen::Error < Error
5
+ class InvalidArity < self
6
+ def self.build(source:, method:, arity:)
7
+ new("Invalid arity for #{source.class}##{method} method. Expected arity: #{arity}")
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BCDD::Result
4
+ module CallableAndThen
5
+ require_relative 'callable_and_then/error'
6
+ require_relative 'callable_and_then/config'
7
+ require_relative 'callable_and_then/caller'
8
+ end
9
+ end
@@ -10,7 +10,11 @@ class BCDD::Result
10
10
  },
11
11
  transitions: {
12
12
  default: true,
13
- affects: %w[BCDD::Result BCDD::Result::Context]
13
+ affects: %w[BCDD::Result BCDD::Result::Context BCDD::Result::Expectations BCDD::Result::Context::Expectations]
14
+ },
15
+ and_then!: {
16
+ default: false,
17
+ affects: %w[BCDD::Result BCDD::Result::Context BCDD::Result::Expectations BCDD::Result::Context::Expectations]
14
18
  }
15
19
  }.transform_values!(&:freeze).freeze
16
20
 
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'singleton'
4
-
5
3
  require_relative 'config/options'
6
4
  require_relative 'config/switcher'
7
5
  require_relative 'config/switchers/addons'
@@ -11,7 +9,7 @@ require_relative 'config/switchers/pattern_matching'
11
9
 
12
10
  class BCDD::Result
13
11
  class Config
14
- include Singleton
12
+ include ::Singleton
15
13
 
16
14
  attr_reader :addon, :feature, :constant_alias, :pattern_matching
17
15
 
@@ -20,6 +18,15 @@ class BCDD::Result
20
18
  @feature = Features.switcher
21
19
  @constant_alias = ConstantAliases.switcher
22
20
  @pattern_matching = PatternMatching.switcher
21
+ @and_then_ = CallableAndThen::Config.new
22
+ end
23
+
24
+ def transitions
25
+ Transitions::Config.instance
26
+ end
27
+
28
+ def and_then!
29
+ @and_then_
23
30
  end
24
31
 
25
32
  def freeze
@@ -27,6 +34,8 @@ class BCDD::Result
27
34
  feature.freeze
28
35
  constant_alias.freeze
29
36
  pattern_matching.freeze
37
+ and_then!.freeze
38
+ transitions.freeze
30
39
 
31
40
  super
32
41
  end
@@ -45,7 +54,9 @@ class BCDD::Result
45
54
  end
46
55
 
47
56
  def inspect
48
- "#<#{self.class.name} options=#{options.keys.sort.inspect}>"
57
+ "#<#{self.class.name} " \
58
+ "options=#{options.keys.sort.inspect} " \
59
+ "and_then!=#{and_then!.options.inspect}>"
49
60
  end
50
61
  end
51
62
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BCDD::Result
4
+ module Context::CallableAndThen
5
+ class Caller < CallableAndThen::Caller
6
+ module KeyArgs
7
+ def self.parameters?(source)
8
+ parameters = source.parameters.map(&:first)
9
+
10
+ !parameters.empty? && parameters.all?(/\Akey/)
11
+ end
12
+
13
+ def self.invalid_arity(source, method)
14
+ CallableAndThen::Error::InvalidArity.build(source: source, method: method, arity: 'only keyword args')
15
+ end
16
+ end
17
+
18
+ def self.call_proc!(source, value, _injected_value)
19
+ return source.call(**value) if KeyArgs.parameters?(source)
20
+
21
+ raise KeyArgs.invalid_arity(source, :call)
22
+ end
23
+
24
+ def self.call_method!(source, method, value, _injected_value)
25
+ return source.send(method.name, **value) if KeyArgs.parameters?(method)
26
+
27
+ raise KeyArgs.invalid_arity(source, method.name)
28
+ end
29
+
30
+ def self.ensure_result_object(source, value, result)
31
+ return result.tap { result.send(:acc).then { _1.merge!(value.merge(_1)) } } if result.is_a?(Context)
32
+
33
+ raise Error::UnexpectedOutcome.build(outcome: result, origin: source, expected: Context::EXPECTED_OUTCOME)
34
+ end
35
+
36
+ private_class_method :call_proc!, :call_method!
37
+ end
38
+ end
39
+ end
@@ -9,7 +9,7 @@ class BCDD::Result::Context
9
9
  module Addons
10
10
  module Continue
11
11
  private def Continue(**value)
12
- Success.new(type: :continued, value: value, subject: self)
12
+ Success(::BCDD::Result::IgnoredTypes::CONTINUE, **value)
13
13
  end
14
14
  end
15
15
 
@@ -17,7 +17,7 @@ class BCDD::Result::Context
17
17
  private def Given(*values)
18
18
  value = values.map(&:to_h).reduce({}) { |acc, val| acc.merge(val) }
19
19
 
20
- Success.new(type: :given, value: value, subject: self)
20
+ Success(::BCDD::Result::IgnoredTypes::GIVEN, **value)
21
21
  end
22
22
  end
23
23