bcdd-result 0.12.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.
- checksums.yaml +4 -4
- data/.rubocop.yml +16 -1
- data/CHANGELOG.md +70 -16
- data/README.md +293 -83
- data/Steepfile +4 -4
- data/examples/multiple_listeners/Rakefile +55 -0
- data/examples/multiple_listeners/app/models/account/member.rb +10 -0
- data/examples/multiple_listeners/app/models/account/owner_creation.rb +62 -0
- data/examples/multiple_listeners/app/models/account.rb +11 -0
- data/examples/multiple_listeners/app/models/user/creation.rb +67 -0
- data/examples/multiple_listeners/app/models/user/token/creation.rb +51 -0
- data/examples/multiple_listeners/app/models/user/token.rb +7 -0
- data/examples/multiple_listeners/app/models/user.rb +15 -0
- data/examples/multiple_listeners/config/boot.rb +16 -0
- data/examples/multiple_listeners/config/initializers/bcdd.rb +11 -0
- data/examples/multiple_listeners/config.rb +27 -0
- data/examples/multiple_listeners/db/setup.rb +61 -0
- data/examples/multiple_listeners/lib/bcdd/result/rollback_on_failure.rb +15 -0
- data/examples/multiple_listeners/lib/bcdd/result/transitions_record.rb +28 -0
- data/examples/multiple_listeners/lib/runtime_breaker.rb +11 -0
- data/examples/multiple_listeners/lib/transitions_listener/stdout.rb +54 -0
- data/examples/single_listener/Rakefile +92 -0
- data/examples/single_listener/app/models/account/member.rb +10 -0
- data/examples/single_listener/app/models/account/owner_creation.rb +62 -0
- data/examples/single_listener/app/models/account.rb +11 -0
- data/examples/single_listener/app/models/user/creation.rb +67 -0
- data/examples/single_listener/app/models/user/token/creation.rb +51 -0
- data/examples/single_listener/app/models/user/token.rb +7 -0
- data/examples/single_listener/app/models/user.rb +15 -0
- data/examples/single_listener/config/boot.rb +16 -0
- data/examples/single_listener/config/initializers/bcdd.rb +11 -0
- data/examples/single_listener/config.rb +23 -0
- data/examples/single_listener/db/setup.rb +49 -0
- data/examples/single_listener/lib/bcdd/result/rollback_on_failure.rb +15 -0
- data/examples/single_listener/lib/runtime_breaker.rb +11 -0
- data/examples/single_listener/lib/single_transitions_listener.rb +108 -0
- data/lib/bcdd/result/callable_and_then/caller.rb +1 -1
- data/lib/bcdd/result/config.rb +6 -1
- data/lib/bcdd/result/context/expectations/mixin.rb +2 -2
- data/lib/bcdd/result/context/mixin.rb +2 -2
- data/lib/bcdd/result/context/success.rb +20 -2
- data/lib/bcdd/result/contract/for_types.rb +1 -1
- data/lib/bcdd/result/contract/for_types_and_values.rb +2 -0
- data/lib/bcdd/result/expectations/mixin.rb +2 -2
- data/lib/bcdd/result/ignored_types.rb +14 -0
- data/lib/bcdd/result/mixin.rb +2 -2
- data/lib/bcdd/result/transitions/config.rb +26 -0
- data/lib/bcdd/result/transitions/listener.rb +51 -0
- data/lib/bcdd/result/transitions/listeners.rb +87 -0
- data/lib/bcdd/result/transitions/tracking/disabled.rb +1 -13
- data/lib/bcdd/result/transitions/tracking/enabled.rb +76 -17
- data/lib/bcdd/result/transitions/tracking.rb +8 -3
- data/lib/bcdd/result/transitions/tree.rb +26 -0
- data/lib/bcdd/result/transitions.rb +3 -4
- data/lib/bcdd/result/version.rb +1 -1
- data/lib/bcdd/result.rb +7 -5
- data/sig/bcdd/result/config.rbs +1 -0
- data/sig/bcdd/result/context.rbs +9 -0
- data/sig/bcdd/result/ignored_types.rbs +9 -0
- data/sig/bcdd/result/transitions.rbs +96 -7
- data/sig/bcdd/result.rbs +2 -2
- metadata +42 -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,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,27 @@
|
|
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/bcdd/result/transitions_record'
|
14
|
+
require 'lib/runtime_breaker'
|
15
|
+
|
16
|
+
module TransitionsListener
|
17
|
+
require 'lib/transitions_listener/stdout'
|
18
|
+
end
|
19
|
+
|
20
|
+
require 'app/models/account'
|
21
|
+
require 'app/models/account/member'
|
22
|
+
require 'app/models/user'
|
23
|
+
require 'app/models/user/token'
|
24
|
+
|
25
|
+
require 'app/models/account/owner_creation'
|
26
|
+
require 'app/models/user/token/creation'
|
27
|
+
require 'app/models/user/creation'
|
@@ -0,0 +1,61 @@
|
|
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 :bcdd_result_transitions do |t|
|
14
|
+
t.string :root_name, null: false, index: true
|
15
|
+
t.string :trace_id, index: true
|
16
|
+
t.integer :version, null: false
|
17
|
+
t.integer :duration, null: false, index: true
|
18
|
+
t.json :ids_tree, null: false, default: []
|
19
|
+
t.json :ids_matrix, null: false, default: {}
|
20
|
+
t.json :records, null: false, default: []
|
21
|
+
|
22
|
+
t.timestamps
|
23
|
+
end
|
24
|
+
|
25
|
+
create_table :accounts do |t|
|
26
|
+
t.string :uuid, null: false, index: {unique: true}
|
27
|
+
|
28
|
+
t.timestamps
|
29
|
+
end
|
30
|
+
|
31
|
+
create_table :users do |t|
|
32
|
+
t.string :uuid, null: false, index: {unique: true}
|
33
|
+
t.string :name, null: false
|
34
|
+
t.string :email, null: false, index: {unique: true}
|
35
|
+
t.string :password_digest, null: false
|
36
|
+
|
37
|
+
t.timestamps
|
38
|
+
end
|
39
|
+
|
40
|
+
create_table :user_tokens do |t|
|
41
|
+
t.belongs_to :user, null: false, foreign_key: true, index: true
|
42
|
+
t.string :access_token, null: false
|
43
|
+
t.string :refresh_token, null: false
|
44
|
+
t.datetime :access_token_expires_at, null: false
|
45
|
+
t.datetime :refresh_token_expires_at, null: false
|
46
|
+
|
47
|
+
t.timestamps
|
48
|
+
end
|
49
|
+
|
50
|
+
create_table :account_members do |t|
|
51
|
+
t.integer :role, null: false, default: 0
|
52
|
+
t.belongs_to :user, null: false, foreign_key: true, index: true
|
53
|
+
t.belongs_to :account, null: false, foreign_key: true, index: true
|
54
|
+
|
55
|
+
t.timestamps
|
56
|
+
|
57
|
+
t.index %i[account_id role], unique: true, where: "(role = 0)"
|
58
|
+
t.index %i[account_id user_id], unique: true
|
59
|
+
end
|
60
|
+
end
|
61
|
+
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,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class BCDD::Result::TransitionsRecord < ActiveRecord::Base
|
4
|
+
self.table_name = 'bcdd_result_transitions'
|
5
|
+
|
6
|
+
class Listener
|
7
|
+
include ::BCDD::Result::Transitions::Listener
|
8
|
+
|
9
|
+
def on_finish(transitions:)
|
10
|
+
metadata = transitions[:metadata]
|
11
|
+
root_name = transitions.dig(:records, 0, :root, :name) || 'Unknown'
|
12
|
+
|
13
|
+
BCDD::Result::TransitionsRecord.create(
|
14
|
+
root_name: root_name,
|
15
|
+
trace_id: metadata[:trace_id],
|
16
|
+
version: transitions[:version],
|
17
|
+
duration: metadata[:duration],
|
18
|
+
ids_tree: metadata[:ids_tree],
|
19
|
+
ids_matrix: metadata[:ids_matrix],
|
20
|
+
records: transitions[:records]
|
21
|
+
)
|
22
|
+
rescue ::StandardError => e
|
23
|
+
err = "#{e.message} (#{e.class}); Backtrace: #{e.backtrace.join(', ')}"
|
24
|
+
|
25
|
+
::Kernel.warn "Error on BCDD::Result::TransitionsRecord::Listener#on_finish: #{err}"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
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,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class TransitionsListener::Stdout
|
4
|
+
include BCDD::Result::Transitions::Listener
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
@buffer = []
|
8
|
+
end
|
9
|
+
|
10
|
+
def on_start(scope:)
|
11
|
+
scope => { id:, name:, desc: }
|
12
|
+
|
13
|
+
@buffer << [id, "##{id} #{name} - #{desc}".chomp('- ')]
|
14
|
+
end
|
15
|
+
|
16
|
+
def on_record(record:)
|
17
|
+
record => { current: { id: }, result: { kind:, type: } }
|
18
|
+
|
19
|
+
method_name = record.dig(:and_then, :method_name)
|
20
|
+
|
21
|
+
@buffer << [id, " * #{kind}(#{type}) from method: #{method_name}".chomp('from method: ')]
|
22
|
+
end
|
23
|
+
|
24
|
+
MapNestedMessages = ->(transitions, buffer, hide_given_and_continue) do
|
25
|
+
ids_matrix = transitions.dig(:metadata, :ids_matrix)
|
26
|
+
|
27
|
+
messages = buffer.filter_map { |(id, msg)| "#{' ' * ids_matrix[id].last}#{msg}" if ids_matrix[id] }
|
28
|
+
|
29
|
+
messages.reject! { _1.match?(/\(_(given|continue)_\)/) } if hide_given_and_continue
|
30
|
+
|
31
|
+
messages
|
32
|
+
end
|
33
|
+
|
34
|
+
def on_finish(transitions:)
|
35
|
+
messages = MapNestedMessages[transitions, @buffer, ENV['HIDE_GIVEN_AND_CONTINUE']]
|
36
|
+
|
37
|
+
puts messages.join("\n")
|
38
|
+
end
|
39
|
+
|
40
|
+
def before_interruption(exception:, transitions:)
|
41
|
+
messages = MapNestedMessages[transitions, @buffer, ENV['HIDE_GIVEN_AND_CONTINUE']]
|
42
|
+
|
43
|
+
puts messages.join("\n")
|
44
|
+
|
45
|
+
bc = ::ActiveSupport::BacktraceCleaner.new
|
46
|
+
bc.add_filter { |line| line.gsub(__dir__.sub('/lib', ''), '').sub(/\A\//, '')}
|
47
|
+
bc.add_silencer { |line| /lib\/bcdd\/result/.match?(line) }
|
48
|
+
bc.add_silencer { |line| line.include?(RUBY_VERSION) }
|
49
|
+
|
50
|
+
backtrace = bc.clean(exception.backtrace)
|
51
|
+
|
52
|
+
puts "\nException: #{exception.message} (#{exception.class}); Backtrace: #{backtrace.join(", ")}"
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
if RUBY_VERSION <= '3.1'
|
4
|
+
puts 'This example requires Ruby 3.1 or higher.'
|
5
|
+
exit! 1
|
6
|
+
end
|
7
|
+
|
8
|
+
# Usage:
|
9
|
+
#
|
10
|
+
# rake DISABLE_TRANSITIONS=t
|
11
|
+
# rake DISABLE_LISTENER=t
|
12
|
+
#
|
13
|
+
# rake HIDE_GIVEN_AND_CONTINUE=t
|
14
|
+
#
|
15
|
+
# rake BREAK_ACCOUNT_CREATION=t
|
16
|
+
# rake BREAK_USER_CREATION=t
|
17
|
+
# rake BREAK_USER_TOKEN_CREATION=t
|
18
|
+
#
|
19
|
+
# rake BREAK_ACCOUNT_CREATION=t HIDE_GIVEN_AND_CONTINUE=t
|
20
|
+
task default: %i[bcdd_result_transitions]
|
21
|
+
|
22
|
+
task :config do
|
23
|
+
require_relative 'config'
|
24
|
+
end
|
25
|
+
|
26
|
+
desc 'creates an account and an owner user through BCDD::Result'
|
27
|
+
task bcdd_result_transitions: %i[config] do
|
28
|
+
BCDD::Result.configuration do |config|
|
29
|
+
config.feature.disable!(:transitions) if ENV['DISABLE_TRANSITIONS']
|
30
|
+
|
31
|
+
config.transitions.listener = SingleTransitionsListener unless ENV['DISABLE_LISTENER']
|
32
|
+
end
|
33
|
+
|
34
|
+
result = nil
|
35
|
+
|
36
|
+
bench = Benchmark.measure do
|
37
|
+
result = Account::OwnerCreation.new.call(
|
38
|
+
owner: {
|
39
|
+
name: "\tJohn Doe \n",
|
40
|
+
email: ' JOHN.doe@email.com',
|
41
|
+
password: '123123123',
|
42
|
+
password_confirmation: '123123123'
|
43
|
+
}
|
44
|
+
)
|
45
|
+
rescue RuntimeBreaker::Interruption => e
|
46
|
+
nil
|
47
|
+
end
|
48
|
+
|
49
|
+
puts "\nBenchmark: #{bench}"
|
50
|
+
end
|
51
|
+
|
52
|
+
desc 'creates an account and an owner user directly through ActiveRecord'
|
53
|
+
task raw_active_record: %i[config] do
|
54
|
+
require_relative 'config'
|
55
|
+
|
56
|
+
result = nil
|
57
|
+
|
58
|
+
bench = Benchmark.measure do
|
59
|
+
email = 'john.doe@email.com'
|
60
|
+
|
61
|
+
ActiveRecord::Base.transaction do
|
62
|
+
User.exists?(email:) and raise "User with email #{email} already exists"
|
63
|
+
|
64
|
+
user = User.create!(
|
65
|
+
uuid: ::SecureRandom.uuid,
|
66
|
+
name: 'John Doe',
|
67
|
+
email:,
|
68
|
+
password: '123123123',
|
69
|
+
password_confirmation: '123123123'
|
70
|
+
)
|
71
|
+
|
72
|
+
executed_at = ::Time.current
|
73
|
+
|
74
|
+
user.token.nil? or raise "User with email #{email} already has a token"
|
75
|
+
|
76
|
+
user.create_token!(
|
77
|
+
access_token: ::SecureRandom.hex(24),
|
78
|
+
refresh_token: ::SecureRandom.hex(24),
|
79
|
+
access_token_expires_at: executed_at + 15.days,
|
80
|
+
refresh_token_expires_at: executed_at + 30.days
|
81
|
+
)
|
82
|
+
|
83
|
+
account = Account.create!(uuid: ::SecureRandom.uuid)
|
84
|
+
|
85
|
+
Account::Member.create!(account: account, user: user, role: :owner)
|
86
|
+
|
87
|
+
result = { account: account, user: user }
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
puts "\nBenchmark: #{bench}"
|
92
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Account::Member < ActiveRecord::Base
|
4
|
+
self.table_name = 'account_members'
|
5
|
+
|
6
|
+
enum role: { owner: 0, admin: 1, contributor: 2 }
|
7
|
+
|
8
|
+
belongs_to :user, inverse_of: :memberships
|
9
|
+
belongs_to :account, inverse_of: :memberships
|
10
|
+
end
|
@@ -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
|