├── .rspec ├── spec ├── dummy │ ├── app │ │ ├── models │ │ │ ├── item.rb │ │ │ ├── user.rb │ │ │ ├── order.rb │ │ │ ├── order_item.rb │ │ │ └── application_record.rb │ │ ├── jobs │ │ │ ├── application_job.rb │ │ │ ├── notify_user_about_created_order_job.rb │ │ │ └── notify_user_about_updated_order_item_job.rb │ │ ├── controllers │ │ │ └── application_controller.rb │ │ └── services │ │ │ ├── create_order.rb │ │ │ └── add_item_to_cart.rb │ ├── config │ │ ├── routes.rb │ │ ├── environment.rb │ │ ├── environments │ │ │ └── test.rb │ │ ├── boot.rb │ │ ├── database.yml │ │ └── application.rb │ └── db │ │ └── schema.rb ├── clean_actions_spec.rb ├── clean_actions │ ├── error_reporter_spec.rb │ ├── fail_with_spec.rb │ ├── isolation_level_validator_spec.rb │ ├── typed_returns_spec.rb │ ├── action_spec.rb │ └── transaction_runner_spec.rb ├── spec_helper.rb └── integrations │ ├── simple_action_spec.rb │ └── nested_action_spec.rb ├── lib ├── clean_actions │ ├── version.rb │ ├── base.rb │ ├── error_reporter.rb │ ├── action_failure.rb │ ├── configuration.rb │ ├── fail_with.rb │ ├── isolation_level_validator.rb │ ├── typed_returns.rb │ ├── action.rb │ └── transaction_runner.rb └── clean_actions.rb ├── .standard.yml ├── bin └── setup ├── CHANGELOG.md ├── Rakefile ├── .gitignore ├── Gemfile ├── .github └── workflows │ ├── lint.yml │ └── test.yml ├── LICENSE.txt ├── clear_actions.gemspec └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /spec/dummy/app/models/item.rb: -------------------------------------------------------------------------------- 1 | class Item < ApplicationRecord 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ApplicationRecord 2 | has_many :orders 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/models/order.rb: -------------------------------------------------------------------------------- 1 | class Order < ApplicationRecord 2 | has_many :order_items 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | end 5 | -------------------------------------------------------------------------------- /lib/clean_actions/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CleanActions 4 | VERSION = "0.1.0" 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/jobs/notify_user_about_created_order_job.rb: -------------------------------------------------------------------------------- 1 | class NotifyUserAboutCreatedOrderJob < ApplicationJob 2 | end 3 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | # For available configuration options, see: 2 | # https://github.com/testdouble/standard 3 | ruby_version: 3.0 4 | -------------------------------------------------------------------------------- /spec/dummy/app/jobs/notify_user_about_updated_order_item_job.rb: -------------------------------------------------------------------------------- 1 | class NotifyUserAboutUpdatedOrderItemJob < ApplicationJob 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/models/order_item.rb: -------------------------------------------------------------------------------- 1 | class OrderItem < ApplicationRecord 2 | belongs_to :order 3 | belongs_to :item 4 | end 5 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | createdb clean_actions_test 8 | -------------------------------------------------------------------------------- /lib/clean_actions/base.rb: -------------------------------------------------------------------------------- 1 | module CleanActions 2 | class Base < Action 3 | include TypedReturns 4 | include FailWith 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "application" 4 | 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.configure do 4 | config.eager_load = false 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::API 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) 4 | 5 | require "bundler/setup" 6 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | adapter: postgresql 3 | pool: 5 4 | timeout: 5000 5 | 6 | test: 7 | <<: *default 8 | database: clean_actions_test 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## main 4 | 5 | # 0.1.0 (21-09-2023) 6 | 7 | - Initial version ([@DmitryTsepelev]) 8 | 9 | [@DmitryTsepelev]: https://github.com/DmitryTsepelev 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | require "standard/rake" 6 | 7 | RSpec::Core::RakeTask.new(:spec) 8 | 9 | task default: %i[spec standard] 10 | -------------------------------------------------------------------------------- /spec/clean_actions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe CleanActions do 4 | subject { described_class } 5 | 6 | it "has a version number" do 7 | expect(subject::VERSION).not_to be nil 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/clean_actions/error_reporter.rb: -------------------------------------------------------------------------------- 1 | module CleanActions 2 | class ErrorReporter 3 | def self.report(message) 4 | Rails.logger.info(message) 5 | 6 | raise message if CleanActions.config.raise_errors? 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/clean_actions/action_failure.rb: -------------------------------------------------------------------------------- 1 | module CleanActions 2 | class ActionFailure < ActiveRecord::Rollback 3 | attr_reader :reason 4 | 5 | def initialize(reason) 6 | @reason = reason 7 | super 8 | end 9 | 10 | def ==(other) 11 | reason == other.reason 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | spec/dummy/db/*.sqlite3 11 | spec/dummy/db/*.sqlite3-journal 12 | spec/dummy/log/*.log 13 | spec/dummy/tmp/ 14 | 15 | .rspec_status 16 | 17 | Gemfile.lock 18 | .ruby-version 19 | .ruby-gemset 20 | .tool-versions -------------------------------------------------------------------------------- /spec/dummy/app/services/create_order.rb: -------------------------------------------------------------------------------- 1 | class CreateOrder < CleanActions::Base 2 | returns Order 3 | 4 | fail_with(:banned_user) { @user.banned? } 5 | 6 | def initialize(user:, raise_standard_error: false) 7 | @user = user 8 | @raise_standard_error = raise_standard_error 9 | end 10 | 11 | def perform_actions 12 | raise StandardError if @raise_standard_error 13 | 14 | @order = @user.orders.find_or_create_by!(status: "cart") 15 | end 16 | 17 | def after_commit 18 | NotifyUserAboutCreatedOrderJob.perform_later(order: @order) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/clean_actions/configuration.rb: -------------------------------------------------------------------------------- 1 | module CleanActions 2 | class Configuration 3 | attr_accessor :raise_errors 4 | 5 | def initialize 6 | @raise_errors = Rails.env.development? || Rails.env.test? 7 | end 8 | 9 | def isolation_level=(isolation_level) 10 | IsolationLevelValidator.validate(isolation_level, allow_serializable: true) 11 | @isolation_level = isolation_level 12 | end 13 | 14 | def isolation_level 15 | @isolation_level ||= :read_committed 16 | end 17 | 18 | alias_method :raise_errors?, :raise_errors 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/dummy/app/services/add_item_to_cart.rb: -------------------------------------------------------------------------------- 1 | class AddItemToCart < CleanActions::Base 2 | returns OrderItem 3 | 4 | fail_with(:out_of_stock) { @item.in_stock == 0 } 5 | 6 | def initialize(user:, item:) 7 | @user = user 8 | @item = item 9 | end 10 | 11 | def perform_actions 12 | @order = CreateOrder.call(user: @user) 13 | @order_item = @order.order_items 14 | .create_with(quantity: 0) 15 | .find_or_create_by!(item: @item) 16 | end 17 | 18 | def after_commit 19 | NotifyUserAboutUpdatedOrderItemJob.perform_later(order_item: @order_item) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/clean_actions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_record" 4 | 5 | require "clean_actions/configuration" 6 | require "clean_actions/error_reporter" 7 | require "clean_actions/isolation_level_validator" 8 | require "clean_actions/fail_with" 9 | require "clean_actions/typed_returns" 10 | require "clean_actions/action_failure" 11 | require "clean_actions/transaction_runner" 12 | require "clean_actions/action" 13 | require "clean_actions/base" 14 | require "clean_actions/version" 15 | 16 | module CleanActions 17 | class << self 18 | def config 19 | @config ||= Configuration.new 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/clean_actions/fail_with.rb: -------------------------------------------------------------------------------- 1 | module CleanActions 2 | module FailWith 3 | def self.included(base) 4 | base.extend(ClassMethods) 5 | end 6 | 7 | def dry_call 8 | self.class.before_actions_blocks.each_with_object([]) do |b, failures| 9 | instance_eval(&b) 10 | rescue CleanActions::ActionFailure => f 11 | failures << f 12 | end 13 | end 14 | 15 | module ClassMethods 16 | def fail_with(failure_reason, &block) 17 | before_actions { fail!(failure_reason) if instance_eval(&block) } 18 | end 19 | 20 | def dry_call(**kwargs) 21 | new(**kwargs).dry_call 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "boot" 4 | 5 | require "rails" 6 | require "active_job/railtie" 7 | require "active_model/railtie" 8 | require "active_record/railtie" 9 | require "action_controller/railtie" 10 | require "rails/test_unit/railtie" 11 | 12 | Bundler.require(*Rails.groups) 13 | 14 | module Dummy 15 | class Application < Rails::Application 16 | config.root = File.join(__dir__, "..") 17 | config.logger = Logger.new("/dev/null") 18 | config.api_only = true 19 | 20 | if Rails::VERSION::MAJOR >= 7 21 | config.active_record.async_query_executor = :global_thread_pool 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | # standard: disable Bundler/DuplicatedGem 8 | if (rails_version = ENV["RAILS_VERSION"]) 9 | case rails_version 10 | when "HEAD" 11 | git "https://github.com/rails/rails.git" do 12 | gem "rails" 13 | end 14 | else 15 | rails_version = "~> #{rails_version}.0" if rails_version.match?(/^\d+\.\d+$/) # "7.0" => "~> 7.0.0" 16 | gem "rails", rails_version 17 | end 18 | end 19 | # standard: enable Bundler/DuplicatedGem 20 | 21 | gem "rake", "~> 13.0" 22 | gem "rspec", "~> 3.0" 23 | gem "rspec-rails", "~> 5.0" 24 | gem "database_cleaner-active_record", "~> 2.0" 25 | gem "standard", "~> 1.24.0" 26 | 27 | # Dummy app dependencies 28 | gem "pg", "~> 1.4" 29 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - '**' 8 | tags-ignore: 9 | - 'v*' 10 | 11 | jobs: 12 | rubocop: 13 | # Skip running tests for local pull requests (use push event instead), run only for foreign ones 14 | if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.owner.login != github.event.pull_request.base.repo.owner.login 15 | name: Standard.rb 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: "3.1" 22 | bundler-cache: true 23 | - name: Lint Ruby code with Standard.rb 24 | run: | 25 | bundle exec rake standard 26 | -------------------------------------------------------------------------------- /spec/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Schema.define do 4 | self.verbose = false 5 | 6 | create_table :users, force: true do |t| 7 | t.boolean :banned, null: false, default: false 8 | t.timestamps null: false 9 | end 10 | 11 | create_table :orders, force: true do |t| 12 | t.string :status, null: false, default: "cart" 13 | t.references :user 14 | t.timestamps null: false 15 | end 16 | 17 | create_table :order_items, force: true do |t| 18 | t.references :order 19 | t.references :item 20 | t.integer :quantity 21 | t.timestamps null: false 22 | end 23 | 24 | create_table :items, force: true do |t| 25 | t.string :name, null: false 26 | t.integer :in_stock, null: false 27 | t.timestamps null: false 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/clean_actions/error_reporter_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe CleanActions::ErrorReporter do 4 | subject { described_class.report(message) } 5 | 6 | let(:message) { "message" } 7 | 8 | before do 9 | allow(Rails.logger).to receive(:info) 10 | end 11 | 12 | specify do 13 | expect { subject }.to raise_error(StandardError, message) 14 | expect(Rails.logger).to have_received(:info).with(message) 15 | end 16 | 17 | context "when CleanActions.config.raise_errors is off" do 18 | before do 19 | CleanActions.config.raise_errors = false 20 | end 21 | 22 | after do 23 | CleanActions.config.raise_errors = true 24 | end 25 | 26 | specify do 27 | subject 28 | expect(Rails.logger).to have_received(:info).with(message) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/clean_actions/isolation_level_validator.rb: -------------------------------------------------------------------------------- 1 | module CleanActions 2 | class IsolationLevelValidator 3 | VALID_ISOLATION_LEVELS = %i[read_uncommited read_committed repeatable_read] 4 | 5 | class << self 6 | def validate(isolation_level, allow_serializable: false) 7 | if isolation_level == :serializable 8 | unless allow_serializable 9 | ErrorReporter.report("serializable isolation should only be used for a whole project, please use global config") 10 | end 11 | 12 | return 13 | end 14 | 15 | return if VALID_ISOLATION_LEVELS.include?(isolation_level) 16 | 17 | ErrorReporter.report("invalid isolation level #{isolation_level} for #{name}") 18 | end 19 | 20 | def can_be_nested(isolation_level) 21 | CleanActions.config.isolation_level == :serializable || 22 | VALID_ISOLATION_LEVELS.index(isolation_level) <= VALID_ISOLATION_LEVELS.index(Thread.current[:root_isolation_level]) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/clean_actions/typed_returns.rb: -------------------------------------------------------------------------------- 1 | module CleanActions 2 | module TypedReturns 3 | def self.included(base) 4 | base.prepend(PrependedMethods) 5 | base.extend(ClassMethods) 6 | end 7 | 8 | module PrependedMethods 9 | def call(**) 10 | returned_value = super 11 | 12 | return returned_value if returned_value.is_a?(ActionFailure) 13 | 14 | if self.class.returned_classes.nil? 15 | returned_value = nil 16 | elsif self.class.returned_classes.none? { returned_value.is_a?(_1) } 17 | ErrorReporter.report( 18 | "expected #{self.class.name} to return #{self.class.returned_classes.map(&:name).join(", ")}, " \ 19 | "returned #{returned_value.inspect}" 20 | ) 21 | end 22 | 23 | returned_value 24 | end 25 | end 26 | 27 | module ClassMethods 28 | attr_reader :returned_classes 29 | 30 | def returns(*klasses) 31 | @returned_classes = klasses 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 DmitryTsepelev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV["RAILS_ENV"] = "test" 4 | 5 | require "redis" 6 | require_relative "dummy/config/environment" 7 | 8 | require "rspec/rails" 9 | 10 | require "clean_actions" 11 | 12 | RSpec.configure do |config| 13 | # For proper work of ActiveSupport::CurrentAttributes reset 14 | config.include ActiveSupport::CurrentAttributes::TestHelper 15 | 16 | config.example_status_persistence_file_path = ".rspec_status" 17 | config.infer_base_class_for_anonymous_controllers = true 18 | 19 | config.use_transactional_fixtures = false 20 | 21 | config.before(:suite) do 22 | DatabaseCleaner.clean_with(:truncation) 23 | end 24 | 25 | config.before(:each) do |e| 26 | DatabaseCleaner.strategy = :truncation 27 | DatabaseCleaner.start 28 | end 29 | 30 | config.append_after(:each) do 31 | DatabaseCleaner.clean 32 | end 33 | 34 | config.expect_with :rspec do |c| 35 | c.syntax = :expect 36 | end 37 | end 38 | 39 | ActiveRecord::Base.establish_connection(ENV["DATABASE_URL"] || {adapter: "postgresql", 40 | database: "clean_actions_test"}) 41 | 42 | load File.dirname(__FILE__) + "/dummy/db/schema.rb" 43 | -------------------------------------------------------------------------------- /clear_actions.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/clean_actions/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "clean_actions" 7 | spec.version = CleanActions::VERSION 8 | spec.authors = ["DmitryTsepelev"] 9 | spec.email = ["dmitry.a.tsepelev@gmail.com"] 10 | spec.homepage = "https://github.com/DmitryTsepelev/clean_actions" 11 | spec.summary = "A modern modular service object toolkit for Rails, that respects database transactions and adds type checks to returned values." 12 | 13 | spec.license = "MIT" 14 | 15 | spec.metadata = { 16 | "bug_tracker_uri" => "https://github.com/DmitryTsepelev/clean_actions/issues", 17 | "changelog_uri" => "https://github.com/DmitryTsepelev/clean_actions/blob/master/CHANGELOG.md", 18 | "documentation_uri" => "https://github.com/DmitryTsepelev/clean_actions/blob/master/README.md", 19 | "homepage_uri" => "https://github.com/DmitryTsepelev/clean_actions", 20 | "source_code_uri" => "https://github.com/DmitryTsepelev/clean_actions" 21 | } 22 | 23 | spec.files = [ 24 | Dir.glob("lib/**/*"), 25 | "README.md", 26 | "CHANGELOG.md", 27 | "LICENSE.txt" 28 | ].flatten 29 | 30 | spec.require_paths = ["lib"] 31 | 32 | spec.required_ruby_version = ">= 2.7.0" 33 | 34 | spec.add_dependency "rails", ">= 6.1" 35 | spec.add_development_dependency "redis", ">= 4.0" 36 | spec.add_development_dependency "prometheus-client" 37 | end 38 | -------------------------------------------------------------------------------- /spec/integrations/simple_action_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "simple action" do 2 | let(:raise_standard_error) { false } 3 | 4 | subject { CreateOrder.call(user: user, raise_standard_error: raise_standard_error) } 5 | 6 | before do 7 | allow(NotifyUserAboutCreatedOrderJob).to receive(:perform_later) 8 | end 9 | 10 | context "when action succeeds" do 11 | let(:user) { User.create! } 12 | 13 | specify do 14 | expect { subject }.to change(Order, :count).by(1) 15 | expect(subject).to eq(Order.last) 16 | expect(NotifyUserAboutCreatedOrderJob).to have_received(:perform_later).with(order: Order.last) 17 | end 18 | end 19 | 20 | context "when action fails because of fail_with" do 21 | let(:user) { User.create!(banned: true) } 22 | 23 | specify do 24 | expect { subject }.to change(Order, :count).by(0) 25 | expect(subject).to be_a(CleanActions::ActionFailure) 26 | .and have_attributes(reason: :banned_user) 27 | expect(NotifyUserAboutCreatedOrderJob).not_to have_received(:perform_later) 28 | end 29 | end 30 | 31 | context "when action fails because of StandardError" do 32 | let(:raise_standard_error) { true } 33 | let(:user) { User.create! } 34 | 35 | specify do 36 | expect { subject }.to raise_error(StandardError).and change(Order, :count).by(0) 37 | expect(NotifyUserAboutCreatedOrderJob).not_to have_received(:perform_later) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - '**' 8 | tags-ignore: 9 | - 'v*' 10 | 11 | jobs: 12 | test: 13 | name: 'Rails ${{ matrix.rails }} × Ruby ${{ matrix.ruby }}' 14 | # Skip running tests for local pull requests (use push event instead), run only for foreign ones 15 | if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.owner.login != github.event.pull_request.base.repo.owner.login 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | include: 21 | - ruby: "3.2" 22 | rails: "HEAD" 23 | - ruby: "3.1" 24 | rails: "HEAD" 25 | - ruby: "3.2" 26 | rails: "7.0" 27 | - ruby: "3.1" 28 | rails: "7.0" 29 | - ruby: "3.0" 30 | rails: "7.0" 31 | - ruby: "2.7" 32 | rails: "6.1" 33 | env: 34 | RAILS_VERSION: ${{ matrix.rails }} 35 | 36 | services: 37 | postgres: 38 | image: postgres 39 | ports: 40 | - 5432:5432 41 | env: 42 | POSTGRES_DB: clean_actions_test 43 | POSTGRES_PASSWORD: postgres 44 | options: >- 45 | --health-cmd pg_isready 46 | --health-interval 10s 47 | --health-timeout 5s 48 | --health-retries 5 49 | steps: 50 | - uses: actions/checkout@v4 51 | - uses: ruby/setup-ruby@v1 52 | with: 53 | ruby-version: ${{ matrix.ruby }} 54 | bundler-cache: true 55 | - name: Run specs 56 | env: 57 | DATABASE_URL: postgres://postgres:postgres@localhost:5432/clean_actions_test 58 | RAILS_ENV: test 59 | run: bundle exec rake spec 60 | -------------------------------------------------------------------------------- /spec/integrations/nested_action_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "nested action" do 2 | let(:item) { Item.create(name: "iPhone 4S", in_stock: 10) } 3 | 4 | subject { AddItemToCart.call(user: user, item: item) } 5 | 6 | before do 7 | allow(NotifyUserAboutCreatedOrderJob).to receive(:perform_later) 8 | allow(NotifyUserAboutUpdatedOrderItemJob).to receive(:perform_later) 9 | end 10 | 11 | context "when both actions succeed" do 12 | let(:user) { User.create! } 13 | 14 | specify do 15 | expect { subject }.to change(Order, :count).by(1).and change(OrderItem, :count).by(1) 16 | expect(subject).to eq(OrderItem.last) 17 | 18 | expect(NotifyUserAboutCreatedOrderJob).to have_received(:perform_later).with(order: Order.last) 19 | expect(NotifyUserAboutUpdatedOrderItemJob).to have_received(:perform_later).with(order_item: OrderItem.last) 20 | end 21 | end 22 | 23 | context "when nested action fails" do 24 | let(:user) { User.create!(banned: true) } 25 | 26 | specify do 27 | expect { subject }.to change(Order, :count).by(0).and change(OrderItem, :count).by(0) 28 | expect(subject).to be_a(CleanActions::ActionFailure) 29 | .and have_attributes(reason: :banned_user) 30 | 31 | expect(NotifyUserAboutCreatedOrderJob).not_to have_received(:perform_later) 32 | expect(NotifyUserAboutUpdatedOrderItemJob).not_to have_received(:perform_later) 33 | end 34 | end 35 | 36 | context "when parent action fails" do 37 | let(:user) { User.create! } 38 | let(:item) { Item.create(name: "iPhone 4S", in_stock: 0) } 39 | 40 | specify do 41 | expect { subject }.to change(Order, :count).by(0).and change(OrderItem, :count).by(0) 42 | expect(subject).to be_a(CleanActions::ActionFailure) 43 | .and have_attributes(reason: :out_of_stock) 44 | 45 | expect(NotifyUserAboutCreatedOrderJob).not_to have_received(:perform_later) 46 | expect(NotifyUserAboutUpdatedOrderItemJob).not_to have_received(:perform_later) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/clean_actions/action.rb: -------------------------------------------------------------------------------- 1 | module CleanActions 2 | class Action 3 | class << self 4 | def call(with_savepoint: false, **kwargs) 5 | new(**kwargs).call(with_savepoint: with_savepoint) 6 | end 7 | 8 | def before_actions(&block) 9 | before_actions_blocks << block 10 | end 11 | 12 | def before_actions_blocks 13 | @before_actions_blocks ||= [] 14 | end 15 | 16 | def with_isolation_level(isolation_level) 17 | IsolationLevelValidator.validate(isolation_level) 18 | 19 | @isolation_level = isolation_level 20 | end 21 | 22 | def isolation_level 23 | @isolation_level ||= CleanActions.config.isolation_level 24 | end 25 | end 26 | 27 | def call(with_savepoint: false) 28 | if TransactionRunner.action_calls_restricted_by 29 | ErrorReporter.report("calling action #{self.class.name} is resticted inside ##{TransactionRunner.action_calls_restricted_by}") 30 | end 31 | 32 | TransactionRunner.restrict_action_calls_by(:before_transaction) { perform_before_transaction } 33 | 34 | TransactionRunner.new(self).run(with_savepoint: with_savepoint) do 35 | TransactionRunner.restrict_action_calls_by(:before_actions) do 36 | self.class.before_actions_blocks.each { |b| instance_eval(&b) } 37 | end 38 | 39 | perform_actions 40 | end 41 | end 42 | 43 | def fail!(reason) 44 | raise ActionFailure.new(reason) 45 | end 46 | 47 | def perform_actions 48 | end 49 | 50 | def after_commit 51 | end 52 | 53 | def ensure 54 | end 55 | 56 | def rollback 57 | end 58 | 59 | private 60 | 61 | def perform_before_transaction 62 | return unless respond_to?(:before_transaction) 63 | 64 | if Thread.current[:transaction_started] 65 | ErrorReporter.report("#{self.class.name}#before_transaction was called inside the transaction") 66 | end 67 | 68 | before_transaction 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/clean_actions/fail_with_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe CleanActions::FailWith do 4 | describe "#dry_run" do 5 | subject { action_class.dry_call(**action_params) } 6 | 7 | let(:action_params) { {} } 8 | 9 | let(:action_class) do 10 | Class.new(CleanActions::Action) do 11 | include CleanActions::FailWith 12 | 13 | fail_with(:fail1) { @action_params[:value] == 1 } 14 | fail_with(:fail_odd) { @action_params[:value].odd? } 15 | 16 | def initialize(action_params) 17 | @action_params = action_params 18 | end 19 | 20 | def perform_actions 21 | 42 22 | end 23 | end 24 | end 25 | 26 | context "when all fail_with pass" do 27 | let(:action_params) { {value: 2} } 28 | 29 | it { is_expected.to eq([]) } 30 | end 31 | 32 | context "when one fail_with fails" do 33 | let(:action_params) { {value: 3} } 34 | 35 | it { is_expected.to match_array([CleanActions::ActionFailure.new(:fail_odd)]) } 36 | end 37 | 38 | context "when many fail_with fail" do 39 | let(:action_params) { {value: 1} } 40 | 41 | it { is_expected.to match_array([CleanActions::ActionFailure.new(:fail_odd), CleanActions::ActionFailure.new(:fail1)]) } 42 | end 43 | end 44 | 45 | describe ".fail_with" do 46 | subject { action_class.call(**action_params) } 47 | 48 | let(:action_params) { {} } 49 | 50 | let(:action_class) do 51 | Class.new(CleanActions::Action) do 52 | include CleanActions::FailWith 53 | 54 | fail_with(:invalid_data) { @action_params[:status] == :invalid } 55 | 56 | def initialize(action_params) 57 | @action_params = action_params 58 | end 59 | 60 | def perform_actions 61 | 42 62 | end 63 | end 64 | end 65 | 66 | context "when fail_with triggers" do 67 | let(:action_params) { {status: :invalid} } 68 | 69 | specify do 70 | expect(subject).to be_a(CleanActions::ActionFailure) 71 | expect(subject.reason).to eq(:invalid_data) 72 | end 73 | end 74 | 75 | context "when fail_with not triggers" do 76 | let(:action_params) { {status: :valid} } 77 | 78 | specify do 79 | expect(subject).to eq(42) 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/clean_actions/transaction_runner.rb: -------------------------------------------------------------------------------- 1 | module CleanActions 2 | class TransactionRunner 3 | class << self 4 | def restrict_action_calls_by(method) 5 | Thread.current[:action_calls_restricted_by] = method 6 | yield 7 | ensure 8 | Thread.current[:action_calls_restricted_by] = nil 9 | end 10 | 11 | def action_calls_restricted_by 12 | Thread.current[:action_calls_restricted_by] 13 | end 14 | end 15 | 16 | def initialize(action) 17 | @action = action 18 | end 19 | 20 | def run(with_savepoint: false, &block) 21 | performed_actions << @action 22 | 23 | if Thread.current[:transaction_started] 24 | unless IsolationLevelValidator.can_be_nested(action_isolation_level) 25 | ErrorReporter.report <<~MSG 26 | action #{@action.class.name} requires #{action_isolation_level}, run inside #{Thread.current[:root_isolation_level]} 27 | MSG 28 | end 29 | 30 | if with_savepoint 31 | return ActiveRecord::Base.transaction(requires_new: true) { block.call } 32 | else 33 | return block.call 34 | end 35 | end 36 | 37 | start_transaction(&block) 38 | end 39 | 40 | private 41 | 42 | delegate :restrict_action_calls_by, to: :class 43 | 44 | def start_transaction(&block) 45 | Thread.current[:transaction_started] = true 46 | Thread.current[:root_isolation_level] = action_isolation_level 47 | 48 | ActiveRecord::Base.transaction(isolation: action_isolation_level) do 49 | block.call.tap { restrict_action_calls_by(:after_commit) { run_after_commit_actions } } 50 | rescue => e 51 | run_rollback_blocks 52 | raise e unless e.is_a?(ActionFailure) 53 | 54 | e 55 | end 56 | ensure 57 | Thread.current[:root_isolation_level] = nil 58 | Thread.current[:transaction_started] = false 59 | run_ensure_blocks 60 | Thread.current[:performed_actions] = [] 61 | end 62 | 63 | def action_isolation_level 64 | @action.class.isolation_level 65 | end 66 | 67 | def run_after_commit_actions 68 | performed_actions.each(&:after_commit) 69 | end 70 | 71 | def run_ensure_blocks 72 | performed_actions.each(&:ensure) 73 | end 74 | 75 | def run_rollback_blocks 76 | performed_actions.each(&:rollback) 77 | end 78 | 79 | def performed_actions 80 | Thread.current[:performed_actions] ||= [] 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/clean_actions/isolation_level_validator_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe CleanActions::IsolationLevelValidator do 4 | describe ".validate" do 5 | subject { described_class.validate(isolation_level, allow_serializable: allow_serializable) } 6 | 7 | let(:isolation_level) { :repeatable_read } 8 | let(:allow_serializable) { false } 9 | 10 | before do 11 | allow(CleanActions::ErrorReporter).to receive(:report) 12 | end 13 | 14 | around(:each) do |example| 15 | old_isolation_level = CleanActions.config.isolation_level 16 | CleanActions.config.isolation_level = :read_committed 17 | example.run 18 | CleanActions.config.isolation_level = old_isolation_level 19 | end 20 | 21 | specify do 22 | subject 23 | expect(CleanActions::ErrorReporter).not_to have_received(:report) 24 | end 25 | 26 | context "when serializable is passed" do 27 | let(:isolation_level) { :serializable } 28 | 29 | specify do 30 | subject 31 | expect(CleanActions::ErrorReporter).to have_received(:report).with( 32 | "serializable isolation should only be used for a whole project, please use global config" 33 | ) 34 | end 35 | end 36 | 37 | context "when allow_serializable is true" do 38 | let(:allow_serializable) { true } 39 | 40 | specify do 41 | subject 42 | expect(CleanActions::ErrorReporter).not_to have_received(:report) 43 | end 44 | 45 | context "when serializable is passed" do 46 | let(:isolation_level) { :serializable } 47 | 48 | specify do 49 | subject 50 | expect(CleanActions::ErrorReporter).not_to have_received(:report) 51 | end 52 | end 53 | end 54 | end 55 | 56 | describe ".can_be_nested" do 57 | subject { described_class.can_be_nested(isolation_level) } 58 | 59 | context "when global isolation level is serializable" do 60 | let(:isolation_level) { :repeatable_read } 61 | 62 | around(:each) do |example| 63 | old_isolation_level = CleanActions.config.isolation_level 64 | CleanActions.config.isolation_level = :serializable 65 | example.run 66 | CleanActions.config.isolation_level = old_isolation_level 67 | end 68 | 69 | it { is_expected.to eq(true) } 70 | end 71 | 72 | context "when current isolation level is same as passed" do 73 | let(:isolation_level) { :read_committed } 74 | 75 | around(:each) do |example| 76 | Thread.current[:root_isolation_level] = :read_committed 77 | example.run 78 | Thread.current[:root_isolation_level] = nil 79 | end 80 | 81 | it { is_expected.to eq(true) } 82 | end 83 | 84 | context "when current isolation level is weaker" do 85 | let(:isolation_level) { :read_committed } 86 | 87 | around(:each) do |example| 88 | Thread.current[:root_isolation_level] = :repeatable_read 89 | example.run 90 | Thread.current[:root_isolation_level] = nil 91 | end 92 | 93 | it { is_expected.to eq(true) } 94 | end 95 | 96 | context "when current isolation level is stronger" do 97 | let(:isolation_level) { :repeatable_read } 98 | 99 | around(:each) do |example| 100 | Thread.current[:root_isolation_level] = :read_committed 101 | example.run 102 | Thread.current[:root_isolation_level] = nil 103 | end 104 | 105 | it { is_expected.to eq(false) } 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /spec/clean_actions/typed_returns_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe CleanActions::TypedReturns do 4 | subject { action_class.call(**action_params) } 5 | 6 | let(:action_params) { {} } 7 | 8 | before do 9 | allow(CleanActions::ErrorReporter).to receive(:report) 10 | end 11 | 12 | context "when returns is not configured" do 13 | let(:action_class) do 14 | Class.new(CleanActions::Action) do 15 | include CleanActions::TypedReturns 16 | 17 | def perform_actions 18 | 42 19 | end 20 | end 21 | end 22 | 23 | it { is_expected.to be_nil } 24 | 25 | context "when ActionFailure is returned" do 26 | let(:action_class) do 27 | Class.new(CleanActions::Action) do 28 | include CleanActions::TypedReturns 29 | 30 | def perform_actions 31 | fail!(:invalid_data) 32 | end 33 | end 34 | end 35 | 36 | specify do 37 | expect(subject).to be_a(CleanActions::ActionFailure) 38 | expect(subject.reason).to eq(:invalid_data) 39 | end 40 | end 41 | end 42 | 43 | context "when returns is configured" do 44 | context "when correct type is returned" do 45 | let(:action_class) do 46 | Class.new(CleanActions::Action) do 47 | include CleanActions::TypedReturns 48 | 49 | returns Integer 50 | 51 | def perform_actions 52 | 42 53 | end 54 | end 55 | end 56 | 57 | it "returns value" do 58 | expect(subject).to eq(42) 59 | end 60 | end 61 | 62 | context "when incorrect type is returned" do 63 | let(:action_class) do 64 | Class.new(CleanActions::Action) do 65 | include CleanActions::TypedReturns 66 | 67 | returns String 68 | 69 | def perform_actions 70 | 42 71 | end 72 | end 73 | end 74 | 75 | specify do 76 | subject 77 | expect(CleanActions::ErrorReporter).to have_received(:report).with("expected to return String, returned 42") 78 | end 79 | end 80 | 81 | context "when ActionFailure is returned" do 82 | let(:action_class) do 83 | Class.new(CleanActions::Action) do 84 | include CleanActions::TypedReturns 85 | 86 | returns Integer 87 | 88 | def perform_actions 89 | fail!(:invalid_data) 90 | end 91 | end 92 | end 93 | 94 | specify do 95 | expect(subject).to be_a(CleanActions::ActionFailure) 96 | expect(subject.reason).to eq(:invalid_data) 97 | end 98 | end 99 | 100 | context "when multiple types are allowed" do 101 | context "when correct type is returned" do 102 | let(:action_class) do 103 | Class.new(CleanActions::Action) do 104 | include CleanActions::TypedReturns 105 | 106 | returns Integer, Hash 107 | 108 | def perform_actions 109 | 42 110 | end 111 | end 112 | end 113 | 114 | it "returns value" do 115 | expect(subject).to eq(42) 116 | end 117 | end 118 | 119 | context "when incorrect type is returned" do 120 | let(:action_class) do 121 | Class.new(CleanActions::Action) do 122 | include CleanActions::TypedReturns 123 | 124 | returns String, Hash 125 | 126 | def perform_actions 127 | 42 128 | end 129 | end 130 | end 131 | 132 | specify do 133 | subject 134 | expect(CleanActions::ErrorReporter).to have_received(:report).with("expected to return String, Hash, returned 42") 135 | end 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /spec/clean_actions/action_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe CleanActions::Action do 4 | subject { action_class.call(**action_params) } 5 | 6 | let(:action_params) { {} } 7 | 8 | before do 9 | allow(CleanActions::ErrorReporter).to receive(:report) 10 | end 11 | 12 | context "#perform_actions" do 13 | let(:ensured_body) { instance_double "EnsuredBody" } 14 | let(:transactional_body) { instance_double "TransactionalBody" } 15 | 16 | let(:action_class) do 17 | e_body = ensured_body 18 | t_body = transactional_body 19 | 20 | Class.new(CleanActions::Action).tap do |action| 21 | action.define_method(:ensure) { e_body.call } 22 | 23 | action.define_method(:perform_actions) do 24 | t_body.call 25 | 42 26 | end 27 | end 28 | end 29 | 30 | before do 31 | allow(ensured_body).to receive(:call) 32 | allow(transactional_body).to receive(:call) 33 | end 34 | 35 | specify do 36 | expect(subject).to eq(42) 37 | expect(transactional_body).to have_received(:call) 38 | expect(ensured_body).to have_received(:call) 39 | end 40 | end 41 | 42 | context "#after_commit" do 43 | let(:ensured_body) { instance_double "EnsuredBody" } 44 | let(:after_commit_body) { instance_double "AfterCommitBody" } 45 | 46 | let(:action_class) do 47 | e_body = ensured_body 48 | ac_body = after_commit_body 49 | 50 | Class.new(CleanActions::Action).tap do |action| 51 | action.define_method(:after_commit) { ac_body.call } 52 | action.define_method(:ensure) { e_body.call } 53 | end 54 | end 55 | 56 | context "when valid body is used" do 57 | before do 58 | allow(ensured_body).to receive(:call) 59 | allow(after_commit_body).to receive(:call) 60 | end 61 | 62 | specify do 63 | expect(subject).to be_nil 64 | expect(after_commit_body).to have_received(:call) 65 | expect(ensured_body).to have_received(:call) 66 | end 67 | end 68 | 69 | context "when another service is called inside after_commit" do 70 | let(:after_commit_body) { Class.new(CleanActions::Action) } 71 | 72 | before do 73 | allow(ensured_body).to receive(:call) 74 | end 75 | 76 | specify do 77 | expect(subject).to be_nil 78 | expect(ensured_body).to have_received(:call) 79 | expect(CleanActions::ErrorReporter).to have_received(:report).with( 80 | "calling action is resticted inside #after_commit" 81 | ) 82 | end 83 | end 84 | end 85 | 86 | context "#fail!" do 87 | let(:ensured_body) { instance_double "EnsuredBody" } 88 | 89 | let(:action_class) do 90 | body = ensured_body 91 | 92 | Class.new(CleanActions::Action).tap do |action| 93 | action.define_method(:perform_actions) do 94 | fail!(:invalid_data) 95 | end 96 | 97 | action.define_method(:ensure) do 98 | body.call 99 | end 100 | end 101 | end 102 | 103 | before do 104 | allow(ensured_body).to receive(:call) 105 | end 106 | 107 | specify do 108 | expect(subject).to be_a(CleanActions::ActionFailure) 109 | expect(subject.reason).to eq(:invalid_data) 110 | expect(ensured_body).to have_received(:call) 111 | end 112 | end 113 | 114 | context ".with_isolation_level" do 115 | let(:action_class) do 116 | Class.new(CleanActions::Action) 117 | end 118 | 119 | before do 120 | allow(ActiveRecord::Base).to receive(:transaction) 121 | end 122 | 123 | specify do 124 | subject 125 | expect(ActiveRecord::Base).to have_received(:transaction).with(isolation: :read_committed) 126 | end 127 | 128 | context "when specific level is configured" do 129 | let(:action_class) do 130 | Class.new(CleanActions::Action).tap do |action| 131 | action.with_isolation_level(:repeatable_read) 132 | end 133 | end 134 | 135 | it "uses configured level" do 136 | subject 137 | expect(ActiveRecord::Base).to have_received(:transaction).with(isolation: :repeatable_read) 138 | end 139 | end 140 | 141 | context "when global level is configured" do 142 | around(:each) do |example| 143 | old_isolation_level = CleanActions.config.isolation_level 144 | CleanActions.config.isolation_level = :repeatable_read 145 | example.run 146 | CleanActions.config.isolation_level = old_isolation_level 147 | end 148 | 149 | it "uses global level" do 150 | subject 151 | expect(ActiveRecord::Base).to have_received(:transaction).with(isolation: :repeatable_read) 152 | end 153 | end 154 | end 155 | 156 | context ".before_transaction_blocks" do 157 | let(:before_transaction_body) { instance_double "BeforeTransactionBody" } 158 | 159 | let(:action_class) do 160 | body = before_transaction_body 161 | 162 | Class.new(CleanActions::Action).tap do |action| 163 | action.define_method(:before_transaction) do 164 | body.call 165 | end 166 | end 167 | end 168 | 169 | before do 170 | allow(before_transaction_body).to receive(:call) 171 | end 172 | 173 | specify do 174 | expect(subject).to be_nil 175 | expect(before_transaction_body).to have_received(:call) 176 | end 177 | 178 | context "when transaction was already in progress" do 179 | around(:each) do |example| 180 | Thread.current[:transaction_started] = true 181 | Thread.current[:root_isolation_level] = :read_committed 182 | example.run 183 | Thread.current[:transaction_started] = false 184 | Thread.current[:root_isolation_level] = nil 185 | end 186 | 187 | specify do 188 | subject 189 | expect(CleanActions::ErrorReporter).to have_received(:report).with("#before_transaction was called inside the transaction") 190 | end 191 | end 192 | end 193 | 194 | context "#rollback" do 195 | let(:rollback_body) { instance_double "RollbackBody" } 196 | 197 | before do 198 | allow(rollback_body).to receive(:call) 199 | end 200 | 201 | context "when action succeeds" do 202 | let(:action_class) do 203 | r_body = rollback_body 204 | 205 | Class.new(CleanActions::Action).tap do |action| 206 | action.define_method(:rollback) { r_body.call } 207 | end 208 | end 209 | 210 | specify do 211 | expect(subject).to be_nil 212 | expect(rollback_body).not_to have_received(:call) 213 | end 214 | end 215 | 216 | context "when action fails" do 217 | let(:action_class) do 218 | r_body = rollback_body 219 | 220 | Class.new(CleanActions::Action).tap do |action| 221 | action.define_method(:perform_actions) do 222 | fail!(:invalid_data) 223 | end 224 | 225 | action.define_method(:rollback) { r_body.call } 226 | end 227 | end 228 | 229 | specify do 230 | expect(subject).to be_a(CleanActions::ActionFailure) 231 | expect(subject.reason).to eq(:invalid_data) 232 | expect(rollback_body).to have_received(:call) 233 | end 234 | end 235 | end 236 | 237 | context "#before_actions" do 238 | let(:before_actions_body) { instance_double "BeforeActionsBody" } 239 | 240 | context "when valid body is used" do 241 | before do 242 | allow(before_actions_body).to receive(:call) 243 | end 244 | 245 | context "when action succeeds" do 246 | let(:action_class) do 247 | ba_body = before_actions_body 248 | 249 | Class.new(CleanActions::Action).tap do |action| 250 | action.before_actions { ba_body.call } 251 | end 252 | end 253 | 254 | specify do 255 | expect(subject).to be_nil 256 | expect(before_actions_body).to have_received(:call) 257 | end 258 | end 259 | end 260 | 261 | context "when another action is executed inside" do 262 | let(:nested_action) { Class.new(CleanActions::Action).new } 263 | 264 | let(:action_class) do 265 | n_action = nested_action 266 | 267 | Class.new(CleanActions::Action).tap do |action| 268 | action.before_actions { n_action.call } 269 | end 270 | end 271 | 272 | specify do 273 | expect(subject).to be_nil 274 | expect(CleanActions::ErrorReporter).to have_received(:report) 275 | end 276 | end 277 | end 278 | end 279 | -------------------------------------------------------------------------------- /spec/clean_actions/transaction_runner_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe CleanActions::TransactionRunner do 4 | def expect_inside_block(action, with_savepoint: false, &block) 5 | described_class.new(action).run(with_savepoint: with_savepoint, &block) 6 | end 7 | 8 | let(:action) { action_class.new } 9 | 10 | let(:action_class) do 11 | Class.new(CleanActions::Action) do 12 | attr_reader :after_commit_happened, :ensure_happened 13 | 14 | def after_commit 15 | @after_commit_happened = true 16 | end 17 | 18 | def ensure 19 | @ensure_happened = true 20 | end 21 | end 22 | end 23 | 24 | context "when action executes successfully" do 25 | specify do 26 | expect_inside_block(action) do 27 | expect(Thread.current[:performed_actions]).to eq([action]) 28 | expect(Thread.current[:transaction_started]).to eq(true) 29 | expect(Thread.current[:root_isolation_level]).to eq(action.class.isolation_level) 30 | end 31 | 32 | expect(action.after_commit_happened).to eq(true) 33 | expect(action.ensure_happened).to eq(true) 34 | expect(Thread.current[:transaction_started]).to eq(false) 35 | expect(Thread.current[:performed_actions]).to be_empty 36 | expect(Thread.current[:root_isolation_level]).to be_nil 37 | end 38 | end 39 | 40 | context "when action calls fail!" do 41 | specify do 42 | expect_inside_block(action) do 43 | expect(Thread.current[:performed_actions]).to eq([action]) 44 | expect(Thread.current[:transaction_started]).to eq(true) 45 | expect(Thread.current[:root_isolation_level]).to eq(action.class.isolation_level) 46 | 47 | raise CleanActions::ActionFailure, :invalid_data 48 | end 49 | 50 | expect(action.after_commit_happened).to be_falsey 51 | expect(action.ensure_happened).to eq(true) 52 | expect(Thread.current[:transaction_started]).to eq(false) 53 | expect(Thread.current[:performed_actions]).to be_empty 54 | expect(Thread.current[:root_isolation_level]).to be_nil 55 | end 56 | end 57 | 58 | context "when action raises ActiveRecord::Rollback" do 59 | let(:rollback_error) { ActiveRecord::Rollback.new } 60 | 61 | specify do 62 | expect do 63 | expect_inside_block(action) do 64 | expect(Thread.current[:performed_actions]).to eq([action]) 65 | expect(Thread.current[:transaction_started]).to eq(true) 66 | expect(Thread.current[:root_isolation_level]).to eq(action.class.isolation_level) 67 | 68 | raise rollback_error 69 | end 70 | end.not_to raise_error 71 | 72 | expect(action.after_commit_happened).to be_falsey 73 | expect(action.ensure_happened).to eq(true) 74 | expect(Thread.current[:transaction_started]).to eq(false) 75 | expect(Thread.current[:performed_actions]).to be_empty 76 | expect(Thread.current[:root_isolation_level]).to be_nil 77 | end 78 | end 79 | 80 | context "when action raises StandardError" do 81 | let(:standard_error) { StandardError.new } 82 | 83 | specify do 84 | expect do 85 | expect_inside_block(action) do 86 | expect(Thread.current[:performed_actions]).to eq([action]) 87 | expect(Thread.current[:transaction_started]).to eq(true) 88 | expect(Thread.current[:root_isolation_level]).to eq(action.class.isolation_level) 89 | 90 | raise standard_error 91 | end 92 | end.to raise_error(standard_error) 93 | 94 | expect(action.after_commit_happened).to be_falsey 95 | expect(action.ensure_happened).to eq(true) 96 | expect(Thread.current[:transaction_started]).to eq(false) 97 | expect(Thread.current[:performed_actions]).to be_empty 98 | expect(Thread.current[:root_isolation_level]).to be_nil 99 | end 100 | end 101 | 102 | context "when another action is executed inside" do 103 | let(:nested_action) { action_class.new } 104 | 105 | before do 106 | allow(ActiveRecord::Base).to receive(:transaction).and_call_original 107 | end 108 | 109 | specify do 110 | expect_inside_block(action) do 111 | expect(Thread.current[:performed_actions]).to eq([action]) 112 | expect(Thread.current[:transaction_started]).to eq(true) 113 | expect(Thread.current[:root_isolation_level]).to eq(action.class.isolation_level) 114 | 115 | expect_inside_block(nested_action) do 116 | expect(Thread.current[:performed_actions]).to eq([action, nested_action]) 117 | expect(Thread.current[:transaction_started]).to eq(true) 118 | expect(Thread.current[:root_isolation_level]).to eq(action.class.isolation_level) 119 | end 120 | 121 | expect(Thread.current[:transaction_started]).to eq(true) 122 | end 123 | 124 | expect(action.after_commit_happened).to be_truthy 125 | expect(action.ensure_happened).to eq(true) 126 | expect(nested_action.after_commit_happened).to be_truthy 127 | expect(Thread.current[:root_isolation_level]).to be_nil 128 | 129 | expect(Thread.current[:transaction_started]).to eq(false) 130 | expect(Thread.current[:performed_actions]).to be_empty 131 | 132 | expect(ActiveRecord::Base).to have_received(:transaction).once.with(isolation: action.class.isolation_level) 133 | end 134 | 135 | context "when nested action fails" do 136 | specify do 137 | expect_inside_block(action) do 138 | expect(Thread.current[:performed_actions]).to eq([action]) 139 | expect(Thread.current[:transaction_started]).to eq(true) 140 | 141 | expect_inside_block(nested_action) do 142 | expect(Thread.current[:performed_actions]).to eq([action, nested_action]) 143 | expect(Thread.current[:transaction_started]).to eq(true) 144 | 145 | raise CleanActions::ActionFailure, :invalid_data 146 | end 147 | 148 | expect(Thread.current[:transaction_started]).to eq(true) 149 | end 150 | 151 | expect(action.after_commit_happened).to be_falsey 152 | expect(action.ensure_happened).to eq(true) 153 | expect(nested_action.after_commit_happened).to be_falsey 154 | 155 | expect(Thread.current[:transaction_started]).to eq(false) 156 | expect(Thread.current[:performed_actions]).to be_empty 157 | 158 | expect(ActiveRecord::Base).to have_received(:transaction).once.with(isolation: action.class.isolation_level) 159 | end 160 | end 161 | 162 | context "when nested action requires stronger isolation level" do 163 | let(:repeatable_read_action_class) do 164 | Class.new(CleanActions::Action) do 165 | with_isolation_level :repeatable_read 166 | end 167 | end 168 | 169 | before do 170 | allow(CleanActions::ErrorReporter).to receive(:report) 171 | end 172 | 173 | it "reports error" do 174 | expect_inside_block(action) do 175 | repeatable_read_action_class.call 176 | end 177 | 178 | expect(CleanActions::ErrorReporter).to have_received(:report).with( 179 | <<~MSG 180 | action requires repeatable_read, run inside read_committed 181 | MSG 182 | ) 183 | end 184 | end 185 | 186 | context "when nested action requires savepoint" do 187 | specify do 188 | expect_inside_block(action) do 189 | expect(Thread.current[:performed_actions]).to eq([action]) 190 | expect(Thread.current[:transaction_started]).to eq(true) 191 | expect(Thread.current[:root_isolation_level]).to eq(action.class.isolation_level) 192 | 193 | expect(ActiveRecord::Base).to have_received(:transaction).once.with(isolation: action.class.isolation_level) 194 | 195 | expect_inside_block(nested_action, with_savepoint: true) do 196 | expect(Thread.current[:performed_actions]).to eq([action, nested_action]) 197 | expect(Thread.current[:transaction_started]).to eq(true) 198 | expect(Thread.current[:root_isolation_level]).to eq(action.class.isolation_level) 199 | 200 | expect(ActiveRecord::Base).to have_received(:transaction).once.with(requires_new: true) 201 | end 202 | 203 | expect(Thread.current[:transaction_started]).to eq(true) 204 | end 205 | 206 | expect(action.after_commit_happened).to be_truthy 207 | expect(action.ensure_happened).to eq(true) 208 | expect(nested_action.after_commit_happened).to be_truthy 209 | expect(Thread.current[:root_isolation_level]).to be_nil 210 | 211 | expect(Thread.current[:transaction_started]).to eq(false) 212 | expect(Thread.current[:performed_actions]).to be_empty 213 | end 214 | end 215 | end 216 | end 217 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CleanActions 2 | 3 | [![Gem Version](https://badge.fury.io/rb/clean_actions.svg)](https://rubygems.org/gems/clean_actions) 4 | [![Tests status](https://github.com/DmitryTsepelev/clean_actions/actions/workflows/test.yml/badge.svg)](https://github.com/DmitryTsepelev/clean_actions/actions/workflows/test.yml) 5 | ![](https://ruby-gem-downloads-badge.herokuapp.com/clean_actions?type=total) 6 | 7 | A modern modular service object toolkit for Rails, that respects database transactions and adds type checks to returned values. 8 | 9 | ```ruby 10 | class AddItemToCart < CleanActions::Base 11 | includes Dry::Initializer 12 | 13 | option :user 14 | option :item 15 | 16 | # This will report an error if someone accidentally returns wrong instance from #perform_actions. 17 | returns OrderItem 18 | 19 | # Such checks are happening inside the transaction right before #perform_actions, so 20 | # you can halt early. 21 | fail_with(:banned_user) { @user.banned? } 22 | 23 | # This method is executed inside the database transaction. 24 | # If transaction was opened by another action, which called this one - savepoint won't be created. 25 | # Last line will be used as a returned value. 26 | def perform_actions 27 | @order = CreateOrder.call(user: @user) # if CreateOrder fails - transaction will be rolled back 28 | @order.order_items.create!(item: @item) # if something else fails here - transaction will be rolled back as well 29 | end 30 | 31 | # This method will be called for each action after whole transaction commits successfully. 32 | def after_commit 33 | ItemAddedSubscription.trigger(order: @order) 34 | end 35 | end 36 | ``` 37 | 38 | You can support my open–source work [here](https://boosty.to/dmitry_tsepelev). 39 | 40 | ## Usage 41 | 42 | Add this line to your application's Gemfile: 43 | 44 | ```ruby 45 | gem 'clean_actions' 46 | ``` 47 | 48 | ## Writing your actions 49 | 50 | Inherit your actions from `CleanActions::Base`, which by defaut includes [typed returns](/README.md#Typed-Returns) and [fail_with](/README.md#Fail-With). 51 | 52 | > If you want to exclude something — inherit from `CleanActions::Action` and configure all includes you need. 53 | 54 | You should implement at least one of two methods—`#perform_actions` or `#after_commit`: 55 | 56 | ```ruby 57 | class AddItemToCart < CleanActions::Base 58 | def perform_actions 59 | @order = CreateOrder.call(user: @user) 60 | @order_item = @order.order_items 61 | .create_with(quantity: 0) 62 | .find_or_create_by!(item: @item) 63 | end 64 | 65 | def after_commit 66 | NotifyUserAboutUpdatedOrderItemJob.perform_later(order_item: @order_item) 67 | end 68 | end 69 | ``` 70 | 71 | When first action is called, it will be wrapped to the database transaction, and all actions called by it will be inside the same transaction. All `#perform_actions` will happen inside the transaction (and rolled back if needed). After that, in case of successful commit, all `#after_commit` actions will happen in order. 72 | 73 | ## Error handling 74 | 75 | If something goes wrong and transaction will raise an error—it will cause transaction to be rolled back. Errors should not be used as a way to manage a control flow, so all unhandled exceptions raised inside actions, will be reraised. 76 | 77 | However, if you do expect an error—it's better to represent it as a returned value. Use `#fail!(:reason)` for that: 78 | 79 | ```ruby 80 | class AddItemToCart < CleanActions::Base 81 | def perform_actions 82 | fail!(:shop_is_closed) 83 | end 84 | end 85 | 86 | AddItemToCart.call # => CleanActions::ActionFailure(reason: :shop_is_closed) 87 | ``` 88 | 89 | ## Typed Returns 90 | 91 | Have you ever been in situation, when it's not clear, what will be returned by the class? Do you have some type system in your project? While you are setting it up—use typed returns: 92 | 93 | ```ruby 94 | class FetchOrder < CleanActions::Base 95 | returns Order 96 | 97 | option :order_id 98 | 99 | def perform_actions 100 | User.find(order_id) 101 | end 102 | end 103 | 104 | FetchOrder.call(42) # => "expected FetchOrder to return Order, returned User" is logged 105 | ``` 106 | 107 | The last line of `#perform_actions` will be returned. Note that if you have this module on but configure nothing—action will return `nil`. 108 | 109 | ## Isolation levels 110 | 111 | By default transactions are executed in `READ COMMITTED` level. You can override it for a specific aciton: 112 | 113 | ```ruby 114 | class FetchOrder < CleanActions::Base 115 | with_isolation_level :repeatable_read 116 | 117 | option :order_id 118 | 119 | def perform_actions 120 | # actions 121 | end 122 | end 123 | 124 | FetchOrder.call(42) # => "expected FetchOrder to return Order, returned User" is logged 125 | ``` 126 | 127 | Also, you can configure it for the whole project: 128 | 129 | ```ruby 130 | CleanActions.config.isolation_level = :serializable 131 | ``` 132 | 133 | ## Savepoints 134 | 135 | If you want to run one action inside another but want a nested one be inside the ([SAVEPOINT](https://www.postgresql.org/docs/current/sql-savepoint.html))—use `with_savepoint`: 136 | 137 | ```ruby 138 | class AddItemToCart < CleanActions::Base 139 | def perform_actions 140 | @order = CreateOrder.call(user: @user, with_savepoint: true) 141 | end 142 | end 143 | ``` 144 | 145 | Note that `after_commit` still happens when the transaction from the root action is commited. 146 | 147 | ## Error configuration 148 | 149 | When something weird happens during the action execution, the message is sent to the Rails log. Also, errors are _raised_ in development and test environments. To change that you can use `.config` object: 150 | 151 | ```ruby 152 | CleanActions.config.raise_errors = true 153 | ``` 154 | 155 | Here is a list of errors affected by this config: 156 | 157 | - type mismatch from (Typed Returns)[/README.md#Typed-Returns]; 158 | - action with (#before_transaction)[/README.md#before_transaction] is called inside the transaction; 159 | - invalid isolation levels; 160 | - action calls from unexpected places. 161 | 162 | ## Advanced Lifecycle 163 | 164 | This section contains some additional hooks to improve your actions. 165 | 166 | ### before_transaction 167 | 168 | If you want to do something outside the transaction (e.g., some IO operation)—use `before_transaction`: 169 | 170 | ```ruby 171 | class SyncData < CleanActions::Base 172 | def before_transaction 173 | @response = ApiClient.fetch 174 | end 175 | 176 | def perform_actions 177 | # use response 178 | end 179 | end 180 | ``` 181 | 182 | Please note, that error will be risen if this action will be called from another action (and transaction will be already in progress): 183 | 184 | ```ruby 185 | class OtherAction < CleanActions::Base 186 | def perform_actions 187 | SyncData.call 188 | end 189 | end 190 | 191 | OtherAction.call # => "SyncData#before_transaction was called inside the transaction" is logged 192 | ``` 193 | 194 | ⚠️ Do not call other actions from this method! 195 | 196 | ### before_actions 197 | 198 | If you want to do something before action — use `#before_action` callback, that is run inside the transaction but before `#perform_actions`: 199 | 200 | ```ruby 201 | class AddItemToCart < CleanActions::Base 202 | def before_actions 203 | @order = Order.find(order_id) 204 | end 205 | 206 | def perform_actions 207 | # use order 208 | end 209 | end 210 | ``` 211 | 212 | ⚠️ Do not call other actions from this method! 213 | 214 | ### fail_with 215 | 216 | Fail with is a syntax sugar over `#fail!` to decouple pre–checks from the execution logic. Take a look at the improved example from the [Error Handling](/README.md#Error-Handling) section: 217 | 218 | ```ruby 219 | class AddItemToCart < CleanActions::Base 220 | fail_with(:shop_is_closed) { Time.now.hour.in?(10..18) } 221 | 222 | def perform_actions 223 | # only when shop is open 224 | end 225 | end 226 | ``` 227 | 228 | If you want to check that action can be called successfully (at least, preconditions are met) — you can use `#dry_call`, which will run _all_ preconditions and return all failures: 229 | 230 | ```ruby 231 | class CheckNumber < CleanActions::Base 232 | fail_with(:fail1) { @value == 1 } 233 | fail_with(:fail_odd) { @value.odd? } 234 | 235 | def initialize(value:) 236 | @value = value 237 | end 238 | end 239 | 240 | CheckNumber.dry_call(value: 1) # => [CleanActions::ActionFailure.new(:fail_odd), CleanActions::ActionFailure.new(:fail1)] 241 | ``` 242 | 243 | ⚠️ Do not call other actions from this method! 244 | 245 | ### rollback 246 | 247 | Actions rollback things inside `#perform_actions` in case of failure because of the database transactions. However, what if you want to rollback something non–transactional? 248 | 249 | Well, if you sent an email or enqueued background job—you cannot do much,. Just in case, you want do something—here is a `#rollback` method that happens only when action fails. 250 | 251 | ```ruby 252 | class DumbCounter < CleanActions::Base 253 | def perform_actions 254 | Thread.current[:counter] ||= 0 255 | Thread.current[:counter] += 1 256 | fail!(:didnt_i_say_its_a_dumb_counter) 257 | end 258 | 259 | def rollback 260 | Thread.current[:counter] ||= 0 261 | Thread.current[:counter] -= 1 262 | end 263 | end 264 | 265 | DumbCounter.call 266 | Thread.current[:counter] # => 0 267 | ``` 268 | 269 | ### ensure 270 | 271 | Opened file inside `#perform_actions` or want to do some other cleanup even when action fails? Use `#ensure`: 272 | 273 | ```ruby 274 | class UseFile < CleanActions::Base 275 | def perform_actions 276 | @file = File.open # ... 277 | end 278 | 279 | def ensure 280 | @file.close 281 | end 282 | end 283 | ``` 284 | 285 | ## Contributing 286 | 287 | Bug reports and pull requests are welcome on GitHub at https://github.com/DmitryTsepelev/clean_actions. 288 | 289 | ## License 290 | 291 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 292 | --------------------------------------------------------------------------------