operandi 5.0.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 +7 -0
- data/.cursor/rules/services/RULE.md +269 -0
- data/.cursor/rules/services-rspec/RULE.md +354 -0
- data/.github/dependabot.yml +11 -0
- data/.github/workflows/ci.yml +77 -0
- data/.gitignore +25 -0
- data/.rspec +3 -0
- data/.rubocop.yml +134 -0
- data/.ruby-version +1 -0
- data/.vscode/cspell.json +18 -0
- data/.vscode/project-words.txt +12 -0
- data/AGENTS.md +139 -0
- data/CHANGELOG.md +111 -0
- data/CLAUDE.md +139 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +28 -0
- data/Gemfile.lock +149 -0
- data/LICENSE.txt +21 -0
- data/README.md +172 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/config/default.yml +57 -0
- data/docs/README.md +105 -0
- data/docs/SUMMARY.md +31 -0
- data/docs/arguments.md +275 -0
- data/docs/best-practices.md +153 -0
- data/docs/callbacks.md +475 -0
- data/docs/concepts.md +79 -0
- data/docs/configuration.md +218 -0
- data/docs/context.md +128 -0
- data/docs/crud.md +525 -0
- data/docs/errors.md +331 -0
- data/docs/generators.md +250 -0
- data/docs/outputs.md +150 -0
- data/docs/pundit-authorization.md +320 -0
- data/docs/quickstart.md +133 -0
- data/docs/recipes.md +14 -0
- data/docs/rubocop.md +430 -0
- data/docs/ruby-lsp.md +121 -0
- data/docs/service-rendering.md +222 -0
- data/docs/sorbet-runtime.md +283 -0
- data/docs/steps.md +438 -0
- data/docs/tapioca.md +188 -0
- data/docs/testing.md +548 -0
- data/lib/generators/operandi/install/USAGE +15 -0
- data/lib/generators/operandi/install/install_generator.rb +45 -0
- data/lib/generators/operandi/install/templates/application_service.rb.tt +8 -0
- data/lib/generators/operandi/install/templates/application_service_spec.rb.tt +7 -0
- data/lib/generators/operandi/install/templates/initializer.rb.tt +30 -0
- data/lib/generators/operandi/service/USAGE +21 -0
- data/lib/generators/operandi/service/service_generator.rb +80 -0
- data/lib/generators/operandi/service/templates/service.rb.tt +48 -0
- data/lib/generators/operandi/service/templates/service_spec.rb.tt +40 -0
- data/lib/operandi/base.rb +230 -0
- data/lib/operandi/base_with_context.rb +57 -0
- data/lib/operandi/callbacks.rb +353 -0
- data/lib/operandi/collection.rb +166 -0
- data/lib/operandi/concerns/execution.rb +80 -0
- data/lib/operandi/concerns/parent_service.rb +32 -0
- data/lib/operandi/concerns/state_management.rb +34 -0
- data/lib/operandi/config.rb +142 -0
- data/lib/operandi/constants.rb +96 -0
- data/lib/operandi/dsl/arguments_dsl.rb +83 -0
- data/lib/operandi/dsl/outputs_dsl.rb +79 -0
- data/lib/operandi/dsl/steps_dsl.rb +206 -0
- data/lib/operandi/dsl/validation.rb +171 -0
- data/lib/operandi/exceptions.rb +66 -0
- data/lib/operandi/message.rb +52 -0
- data/lib/operandi/messages.rb +185 -0
- data/lib/operandi/rspec/matchers/define_argument.rb +172 -0
- data/lib/operandi/rspec/matchers/define_output.rb +145 -0
- data/lib/operandi/rspec/matchers/define_step.rb +223 -0
- data/lib/operandi/rspec/matchers/execute_step.rb +228 -0
- data/lib/operandi/rspec/matchers/have_error_on.rb +144 -0
- data/lib/operandi/rspec/matchers/have_warning_on.rb +146 -0
- data/lib/operandi/rspec/matchers/trigger_callback.rb +136 -0
- data/lib/operandi/rspec.rb +15 -0
- data/lib/operandi/rubocop/cop/operandi/argument_type_required.rb +52 -0
- data/lib/operandi/rubocop/cop/operandi/condition_method_exists.rb +173 -0
- data/lib/operandi/rubocop/cop/operandi/deprecated_accessors.rb +113 -0
- data/lib/operandi/rubocop/cop/operandi/deprecated_methods.rb +113 -0
- data/lib/operandi/rubocop/cop/operandi/dsl_order.rb +181 -0
- data/lib/operandi/rubocop/cop/operandi/missing_private_keyword.rb +102 -0
- data/lib/operandi/rubocop/cop/operandi/no_direct_instantiation.rb +66 -0
- data/lib/operandi/rubocop/cop/operandi/no_hash_argument.rb +101 -0
- data/lib/operandi/rubocop/cop/operandi/output_type_required.rb +52 -0
- data/lib/operandi/rubocop/cop/operandi/prefer_fail_method.rb +112 -0
- data/lib/operandi/rubocop/cop/operandi/prefer_optional_over_default_nil.rb +124 -0
- data/lib/operandi/rubocop/cop/operandi/redundant_optional.rb +103 -0
- data/lib/operandi/rubocop/cop/operandi/reserved_name.rb +56 -0
- data/lib/operandi/rubocop/cop/operandi/step_method_exists.rb +134 -0
- data/lib/operandi/rubocop.rb +22 -0
- data/lib/operandi/settings/field.rb +147 -0
- data/lib/operandi/settings/step.rb +105 -0
- data/lib/operandi/utils.rb +36 -0
- data/lib/operandi/version.rb +5 -0
- data/lib/operandi.rb +11 -0
- data/lib/ruby_lsp/operandi/addon.rb +37 -0
- data/lib/ruby_lsp/operandi/definition.rb +134 -0
- data/lib/ruby_lsp/operandi/indexing_enhancement.rb +224 -0
- data/lib/tapioca/dsl/compilers/operandi.rb +378 -0
- data/operandi.gemspec +33 -0
- data/rbi/operandi.rbi +197 -0
- data/sorbet/cache/data.mdb +0 -0
- data/sorbet/cache/lock.mdb +0 -0
- metadata +151 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../constants"
|
|
4
|
+
|
|
5
|
+
module Operandi
|
|
6
|
+
module Dsl
|
|
7
|
+
# Shared validation logic for DSL modules
|
|
8
|
+
module Validation
|
|
9
|
+
# Validate that the name is a symbol
|
|
10
|
+
#
|
|
11
|
+
# @param name [Object] the name to validate
|
|
12
|
+
# @param field_type [Symbol] the type of field (:argument, :output, :step)
|
|
13
|
+
# @param service_class [Class] the service class for error messages
|
|
14
|
+
def self.validate_symbol_name!(name, field_type, service_class)
|
|
15
|
+
return if name.is_a?(Symbol)
|
|
16
|
+
|
|
17
|
+
raise Operandi::InvalidNameError,
|
|
18
|
+
"#{field_type.to_s.capitalize} name must be a Symbol, " \
|
|
19
|
+
"got #{name.class} (#{name.inspect}) in #{service_class}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Validate that the name is not a reserved word
|
|
23
|
+
#
|
|
24
|
+
# @param name [Symbol] the name to validate
|
|
25
|
+
# @param field_type [Symbol] the type of field (:argument, :output, :step)
|
|
26
|
+
# @param service_class [Class] the service class for error messages
|
|
27
|
+
def self.validate_reserved_name!(name, field_type, service_class)
|
|
28
|
+
return unless ReservedNames::ALL.include?(name.to_sym)
|
|
29
|
+
|
|
30
|
+
raise Operandi::ReservedNameError,
|
|
31
|
+
"Cannot use `#{name}` as #{field_type} name in #{service_class} - " \
|
|
32
|
+
"it is a reserved word that conflicts with gem methods"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Validate that the name doesn't conflict with other defined names
|
|
36
|
+
#
|
|
37
|
+
# @param name [Symbol] the name to validate
|
|
38
|
+
# @param field_type [Symbol] the type of field being defined (:argument, :output, :step)
|
|
39
|
+
# @param service_class [Class] the service class to check for conflicts
|
|
40
|
+
def self.validate_name_conflicts!(name, field_type, service_class)
|
|
41
|
+
name_sym = name.to_sym
|
|
42
|
+
|
|
43
|
+
case field_type
|
|
44
|
+
when :argument
|
|
45
|
+
validate_argument_conflicts!(name_sym, service_class)
|
|
46
|
+
when :output
|
|
47
|
+
validate_output_conflicts!(name_sym, service_class)
|
|
48
|
+
when :step
|
|
49
|
+
validate_step_conflicts!(name_sym, service_class)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Validate argument name doesn't conflict with outputs or steps
|
|
54
|
+
def self.validate_argument_conflicts!(name_sym, service_class)
|
|
55
|
+
# Check against existing outputs
|
|
56
|
+
if has_output?(name_sym, service_class)
|
|
57
|
+
raise Operandi::ReservedNameError,
|
|
58
|
+
"Cannot use `#{name_sym}` as argument name in #{service_class} - " \
|
|
59
|
+
"it is already defined as an output"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check against existing steps
|
|
63
|
+
if has_step?(name_sym, service_class)
|
|
64
|
+
raise Operandi::ReservedNameError,
|
|
65
|
+
"Cannot use `#{name_sym}` as argument name in #{service_class} - " \
|
|
66
|
+
"it is already defined as a step"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Validate output name doesn't conflict with arguments or steps
|
|
71
|
+
def self.validate_output_conflicts!(name_sym, service_class)
|
|
72
|
+
# Check against existing arguments
|
|
73
|
+
if has_argument?(name_sym, service_class)
|
|
74
|
+
raise Operandi::ReservedNameError,
|
|
75
|
+
"Cannot use `#{name_sym}` as output name in #{service_class} - " \
|
|
76
|
+
"it is already defined as an argument"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Check against existing steps
|
|
80
|
+
if has_step?(name_sym, service_class)
|
|
81
|
+
raise Operandi::ReservedNameError,
|
|
82
|
+
"Cannot use `#{name_sym}` as output name in #{service_class} - " \
|
|
83
|
+
"it is already defined as a step"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Validate step name doesn't conflict with arguments or outputs
|
|
88
|
+
def self.validate_step_conflicts!(name_sym, service_class)
|
|
89
|
+
# Check against existing arguments
|
|
90
|
+
if has_argument?(name_sym, service_class)
|
|
91
|
+
raise Operandi::ReservedNameError,
|
|
92
|
+
"Cannot use `#{name_sym}` as step name in #{service_class} - " \
|
|
93
|
+
"it is already defined as an argument"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Check against existing outputs
|
|
97
|
+
if has_output?(name_sym, service_class)
|
|
98
|
+
raise Operandi::ReservedNameError,
|
|
99
|
+
"Cannot use `#{name_sym}` as step name in #{service_class} - " \
|
|
100
|
+
"it is already defined as an output"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Check if a name is already defined as an argument
|
|
105
|
+
def self.has_argument?(name_sym, service_class)
|
|
106
|
+
# Check own_arguments (current class)
|
|
107
|
+
(service_class.respond_to?(:own_arguments) && service_class.own_arguments.key?(name_sym)) ||
|
|
108
|
+
# Check inherited arguments
|
|
109
|
+
(service_class.superclass.respond_to?(:arguments) && service_class.superclass.arguments.key?(name_sym))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Check if a name is already defined as an output
|
|
113
|
+
def self.has_output?(name_sym, service_class)
|
|
114
|
+
# Check own_outputs (current class)
|
|
115
|
+
(service_class.respond_to?(:own_outputs) && service_class.own_outputs.key?(name_sym)) ||
|
|
116
|
+
# Check inherited outputs
|
|
117
|
+
(service_class.superclass.respond_to?(:outputs) && service_class.superclass.outputs.key?(name_sym))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Check if a name is already defined as a step
|
|
121
|
+
def self.has_step?(name_sym, service_class)
|
|
122
|
+
# Check step_operations (current class) for non-removed steps
|
|
123
|
+
(service_class.respond_to?(:step_operations) &&
|
|
124
|
+
service_class.step_operations.any? { |op| op[:name] == name_sym && op[:action] != :remove }) ||
|
|
125
|
+
# Check inherited steps
|
|
126
|
+
(service_class.superclass.respond_to?(:steps) && service_class.superclass.steps.key?(name_sym))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Validate that the type option is provided when require_type is enabled
|
|
130
|
+
#
|
|
131
|
+
# @param name [Symbol] the field name
|
|
132
|
+
# @param field_type [Symbol] the type of field (:argument, :output)
|
|
133
|
+
# @param service_class [Class] the service class for error messages
|
|
134
|
+
# @param opts [Hash] the options hash to check for type
|
|
135
|
+
def self.validate_type_required!(name, field_type, service_class, opts)
|
|
136
|
+
return if opts.key?(:type)
|
|
137
|
+
return unless require_type_enabled_for?(field_type, service_class)
|
|
138
|
+
|
|
139
|
+
config_name = field_type == :argument ? "require_arg_type" : "require_output_type"
|
|
140
|
+
raise Operandi::MissingTypeError,
|
|
141
|
+
"#{field_type.to_s.capitalize} `#{name}` in #{service_class} must have a type specified " \
|
|
142
|
+
"(#{config_name} is enabled)"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Check if require_type is enabled for the given field type and service class
|
|
146
|
+
#
|
|
147
|
+
# @param field_type [Symbol] the type of field (:argument, :output)
|
|
148
|
+
# @param service_class [Class] the service class to check
|
|
149
|
+
# @return [Boolean] whether type is required for the field type
|
|
150
|
+
def self.require_type_enabled_for?(field_type, service_class)
|
|
151
|
+
config_key = field_type == :argument ? :require_arg_type : :require_output_type
|
|
152
|
+
|
|
153
|
+
# Check class-level config in the inheritance chain, then fall back to global config
|
|
154
|
+
klass = service_class
|
|
155
|
+
while klass.respond_to?(:class_config)
|
|
156
|
+
class_config = klass.class_config
|
|
157
|
+
|
|
158
|
+
# Check specific config first (require_arg_type or require_output_type)
|
|
159
|
+
return class_config[config_key] if class_config&.key?(config_key)
|
|
160
|
+
|
|
161
|
+
# Check convenience config (require_type) for backward compatibility
|
|
162
|
+
return class_config[:require_type] if class_config&.key?(:require_type)
|
|
163
|
+
|
|
164
|
+
klass = klass.superclass
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
Operandi.config.public_send(config_key)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Operandi
|
|
4
|
+
# Base exception class for all Operandi errors.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised for failures that occur while a service instance is running.
|
|
8
|
+
class RuntimeError < Error
|
|
9
|
+
# @return [Base] the service instance that raised the error
|
|
10
|
+
attr_reader :service
|
|
11
|
+
|
|
12
|
+
# @param message [String, nil] the error message
|
|
13
|
+
# @param service [Base] the service instance that raised the error
|
|
14
|
+
def initialize(message = nil, service:)
|
|
15
|
+
raise ArgumentError, "service is required" unless service
|
|
16
|
+
|
|
17
|
+
unless defined?(Operandi::Base) && service.is_a?(Operandi::Base)
|
|
18
|
+
raise ArgumentError, "service must be an Operandi::Base instance"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
@service = service
|
|
22
|
+
super(message)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Raised when an argument or output value doesn't match the expected type.
|
|
27
|
+
class ArgTypeError < Error
|
|
28
|
+
# @return [Class<Base>] the service class associated with the type error
|
|
29
|
+
attr_reader :service_class
|
|
30
|
+
|
|
31
|
+
# @param message [String, nil] the error message
|
|
32
|
+
# @param service_class [Class<Base>] the service class associated with the type error
|
|
33
|
+
def initialize(message = nil, service_class:)
|
|
34
|
+
raise ArgumentError, "service_class is required" unless service_class
|
|
35
|
+
|
|
36
|
+
unless defined?(Operandi::Base) &&
|
|
37
|
+
service_class.is_a?(Class) &&
|
|
38
|
+
service_class <= Operandi::Base
|
|
39
|
+
raise ArgumentError, "service_class must be an Operandi::Base subclass"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@service_class = service_class
|
|
43
|
+
super(message)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Raised when using a reserved name for an argument, output, or step.
|
|
48
|
+
class ReservedNameError < Error; end
|
|
49
|
+
|
|
50
|
+
# Raised when a name is invalid (e.g., not a Symbol).
|
|
51
|
+
class InvalidNameError < Error; end
|
|
52
|
+
|
|
53
|
+
# Raised when a service has no steps defined and no run method.
|
|
54
|
+
class NoStepsError < Error; end
|
|
55
|
+
|
|
56
|
+
# Raised when type is required but not specified for an argument or output.
|
|
57
|
+
class MissingTypeError < Error; end
|
|
58
|
+
|
|
59
|
+
# Control flow exception for stop_immediately!
|
|
60
|
+
# Not an error - used to halt execution gracefully.
|
|
61
|
+
class StopExecution < StandardError; end
|
|
62
|
+
|
|
63
|
+
# Control flow exception for fail_immediately!
|
|
64
|
+
# Unlike StopExecution, this exception causes transaction rollback.
|
|
65
|
+
class FailExecution < StandardError; end
|
|
66
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Operandi
|
|
4
|
+
# Represents a single error or warning message.
|
|
5
|
+
#
|
|
6
|
+
# @example Creating a message
|
|
7
|
+
# message = Message.new(:name, "can't be blank", break: true)
|
|
8
|
+
# message.key # => :name
|
|
9
|
+
# message.text # => "can't be blank"
|
|
10
|
+
# message.break? # => true
|
|
11
|
+
class Message
|
|
12
|
+
# @return [Symbol] the key/field this message belongs to
|
|
13
|
+
attr_reader :key
|
|
14
|
+
|
|
15
|
+
# @return [String] the message text
|
|
16
|
+
attr_reader :text
|
|
17
|
+
|
|
18
|
+
# Create a new message.
|
|
19
|
+
#
|
|
20
|
+
# @param key [Symbol] the key/field this message belongs to
|
|
21
|
+
# @param text [String] the message text
|
|
22
|
+
# @param opts [Hash] additional options
|
|
23
|
+
# @option opts [Boolean] :break whether to stop step execution
|
|
24
|
+
# @option opts [Boolean] :rollback whether to rollback the transaction
|
|
25
|
+
def initialize(key, text, opts = {})
|
|
26
|
+
@key = key
|
|
27
|
+
@text = text
|
|
28
|
+
@opts = opts
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Check if this message should stop step execution.
|
|
32
|
+
#
|
|
33
|
+
# @return [Boolean] true if break option was set
|
|
34
|
+
def break?
|
|
35
|
+
@opts[:break]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Check if this message should trigger a transaction rollback.
|
|
39
|
+
#
|
|
40
|
+
# @return [Boolean] true if rollback option was set
|
|
41
|
+
def rollback?
|
|
42
|
+
@opts[:rollback]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Return the message text.
|
|
46
|
+
#
|
|
47
|
+
# @return [String] the message text
|
|
48
|
+
def to_s
|
|
49
|
+
text
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Operandi
|
|
4
|
+
# Collection of error or warning messages, organized by key.
|
|
5
|
+
#
|
|
6
|
+
# @example Adding and accessing errors
|
|
7
|
+
# errors.add(:name, "can't be blank")
|
|
8
|
+
# errors.add(:email, "is invalid")
|
|
9
|
+
# errors[:name] # => [#<Message key: :name, text: "can't be blank">]
|
|
10
|
+
# errors.to_h # => { name: ["can't be blank"], email: ["is invalid"] }
|
|
11
|
+
class Messages
|
|
12
|
+
extend Forwardable
|
|
13
|
+
|
|
14
|
+
# @!method [](key)
|
|
15
|
+
# Get messages for a specific key.
|
|
16
|
+
# @param key [Symbol] the key to look up
|
|
17
|
+
# @return [Array<Message>, nil] array of messages or nil
|
|
18
|
+
|
|
19
|
+
# @!method any?
|
|
20
|
+
# Check if there are any messages.
|
|
21
|
+
# @return [Boolean] true if messages exist
|
|
22
|
+
|
|
23
|
+
# @!method empty?
|
|
24
|
+
# Check if the collection is empty.
|
|
25
|
+
# @return [Boolean] true if no messages
|
|
26
|
+
|
|
27
|
+
# @!method size
|
|
28
|
+
# Get number of keys with messages.
|
|
29
|
+
# @return [Integer] number of keys
|
|
30
|
+
|
|
31
|
+
# @!method keys
|
|
32
|
+
# Get all keys with messages.
|
|
33
|
+
# @return [Array<Symbol>] array of keys
|
|
34
|
+
|
|
35
|
+
# @!method key?(key)
|
|
36
|
+
# Check if a key has messages.
|
|
37
|
+
# @param key [Symbol] the key to check
|
|
38
|
+
# @return [Boolean] true if key has messages
|
|
39
|
+
def_delegators :@messages,
|
|
40
|
+
:[],
|
|
41
|
+
:any?,
|
|
42
|
+
:empty?,
|
|
43
|
+
:size,
|
|
44
|
+
:keys,
|
|
45
|
+
:values,
|
|
46
|
+
:each,
|
|
47
|
+
:each_with_index,
|
|
48
|
+
:each_with_object,
|
|
49
|
+
:key?
|
|
50
|
+
alias has_key? key?
|
|
51
|
+
|
|
52
|
+
# Initialize a new messages collection.
|
|
53
|
+
#
|
|
54
|
+
# @param config [Hash] configuration options
|
|
55
|
+
# @param service [Base] service instance that owns this collection
|
|
56
|
+
# @option config [Boolean] :break_on_add stop execution when message added
|
|
57
|
+
# @option config [Boolean] :raise_on_add raise exception when message added
|
|
58
|
+
# @option config [Boolean] :rollback_on_add rollback transaction when message added
|
|
59
|
+
def initialize(config = {}, service:)
|
|
60
|
+
@break = false
|
|
61
|
+
@config = config
|
|
62
|
+
@messages = {}
|
|
63
|
+
@service = service
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Get total count of all messages across all keys.
|
|
67
|
+
#
|
|
68
|
+
# @return [Integer] total number of messages
|
|
69
|
+
def count
|
|
70
|
+
@messages.values.sum(&:size)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Add a message to the collection.
|
|
74
|
+
#
|
|
75
|
+
# @param key [Symbol] the key/field for this message
|
|
76
|
+
# @param texts [String, Array<String>, Message] the message text(s) to add
|
|
77
|
+
# @param opts [Hash] additional options
|
|
78
|
+
# @option opts [Boolean] :break override break behavior for this message
|
|
79
|
+
# @option opts [Boolean] :rollback override rollback behavior for this message
|
|
80
|
+
# @return [void]
|
|
81
|
+
# @raise [RuntimeError, Error] if text is nil or empty
|
|
82
|
+
#
|
|
83
|
+
# @example Add a single error
|
|
84
|
+
# errors.add(:name, "can't be blank")
|
|
85
|
+
#
|
|
86
|
+
# @example Add multiple errors
|
|
87
|
+
# errors.add(:email, ["is invalid", "is already taken"])
|
|
88
|
+
def add(key, texts, opts = {})
|
|
89
|
+
raise_error("Error must be a non-empty string") unless texts
|
|
90
|
+
|
|
91
|
+
message = nil
|
|
92
|
+
|
|
93
|
+
[*texts].each do |text|
|
|
94
|
+
message = text.is_a?(Message) ? text : Message.new(key, text, opts)
|
|
95
|
+
|
|
96
|
+
raise_error("Error must be a non-empty string") unless valid_error_text?(message.text)
|
|
97
|
+
|
|
98
|
+
@messages[key] ||= []
|
|
99
|
+
@messages[key] << message
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
raise!(message)
|
|
103
|
+
break!(opts.key?(:break) ? opts[:break] : message.break?)
|
|
104
|
+
rollback!(opts.key?(:rollback) ? opts[:rollback] : message.rollback?) if !opts.key?(:last) || opts[:last]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Check if step execution should stop.
|
|
108
|
+
#
|
|
109
|
+
# @return [Boolean] true if a message triggered a break
|
|
110
|
+
def break?
|
|
111
|
+
@break
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Copy messages from another source.
|
|
115
|
+
#
|
|
116
|
+
# @param entity [ActiveRecord::Base, Base, Hash, #each] source to copy from
|
|
117
|
+
# @param opts [Hash] options to pass to each added message
|
|
118
|
+
# @return [void]
|
|
119
|
+
# @raise [RuntimeError, Error] if entity type is not supported
|
|
120
|
+
#
|
|
121
|
+
# @example Copy from ActiveRecord model
|
|
122
|
+
# errors.copy_from(user) # copies user.errors
|
|
123
|
+
#
|
|
124
|
+
# @example Copy from another service
|
|
125
|
+
# errors.copy_from(child_service)
|
|
126
|
+
def copy_from(entity, opts = {})
|
|
127
|
+
if defined?(ActiveRecord::Base) && entity.is_a?(ActiveRecord::Base)
|
|
128
|
+
copy_from(entity.errors.messages, opts)
|
|
129
|
+
elsif entity.is_a?(Operandi::Base)
|
|
130
|
+
copy_from(entity.errors, opts)
|
|
131
|
+
elsif entity.respond_to?(:each)
|
|
132
|
+
last_index = entity.size - 1
|
|
133
|
+
|
|
134
|
+
entity.each_with_index do |(key, message), index|
|
|
135
|
+
add(key, message, opts.merge(last: index == last_index))
|
|
136
|
+
end
|
|
137
|
+
else
|
|
138
|
+
raise_error("Don't know how to import errors from #{entity}")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
alias from_record copy_from
|
|
142
|
+
|
|
143
|
+
# Convert messages to a hash with string values.
|
|
144
|
+
#
|
|
145
|
+
# @return [Hash{Symbol => Array<String>}] messages as hash
|
|
146
|
+
def to_h
|
|
147
|
+
@messages.to_h.transform_values { |value| value.map(&:to_s) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def valid_error_text?(text)
|
|
153
|
+
return false unless text.is_a?(String)
|
|
154
|
+
|
|
155
|
+
!text.strip.empty?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def break!(break_execution)
|
|
159
|
+
return unless break_execution.nil? ? @config[:break_on_add] : break_execution
|
|
160
|
+
|
|
161
|
+
@break = true
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def raise!(message)
|
|
165
|
+
return unless @config[:raise_on_add]
|
|
166
|
+
|
|
167
|
+
if message.key == :base
|
|
168
|
+
raise_error(message.text.capitalize.strip)
|
|
169
|
+
else
|
|
170
|
+
raise_error("#{message.key.to_s.capitalize} #{message.text}".strip)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def raise_error(message)
|
|
175
|
+
raise Operandi::RuntimeError.new(message, service: @service)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def rollback!(rollback)
|
|
179
|
+
return unless defined?(ActiveRecord::Rollback)
|
|
180
|
+
return unless rollback.nil? ? @config[:rollback_on_add] : rollback
|
|
181
|
+
|
|
182
|
+
raise ActiveRecord::Rollback
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Operandi
|
|
4
|
+
module RSpec
|
|
5
|
+
module Matchers
|
|
6
|
+
# Matcher for testing argument definitions on a service class
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage
|
|
9
|
+
# expect(MyService).to define_argument(:name)
|
|
10
|
+
#
|
|
11
|
+
# @example With type constraint
|
|
12
|
+
# expect(MyService).to define_argument(:name).with_type(String)
|
|
13
|
+
#
|
|
14
|
+
# @example With optional flag
|
|
15
|
+
# expect(MyService).to define_argument(:email).optional
|
|
16
|
+
#
|
|
17
|
+
# @example With default value
|
|
18
|
+
# expect(MyService).to define_argument(:status).with_default("pending")
|
|
19
|
+
#
|
|
20
|
+
# @example With context flag
|
|
21
|
+
# expect(MyService).to define_argument(:current_user).with_context
|
|
22
|
+
#
|
|
23
|
+
# @example Combined
|
|
24
|
+
# expect(MyService).to define_argument(:count).with_type(Integer).optional.with_default(0)
|
|
25
|
+
def define_argument(name)
|
|
26
|
+
DefineArgumentMatcher.new(name)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class DefineArgumentMatcher
|
|
30
|
+
def initialize(name)
|
|
31
|
+
@name = name
|
|
32
|
+
@expected_type = nil
|
|
33
|
+
@expected_optional = nil
|
|
34
|
+
@expected_default = nil
|
|
35
|
+
@check_default = false
|
|
36
|
+
@expected_context = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def with_type(type)
|
|
40
|
+
@expected_type = type
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def optional(value = true)
|
|
45
|
+
@expected_optional = value
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def required
|
|
50
|
+
@expected_optional = false
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def with_default(default)
|
|
55
|
+
@check_default = true
|
|
56
|
+
@expected_default = default
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def with_context(value = true)
|
|
61
|
+
@expected_context = value
|
|
62
|
+
self
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def matches?(service_class)
|
|
66
|
+
@service_class = service_class
|
|
67
|
+
@actual_class = service_class.is_a?(Class) ? service_class : service_class.class
|
|
68
|
+
|
|
69
|
+
return false unless argument_defined?
|
|
70
|
+
return false unless type_matches?
|
|
71
|
+
return false unless optional_matches?
|
|
72
|
+
return false unless default_matches?
|
|
73
|
+
return false unless context_matches?
|
|
74
|
+
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def failure_message
|
|
79
|
+
return "expected #{@actual_class} to define argument :#{@name}" unless argument_defined?
|
|
80
|
+
return type_failure_message unless type_matches?
|
|
81
|
+
return optional_failure_message unless optional_matches?
|
|
82
|
+
return default_failure_message unless default_matches?
|
|
83
|
+
return context_failure_message unless context_matches?
|
|
84
|
+
|
|
85
|
+
""
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def failure_message_when_negated
|
|
89
|
+
"expected #{@actual_class} not to define argument :#{@name}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def description
|
|
93
|
+
desc = "define argument :#{@name}"
|
|
94
|
+
desc += " with type #{@expected_type}" if @expected_type
|
|
95
|
+
desc += " as optional" if @expected_optional == true
|
|
96
|
+
desc += " as required" if @expected_optional == false
|
|
97
|
+
desc += " with default #{@expected_default.inspect}" if @check_default
|
|
98
|
+
desc += " with context" if @expected_context
|
|
99
|
+
desc
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def argument_defined?
|
|
105
|
+
@actual_class.respond_to?(:arguments) && @actual_class.arguments.key?(@name)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def argument
|
|
109
|
+
@argument ||= @actual_class.arguments[@name]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def type_matches?
|
|
113
|
+
return true if @expected_type.nil?
|
|
114
|
+
|
|
115
|
+
# Access the type via instance variable since there's no public getter
|
|
116
|
+
actual_type = argument.instance_variable_get(:@type)
|
|
117
|
+
actual_type == @expected_type
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def optional_matches?
|
|
121
|
+
return true if @expected_optional.nil?
|
|
122
|
+
|
|
123
|
+
argument.optional == @expected_optional
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def default_matches?
|
|
127
|
+
return true unless @check_default
|
|
128
|
+
|
|
129
|
+
argument.default_exists && argument.default == @expected_default
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def context_matches?
|
|
133
|
+
return true if @expected_context.nil?
|
|
134
|
+
|
|
135
|
+
argument.context == @expected_context
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def type_failure_message
|
|
139
|
+
actual_type = argument.instance_variable_get(:@type)
|
|
140
|
+
"expected #{@actual_class} argument :#{@name} to have type #{@expected_type}, " \
|
|
141
|
+
"but it has type #{actual_type.inspect}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def optional_failure_message
|
|
145
|
+
if @expected_optional
|
|
146
|
+
"expected #{@actual_class} argument :#{@name} to be optional, but it is required"
|
|
147
|
+
else
|
|
148
|
+
"expected #{@actual_class} argument :#{@name} to be required, but it is optional"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def default_failure_message
|
|
153
|
+
if argument.default_exists
|
|
154
|
+
"expected #{@actual_class} argument :#{@name} to have default #{@expected_default.inspect}, " \
|
|
155
|
+
"but it has default #{argument.default.inspect}"
|
|
156
|
+
else
|
|
157
|
+
"expected #{@actual_class} argument :#{@name} to have default #{@expected_default.inspect}, " \
|
|
158
|
+
"but no default is defined"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def context_failure_message
|
|
163
|
+
if @expected_context
|
|
164
|
+
"expected #{@actual_class} argument :#{@name} to have context flag, but it doesn't"
|
|
165
|
+
else
|
|
166
|
+
"expected #{@actual_class} argument :#{@name} not to have context flag, but it does"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|