bcdd-result 0.6.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +31 -11
  3. data/CHANGELOG.md +148 -0
  4. data/README.md +849 -242
  5. data/Rakefile +9 -3
  6. data/Steepfile +1 -1
  7. data/lib/bcdd/result/config/constant_alias.rb +33 -0
  8. data/lib/bcdd/result/config/options.rb +26 -0
  9. data/lib/bcdd/result/config/switcher.rb +82 -0
  10. data/lib/bcdd/result/config.rb +71 -0
  11. data/lib/bcdd/result/context/expectations/mixin.rb +23 -0
  12. data/lib/bcdd/result/context/expectations.rb +25 -0
  13. data/lib/bcdd/result/context/failure.rb +9 -0
  14. data/lib/bcdd/result/context/mixin.rb +41 -0
  15. data/lib/bcdd/result/context/success.rb +15 -0
  16. data/lib/bcdd/result/context.rb +74 -0
  17. data/lib/bcdd/result/{expectations/contract → contract}/disabled.rb +2 -2
  18. data/lib/bcdd/result/{expectations → contract}/error.rb +5 -3
  19. data/lib/bcdd/result/{expectations/contract → contract}/evaluator.rb +2 -2
  20. data/lib/bcdd/result/{expectations/contract → contract}/for_types.rb +2 -2
  21. data/lib/bcdd/result/contract/for_types_and_values.rb +44 -0
  22. data/lib/bcdd/result/contract/interface.rb +21 -0
  23. data/lib/bcdd/result/{expectations → contract}/type_checker.rb +1 -1
  24. data/lib/bcdd/result/contract.rb +33 -0
  25. data/lib/bcdd/result/error.rb +7 -9
  26. data/lib/bcdd/result/expectations/mixin.rb +19 -12
  27. data/lib/bcdd/result/expectations.rb +51 -36
  28. data/lib/bcdd/result/failure/methods.rb +21 -0
  29. data/lib/bcdd/result/failure.rb +2 -16
  30. data/lib/bcdd/result/mixin.rb +26 -8
  31. data/lib/bcdd/result/success/methods.rb +21 -0
  32. data/lib/bcdd/result/success.rb +2 -16
  33. data/lib/bcdd/result/version.rb +1 -1
  34. data/lib/bcdd/result.rb +17 -4
  35. data/lib/bcdd-result.rb +3 -0
  36. data/sig/bcdd/result.rbs +340 -88
  37. metadata +27 -16
  38. data/lib/bcdd/result/expectations/contract/for_types_and_values.rb +0 -37
  39. data/lib/bcdd/result/expectations/contract/interface.rb +0 -21
  40. data/lib/bcdd/result/expectations/contract.rb +0 -25
  41. data/lib/result.rb +0 -5
data/Rakefile CHANGED
@@ -3,10 +3,16 @@
3
3
  require 'bundler/gem_tasks'
4
4
  require 'rake/testtask'
5
5
 
6
+ Rake::TestTask.new(:test_configuration) do |t|
7
+ t.libs += %w[lib test]
8
+
9
+ t.test_files = FileList.new('test/**/configuration_test.rb')
10
+ end
11
+
6
12
  Rake::TestTask.new(:test) do |t|
7
- t.libs << 'test'
8
- t.libs << 'lib'
9
- t.test_files = FileList['test/**/*_test.rb']
13
+ t.libs += %w[lib test]
14
+
15
+ t.test_files = FileList.new('test/**/*_test.rb')
10
16
  end
11
17
 
12
18
  require 'rubocop/rake_task'
data/Steepfile CHANGED
@@ -10,7 +10,7 @@ target :lib do
10
10
  # check 'app/models/**/*.rb' # Glob
11
11
  # ignore 'lib/templates/*.rb'
12
12
 
13
- # library 'pathname' # Standard libraries
13
+ library 'singleton' # Standard libraries
14
14
  # library 'strong_json' # Gems
15
15
 
