senro_usecaser 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.
@@ -0,0 +1,660 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module SenroUsecaser
6
+ # Represents a step in an organized pipeline
7
+ class Step
8
+ attr_reader :use_case_class, :if_condition, :unless_condition, :on_failure, :all_conditions, :any_conditions,
9
+ :input_mapping
10
+
11
+ # rubocop:disable Metrics/ParameterLists
12
+ #: (singleton(Base), ?if_condition: (Symbol | Proc)?, ?unless_condition: (Symbol | Proc)?, ?on_failure: Symbol?,
13
+ #: ?all_conditions: Array[(Symbol | Proc)]?, ?any_conditions: Array[(Symbol | Proc)]?,
14
+ #: ?input_mapping: (Symbol | Proc)?) -> void
15
+ def initialize(use_case_class, if_condition: nil, unless_condition: nil, on_failure: nil,
16
+ all_conditions: nil, any_conditions: nil, input_mapping: nil)
17
+ @use_case_class = use_case_class
18
+ @if_condition = if_condition
19
+ @unless_condition = unless_condition
20
+ @on_failure = on_failure
21
+ @all_conditions = all_conditions
22
+ @any_conditions = any_conditions
23
+ @input_mapping = input_mapping
24
+ end
25
+ # rubocop:enable Metrics/ParameterLists
26
+
27
+ # Checks if this step should be executed based on conditions
28
+ #
29
+ #: (untyped, untyped) -> bool
30
+ def should_execute?(input, use_case_instance)
31
+ return false if if_condition && !evaluate_condition(if_condition, input, use_case_instance)
32
+ return false if unless_condition && evaluate_condition(unless_condition, input, use_case_instance)
33
+ return false if all_conditions && !all_conditions_met?(input, use_case_instance)
34
+ return false if any_conditions && !any_condition_met?(input, use_case_instance)
35
+
36
+ true
37
+ end
38
+
39
+ # Maps the input for this step based on input_mapping configuration
40
+ #
41
+ #: (untyped, untyped) -> untyped
42
+ def map_input(input, use_case_instance)
43
+ return input unless input_mapping
44
+
45
+ case input_mapping
46
+ when Symbol
47
+ use_case_instance.send(input_mapping, input)
48
+ when Proc
49
+ input_mapping.call(input)
50
+ else
51
+ input
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ #: ((Symbol | Proc), untyped, untyped) -> bool
58
+ def evaluate_condition(condition, input, use_case_instance)
59
+ case condition
60
+ when Symbol
61
+ use_case_instance.send(condition, input)
62
+ when Proc
63
+ condition.call(input)
64
+ else
65
+ raise ArgumentError, "Invalid condition type: #{condition.class}"
66
+ end
67
+ end
68
+
69
+ #: (untyped, untyped) -> bool
70
+ def all_conditions_met?(input, use_case_instance)
71
+ all_conditions.all? { |cond| evaluate_condition(cond, input, use_case_instance) }
72
+ end
73
+
74
+ #: (untyped, untyped) -> bool
75
+ def any_condition_met?(input, use_case_instance)
76
+ any_conditions.any? { |cond| evaluate_condition(cond, input, use_case_instance) }
77
+ end
78
+ end
79
+
80
+ # Base class for all UseCases
81
+ #
82
+ # @example Basic UseCase with keyword arguments
83
+ # class CreateUserUseCase < SenroUsecaser::Base
84
+ # def call(name:, email:)
85
+ # user = User.create(name: name, email: email)
86
+ # success(user)
87
+ # end
88
+ # end
89
+ #
90
+ # result = CreateUserUseCase.call(name: "Taro", email: "taro@example.com")
91
+ #
92
+ # @example With input/output classes (recommended for pipelines)
93
+ # class CreateUserUseCase < SenroUsecaser::Base
94
+ # input CreateUserInput
95
+ # output CreateUserOutput
96
+ #
97
+ # def call(input)
98
+ # user = User.create(name: input.name, email: input.email)
99
+ # success(CreateUserOutput.new(user: user))
100
+ # end
101
+ # end
102
+ #
103
+ # @example Pipeline with input/output chaining
104
+ # class StepA < SenroUsecaser::Base
105
+ # input AInput
106
+ # output AOutput
107
+ # def call(input)
108
+ # success(AOutput.new(value: input.value * 2))
109
+ # end
110
+ # end
111
+ #
112
+ # class StepB < SenroUsecaser::Base
113
+ # input AOutput # Receives StepA's output directly
114
+ # output BOutput
115
+ # def call(input)
116
+ # success(BOutput.new(result: input.value + 1))
117
+ # end
118
+ # end
119
+ #
120
+ # class Pipeline < SenroUsecaser::Base
121
+ # organize StepA, StepB
122
+ # end
123
+ class Base
124
+ class << self
125
+ # Declares a dependency to be injected from the container
126
+ #
127
+ #: (Symbol, ?Class) -> void
128
+ def depends_on(name, type = nil)
129
+ dependencies << name unless dependencies.include?(name)
130
+ dependency_types[name] = type if type
131
+
132
+ define_method(name) do
133
+ @_dependencies[name]
134
+ end
135
+ end
136
+
137
+ # Returns the list of declared dependencies
138
+ #
139
+ #: () -> Array[Symbol]
140
+ def dependencies
141
+ @dependencies ||= []
142
+ end
143
+
144
+ # Returns the dependency type mapping
145
+ #
146
+ #: () -> Hash[Symbol, Class]
147
+ def dependency_types
148
+ @dependency_types ||= {}
149
+ end
150
+
151
+ # Sets the namespace for dependency resolution
152
+ #
153
+ #: ((Symbol | String)) -> void
154
+ def namespace(name)
155
+ @use_case_namespace = name
156
+ end
157
+
158
+ # Returns the declared namespace
159
+ #
160
+ #: () -> (Symbol | String)?
161
+ attr_reader :use_case_namespace
162
+
163
+ # Declares a sequence of UseCases to execute as a pipeline
164
+ #
165
+ # @example Basic organize
166
+ # organize StepA, StepB, StepC
167
+ #
168
+ # @example With block and step
169
+ # organize do
170
+ # step StepA
171
+ # step StepB, if: :should_run?
172
+ # step StepC, on_failure: :continue
173
+ # end
174
+ #
175
+ #: (*Class, ?on_failure: Symbol) ?{ () -> void } -> void
176
+ def organize(*use_case_classes, on_failure: :stop, &block)
177
+ @on_failure_strategy = on_failure
178
+
179
+ if block
180
+ @organized_steps = [] #: Array[Step]
181
+ @_defining_steps = true
182
+ instance_eval(&block) # steep:ignore BlockTypeMismatch
183
+ @_defining_steps = false
184
+ else
185
+ @organized_steps = use_case_classes.map { |klass| Step.new(klass) }
186
+ end
187
+ end
188
+
189
+ # Defines a step in the organize block
190
+ #
191
+ # rubocop:disable Metrics/ParameterLists
192
+ #: (Class, ?if: (Symbol | Proc)?, ?unless: (Symbol | Proc)?, ?on_failure: Symbol?,
193
+ #: ?all: Array[(Symbol | Proc)]?, ?any: Array[(Symbol | Proc)]?,
194
+ #: ?input: (Symbol | Proc)?) -> void
195
+ def step(use_case_class, if: nil, unless: nil, on_failure: nil, all: nil, any: nil, input: nil)
196
+ raise "step can only be called inside organize block" unless @_defining_steps
197
+
198
+ @organized_steps << Step.new(
199
+ use_case_class,
200
+ if_condition: binding.local_variable_get(:if),
201
+ unless_condition: binding.local_variable_get(:unless),
202
+ on_failure: on_failure,
203
+ all_conditions: all,
204
+ any_conditions: any,
205
+ input_mapping: input
206
+ )
207
+ end
208
+ # rubocop:enable Metrics/ParameterLists
209
+
210
+ # Returns the list of organized steps
211
+ #
212
+ #: () -> Array[Step]?
213
+ attr_reader :organized_steps
214
+
215
+ # Returns the failure handling strategy
216
+ #
217
+ #: () -> Symbol
218
+ def on_failure_strategy
219
+ @on_failure_strategy || :stop
220
+ end
221
+
222
+ # Adds extension modules with hooks
223
+ #
224
+ #: (*Module) -> void
225
+ def extend_with(*extensions)
226
+ extensions.each { |ext| self.extensions << ext }
227
+ end
228
+
229
+ # Returns the list of extensions
230
+ #
231
+ #: () -> Array[Module]
232
+ def extensions
233
+ @extensions ||= []
234
+ end
235
+
236
+ # Adds a before hook
237
+ #
238
+ #: () { (untyped) -> void } -> void
239
+ def before(&block)
240
+ before_hooks << block
241
+ end
242
+
243
+ # Returns the list of before hooks
244
+ #
245
+ #: () -> Array[Proc]
246
+ def before_hooks
247
+ @before_hooks ||= []
248
+ end
249
+
250
+ # Adds an after hook
251
+ #
252
+ #: () { (untyped, Result[untyped]) -> void } -> void
253
+ def after(&block)
254
+ after_hooks << block
255
+ end
256
+
257
+ # Returns the list of after hooks
258
+ #
259
+ #: () -> Array[Proc]
260
+ def after_hooks
261
+ @after_hooks ||= []
262
+ end
263
+
264
+ # Adds an around hook
265
+ #
266
+ #: () { (untyped) { () -> Result[untyped] } -> Result[untyped] } -> void
267
+ def around(&block)
268
+ around_hooks << block if block
269
+ end
270
+
271
+ # Returns the list of around hooks
272
+ #
273
+ #: () -> Array[Proc]
274
+ def around_hooks
275
+ @around_hooks ||= []
276
+ end
277
+
278
+ # Declares the expected input type for this UseCase
279
+ #
280
+ #: (Class) -> void
281
+ def input(type)
282
+ @input_class = type
283
+ end
284
+
285
+ # Returns the input class
286
+ #
287
+ #: () -> Class?
288
+ attr_reader :input_class
289
+
290
+ # Declares the expected output type for this UseCase
291
+ #
292
+ #: ((Class | Hash[Symbol, Class])) -> void
293
+ def output(type_or_schema)
294
+ @output_schema = type_or_schema
295
+ end
296
+
297
+ # Returns the output schema
298
+ #
299
+ #: () -> (Class | Hash[Symbol, Class])?
300
+ attr_reader :output_schema
301
+
302
+ # Calls the UseCase with the given input
303
+ #
304
+ #: [T] (?untyped, ?container: Container, **untyped) -> Result[T]
305
+ def call(input = nil, container: nil, **args)
306
+ new(container: container).perform(input, capture_exceptions: false, **args)
307
+ end
308
+
309
+ # Calls the UseCase and captures any exceptions as failures
310
+ #
311
+ #: [T] (?untyped, ?container: Container, **untyped) -> Result[T]
312
+ def call!(input = nil, container: nil, **args)
313
+ new(container: container).perform(input, capture_exceptions: true, **args)
314
+ rescue StandardError => e
315
+ Result.from_exception(e)
316
+ end
317
+
318
+ # Calls the UseCase with custom exception handling options
319
+ #
320
+ #: [T] (input: untyped, ?container: Container, ?exception_classes: Array[Class], ?code: Symbol) -> Result[T]
321
+ def call_with_capture(input:, container: nil, exception_classes: [StandardError], code: :exception)
322
+ new(container: container).perform(input)
323
+ rescue *exception_classes => e
324
+ Result.from_exception(e, code: code)
325
+ end
326
+
327
+ # @api private
328
+ def inherited(subclass)
329
+ super
330
+ copy_configuration_to(subclass)
331
+ copy_hooks_to(subclass)
332
+ end
333
+
334
+ private
335
+
336
+ def copy_configuration_to(subclass)
337
+ subclass.instance_variable_set(:@dependencies, dependencies.dup)
338
+ subclass.instance_variable_set(:@dependency_types, dependency_types.dup)
339
+ subclass.instance_variable_set(:@use_case_namespace, @use_case_namespace)
340
+ subclass.instance_variable_set(:@organized_steps, @organized_steps&.dup)
341
+ subclass.instance_variable_set(:@on_failure_strategy, @on_failure_strategy)
342
+ subclass.instance_variable_set(:@input_class, @input_class)
343
+ subclass.instance_variable_set(:@output_schema, @output_schema)
344
+ end
345
+
346
+ def copy_hooks_to(subclass)
347
+ subclass.instance_variable_set(:@extensions, extensions.dup)
348
+ subclass.instance_variable_set(:@before_hooks, before_hooks.dup)
349
+ subclass.instance_variable_set(:@after_hooks, after_hooks.dup)
350
+ subclass.instance_variable_set(:@around_hooks, around_hooks.dup)
351
+ end
352
+ end
353
+
354
+ # Initializes the UseCase with dependencies resolved from the container
355
+ #
356
+ #: (?container: Container?, ?dependencies: Hash[Symbol, untyped]) -> void
357
+ def initialize(container: nil, dependencies: {})
358
+ @_container = container || SenroUsecaser.container
359
+ @_dependencies = {} #: Hash[Symbol, untyped]
360
+
361
+ resolve_dependencies(@_container, dependencies)
362
+ end
363
+
364
+ # Performs the UseCase with hooks
365
+ #
366
+ #: (untyped, ?capture_exceptions: bool) -> Result[untyped]
367
+ def perform(input, capture_exceptions: false)
368
+ @_capture_exceptions = capture_exceptions
369
+
370
+ unless self.class.input_class || self.class.organized_steps
371
+ raise ArgumentError, "#{self.class.name} must define `input` class"
372
+ end
373
+
374
+ execute_with_hooks(input) do
375
+ call(input)
376
+ end
377
+ end
378
+
379
+ # Executes the UseCase logic
380
+ #
381
+ #: (?untyped input) -> Result[untyped]
382
+ def call(input = nil)
383
+ return execute_pipeline(input) if self.class.organized_steps
384
+
385
+ raise NotImplementedError, "#{self.class.name}#call must be implemented"
386
+ end
387
+
388
+ private
389
+
390
+ # Creates a success Result with the given value
391
+ #
392
+ #: [T] (T) -> Result[T]
393
+ def success(value)
394
+ Result.success(value)
395
+ end
396
+
397
+ # Creates a failure Result with the given errors
398
+ #
399
+ #: (*Error) -> Result[untyped]
400
+ def failure(*errors)
401
+ Result.failure(*errors)
402
+ end
403
+
404
+ # Creates a failure Result from an exception
405
+ #
406
+ #: (Exception, ?code: Symbol) -> Result[untyped]
407
+ def failure_from_exception(exception, code: :exception)
408
+ Result.from_exception(exception, code: code)
409
+ end
410
+
411
+ # Executes a block and captures any exceptions as failures
412
+ #
413
+ #: [T] (*Class, ?code: Symbol) { () -> T } -> Result[T]
414
+ def capture(*exception_classes, code: :exception, &)
415
+ Result.capture(*exception_classes, code: code, &)
416
+ end
417
+
418
+ # Executes the core logic with before/after/around hooks
419
+ #
420
+ #: (untyped) { () -> Result[untyped] } -> Result[untyped]
421
+ def execute_with_hooks(input, &core_block)
422
+ execution = build_around_chain(input, core_block)
423
+ run_before_hooks(input)
424
+ result = execution.call
425
+ run_after_hooks(input, result)
426
+ result
427
+ end
428
+
429
+ # Wraps a non-Result value in Result.success
430
+ #
431
+ #: (untyped) -> Result[untyped]
432
+ def wrap_result(value)
433
+ return value if value.is_a?(Result)
434
+
435
+ Result.success(value)
436
+ end
437
+
438
+ # Builds the around hook chain
439
+ #
440
+ #: (untyped, Proc) -> Proc
441
+ def build_around_chain(input, core_block)
442
+ wrapped_core = -> { wrap_result(core_block.call) }
443
+ all_around_hooks = collect_around_hooks
444
+
445
+ all_around_hooks.reverse.reduce(wrapped_core) do |inner, hook|
446
+ -> { wrap_result(hook.call(input) { inner.call }) }
447
+ end
448
+ end
449
+
450
+ # Collects all around hooks from extensions and block-based hooks
451
+ #
452
+ #: () -> Array[Proc]
453
+ def collect_around_hooks
454
+ hooks = [] #: Array[Proc]
455
+ self.class.extensions.each do |ext|
456
+ hooks << ext.method(:around).to_proc if ext.respond_to?(:around)
457
+ end
458
+ hooks.concat(self.class.around_hooks)
459
+ hooks
460
+ end
461
+
462
+ # Runs all before hooks
463
+ #
464
+ #: (untyped) -> void
465
+ def run_before_hooks(input)
466
+ self.class.extensions.each do |ext|
467
+ ext.send(:before, input) if ext.respond_to?(:before)
468
+ end
469
+ self.class.before_hooks.each { |hook| hook.call(input) }
470
+ end
471
+
472
+ # Runs all after hooks
473
+ #
474
+ #: (untyped, Result[untyped]) -> void
475
+ def run_after_hooks(input, result)
476
+ self.class.extensions.each do |ext|
477
+ ext.send(:after, input, result) if ext.respond_to?(:after)
478
+ end
479
+ self.class.after_hooks.each { |hook| hook.call(input, result) }
480
+ end
481
+
482
+ # Resolves dependencies from the container
483
+ #
484
+ #: (Container, Hash[Symbol, untyped]) -> void
485
+ def resolve_dependencies(container, manual_dependencies)
486
+ self.class.dependencies.each do |name|
487
+ @_dependencies[name] = if manual_dependencies.key?(name)
488
+ manual_dependencies[name]
489
+ else
490
+ resolve_from_container(container, name)
491
+ end
492
+ end
493
+ end
494
+
495
+ # Resolves a single dependency from the container
496
+ #
497
+ #: (Container, Symbol) -> untyped
498
+ def resolve_from_container(container, name)
499
+ namespace = effective_namespace
500
+ if namespace
501
+ container.resolve_in(namespace, name)
502
+ else
503
+ container.resolve(name)
504
+ end
505
+ end
506
+
507
+ # Returns the effective namespace for dependency resolution
508
+ #
509
+ #: () -> (Symbol | String)?
510
+ def effective_namespace
511
+ return self.class.use_case_namespace if self.class.use_case_namespace
512
+ return nil unless SenroUsecaser.configuration.infer_namespace_from_module
513
+
514
+ infer_namespace_from_class
515
+ end
516
+
517
+ # Infers namespace from the class's module structure
518
+ #
519
+ #: () -> String?
520
+ def infer_namespace_from_class
521
+ class_name = self.class.name
522
+ return nil unless class_name
523
+
524
+ parts = class_name.split("::")
525
+ return nil if parts.length <= 1
526
+
527
+ module_parts = parts[0...-1] || [] #: Array[String]
528
+ return nil if module_parts.empty?
529
+
530
+ module_parts.map { |part| part.gsub(/([a-z])([A-Z])/, '\1_\2').downcase }.join("::")
531
+ end
532
+
533
+ # Executes the organized UseCase pipeline
534
+ #
535
+ #: (untyped) -> Result[untyped]
536
+ def execute_pipeline(input)
537
+ case self.class.on_failure_strategy
538
+ when :stop
539
+ execute_pipeline_stop(input)
540
+ when :continue
541
+ execute_pipeline_continue(input)
542
+ when :collect
543
+ execute_pipeline_collect(input)
544
+ else
545
+ raise ArgumentError, "Unknown on_failure strategy: #{self.class.on_failure_strategy}"
546
+ end
547
+ end
548
+
549
+ # Executes pipeline with :stop strategy
550
+ #
551
+ #: (untyped) -> Result[untyped]
552
+ def execute_pipeline_stop(input)
553
+ current_input = input
554
+ result = nil #: Result[untyped]?
555
+
556
+ self.class.organized_steps&.each do |step|
557
+ next unless step.should_execute?(current_input, self)
558
+
559
+ step_result = execute_step(step, current_input)
560
+ return step_result if step_result.failure? && step_should_stop?(step)
561
+
562
+ current_input = step_result.value if step_result.success?
563
+ result = step_result
564
+ end
565
+
566
+ result || success(current_input)
567
+ end
568
+
569
+ # Executes pipeline with :continue strategy
570
+ #
571
+ #: (untyped) -> Result[untyped]
572
+ def execute_pipeline_continue(input)
573
+ current_input = input
574
+ result = nil #: Result[untyped]?
575
+
576
+ self.class.organized_steps&.each do |step|
577
+ next unless step.should_execute?(current_input, self)
578
+
579
+ step_result = execute_step(step, current_input)
580
+ return step_result if step_result.failure? && step.on_failure == :stop
581
+
582
+ current_input = step_result.value if step_result.success?
583
+ result = step_result
584
+ end
585
+
586
+ result || success(current_input)
587
+ end
588
+
589
+ # Executes pipeline with :collect strategy
590
+ #
591
+ #: (untyped) -> Result[untyped]
592
+ def execute_pipeline_collect(input)
593
+ errors = [] #: Array[Error]
594
+ state = { input: input, errors: errors, last_success: nil }
595
+
596
+ self.class.organized_steps&.each do |step|
597
+ next unless step.should_execute?(state[:input], self)
598
+
599
+ result = execute_step(step, state[:input])
600
+ break if should_stop_collect_pipeline?(result, step, state)
601
+ end
602
+
603
+ build_collect_result(state)
604
+ end
605
+
606
+ # Updates collect state and checks if pipeline should stop
607
+ #
608
+ #: (Result[untyped], Step, Hash[Symbol, untyped]) -> bool
609
+ def should_stop_collect_pipeline?(result, step, state)
610
+ if result.failure?
611
+ state[:errors].concat(result.errors)
612
+ step.on_failure == :stop
613
+ else
614
+ state[:input] = result.value
615
+ state[:last_success] = result
616
+ false
617
+ end
618
+ end
619
+
620
+ # Builds the final result for collect mode
621
+ #
622
+ #: (Hash[Symbol, untyped]) -> Result[untyped]
623
+ def build_collect_result(state)
624
+ if state[:errors].any?
625
+ Result.failure(*state[:errors])
626
+ else
627
+ state[:last_success] || success(state[:input])
628
+ end
629
+ end
630
+
631
+ # Executes a single step in the pipeline
632
+ #
633
+ #: (Step, untyped) -> Result[untyped]
634
+ def execute_step(step, input)
635
+ mapped_input = step.map_input(input, self)
636
+ call_use_case(step.use_case_class, mapped_input)
637
+ end
638
+
639
+ # Determines if a step failure should stop the pipeline
640
+ #
641
+ #: (Step) -> bool
642
+ def step_should_stop?(step)
643
+ step_strategy = step.on_failure || self.class.on_failure_strategy
644
+ step_strategy == :stop
645
+ end
646
+
647
+ # Calls a single UseCase in the pipeline
648
+ # Requires input_class to be defined for pipeline steps
649
+ #
650
+ #: (singleton(Base), untyped) -> Result[untyped]
651
+ def call_use_case(use_case_class, input)
652
+ unless use_case_class.input_class
653
+ raise ArgumentError, "#{use_case_class.name} must define `input` class to be used in a pipeline"
654
+ end
655
+
656
+ call_method = @_capture_exceptions || false ? :call! : :call #: Symbol
657
+ use_case_class.public_send(call_method, input, container: @_container)
658
+ end
659
+ end
660
+ end