discourse/lib/service/base/context.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

94 lines
2.2 KiB
Ruby

# frozen_string_literal: true
module Service
module Base
# Simple structure to hold the context of the service during its whole lifecycle.
class Context
delegate :slice, :dig, :merge!, to: :store
def self.build(context = {})
self === context ? context : new(context)
end
def initialize(context = {})
@store = context.symbolize_keys
end
def [](key)
store[key.to_sym]
end
def []=(key, value)
store[key.to_sym] = value
end
def to_h
store.deep_dup
end
def with_isolation(persist_keys: [])
@isolated_store = to_h
yield
ensure
@store.merge!(@isolated_store.slice(*persist_keys, *step_result_keys))
@isolated_store = nil
end
# @return [Boolean] returns +true+ if the context is set as successful (default)
def success?
!failure?
end
# @return [Boolean] returns +true+ if the context is set as failed
# @see #fail!
# @see #fail
def failure?
@failure || false
end
# Marks the context as failed.
# @param context [Hash, Context] the context to merge into the current one
# @example
# context.fail!("failure": "something went wrong")
# @return [Context]
def fail!(context = {})
self.fail(context)
raise Failure, self
end
# Marks the context as failed without raising an exception.
# @param context [Hash, Context] the context to merge into the current one
# @example
# context.fail("failure": "something went wrong")
# @return [Context]
def fail(context = {})
store.merge!(context.symbolize_keys)
@failure = true
self
end
def inspect_steps
Service::StepsInspector.new(self).inspect
end
private
def store
@isolated_store || @store
end
def step_result_keys
store.keys.select { it.start_with?("result.") }
end
def method_missing(method_name, *args, &block)
return super if args.present?
store[method_name]
end
def respond_to_missing?(name, include_all)
store.key?(name) || super
end
end
end
end