Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/master' into feature/add_from_st…
Browse files Browse the repository at this point in the history
…ate_to_transition
  • Loading branch information
Tabby committed Mar 16, 2023
2 parents 2ac555e + a37a3b9 commit d85e999
Show file tree
Hide file tree
Showing 13 changed files with 299 additions and 18 deletions.
23 changes: 18 additions & 5 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ name: tests

on:
push:
branches:
- "master"
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:[email protected]/statesman_test
DATABASE_DEPENDENCY_PORT: "3306"
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions lib/statesman.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
21 changes: 21 additions & 0 deletions lib/statesman/adapters/type_safe_active_record_queries.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 4 additions & 2 deletions lib/statesman/exceptions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion lib/statesman/guard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion lib/statesman/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Statesman
VERSION = "10.0.0"
VERSION = "10.1.0"
end
8 changes: 4 additions & 4 deletions spec/statesman/adapters/active_record_queries_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,17 +117,17 @@ 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

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 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
Expand Down
208 changes: 208 additions & 0 deletions spec/statesman/adapters/type_safe_active_record_queries_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit d85e999

Please sign in to comment.