16
16
  # configure_code_diagnostics(D::Ruby.default) # `default` diagnostics setting (applies by default)
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BCDD::Result
4
+ class Config
5
+ module ConstantAlias
6
+ RESULT = 'Result'
7
+
8
+ OPTIONS = {
9
+ RESULT => { default: false, affects: %w[Object] }
10
+ }.transform_values!(&:freeze).freeze
11
+
12
+ MAPPING = {
13
+ RESULT => { target: ::Object, name: :Result, value: ::BCDD::Result }
14
+ }.transform_values!(&:freeze).freeze
15
+
16
+ Listener = ->(option_name, boolean) do
17
+ mapping = MAPPING.fetch(option_name)
18
+
19
+ target, name, value = mapping.fetch_values(:target, :name, :value)
20
+
21
+ defined = target.const_defined?(name, false)
22
+
23
+ boolean ? defined || target.const_set(name, value) : defined && target.send(:remove_const, name)
24
+ end
25
+
26
+ def self.switcher
27
+ Switcher.new(options: OPTIONS, listener: Listener)
28
+ end
29
+ end
30
+
31
+ private_constant :ConstantAlias
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BCDD::Result
4
+ class Config
5
+ module Options
6
+ def self.with_defaults(all_flags, config)
7
+ all_flags ||= {}
8
+
9
+ default_flags = Config.instance.to_h.fetch(config)
10
+
11
+ config_flags = all_flags.fetch(config, {})
12
+
13
+ default_flags.merge(config_flags).slice(*default_flags.keys)
14
+ end
15
+
16
+ def self.filter_map(all_flags, config:, from:)
17
+ with_defaults(all_flags, config)
18
+ .filter_map { |name, truthy| from[name] if truthy }
19
+ end
20
+
21
+ def self.addon(map:, from:)
22
+ filter_map(map, config: :addon, from: from)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BCDD::Result
4
+ class Config
5
+ class Switcher
6
+ attr_reader :_options, :_affects, :listener
7
+
8
+ private :_options, :_affects, :listener
9
+
10
+ def initialize(options:, listener: nil)
11
+ @_options = options.transform_values { _1.fetch(:default) }
12
+ @_affects = options.transform_values { _1.fetch(:affects) }
13
+ @listener = listener
14
+ end
15
+
16
+ def inspect
17
+ "#<#{self.class.name} options=#{_options.inspect}>"
18
+ end
19
+
20
+ def freeze
21
+ _options.freeze
22
+ super
23
+ end
24
+
25
+ def to_h
26
+ _options.dup
27
+ end
28
+
29
+ def options
30
+ _affects.to_h { |name, affects| [name, { enabled: _options[name], affects: affects }] }
31
+ end
32
+
33
+ def enabled?(name)
34
+ _options[name] || false
35
+ end
36
+
37
+ def enable!(*names)
38
+ set_many(names, to: true)
39
+ end
40
+
41
+ def disable!(*names)
42
+ set_many(names, to: false)
43
+ end
44
+
45
+ private
46
+
47
+ def set_many(names, to:)
48
+ require_option!(names)
49
+
50
+ names.each do |name|
51
+ set_one(name, to)
52
+
53
+ listener&.call(name, to)
54
+ end
55
+
56
+ options.slice(*names)
57
+ end
58
+
59
+ def set_one(name, boolean)
60
+ validate_option!(name)
61
+
62
+ _options[name] = boolean
63
+ end
64
+
65
+ def require_option!(names)
66
+ raise ::ArgumentError, "One or more options required. #{available_options_message}" if names.empty?
67
+ end
68
+
69
+ def validate_option!(name)
70
+ return if _options.key?(name)
71
+
72
+ raise ::ArgumentError, "Invalid option: #{name.inspect}. #{available_options_message}"
73
+ end
74
+
75
+ def available_options_message
76
+ "Available options: #{_options.keys.map(&:inspect).join(', ')}"
77
+ end
78
+ end
79
+
80
+ private_constant :Switcher
81
+ end
82
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+
5
+ require_relative 'config/options'
6
+ require_relative 'config/switcher'
7
+ require_relative 'config/constant_alias'
8
+
9
+ class BCDD::Result
10
+ class Config
11
+ include Singleton
12
+
13
+ ADDON = {
14
+ continue: {
15
+ default: false,
16
+ affects: %w[BCDD::Result BCDD::Result::Context BCDD::Result::Expectations BCDD::Result::Context::Expectations]
17
+ }
18
+ }.transform_values!(&:freeze).freeze
19
+
20
+ FEATURE = {
21
+ expectations: {
22
+ default: true,
23
+ affects: %w[BCDD::Result::Expectations BCDD::Result::Context::Expectations]
24
+ }
25
+ }.transform_values!(&:freeze).freeze
26
+
27
+ PATTERN_MATCHING = {
28
+ nil_as_valid_value_checking: {
29
+ default: false,
30
+ affects: %w[BCDD::Result::Expectations BCDD::Result::Context::Expectations]
31
+ }
32
+ }.transform_values!(&:freeze).freeze
33
+
34
+ attr_reader :addon, :feature, :constant_alias, :pattern_matching
35
+
36
+ def initialize
37
+ @addon = Switcher.new(options: ADDON)
38
+ @feature = Switcher.new(options: FEATURE)
39
+ @constant_alias = ConstantAlias.switcher
40
+ @pattern_matching = Switcher.new(options: PATTERN_MATCHING)
41
+ end
42
+
43
+ def freeze
44
+ addon.freeze
45
+ feature.freeze
46
+ constant_alias.freeze
47
+ pattern_matching.freeze
48
+
49
+ super
50
+ end
51
+
52
+ def options
53
+ {
54
+ addon: addon,
55
+ feature: feature,
56
+ constant_alias: constant_alias,
57
+ pattern_matching: pattern_matching
58
+ }
59
+ end
60
+
61
+ def to_h
62
+ options.transform_values(&:to_h)
63
+ end
64
+
65
+ def inspect
66
+ "#<#{self.class.name} options=#{options.keys.sort.inspect}>"
67
+ end
68
+
69
+ private_constant :ADDON, :FEATURE, :PATTERN_MATCHING
70
+ end
71
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BCDD::Result::Context
4
+ module Expectations::Mixin
5
+ Factory = BCDD::Result::Expectations::Mixin::Factory
6
+
7
+ METHODS = BCDD::Result::Expectations::Mixin::METHODS
8
+
9
+ module Addons
10
+ module Continuable
11
+ private def Continue(**value)
12
+ Success.new(type: :continued, value: value, subject: self)
13
+ end
14
+ end
15
+
16
+ OPTIONS = { continue: Continuable }.freeze
17
+
18
+ def self.options(config_flags)
19
+ ::BCDD::Result::Config::Options.addon(map: config_flags, from: OPTIONS)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BCDD::Result::Context
4
+ class Expectations < BCDD::Result::Expectations
5
+ require_relative 'expectations/mixin'
6
+
7
+ def self.mixin_module
8
+ Mixin
9
+ end
10
+
11
+ def self.result_factory_without_expectations
12
+ ::BCDD::Result::Context
13
+ end
14
+
15
+ private_class_method :mixin!, :mixin_module, :result_factory_without_expectations
16
+
17
+ def Success(type, **value)
18
+ Success.new(type: type, value: value, subject: subject, expectations: contract)
19
+ end
20
+
21
+ def Failure(type, **value)
22
+ Failure.new(type: type, value: value, subject: subject, expectations: contract)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BCDD::Result::Context::Failure < BCDD::Result::Context
4
+ include BCDD::Result::Failure::Methods
5
+
6
+ def and_expose(_type, _keys)
7
+ self
8
+ end
9
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BCDD::Result::Context
4
+ module Mixin
5
+ Factory = BCDD::Result::Mixin::Factory
6
+
7
+ module Methods
8
+ def Success(type, **value)
9
+ Success.new(type: type, value: value, subject: self)
10
+ end
11
+
12
+ def Failure(type, **value)
13
+ Failure.new(type: type, value: value, subject: self)
14
+ end
15
+ end
16
+
17
+ module Addons
18
+ module Continuable
19
+ private def Continue(**value)
20
+ Success.new(type: :continued, value: value, subject: self)
21
+ end
22
+ end
23
+
24
+ OPTIONS = { continue: Continuable }.freeze
25
+
26
+ def self.options(config_flags)
27
+ ::BCDD::Result::Config::Options.addon(map: config_flags, from: OPTIONS)
28
+ end
29
+ end
30
+ end
31
+
32
+ def self.mixin_module
33
+ Mixin
34
+ end
35
+
36
+ def self.result_factory
37
+ ::BCDD::Result::Context
38
+ end
39
+
40
+ private_class_method :mixin_module, :result_factory
41
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BCDD::Result::Context::Success < BCDD::Result::Context
4
+ include ::BCDD::Result::Success::Methods
5
+
6
+ def and_expose(type, keys)
7
+ unless keys.is_a?(::Array) && !keys.empty? && keys.all?(::Symbol)
8
+ raise ::ArgumentError, 'keys must be an Array of Symbols'
9
+ end
10
+
11
+ exposed_value = acc.merge(value).slice(*keys)
12
+
13
+ self.class.new(type: type, value: exposed_value, subject: subject)
14
+ end
15
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BCDD::Result
4
+ class Context < self
5
+ require_relative 'context/failure'
6
+ require_relative 'context/success'
7
+ require_relative 'context/mixin'
8
+ require_relative 'context/expectations'
9
+
10
+ def self.Success(type, **value)
11
+ Success.new(type: type, value: value)
12
+ end
13
+
14
+ def self.Failure(type, **value)
15
+ Failure.new(type: type, value: value)
16
+ end
17
+
18
+ def initialize(type:, value:, subject: nil, expectations: nil)
19
+ value.is_a?(::Hash) or raise ::ArgumentError, 'value must be a Hash'
20
+
21
+ @acc = {}
22
+
23
+ super
24
+ end
25
+
26
+ def and_then(method_name = nil, **context_data, &block)
27
+ super(method_name, context_data, &block)
28
+ end
29
+
30
+ protected
31
+
32
+ attr_reader :acc
33
+
34
+ private
35
+
36
+ SubjectMethodArity = ->(method) do
37
+ return 0 if method.arity.zero?
38
+ return 1 if method.parameters.map(&:first).all?(/\Akey/)
39
+
40
+ -1
41
+ end
42
+
43
+ def call_subject_method(method_name, context)
44
+ method = subject.method(method_name)
45
+
46
+ acc.merge!(value.merge(context))
47
+
48
+ result =
49
+ case SubjectMethodArity[method]
50
+ when 0 then subject.send(method_name)
51
+ when 1 then subject.send(method_name, **acc)
52
+ else raise Error::InvalidSubjectMethodArity.build(subject: subject, method: method, max_arity: 1)
53
+ end
54
+
55
+ ensure_result_object(result, origin: :method)
56
+ end
57
+
58
+ def ensure_result_object(result, origin:)
59
+ raise_unexpected_outcome_error(result, origin) unless result.is_a?(Context)
60
+
61
+ return result.tap { _1.acc.merge!(acc) } if result.subject.equal?(subject)
62
+
63
+ raise Error::InvalidResultSubject.build(given_result: result, expected_subject: subject)
64
+ end
65
+
66
+ EXPECTED_OUTCOME = 'BCDD::Result::Context::Success or BCDD::Result::Context::Failure'
67
+
68
+ def raise_unexpected_outcome_error(result, origin)
69
+ raise Error::UnexpectedOutcome.build(outcome: result, origin: origin, expected: EXPECTED_OUTCOME)
70
+ end
71
+
72
+ private_constant :SubjectMethodArity, :EXPECTED_OUTCOME
73
+ end
74
+ end
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class BCDD::Result::Expectations
3
+ class BCDD::Result
4
4
  module Contract::Disabled
