From 29a000fb09bc327e306fee3e1d7c47e2f6e2e67e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Mar 2023 18:07:52 +0000 Subject: [PATCH 01/12] Update sqlite3 requirement from ~> 1.4.2 to ~> 1.6.1 Updates the requirements on [sqlite3](https://github.com/sparklemotion/sqlite3-ruby) to permit the latest version. - [Release notes](https://github.com/sparklemotion/sqlite3-ruby/releases) - [Changelog](https://github.com/sparklemotion/sqlite3-ruby/blob/master/CHANGELOG.md) - [Commits](https://github.com/sparklemotion/sqlite3-ruby/compare/v1.4.2...v1.6.1) --- updated-dependencies: - dependency-name: sqlite3 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- statesman.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/statesman.gemspec b/statesman.gemspec index a127025b..479600dc 100644 --- a/statesman.gemspec +++ b/statesman.gemspec @@ -34,7 +34,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency "rspec-github", "~> 2.3.1" 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 = { From 4ea2b44774f74fd59c5551c9fc84dfbc51b04c18 Mon Sep 17 00:00:00 2001 From: Peter Goldstein Date: Sun, 5 Mar 2023 20:44:30 -0500 Subject: [PATCH 02/12] Add Ruby 3.2 and MySQL 8.0 to the CI matrix --- .github/workflows/tests.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d8a927cf..29d6bd7e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,12 +21,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] + exclude: + - ruby-version: 3.2 + rails-version: "6.1.5" runs-on: ubuntu-latest services: postgres: @@ -60,13 +63,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: From 6c1d4d95e6d6292c11df208db82609acd8ca2f67 Mon Sep 17 00:00:00 2001 From: Stephen Binns Date: Mon, 6 Mar 2023 14:06:38 +0000 Subject: [PATCH 03/12] Ensure CI runs on PRs as well as pushes --- .github/workflows/tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d8a927cf..c14fb5fa 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 }} From 1eda6cfb4da44b1499bad8180f8a33497b3f2efd Mon Sep 17 00:00:00 2001 From: Stephen Binns Date: Mon, 6 Mar 2023 14:12:23 +0000 Subject: [PATCH 04/12] Quote ruby and postgres versions This helps avoid unexpected versions being used as YAML will omit the .0 --- .github/workflows/tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c14fb5fa..087ac55a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,12 +24,12 @@ jobs: strategy: fail-fast: false matrix: - ruby-version: [2.7, 3.0, 3.1] + ruby-version: ["2.7", "3.0", "3.1"] rails-version: - "6.1.5" - "7.0.4" - "main" - postgres-version: [9.6, 11, 14] + postgres-version: ["9.6", "11", "14"] runs-on: ubuntu-latest services: postgres: @@ -63,7 +63,7 @@ jobs: strategy: fail-fast: false matrix: - ruby-version: [2.7, 3.0, 3.1] + ruby-version: ["2.7", "3.0", "3.1"] rails-version: - "6.1.5" - "7.0.4" From e5152dec2126fad8a2cb65d6cbde866c548dae45 Mon Sep 17 00:00:00 2001 From: Joseph Southan Date: Fri, 10 Mar 2023 15:17:16 +0000 Subject: [PATCH 05/12] Add the source location of the Guard callback to GuardFailedError Often it can be difficult to know _which_ guard failed, especially if there are a lot of guards that may intersect. ``` Statesman::GuardFailedError: Guard on transition from: '' to '["bar"]' returned false from /Users/joesouthan/foobar/app/state_machines/foobars.rb:98 ``` --- CHANGELOG.md | 5 +++++ lib/statesman/exceptions.rb | 6 ++++-- lib/statesman/guard.rb | 2 +- lib/statesman/version.rb | 2 +- spec/statesman/exceptions_spec.rb | 8 +++++++- spec/statesman/machine_spec.rb | 4 ++-- 6 files changed, 20 insertions(+), 7 deletions(-) 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/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/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 } From 4710f6e2c5962e940144c454664265c644d66339 Mon Sep 17 00:00:00 2001 From: Joseph Southan Date: Fri, 10 Mar 2023 15:24:57 +0000 Subject: [PATCH 06/12] Rubocop autocorrect --- spec/statesman/adapters/active_record_queries_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 From dc1619ba32012fee178e9a33e05adc35d8ad120c Mon Sep 17 00:00:00 2001 From: Joseph Southan Date: Fri, 10 Mar 2023 15:30:41 +0000 Subject: [PATCH 07/12] Add health check for mysql --- .github/workflows/tests.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e8268fe3..56717533 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -86,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" From 4fec9d8412d401638d98fb444597b915c4ae5527 Mon Sep 17 00:00:00 2001 From: Stephen Binns Date: Tue, 7 Mar 2023 12:20:16 +0000 Subject: [PATCH 08/12] Add type checker safe method of including queries This avoids the dynamic include issues but also retains some of the ergomonics of that method --- .../type_safe_active_record_queries.rb | 23 ++ .../type_safe_active_record_queries_spec.rb | 208 ++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 lib/statesman/adapters/type_safe_active_record_queries.rb create mode 100644 spec/statesman/adapters/type_safe_active_record_queries_spec.rb 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..686dff3f --- /dev/null +++ b/lib/statesman/adapters/type_safe_active_record_queries.rb @@ -0,0 +1,23 @@ +# 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 + + def self.included(base); end + 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 From 48c1d5431c92d0b49aa112aa0e814aa5e2d9c422 Mon Sep 17 00:00:00 2001 From: Stephen Binns Date: Tue, 7 Mar 2023 12:24:52 +0000 Subject: [PATCH 09/12] Add documentation --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) 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) From 488858577ad38cd9f975277c6496b18be8681c0b Mon Sep 17 00:00:00 2001 From: Stephen Binns Date: Tue, 7 Mar 2023 12:25:06 +0000 Subject: [PATCH 10/12] Ensure module is autoloaded correctly --- lib/statesman.rb | 2 ++ 1 file changed, 2 insertions(+) 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) From cc5050110206cfe1f2d9a6953198ec0aabe48d5a Mon Sep 17 00:00:00 2001 From: Stephen Binns Date: Tue, 7 Mar 2023 13:30:15 +0000 Subject: [PATCH 11/12] Cleanup method --- lib/statesman/adapters/type_safe_active_record_queries.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/statesman/adapters/type_safe_active_record_queries.rb b/lib/statesman/adapters/type_safe_active_record_queries.rb index 686dff3f..e6359744 100644 --- a/lib/statesman/adapters/type_safe_active_record_queries.rb +++ b/lib/statesman/adapters/type_safe_active_record_queries.rb @@ -16,8 +16,6 @@ def configure_state_machine(args = {}) ), ) end - - def self.included(base); end end end end From 6a2abb345742340ff29a426d867dfd690f43434c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Mar 2023 16:37:34 +0000 Subject: [PATCH 12/12] Update rspec-github requirement from ~> 2.3.1 to ~> 2.4.0 Updates the requirements on [rspec-github](https://github.com/drieam/rspec-github) to permit the latest version. - [Release notes](https://github.com/drieam/rspec-github/releases) - [Commits](https://github.com/drieam/rspec-github/compare/2.3.1...2.4.0) --- updated-dependencies: - dependency-name: rspec-github dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- statesman.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/statesman.gemspec b/statesman.gemspec index 479600dc..b4bbe7ba 100644 --- a/statesman.gemspec +++ b/statesman.gemspec @@ -31,7 +31,7 @@ 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.6.1"