hotstepper 0.1.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 (76) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +36 -0
  4. data/Rakefile +8 -0
  5. data/app/validators/unique_items_validator.rb +40 -0
  6. data/config/locales/custom-predicates.en.yml +70 -0
  7. data/config/locales/dry-validation-errors.en.yml +113 -0
  8. data/config/locales/en.yml +14 -0
  9. data/config/routes.rb +4 -0
  10. data/lib/boot/setup.rb +151 -0
  11. data/lib/generators/hotstepper/service/USAGE +17 -0
  12. data/lib/generators/hotstepper/service/service_generator.rb +137 -0
  13. data/lib/generators/hotstepper/service/templates/operation.rb.tt +10 -0
  14. data/lib/generators/hotstepper/service/templates/service.rb.tt +39 -0
  15. data/lib/generators/hotstepper/typespace/USAGE +8 -0
  16. data/lib/generators/hotstepper/typespace/templates/types.rb.tt +5 -0
  17. data/lib/generators/hotstepper/typespace/typespace_generator.rb +25 -0
  18. data/lib/hotstepper/arel_ext/array_encoding.rb +66 -0
  19. data/lib/hotstepper/arel_ext/base_helpers.rb +220 -0
  20. data/lib/hotstepper/arel_ext/helpers.rb +17 -0
  21. data/lib/hotstepper/arel_ext/json_encoding.rb +49 -0
  22. data/lib/hotstepper/arel_ext/ltree_encoding.rb +52 -0
  23. data/lib/hotstepper/arel_ext/postgres_helpers.rb +34 -0
  24. data/lib/hotstepper/arel_ext/types.rb +37 -0
  25. data/lib/hotstepper/arel_ext/value_select_query.rb +142 -0
  26. data/lib/hotstepper/common.rb +52 -0
  27. data/lib/hotstepper/engine.rb +38 -0
  28. data/lib/hotstepper/models/association_helpers.rb +141 -0
  29. data/lib/hotstepper/models/checks_before_destruction.rb +35 -0
  30. data/lib/hotstepper/models/id_quoting.rb +23 -0
  31. data/lib/hotstepper/models/lazy_ordering.rb +57 -0
  32. data/lib/hotstepper/models/limit_to_one.rb +35 -0
  33. data/lib/hotstepper/models/lookup_helpers.rb +21 -0
  34. data/lib/hotstepper/models/materialized_view.rb +39 -0
  35. data/lib/hotstepper/models/model.rb +40 -0
  36. data/lib/hotstepper/models/querying.rb +55 -0
  37. data/lib/hotstepper/models/timestamp_scopes.rb +26 -0
  38. data/lib/hotstepper/models/view.rb +14 -0
  39. data/lib/hotstepper/models/where_matches.rb +67 -0
  40. data/lib/hotstepper/monadic/coercion.rb +111 -0
  41. data/lib/hotstepper/monadic/decorators.rb +61 -0
  42. data/lib/hotstepper/monadic/do_for.rb +22 -0
  43. data/lib/hotstepper/monadic.rb +8 -0
  44. data/lib/hotstepper/operation.rb +41 -0
  45. data/lib/hotstepper/patterns.rb +20 -0
  46. data/lib/hotstepper/query_building.rb +142 -0
  47. data/lib/hotstepper/service.rb +84 -0
  48. data/lib/hotstepper/steps/abstract_module.rb +21 -0
  49. data/lib/hotstepper/steps/implementation.rb +39 -0
  50. data/lib/hotstepper/steps/step.rb +26 -0
  51. data/lib/hotstepper/steps/watcher.rb +23 -0
  52. data/lib/hotstepper/steps/wrapper.rb +16 -0
  53. data/lib/hotstepper/struct.rb +30 -0
  54. data/lib/hotstepper/support/file_wrapper.rb +75 -0
  55. data/lib/hotstepper/support/generators/common.rb +39 -0
  56. data/lib/hotstepper/support/generators/namespaced.rb +23 -0
  57. data/lib/hotstepper/support/generators/service_operations.rb +14 -0
  58. data/lib/hotstepper/support/generators/step_definition.rb +45 -0
  59. data/lib/hotstepper/support/namespace_wrapper.rb +73 -0
  60. data/lib/hotstepper/support/null_service.rb +14 -0
  61. data/lib/hotstepper/support/types.rb +26 -0
  62. data/lib/hotstepper/testing/do_for.rb +23 -0
  63. data/lib/hotstepper/testing/operations.rb +88 -0
  64. data/lib/hotstepper/testing/typing.rb +18 -0
  65. data/lib/hotstepper/testing.rb +24 -0
  66. data/lib/hotstepper/typespace.rb +108 -0
  67. data/lib/hotstepper/typing.rb +69 -0
  68. data/lib/hotstepper/version.rb +5 -0
  69. data/lib/hotstepper.rb +56 -0
  70. data/lib/tasks/hotstepper_tasks.rake +6 -0
  71. data/lib/testing/contexts/operations.rb +13 -0
  72. data/lib/testing/matchers/00_disable_with.rb +22 -0
  73. data/lib/testing/matchers/dry_validation.rb +94 -0
  74. data/lib/testing/matchers/monadic.rb +107 -0
  75. data/lib/testing/matchers/negated.rb +8 -0
  76. metadata +377 -0
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ module Monadic
5
+ module Coercion
6
+ include Dry::Monads[:maybe, :result, :validated]
7
+
8
+ # @api private
9
+ NO_ARG = Object.new.freeze
10
+
11
+ # @param [Object] input
12
+ # @param [:maybe, :result, :validated] mode
13
+ # @return [Dry::Monads::Maybe, Dry::Monads::Result, Dry::Monads::Validated]
14
+ def call(input, mode:)
15
+ case mode
16
+ in :maybe
17
+ coerce_to_maybe(input)
18
+ in :result
19
+ coerce_to_result(input)
20
+ in :validated
21
+ coerce_to_validated(input)
22
+ else
23
+ # :nocov:
24
+ raise ArgumentError, "invalid mode #{mode.inspect}"
25
+ # :nocov:
26
+ end
27
+ end
28
+
29
+ # @param [Object] input
30
+ # @yieldreturn [Object] the value to be converted to a monad (if necessary)
31
+ # @return [Dry::Monads::Maybe, Dry::Monads::Result, Dry::Monads::Validated]
32
+ def enforce(input = NO_ARG, mode:)
33
+ if input.equal?(NO_ARG)
34
+ # :nocov:
35
+ raise ArgumentError, "no argument given and no block provided" unless block_given?
36
+ # :nocov:
37
+
38
+ return call(yield, mode:)
39
+ end
40
+
41
+ call(input, mode:)
42
+ end
43
+
44
+ def to_maybe(input = NO_ARG, &) = enforce(input, mode: :maybe, &)
45
+
46
+ def to_result(input = NO_ARG, &) = enforce(input, mode: :result, &)
47
+
48
+ def to_validated(input = NO_ARG, &) = enforce(input, mode: :validated, &)
49
+
50
+ private
51
+
52
+ # @param [Object] input
53
+ # @return [Dry::Monads::Maybe]
54
+ def coerce_to_maybe(input)
55
+ return input if input.kind_of?(Dry::Monads::Maybe)
56
+
57
+ input.to_monad.then do |monad|
58
+ return monad if monad.kind_of?(Dry::Monads::Maybe)
59
+
60
+ return coerce_to_maybe(monad) if monad != input
61
+ end if input.respond_to?(:to_monad)
62
+
63
+ input.to_maybe.then do |maybe|
64
+ return maybe if maybe.kind_of?(Dry::Monads::Maybe)
65
+ end if input.respond_to?(:to_maybe)
66
+
67
+ Maybe(input)
68
+ end
69
+
70
+ # @param [Object] input
71
+ # @return [Dry::Monads::Result]
72
+ def coerce_to_result(input)
73
+ return input if input.kind_of?(Dry::Monads::Result)
74
+
75
+ input.to_monad.then do |monad|
76
+ return monad if monad.kind_of?(Dry::Monads::Result)
77
+ return coerce_to_result(monad) if monad != input
78
+ end if input.respond_to?(:to_monad)
79
+
80
+ input.to_result.then do |result|
81
+ return result if result.kind_of?(Dry::Monads::Result)
82
+ end if input.respond_to?(:to_result)
83
+
84
+ Success input
85
+ end
86
+
87
+ # @param [Object] input
88
+ # @return [Dry::Monads::Validated]
89
+ def coerce_to_validated(input)
90
+ return input if input.kind_of?(Dry::Monads::Validated)
91
+
92
+ # Treat Nones as Invalids when coercing to Validated, since Validated doesn't have a concept of None.
93
+ return Invalid(:none) if input.kind_of?(Dry::Monads::Maybe) && input.none?
94
+
95
+ # Treat Somes as Valids when coercing to Validated, since Validated doesn't have a concept of Some.
96
+ return Valid(input.value!) if input.kind_of?(Dry::Monads::Maybe) && input.some?
97
+
98
+ input.to_monad.then do |monad|
99
+ return monad if monad.kind_of?(Dry::Monads::Validated)
100
+ return coerce_to_validated(monad) if monad != input
101
+ end if input.respond_to?(:to_monad)
102
+
103
+ input.to_validated.then do |validated|
104
+ return validated if validated.kind_of?(Dry::Monads::Validated)
105
+ end if input.respond_to?(:to_validated)
106
+
107
+ Valid(input)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ module Monadic
5
+ # A concern that should be `extend`ed on classes to define monadic operations
6
+ # on them. It is used to DRY up the definition of `!` methods for monadic
7
+ # operations, matchers, and validatators.
8
+ module Decorators
9
+ ON_VALID_RESULT = proc {}
10
+
11
+ private_constant :ON_VALID_RESULT
12
+
13
+ # @param [Symbol] method_name
14
+ # @param [Class] with a Dry::Matcher class
15
+ # @return [void]
16
+ def monadic_matcher!(method_name, with: Dry::Matcher::ResultMatcher)
17
+ include Dry::Matcher.for(method_name, with:)
18
+
19
+ monadic_operation!(method_name)
20
+ end
21
+
22
+ # @param [Symbol] method_name
23
+ # @return [void]
24
+ def monadic_operation!(method_name)
25
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
26
+ def #{method_name}!(...)
27
+ #{method_name}(...).value!.then do |result|
28
+ Dry::Monads::Unit == result ? nil : result
29
+ end
30
+ end
31
+ RUBY
32
+ end
33
+
34
+ # @param [Symbol] method_name
35
+ # @return [void]
36
+ def monadic_validator!(method_name)
37
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
38
+ def #{method_name}!(...)
39
+ enumerate_reasons = ->(reasons) do
40
+ reasons.to_a.each do |reason|
41
+ yield reason if block_given?
42
+ end
43
+ end
44
+
45
+ #{method_name}(...).either(
46
+ ON_VALID_RESULT,
47
+ enumerate_reasons
48
+ )
49
+ end
50
+ RUBY
51
+ end
52
+
53
+ class << self
54
+ # @return [void]
55
+ def included(base)
56
+ raise "This module is meant to be extended, not included"
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ module Monadic
5
+ # A module that gives you a `do_for!` decorator
6
+ module DoFor
7
+ # @param [Symbol] name
8
+ # @return [Symbol]
9
+ def do_for!(name)
10
+ include Dry::Monads::Do.for name
11
+
12
+ # If this is being called as a decorator, the method is already defined and dry-monad's
13
+ # method watcher won't fire. We trigger it manually.
14
+ if method_defined?(name) || protected_method_defined?(name) || private_method_defined?(name)
15
+ __send__(:method_added, name)
16
+ end
17
+
18
+ name
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ # Monadic helpers for service objects.
5
+ module Monadic
6
+ extend Hotstepper::Monadic::Coercion
7
+ end
8
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ # @abstract
5
+ #
6
+ # A base class for defining an operation that takes its args
7
+ # and provides them to a specific service.
8
+ #
9
+ # The operation is automatically set up to support dry-matcher result matching.
10
+ #
11
+ # It allows services to be dependency-injected with dry-rails / dry-system.
12
+ class Operation
13
+ extend Dry::Core::ClassAttributes
14
+
15
+ defines :service_klass, type: Hotstepper::Support::Types::ServiceClass
16
+
17
+ service_klass Hotstepper::Support::NullService
18
+
19
+ # @return [Object] whatever the service class' call method returns,
20
+ # which is often a `Dry::Monads::Result` of some kind.
21
+ def call(...) = self.class.service_klass.new(...).call
22
+
23
+ class << self
24
+ # @api private
25
+ # @return [void]
26
+ def set_up_matcher!
27
+ include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)
28
+ end
29
+
30
+ # @param [Class] klass
31
+ # @param [Boolean] with_matcher whether to set up `Dry::Matcher::ResultMatcher`
32
+ # for fluent handling of the service's result.
33
+ # @return [void]
34
+ def wraps_service!(klass, with_matcher: false)
35
+ service_klass klass
36
+
37
+ set_up_matcher! if with_matcher
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ module Patterns
5
+ # @return [Regexp]
6
+ EMAIL = URI::MailTo::EMAIL_REGEXP
7
+
8
+ # @return [Regexp]
9
+ GlobalID = URI::DEFAULT_PARSER.make_regexp("gid")
10
+
11
+ # @return [Regexp]
12
+ HTTP = URI::DEFAULT_PARSER.make_regexp(/https?/)
13
+
14
+ # @return [Regexp]
15
+ HTTPS = URI::DEFAULT_PARSER.make_regexp("https")
16
+
17
+ # @return [Regexp]
18
+ STEP_NAME = /\A[a-zA-Z_]\w+[a-z0-9]*\z/
19
+ end
20
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ # Methods for working directly with SQL queries in operations and services.
5
+ #
6
+ # To use this module, include it in your ruby class and then call the `sql_*` methods
7
+ # to execute SQL queries.
8
+ # * The `compile_*` methods can be used to build SQL query strings
9
+ # from template strings
10
+ # * The `with_*` methods can be used to conditionally format SQL templates based
11
+ # on the presence of their inputs
12
+ module QueryBuilding
13
+ extend ActiveSupport::Concern
14
+
15
+ included do
16
+ include Dry::Monads[:result]
17
+
18
+ delegate :quote_column_name, to: :connection
19
+ end
20
+
21
+ # @param [<String>] parts
22
+ # @return [void]
23
+ def sql_select!(*parts)
24
+ ApplicationRecord.uncached do
25
+ connection.exec_query compile_query(*parts)
26
+ end
27
+ end
28
+
29
+ alias sql_exec! sql_select!
30
+
31
+ # @param [<String>] parts
32
+ # @return [void]
33
+ def sql_insert!(*parts)
34
+ # We use exec_query instead of exec_insert,
35
+ # because we want control over RETURNING
36
+ connection.exec_query compile_query(*parts)
37
+ ensure
38
+ clear_query_cache!
39
+ end
40
+
41
+ # @param [<String>] parts
42
+ # @return [Integer]
43
+ def sql_update!(*parts)
44
+ connection.exec_update compile_query(*parts)
45
+ ensure
46
+ clear_query_cache!
47
+ end
48
+
49
+ # @param [<String>] parts
50
+ # @return [Integer]
51
+ def sql_delete!(*parts)
52
+ connection.exec_delete compile_query(*parts)
53
+ ensure
54
+ clear_query_cache!
55
+ end
56
+
57
+ # @param [Symbol] table_name
58
+ # @return [Arel::Table]
59
+ def arel_table_for(table_name) = Arel::Table.new(table_name)
60
+
61
+ # @api private
62
+ # @return [ActiveRecord::ConnectionAdapters::PostgreSQLAdapter]
63
+ def connection = Hotstepper.config.db.connection_class.connection
64
+
65
+ # Join components of a query with the separator value in `join_with`.
66
+ #
67
+ # @param [<String>] parts
68
+ # @param [String] join_with
69
+ # @param [Boolean] prefix
70
+ # @return [String]
71
+ def compile_query(*parts, join_with: " ", prefix_with: join_with, prefix: false)
72
+ compiled = parts.flatten.compact_blank.join(join_with).strip
73
+
74
+ return compiled unless prefix && compiled.present?
75
+
76
+ "#{prefix_with.presence || join_with}#{compiled}"
77
+ end
78
+
79
+ # Join components of a query with ` AND `.
80
+ #
81
+ # @param [<String>] parts
82
+ # @param [Boolean] prefix
83
+ # @return [String]
84
+ def compile_and(*parts, prefix: false, **options) = compile_query(*parts, **options, join_with: " AND ", prefix:)
85
+
86
+ # Join components of a query with ` OR `.
87
+ #
88
+ # @param [<String>] parts
89
+ # @param [Boolean] prefix
90
+ # @return [String]
91
+ def compile_or(*parts, prefix: false, **options) = compile_query(*parts, **options, join_with: " OR ", prefix:)
92
+
93
+ # Provide the quoted model id as a formatting argument
94
+ # to the `template`.
95
+ #
96
+ # @param [ApplicationRecord] model
97
+ # @param [String] template
98
+ # @return [String]
99
+ def with_quoted_id_for(model, template)
100
+ return "" if model.blank? || model.new_record? || model.destroyed?
101
+
102
+ with_sql_template template, model.quoted_id
103
+ end
104
+
105
+ # @param [#to_s] prefix
106
+ # @param [<String>, String] id_or_ids
107
+ # @param [#to_s] column
108
+ # @return [String]
109
+ def with_quoted_id_or_ids_on(prefix, id_or_ids, column: :id)
110
+ case id_or_ids
111
+ when ::Hotstepper::Support::Types::UUIDList
112
+ arel_table_for(prefix)[column].in(id_or_ids).to_sql
113
+ when ::Hotstepper::Support::Types::UUID
114
+ arel_table_for(prefix)[column].eq(id_or_ids).to_sql
115
+ else
116
+ ""
117
+ end
118
+ end
119
+
120
+ # @param [String] template
121
+ # @param [<Object>] values format values for `String#%`
122
+ # @return [String]
123
+ def with_sql_template(template, *values)
124
+ formatted = template % values
125
+
126
+ cleanup_query formatted
127
+ end
128
+
129
+ private
130
+
131
+ # @param [String] sql
132
+ # @return [String]
133
+ def cleanup_query(sql)
134
+ sql.strip_heredoc.squish
135
+ end
136
+
137
+ # @return [void]
138
+ def clear_query_cache!
139
+ connection.clear_query_cache
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ # @abstract
5
+ class Service
6
+ extend ActiveModel::Callbacks
7
+ extend Dry::Core::ClassAttributes
8
+ extend Hotstepper::Monadic::DoFor
9
+
10
+ include Dry::Core::Constants
11
+ include Dry::Monads[:result, :maybe, :try]
12
+
13
+ include Hotstepper::Common::CallsOperation
14
+
15
+ defines :step_mapping, type: Hotstepper::Steps::Step::Map
16
+
17
+ step_mapping EMPTY_HASH
18
+
19
+ do_for! :call
20
+
21
+ define_model_callbacks :execute
22
+
23
+ # @return [Symbol]
24
+ attr_reader :current_step
25
+
26
+ def call
27
+ # :nocov:
28
+ raise NotImplementedError, "Subclasses of Hotstepper::Service must implement #call"
29
+ # :nocov:
30
+ end
31
+
32
+ # @api private
33
+ def inspect
34
+ # :nocov:
35
+ "#<#{self.class}>"
36
+ # :nocov:
37
+ end
38
+
39
+ # @api private
40
+ # @return [void]
41
+ def __run_step__(step_name)
42
+ @current_step = step_name
43
+
44
+ run_callbacks step_name do
45
+ Hotstepper::Monadic.to_result do
46
+ __send__(step_name)
47
+ end
48
+ end
49
+ ensure
50
+ @current_step = nil
51
+ end
52
+
53
+ class << self
54
+ # @param [Class] subclass
55
+ # @return [void]
56
+ def inherited(subclass)
57
+ super
58
+
59
+ subclass.include Dry::Monads::Do.for(:call)
60
+ end
61
+
62
+ # Defines a step on this service.
63
+ # @param [Symbol] name
64
+ # @return [Symbol]
65
+ # @!macro [attach] step!
66
+ # @!method $1
67
+ # The actual business logic of the step method.
68
+ # @return [Dry::Monads::Result]
69
+ # @!method $1!
70
+ # A wrapper around {$1} that ensures its retval is monadic, runs callbacks,
71
+ # and sets the current step.
72
+ # @return [Dry::Monads::Result]
73
+ def step!(name)
74
+ step = Hotstepper::Steps::Step.new(name:)
75
+
76
+ new_mapping = step_mapping.merge(step.name => step).freeze
77
+
78
+ step_mapping new_mapping
79
+
80
+ include step.implementation
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ module Steps
5
+ # @api private
6
+ # @abstract
7
+ class AbstractModule < Module
8
+ include Dry::Initializer[undefined: false].define -> do
9
+ param :step, Hotstepper::Steps::Step::Type
10
+ end
11
+
12
+ delegate :name, to: :step, prefix: true
13
+
14
+ def inspect
15
+ # :nocov:
16
+ "#{self.class}[#{step_name.inspect}]"
17
+ # :nocov:
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ module Steps
5
+ # @api private
6
+ class Implementation < AbstractModule
7
+ def initialize(...)
8
+ super
9
+
10
+ @wrapper = Hotstepper::Steps::Wrapper.new(step)
11
+ @watcher = Hotstepper::Steps::Watcher.new(step)
12
+
13
+ define_methods!
14
+ end
15
+
16
+ def included(base)
17
+ super
18
+
19
+ base.include Dry::Monads::Do.for(step_name)
20
+ base.define_model_callbacks step_name
21
+ base.extend @watcher
22
+ base.prepend @wrapper
23
+ end
24
+
25
+ private
26
+
27
+ # @return [void]
28
+ def define_methods!
29
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
30
+ def #{step_name} = Dry::Monads.Success()
31
+
32
+ def #{step_name}!
33
+ __run_step__(#{step_name.inspect})
34
+ end
35
+ RUBY
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ module Steps
5
+ # A step is a single unit of work in a service. It is defined as a method on the service.
6
+ #
7
+ # @api private
8
+ class Step < Hotstepper::Struct
9
+ include Dry::Core::Equalizer.new(:name)
10
+ include Hotstepper::Typing
11
+
12
+ map_type!(key: Hotstepper::Support::Types::Symbol)
13
+
14
+ attribute :name, Hotstepper::Support::Types::StepName
15
+
16
+ # @return [Hotstepper::Steps::Implementation]
17
+ attr_reader :implementation
18
+
19
+ def initialize(...)
20
+ super
21
+
22
+ @implementation = Hotstepper::Steps::Implementation.new(self)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ module Steps
5
+ # A module that is included into the step class to apply the `Dry::Monads::Do`
6
+ # mixin onto it whenever the class is subclassed.
7
+ #
8
+ # @api private
9
+ class Watcher < Hotstepper::Steps::AbstractModule
10
+ def initialize(...)
11
+ super
12
+
13
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
14
+ def inherited(subclass)
15
+ super
16
+
17
+ subclass.do_for! #{step_name.inspect}
18
+ end
19
+ RUBY
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ module Steps
5
+ # A module that is prepended into the step class to watch for overrides
6
+ #
7
+ # @api private
8
+ class Wrapper < Hotstepper::Steps::AbstractModule
9
+ def prepended(base)
10
+ super
11
+
12
+ Dry::Monads::Do.wrap_method(self, step_name, :public)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ # An opinionated dry-struct implementation that accepts stringified keys,
5
+ # and will apply defaults when explicitly passed `nil`
6
+ # on keys where applicable.
7
+ #
8
+ # @abstract
9
+ # @see https://dry-rb.org/gems/dry-struct/main/
10
+ class Struct < Dry::Struct
11
+ transform_keys(&:to_sym)
12
+
13
+ transform_types do |type|
14
+ if type.default?
15
+ type.constructor do |value|
16
+ value.nil? ? Dry::Types::Undefined : value
17
+ end
18
+ else
19
+ type
20
+ end
21
+ end
22
+
23
+ # @param [Array<Symbol, String>] keys
24
+ def slice(*keys)
25
+ keys.flatten.index_with { public_send _1 }
26
+ end
27
+
28
+ alias deconstruct_keys slice
29
+ end
30
+ end