5
5
  extend Contract::Interface
6
6
 
7
- EMPTY_SET = Set.new.freeze
7
+ EMPTY_SET = ::Set.new.freeze
8
8
 
9
9
  def self.allowed_types
10
10
  EMPTY_SET
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class BCDD::Result::Expectations::Error < BCDD::Result::Error
3
+ class BCDD::Result::Contract::Error < BCDD::Result::Error
4
4
  class UnexpectedType < self
5
5
  def self.build(type:, allowed_types:)
6
6
  new("type :#{type} is not allowed. Allowed types: #{allowed_types.map(&:inspect).join(', ')}")
@@ -8,8 +8,10 @@ class BCDD::Result::Expectations::Error < BCDD::Result::Error
8
8
  end
9
9
 
10
10
  class UnexpectedValue < self
11
- def self.build(type:, value:)
12
- new("value #{value.inspect} is not allowed for :#{type} type")
11
+ def self.build(type:, value:, cause: nil)
12
+ cause_message = " (#{cause.message})" if cause
13
+
14
+ new("value #{value.inspect} is not allowed for :#{type} type#{cause_message}")
13
15
  end
14
16
  end
15
17
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class BCDD::Result::Expectations
3
+ class BCDD::Result
4
4
  class Contract::Evaluator
5
5
  include Contract::Interface
