discourse/lib/service/base/each_step.rb
Loïc Guitaut 3ed866a6c9
DEV: Add each step to service framework for collection iteration (#38759)
Services that process collections (bulk delete, bulk create, etc.)
currently require manual iteration inside a `step`, losing the
framework's built-in error handling and inspection. This adds a
first-class `each` step that brings the full service DSL inside the
iteration loop.

```ruby
  each :users do
    policy :can_delete
    step :destroy
  end
```

Each iteration receives the singularized item name (`user:`) and
`index:` as keyword arguments. If any nested step fails, iteration stops
and the failing item/index remain in context for error reporting.
Existing matchers (`fail_a_policy`, `fail_a_step`, etc.) work inside
each blocks.

A `persist:` option allows values to accumulate across iterations and
survive the loop's variable isolation:

```ruby
  each :tag_names, persist: { results: -> { { created: [], failed: [] } } } do
    step :create_tag
  end
  # context[:results] available after the loop
```

The steps inspector displays iteration progress ((3/3) on success, (1/3)
when failing at the first item, (empty collection) with ⏭️ when
skipped).
2026-04-03 09:44:03 +02:00

60 lines
1.6 KiB
Ruby

# frozen_string_literal: true
module Service
module Base
class EachStep < Step
include StepsHelpers
attr_reader :steps, :item_name, :initializers
def initialize(name, as: nil, persist: nil, &block)
super(name)
@item_name = as || name.to_s.singularize.to_sym
@initializers = build_initializers(persist)
@steps = []
instance_exec(&block)
end
def run_step
context.with_isolation(persist_keys: [item_name, :index, *initializers.keys]) do
context.merge!(initializers.transform_values { instance.instance_exec(&it) })
Array
.wrap(context[name])
.tap do |collection|
context[result_key].merge!(total: collection.size, skipped?: collection.empty?)
end
.each_with_index do |item, index|
context.merge!(item_name => item, :index => index)
steps.each { |step| step.call(instance, context) }
end
end
end
private
def build_initializers(value)
case value
when nil
{}
when Array
value.each_with_object({}) { |item, hash| hash.merge!(build_initializers(item)) }
when Symbol
{ value => proc {} }
when Hash
value.transform_values(&method(:make_lambda))
end
end
def make_lambda(filter)
case filter
when Symbol
proc { send(filter) }
when Proc
proc { instance_exec(&filter) }
else
proc {}
end
end
end
end
end