diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 56717533..625dc6f4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,15 +24,18 @@ jobs: strategy: fail-fast: false matrix: - ruby-version: ["2.7", "3.0", "3.1", "3.2"] + 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.5" + rails-version: "6.1.7.6" + - ruby-version: "3.3" + rails-version: "6.1.7.6" runs-on: ubuntu-latest services: postgres: @@ -66,15 +69,18 @@ jobs: strategy: fail-fast: false matrix: - ruby-version: ["2.7", "3.0", "3.1", "3.2"] + 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", "8.0"] + mysql-version: ["8.0", "8.2"] exclude: - - ruby-version: 3.2 - rails-version: "6.1.5" + - 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: 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 96d99866..84904ecf 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -4,7 +4,7 @@ inherit_gem: gc_ruboconfig: rubocop.yml AllCops: - TargetRubyVersion: 2.7 + TargetRubyVersion: 3.0 NewCops: enable Metrics/AbcSize: diff --git a/.ruby-version b/.ruby-version index 944880fa..15a27998 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.0 +3.3.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eb4a9f7..588840de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,43 +1,90 @@ -## v10.2.3 2nd Aug 2023 +# 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 -### Changed +### Fixed + - Fixed an edge case where `adapter.reset` were failing if the cache is empty ## v10.2.0 3rd April 2023 -### Changed +### Fixed + - Fixed caching of `last_transition` [#505](https://github.com/gocardless/statesman/pull/505) ## v10.1.0 10th March 2023 -### CHanged +### Changed + - Add the source location of the guard callback to `Statesman::GuardFailedError` ## v10.0.0 17th May 2022 -### Changed +### 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 @@ -47,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) @@ -103,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 @@ -149,7 +211,9 @@ MyTransition end ``` + to + ```ruby include Statesman::Adapters::ActiveRecordQueries[ initial_state: :initial, @@ -159,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)) @@ -194,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)) @@ -232,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 @@ -240,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 @@ -258,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` @@ -266,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)) @@ -324,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 012d1a45..a258f5dd 100644 --- a/Gemfile +++ b/Gemfile @@ -9,8 +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 "pry" - gem "test-unit", "~> 3.3" if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.2.0") + gem "test-unit", "~> 3.3" end diff --git a/README.md b/README.md index 7f34ead6..f0ae7ebd 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 @@ -378,100 +402,129 @@ Machine::ANOTHER_STATE # => "another_state" ## 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 @@ -508,14 +561,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 @@ -527,7 +582,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. @@ -543,7 +598,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: @@ -551,7 +606,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 @@ -567,31 +622,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`: @@ -607,7 +665,7 @@ describe "guards" do end ``` -#### Testing Callbacks +### Testing Callbacks Callbacks are tested by asserting that the action they perform occurs: 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 147d5d71..b6c1a546 100644 --- a/lib/generators/statesman/generator_helpers.rb +++ b/lib/generators/statesman/generator_helpers.rb @@ -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 8890039f..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" @@ -34,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 5176d2af..1ee46417 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 @@ -88,10 +84,10 @@ def create_transition(from, to, metadata) default_transition_attributes(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. @@ -130,8 +126,8 @@ def default_transition_attributes(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, ) @@ -144,7 +140,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)) @@ -152,9 +148,11 @@ 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) @@ -223,7 +221,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 @@ -233,11 +231,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 @@ -246,13 +244,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) @@ -263,7 +256,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 @@ -334,16 +327,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 @@ -353,7 +346,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 @@ -373,9 +366,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 d31d0058..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) @@ -153,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 bdbb6c15..c25eb03d 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/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/machine.rb b/lib/statesman/machine.rb index db53241a..bde86f22 100644 --- a/lib/statesman/machine.rb +++ b/lib/statesman/machine.rb @@ -245,12 +245,20 @@ def array_to_s_or_nil(input) def initialize(object, options = { transition_class: Statesman::Adapters::MemoryTransition, + initial_transition: false, }) @object = object @transition_class = options[:transition_class] @storage_adapter = adapter_class(@transition_class).new( @transition_class, object, self, options ) + + if options[:initial_transition] + if history.empty? && self.class.initial_state + @storage_adapter.create(nil, self.class.initial_state) + end + end + send(:after_initialize) if respond_to? :after_initialize end diff --git a/lib/statesman/version.rb b/lib/statesman/version.rb index 62573a4f..61cd55c1 100644 --- a/lib/statesman/version.rb +++ b/lib/statesman/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Statesman - VERSION = "10.2.3" + 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 c269171e..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 @@ -53,6 +77,7 @@ def connection_failure ] tables.each do |table_name| sql = "DROP TABLE IF EXISTS #{table_name};" + ActiveRecord::Base.connection.execute(sql) end @@ -82,7 +107,8 @@ def prepare_sti_transitions_table CreateStiActiveRecordModelTransitionMigration.migrate(:up) StiActiveRecordModelTransition.reset_column_information end - - MyNamespace::MyActiveRecordModelTransition.serialize(:metadata, JSON) 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 e57dc9e6..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 } diff --git a/spec/statesman/adapters/active_record_spec.rb b/spec/statesman/adapters/active_record_spec.rb index db55c04b..8beb7cc6 100644 --- a/spec/statesman/adapters/active_record_spec.rb +++ b/spec/statesman/adapters/active_record_spec.rb @@ -1,17 +1,14 @@ # 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 @@ -26,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 @@ -36,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 @@ -91,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 @@ -117,13 +104,15 @@ describe "#create" do 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 @@ -131,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 @@ -355,12 +345,34 @@ end end end + + 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 describe "#last" do - let(:adapter) do - described_class.new(MyActiveRecordModelTransition, model, observer) - end + 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) } @@ -377,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 @@ -467,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 467ee68c..731e8694 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 67a3bcc8..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. diff --git a/spec/statesman/adapters/type_safe_active_record_queries_spec.rb b/spec/statesman/adapters/type_safe_active_record_queries_spec.rb index c5c68a59..263a425d 100644 --- a/spec/statesman/adapters/type_safe_active_record_queries_spec.rb +++ b/spec/statesman/adapters/type_safe_active_record_queries_spec.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -require "spec_helper" - -describe Statesman::Adapters::TypeSafeActiveRecordQueries, active_record: true do +describe Statesman::Adapters::TypeSafeActiveRecordQueries, :active_record do def configure(klass, transition_class) klass.send(:extend, described_class) klass.configure_state_machine( 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 275a6d60..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 } 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 ea676d9b..6a3da4e7 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 } @@ -493,12 +491,83 @@ it_behaves_like "a callback store", :after_guard_failure, :after_guard_failure end + shared_examples "initial transition is not created" do + it "doesn't call .create on storage adapter" do + expect_any_instance_of(Statesman.storage_adapter).to_not receive(:create) + machine.new(my_model, options) + end + end + + shared_examples "initial transition is created" do + it "calls .create on storage adapter" do + expect_any_instance_of(Statesman.storage_adapter).to receive(:create).with(nil, "x") + machine.new(my_model, options) + end + + it "creates a new transition object" do + instance = machine.new(my_model, options) + + expect(instance.history.count).to eq(1) + expect(instance.history.first.to_state).to eq("x") + end + end + describe "#initialize" do it "accepts an object to manipulate" do machine_instance = machine.new(my_model) expect(machine_instance.object).to be(my_model) end + context "initial_transition is not provided" do + let(:options) { {} } + + it_behaves_like "initial transition is not created" + end + + context "initial_transition is provided" do + context "initial_transition is true" do + let(:options) do + { initial_transition: true, + transition_class: Statesman::Adapters::MemoryTransition } + end + + context "history is empty" do + context "initial state is defined" do + before { machine.state(:x, initial: true) } + + it_behaves_like "initial transition is created" + end + + context "initial state is not defined" do + it_behaves_like "initial transition is not created" + end + end + + context "history is not empty" do + before do + allow_any_instance_of(Statesman.storage_adapter).to receive(:history). + and_return([{}]) + end + + context "initial state is defined" do + before { machine.state(:x, initial: true) } + + it_behaves_like "initial transition is not created" + end + + context "initial state is not defined" do + it_behaves_like "initial transition is not created" + end + end + end + + context "initial_transition is false" do + let(:options) { { initial_transition: false } } + + it_behaves_like "initial transition is not created" + end + end + context "transition class" do it "sets a default" do expect(Statesman.storage_adapter).to receive(:new).once. 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 2b30578c..a8c7dbc0 100644 --- a/spec/support/active_record.rb +++ b/spec/support/active_record.rb @@ -24,7 +24,6 @@ class MyActiveRecordModelTransition < ActiveRecord::Base include Statesman::Adapters::ActiveRecordTransition belongs_to :my_active_record_model - serialize :metadata, JSON end class MyActiveRecordModel < ActiveRecord::Base @@ -51,7 +50,11 @@ 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 @@ -78,7 +81,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 @@ -95,7 +98,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, @@ -129,7 +132,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 @@ -156,7 +200,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 @@ -169,7 +213,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, @@ -221,7 +265,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_" @@ -252,7 +295,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 @@ -264,7 +307,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, @@ -310,7 +353,6 @@ class StiActiveRecordModelTransition < ActiveRecord::Base include Statesman::Adapters::ActiveRecordTransition belongs_to :sti_active_record_model - serialize :metadata, JSON end class StiAActiveRecordModelTransition < StiActiveRecordModelTransition @@ -342,7 +384,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 @@ -355,7 +397,7 @@ def change %i[type sti_active_record_model_id sort_key], unique: true, name: "sti_sort_key_index" - if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes? + 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, 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 a69d1e50..15532ead 100644 --- a/statesman.gemspec +++ b/statesman.gemspec @@ -20,20 +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", "~> 4.3.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.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.4.0" spec.add_development_dependency "rspec-its", "~> 1.1" spec.add_development_dependency "rspec-rails", "~> 6.0" - spec.add_development_dependency "sqlite3", "~> 1.6.1" + spec.add_development_dependency "sqlite3", "~> 1.7.0" spec.add_development_dependency "timecop", "~> 0.9.1" spec.metadata = {