6
6
 
@@ -25,7 +25,7 @@ class BCDD::Result::Expectations
25
25
  def type!(type)
26
26
  return type if type?(type)
27
27
 
28
- raise Error::UnexpectedType.build(type: type, allowed_types: allowed_types)
28
+ raise Contract::Error::UnexpectedType.build(type: type, allowed_types: allowed_types)
29
29
  end
30
30
 
31
31
  def type_and_value!(data)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class BCDD::Result::Expectations
3
+ class BCDD::Result
4
4
  class Contract::ForTypes
5
5
  include Contract::Interface
6
6
 
@@ -17,7 +17,7 @@ class BCDD::Result::Expectations
17
17
  def type!(type)
18
18
  return type if type?(type)
19
19
 
20
- raise Error::UnexpectedType.build(type: type, allowed_types: allowed_types)
20
+ raise Contract::Error::UnexpectedType.build(type: type, allowed_types: allowed_types)
21
21
  end
22
22
 
23
23
  def type_and_value!(data)
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BCDD::Result
4
+ class Contract::ForTypesAndValues
5
+ include Contract::Interface
6
+
7
+ def initialize(types_and_values, config)
8
+ @nil_as_valid_value_checking =
9
+ Config::Options
10
+ .with_defaults(config, :pattern_matching)
11
+ .fetch(:nil_as_valid_value_checking)
12
+
13
+ @types_and_values = types_and_values.transform_keys(&:to_sym)
14
+
15
+ @types_contract = Contract::ForTypes.new(@types_and_values.keys)
16
+ end
17
+
18
+ def allowed_types
19
+ @types_contract.allowed_types
20
+ end
21
+
22
+ def type?(type)
23
+ @types_contract.type?(type)
24
+ end
25
+
26
+ def type!(type)
27
+ @types_contract.type!(type)
28
+ end
29
+
30
+ def type_and_value!(data)
31
+ type, value = data.type, data.value
32
+
33
+ value_checking = @types_and_values[type!(type)]
34
+
35
+ checking_result = value_checking === value
36
+
37
+ return value if checking_result || (checking_result.nil? && @nil_as_valid_value_checking)
38
+
39
+ raise Contract::Error::UnexpectedValue.build(type: type, value: value)
40
+ rescue ::NoMatchingPatternError => e
41
+ raise Contract::Error::UnexpectedValue.build(type: data.type, value: data.value, cause: e)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BCDD::Result
4
+ module Contract::Interface
5
+ def allowed_types
6
+ raise Error::NotImplemented
7
+ end
8
+
9
+ def type?(_type)
10
+ raise Error::NotImplemented
11
+ end
12
+
13
+ def type!(_type)
14
+ raise Error::NotImplemented
15
+ end
16
+
17
+ def type_and_value!(_data)
18
+ raise Error::NotImplemented
19
+ end
20
+ end
21
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class BCDD::Result::Expectations
3
+ module BCDD::Result::Contract
4
4
  class TypeChecker
