diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d8a927cf..625dc6f4 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,18 @@ jobs: strategy: fail-fast: false matrix: - ruby-version: [2.7, 3.0, 3.1] + ruby-version: ["3.0", "3.1", "3.2", "3.3"] rails-version: - - "6.1.5" - - "7.0.4" + - "6.1.7.6" + - "7.0.8" + - "7.1.1" - "main" - postgres-version: [9.6, 11, 14] + postgres-version: ["12", "13", "14", "15", "16"] + exclude: + - ruby-version: "3.2" + rails-version: "6.1.7.6" + - ruby-version: "3.3" + rails-version: "6.1.7.6" runs-on: ubuntu-latest services: postgres: @@ -60,13 +69,18 @@ jobs: strategy: fail-fast: false matrix: - ruby-version: [2.7, 3.0, 3.1] + ruby-version: ["3.0", "3.1", "3.2", "3.3"] rails-version: - - "6.1.5" - - "7.0.4" + - "6.1.7.6" + - "7.0.8" + - "7.1.1" - "main" - mysql-version: - - "5.7" + mysql-version: ["8.0", "8.2"] + exclude: + - ruby-version: "3.2" + rails-version: "6.1.7.6" + - ruby-version: "3.3" + rails-version: "6.1.7.6" runs-on: ubuntu-latest services: mysql: @@ -78,6 +92,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/.gitignore b/.gitignore index b878c414..88a3a80f 100644 --- a/.gitignore +++ b/.gitignore @@ -55,9 +55,6 @@ Gemfile.lock # Used by RuboCop. Remote config files pulled in from inherit_from directive. # .rubocop-https?--* -# Project-specific ignores -.rspec - # VSCode .vscode diff --git a/.rspec b/.rspec new file mode 100644 index 00000000..c99d2e73 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml index 823e7dd5..84904ecf 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -5,6 +5,7 @@ inherit_gem: AllCops: TargetRubyVersion: 3.0 + NewCops: enable Metrics/AbcSize: Max: 60 @@ -14,3 +15,6 @@ Metrics/CyclomaticComplexity: Metrics/PerceivedComplexity: Max: 11 + +Gemspec/DevelopmentDependencies: + Enabled: false diff --git a/.ruby-version b/.ruby-version index b5021469..15a27998 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.0.2 +3.3.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index f2018fea..588840de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,90 @@ -## v10.0.0 17th May 2022 +# Changelog + + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## v12.1.0 5th January 2024 + +### Fixed + +- Fixed autoloading the VERSION constants +- Fixed Ensuring inheritance issues with STI tabled +- Enabled gaplock protection when using trilogy mysql adapter + +### Added + +- Added Ruby 3.3 to build matrix +- Added optional initial transition + +## v12.0.0 30th November 2023 + +### Added + +- Added multi-database support [#522](https://github.com/gocardless/statesman/pull/522) + - This now uses the correct ActiveRecord connection for the model or transition in a multi-database environment + +## v11.0.0 3rd November 2023 + +### Changed + +- Updated to support ActiveRecord > 7.2 +- Remove support for: + - Ruby; 2.7 + - Postgres; 9.6, 10, 11 + - MySQL; 5.7 + +## v10.2.3 2nd Aug 2023 + +### Fixed + +- Fixed calls to reloading internal cache is the state_machine was made private / protected + +## v10.2.2 21st April 2023 ### Changed + +- Calling `active_record.reload` resets the adapater's internal cache + +## v10.2.1 3rd April 2023 + +### Fixed + +- Fixed an edge case where `adapter.reset` were failing if the cache is empty + +## v10.2.0 3rd April 2023 + +### Fixed + +- Fixed caching of `last_transition` [#505](https://github.com/gocardless/statesman/pull/505) + +## v10.1.0 10th March 2023 + +### Changed + +- Add the source location of the guard callback to `Statesman::GuardFailedError` + +## v10.0.0 17th May 2022 + +### Added + - Added support for Ruby 3.1 [#462](https://github.com/gocardless/statesman/pull/462) -- Removed support for Ruby 2.5 and 2.6 [#462](https://github.com/gocardless/statesman/pull/462) - Added `remove_state` and `remove_transitions` methods to `Statesman::Machine` [#464](https://github.com/gocardless/statesman/pull/464) +### Changed + +- Removed support for Ruby 2.5 and 2.6 [#462](https://github.com/gocardless/statesman/pull/462) + ## v9.0.1 4th February 2021 ### Changed + - Deprecate `ActiveRecord::Base.default_timezone` in favour of `ActiveRecord.default_timezone` [#446](https://github.com/gocardless/statesman/pull/446) ## v9.0.0 9th August 2021 ### Added + - Added Ruby 3.0 support ### Breaking changes @@ -22,19 +94,20 @@ ## v8.0.3 8th June 2021 ### Added + - Implement `Machine#last_transition_to`, to find the last transition to a given state [#438](https://github.com/gocardless/statesman/pull/438) ## v8.0.2 30th March 2021 -### Changed +### Fixed - Fixed a bug where the `history` of a model was left in an incorrect state after a transition conflict [#433](https://github.com/gocardless/statesman/pull/433) ## v8.0.1 20th January 2021 -### Changed +### Fixed - Fixed `no implicit conversion of nil into String` error when quoting null values [#427](https://github.com/gocardless/statesman/pull/427) @@ -78,43 +151,57 @@ ## v7.1.0, 10th Feb 2020 +### Fixed + - Fix `to_s` on `TransitionFailedError` & `GuardFailedError`. `.message` and `.to_s` diverged when `from` and `to` accessors where added in v4.1.3 ## v7.0.1, 8th Jan 2020 +### Fixed + - Fix deprecation warning with Ruby 2.7 [#386](https://github.com/gocardless/statesman/pull/386) ## v7.0.0, 8th Jan 2020 -**Breaking changes** +### Breaking changes - Drop official support for Rails 4.2, 5.0 and 5.1, following our [compatibility policy](https://github.com/gocardless/statesman/blob/master/docs/COMPATIBILITY.md). ## v6.0.0, 20th December 2019 -**Breaking changes** +### Breaking changes - Drop official support for Ruby 2.2 and 2.3 following our [compatibility policy](https://github.com/gocardless/statesman/blob/master/docs/COMPATIBILITY.md). ## v5.2.0, 17th December 2019 +### Changed + - Issue `most_recent_transition_join` query as a single-line string [#381](https://github.com/gocardless/statesman/pull/381) ## v5.1.0, 22th November 2019 +### Fixed + - Correct `Statesman::Adapters::ActiveRecordQueries` error text [@Bramjetten](https://github.com/gocardless/statesman/pull/376) - Removes duplicate `map` call [Isaac Seymour](https://github.com/gocardless/statesman/pull/362) + +### Changed + - Update changelog with instructions of how to use `ActiveRecordQueries` added in v5.0.0 - Pass exception into `after_transition_failure` and `after_guard_failure` callbacks [@credric-cordenier](https://github.com/gocardless/statesman/pull/378) ## v5.0.0, 11th November 2019 +### Added + - Adds new syntax and restrictions to ActiveRecordQueries [PR#358](https://github.com/gocardless/statesman/pull/358). With the introduction of this, defining `self.transition_class` or `self.initial_state` is deprecated and will be removed in the next major release. Change + ```ruby include Statesman::Adapters::ActiveRecordQueries def self.initial_state @@ -124,7 +211,9 @@ MyTransition end ``` + to + ```ruby include Statesman::Adapters::ActiveRecordQueries[ initial_state: :initial, @@ -134,34 +223,51 @@ ## v4.1.4, 11th November 2019 +### Changed + - Reverts the breaking changes from [PR#358](https://github.com/gocardless/statesman/pull/358) & `v4.1.3` that where included in the last minor release. If you have changed your code to work with these changes `v5.0.0` will be a copy of `v4.1.3` with a bugfix applied. ## v4.1.3, 6th November 2019 +### Added + - Add accessible from / to state attributes on the `TransitionFailedError` to avoid parsing strings [@ahjmorton](https://github.com/gocardless/statesman/pull/367) - Add `after_transition_failure` mechanism [@credric-cordenier](https://github.com/gocardless/statesman/pull/366) ## v4.1.2, 17th August 2019 +### Added + - Add support for Rails 6 [@greysteil](https://github.com/gocardless/statesman/pull/360) ## v4.1.1, 6th July 2019 +### Fixed + - Fix statesman index detection for indexes that start t-z [@hmarr](https://github.com/gocardless/statesman/pull/354) - Correct access of metadata via `state_machine` [@glenpike](https://github.com/gocardless/statesman/pull/349) ## v4.1.0, 10 April 2019 -- Add better support for mysql (and others) in `transition_conflict_error?` [@greysteil](https://github.com/greysteil) (https://github.com/gocardless/statesman/pull/342) +### Changed + +- Add better support for mysql (and others) in `transition_conflict_error?` [@greysteil](https://github.com/greysteil) () ## v4.0.0, 22 February 2019 -- Forces Statesman to use a new transactions with `requires_new: true` (https://github.com/gocardless/statesman/pull/249) +### Fixed + - Fixes an issue with `after_commit` transition blocks that where being executed even if the transaction rolled back. ([patch](https://github.com/gocardless/statesman/pull/338) by [@matid](https://github.com/matid)) +### Changed + +- Forces Statesman to use a new transactions with `requires_new: true` () + ## v3.5.0, 2 November 2018 +### Changed + - Expose `most_recent_transition_join` - ActiveRecords `or` requires that both sides of the query match up. Exposing this methods makes things easier if one side of the `or` uses `in_state` or `not_in_state`. (patch by [@adambutler](https://github.com/adambutler)) @@ -169,37 +275,50 @@ ## v3.4.1, 14 February 2018 ❤️ +### Added + - Support ActiveRecord transition classes which don't include `Statesman::Adapters::ActiveRecordTransition`, and thus don't have a `.updated_timestamp_column` method (see #310 for further details) (patch by [@timrogers](https://github.com/timrogers)) ## v3.4.0, 12 February 2018 +### Changed + - When unsetting the `most_recent` flag during a transition, don't assume that transitions have an `updated_at` attribute, but rather allow the "updated timestamp column" to be re-configured or disabled entirely (patch by [@timrogers](https://github.com/timrogers)) ## v3.3.0, 5 January 2018 +### Changed + - Touch `updated_at` on transitions when unsetting `most_recent` flag (patch by [@NGMarmaduke](https://github.com/NGMarmaduke)) - Fix `force_reload` for ActiveRecord models with loaded transitions (patch by [@jacobpgn](https://github.com/)) ## v3.2.0, 27 November 2017 +### Added + - Allow specifying metadata with `Machine#allowed_transitions` (patch by [@vvondra](https://github.com/vvondra)) ## v3.1.0, 1 September 2017 +### Added + - Add support for Rails 5.0.x and 5.1.x (patch by [@kenchan0130](https://github.com/kenchan0130) and [@timrogers](https://github.com/timrogers)) + +### Changed + - Run tests in CircleCI instead of TravisCI (patch by [@timrogers](https://github.com/timrogers)) - Update Rubocop and fix offences (patch by [@timrogers](https://github.com/timrogers)) ## v3.0.0, 3 July 2017 -*Breaking changes* +### Breaking changes - Drop support for Rails < 4.2 - Drop support for Ruby < 2.2 For details on our compatibility policy, see `docs/COMPATIBILITY.md`. -*Changes* +### Changed - Better handling of custom transition association names (patch by [@greysteil](https://github.com/greysteil)) - Add foreign keys to transition table generator (patch by [@greysteil](https://github.com/greysteil)) @@ -207,6 +326,8 @@ For details on our compatibility policy, see `docs/COMPATIBILITY.md`. ## v2.0.1, 29 March 2016 +### Added + - Add support for Rails 5 (excluding Mongoid adapter) ## v2.0.0, 5 January 2016 @@ -215,7 +336,7 @@ For details on our compatibility policy, see `docs/COMPATIBILITY.md`. ## v2.0.0.rc1, 23 December 2015 -*Breaking changes* +### Breaking changes - Unset most_recent after before transitions - TL;DR: set `autosave: false` on the `has_many` association between your parent and transition model and this change will almost certainly not affect your integration @@ -233,7 +354,7 @@ For details on our compatibility policy, see `docs/COMPATIBILITY.md`. - To keep Statesman lightweight we've moved event functionality into the `statesman-events` gem - If you are using events, add `statesman-events` to your gemfile and include `Statesman::Events` in your state machines -*Changes* +### Changed - Add after_destroy hook to ActiveRecord transition model templates - Add `in_state?` instance method to `Statesman::Machine` @@ -241,56 +362,73 @@ For details on our compatibility policy, see `docs/COMPATIBILITY.md`. ## v1.3.1, 2 July 2015 +### Changed + - Fix `in_state` queries with a custom `transition_name` (patch by [0tsuki](https://github.com/0tsuki)) - Fix `backfill_most_recent` rake task for databases that support partial indexes (patch by [greysteil](https://github.com/greysteil)) ## v1.3.0, 20 June 2015 +### Changed + - Rename `last_transition` alias in `ActiveRecordQueries` to `most_recent_#{model_name}`, to allow merging of two such queries (patch by [@isaacseymour](https://github.com/isaacseymour)) ## v1.2.5, 17 June 2015 +### Changed + - Make `backfill_most_recent` rake task db-agnostic (patch by [@timothyp](https://github.com/timothyp)) ## v1.2.4, 16 June 2015 +### Changed + - Clarify error messages when misusing `Statesman::Adapters::ActiveRecordTransition` (patch by [@isaacseymour](https://github.com/isaacseymour)) ## v1.2.3, 14 April 2015 +### Changed + - Fix use of most_recent column in MySQL (partial indexes aren't supported) (patch by [@greysteil](https://github.com/greysteil)) ## v1.2.2, 24 March 2015 +### Added + - Add support for namespaced transition models (patch by [@DanielWright](https://github.com/DanielWright)) ## v1.2.1, 24 March 2015 +### Added + - Add support for Postgres 9.4's `jsonb` column type (patch by [@isaacseymour](https://github.com/isaacseymour)) ## v1.2.0, 18 March 2015 -*Changes* +### Added - Add a `most_recent` column to transition tables to greatly speed up queries (ActiveRecord adapter only). - All queries are backwards-compatible, so everything still works without the new column. - The upgrade path is: - Generate and run a migration for adding the column, by running `rails generate statesman:add_most_recent `. - - Backfill the `most_recent` column on old records by running `rake statesman:backfill_most_recent[ParentModel] `. + - Backfill the `most_recent` column on old records by running `rake statesman:backfill_most_recent[ParentModel]`. - Add constraints and indexes to the transition table that make use of the new field, by running `rails g statesman:add_constraints_to_most_recent `. - The upgrade path has been designed to be zero-downtime, even on large tables. As a result, please note that queries will only use the `most_recent` field after the constraints have been added. -- `ActiveRecordQueries.{not_,}in_state` now accepts an array of states. +### Changed + +- `ActiveRecordQueries.{not_,}in_state` now accepts an array of states. ## v1.1.0, 9 December 2014 -*Fixes* + +### Fixed - Support for Rails 4.2.0.rc2: - Remove use of serialized_attributes when using 4.2+. (patch by [@greysteil](https://github.com/greysteil)) - Use reflect_on_association rather than directly using the reflections hash. (patch by [@timrogers](https://github.com/timrogers)) - Fix `ActiveRecordQueries.in_state` when `Model.initial_state` is defined as a symbol. (patch by [@isaacseymour](https://github.com/isaacseymour)) -*Changes* +### Changed - Transition metadata now defaults to `{}` rather than `nil`. (patch by [@greysteil](https://github.com/greysteil)) @@ -299,99 +437,126 @@ For details on our compatibility policy, see `docs/COMPATIBILITY.md`. No changes from v1.0.0.beta2 ## v1.0.0.beta2, 10 October 2014 -*Breaking changes* + +### Breaking changes - Rename `ActiveRecordModel` to `ActiveRecordQueries`, to reflect the fact that it mixes in some helpful scopes, but is not required. ## v1.0.0.beta1, 9 October 2014 -*Breaking changes* + +### Breaking changes - Classes which include `ActiveRecordModel` must define an `initial_state` class method. -*Fixes* +### Fixed - `ActiveRecordModel.in_state` and `ActiveRecordModel.not_in_state` now handle inital states correctly (patch by [@isaacseymour](https://github.com/isaacseymour)) -*Additions* +### Added - Transition tables created by generated migrations have `NOT NULL` constraints on `to_state`, `sort_key` and foreign key columns (patch by [@greysteil](https://github.com/greysteil)) - `before_transition` and `after_transition` allow an array of to states (patch by [@isaacseymour](https://github.com/isaacseymour)) ## v0.8.3, 2 September 2014 -*Fixes* + +### Fixed - Optimisation for Machine#available_events (patch by [@pacso](https://github.com/pacso)) ## v0.8.2, 2 September 2014 -*Fixes* + +### Fixed - Stop generating a default value for the metadata column if using MySQL. ## v0.8.1, 19 August 2014 -*Fixes* + +### Fixed - Adds check in Machine#transition to make sure the 'to' state is not an empty array (patch by [@barisbalic](https://github.com/barisbalic)) ## v0.8.0, 29 June 2014 -*Additions* + +### Added - Events. Machines can now define events as a logical grouping of transitions (patch by [@iurimatias](https://github.com/iurimatias)) - Retries. Individual transitions can be executed with a retry policy by wrapping the method call in a `Machine.retry_conflicts {}` block (patch by [@greysteil](https://github.com/greysteil)) ## v0.7.0, 25 June 2014 -*Additions* + +### Added - `Adapters::ActiveRecord` now handles `ActiveRecord::RecordNotUnique` errors explicitly and re-raises with a `Statesman::TransitionConflictError` if it is due to duplicate sort_keys (patch by [@greysteil](https://github.com/greysteil)) ## v0.6.1, 21 May 2014 -*Fixes* + +### Fixed + - Fixes an issue where the wrong transition was passed to after_transition callbacks for the second and subsequent transition of a given state machine (patch by [@alan](https://github.com/alan)) ## v0.6.0, 19 May 2014 -*Additions* + +### Added + - Generators now handle namespaced classes (patch by [@hrmrebecca](https://github.com/hrmrebecca)) -*Changes* +### Changed + - `Machine#transition_to` now only swallows Statesman generated errors. An exception in your guard or callback will no longer be caught by Statesman (patch by [@paulspringett](https://github.com/paulspringett)) ## v0.5.0, 27 March 2014 -*Additions* + +### Added + - Scope methods. Adds a module which can be mixed in to an ActiveRecord model to provide `.in_state` and `.not_in_state` query scopes. - Adds `Machine#after_initialize` hook (patch by [@att14](https://github.com/att14)) -*Fixes* +### Fixed + - Added MongoidTransition to the autoload statements, fixing [#29](https://github.com/gocardless/statesman/issues/29) (patch by [@tomclose](https://github.com/tomclose)) ## v0.4.0, 27 February 2014 -*Additions* + +### Added + - Adds after_commit flag to after_transition for callbacks to be executed after the transaction has been committed on the ActiveRecord adapter. These callbacks will still be executed on non transactional adapters. ## v0.3.0, 20 February 2014 -*Additions* + +### Added + - Adds Machine#allowed_transitions method (patch by [@prikha](https://github.com/prikha)) ## v0.2.1, 31 December 2013 -*Fixes* + +### Fixed + - Don't add attr_accessible to generated transition model if running in Rails 4 ## v0.2.0, 16 December 2013 -*Additions* + +### Added + - Adds Ruby 1.9.3 support (patch by [@jakehow](https://github.com/jakehow)) - All Mongo dependent tests are tagged so they can be excluded from test runs -*Changes* +### Changed + - Specs now crash immediately if Mongo is not running ## v0.1.0, 5 November 2013 -*Additions* +### Added + - Adds Mongoid adapter and generators (patch by [@dluxemburg](https://github.com/dluxemburg)) -*Changes* +### Changed + - Replaces `config#transition_class` with `Statesman::Adapters::ActiveRecordTransition` mixin. (inspired by [@cjbell88](https://github.com/cjbell88)) - Renames the active record transition generator from `statesman:transition` to `statesman:active_record_transition`. - Moves to using `require_relative` internally where possible to avoid stomping on application load paths. -## v0.0.1, 28 October 2013. +## v0.0.1, 28 October 2013 + - Initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ea98b89..5dac9af3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,11 @@ +# Contributing + Thanks for taking an interest in contributing to Statesman, here are a few ways you can help make this project better! -## Contributing +## Submitting pull requests -- Generally we welcome new features but please first open an issue where we +- Generally we welcome new features but please first open an issue where we can discuss whether it fits with our vision for the project. - Any new feature or bug fix needs an accompanying test case. - No need to add to the changelog, we will take care of updating it as we make @@ -17,23 +19,22 @@ request passes by running `rubocop`. ## Documentation -Please add a section to the readme for any new feature additions or behaviour -changes. +Please add a section to [the readme](README.md) for any new feature additions or behavioural changes. ## Releasing -We publish new versions of Stateman using [RubyGems](https://guides.rubygems.org/publishing/). Once -the relevant changes have been merged and `VERSION` has been appropriately bumped to the new -version, we run the following command. -``` -$ gem build statesman.gemspec +We publish new versions of Stateman using [RubyGems](https://guides.rubygems.org/publishing/). Once the relevant changes have been merged and `VERSION` has been appropriately bumped to the new version, we run the following command. + +```sh +gem build statesman.gemspec ``` -This builds a `.gem` file locally that will be named something like `statesman-X` where `X` is the -new version. For example, if we are releasing version 9.0.0, the file would be + +This builds a `.gem` file locally that will be named something like `statesman-X` where `X` is the new version. For example, if we are releasing version 9.0.0, the file would be `statesman-9.0.0.gem`. To publish, run `gem push` with the new `.gem` file we just generated. This requires a OTP that is currently only available to GoCardless engineers. For example, if we were to continue to publish version 9.0.0, we would run: -``` -$ gem push statesman-9.0.0.gem + +```sh +gem push statesman-9.0.0.gem ``` diff --git a/Gemfile b/Gemfile index f54f8ba0..a258f5dd 100644 --- a/Gemfile +++ b/Gemfile @@ -9,7 +9,8 @@ if ENV['RAILS_VERSION'] == 'main' elsif ENV['RAILS_VERSION'] gem "rails", "~> #{ENV['RAILS_VERSION']}" end + group :development do - # test/unit is no longer bundled with Ruby 2.2, but required by Rails - gem "test-unit", "~> 3.3" if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.2.0") + gem "pry" + gem "test-unit", "~> 3.3" end diff --git a/README.md b/README.md index 76ffeeec..600e20a3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ +

