diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d8a927cf..56717533 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,6 +2,9 @@ name: tests on: push: + branches: + - "master" + pull_request: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -21,12 +24,15 @@ jobs: strategy: fail-fast: false matrix: - ruby-version: [2.7, 3.0, 3.1] + ruby-version: ["2.7", "3.0", "3.1", "3.2"] rails-version: - "6.1.5" - "7.0.4" - "main" - postgres-version: [9.6, 11, 14] + postgres-version: ["9.6", "11", "14"] + exclude: + - ruby-version: "3.2" + rails-version: "6.1.5" runs-on: ubuntu-latest services: postgres: @@ -60,13 +66,15 @@ jobs: strategy: fail-fast: false matrix: - ruby-version: [2.7, 3.0, 3.1] + ruby-version: ["2.7", "3.0", "3.1", "3.2"] rails-version: - "6.1.5" - "7.0.4" - "main" - mysql-version: - - "5.7" + mysql-version: ["5.7", "8.0"] + exclude: + - ruby-version: 3.2 + rails-version: "6.1.5" runs-on: ubuntu-latest services: mysql: @@ -78,6 +86,11 @@ jobs: MYSQL_DATABASE: statesman_test ports: - "3306:3306" + options: >- + --health-cmd "mysqladmin ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 env: DATABASE_URL: mysql2://foobar:password@127.0.0.1/statesman_test DATABASE_DEPENDENCY_PORT: "3306" diff --git a/CHANGELOG.md b/CHANGELOG.md index f2018fea..b24609d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## v10.1.0 10th March 2023 + +### CHanged +- Add the source location of the guard callback to `Statesman::GuardFailedError` + ## v10.0.0 17th May 2022 ### Changed diff --git a/README.md b/README.md index 76ffeeec..1f4dec75 100644 --- a/README.md +++ b/README.md @@ -611,6 +611,30 @@ describe "some callback" do end ``` +## Compatibility with type checkers + +Including ActiveRecordQueries to your model can cause issues with type checkers +such as Sorbet, this is because this technically is using a dynamic include, +which is not supported by Sorbet. + +To avoid these issues you can instead include the TypeSafeActiveRecordQueries +module and pass in configuration. + +```ruby +class Order < ActiveRecord::Base + has_many :order_transitions, autosave: false + + include Statesman::Adapters::TypeSafeActiveRecordQueries + + configure_state_machine transition_class: OrderTransition, + initial_state: :pending + + def state_machine + @state_machine ||= OrderStateMachine.new(self, transition_class: OrderTransition) + end +end +``` + # Third-party extensions [statesman-sequel](https://github.com/badosu/statesman-sequel) - An adapter to make Statesman work with [Sequel](https://github.com/jeremyevans/sequel) diff --git a/lib/statesman.rb b/lib/statesman.rb index b11d6984..8890039f 100644 --- a/lib/statesman.rb +++ b/lib/statesman.rb @@ -14,6 +14,8 @@ module Adapters "statesman/adapters/active_record_transition" autoload :ActiveRecordQueries, "statesman/adapters/active_record_queries" + autoload :TypeSafeActiveRecordQueries, + "statesman/adapters/type_safe_active_record_queries" end require "statesman/railtie" if defined?(::Rails::Railtie) diff --git a/lib/statesman/adapters/type_safe_active_record_queries.rb b/lib/statesman/adapters/type_safe_active_record_queries.rb new file mode 100644 index 00000000..e6359744 --- /dev/null +++ b/lib/statesman/adapters/type_safe_active_record_queries.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Statesman + module Adapters + module TypeSafeActiveRecordQueries + def configure_state_machine(args = {}) + transition_class = args.fetch(:transition_class) + initial_state = args.fetch(:initial_state) + + include( + ActiveRecordQueries::ClassMethods.new( + transition_class: transition_class, + initial_state: initial_state, + most_recent_transition_alias: try(:most_recent_transition_alias), + transition_name: try(:transition_name), + ), + ) + end + end + end +end diff --git a/lib/statesman/exceptions.rb b/lib/statesman/exceptions.rb index 214b8d21..96adcc51 100644 --- a/lib/statesman/exceptions.rb +++ b/lib/statesman/exceptions.rb @@ -28,13 +28,15 @@ def _message end class GuardFailedError < StandardError - def initialize(from, to) + def initialize(from, to, callback) @from = from @to = to + @callback = callback super(_message) + set_backtrace(callback.source_location.join(":")) if callback&.source_location end - attr_reader :from, :to + attr_reader :from, :to, :callback private diff --git a/lib/statesman/guard.rb b/lib/statesman/guard.rb index 475f04b3..50a87b1b 100644 --- a/lib/statesman/guard.rb +++ b/lib/statesman/guard.rb @@ -6,7 +6,7 @@ module Statesman class Guard < Callback def call(*args) - raise GuardFailedError.new(from, to) unless super(*args) + raise GuardFailedError.new(from, to, callback) unless super(*args) end end end diff --git a/lib/statesman/version.rb b/lib/statesman/version.rb index 76b7f3e0..cc251524 100644 --- a/lib/statesman/version.rb +++ b/lib/statesman/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Statesman - VERSION = "10.0.0" + VERSION = "10.1.0" end diff --git a/spec/statesman/adapters/active_record_queries_spec.rb b/spec/statesman/adapters/active_record_queries_spec.rb index e3c78c9c..685734eb 100644 --- a/spec/statesman/adapters/active_record_queries_spec.rb +++ b/spec/statesman/adapters/active_record_queries_spec.rb @@ -117,8 +117,8 @@ def configure_new(klass, transition_class) subject(:not_in_state) { MyActiveRecordModel.not_in_state(:succeeded, :failed) } it do - expect(not_in_state).to match_array([initial_state_model, - returned_to_initial_model]) + expect(not_in_state).to contain_exactly(initial_state_model, + returned_to_initial_model) end end @@ -126,8 +126,8 @@ def configure_new(klass, transition_class) subject(:not_in_state) { MyActiveRecordModel.not_in_state(%i[succeeded failed]) } it do - expect(not_in_state).to match_array([initial_state_model, - returned_to_initial_model]) + expect(not_in_state).to contain_exactly(initial_state_model, + returned_to_initial_model) end end end diff --git a/spec/statesman/adapters/type_safe_active_record_queries_spec.rb b/spec/statesman/adapters/type_safe_active_record_queries_spec.rb new file mode 100644 index 00000000..c5c68a59 --- /dev/null +++ b/spec/statesman/adapters/type_safe_active_record_queries_spec.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe Statesman::Adapters::TypeSafeActiveRecordQueries, active_record: true do + def configure(klass, transition_class) + klass.send(:extend, described_class) + klass.configure_state_machine( + transition_class: transition_class, + initial_state: :initial, + ) + end + + before do + prepare_model_table + prepare_transitions_table + prepare_other_model_table + prepare_other_transitions_table + + Statesman.configure do + storage_adapter(Statesman::Adapters::ActiveRecord) + end + end + + after { Statesman.configure { storage_adapter(Statesman::Adapters::Memory) } } + + let!(:model) do + model = MyActiveRecordModel.create + model.state_machine.transition_to(:succeeded) + model + end + + let!(:other_model) do + model = MyActiveRecordModel.create + model.state_machine.transition_to(:failed) + model + end + + let!(:initial_state_model) { MyActiveRecordModel.create } + + let!(:returned_to_initial_model) do + model = MyActiveRecordModel.create + model.state_machine.transition_to(:failed) + model.state_machine.transition_to(:initial) + model + end + + shared_examples "testing methods" do + before do + configure(MyActiveRecordModel, MyActiveRecordModelTransition) + configure(OtherActiveRecordModel, OtherActiveRecordModelTransition) + + MyActiveRecordModel.send(:has_one, :other_active_record_model) + OtherActiveRecordModel.send(:belongs_to, :my_active_record_model) + end + + describe ".in_state" do + context "given a single state" do + subject { MyActiveRecordModel.in_state(:succeeded) } + + it { is_expected.to include model } + it { is_expected.to_not include other_model } + end + + context "given multiple states" do + subject { MyActiveRecordModel.in_state(:succeeded, :failed) } + + it { is_expected.to include model } + it { is_expected.to include other_model } + end + + context "given the initial state" do + subject { MyActiveRecordModel.in_state(:initial) } + + it { is_expected.to include initial_state_model } + it { is_expected.to include returned_to_initial_model } + end + + context "given an array of states" do + subject { MyActiveRecordModel.in_state(%i[succeeded failed]) } + + it { is_expected.to include model } + it { is_expected.to include other_model } + end + + context "merging two queries" do + subject do + MyActiveRecordModel.in_state(:succeeded). + joins(:other_active_record_model). + merge(OtherActiveRecordModel.in_state(:initial)) + end + + it { is_expected.to be_empty } + end + end + + describe ".not_in_state" do + context "given a single state" do + subject { MyActiveRecordModel.not_in_state(:failed) } + + it { is_expected.to include model } + it { is_expected.to_not include other_model } + end + + context "given multiple states" do + subject(:not_in_state) { MyActiveRecordModel.not_in_state(:succeeded, :failed) } + + it do + expect(not_in_state).to contain_exactly(initial_state_model, + returned_to_initial_model) + end + end + + context "given an array of states" do + subject(:not_in_state) { MyActiveRecordModel.not_in_state(%i[succeeded failed]) } + + it do + expect(not_in_state).to contain_exactly(initial_state_model, + returned_to_initial_model) + end + end + end + + context "with a custom name for the transition association" do + before do + # Switch to using OtherActiveRecordModelTransition, so the existing + # relation with MyActiveRecordModelTransition doesn't interfere with + # this spec. + MyActiveRecordModel.send(:has_many, + :custom_name, + class_name: "OtherActiveRecordModelTransition") + + MyActiveRecordModel.class_eval do + def self.transition_class + OtherActiveRecordModelTransition + end + end + end + + describe ".in_state" do + subject(:query) { MyActiveRecordModel.in_state(:succeeded) } + + specify { expect { query }.to_not raise_error } + end + end + + context "with a custom primary key for the model" do + before do + # Switch to using OtherActiveRecordModelTransition, so the existing + # relation with MyActiveRecordModelTransition doesn't interfere with + # this spec. + # Configure the relationship to use a different primary key, + MyActiveRecordModel.send(:has_many, + :custom_name, + class_name: "OtherActiveRecordModelTransition", + primary_key: :external_id) + + MyActiveRecordModel.class_eval do + def self.transition_class + OtherActiveRecordModelTransition + end + end + end + + describe ".in_state" do + subject(:query) { MyActiveRecordModel.in_state(:succeeded) } + + specify { expect { query }.to_not raise_error } + end + end + + context "after_commit transactional integrity" do + before do + MyStateMachine.class_eval do + cattr_accessor(:after_commit_callback_executed) { false } + + after_transition(from: :initial, to: :succeeded, after_commit: true) do + # This leaks state in a testable way if transactional integrity is broken. + MyStateMachine.after_commit_callback_executed = true + end + end + end + + after do + MyStateMachine.class_eval do + callbacks[:after_commit] = [] + end + end + + let!(:model) do + MyActiveRecordModel.create + end + + it do + expect do + ActiveRecord::Base.transaction do + model.state_machine.transition_to!(:succeeded) + raise ActiveRecord::Rollback + end + end.to_not change(MyStateMachine, :after_commit_callback_executed) + end + end + end + + context "using configuration method" do + include_examples "testing methods" + end +end diff --git a/spec/statesman/exceptions_spec.rb b/spec/statesman/exceptions_spec.rb index 73189385..275a6d60 100644 --- a/spec/statesman/exceptions_spec.rb +++ b/spec/statesman/exceptions_spec.rb @@ -64,12 +64,18 @@ end describe "GuardFailedError" do - subject(:error) { Statesman::GuardFailedError.new("from", "to") } + subject(:error) { Statesman::GuardFailedError.new("from", "to", callback) } + + let(:callback) { -> { "hello" } } its(:message) do is_expected.to eq("Guard on transition from: 'from' to 'to' returned false") end + its(:backtrace) do + is_expected.to eq([callback.source_location.join(":")]) + end + its "string matches its message" do expect(error.to_s).to eq(error.message) end diff --git a/spec/statesman/machine_spec.rb b/spec/statesman/machine_spec.rb index c7b8bfb6..d8b748b5 100644 --- a/spec/statesman/machine_spec.rb +++ b/spec/statesman/machine_spec.rb @@ -935,10 +935,10 @@ def after_initialize; end it { is_expected.to be(:some_state) } end - context "when it is unsuccesful" do + context "when it is unsuccessful" do before do allow(instance).to receive(:transition_to!). - and_raise(Statesman::GuardFailedError.new(:x, :some_state)) + and_raise(Statesman::GuardFailedError.new(:x, :some_state, nil)) end it { is_expected.to be_falsey } diff --git a/statesman.gemspec b/statesman.gemspec index a127025b..b4bbe7ba 100644 --- a/statesman.gemspec +++ b/statesman.gemspec @@ -31,10 +31,10 @@ Gem::Specification.new do |spec| spec.add_development_dependency "rails", ">= 5.2" spec.add_development_dependency "rake", "~> 13.0.0" spec.add_development_dependency "rspec", "~> 3.1" - spec.add_development_dependency "rspec-github", "~> 2.3.1" + spec.add_development_dependency "rspec-github", "~> 2.4.0" spec.add_development_dependency "rspec-its", "~> 1.1" spec.add_development_dependency "rspec-rails", "~> 3.1" - spec.add_development_dependency "sqlite3", "~> 1.4.2" + spec.add_development_dependency "sqlite3", "~> 1.6.1" spec.add_development_dependency "timecop", "~> 0.9.1" spec.metadata = {