5
5
  attr_reader :result_type, :expectations
6
6
 
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BCDD::Result::Contract
4
+ require_relative 'contract/error'
5
+ require_relative 'contract/type_checker'
6
+ require_relative 'contract/interface'
7
+ require_relative 'contract/evaluator'
8
+ require_relative 'contract/disabled'
9
+ require_relative 'contract/for_types'
10
+ require_relative 'contract/for_types_and_values'
11
+
12
+ NONE = Evaluator.new(Disabled, Disabled).freeze
13
+
14
+ def self.evaluate(data, contract)
15
+ contract ||= NONE
16
+
17
+ contract.type_and_value!(data)
18
+
19
+ TypeChecker.new(data.type, expectations: contract)
20
+ end
21
+
22
+ ToEnsure = ->(spec, config) do
23
+ return Disabled if spec.nil?
24
+
25
+ spec.is_a?(::Hash) ? ForTypesAndValues.new(spec, config) : ForTypes.new(Array(spec))
26
+ end
27
+
28
+ def self.new(success:, failure:, config:)
29
+ Evaluator.new(ToEnsure[success, config], ToEnsure[failure, config])
30
+ end
31
+
32
+ private_constant :ToEnsure
33
+ end
@@ -15,16 +15,14 @@ class BCDD::Result::Error < StandardError
15
15
  end
16
16
 
17
17
  class UnexpectedOutcome < self
18
- def self.build(outcome:, origin:)
19
- message =
20
- "Unexpected outcome: #{outcome.inspect}. The #{origin} must return this object wrapped by " \
21
- 'BCDD::Result::Success or BCDD::Result::Failure'
18
+ def self.build(outcome:, origin:, expected: nil)
19
+ expected ||= 'BCDD::Result::Success or BCDD::Result::Failure'
22
20
 
23
- new(message)
21
+ new("Unexpected outcome: #{outcome.inspect}. The #{origin} must return this object wrapped by #{expected}")
24
22
  end
25
23
  end
26
24
 
27
- class WrongResultSubject < self
25
+ class InvalidResultSubject < self
28
26
  def self.build(given_result:, expected_subject:)
29
27
  message =
30
28
  "You cannot call #and_then and return a result that does not belong to the subject!\n" \
@@ -36,9 +34,9 @@ class BCDD::Result::Error < StandardError
36
34
  end
37
35
  end
38
36
 
39
- class WrongSubjectMethodArity < self
40
- def self.build(subject:, method:)
41
- new("#{subject.class}##{method.name} has unsupported arity (#{method.arity}). Expected 0, 1 or 2.")
37
+ class InvalidSubjectMethodArity < self
38
+ def self.build(subject:, method:, max_arity:)
39
+ new("#{subject.class}##{method.name} has unsupported arity (#{method.arity}). Expected 0..#{max_arity}")
42
40
  end
43
41
  end
44
42