Statesman

+ A statesmanlike state machine library. @@ -8,7 +10,7 @@ For our policy on compatibility with Ruby and Rails versions, see [COMPATIBILITY [![CircleCI](https://circleci.com/gh/gocardless/statesman.svg?style=shield)](https://circleci.com/gh/gocardless/statesman) [![Code Climate](https://codeclimate.com/github/gocardless/statesman.svg)](https://codeclimate.com/github/gocardless/statesman) [![Gitter](https://badges.gitter.im/join.svg)](https://gitter.im/gocardless/statesman?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) -[![SemVer](https://api.dependabot.com/badges/compatibility_score?dependency-name=statesman&package-manager=bundler&version-scheme=semver)](https://dependabot.com/compatibility-score.html?dependency-name=statesman&package-manager=bundler&version-scheme=semver) +[![SemVer](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=statesman&package-manager=bundler&version-scheme=semver&previous-version=11.0.0&new-version=12.0.0)](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=statesman&package-manager=bundler&version-scheme=semver&previous-version=11.0.0&new-version=12.0.0) Statesman is an opinionated state machine library designed to provide a robust audit trail and data integrity. It decouples the state machine logic from the @@ -16,6 +18,7 @@ underlying model and allows for easy composition with one or more model classes. As such, the design of statesman is a little different from other state machine libraries: + - State behaviour is defined in a separate, "state machine" class, rather than added directly onto a model. State machines are then instantiated with the model to which they should apply. @@ -30,7 +33,7 @@ protection. To get started, just add Statesman to your `Gemfile`, and then run `bundle`: ```ruby -gem 'statesman', '~> 10.0.0' +gem 'statesman', '~> 12.0.0' ``` ## Usage @@ -136,7 +139,7 @@ end class Circular include Statesman::Machine extend Template - + define_states define_transitions end @@ -144,10 +147,10 @@ end class Linear include Statesman::Machine extend Template - + define_states define_transitions - + remove_transitions from: :c, to: :a end @@ -179,7 +182,7 @@ end Generate the transition model: ```bash -$ rails g statesman:active_record_transition Order OrderTransition +rails g statesman:active_record_transition Order OrderTransition ``` Your transition class should @@ -212,13 +215,14 @@ class Order < ActiveRecord::Base :transition_to!, :transition_to, :in_state?, to: :state_machine end ``` -#### Using PostgreSQL JSON column + +### Using PostgreSQL JSON column By default, Statesman uses `serialize` to store the metadata in JSON format. It is also possible to use the PostgreSQL JSON column if you are using Rails 4 or 5. To do that -* Change `metadata` column type in the transition model migration to `json` or `jsonb` +- Change `metadata` column type in the transition model migration to `json` or `jsonb` ```ruby # Before @@ -229,7 +233,7 @@ or 5. To do that t.json :metadata, default: {} ``` -* Remove the `include Statesman::Adapters::ActiveRecordTransition` statement from +- Remove the `include Statesman::Adapters::ActiveRecordTransition` statement from your transition model. (If you want to customise your transition class's "updated timestamp column", as described above, you should define a `.updated_timestamp_column` method on your class and return the name of the column @@ -238,63 +242,73 @@ or 5. To do that ## Configuration -#### `storage_adapter` +### `storage_adapter` ```ruby Statesman.configure do storage_adapter(Statesman::Adapters::ActiveRecord) end ``` + Statesman defaults to storing transitions in memory. If you're using rails, you can instead configure it to persist transitions to the database by using the ActiveRecord adapter. Statesman will fallback to memory unless you specify a transition_class when instantiating your state machine. This allows you to only persist transitions on certain state machines in your app. - ## Class methods -#### `Machine.state` +### `Machine.state` + ```ruby Machine.state(:some_state, initial: true) Machine.state(:another_state) ``` + Define a new state and optionally mark as the initial state. -#### `Machine.transition` +### `Machine.transition` + ```ruby Machine.transition(from: :some_state, to: :another_state) ``` + Define a transition rule. Both method parameters are required, `to` can also be an array of states (`.transition(from: :some_state, to: [:another_state, :some_other_state])`). -#### `Machine.guard_transition` +### `Machine.guard_transition` + ```ruby Machine.guard_transition(from: :some_state, to: :another_state) do |object| object.some_boolean? end ``` + Define a guard. `to` and `from` parameters are optional, a nil parameter means guard all transitions. The passed block should evaluate to a boolean and must be idempotent as it could be called many times. The guard will pass when it evaluates to a truthy value and fail when it evaluates to a falsey value (`nil` or `false`). -#### `Machine.before_transition` +### `Machine.before_transition` + ```ruby Machine.before_transition(from: :some_state, to: :another_state) do |object| object.side_effect end ``` + Define a callback to run before a transition. `to` and `from` parameters are optional, a nil parameter means run before all transitions. This callback can have side-effects as it will only be run once immediately before the transition. -#### `Machine.after_transition` +### `Machine.after_transition` + ```ruby Machine.after_transition(from: :some_state, to: :another_state) do |object, transition| object.side_effect end ``` + Define a callback to run after a successful transition. `to` and `from` parameters are optional, a nil parameter means run after all transitions. The model object and transition object are passed as arguments to the callback. @@ -304,12 +318,14 @@ after the transition. If you specify `after_commit: true`, the callback will be executed once the transition has been committed to the database. -#### `Machine.after_transition_failure` +### `Machine.after_transition_failure` + ```ruby Machine.after_transition_failure(from: :some_state, to: :another_state) do |object, exception| Logger.info("transition to #{exception.to} failed for #{object.id}") end ``` + Define a callback to run if `Statesman::TransitionFailedError` is raised during the execution of transition callbacks. `to` and `from` parameters are optional, a nil parameter means run after all transitions. @@ -318,12 +334,14 @@ This is executed outside of the transaction wrapping other callbacks. If using `transition!` the exception is re-raised after these callbacks are executed. -#### `Machine.after_guard_failure` +### `Machine.after_guard_failure` + ```ruby Machine.after_guard_failure(from: :some_state, to: :another_state) do |object, exception| Logger.info("guard failed during transition to #{exception.to} for #{object.id}") end ``` + Define a callback to run if `Statesman::GuardFailedError` is raised during the execution of guard callbacks. `to` and `from` parameters are optional, a nil parameter means run after all transitions. @@ -332,29 +350,35 @@ This is executed outside of the transaction wrapping other callbacks. If using `transition!` the exception is re-raised after these callbacks are executed. +### `Machine.new` -#### `Machine.new` ```ruby my_machine = Machine.new(my_model, transition_class: MyTransitionModel) ``` + Initialize a new state machine instance. `my_model` is required. If using the ActiveRecord adapter `my_model` should have a `has_many` association with `MyTransitionModel`. -#### `Machine.retry_conflicts` +### `Machine.retry_conflicts` + ```ruby Machine.retry_conflicts { instance.transition_to(:new_state) } ``` + Automatically retry the given block if a `TransitionConflictError` is raised. If you know you want to retry a transition if it fails due to a race condition call it from within this block. Takes an (optional) argument for the maximum number of retry attempts (defaults to 1). -#### `Machine.states` +### `Machine.states` + Returns an array of all possible state names as strings. -#### `Machine.successors` +### `Machine.successors` + Returns a hash of states and the states it is valid for them to transition to. + ```ruby Machine.successors @@ -368,100 +392,129 @@ Machine.successors ## Instance methods -#### `Machine#current_state` +### `Machine#current_state` + Returns the current state based on existing transition objects. Takes an optional keyword argument to force a reload of data from the database. e.g `current_state(force_reload: true)` -#### `Machine#in_state?(:state_1, :state_2, ...)` +### `Machine#in_state?(:state_1, :state_2, ...)` + Returns true if the machine is in any of the given states. -#### `Machine#history` +### `Machine#history` + Returns a sorted array of all transition objects. -#### `Machine#last_transition` +### `Machine#last_transition` + Returns the most recent transition object. -#### `Machine#last_transition_to(:state)` +### `Machine#last_transition_to(:state)` + Returns the most recent transition object to a given state. -#### `Machine#allowed_transitions` +### `Machine#allowed_transitions` + Returns an array of states you can `transition_to` from current state. -#### `Machine#can_transition_to?(:state)` +### `Machine#can_transition_to?(:state)` + Returns true if the current state can transition to the passed state and all applicable guards pass. -#### `Machine#transition_to!(:state)` +### `Machine#transition_to!(:state)` + Transition to the passed state, returning `true` on success. Raises `Statesman::GuardFailedError` or `Statesman::TransitionFailedError` on failure. -#### `Machine#transition_to(:state)` +### `Machine#transition_to(:state)` + Transition to the passed state, returning `true` on success. Swallows all Statesman exceptions and returns false on failure. (NB. if your guard or callback code throws an exception, it will not be caught.) - ## Errors ### Initialization errors + These errors are raised when the Machine and/or Model is initialized. A simple spec like + ```ruby expect { OrderStateMachine.new(Order.new, transition_class: OrderTransition) }.to_not raise_error ``` + will expose these errors as part of your test suite #### InvalidStateError + Raised if: - * Attempting to define a transition without a `to` state. - * Attempting to define a transition with a non-existent state. - * Attempting to define multiple states as `initial`. + +- Attempting to define a transition without a `to` state. +- Attempting to define a transition with a non-existent state. +- Attempting to define multiple states as `initial`. #### InvalidTransitionError + Raised if: - * Attempting to define a callback `from` a state that has no valid transitions (A terminal state). - * Attempting to define a callback `to` the `initial` state if that state has no transitions to it. - * Attempting to define a callback with `from` and `to` where any of the pairs have no transition between them. + +- Attempting to define a callback `from` a state that has no valid transitions (A terminal state). +- Attempting to define a callback `to` the `initial` state if that state has no transitions to it. +- Attempting to define a callback with `from` and `to` where any of the pairs have no transition between them. #### InvalidCallbackError + Raised if: - * Attempting to define a callback without a block. + +- Attempting to define a callback without a block. #### UnserializedMetadataError + Raised if: - * ActiveRecord is configured to not serialize the `metadata` attribute into - to Database column backing it. See the `Using PostgreSQL JSON column` section. + +- ActiveRecord is configured to not serialize the `metadata` attribute into + to Database column backing it. See the `Using PostgreSQL JSON column` section. #### IncompatibleSerializationError + Raised if: - * There is a mismatch between the column type of the `metadata` in the - Database and the model. See the `Using PostgreSQL JSON column` section. + +- There is a mismatch between the column type of the `metadata` in the + Database and the model. See the `Using PostgreSQL JSON column` section. #### MissingTransitionAssociation + Raised if: - * The model that `Statesman::Adapters::ActiveRecordQueries` is included in - does not have a `has_many` association to the `transition_class`. + +- The model that `Statesman::Adapters::ActiveRecordQueries` is included in + does not have a `has_many` association to the `transition_class`. ### Runtime errors + These errors are raised by `transition_to!`. Using `transition_to` will supress `GuardFailedError` and `TransitionFailedError` and return `false` instead. #### GuardFailedError + Raised if: - * A guard callback between `from` and `to` state returned a falsey value. + +- A guard callback between `from` and `to` state returned a falsey value. #### TransitionFailedError + Raised if: - * A transition is attempted but `current_state -> new_state` is not a valid pair. + +- A transition is attempted but `current_state -> new_state` is not a valid pair. #### TransitionConflictError + Raised if: - * A database conflict affecting the `sort_key` or `most_recent` columns occurs - when attempting a transition. - Retried automatically if it occurs wrapped in `retry_conflicts`. +- A database conflict affecting the `sort_key` or `most_recent` columns occurs + when attempting a transition. + Retried automatically if it occurs wrapped in `retry_conflicts`. ## Model scopes @@ -498,14 +551,16 @@ class Order < ActiveRecord::Base end ``` -#### `Model.in_state(:state_1, :state_2, etc)` +### `Model.in_state(:state_1, :state_2, etc)` + Returns all models currently in any of the supplied states. -#### `Model.not_in_state(:state_1, :state_2, etc)` +### `Model.not_in_state(:state_1, :state_2, etc)` + Returns all models not currently in any of the supplied states. +### `Model.most_recent_transition_join` -#### `Model.most_recent_transition_join` This joins the model to its most recent transition whatever that may be. We expose this method to ease use of ActiveRecord's `or` e.g @@ -517,7 +572,7 @@ Model.in_state(:state_1).or( ## Frequently Asked Questions -#### Storing the state on the model object +### Storing the state on the model object If you wish to store the model state on the model directly, you can keep it up to date using an `after_transition` hook. @@ -533,7 +588,7 @@ end You could also use a calculated column or view in your database. -#### Accessing metadata from the last transition +### Accessing metadata from the last transition Given a field `foo` that was stored in the metadata, you can access it like so: @@ -541,7 +596,7 @@ Given a field `foo` that was stored in the metadata, you can access it like so: model_instance.state_machine.last_transition.metadata["foo"] ``` -#### Events +### Events Used to using a state machine with "events"? Support for events is provided by the [statesman-events](https://github.com/gocardless/statesman-events) gem. Once @@ -557,31 +612,34 @@ class OrderStateMachine end ``` -#### Deleting records. +### Deleting records If you need to delete the Parent model regularly you will need to change either the association deletion behaviour or add a `DELETE CASCADE` condition to foreign key in your database. E.g -``` + +```ruby has_many :order_transitions, autosave: false, dependent: :destroy ``` + or when migrating the transition model -``` + +```ruby add_foreign_key :order_transitions, :orders, on_delete: :cascade ``` - ## Testing Statesman Implementations This answer was abstracted from [this issue](https://github.com/gocardless/statesman/issues/77). At GoCardless we focus on testing that: + - guards correctly prevent / allow transitions - callbacks execute when expected and perform the expected actions -#### Testing Guards +### Testing Guards Guards can be tested by asserting that `transition_to!` does or does not raise a `Statesman::GuardFailedError`: @@ -597,7 +655,7 @@ describe "guards" do end ``` -#### Testing Callbacks +### Testing Callbacks Callbacks are tested by asserting that the action they perform occurs: @@ -611,6 +669,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/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index ae224b37..fd76e626 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -4,11 +4,11 @@ Our goal as Statesman maintainers is for the library to be compatible with all s Specifically, any CRuby/MRI version that has not received an End of Life notice ([e.g. this notice for Ruby 2.1](https://www.ruby-lang.org/en/news/2017/04/01/support-of-ruby-2-1-has-ended/)) is supported. Similarly, any version of Rails listed as currently supported on [this page](http://guides.rubyonrails.org/maintenance_policy.html) is one we aim to support in Statesman. -To that end, [our build matrix](../.circleci/config.yml) includes all these versions. +To that end, [our build matrix](../.github/workflows/tests.yml) includes all these versions. Any time Statesman doesn't work on a supported combination of Ruby and Rails, it's a bug, and can be reported [here](https://github.com/gocardless/statesman/issues). -# Deprecation +## Deprecation Whenever a version of Ruby or Rails falls out of support, we will mirror that change in Statesman by updating the build matrix and releasing a new major version. diff --git a/lib/generators/statesman/generator_helpers.rb b/lib/generators/statesman/generator_helpers.rb index b4dce024..b6c1a546 100644 --- a/lib/generators/statesman/generator_helpers.rb +++ b/lib/generators/statesman/generator_helpers.rb @@ -11,7 +11,7 @@ def model_file_name end def migration_class_name - klass.gsub(/::/, "").pluralize + klass.gsub("::", "").pluralize end def next_migration_number @@ -52,7 +52,7 @@ def configuration end def database_supports_partial_indexes? - Statesman::Adapters::ActiveRecord.database_supports_partial_indexes? + Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(klass.constantize) end def metadata_default_value diff --git a/lib/statesman.rb b/lib/statesman.rb index b11d6984..cf9fe346 100644 --- a/lib/statesman.rb +++ b/lib/statesman.rb @@ -6,7 +6,7 @@ module Statesman autoload :Callback, "statesman/callback" autoload :Guard, "statesman/guard" autoload :Utils, "statesman/utils" - autoload :Version, "statesman/version" + autoload :VERSION, "statesman/version" module Adapters autoload :Memory, "statesman/adapters/memory" autoload :ActiveRecord, "statesman/adapters/active_record" @@ -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) @@ -32,10 +34,8 @@ def self.storage_adapter @storage_adapter || Adapters::Memory end - def self.mysql_gaplock_protection? - return @mysql_gaplock_protection unless @mysql_gaplock_protection.nil? - - @mysql_gaplock_protection = config.mysql_gaplock_protection? + def self.mysql_gaplock_protection?(connection) + config.mysql_gaplock_protection?(connection) end def self.config diff --git a/lib/statesman/adapters/active_record.rb b/lib/statesman/adapters/active_record.rb index 63f1fadf..36d15b65 100644 --- a/lib/statesman/adapters/active_record.rb +++ b/lib/statesman/adapters/active_record.rb @@ -7,19 +7,15 @@ module Adapters class ActiveRecord JSON_COLUMN_TYPES = %w[json jsonb].freeze - def self.database_supports_partial_indexes? + def self.database_supports_partial_indexes?(model) # Rails 3 doesn't implement `supports_partial_index?` - if ::ActiveRecord::Base.connection.respond_to?(:supports_partial_index?) - ::ActiveRecord::Base.connection.supports_partial_index? + if model.connection.respond_to?(:supports_partial_index?) + model.connection.supports_partial_index? else - ::ActiveRecord::Base.connection.adapter_name == "PostgreSQL" + model.connection.adapter_name.casecmp("postgresql").zero? end end - def self.adapter_name - ::ActiveRecord::Base.connection.adapter_name.downcase - end - def initialize(transition_class, parent_model, observer, options = {}) serialized = serialized?(transition_class) column_type = transition_class.columns_hash["metadata"].sql_type @@ -52,7 +48,7 @@ def create(from, to, metadata = {}) raise ensure - @last_transition = nil + reset end def history(force_reload: false) @@ -65,18 +61,20 @@ def history(force_reload: false) end end - # rubocop:disable Naming/MemoizedInstanceVariableName def last(force_reload: false) if force_reload @last_transition = history(force_reload: true).last + elsif instance_variable_defined?(:@last_transition) + @last_transition else - @last_transition ||= history.last + @last_transition = history.last end end - # rubocop:enable Naming/MemoizedInstanceVariableName def reset - @last_transition = nil + if instance_variable_defined?(:@last_transition) + remove_instance_variable(:@last_transition) + end end private @@ -86,10 +84,10 @@ def create_transition(from, to, metadata) default_transition_attributes(from, to, metadata), ) - ::ActiveRecord::Base.transaction(requires_new: true) do + transition_class.transaction(requires_new: true) do @observer.execute(:before, from, to, transition) - if mysql_gaplock_protection? + if mysql_gaplock_protection?(transition_class.connection) # We save the transition first with most_recent falsy, then mark most_recent # true after to avoid letting MySQL acquire a next-key lock which can cause # deadlocks. @@ -134,8 +132,8 @@ def default_transition_attributes(from, to, metadata) end def add_after_commit_callback(from, to, transition) - ::ActiveRecord::Base.connection.add_transaction_record( - ActiveRecordAfterCommitWrap.new do + transition_class.connection.add_transaction_record( + ActiveRecordAfterCommitWrap.new(transition_class.connection) do @observer.execute(:after_commit, from, to, transition) end, ) @@ -148,7 +146,7 @@ def transitions_for_parent # Sets the given transition most_recent = t while unsetting the most_recent of any # previous transitions. def update_most_recents(most_recent_id = nil) - update = build_arel_manager(::Arel::UpdateManager) + update = build_arel_manager(::Arel::UpdateManager, transition_class) update.table(transition_table) update.where(most_recent_transitions(most_recent_id)) update.set(build_most_recents_update_all_values(most_recent_id)) @@ -156,20 +154,33 @@ def update_most_recents(most_recent_id = nil) # MySQL will validate index constraints across the intermediate result of an # update. This means we must order our update to deactivate the previous # most_recent before setting the new row to be true. - update.order(transition_table[:most_recent].desc) if mysql_gaplock_protection? + if mysql_gaplock_protection?(transition_class.connection) + update.order(transition_table[:most_recent].desc) + end - ::ActiveRecord::Base.connection.update(update.to_sql) + transition_class.connection.update(update.to_sql(transition_class)) end def most_recent_transitions(most_recent_id = nil) if most_recent_id - transitions_of_parent.and( + concrete_transitions_of_parent.and( transition_table[:id].eq(most_recent_id).or( transition_table[:most_recent].eq(true), ), ) else - transitions_of_parent.and(transition_table[:most_recent].eq(true)) + concrete_transitions_of_parent.and(transition_table[:most_recent].eq(true)) + end + end + + def concrete_transitions_of_parent + if transition_sti? + transitions_of_parent.and( + transition_table[transition_class.inheritance_column]. + eq(transition_class.name), + ) + else + transitions_of_parent end end @@ -216,7 +227,7 @@ def most_recent_value(most_recent_id) if most_recent_id Arel::Nodes::Case.new. when(transition_table[:id].eq(most_recent_id)).then(db_true). - else(not_most_recent_value).to_sql + else(not_most_recent_value).to_sql(transition_class) else Arel::Nodes::SqlLiteral.new(not_most_recent_value) end @@ -226,11 +237,11 @@ def most_recent_value(most_recent_id) # change in Arel as we move into Rails >6.0. # # https://github.com/rails/rails/commit/7508284800f67b4611c767bff9eae7045674b66f - def build_arel_manager(manager) + def build_arel_manager(manager, engine) if manager.instance_method(:initialize).arity.zero? manager.new else - manager.new(::ActiveRecord::Base) + manager.new(engine) end end @@ -239,13 +250,8 @@ def next_sort_key end def serialized?(transition_class) - if ::ActiveRecord.respond_to?(:gem_version) && - ::ActiveRecord.gem_version >= Gem::Version.new("4.2.0.a") - transition_class.type_for_attribute("metadata"). - is_a?(::ActiveRecord::Type::Serialized) - else - transition_class.serialized_attributes.include?("metadata") - end + transition_class.type_for_attribute("metadata"). + is_a?(::ActiveRecord::Type::Serialized) end def transition_conflict_error?(err) @@ -256,7 +262,7 @@ def transition_conflict_error?(err) end def unique_indexes - ::ActiveRecord::Base.connection. + transition_class.connection. indexes(transition_class.table_name). select do |index| next unless index.unique @@ -268,13 +274,18 @@ def unique_indexes end end - def parent_join_foreign_key - association = - parent_model.class. - reflect_on_all_associations(:has_many). - find { |r| r.name.to_s == @association_name.to_s } + def transition_sti? + transition_class.column_names.include?(transition_class.inheritance_column) + end + + def parent_association + parent_model.class. + reflect_on_all_associations(:has_many). + find { |r| r.name.to_s == @association_name.to_s } + end - association_join_primary_key(association) + def parent_join_foreign_key + association_join_primary_key(parent_association) end def association_join_primary_key(association) @@ -322,16 +333,16 @@ def default_timezone ::ActiveRecord::Base.default_timezone end - def mysql_gaplock_protection? - Statesman.mysql_gaplock_protection? + def mysql_gaplock_protection?(connection) + Statesman.mysql_gaplock_protection?(connection) end def db_true - ::ActiveRecord::Base.connection.quote(type_cast(true)) + transition_class.connection.quote(type_cast(true)) end def db_false - ::ActiveRecord::Base.connection.quote(type_cast(false)) + transition_class.connection.quote(type_cast(false)) end def db_null @@ -341,7 +352,7 @@ def db_null # Type casting against a column is deprecated and will be removed in Rails 6.2. # See https://github.com/rails/arel/commit/6160bfbda1d1781c3b08a33ec4955f170e95be11 def type_cast(value) - ::ActiveRecord::Base.connection.type_cast(value) + transition_class.connection.type_cast(value) end # Check whether the `most_recent` column allows null values. If it doesn't, set old @@ -361,9 +372,9 @@ def not_most_recent_value(db_cast: true) end class ActiveRecordAfterCommitWrap - def initialize(&block) + def initialize(connection, &block) @callback = block - @connection = ::ActiveRecord::Base.connection + @connection = connection end def self.trigger_transactional_callbacks? diff --git a/lib/statesman/adapters/active_record_queries.rb b/lib/statesman/adapters/active_record_queries.rb index 0a1f0187..6f486457 100644 --- a/lib/statesman/adapters/active_record_queries.rb +++ b/lib/statesman/adapters/active_record_queries.rb @@ -39,7 +39,7 @@ def initialize(**args) end def included(base) - ensure_inheritance(base) + ensure_inheritance(base) if base.respond_to?(:subclasses) && base.subclasses.any? query_builder = QueryBuilder.new(base, **@args) @@ -49,6 +49,14 @@ def included(base) define_in_state(base, query_builder) define_not_in_state(base, query_builder) + + define_method(:reload) do |*a| + instance = super(*a) + if instance.respond_to?(:state_machine, true) + instance.send(:state_machine).reset + end + instance + end end private @@ -145,7 +153,7 @@ def most_recent_transition_alias end def db_true - ::ActiveRecord::Base.connection.quote(true) + model.connection.quote(true) end end end diff --git a/lib/statesman/adapters/active_record_transition.rb b/lib/statesman/adapters/active_record_transition.rb index 5560d19d..2c4c0ba4 100644 --- a/lib/statesman/adapters/active_record_transition.rb +++ b/lib/statesman/adapters/active_record_transition.rb @@ -10,7 +10,11 @@ module ActiveRecordTransition extend ActiveSupport::Concern included do - serialize :metadata, JSON + if ::ActiveRecord.gem_version >= Gem::Version.new("7.1") + serialize :metadata, coder: JSON + else + serialize :metadata, JSON + end class_attribute :updated_timestamp_column self.updated_timestamp_column = DEFAULT_UPDATED_TIMESTAMP_COLUMN 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/callback.rb b/lib/statesman/callback.rb index 9c0623ea..5d9f76c1 100644 --- a/lib/statesman/callback.rb +++ b/lib/statesman/callback.rb @@ -40,11 +40,11 @@ def matches_all_transitions end def matches_from_state(from, to) - (from == self.from && (to.nil? || self.to.empty?)) + from == self.from && (to.nil? || self.to.empty?) end def matches_to_state(from, to) - ((from.nil? || self.from.nil?) && self.to.include?(to)) + (from.nil? || self.from.nil?) && self.to.include?(to) end def matches_both_states(from, to) diff --git a/lib/statesman/config.rb b/lib/statesman/config.rb index c18bcf1c..beaea8cb 100644 --- a/lib/statesman/config.rb +++ b/lib/statesman/config.rb @@ -15,17 +15,10 @@ def storage_adapter(adapter_class) @adapter_class = adapter_class end - def mysql_gaplock_protection? - return @mysql_gaplock_protection unless @mysql_gaplock_protection.nil? - + def mysql_gaplock_protection?(connection) # If our adapter class suggests we're using mysql, enable gaplock protection by # default. - enable_mysql_gaplock_protection if mysql_adapter?(adapter_class) - @mysql_gaplock_protection - end - - def enable_mysql_gaplock_protection - @mysql_gaplock_protection = true + mysql_adapter?(connection) end private @@ -34,7 +27,7 @@ def mysql_adapter?(adapter_class) adapter_name = adapter_name(adapter_class) return false unless adapter_name - adapter_name.start_with?("mysql") + adapter_name.downcase.start_with?("mysql", "trilogy") end def adapter_name(adapter_class) 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..61cd55c1 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 = "12.1.0" end diff --git a/lib/tasks/statesman.rake b/lib/tasks/statesman.rake index 47fa738b..94636869 100644 --- a/lib/tasks/statesman.rake +++ b/lib/tasks/statesman.rake @@ -21,8 +21,8 @@ namespace :statesman do batch_size = 500 parent_class.find_in_batches(batch_size: batch_size) do |models| - ActiveRecord::Base.transaction(requires_new: true) do - if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes? + transition_class.transaction(requires_new: true) do + if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(transition_class) # Set all transitions' most_recent to FALSE transition_class.where(parent_fk => models.map(&:id)). update_all(most_recent: false, updated_at: updated_at) diff --git a/spec/generators/statesman/active_record_transition_generator_spec.rb b/spec/generators/statesman/active_record_transition_generator_spec.rb index 68431391..860cbb6d 100644 --- a/spec/generators/statesman/active_record_transition_generator_spec.rb +++ b/spec/generators/statesman/active_record_transition_generator_spec.rb @@ -1,10 +1,16 @@ # frozen_string_literal: true -require "spec_helper" require "support/generators_shared_examples" require "generators/statesman/active_record_transition_generator" describe Statesman::ActiveRecordTransitionGenerator, type: :generator do + before do + stub_const("Bacon", Class.new(ActiveRecord::Base)) + stub_const("BaconTransition", Class.new(ActiveRecord::Base)) + stub_const("Yummy::Bacon", Class.new(ActiveRecord::Base)) + stub_const("Yummy::BaconTransition", Class.new(ActiveRecord::Base)) + end + it_behaves_like "a generator" do let(:migration_name) { "db/migrate/create_bacon_transitions.rb" } end diff --git a/spec/generators/statesman/migration_generator_spec.rb b/spec/generators/statesman/migration_generator_spec.rb index 03966bae..410e098e 100644 --- a/spec/generators/statesman/migration_generator_spec.rb +++ b/spec/generators/statesman/migration_generator_spec.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true -require "spec_helper" require "support/generators_shared_examples" require "generators/statesman/migration_generator" describe Statesman::MigrationGenerator, type: :generator do + before do + stub_const("Yummy::Bacon", Class.new(ActiveRecord::Base)) + stub_const("Yummy::BaconTransition", Class.new(ActiveRecord::Base)) + end + it_behaves_like "a generator" do let(:migration_name) { "db/migrate/add_statesman_to_bacon_transitions.rb" } end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3ea09693..7723ecf3 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,13 +5,14 @@ require "mysql2" require "pg" require "active_record" +require "active_record/database_configurations" # We have to include all of Rails to make rspec-rails work require "rails" require "action_view" require "action_dispatch" require "action_controller" require "rspec/rails" -require "support/active_record" +require "support/exactly_query_databases" require "rspec/its" require "pry" @@ -28,10 +29,31 @@ def connection_failure if config.exclusion_filter[:active_record] puts "Skipping ActiveRecord tests" else - # Connect to the database for activerecord tests - db_conn_spec = ENV["DATABASE_URL"] - db_conn_spec ||= { adapter: "sqlite3", database: ":memory:" } - ActiveRecord::Base.establish_connection(db_conn_spec) + current_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call + + # We have to parse this to a hash since ActiveRecord::Base.configurations + # will only consider a single URL config. + url_config = if ENV["DATABASE_URL"] + ActiveRecord::DatabaseConfigurations::ConnectionUrlResolver. + new(ENV["DATABASE_URL"]).to_hash.merge({ sslmode: "disable" }) + end + + db_config = { + current_env => { + primary: url_config || { + adapter: "sqlite3", + database: "/tmp/statesman.db", + }, + secondary: url_config || { + adapter: "sqlite3", + database: "/tmp/statesman.db", + }, + }, + } + + # Connect to the primary database for activerecord tests. + ActiveRecord::Base.configurations = db_config + ActiveRecord::Base.establish_connection(:primary) db_adapter = ActiveRecord::Base.connection.adapter_name puts "Running with database adapter '#{db_adapter}'" @@ -40,7 +62,9 @@ def connection_failure ActiveRecord::Migration.verbose = false end - config.before(:each, active_record: true) do + # Since our primary and secondary connections point to the same database, we don't + # need to worry about applying these actions to both. + config.before(:each, :active_record) do tables = %w[ my_active_record_models my_active_record_model_transitions @@ -48,9 +72,12 @@ def connection_failure my_namespace_my_active_record_model_transitions other_active_record_models other_active_record_model_transitions + sti_active_record_models + sti_active_record_model_transitions ] tables.each do |table_name| sql = "DROP TABLE IF EXISTS #{table_name};" + ActiveRecord::Base.connection.execute(sql) end @@ -72,6 +99,16 @@ def prepare_other_transitions_table OtherActiveRecordModelTransition.reset_column_information end - MyNamespace::MyActiveRecordModelTransition.serialize(:metadata, JSON) + def prepare_sti_model_table + CreateStiActiveRecordModelMigration.migrate(:up) + end + + def prepare_sti_transitions_table + CreateStiActiveRecordModelTransitionMigration.migrate(:up) + StiActiveRecordModelTransition.reset_column_information + end end end + +# We have to require this after the databases are configured. +require "support/active_record" diff --git a/spec/statesman/adapters/active_record_queries_spec.rb b/spec/statesman/adapters/active_record_queries_spec.rb index d8fa21d5..9ca8227a 100644 --- a/spec/statesman/adapters/active_record_queries_spec.rb +++ b/spec/statesman/adapters/active_record_queries_spec.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -require "spec_helper" - -describe Statesman::Adapters::ActiveRecordQueries, active_record: true do +describe Statesman::Adapters::ActiveRecordQueries, :active_record do def configure_old(klass, transition_class) klass.define_singleton_method(:transition_class) { transition_class } klass.define_singleton_method(:initial_state) { :initial } @@ -117,8 +115,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 +124,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/active_record_spec.rb b/spec/statesman/adapters/active_record_spec.rb index 3dbeb5b6..8beb7cc6 100644 --- a/spec/statesman/adapters/active_record_spec.rb +++ b/spec/statesman/adapters/active_record_spec.rb @@ -1,16 +1,16 @@ # frozen_string_literal: true -require "spec_helper" require "timecop" require "statesman/adapters/shared_examples" require "statesman/exceptions" -describe Statesman::Adapters::ActiveRecord, active_record: true do +describe Statesman::Adapters::ActiveRecord, :active_record do before do prepare_model_table prepare_transitions_table - MyActiveRecordModelTransition.serialize(:metadata, JSON) + prepare_sti_model_table + prepare_sti_transitions_table Statesman.configure do # Rubocop requires described_class to be used, but this block @@ -23,8 +23,10 @@ after { Statesman.configure { storage_adapter(Statesman::Adapters::Memory) } } + let(:model_class) { MyActiveRecordModel } + let(:transition_class) { MyActiveRecordModelTransition } let(:observer) { double(Statesman::Machine, execute: nil) } - let(:model) { MyActiveRecordModel.create(current_state: :pending) } + let(:model) { model_class.create(current_state: :pending) } it_behaves_like "an adapter", described_class, MyActiveRecordModelTransition @@ -33,17 +35,11 @@ before do metadata_column = double allow(metadata_column).to receive_messages(sql_type: "") - allow(MyActiveRecordModelTransition).to receive_messages(columns_hash: - { "metadata" => metadata_column }) - if ::ActiveRecord.respond_to?(:gem_version) && - ::ActiveRecord.gem_version >= Gem::Version.new("4.2.0.a") - expect(MyActiveRecordModelTransition). - to receive(:type_for_attribute).with("metadata"). - and_return(ActiveRecord::Type::Value.new) - else - expect(MyActiveRecordModelTransition). - to receive_messages(serialized_attributes: {}) - end + allow(MyActiveRecordModelTransition). + to receive_messages(columns_hash: { "metadata" => metadata_column }) + expect(MyActiveRecordModelTransition). + to receive(:type_for_attribute).with("metadata"). + and_return(ActiveRecord::Type::Value.new) end it "raises an exception" do @@ -60,10 +56,10 @@ allow(metadata_column).to receive_messages(sql_type: "json") allow(MyActiveRecordModelTransition).to receive_messages(columns_hash: { "metadata" => metadata_column }) - if ::ActiveRecord.respond_to?(:gem_version) && - ::ActiveRecord.gem_version >= Gem::Version.new("4.2.0.a") - serialized_type = ::ActiveRecord::Type::Serialized.new( - "", ::ActiveRecord::Coders::JSON + if ActiveRecord.respond_to?(:gem_version) && + ActiveRecord.gem_version >= Gem::Version.new("4.2.0.a") + serialized_type = ActiveRecord::Type::Serialized.new( + "", ActiveRecord::Coders::JSON ) expect(MyActiveRecordModelTransition). to receive(:type_for_attribute).with("metadata"). @@ -88,18 +84,12 @@ allow(metadata_column).to receive_messages(sql_type: "jsonb") allow(MyActiveRecordModelTransition).to receive_messages(columns_hash: { "metadata" => metadata_column }) - if ::ActiveRecord.respond_to?(:gem_version) && - ::ActiveRecord.gem_version >= Gem::Version.new("4.2.0.a") - serialized_type = ::ActiveRecord::Type::Serialized.new( - "", ::ActiveRecord::Coders::JSON - ) - expect(MyActiveRecordModelTransition). - to receive(:type_for_attribute).with("metadata"). - and_return(serialized_type) - else - expect(MyActiveRecordModelTransition). - to receive_messages(serialized_attributes: { "metadata" => "" }) - end + serialized_type = ActiveRecord::Type::Serialized.new( + "", ActiveRecord::Coders::JSON + ) + expect(MyActiveRecordModelTransition). + to receive(:type_for_attribute).with("metadata"). + and_return(serialized_type) end it "raises an exception" do @@ -112,15 +102,17 @@ end describe "#create" do - subject { -> { create } } + subject(:transition) { create } - let!(:adapter) do - described_class.new(MyActiveRecordModelTransition, model, observer) - end + let!(:adapter) { described_class.new(transition_class, model, observer) } let(:from) { :x } let(:to) { :y } let(:create) { adapter.create(from, to) } + it "only connects to the primary database" do + expect { create }.to exactly_query_databases({ primary: [:writing] }) + end + context "when there is a race" do it "raises a TransitionConflictError" do adapter2 = adapter.dup @@ -128,7 +120,8 @@ adapter.last adapter2.create(:y, :z) expect { adapter.create(:y, :z) }. - to raise_exception(Statesman::TransitionConflictError) + to raise_exception(Statesman::TransitionConflictError). + and exactly_query_databases({ primary: [:writing] }) end it "does not pollute the state when the transition fails" do @@ -165,27 +158,25 @@ context "ActiveRecord::RecordNotUnique unrelated to this transition" do let(:error) do - if ::ActiveRecord.respond_to?(:gem_version) && - ::ActiveRecord.gem_version >= Gem::Version.new("4.0.0") + if ActiveRecord.respond_to?(:gem_version) && + ActiveRecord.gem_version >= Gem::Version.new("4.0.0") ActiveRecord::RecordNotUnique.new("unrelated") else ActiveRecord::RecordNotUnique.new("unrelated", nil) end end - it { is_expected.to raise_exception(ActiveRecord::RecordNotUnique) } + it { expect { transition }.to raise_exception(ActiveRecord::RecordNotUnique) } end context "other errors" do let(:error) { StandardError } - it { is_expected.to raise_exception(StandardError) } + it { expect { transition }.to raise_exception(StandardError) } end end describe "updating the most_recent column" do - subject { create } - context "with no previous transition" do its(:most_recent) { is_expected.to eq(true) } end @@ -302,17 +293,90 @@ from(true).to be_falsey end end + + context "when transition uses STI" do + let(:sti_model) { StiActiveRecordModel.create } + + let(:adapter_a) do + described_class.new( + StiAActiveRecordModelTransition, + sti_model, + observer, + { association_name: :sti_a_active_record_model_transitions }, + ) + end + let(:adapter_b) do + described_class.new( + StiBActiveRecordModelTransition, + sti_model, + observer, + { association_name: :sti_b_active_record_model_transitions }, + ) + end + let(:create) { adapter_a.create(from, to) } + + context "with a previous unrelated transition" do + let!(:transition_b) { adapter_b.create(from, to) } + + its(:most_recent) { is_expected.to eq(true) } + + it "doesn't update the previous transition's most_recent flag" do + expect { create }. + to_not(change { transition_b.reload.most_recent }) + end + end + + context "with previous related and unrelated transitions" do + let!(:transition_a) { adapter_a.create(from, to) } + let!(:transition_b) { adapter_b.create(from, to) } + + its(:most_recent) { is_expected.to eq(true) } + + it "updates the previous transition's most_recent flag" do + expect { create }. + to change { transition_a.reload.most_recent }. + from(true).to be_falsey + end + + it "doesn't update the previous unrelated transition's most_recent flag" do + expect { create }. + to_not(change { transition_b.reload.most_recent }) + end + end + end end - end - describe "#last" do - let(:adapter) do - described_class.new(MyActiveRecordModelTransition, model, observer) + context "when using the secondary database" do + let(:model_class) { SecondaryActiveRecordModel } + let(:transition_class) { SecondaryActiveRecordModelTransition } + + it "doesn't connect to the primary database" do + expect { create }.to exactly_query_databases({ secondary: [:writing] }) + expect(adapter.last.to_state).to eq("y") + end + + context "when there is a race" do + it "raises a TransitionConflictError and uses the correct database" do + adapter2 = adapter.dup + adapter2.create(:x, :y) + adapter.last + adapter2.create(:y, :z) + + expect { adapter.create(:y, :z) }. + to raise_exception(Statesman::TransitionConflictError). + and exactly_query_databases({ secondary: [:writing] }) + end + end end + end - before { adapter.create(:x, :y) } + describe "#last" do + let(:transition_class) { MyActiveRecordModelTransition } + let(:adapter) { described_class.new(transition_class, model, observer) } context "with a previously looked up transition" do + before { adapter.create(:x, :y) } + before { adapter.last } it "caches the transition" do @@ -325,8 +389,19 @@ before { adapter.create(:y, :z, []) } it "retrieves the new transition from the database" do + expect { adapter.last.to_state }.to exactly_query_databases({ primary: [:writing] }) expect(adapter.last.to_state).to eq("z") end + + context "when using the secondary database" do + let(:model_class) { SecondaryActiveRecordModel } + let(:transition_class) { SecondaryActiveRecordModelTransition } + + it "retrieves the new transition from the database" do + expect { adapter.last.to_state }.to exactly_query_databases({ secondary: [:writing] }) + expect(adapter.last.to_state).to eq("z") + end + end end context "when a new transition has been created elsewhere" do @@ -378,6 +453,22 @@ expect(adapter.last.to_state).to eq("y") end end + + context "without previous transitions" do + it "does query the database only once" do + expect(model.my_active_record_model_transitions). + to receive(:order).once.and_call_original + + expect(adapter.last).to eq(nil) + expect(adapter.last).to eq(nil) + end + end + end + + describe "#reset" do + it "works with empty cache" do + expect { model.state_machine.reset }.to_not raise_error + end end it "resets last with #reload" do @@ -399,10 +490,6 @@ CreateNamespacedARModelTransitionMigration.migrate(:up) end - before do - MyNamespace::MyActiveRecordModelTransition.serialize(:metadata, JSON) - end - let(:observer) { double(Statesman::Machine, execute: nil) } let(:model) do MyNamespace::MyActiveRecordModel.create(current_state: :pending) diff --git a/spec/statesman/adapters/active_record_transition_spec.rb b/spec/statesman/adapters/active_record_transition_spec.rb index b321d7eb..efbc1b23 100644 --- a/spec/statesman/adapters/active_record_transition_spec.rb +++ b/spec/statesman/adapters/active_record_transition_spec.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "spec_helper" require "json" describe Statesman::Adapters::ActiveRecordTransition do @@ -8,7 +7,11 @@ describe "including behaviour" do it "calls Class.serialize" do - expect(transition_class).to receive(:serialize).with(:metadata, JSON).once + if Gem::Version.new(ActiveRecord::VERSION::STRING) >= Gem::Version.new("7.1") + expect(transition_class).to receive(:serialize).with(:metadata, coder: JSON).once + else + expect(transition_class).to receive(:serialize).with(:metadata, JSON).once + end transition_class.send(:include, described_class) end end diff --git a/spec/statesman/adapters/memory_spec.rb b/spec/statesman/adapters/memory_spec.rb index f54df755..e7a1b043 100644 --- a/spec/statesman/adapters/memory_spec.rb +++ b/spec/statesman/adapters/memory_spec.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "spec_helper" require "statesman/adapters/shared_examples" require "statesman/adapters/memory_transition" diff --git a/spec/statesman/adapters/memory_transition_spec.rb b/spec/statesman/adapters/memory_transition_spec.rb index a78184e3..b5d932bf 100644 --- a/spec/statesman/adapters/memory_transition_spec.rb +++ b/spec/statesman/adapters/memory_transition_spec.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "spec_helper" require "statesman/adapters/memory_transition" describe Statesman::Adapters::MemoryTransition do diff --git a/spec/statesman/adapters/shared_examples.rb b/spec/statesman/adapters/shared_examples.rb index 837ddb50..de902bbb 100644 --- a/spec/statesman/adapters/shared_examples.rb +++ b/spec/statesman/adapters/shared_examples.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "spec_helper" - # All adpators must define seven methods: # initialize: Accepts a transition class, parent model and state_attr. # transition_class: Returns the transition class object passed to initialize. @@ -30,14 +28,14 @@ end describe "#create" do - subject { -> { create } } + subject(:transition) { create } let(:from) { :x } let(:to) { :y } let(:there) { :z } let(:create) { adapter.create(from, to) } - it { is_expected.to change(adapter.history, :count).by(1) } + it { expect { transition }.to change(adapter.history, :count).by(1) } context "the new transition" do subject(:instance) { create } 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..263a425d --- /dev/null +++ b/spec/statesman/adapters/type_safe_active_record_queries_spec.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +describe Statesman::Adapters::TypeSafeActiveRecordQueries, :active_record 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/callback_spec.rb b/spec/statesman/callback_spec.rb index 59f2aaba..723d9be1 100644 --- a/spec/statesman/callback_spec.rb +++ b/spec/statesman/callback_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "spec_helper" - describe Statesman::Callback do let(:cb_lambda) { -> {} } let(:callback) do diff --git a/spec/statesman/config_spec.rb b/spec/statesman/config_spec.rb index f693e4ea..bc11f7b4 100644 --- a/spec/statesman/config_spec.rb +++ b/spec/statesman/config_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "spec_helper" - describe Statesman::Config do let(:instance) { described_class.new } diff --git a/spec/statesman/exceptions_spec.rb b/spec/statesman/exceptions_spec.rb index 73189385..36e0b7d9 100644 --- a/spec/statesman/exceptions_spec.rb +++ b/spec/statesman/exceptions_spec.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -require "spec_helper" - -describe Statesman do +describe "Exceptions" do describe "InvalidStateError" do subject(:error) { Statesman::InvalidStateError.new } @@ -64,12 +62,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/guard_spec.rb b/spec/statesman/guard_spec.rb index 2345665e..26ea061f 100644 --- a/spec/statesman/guard_spec.rb +++ b/spec/statesman/guard_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "spec_helper" - describe Statesman::Guard do let(:callback) { -> {} } let(:guard) { described_class.new(from: nil, to: nil, callback: callback) } diff --git a/spec/statesman/machine_spec.rb b/spec/statesman/machine_spec.rb index 6b6ef17a..aefb516a 100644 --- a/spec/statesman/machine_spec.rb +++ b/spec/statesman/machine_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "spec_helper" - describe Statesman::Machine do let(:machine) { Class.new { include Statesman::Machine } } let(:my_model) { Class.new { attr_accessor :current_state }.new } @@ -28,7 +26,7 @@ end describe ".remove_state" do - subject(:remove_state) { -> { machine.remove_state(:x) } } + subject(:remove_state) { machine.remove_state(:x) } before do machine.class_eval do @@ -39,7 +37,7 @@ end it "removes the state" do - expect(remove_state). + expect { remove_state }. to change(machine, :states). from(match_array(%w[x y z])). to(%w[y z]) @@ -49,7 +47,7 @@ before { machine.transition from: :x, to: :y } it "removes the transition" do - expect(remove_state). + expect { remove_state }. to change(machine, :successors). from({ "x" => ["y"] }). to({}) @@ -59,7 +57,7 @@ before { machine.transition from: :x, to: :z } it "removes all transitions" do - expect(remove_state). + expect { remove_state }. to change(machine, :successors). from({ "x" => %w[y z] }). to({}) @@ -71,7 +69,7 @@ before { machine.transition from: :y, to: :x } it "removes the transition" do - expect(remove_state). + expect { remove_state }. to change(machine, :successors). from({ "y" => ["x"] }). to({}) @@ -81,7 +79,7 @@ before { machine.transition from: :z, to: :x } it "removes all transitions" do - expect(remove_state). + expect { remove_state }. to change(machine, :successors). from({ "y" => ["x"], "z" => ["x"] }). to({}) @@ -104,7 +102,7 @@ end it "removes the guard" do - expect(remove_state). + expect { remove_state }. to change(machine, :callbacks). from(a_hash_including(guards: match_array(guards))). to(a_hash_including(guards: [])) @@ -125,7 +123,7 @@ end it "removes the guard" do - expect(remove_state). + expect { remove_state }. to change(machine, :callbacks). from(a_hash_including(guards: match_array(guards))). to(a_hash_including(guards: [])) @@ -497,7 +495,6 @@ instance = machine.new(my_model, options) expect(instance.history.count).to eq(1) - expect(instance.history.first).to be_a(Statesman::Adapters::MemoryTransition) expect(instance.history.first.to_state).to eq("x") end end @@ -535,7 +532,8 @@ context "history is not empty" do before do - allow_any_instance_of(Statesman.storage_adapter).to receive(:history).and_return([{}]) + allow_any_instance_of(Statesman.storage_adapter).to receive(:history). + and_return([{}]) end context "initial state is defined" do @@ -1006,10 +1004,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 } @@ -1047,20 +1045,20 @@ def after_initialize; end end context "with defined callbacks" do - let(:callback_1) { -> { "Hi" } } - let(:callback_2) { -> { "Bye" } } + let(:callback_one) { -> { "Hi" } } + let(:callback_two) { -> { "Bye" } } before do - machine.send(definer, from: :x, to: :y, &callback_1) - machine.send(definer, from: :y, to: :z, &callback_2) + machine.send(definer, from: :x, to: :y, &callback_one) + machine.send(definer, from: :y, to: :z, &callback_two) end it "contains the relevant callback" do - expect(callbacks.map(&:callback)).to include(callback_1) + expect(callbacks.map(&:callback)).to include(callback_one) end it "does not contain the irrelevant callback" do - expect(callbacks.map(&:callback)).to_not include(callback_2) + expect(callbacks.map(&:callback)).to_not include(callback_two) end end end diff --git a/spec/statesman/utils_spec.rb b/spec/statesman/utils_spec.rb index b3e76b8b..3bb68b63 100644 --- a/spec/statesman/utils_spec.rb +++ b/spec/statesman/utils_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "spec_helper" - describe Statesman::Utils do describe ".rails_major_version" do subject { described_class.rails_major_version } diff --git a/spec/support/active_record.rb b/spec/support/active_record.rb index 883723a9..21537431 100644 --- a/spec/support/active_record.rb +++ b/spec/support/active_record.rb @@ -20,10 +20,21 @@ class MyStateMachine transition from: :failed, to: :initial end +class MyActiveRecordModelTransition < ActiveRecord::Base + include Statesman::Adapters::ActiveRecordTransition + + belongs_to :my_active_record_model +end + class MyActiveRecordModel < ActiveRecord::Base has_many :my_active_record_model_transitions, autosave: false alias_method :transitions, :my_active_record_model_transitions + include Statesman::Adapters::ActiveRecordQueries[ + transition_class: MyActiveRecordModelTransition, + initial_state: :initial + ] + def state_machine @state_machine ||= MyStateMachine.new( self, transition_class: MyActiveRecordModelTransition @@ -33,25 +44,17 @@ def state_machine def metadata super || {} end - - def reload(*) - state_machine.reset - super - end -end - -class MyActiveRecordModelTransition < ActiveRecord::Base - include Statesman::Adapters::ActiveRecordTransition - - belongs_to :my_active_record_model - serialize :metadata, JSON end class MyActiveRecordModelTransitionWithoutInclude < ActiveRecord::Base self.table_name = "my_active_record_model_transitions" belongs_to :my_active_record_model - serialize :metadata, JSON + if ::ActiveRecord.gem_version >= Gem::Version.new("7.1") + serialize :metadata, coder: JSON + else + serialize :metadata, JSON + end end class CreateMyActiveRecordModelMigration < MIGRATION_CLASS @@ -79,7 +82,7 @@ def change t.text :metadata, default: "{}" end - if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes? + if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(ActiveRecord::Base) t.boolean :most_recent, default: true, null: false else t.boolean :most_recent, default: true @@ -96,7 +99,7 @@ def change %i[my_active_record_model_id sort_key], unique: true, name: "sort_key_index" - if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes? + if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(ActiveRecord::Base) add_index :my_active_record_model_transitions, %i[my_active_record_model_id most_recent], unique: true, @@ -130,7 +133,48 @@ class OtherActiveRecordModelTransition < ActiveRecord::Base include Statesman::Adapters::ActiveRecordTransition belongs_to :other_active_record_model - serialize :metadata, JSON +end + +class SecondaryRecord < ActiveRecord::Base + self.abstract_class = true + + connects_to database: { writing: :secondary, reading: :secondary } +end + +class SecondaryActiveRecordModelTransition < SecondaryRecord + self.table_name = "my_active_record_model_transitions" + + include Statesman::Adapters::ActiveRecordTransition + + belongs_to :my_active_record_model, + class_name: "SecondaryActiveRecordModel", + foreign_key: "my_active_record_model_transition_id" +end + +class SecondaryActiveRecordModel < SecondaryRecord + self.table_name = "my_active_record_models" + + has_many :my_active_record_model_transitions, + class_name: "SecondaryActiveRecordModelTransition", + foreign_key: "my_active_record_model_id", + autosave: false + + alias_method :transitions, :my_active_record_model_transitions + + include Statesman::Adapters::ActiveRecordQueries[ + transition_class: SecondaryActiveRecordModelTransition, + initial_state: :initial + ] + + def state_machine + @state_machine ||= MyStateMachine.new( + self, transition_class: SecondaryActiveRecordModelTransition + ) + end + + def metadata + super || {} + end end class CreateOtherActiveRecordModelMigration < MIGRATION_CLASS @@ -159,7 +203,7 @@ def change t.text :metadata, default: "{}" end - if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes? + if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(ActiveRecord::Base) t.boolean :most_recent, default: true, null: false else t.boolean :most_recent, default: true @@ -172,7 +216,7 @@ def change %i[other_active_record_model_id sort_key], unique: true, name: "other_sort_key_index" - if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes? + if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(ActiveRecord::Base) add_index :other_active_record_model_transitions, %i[other_active_record_model_id most_recent], unique: true, @@ -225,7 +269,6 @@ class MyActiveRecordModelTransition < ActiveRecord::Base belongs_to :my_active_record_model, class_name: "MyNamespace::MyActiveRecordModel" - serialize :metadata, JSON def self.table_name_prefix "my_namespace_" @@ -258,7 +301,7 @@ def change t.text :metadata, default: "{}" end - if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes? + if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(ActiveRecord::Base) t.boolean :most_recent, default: true, null: false else t.boolean :most_recent, default: true @@ -270,7 +313,7 @@ def change add_index :my_namespace_my_active_record_model_transitions, :sort_key, unique: true, name: "my_namespaced_key" - if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes? + if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(ActiveRecord::Base) add_index :my_namespace_my_active_record_model_transitions, %i[my_active_record_model_id most_recent], unique: true, @@ -285,3 +328,93 @@ def change end # rubocop:enable MethodLength, Metrics/AbcSize end + +class StiActiveRecordModel < ActiveRecord::Base + has_many :sti_a_active_record_model_transitions, autosave: false + has_many :sti_b_active_record_model_transitions, autosave: false + + def state_machine_a + @state_machine_a ||= MyStateMachine.new( + self, transition_class: StiAActiveRecordModelTransition + ) + end + + def state_machine_b + @state_machine_b ||= MyStateMachine.new( + self, transition_class: StiBActiveRecordModelTransition + ) + end + + def metadata + super || {} + end + + def reload(*) + state_machine_a.reset + state_machine_b.reset + super + end +end + +class StiActiveRecordModelTransition < ActiveRecord::Base + include Statesman::Adapters::ActiveRecordTransition + + belongs_to :sti_active_record_model +end + +class StiAActiveRecordModelTransition < StiActiveRecordModelTransition +end + +class StiBActiveRecordModelTransition < StiActiveRecordModelTransition +end + +class CreateStiActiveRecordModelMigration < MIGRATION_CLASS + def change + create_table :sti_active_record_models do |t| + t.timestamps null: false + end + end +end + +class CreateStiActiveRecordModelTransitionMigration < MIGRATION_CLASS + def change + create_table :sti_active_record_model_transitions do |t| + t.string :to_state + t.integer :sti_active_record_model_id + t.integer :sort_key + t.string :type + + # MySQL doesn't allow default values on text fields + if ActiveRecord::Base.connection.adapter_name == "Mysql2" + t.text :metadata + else + t.text :metadata, default: "{}" + end + + if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(ActiveRecord::Base) + t.boolean :most_recent, default: true, null: false + else + t.boolean :most_recent, default: true + end + + t.timestamps null: false + end + + add_index :sti_active_record_model_transitions, + %i[type sti_active_record_model_id sort_key], + unique: true, name: "sti_sort_key_index" + + if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(ActiveRecord::Base) + add_index :sti_active_record_model_transitions, + %i[type sti_active_record_model_id most_recent], + unique: true, + where: "most_recent", + name: "index_sti_active_record_model_transitions_parent_latest" + else + add_index :sti_active_record_model_transitions, + %i[type sti_active_record_model_id most_recent], + unique: true, + name: "index_sti_active_record_model_transitions_parent_latest" + end + end +end diff --git a/spec/support/exactly_query_databases.rb b/spec/support/exactly_query_databases.rb new file mode 100644 index 00000000..209b6231 --- /dev/null +++ b/spec/support/exactly_query_databases.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# `expected_dbs` should be a Hash of the form: +# { +# primary: [:writing, :reading], +# replica: [:reading], +# } +RSpec::Matchers.define :exactly_query_databases do |expected_dbs| + match do |block| + @expected_dbs = expected_dbs.transform_values(&:to_set).with_indifferent_access + @actual_dbs = Hash.new { |h, k| h[k] = Set.new }.with_indifferent_access + + ActiveSupport::Notifications. + subscribe("sql.active_record") do |_name, _start, _finish, _id, payload| + pool = payload.fetch(:connection).pool + + next if pool.is_a?(ActiveRecord::ConnectionAdapters::NullPool) + + name = pool.db_config.name + role = pool.role + + @actual_dbs[name] << role + end + + block.call + + @actual_dbs == @expected_dbs + end + + failure_message do |_block| + "expected to query exactly #{@expected_dbs}, but queried #{@actual_dbs}" + end + + supports_block_expectations +end diff --git a/statesman.gemspec b/statesman.gemspec index a127025b..15532ead 100644 --- a/statesman.gemspec +++ b/statesman.gemspec @@ -20,21 +20,20 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.required_ruby_version = ">= 2.7" + spec.required_ruby_version = ">= 3.0" spec.add_development_dependency "ammeter", "~> 1.1" spec.add_development_dependency "bundler", "~> 2" - spec.add_development_dependency "gc_ruboconfig", "~> 3.6.0" + spec.add_development_dependency "gc_ruboconfig", "~> 4.4.1" spec.add_development_dependency "mysql2", ">= 0.4", "< 0.6" - spec.add_development_dependency "pg", ">= 0.18", "<= 1.3" - spec.add_development_dependency "pry" + spec.add_development_dependency "pg", ">= 0.18", "<= 1.6" spec.add_development_dependency "rails", ">= 5.2" - spec.add_development_dependency "rake", "~> 13.0.0" + spec.add_development_dependency "rake", "~> 13.1.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 "rspec-rails", "~> 6.0" + spec.add_development_dependency "sqlite3", "~> 1.7.0" spec.add_development_dependency "timecop", "~> 0.9.1" spec.metadata = {