mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-01 19:00:38 +08:00
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).
286 lines
9.1 KiB
Ruby
286 lines
9.1 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# @!parse
|
|
# module Service::Base
|
|
# # @!scope class
|
|
# # @!method model(name = :model, step_name = :"fetch_#{name}", optional: false)
|
|
# # @param name [Symbol] name of the model
|
|
# # @param step_name [Symbol] name of the method to call for this step
|
|
# # @param optional [Boolean] if +true+, then the step won't fail if its return value is falsy.
|
|
# # Evaluates arbitrary code to build or fetch a model (typically from the
|
|
# # DB). If the step returns a falsy value, then the step will fail.
|
|
# #
|
|
# # It stores the resulting model in +context[:model]+ by default (can be
|
|
# # customized by providing the +name+ argument).
|
|
# #
|
|
# # @example
|
|
# # model :channel
|
|
# #
|
|
# # private
|
|
# #
|
|
# # def fetch_channel(channel_id:)
|
|
# # Chat::Channel.find_by(id: channel_id)
|
|
# # end
|
|
#
|
|
# # @!scope class
|
|
# # @!method policy(name = :default, class_name: nil)
|
|
# # @param name [Symbol] name for this policy
|
|
# # @param class_name [Class] a policy object (should inherit from +PolicyBase+)
|
|
# # Performs checks related to the state of the system. If the
|
|
# # step doesn't return a truthy value, then the policy will fail.
|
|
# #
|
|
# # When using a policy object, there is no need to define a method on the
|
|
# # service for the policy step. The policy object `#call` method will be
|
|
# # called and if the result isn't truthy, a `#reason` method is expected to
|
|
# # be implemented to explain the failure.
|
|
# #
|
|
# # Policy objects are usually useful for more complex logic.
|
|
# #
|
|
# # @example Without a policy object
|
|
# # policy :no_direct_message_channel
|
|
# #
|
|
# # private
|
|
# #
|
|
# # def no_direct_message_channel(channel:)
|
|
# # !channel.direct_message_channel?
|
|
# # end
|
|
# #
|
|
# # @example With a policy object
|
|
# # # in the service object
|
|
# # policy :no_direct_message_channel, class_name: NoDirectMessageChannelPolicy
|
|
# #
|
|
# # # in the policy object File
|
|
# # class NoDirectMessageChannelPolicy < PolicyBase
|
|
# # def call
|
|
# # !context.channel.direct_message_channel?
|
|
# # end
|
|
# #
|
|
# # def reason
|
|
# # "Direct message channels aren't supported"
|
|
# # end
|
|
# # end
|
|
#
|
|
# # @!scope class
|
|
# # @!method params(name = :default, default_values_from: nil, &block)
|
|
# # @param name [Symbol] name for this contract
|
|
# # @param default_values_from [Symbol] name of the model to get default values from
|
|
# # @param block [Proc] a block containing validations
|
|
# # Checks the validity of the input parameters.
|
|
# # Implements ActiveModel::Validations and ActiveModel::Attributes.
|
|
# #
|
|
# # It stores the resulting contract in +context[:params]+ by default
|
|
# # (can be customized by providing the +name+ argument).
|
|
# #
|
|
# # @example
|
|
# # params do
|
|
# # attribute :name
|
|
# # validates :name, presence: true
|
|
# # end
|
|
#
|
|
# # @!scope class
|
|
# # @!method step(name)
|
|
# # @param name [Symbol] the name of this step
|
|
# # Runs arbitrary code. To mark a step as failed, a call to {#fail!} needs
|
|
# # to be made explicitly.
|
|
# #
|
|
# # @example
|
|
# # step :update_channel
|
|
# #
|
|
# # private
|
|
# #
|
|
# # def update_channel(channel:, params_to_edit:)
|
|
# # channel.update!(params_to_edit)
|
|
# # end
|
|
# # @example using {#fail!} in a step
|
|
# # step :save_channel
|
|
# #
|
|
# # private
|
|
# #
|
|
# # def save_channel(channel:)
|
|
# # fail!("something went wrong") if !channel.save
|
|
# # end
|
|
#
|
|
# # @!scope class
|
|
# # @!method transaction(&block)
|
|
# # @param block [Proc] a block containing steps to be run inside a transaction
|
|
# # Runs steps inside a DB transaction.
|
|
# #
|
|
# # @example
|
|
# # transaction do
|
|
# # step :prevents_slug_collision
|
|
# # step :soft_delete_channel
|
|
# # step :log_channel_deletion
|
|
# # end
|
|
#
|
|
# # @!scope class
|
|
# # @!method options(&block)
|
|
# # @param block [Proc] a block containing options definition
|
|
# # This is used to define options allowing to parameterize the service
|
|
# # behavior. The resulting options are available in `context[:options]`.
|
|
# #
|
|
# # @example
|
|
# # options do
|
|
# # attribute :my_option, :boolean, default: false
|
|
# # end
|
|
#
|
|
# # @!scope class
|
|
# # @!method try(*exceptions, &block)
|
|
# # @param exceptions [Array<Class>] one or more exception classes to catch (defaults to +StandardError+)
|
|
# # @param block [Proc] a block containing steps to be wrapped
|
|
# # Wraps steps and catches specified exceptions. If any wrapped step
|
|
# # raises a matching exception, the step fails and the execution flow
|
|
# # is halted. The caught exception is available on the result object.
|
|
# #
|
|
# # @example
|
|
# # try do
|
|
# # step :risky_operation
|
|
# # end
|
|
# #
|
|
# # @example catching specific exceptions
|
|
# # try(ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid) do
|
|
# # step :save_record
|
|
# # end
|
|
#
|
|
# # @!scope class
|
|
# # @!method lock(*keys, &block)
|
|
# # @param keys [Array<Symbol>] one or more keys to build a unique lock name
|
|
# # @param block [Proc] a block containing steps to be wrapped
|
|
# # Wraps steps inside a +DistributedMutex+. Keys are resolved from
|
|
# # +params+ first, then from the service context. When a resolved value
|
|
# # responds to +id+ (e.g. an ActiveRecord model), its +id+ is used
|
|
# # automatically. Fails if the lock cannot be acquired.
|
|
# #
|
|
# # @example locking on a param
|
|
# # lock(:user_id) do
|
|
# # step :update_user
|
|
# # end
|
|
# #
|
|
# # @example locking on a model from context
|
|
# # model :topic
|
|
# # lock(:topic) do
|
|
# # step :update_topic
|
|
# # end
|
|
#
|
|
# # @!scope class
|
|
# # @!method only_if(name, &block)
|
|
# # @param name [Symbol] the name of the condition to check
|
|
# # @param block [Proc] a block containing steps to conditionally run
|
|
# # Conditionally runs the steps in its block. If the condition method
|
|
# # returns a falsy value, the steps are skipped but the execution flow
|
|
# # is not halted.
|
|
# #
|
|
# # @example
|
|
# # only_if(:has_post) do
|
|
# # step :update_post
|
|
# # step :log_post_update
|
|
# # end
|
|
#
|
|
# # @!scope class
|
|
# # @!method each(collection_name, as: nil, persist: nil, &block)
|
|
# # @param collection_name [Symbol] the name of the context key holding the collection
|
|
# # @param as [Symbol] override the singularized item name
|
|
# # @param persist [Hash, Array, Symbol] keys that accumulate across iterations
|
|
# # @param block [Proc] a block containing steps to run for each item
|
|
# # Iterates over a collection and runs nested steps for each item.
|
|
# # The singularized item name and +index+ are provided as keyword
|
|
# # arguments. Iteration stops on first failure. Variables set inside
|
|
# # the loop are isolated unless listed in +persist+.
|
|
# #
|
|
# # @example basic iteration
|
|
# # each :users do
|
|
# # policy :can_delete
|
|
# # step :destroy
|
|
# # end
|
|
# #
|
|
# # @example accumulating results
|
|
# # each :tag_names, persist: { results: -> { [] } } do
|
|
# # step :create_tag
|
|
# # end
|
|
# end
|
|
|
|
module Service
|
|
module Base
|
|
extend ActiveSupport::Concern
|
|
|
|
# The only exception that can be raised by a service.
|
|
class Failure < StandardError
|
|
# @return [Context]
|
|
attr_reader :context
|
|
|
|
# @!visibility private
|
|
def initialize(context = nil)
|
|
@context = context
|
|
super
|
|
end
|
|
end
|
|
|
|
included do
|
|
# The global context which is available from any step.
|
|
attr_reader :context
|
|
end
|
|
|
|
class_methods do
|
|
include StepsHelpers
|
|
|
|
def call(context = {}, &actions)
|
|
return new(context).tap(&:run).context unless block_given?
|
|
Service::Runner.call(self, context, &actions)
|
|
end
|
|
|
|
def call!(context = {})
|
|
new(context).tap(&:run!).context
|
|
end
|
|
|
|
def steps
|
|
@steps ||= []
|
|
end
|
|
end
|
|
|
|
# @!visibility private
|
|
def initialize(initial_context = {})
|
|
@context =
|
|
Context.build(
|
|
initial_context
|
|
.compact
|
|
.reverse_merge(params: {})
|
|
.merge(__steps__: self.class.steps, __service_class__: self.class),
|
|
)
|
|
initialize_params
|
|
end
|
|
|
|
# @!visibility private
|
|
def run
|
|
run!
|
|
rescue Failure => exception
|
|
raise if context.object_id != exception.context.object_id
|
|
end
|
|
|
|
# @!visibility private
|
|
def run!
|
|
self.class.steps.each { |step| step.call(self, context) }
|
|
end
|
|
|
|
# @!visibility private
|
|
def fail!(message)
|
|
step_name = caller_locations(1, 1)[0].base_label
|
|
context["result.step.#{step_name}"].fail(error: message)
|
|
context.fail!
|
|
end
|
|
|
|
private
|
|
|
|
def initialize_params
|
|
klass =
|
|
Data.define(*context[:params].keys) do
|
|
alias to_hash to_h
|
|
|
|
delegate :slice, :merge, to: :to_h
|
|
|
|
def method_missing(*)
|
|
nil
|
|
end
|
|
end
|
|
context[:params] = klass.new(*context[:params].values)
|
|
end
|
|
end
|
|
end
|