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.
Files changed (107) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/rules/services/RULE.md +269 -0
  3. data/.cursor/rules/services-rspec/RULE.md +354 -0
  4. data/.github/dependabot.yml +11 -0
  5. data/.github/workflows/ci.yml +77 -0
  6. data/.gitignore +25 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +134 -0
  9. data/.ruby-version +1 -0
  10. data/.vscode/cspell.json +18 -0
  11. data/.vscode/project-words.txt +12 -0
  12. data/AGENTS.md +139 -0
  13. data/CHANGELOG.md +111 -0
  14. data/CLAUDE.md +139 -0
  15. data/CODE_OF_CONDUCT.md +74 -0
  16. data/Gemfile +28 -0
  17. data/Gemfile.lock +149 -0
  18. data/LICENSE.txt +21 -0
  19. data/README.md +172 -0
  20. data/Rakefile +8 -0
  21. data/bin/console +15 -0
  22. data/bin/setup +8 -0
  23. data/config/default.yml +57 -0
  24. data/docs/README.md +105 -0
  25. data/docs/SUMMARY.md +31 -0
  26. data/docs/arguments.md +275 -0
  27. data/docs/best-practices.md +153 -0
  28. data/docs/callbacks.md +475 -0
  29. data/docs/concepts.md +79 -0
  30. data/docs/configuration.md +218 -0
  31. data/docs/context.md +128 -0
  32. data/docs/crud.md +525 -0
  33. data/docs/errors.md +331 -0
  34. data/docs/generators.md +250 -0
  35. data/docs/outputs.md +150 -0
  36. data/docs/pundit-authorization.md +320 -0
  37. data/docs/quickstart.md +133 -0
  38. data/docs/recipes.md +14 -0
  39. data/docs/rubocop.md +430 -0
  40. data/docs/ruby-lsp.md +121 -0
  41. data/docs/service-rendering.md +222 -0
  42. data/docs/sorbet-runtime.md +283 -0
  43. data/docs/steps.md +438 -0
  44. data/docs/tapioca.md +188 -0
  45. data/docs/testing.md +548 -0
  46. data/lib/generators/operandi/install/USAGE +15 -0
  47. data/lib/generators/operandi/install/install_generator.rb +45 -0
  48. data/lib/generators/operandi/install/templates/application_service.rb.tt +8 -0
  49. data/lib/generators/operandi/install/templates/application_service_spec.rb.tt +7 -0
  50. data/lib/generators/operandi/install/templates/initializer.rb.tt +30 -0
  51. data/lib/generators/operandi/service/USAGE +21 -0
  52. data/lib/generators/operandi/service/service_generator.rb +80 -0
  53. data/lib/generators/operandi/service/templates/service.rb.tt +48 -0
  54. data/lib/generators/operandi/service/templates/service_spec.rb.tt +40 -0
  55. data/lib/operandi/base.rb +230 -0
  56. data/lib/operandi/base_with_context.rb +57 -0
  57. data/lib/operandi/callbacks.rb +353 -0
  58. data/lib/operandi/collection.rb +166 -0
  59. data/lib/operandi/concerns/execution.rb +80 -0
  60. data/lib/operandi/concerns/parent_service.rb +32 -0
  61. data/lib/operandi/concerns/state_management.rb +34 -0
  62. data/lib/operandi/config.rb +142 -0
  63. data/lib/operandi/constants.rb +96 -0
  64. data/lib/operandi/dsl/arguments_dsl.rb +83 -0
  65. data/lib/operandi/dsl/outputs_dsl.rb +79 -0
  66. data/lib/operandi/dsl/steps_dsl.rb +206 -0
  67. data/lib/operandi/dsl/validation.rb +171 -0
  68. data/lib/operandi/exceptions.rb +66 -0
  69. data/lib/operandi/message.rb +52 -0
  70. data/lib/operandi/messages.rb +185 -0
  71. data/lib/operandi/rspec/matchers/define_argument.rb +172 -0
  72. data/lib/operandi/rspec/matchers/define_output.rb +145 -0
  73. data/lib/operandi/rspec/matchers/define_step.rb +223 -0
  74. data/lib/operandi/rspec/matchers/execute_step.rb +228 -0
  75. data/lib/operandi/rspec/matchers/have_error_on.rb +144 -0
  76. data/lib/operandi/rspec/matchers/have_warning_on.rb +146 -0
  77. data/lib/operandi/rspec/matchers/trigger_callback.rb +136 -0
  78. data/lib/operandi/rspec.rb +15 -0
  79. data/lib/operandi/rubocop/cop/operandi/argument_type_required.rb +52 -0
  80. data/lib/operandi/rubocop/cop/operandi/condition_method_exists.rb +173 -0
  81. data/lib/operandi/rubocop/cop/operandi/deprecated_accessors.rb +113 -0
  82. data/lib/operandi/rubocop/cop/operandi/deprecated_methods.rb +113 -0
  83. data/lib/operandi/rubocop/cop/operandi/dsl_order.rb +181 -0
  84. data/lib/operandi/rubocop/cop/operandi/missing_private_keyword.rb +102 -0
  85. data/lib/operandi/rubocop/cop/operandi/no_direct_instantiation.rb +66 -0
  86. data/lib/operandi/rubocop/cop/operandi/no_hash_argument.rb +101 -0
  87. data/lib/operandi/rubocop/cop/operandi/output_type_required.rb +52 -0
  88. data/lib/operandi/rubocop/cop/operandi/prefer_fail_method.rb +112 -0
  89. data/lib/operandi/rubocop/cop/operandi/prefer_optional_over_default_nil.rb +124 -0
  90. data/lib/operandi/rubocop/cop/operandi/redundant_optional.rb +103 -0
  91. data/lib/operandi/rubocop/cop/operandi/reserved_name.rb +56 -0
  92. data/lib/operandi/rubocop/cop/operandi/step_method_exists.rb +134 -0
  93. data/lib/operandi/rubocop.rb +22 -0
  94. data/lib/operandi/settings/field.rb +147 -0
  95. data/lib/operandi/settings/step.rb +105 -0
  96. data/lib/operandi/utils.rb +36 -0
  97. data/lib/operandi/version.rb +5 -0
  98. data/lib/operandi.rb +11 -0
  99. data/lib/ruby_lsp/operandi/addon.rb +37 -0
  100. data/lib/ruby_lsp/operandi/definition.rb +134 -0
  101. data/lib/ruby_lsp/operandi/indexing_enhancement.rb +224 -0
  102. data/lib/tapioca/dsl/compilers/operandi.rb +378 -0
  103. data/operandi.gemspec +33 -0
  104. data/rbi/operandi.rbi +197 -0
  105. data/sorbet/cache/data.mdb +0 -0
  106. data/sorbet/cache/lock.mdb +0 -0
  107. metadata +151 -0
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Operandi
4
+ # Provides callback hooks for service and step lifecycle events.
5
+ #
6
+ # @example Service-level callbacks
7
+ # class MyService < Operandi::Base
8
+ # before_service_run :log_start
9
+ # after_service_run { |service| Rails.logger.info("Done!") }
10
+ # on_service_success :send_notification
11
+ # on_service_failure :log_error
12
+ # end
13
+ #
14
+ # @example Step-level callbacks
15
+ # class MyService < Operandi::Base
16
+ # before_step_run :log_step_start
17
+ # after_step_run { |service, step_name| puts "Finished #{step_name}" }
18
+ # on_step_failure :handle_step_error
19
+ # end
20
+ #
21
+ # @example Around callbacks
22
+ # class MyService < Operandi::Base
23
+ # around_service_run :with_timing
24
+ #
25
+ # private
26
+ #
27
+ # def with_timing(service)
28
+ # start = Time.now
29
+ # yield
30
+ # puts "Took #{Time.now - start}s"
31
+ # end
32
+ # end
33
+ module Callbacks
34
+ # Available callback events.
35
+ # @return [Array<Symbol>] list of callback event names
36
+ EVENTS = [
37
+ :before_step_run,
38
+ :after_step_run,
39
+ :around_step_run,
40
+ :on_step_success,
41
+ :on_step_failure,
42
+ :on_step_crash,
43
+ :before_service_run,
44
+ :after_service_run,
45
+ :around_service_run,
46
+ :on_service_success,
47
+ :on_service_failure,
48
+ ].freeze
49
+
50
+ # Run all callbacks for a given event.
51
+ #
52
+ # @param event [Symbol] the callback event name
53
+ # @param args [Array] arguments to pass to callbacks
54
+ # @yield for around callbacks, the block to wrap
55
+ # @return [void]
56
+ def run_callbacks(event, *args, &)
57
+ callbacks = self.class.all_callbacks_for(event)
58
+
59
+ if event.to_s.start_with?("around_")
60
+ run_around_callbacks(callbacks, args, &)
61
+ else
62
+ run_simple_callbacks(callbacks, args)
63
+ yield if block_given?
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def run_simple_callbacks(callbacks, args)
70
+ callbacks.each do |callback|
71
+ execute_callback(callback, args)
72
+ end
73
+ end
74
+
75
+ def run_around_callbacks(callbacks, args, &block)
76
+ return yield if callbacks.empty?
77
+
78
+ # Build a chain of around callbacks
79
+ chain = callbacks.reverse.reduce(block) do |next_block, callback|
80
+ proc { execute_callback(callback, args, &next_block) }
81
+ end
82
+
83
+ chain.call
84
+ end
85
+
86
+ def execute_callback(callback, args, &block)
87
+ case callback
88
+ in Symbol
89
+ block_given? ? send(callback, *args, &block) : send(callback, *args)
90
+ in Proc
91
+ block_given? ? instance_exec(*args, block, &callback) : instance_exec(*args, &callback)
92
+ else
93
+ raise ArgumentError, "Callback must be a Symbol or Proc, got #{callback.class}"
94
+ end
95
+ end
96
+ end
97
+
98
+ # Class methods for registering callbacks.
99
+ # Extend this module in your service class to get callback DSL methods.
100
+ #
101
+ # @example
102
+ # class MyService < Operandi::Base
103
+ # before_service_run :setup
104
+ # after_service_run :cleanup
105
+ # end
106
+ module CallbackDsl
107
+ # Registers a callback to run before each step executes.
108
+ #
109
+ # @param method_name [Symbol, nil] name of the instance method to call
110
+ # @yield [service, step_name] block to execute if no method name provided
111
+ # @yieldparam service [Operandi::Base] the service instance
112
+ # @yieldparam step_name [Symbol] the name of the step about to run
113
+ # @return [void]
114
+ # @raise [ArgumentError] if neither method name nor block is provided
115
+ #
116
+ # @example With method name
117
+ # before_step_run :log_step_start
118
+ #
119
+ # @example With block
120
+ # before_step_run { |service, step_name| puts "Starting #{step_name}" }
121
+ def before_step_run(method_name = nil, &)
122
+ register_callback(:before_step_run, method_name, &)
123
+ end
124
+
125
+ # Registers a callback to run after each step executes.
126
+ #
127
+ # @param method_name [Symbol, nil] name of the instance method to call
128
+ # @yield [service, step_name] block to execute if no method name provided
129
+ # @yieldparam service [Operandi::Base] the service instance
130
+ # @yieldparam step_name [Symbol] the name of the step that just ran
131
+ # @return [void]
132
+ # @raise [ArgumentError] if neither method name nor block is provided
133
+ #
134
+ # @example With method name
135
+ # after_step_run :log_step_complete
136
+ #
137
+ # @example With block
138
+ # after_step_run { |service, step_name| puts "Finished #{step_name}" }
139
+ def after_step_run(method_name = nil, &)
140
+ register_callback(:after_step_run, method_name, &)
141
+ end
142
+
143
+ # Registers an around callback that wraps each step execution.
144
+ # The callback must yield to execute the step.
145
+ #
146
+ # @param method_name [Symbol, nil] name of the instance method to call
147
+ # @yield [service, step_name] block to execute if no method name provided
148
+ # @yieldparam service [Operandi::Base] the service instance
149
+ # @yieldparam step_name [Symbol] the name of the step being wrapped
150
+ # @return [void]
151
+ # @raise [ArgumentError] if neither method name nor block is provided
152
+ #
153
+ # @example With method name
154
+ # around_step_run :with_step_timing
155
+ #
156
+ # def with_step_timing(service, step_name)
157
+ # start = Time.now
158
+ # yield
159
+ # puts "#{step_name} took #{Time.now - start}s"
160
+ # end
161
+ def around_step_run(method_name = nil, &)
162
+ register_callback(:around_step_run, method_name, &)
163
+ end
164
+
165
+ # Registers a callback to run when a step completes successfully (without adding errors).
166
+ #
167
+ # @param method_name [Symbol, nil] name of the instance method to call
168
+ # @yield [service, step_name] block to execute if no method name provided
169
+ # @yieldparam service [Operandi::Base] the service instance
170
+ # @yieldparam step_name [Symbol] the name of the successful step
171
+ # @return [void]
172
+ # @raise [ArgumentError] if neither method name nor block is provided
173
+ #
174
+ # @example With method name
175
+ # on_step_success :track_step_success
176
+ #
177
+ # @example With block
178
+ # on_step_success { |service, step_name| Analytics.track("step.success", step: step_name) }
179
+ def on_step_success(method_name = nil, &)
180
+ register_callback(:on_step_success, method_name, &)
181
+ end
182
+
183
+ # Registers a callback to run when a step fails (adds errors).
184
+ #
185
+ # @param method_name [Symbol, nil] name of the instance method to call
186
+ # @yield [service, step_name] block to execute if no method name provided
187
+ # @yieldparam service [Operandi::Base] the service instance
188
+ # @yieldparam step_name [Symbol] the name of the failed step
189
+ # @return [void]
190
+ # @raise [ArgumentError] if neither method name nor block is provided
191
+ #
192
+ # @example With method name
193
+ # on_step_failure :handle_step_error
194
+ #
195
+ # @example With block
196
+ # on_step_failure { |service, step_name| Rails.logger.error("Step #{step_name} failed") }
197
+ def on_step_failure(method_name = nil, &)
198
+ register_callback(:on_step_failure, method_name, &)
199
+ end
200
+
201
+ # Registers a callback to run when a step raises an exception.
202
+ #
203
+ # @param method_name [Symbol, nil] name of the instance method to call
204
+ # @yield [service, step_name, exception] block to execute if no method name provided
205
+ # @yieldparam service [Operandi::Base] the service instance
206
+ # @yieldparam step_name [Symbol] the name of the crashed step
207
+ # @yieldparam exception [Exception] the exception that was raised
208
+ # @return [void]
209
+ # @raise [ArgumentError] if neither method name nor block is provided
210
+ #
211
+ # @example With method name
212
+ # on_step_crash :report_crash
213
+ #
214
+ # @example With block
215
+ # on_step_crash { |service, step_name, error| Sentry.capture_exception(error) }
216
+ def on_step_crash(method_name = nil, &)
217
+ register_callback(:on_step_crash, method_name, &)
218
+ end
219
+
220
+ # Registers a callback to run before the service starts executing.
221
+ #
222
+ # @param method_name [Symbol, nil] name of the instance method to call
223
+ # @yield [service] block to execute if no method name provided
224
+ # @yieldparam service [Operandi::Base] the service instance
225
+ # @return [void]
226
+ # @raise [ArgumentError] if neither method name nor block is provided
227
+ #
228
+ # @example With method name
229
+ # before_service_run :log_start
230
+ #
231
+ # @example With block
232
+ # before_service_run { |service| Rails.logger.info("Starting #{service.class.name}") }
233
+ def before_service_run(method_name = nil, &)
234
+ register_callback(:before_service_run, method_name, &)
235
+ end
236
+
237
+ # Registers a callback to run after the service completes (regardless of success/failure).
238
+ #
239
+ # @param method_name [Symbol, nil] name of the instance method to call
240
+ # @yield [service] block to execute if no method name provided
241
+ # @yieldparam service [Operandi::Base] the service instance
242
+ # @return [void]
243
+ # @raise [ArgumentError] if neither method name nor block is provided
244
+ #
245
+ # @example With method name
246
+ # after_service_run :cleanup
247
+ #
248
+ # @example With block
249
+ # after_service_run { |service| Rails.logger.info("Done!") }
250
+ def after_service_run(method_name = nil, &)
251
+ register_callback(:after_service_run, method_name, &)
252
+ end
253
+
254
+ # Registers an around callback that wraps the entire service execution.
255
+ # The callback must yield to execute the service.
256
+ #
257
+ # @param method_name [Symbol, nil] name of the instance method to call
258
+ # @yield [service] block to execute if no method name provided
259
+ # @yieldparam service [Operandi::Base] the service instance
260
+ # @return [void]
261
+ # @raise [ArgumentError] if neither method name nor block is provided
262
+ #
263
+ # @example With method name
264
+ # around_service_run :with_timing
265
+ #
266
+ # def with_timing(service)
267
+ # start = Time.now
268
+ # yield
269
+ # puts "Took #{Time.now - start}s"
270
+ # end
271
+ def around_service_run(method_name = nil, &)
272
+ register_callback(:around_service_run, method_name, &)
273
+ end
274
+
275
+ # Registers a callback to run when the service completes successfully (without errors).
276
+ #
277
+ # @param method_name [Symbol, nil] name of the instance method to call
278
+ # @yield [service] block to execute if no method name provided
279
+ # @yieldparam service [Operandi::Base] the service instance
280
+ # @return [void]
281
+ # @raise [ArgumentError] if neither method name nor block is provided
282
+ #
283
+ # @example With method name
284
+ # on_service_success :send_notification
285
+ #
286
+ # @example With block
287
+ # on_service_success { |service| NotificationMailer.success(service.user).deliver_later }
288
+ def on_service_success(method_name = nil, &)
289
+ register_callback(:on_service_success, method_name, &)
290
+ end
291
+
292
+ # Registers a callback to run when the service completes with errors.
293
+ #
294
+ # @param method_name [Symbol, nil] name of the instance method to call
295
+ # @yield [service] block to execute if no method name provided
296
+ # @yieldparam service [Operandi::Base] the service instance
297
+ # @return [void]
298
+ # @raise [ArgumentError] if neither method name nor block is provided
299
+ #
300
+ # @example With method name
301
+ # on_service_failure :log_error
302
+ #
303
+ # @example With block
304
+ # on_service_failure { |service| Rails.logger.error(service.errors.full_messages) }
305
+ def on_service_failure(method_name = nil, &)
306
+ register_callback(:on_service_failure, method_name, &)
307
+ end
308
+
309
+ # Get callbacks defined in this class for a specific event.
310
+ #
311
+ # @param event [Symbol] the callback event name
312
+ # @return [Array<Symbol, Proc>] callbacks for this event
313
+ def callbacks_for(event)
314
+ @callbacks ||= {}
315
+ @callbacks[event] ||= []
316
+ end
317
+
318
+ # Get all callbacks for an event including inherited ones.
319
+ #
320
+ # @param event [Symbol] the callback event name
321
+ # @return [Array<Symbol, Proc>] all callbacks for this event
322
+ def all_callbacks_for(event)
323
+ if superclass.respond_to?(:all_callbacks_for)
324
+ inherited = superclass.all_callbacks_for(event)
325
+ else
326
+ inherited = []
327
+ end
328
+
329
+ inherited + callbacks_for(event)
330
+ end
331
+
332
+ private
333
+
334
+ # Registers a callback for a given event.
335
+ #
336
+ # @param event [Symbol] the callback event name
337
+ # @param method_name [Symbol, nil] name of the instance method to call
338
+ # @yield block to execute if no method name provided
339
+ # @return [void]
340
+ # @raise [ArgumentError] if neither method name nor block is provided
341
+ # @api private
342
+ def register_callback(event, method_name = nil, &block)
343
+ callback = method_name || block
344
+ raise ArgumentError, "#{event} requires a method name (symbol) or a block" unless callback
345
+
346
+ unless callback.is_a?(Symbol) || callback.is_a?(Proc)
347
+ raise ArgumentError, "#{event} callback must be a Symbol or Proc"
348
+ end
349
+
350
+ callbacks_for(event) << callback
351
+ end
352
+ end
353
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constants"
4
+
5
+ module Operandi
6
+ # Collection module for storing argument and output values.
7
+ module Collection
8
+ # Storage for service arguments or outputs with type validation.
9
+ #
10
+ # @example Accessing values
11
+ # service.arg[:name] # => "John"
12
+ # service.output[:user] # => #<User id: 1>
13
+ class Base
14
+ extend Forwardable
15
+
16
+ # @!method key?(key)
17
+ # Check if a key exists in the collection.
18
+ # @param key [Symbol] the key to check
19
+ # @return [Boolean] true if key exists
20
+
21
+ # @!method to_h
22
+ # Convert collection to a hash.
23
+ # @return [Hash] the stored values
24
+ def_delegators :@storage, :key?, :to_h
25
+
26
+ # Initialize a new collection.
27
+ #
28
+ # @param instance [Base] the service instance
29
+ # @param collection_type [String] "arguments" or "outputs"
30
+ # @param storage [Hash] initial values
31
+ # @raise [ArgTypeError] if storage is not a Hash
32
+ def initialize(instance, collection_type, storage = {})
33
+ validate_collection_type!(collection_type)
34
+
35
+ @instance = instance
36
+ @collection_type = collection_type
37
+ @storage = storage
38
+
39
+ return if storage.is_a?(Hash)
40
+
41
+ message = "#{instance.class} - #{collection_type} must be a Hash"
42
+ raise Operandi::ArgTypeError.new(message, service_class: instance.class)
43
+ end
44
+
45
+ # Set a value in the collection.
46
+ #
47
+ # @param key [Symbol] the key to set
48
+ # @param value [Object] the value to store
49
+ # @return [Object] the stored value
50
+ def set(key, value)
51
+ @storage[key] = value
52
+ end
53
+
54
+ # Get a value from the collection.
55
+ #
56
+ # @param key [Symbol] the key to retrieve
57
+ # @return [Object, nil] the stored value or nil
58
+ def get(key)
59
+ @storage[key]
60
+ end
61
+
62
+ # Get a value using bracket notation.
63
+ #
64
+ # @param key [Symbol] the key to retrieve
65
+ # @return [Object, nil] the stored value or nil
66
+ def [](key)
67
+ get(key)
68
+ end
69
+
70
+ # Set a value using bracket notation.
71
+ #
72
+ # @param key [Symbol] the key to set
73
+ # @param value [Object] the value to store
74
+ # @return [Object] the stored value
75
+ def []=(key, value)
76
+ set(key, value)
77
+ end
78
+
79
+ # Load default values for fields that haven't been set.
80
+ #
81
+ # @return [void]
82
+ def load_defaults
83
+ settings_collection.each do |name, settings|
84
+ next if !settings.default_exists || key?(name)
85
+
86
+ if settings.default.is_a?(Proc)
87
+ set(name, @instance.instance_exec(&settings.default))
88
+ else
89
+ set(name, Utils.deep_dup(settings.default))
90
+ end
91
+ end
92
+ end
93
+
94
+ # Validate all values against their type definitions.
95
+ #
96
+ # @return [void]
97
+ # @raise [ArgTypeError] if a value fails type validation
98
+ def validate!
99
+ settings_collection.each do |name, field|
100
+ next if field.optional && (!key?(name) || get(name).nil?)
101
+
102
+ # validate_type! returns the validated value
103
+ validated_value = field.validate_type!(get(name))
104
+
105
+ # Store the validated value back
106
+ set(name, validated_value) if validated_value != get(name)
107
+ end
108
+ end
109
+
110
+ # Extend arguments hash with context values from this collection.
111
+ # Only applies to arguments collections.
112
+ #
113
+ # @param args [Hash] arguments hash to extend
114
+ # @return [Hash] the extended arguments hash
115
+ def extend_with_context(args)
116
+ return args unless @collection_type == CollectionTypes::ARGUMENTS
117
+
118
+ settings_collection.each do |name, field|
119
+ next if !field.context || args.key?(name) || !key?(name)
120
+
121
+ args[field.name] = get(name)
122
+ end
123
+
124
+ args
125
+ end
126
+
127
+ # Access a value using method call syntax.
128
+ #
129
+ # @param method_name [Symbol] the method name (used as key)
130
+ # @param args [Array] method arguments (must be empty)
131
+ # @return [Object, nil] the stored value or nil
132
+ # @raise [NoMethodError] if the key doesn't exist
133
+ def method_missing(method_name, *args, &)
134
+ return super unless respond_to_missing?(method_name, false)
135
+
136
+ get(method_name)
137
+ end
138
+
139
+ # Check if a method name corresponds to a stored key.
140
+ #
141
+ # @param method_name [Symbol] the method name to check
142
+ # @param include_private [Boolean] whether to include private methods
143
+ # @return [Boolean] true if the key exists
144
+ def respond_to_missing?(method_name, include_private = false)
145
+ key?(method_name) || settings_collection.key?(method_name) || super
146
+ end
147
+
148
+ private
149
+
150
+ def validate_collection_type!(type)
151
+ return if CollectionTypes::ALL.include?(type)
152
+
153
+ raise ArgumentError,
154
+ "collection_type must be one of #{CollectionTypes::ALL.join(', ')}, got: #{type.inspect}"
155
+ end
156
+
157
+ def settings_collection
158
+ @instance.class.public_send(@collection_type)
159
+ end
160
+ end
161
+
162
+ # Aliases for backwards compatibility
163
+ Arguments = Base
164
+ Outputs = Base
165
+ end
166
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Operandi
4
+ module Concerns
5
+ # Handles service execution logic including steps and validation
6
+ module Execution
7
+ private
8
+
9
+ # Execute the main service logic
10
+ def execute_service
11
+ self.class.validate_steps!
12
+ run_steps
13
+ run_steps_with_always
14
+ @output.validate! if success?
15
+
16
+ copy_warnings_to_parent_service
17
+ copy_errors_to_parent_service
18
+ end
19
+
20
+ # Run all service result callbacks based on success/failure
21
+ def run_service_result_callbacks
22
+ run_callbacks(:after_service_run, self)
23
+
24
+ if success?
25
+ run_callbacks(:on_service_success, self)
26
+ else
27
+ run_callbacks(:on_service_failure, self)
28
+ end
29
+ end
30
+
31
+ # Run normal steps within transaction
32
+ def run_steps
33
+ within_transaction do
34
+ # Cache steps once for both normal and always execution
35
+ @cached_steps = self.class.steps
36
+
37
+ @cached_steps.each do |name, step|
38
+ @launched_steps << name if step.run(self)
39
+
40
+ break if @errors.break? || @warnings.break?
41
+ end
42
+ rescue Operandi::StopExecution
43
+ # Gracefully handle stop_immediately! inside transaction to prevent rollback
44
+ @stopped = true
45
+ end
46
+ rescue Operandi::FailExecution
47
+ # FailExecution bubbles out of transaction (causing rollback) but is caught here
48
+ nil
49
+ end
50
+
51
+ # Run steps with parameter `always` if they weren't launched because of errors/warnings
52
+ def run_steps_with_always
53
+ # Use cached steps from run_steps, or get them if run_steps wasn't called
54
+ steps_to_check = @cached_steps || self.class.steps
55
+
56
+ steps_to_check.each do |name, step|
57
+ next if !step.always || @launched_steps.include?(name)
58
+
59
+ @launched_steps << name if step.run(self)
60
+ end
61
+ end
62
+
63
+ # Load defaults for outputs and arguments, then validate arguments
64
+ def load_defaults_and_validate
65
+ @output.load_defaults
66
+ @arg.load_defaults
67
+ @arg.validate!
68
+ end
69
+
70
+ # Execute block within transaction if configured
71
+ def within_transaction(&)
72
+ if @config[:use_transactions] && defined?(ActiveRecord::Base)
73
+ ActiveRecord::Base.transaction(requires_new: true, &)
74
+ else
75
+ yield
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Operandi
4
+ module Concerns
5
+ # Handles copying errors and warnings to parent services
6
+ module ParentService
7
+ private
8
+
9
+ # Copy warnings from this service to parent service
10
+ def copy_warnings_to_parent_service
11
+ return if !@parent_service || !@config[:load_warnings]
12
+
13
+ @parent_service.warnings.copy_from(
14
+ @warnings,
15
+ break: @config[:self_break_on_warning],
16
+ rollback: @config[:self_rollback_on_warning],
17
+ )
18
+ end
19
+
20
+ # Copy errors from this service to parent service
21
+ def copy_errors_to_parent_service
22
+ return if !@parent_service || !@config[:load_errors]
23
+
24
+ @parent_service.errors.copy_from(
25
+ @errors,
26
+ break: @config[:self_break_on_error],
27
+ rollback: @config[:self_rollback_on_error],
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Operandi
4
+ module Concerns
5
+ # Manages service state including errors and warnings initialization
6
+ module StateManagement
7
+ private
8
+
9
+ # Initialize errors collection with configuration
10
+ def initialize_errors
11
+ @errors = Messages.new(
12
+ {
13
+ break_on_add: @config[:break_on_error],
14
+ raise_on_add: @config[:raise_on_error],
15
+ rollback_on_add: @config[:use_transactions] && @config[:rollback_on_error],
16
+ },
17
+ service: self,
18
+ )
19
+ end
20
+
21
+ # Initialize warnings collection with configuration
22
+ def initialize_warnings
23
+ @warnings = Messages.new(
24
+ {
25
+ break_on_add: @config[:break_on_warning],
26
+ raise_on_add: @config[:raise_on_warning],
27
+ rollback_on_add: @config[:use_transactions] && @config[:rollback_on_warning],
28
+ },
29
+ service: self,
30
+ )
31
+ end
32
+ end
33
+ end
34
+ end