pax_global_header00006660000000000000000000000064152130550110014503gustar00rootroot0000000000000052 comment=e0e4d2911598de3423029b68e7fd02e25a27dbd3 state-machines-state_machines-activerecord-b9cb1e5/000077500000000000000000000000001521305501100225415ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/.github/000077500000000000000000000000001521305501100241015ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/.github/workflows/000077500000000000000000000000001521305501100261365ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/.github/workflows/release.yml000066400000000000000000000004141521305501100303000ustar00rootroot00000000000000name: release-please on: push: branches: - master workflow_dispatch: permissions: contents: write pull-requests: write issues: write jobs: release-please: runs-on: ubuntu-latest steps: - uses: googleapis/release-please-action@v4 state-machines-state_machines-activerecord-b9cb1e5/.github/workflows/ruby.yml000066400000000000000000000012661521305501100276470ustar00rootroot00000000000000name: Ruby on: push: branches: [ master ] pull_request: branches: [ master ] jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: ruby-version: ['3.4', '4.0'] gemfiles: - gemfiles/active_record_7.2.gemfile - gemfiles/active_record_8.0.gemfile - gemfiles/active_record_8.1.gemfile env: BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfiles }} steps: - uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby-version }} bundler-cache: true - name: Run tests run: bundle exec rake state-machines-state_machines-activerecord-b9cb1e5/.gitignore000066400000000000000000000002711521305501100245310ustar00rootroot00000000000000*.gem *.rbc .bundle .config .yardoc Gemfile.lock InstalledFiles _yardoc coverage doc/ lib/bundler/man pkg rdoc spec/reports tmp *.bundle *.so *.o *.a log/active_record.log .idea/ *.lockstate-machines-state_machines-activerecord-b9cb1e5/.release-please-manifest.json000066400000000000000000000000251521305501100302020ustar00rootroot00000000000000{ ".": "0.200.0" } state-machines-state_machines-activerecord-b9cb1e5/Appraisals000066400000000000000000000014631521305501100245670ustar00rootroot00000000000000# frozen_string_literal: true appraise 'active_record_7.2' do gem 'state_machines', '>= 0.100.4' gem 'state_machines-activemodel', '>= 0.101.0' gem 'sqlite3', platforms: :mri gem 'activerecord-jdbcsqlite3-adapter', platform: %i[jruby truffleruby] gem 'activerecord', '~> 7.2.0' end appraise 'active_record_8.0' do gem 'state_machines', '>= 0.100.4' gem 'state_machines-activemodel', '>= 0.101.0' gem 'sqlite3', platforms: :mri gem 'activerecord-jdbcsqlite3-adapter', platform: %i[jruby truffleruby] gem 'activerecord', '~> 8.0.0' end appraise 'active_record_8.1' do gem 'state_machines', '>= 0.100.4' gem 'state_machines-activemodel', '>= 0.101.0' gem 'sqlite3', platforms: :mri gem 'activerecord-jdbcsqlite3-adapter', platform: %i[jruby truffleruby] gem 'activerecord', '~> 8.1.0' end state-machines-state_machines-activerecord-b9cb1e5/CHANGELOG.md000066400000000000000000000137761521305501100243700ustar00rootroot00000000000000# Changelog ## [0.200.0](https://github.com/state-machines/state_machines-activerecord/compare/state_machines-activerecord/v0.103.0...state_machines-activerecord/v0.200.0) (2026-06-12) ### Features * remove redundant run_scope override ([5aab883](https://github.com/state-machines/state_machines-activerecord/commit/5aab8838cd04e5c741244f938be0faedd4eb1d13)) * remove redundant run_scope override ([c8197a6](https://github.com/state-machines/state_machines-activerecord/commit/c8197a6f3cce8aeaacfe34e35ae2fe0757f48c71)) * support after_commit option on after_transition callbacks ([#138](https://github.com/state-machines/state_machines-activerecord/issues/138)) ([b717068](https://github.com/state-machines/state_machines-activerecord/commit/b717068531bc184b801549e2b959a8a1248b0b89)), closes [#112](https://github.com/state-machines/state_machines-activerecord/issues/112) ### Bug Fixes * add opt-out for integer state attribute conversion ([4f0596b](https://github.com/state-machines/state_machines-activerecord/commit/4f0596bc6a72dcd83ff46948a80812f9e60604ea)) * handle auto-indexed integer column defaults ([#137](https://github.com/state-machines/state_machines-activerecord/issues/137)) ([b839a99](https://github.com/state-machines/state_machines-activerecord/commit/b839a990c3c554d3b634b935412597292823fba2)) * re-register integer attribute type when a machine is cloned for a subclass ([#136](https://github.com/state-machines/state_machines-activerecord/issues/136)) ([e903f7b](https://github.com/state-machines/state_machines-activerecord/commit/e903f7b1bba3cb8ce563576f19171c8a9170992a)) ## [0.103.0](https://github.com/state-machines/state_machines-activerecord/compare/state_machines-activerecord/v0.102.0...state_machines-activerecord/v0.103.0) (2026-03-23) ### Features * support integer columns for state machine attributes ([452edec](https://github.com/state-machines/state_machines-activerecord/commit/452edec8d23e7f9d5546e95df8c2b3ab237c5b70)) ## [0.102.0](https://github.com/state-machines/state_machines-activerecord/compare/state_machines-activerecord/v0.100.0...state_machines-activerecord/v0.102.0) (2026-03-22) ### Features * Add Rails enum integration with automatic conflict resolution ([#122](https://github.com/state-machines/state_machines-activerecord/issues/122)) ([08c7650](https://github.com/state-machines/state_machines-activerecord/commit/08c765029c02cd9c83768172279ee8a78b641f7e)) * transparent integer column support via custom AR attribute type ([6efa970](https://github.com/state-machines/state_machines-activerecord/commit/6efa970badf3ae2685042f1a4d752192aa2f74b6)) ### Bug Fixes * bump state_machines-activemodel to >= 0.102.0 ([2f99c90](https://github.com/state-machines/state_machines-activerecord/commit/2f99c90e9f4f6ef158b0e195b930d9eb3167bb66)) * bump state_machines-activemodel to 0.101.0 and test Rails 8.1.0 ([#128](https://github.com/state-machines/state_machines-activerecord/issues/128)) ([8794d30](https://github.com/state-machines/state_machines-activerecord/commit/8794d30cd0abee26b9b0f6d4eabd5cb95f409c59)) * remove bump-patch-for-minor-pre-major ([215ccb2](https://github.com/state-machines/state_machines-activerecord/commit/215ccb271e63eeaa46de640b3eb001dc69747d8e)) ## [0.100.0](https://github.com/state-machines/state_machines-activerecord/compare/state_machines-activerecord/v0.100.0...state_machines-activerecord/v0.100.0) (2025-07-17) ### Features * Add Rails enum integration with automatic conflict resolution ([#122](https://github.com/state-machines/state_machines-activerecord/issues/122)) ([08c7650](https://github.com/state-machines/state_machines-activerecord/commit/08c765029c02cd9c83768172279ee8a78b641f7e)) ## [0.100.0](https://github.com/state-machines/state_machines-activerecord/compare/state_machines-activerecord/v0.40.0...state_machines-activerecord/v0.100.0) (2025-07-17) ### Bug Fixes * prevent fiber deadlocks with Ruby 3.4+ and Rails 8 ([#123](https://github.com/state-machines/state_machines-activerecord/issues/123)) ([507bdf0](https://github.com/state-machines/state_machines-activerecord/commit/507bdf0064a9e2fdd97f8915d40a7c961b6e884b)) ## [0.40.0](https://github.com/state-machines/state_machines-activerecord/compare/state_machines-activerecord/v0.31.0...state_machines-activerecord/v0.40.0) (2025-06-30) ### Features * Drop Rails 7.1 support and require Rails 7.2+ ([#120](https://github.com/state-machines/state_machines-activerecord/issues/120)) ([e3df786](https://github.com/state-machines/state_machines-activerecord/commit/e3df786d27ff58937c705d9a5ee54ba6c2e26394)), closes [#57](https://github.com/state-machines/state_machines-activerecord/issues/57) ## [0.31.0](https://github.com/state-machines/state_machines-activerecord/compare/state_machines-activerecord/v0.10.0...state_machines-activerecord/v0.31.0) (2025-06-30) ### Features * Add transparent scope support (cherry-pick PR [#98](https://github.com/state-machines/state_machines-activerecord/issues/98)) ([5eef308](https://github.com/state-machines/state_machines-activerecord/commit/5eef30895e75840d0817166f1c36969f472ecaa0)) ### Bug Fixes * update I18n lambdas and apply RuboCop corrections ([#117](https://github.com/state-machines/state_machines-activerecord/issues/117)) ([4b739d4](https://github.com/state-machines/state_machines-activerecord/commit/4b739d4b69ebbef581ee6086457267fcf86bd922)) ## [0.10.0](https://github.com/state-machines/state_machines-activerecord/compare/state_machines-activerecord-v0.9.0...state_machines-activerecord/v0.10.0) (2025-06-12) ### Features * drop support for legacy version ([58a29a5](https://github.com/state-machines/state_machines-activerecord/commit/58a29a5dba93496e48ffda34563d2ac59f446bc0)) ### Bug Fixes * prepare to release 0.10.0 ([#115](https://github.com/state-machines/state_machines-activerecord/issues/115)) ([f9f89a4](https://github.com/state-machines/state_machines-activerecord/commit/f9f89a49507bc6bc5de125c4ddff54d0cc43f362)) * readme fix and release ([474cdd4](https://github.com/state-machines/state_machines-activerecord/commit/474cdd406a98fd3004f00b4ebd677ea882ea4182)) state-machines-state_machines-activerecord-b9cb1e5/Gemfile000066400000000000000000000002251521305501100240330ustar00rootroot00000000000000# frozen_string_literal: true source 'https://rubygems.org' gemspec platforms :mri do gem 'debug' end group :development do gem 'rubocop' end state-machines-state_machines-activerecord-b9cb1e5/LICENSE.txt000066400000000000000000000021351521305501100243650ustar00rootroot00000000000000Copyright (c) 2006-2012 Aaron Pfeifer Copyright (c) 2014-2025 Abdelkader Boudih MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. state-machines-state_machines-activerecord-b9cb1e5/README.md000066400000000000000000000174731521305501100240340ustar00rootroot00000000000000[![Build Status](https://github.com/state-machines/state_machines-activerecord/actions/workflows/ruby.yml/badge.svg)](https://github.com/state-machines/state_machines-activerecord/actions/workflows/ruby.yml) # StateMachines Active Record Integration The Active Record 7.2+ integration adds support for database transactions, automatically saving the record, named scopes, validation errors. ## Requirements - Ruby 3.2+ - Rails 7.2+ ## Installation Add this line to your application's Gemfile: gem 'state_machines-activerecord' And then execute: $ bundle Or install it yourself as: $ gem install state_machines-activerecord ## Usage For the complete usage guide, see http://www.rubydoc.info/github/state-machines/state_machines-activerecord/StateMachines/Integrations/ActiveRecord ### Example ```ruby class Vehicle < ApplicationRecord state_machine :initial => :parked do before_transition :parked => any - :parked, :do => :put_on_seatbelt after_transition any => :parked do |vehicle, transition| vehicle.seatbelt = 'off' end around_transition :benchmark event :ignite do transition :parked => :idling end state :first_gear, :second_gear do validates :seatbelt_on, presence: true end end def put_on_seatbelt ... end def benchmark ... yield ... end end ``` ### Scopes Usage of the generated scopes (assuming default column `state`): ```ruby Vehicle.with_state(:parked) # also plural #with_states Vehicle.without_states(:first_gear, :second_gear) # also singular #without_state ``` #### Transparent Scopes State scopes will return all records when `nil` is passed, making them perfect for search filters: ```ruby Vehicle.with_state(nil) # Returns all vehicles Vehicle.with_state(params[:state]) # Returns all vehicles if params[:state] is nil Vehicle.where(color: 'red').with_state(nil) # Returns all red vehicles (chainable) ``` ## Rails Enum Integration When your ActiveRecord model uses Rails enums and defines a state machine on the same attribute, this gem automatically detects the conflict and provides seamless integration. This prevents method name collisions between Rails enum methods and state machine methods. ### Auto-Detection and Conflict Resolution ```ruby class Order < ApplicationRecord # Rails enum definition enum :status, { pending: 0, processing: 1, completed: 2, cancelled: 3 } # State machine on the same attribute state_machine :status do state :pending, :processing, :completed, :cancelled event :process do transition pending: :processing end event :complete do transition processing: :completed end event :cancel do transition [:pending, :processing] => :cancelled end end end ``` When enum integration is detected, the gem automatically: - Preserves original Rails enum methods (`pending?`, `processing?`, etc.) - Generates prefixed state machine methods to avoid conflicts (`status_pending?`, `status_processing?`, etc.) - Creates prefixed scope methods (`Order.status_pending`, `Order.status_processing`, etc.) ### Available Methods **Original Rails enum methods (preserved):** ```ruby order = Order.create(status: :pending) order.pending? # => true (Rails enum method) order.processing? # => false (Rails enum method) order.processing! # Sets status to :processing (Rails enum method) Order.pending # Rails enum scope Order.processing # Rails enum scope ``` **Generated state machine methods (prefixed):** ```ruby # Predicate methods order.status_pending? # => true (state machine method) order.status_processing? # => false (state machine method) order.status_completed? # => false (state machine method) # Bang methods (for conflict resolution only) # These are placeholders and raise runtime errors order.status_processing! # => raises RuntimeError # Scope methods Order.status_pending # State machine scope Order.status_processing # State machine scope Order.not_status_pending # Negative state machine scope ``` ### Introspection API The integration provides a comprehensive introspection API for advanced use cases: ```ruby machine = Order.state_machine(:status) # Check if enum integration is enabled machine.enum_integrated? # => true # Get the Rails enum mapping machine.enum_mapping # => {"pending"=>0, "processing"=>1, "completed"=>2, "cancelled"=>3} # Get original Rails enum methods that were preserved machine.original_enum_methods # => ["pending?", "processing?", "completed?", "cancelled?", "pending!", "processing!", ...] # Get state machine methods that were generated machine.state_machine_methods # => ["status_pending?", "status_processing?", "status_completed?", "status_cancelled?", ...] ``` ## Integer-backed state attributes Integer columns whose states don't declare explicit values are converted transparently: application code reads state names while the database stores integers (mapped by definition order). ```ruby class Order < ApplicationRecord state_machine :status, initial: :pending do state :pending state :approved end end order = Order.create! order.status = :approved order.status # => "approved" # The database stores 1. ``` Machines where every state declares an explicit integer value keep the classic raw-integer behavior automatically: reads return the integer and `status_name` returns the state name: ```ruby class LegacyOrder < ApplicationRecord self.table_name = "orders" state_machine :status, initial: :pending do state :pending, value: 0 state :approved, value: 1 event :approve do transition pending: :approved end end end order = LegacyOrder.create! order.approve! order.status # => 1 order.status_name # => :approved # The database stores 1. ``` Applications that want no type conversion at all can disable it during boot, before defining affected state machines: ```ruby # config/initializers/state_machines.rb StateMachines::Integrations::ActiveRecord.auto_convert_integer_state_attributes = false ``` With this setting disabled, integer-backed state attributes are left entirely to ActiveRecord's normal integer handling. ### Requirements for Enum Integration - The state machine attribute must match an existing Rails enum attribute - Auto-detection is enabled by default when this condition is met ### Configuration Options The enum integration supports several configuration options: - `prefix` (default: true) - Adds a prefix to generated methods to avoid conflicts - `suffix` (default: false) - Alternative naming strategy using suffixes instead of prefixes - `scopes` (default: true) - Controls whether state machine scopes are generated ## State driven validations As mentioned in `StateMachines::Machine#state`, you can define behaviors, like validations, that only execute for certain states. One *important* caveat here is that, due to a constraint in ActiveRecord's validation framework, custom validators will not work as expected when defined to run in multiple states. For example: ```ruby class Vehicle < ApplicationRecord state_machine do state :first_gear, :second_gear do validate :speed_is_legal end end end ``` In this case, the :speed_is_legal validation will only get run for the :second_gear state. To avoid this, you can define your custom validation like so: ```ruby class Vehicle < ApplicationRecord state_machine do state :first_gear, :second_gear do validate {|vehicle| vehicle.speed_is_legal} end end end ``` ## Contributing 1. Fork it ( https://github.com/state-machines/state_machines-activerecord/fork ) 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Commit your changes (`git commit -am 'Add some feature'`) 4. Push to the branch (`git push origin my-new-feature`) 5. Create a new Pull Request state-machines-state_machines-activerecord-b9cb1e5/Rakefile000066400000000000000000000003051521305501100242040ustar00rootroot00000000000000# frozen_string_literal: true require 'bundler/gem_tasks' require 'rake/testtask' Rake::TestTask.new do |t| t.pattern = 'test/*_test.rb' end desc 'Default: run all tests.' task default: :test state-machines-state_machines-activerecord-b9cb1e5/gemfiles/000077500000000000000000000000001521305501100243345ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/gemfiles/active_record_7.2.gemfile000066400000000000000000000006051521305501100310660ustar00rootroot00000000000000# This file was generated by Appraisal source "https://rubygems.org" gem "state_machines", ">= 0.100.4" gem "state_machines-activemodel", ">= 0.101.0" gem "sqlite3", platforms: :mri gem "activerecord-jdbcsqlite3-adapter", platform: [:jruby, :truffleruby] gem "activerecord", "~> 7.2.0" group :development do gem "rubocop" end platforms :mri do gem "debug" end gemspec path: "../" state-machines-state_machines-activerecord-b9cb1e5/gemfiles/active_record_8.0.gemfile000066400000000000000000000006051521305501100310650ustar00rootroot00000000000000# This file was generated by Appraisal source "https://rubygems.org" gem "state_machines", ">= 0.100.4" gem "state_machines-activemodel", ">= 0.101.0" gem "sqlite3", platforms: :mri gem "activerecord-jdbcsqlite3-adapter", platform: [:jruby, :truffleruby] gem "activerecord", "~> 8.0.0" group :development do gem "rubocop" end platforms :mri do gem "debug" end gemspec path: "../" state-machines-state_machines-activerecord-b9cb1e5/gemfiles/active_record_8.1.gemfile000066400000000000000000000006331521305501100310670ustar00rootroot00000000000000# This file was generated by Appraisal source "https://rubygems.org" gem "state_machines", ">= 0.100.4" gem "state_machines-activemodel", ">= 0.101.0" gem "sqlite3", platforms: :mri gem "activerecord-jdbcsqlite3-adapter", platform: [:jruby, :truffleruby] gem "activerecord", "~> 8.1.0" gem "rake", "~> 13.0" group :development do gem "rubocop" end platforms :mri do gem "debug" end gemspec path: "../" state-machines-state_machines-activerecord-b9cb1e5/lib/000077500000000000000000000000001521305501100233075ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/lib/state_machines-activerecord.rb000066400000000000000000000003661521305501100313000ustar00rootroot00000000000000# frozen_string_literal: true require 'active_support' require 'state_machines/integrations/active_record' ActiveSupport.on_load(:i18n) do I18n.load_path << File.expand_path('state_machines/integrations/active_record/locale.rb', __dir__) end state-machines-state_machines-activerecord-b9cb1e5/lib/state_machines/000077500000000000000000000000001521305501100262765ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/lib/state_machines/integrations/000077500000000000000000000000001521305501100310045ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/lib/state_machines/integrations/active_record.rb000066400000000000000000001126141521305501100341470ustar00rootroot00000000000000# frozen_string_literal: true require 'state_machines-activemodel' require 'active_record' require 'state_machines/integrations/active_record/version' require 'state_machines/integrations/active_record/type/integer' module StateMachines module Integrations # :nodoc: # Adds support for integrating state machines with ActiveRecord models. # # == Examples # # Below is an example of a simple state machine defined within an # ActiveRecord model: # # class Vehicle < ApplicationRecord # state_machine :initial => :parked do # event :ignite do # transition :parked => :idling # end # end # end # # The examples in the sections below will use the above class as a # reference. # # == Actions # # By default, the action that will be invoked when a state is transitioned # is the +save+ action. This will cause the record to save the changes # made to the state machine's attribute. *Note* that if any other changes # were made to the record prior to transition, then those changes will # be saved as well. # # For example, # # vehicle = Vehicle.create # => # # vehicle.name = 'Ford Explorer' # vehicle.ignite # => true # vehicle.reload # => # # # *Note* that if you want a transition to update additional attributes of the record, # either the changes need to be made in a +before_transition+ callback or you need # to save the record manually. # # == Events # # As described in StateMachines::InstanceMethods#state_machine, event # attributes are created for every machine that allow transitions to be # performed automatically when the object's action (in this case, :save) # is called. # # In ActiveRecord, these automated events are run in the following order: # * before validation - Run before callbacks and persist new states, then validate # * before save - If validation was skipped, run before callbacks and persist new states, then save # * after save - Run after callbacks # # For example, # # vehicle = Vehicle.create # => # # vehicle.state_event # => nil # vehicle.state_event = 'invalid' # vehicle.valid? # => false # vehicle.errors.full_messages # => ["State event is invalid"] # # vehicle.state_event = 'ignite' # vehicle.valid? # => true # vehicle.save # => true # vehicle.state # => "idling" # vehicle.state_event # => nil # # Note that this can also be done on a mass-assignment basis: # # vehicle = Vehicle.create(:state_event => 'ignite') # => # # vehicle.state # => "idling" # # This technique is always used for transitioning states when the +save+ # action (which is the default) is configured for the machine. # # === Security implications # # Beware that public event attributes mean that events can be fired # whenever mass-assignment is being used. If you want to prevent malicious # users from tampering with events through URLs / forms, you should use # Rails' strong parameters to control which attributes are permitted: # # class VehiclesController < ApplicationController # def vehicle_params # params.require(:vehicle).permit(:color, :make, :model) # # Exclude state_event to prevent tampering # end # end # # If you want to only have *some* events be able to fire via mass-assignment, # you can build two state machines (one public and one protected) like so: # # class Vehicle < ApplicationRecord # # Define private machine # state_machine do # # Define private events here # end # # # Public machine targets the same state as the private machine # state_machine :public_state, :attribute => :state do # # Define public events here # end # # # Control access via strong parameters in your controller # end # # == Transactions # # In order to ensure that any changes made during transition callbacks # are rolled back during a failed attempt, every transition is wrapped # within a transaction. # # For example, # # class Message < ApplicationRecord # end # # Vehicle.state_machine do # before_transition do |vehicle, transition| # Message.create(:content => transition.inspect) # false # end # end # # vehicle = Vehicle.create # => # # vehicle.ignite # => false # Message.count # => 0 # # *Note* that only before callbacks that halt the callback chain and # failed attempts to save the record will result in the transaction being # rolled back. If an after callback halts the chain, the previous result # still applies and the transaction is *not* rolled back. # # To turn off transactions: # # class Vehicle < ApplicationRecord # state_machine :initial => :parked, :use_transactions => false do # ... # end # end # # == Validations # # As mentioned in StateMachines::Machine#state, you can define behaviors, # like validations, that only execute for certain states. One *important* # caveat here is that, due to a constraint in ActiveRecord's validation # framework, custom validators will not work as expected when defined to run # in multiple states. For example: # # class Vehicle < ApplicationRecord # state_machine do # ... # state :first_gear, :second_gear do # validate :speed_is_legal # end # end # end # # In this case, the :speed_is_legal validation will only get run # for the :second_gear state. To avoid this, you can define your # custom validation like so: # # class Vehicle < ApplicationRecord # state_machine do # ... # state :first_gear, :second_gear do # validate {|vehicle| vehicle.speed_is_legal} # end # end # end # # == Validation errors # # If an event fails to successfully fire because there are no matching # transitions for the current record, a validation error is added to the # record's state attribute to help in determining why it failed and for # reporting via the UI. # # For example, # # vehicle = Vehicle.create(:state => 'idling') # => # # vehicle.ignite # => false # vehicle.errors.full_messages # => ["State cannot transition via \"ignite\""] # # If an event fails to fire because of a validation error on the record and # *not* because a matching transition was not available, no error messages # will be added to the state attribute. # # In addition, if you're using the ignite! version of the event, # then the failure reason (such as the current validation errors) will be # included in the exception that gets raised when the event fails. For # example, assuming there's a validation on a field called +name+ on the class: # # vehicle = Vehicle.new # vehicle.ignite! # => StateMachines::InvalidTransition: Cannot transition state via :ignite from :parked # # (Reason(s): Name cannot be blank) # # == Scopes # # To assist in filtering models with specific states, a series of scopes # are defined on the model for finding records with or without a # particular set of states. # # These scopes are essentially the functional equivalent of the # following definitions: # # class Vehicle < ApplicationRecord # # with_states also aliased to with_state # scope :with_states, ->(states) { states.present? ? where(state: states) : all } # # # without_states also aliased to without_state # scope :without_states, ->(states) { states.present? ? where.not(state: states) : all } # end # # *Note*, however, that the states are converted to their stored values # before being passed into the query. # # Because of the way scopes work in ActiveRecord, they can be # chained like so: # # Vehicle.with_state(:parked).order(id: :desc) # # Note that states can also be referenced by the string version of their # name: # # Vehicle.with_state('parked') # # === Transparent Scopes # # When `nil` is passed to any of the state scopes, they return `all` records # without applying any filters. This allows for more flexible scope chaining # in search interfaces: # # Vehicle.with_state(params[:state]) # Returns all vehicles if params[:state] is nil # Vehicle.where(color: 'red').with_state(nil) # Returns all red vehicles # # == Callbacks # # All before/after transition callbacks defined for ActiveRecord models # behave in the same way that other ActiveRecord callbacks behave. The # object involved in the transition is passed in as an argument. # # For example, # # class Vehicle < ApplicationRecord # state_machine :initial => :parked do # before_transition any => :idling do |vehicle| # vehicle.put_on_seatbelt # end # # before_transition do |vehicle, transition| # # log message # end # # event :ignite do # transition :parked => :idling # end # end # # def put_on_seatbelt # ... # end # end # # Note, also, that the transition can be accessed by simply defining # additional arguments in the callback block. # # === Failure callbacks # # +after_failure+ callbacks allow you to execute behaviors when a transition # is allowed, but fails to save. This could be useful for something like # auditing transition attempts. Since callbacks run within transactions in # ActiveRecord, a save failure will cause any records that get created in # your callback to roll back. You can work around this issue like so: # # class TransitionLog < ApplicationRecord # connects_to database: { writing: :primary, reading: :primary } # end # # class Vehicle < ApplicationRecord # state_machine do # after_failure do |vehicle, transition| # TransitionLog.create(:vehicle => vehicle, :transition => transition) # end # # ... # end # end # # The +TransitionLog+ model establishes a separate connection to the database # that allows new records to be saved without being affected by rollbacks # in the +Vehicle+ model's transaction. # # === Callback Order # # Callbacks occur in the following order. Callbacks specific to state_machine # are bolded. The remaining callbacks are part of ActiveRecord. # # * (-) save # * (-) begin transaction (if enabled) # * (1) *before_transition* # * (-) valid # * (2) before_validation # * (-) validate # * (3) after_validation # * (4) before_save # * (5) before_create # * (-) create # * (6) after_create # * (7) after_save # * (8) *after_transition* # * (-) end transaction (if enabled) # * (9) after_commit # * (10) *after_transition* callbacks defined with :after_commit => true # # An +after_transition+ callback defined with the :after_commit # option is deferred until the surrounding database transaction has been # committed (and discarded if it rolls back). See the documentation for # +after_transition+ in this integration for more details. # # == Internationalization # # Any error message that is generated from performing invalid # transitions can be localized. The following default translations are used: # # en: # activerecord: # errors: # messages: # invalid: "is invalid" # # %{value} = attribute value, %{state} = Human state name # invalid_event: "cannot transition when %{state}" # # %{value} = attribute value, %{event} = Human event name, %{state} = Human current state name # invalid_transition: "cannot transition via %{event}" # # You can override these for a specific model like so: # # en: # activerecord: # errors: # models: # user: # invalid: "is not valid" # # In addition to the above, you can also provide translations for the # various states / events in each state machine. Using the Vehicle example, # state translations will be looked for using the following keys, where # +model_name+ = "vehicle", +machine_name+ = "state" and +state_name+ = "parked": # * activerecord.state_machines.#{model_name}.#{machine_name}.states.#{state_name} # * activerecord.state_machines.#{model_name}.states.#{state_name} # * activerecord.state_machines.#{machine_name}.states.#{state_name} # * activerecord.state_machines.states.#{state_name} # # Event translations will be looked for using the following keys, where # +model_name+ = "vehicle", +machine_name+ = "state" and +event_name+ = "ignite": # * activerecord.state_machines.#{model_name}.#{machine_name}.events.#{event_name} # * activerecord.state_machines.#{model_name}.events.#{event_name} # * activerecord.state_machines.#{machine_name}.events.#{event_name} # * activerecord.state_machines.events.#{event_name} # # An example translation configuration might look like so: # # es: # activerecord: # state_machines: # states: # parked: 'estacionado' # events: # park: 'estacionarse' module ActiveRecord include Base include ActiveModel # The default options to use for state machines using this integration @defaults = { action: :save, use_transactions: true } @auto_convert_integer_state_attributes = true # Machine-specific methods for enum integration module MachineMethods # Enum integration metadata storage attr_accessor :enum_integration # Hook called after machine initialization def after_initialize super initialize_enum_integration register_integer_type if register_integer_type? end # Hook called when a machine is assigned to a class, including when an # inherited machine is cloned for an STI subclass. The cloned machine # carries the parent's @integer_type_registered flag while the subclass # still inherits the parent's attribute type, which references the # parent machine's state collection. Re-register so the subclass type # sees this machine's (cloned) states, picking up subclass-added states # as they are defined. # # @param klass [Class] the new owner class # @return [Class] the assigned owner class def owner_class=(klass) super.tap do register_integer_type if integer_type_registered? end end # Check if enum integration should be enabled for this machine def detect_enum_integration return nil unless owner_class.defined_enums.key?(attribute.to_s) # For now, auto-detect enum and enable basic integration # Later we can add explicit configuration options { enabled: true, prefix: true, suffix: false, scopes: true, enum_values: owner_class.defined_enums[attribute.to_s] || {}, original_enum_methods: detect_existing_enum_methods, state_machine_methods: [] } end # Initialize enum integration if enum is detected def initialize_enum_integration detected_config = detect_enum_integration return unless detected_config # Store enum integration metadata self.enum_integration = detected_config end # Override state method to trigger method generation after states are defined def state(*, &) result = super # Generate methods after each state addition if enum integration is enabled generate_state_machine_methods if enum_integrated? # States defined after initialization (e.g. adding an explicit value # to the initial state) can change how the column default maps to # states, so re-evaluate the conflicting-default warning recheck_conflicting_attribute_default if integer_type_registered? result end # Warns at most once when the column default conflicts with the # machine's initial state. The conflict is re-evaluated as states are # defined, so remember when the warning has been issued. def check_conflicting_attribute_default return if @attribute_default_conflict_warned initial_state = states.detect(&:initial) conflict = !owner_class_attribute_default.nil? && ( dynamic_initial_state? || !owner_class_attribute_default_matches?(initial_state) ) return unless conflict @attribute_default_conflict_warned = true super end # Returns true when this machine should use the custom integer attribute type # to convert between Ruby state names and integer database values. This only # applies to non-enum integer columns when automatic conversion is enabled. def register_integer_type? StateMachines::Integrations::ActiveRecord.auto_convert_integer_state_attributes && integer_column? && !enum_integrated? end # Check if this machine has enum integration enabled def enum_integrated? enum_integration && enum_integration[:enabled] end # Get the enum mapping for this attribute def enum_mapping return {} unless enum_integrated? enum_integration[:enum_values] || {} end # Get list of original enum methods that were preserved def original_enum_methods return [] unless enum_integrated? enum_integration[:original_enum_methods] || [] end # Get list of state machine methods that were generated def state_machine_methods return [] unless enum_integrated? enum_integration[:state_machine_methods] || [] end def integer_type_registered? !!@integer_type_registered end # Creates a callback that will be invoked *after* a transition is # performed, so long as the given requirements match the transition. # # In addition to the configuration options supported by the core # +after_transition+ (see StateMachines::Machine#after_transition), the # ActiveRecord integration supports: # * :after_commit - Defer execution of the callback until the # database transaction wrapping the transition has been committed. # When no transaction is open at that point, the callback runs # immediately. When the transaction (or an outer transaction wrapping # it) is rolled back, the callback is discarded. # # This is the safe place to enqueue background jobs that reference the # record (e.g. via GlobalID), since a regular +after_transition+ runs # inside the transaction, before the record's changes are visible to # other connections: # # class Vehicle < ApplicationRecord # state_machine do # after_transition on: :ignite, after_commit: true do |vehicle| # EngineWarmupJob.perform_later(vehicle) # end # # ... # end # end # # Note that a deferred callback cannot halt the callback chain or # affect the result of the transition: by the time it runs, the # transition has already been committed. For the same reason, an # exception raised by a deferred callback is not propagated (doing so # would revert the record's in-memory state even though the database # was already updated); it is reported to +ActiveSupport.error_reporter+ # (+Rails.error+) instead. Conditions (:if/:unless) # and state requirements are evaluated when the transition is # performed, not at commit time. # # Like ActiveRecord's own +after_commit+, a surrounding # transaction(joinable: false) wrapper is transparent: the # callback fires at the inner commit. This is what makes it fire # under transactional test fixtures. def after_transition(*args, **options, &block) # The flag may hide in a legacy trailing positional options hash positional_options = args.last.is_a?(Hash) ? args.pop.dup : {} options = positional_options.merge(options) # Only a boolean is the flag — a non-boolean value is the implicit # state-requirement form for a state named :after_commit flag = options[:after_commit] return super unless flag == true || flag == false options.delete(:after_commit) return super unless flag # Method handling goes to a real Callback (reusing core's binding, # arity and :do semantics); branch matching stays on the wrapper so # conditions are evaluated at transition time parsed = parse_callback_arguments(args, options) method_options = parsed.slice(:do, :bind_to_object) method_options[:terminator] = callback_terminator branch_options = parsed.except(:do, :bind_to_object, :terminator) deferred = Callback.new(:after, method_options, &block) super(**branch_options, bind_to_object: false) do |object, transition| object.class.current_transaction.after_commit do # The transition's catch(:halt) is gone at commit time catch(:halt) { deferred.call(object, {}, transition) } rescue StandardError => e # Raising would roll back in-memory state already committed; report instead ActiveSupport.error_reporter.report(e, handled: false, source: 'state_machines-activerecord') end end end # Machine internals (state matching, validations) call read() to get the # current state value and compare it against state.value. The custom # integer type already returns the canonical value when state values are # uniform: raw integers when every named state has an explicit integer # value (passthrough), name strings when none do (state.value is the # name). Only machines mixing explicit and auto-indexed values need an # override, because the type returns name strings while the explicit # states match on integers; map the stored value back to the matched # state's canonical state.value. # # @param object [ActiveRecord::Base] record being read # @param attr_sym [Symbol] attribute kind (:state, :event, ...) # @param ivar [Boolean] whether to read from an instance variable # @return [Object] a value machine internals can match on state.value def read(object, attr_sym, ivar = false) return super unless integer_type_registered? && attr_sym == :state return super unless mixed_integer_state_values? raw = object.read_attribute_before_type_cast(attribute.to_s) if raw.is_a?(::String) || raw.is_a?(::Symbol) matched = states.detect { |s| s.name && s.name.to_s == raw.to_s } return matched.value if matched end name = owner_class.type_for_attribute(attribute.to_s).deserialize(raw) matched = states.detect { |s| s.name && s.name.to_s == name.to_s } matched ? matched.value : raw end private # Re-runs the conflicting-default check for states defined after # initialization. Skipped until an initial state exists (the DSL block # evaluates before initial_state= during Machine.new). # # @return [void] def recheck_conflicting_attribute_default return unless states.detect(&:initial) check_conflicting_attribute_default end # Returns true when named states mix explicit integer values with # auto-indexed (name-valued) states. Uses value(false) so dynamic # (Proc) state values are never evaluated for this metadata decision. # # @return [Boolean] def mixed_integer_state_values? named = states.select(&:name) explicit = named.count { |s| s.value(false).is_a?(::Integer) } explicit.positive? && explicit < named.size end # Returns true when the state machine attribute is backed by an integer column def integer_column? return false unless owner_class.respond_to?(:type_for_attribute) return false unless owner_class.connected? && owner_class.table_exists? owner_class.type_for_attribute(attribute.to_s).type == :integer rescue ::ActiveRecord::StatementInvalid, ::ActiveRecord::ConnectionNotEstablished false end # Registers a custom AR attribute type so that integer columns transparently # convert between state name strings and stored integers. # Saves the raw column default first so the conflicting-default check # (which fires later, during initial_state=) still compares raw integers. def register_integer_type @raw_integer_column_default = owner_class.column_defaults[attribute.to_s] @integer_type_registered = true # When re-registering (e.g. for an STI subclass that inherited the # parent's custom type), unwrap it to keep the column's original type # as the passthrough delegate. current_type = owner_class.type_for_attribute(attribute.to_s) raw_type = current_type.is_a?(StateMachines::Type::Integer) ? current_type.raw_type : current_type owner_class.attribute(attribute.to_s, StateMachines::Type::Integer.new(states, raw_type: raw_type)) end # Detect existing enum methods for this attribute def detect_existing_enum_methods return [] unless owner_class.defined_enums.key?(attribute.to_s) enum_values = owner_class.defined_enums[attribute.to_s] methods = [] enum_values.each_key do |value| # Predicate methods like 'active?' predicate = "#{value}?" methods << predicate if owner_class.method_defined?(predicate) # Bang methods like 'active!' bang_method = "#{value}!" methods << bang_method if owner_class.method_defined?(bang_method) # Scope methods (class-level) methods << value.to_s if owner_class.respond_to?(value) methods << "not_#{value}" if owner_class.respond_to?("not_#{value}") end methods end # Generate method name with prefix/suffix based on configuration def generate_state_method_name(state_name, method_type) return state_name unless enum_integrated? config = enum_integration base_name = case method_type when :predicate "#{state_name}?" when :bang "#{state_name}!" else state_name.to_s end # Apply prefix if config[:prefix] prefix = config[:prefix] == true ? "#{attribute}_" : "#{config[:prefix]}_" base_name = "#{prefix}#{base_name}" end # Apply suffix if config[:suffix] suffix = config[:suffix] == true ? "_#{attribute}" : "_#{config[:suffix]}" base_name = base_name.gsub(/(\?|!)$/, "#{suffix}\\1") base_name = "#{base_name}#{suffix}" unless base_name.end_with?('?', '!') end base_name end # Generate state machine methods with conflict resolution def generate_state_machine_methods return unless enum_integrated? # Initialize tracking if not already done @processed_states ||= Set.new enum_integration[:state_machine_methods] ||= [] # Get all states for this machine states.each do |state| state_name = state.name.to_s next if state.nil? # Skip nil state next if @processed_states.include?(state_name) # Skip already processed states # Generate predicate method (e.g., status_pending?) predicate_method = generate_state_method_name(state_name, :predicate) if predicate_method != "#{state_name}?" define_state_predicate_method(state_name, predicate_method) track_generated_method(predicate_method) end # Generate bang method (e.g., status_pending!) bang_method = generate_state_method_name(state_name, :bang) if bang_method != "#{state_name}!" define_state_bang_method(state_name, bang_method) track_generated_method(bang_method) end # Generate scope methods (e.g., status_pending) if scopes are enabled if enum_integration[:scopes] scope_method = generate_state_method_name(state_name, :scope) if scope_method != state_name define_state_scope_method(state_name, scope_method) track_generated_method(scope_method) end end # Mark this state as processed @processed_states.add(state_name) end end # Define a prefixed predicate method for a state def define_state_predicate_method(state_name, method_name) machine_attribute = attribute target_state_name = state_name.to_sym owner_class.define_method(method_name) do machine = self.class.state_machine(machine_attribute) machine.states.matches?(self, target_state_name) end end # Define a prefixed bang method for a state def define_state_bang_method(state_name, method_name) owner_class.define_method(method_name) do # Raise an error with actionable guidance raise "#{method_name} is a conflict-resolution placeholder. " \ "Use the original enum method '#{state_name}!' or state machine events instead." end end # Define a prefixed scope method for a state def define_state_scope_method(state_name, method_name) machine_attribute = attribute scope_lambda = lambda do |value = true| machine = state_machine(machine_attribute) state_value = machine.states[state_name.to_sym].value if value where(machine_attribute => state_value) else where.not(machine_attribute => state_value) end end owner_class.define_singleton_method(method_name, &scope_lambda) owner_class.define_singleton_method("not_#{method_name}") do public_send(method_name, false) end end # Track generated state machine methods for introspection def track_generated_method(method_name) return unless enum_integrated? # Use a Set to ensure no duplicates enum_integration[:state_machine_methods] ||= [] return if enum_integration[:state_machine_methods].include?(method_name) enum_integration[:state_machine_methods] << method_name end end # Include MachineMethods to make enum integration methods available on machine instances include MachineMethods class << self attr_accessor :auto_convert_integer_state_attributes # Classes that inherit from ActiveRecord::Base will automatically use # the ActiveRecord integration. def matching_ancestors [::ActiveRecord::Base] end end protected # Only runs validations on the action if using :save def runs_validations_on_action? action == :save end # Gets the db default for the machine's attribute. # For integer columns the raw pre-type-registration default is returned so # that check_conflicting_attribute_default can compare integers to integers. def owner_class_attribute_default return @raw_integer_column_default if defined?(@raw_integer_column_default) return unless owner_class.connected? && owner_class.table_exists? owner_class.column_defaults[attribute.to_s] end # Checks whether the given state matches the column default. When the # custom integer type is registered, auto-indexed states match on their # name string while the column default is a raw integer, so the default # is also compared through the type's mapping (e.g. 0 matches the first # auto-indexed state). # # @param state [StateMachines::State] the state to compare (the initial state) # @return [Boolean] whether the column default represents this state def owner_class_attribute_default_matches?(state) matches = super return matches if matches || !integer_type_registered? default = owner_class_attribute_default state.matches?(owner_class.type_for_attribute(attribute.to_s).deserialize(default)) end def define_state_initializer define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1 def initialize(attributes = nil, *) super(attributes) do |*args| attributes = (attributes || {}).transform_keys { |key| self.class.attribute_aliases[key.to_s] || key } scoped_attributes = attributes.merge(self.class.scope_attributes) self.class.state_machines.initialize_states(self, {}, scoped_attributes) yield(*args) if block_given? end end END_EVAL end # Uses around callbacks to run state events if using the :save hook def define_action_hook if action_hook == :save define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1 def save(*, **) self.class.state_machine(#{name.inspect}).send(:around_save, self) { super } end def save!(*, **) result = self.class.state_machine(#{name.inspect}).send(:around_save, self) { super } result || raise(ActiveRecord::RecordInvalid.new(self)) end def changed_for_autosave? super || self.class.state_machines.any? {|name, machine| machine.action == :save && machine.read(self, :event)} end END_EVAL else super end end # Runs state events around the machine's :save action def around_save(object, &) # Pass fiber: false to avoid deadlocks with ActiveRecord's LoadInterlockAwareMonitor object.class.state_machines.transitions(object, action, fiber: false).perform(&) end # Creates a scope for finding records *with* a particular state or # states for the attribute def create_with_scope(_name) attr_name = attribute lambda do |klass, values| if values.present? klass.where(attr_name => values) else klass.all end end end # Creates a scope for finding records *without* a particular state or # states for the attribute def create_without_scope(_name) attr_name = attribute lambda do |klass, values| if values.present? klass.where.not(attr_name => values) else klass.all end end end # Runs a new database transaction, rolling back any changes by raising # an ActiveRecord::Rollback exception if the yielded block fails # (i.e. returns false). def transaction(object) result = nil object.class.transaction do raise ::ActiveRecord::Rollback unless (result = yield) end result end def locale_path "#{File.dirname(__FILE__)}/active_record/locale.rb" end private # ActiveModel's use of method_missing / respond_to for attribute methods # breaks both ancestor lookups and defined?(super). Need to special-case # the existence of query attribute methods. def owner_class_ancestor_has_method?(scope, method) scope == :instance && method == "#{attribute}?" ? owner_class : super end end register(ActiveRecord) end end state-machines-state_machines-activerecord-b9cb1e5/lib/state_machines/integrations/active_record/000077500000000000000000000000001521305501100336155ustar00rootroot00000000000000locale.rb000066400000000000000000000011171521305501100353220ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/lib/state_machines/integrations/active_record# frozen_string_literal: true # Use lazy evaluation to avoid circular dependencies with frozen default_messages # This ensures messages can be updated after gem loading while maintaining thread safety { en: { activerecord: { errors: { messages: { invalid: ->(*) { StateMachines::Machine.default_messages[:invalid] }, invalid_event: ->(*) { format(StateMachines::Machine.default_messages[:invalid_event], '%s') }, invalid_transition: ->(*) { format(StateMachines::Machine.default_messages[:invalid_transition], '%s') } } } } } } type/000077500000000000000000000000001521305501100345175ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/lib/state_machines/integrations/active_recordinteger.rb000066400000000000000000000117071521305501100365070ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/lib/state_machines/integrations/active_record/type# frozen_string_literal: true module StateMachines module Type # Custom ActiveRecord attribute type for state machine attributes backed by # integer columns. Handles bidirectional conversion between state name strings # (used internally by the state machine) and integer values (stored in the DB). # # States without explicit integer values are mapped by their index position # in the states collection (0, 1, 2, …). States with an explicit integer # value (e.g. state :pending, value: 2) use that value directly. # # When *every* named state has an explicit integer value, the column already # stores the canonical state values and no name<->integer conversion is # needed. In that case the type delegates to the column's original integer # type, preserving the classic raw-integer behavior # (e.g. record.status # => 1, record.status_name # => :approved). class Integer < ::ActiveRecord::Type::Value # The column's original attribute type, exposed so re-registration # (e.g. for STI subclasses) can reuse it instead of wrapping this type. # # @return [ActiveModel::Type::Value] attr_reader :raw_type # @param states [StateMachines::StateCollection] live collection of the # machine's states; held by reference because states are defined after # the type is registered # @param raw_type [ActiveModel::Type::Value, nil] the column's original # attribute type, used verbatim in passthrough mode so adapter-specific # integer behavior (limits, range checks) is preserved def initialize(states, raw_type: nil) @states = states @raw_type = raw_type || ::ActiveModel::Type::Integer.new super() end # Converts an integer from the database to a state name string. # # @param value [Integer, String, nil] raw database value # @return [String, Integer, nil] state name, or the original type's value # in passthrough mode, or the raw value when no state matches def deserialize(value) states = named_states return @raw_type.deserialize(value) if passthrough?(states) return nil if value.nil? int_val = value.to_i state = states.detect { |s| state_integer(s, states) == int_val } state ? state.name.to_s : value end # Converts an assigned value (symbol, string, or integer) to the in-memory # state name string. # # @param value [Symbol, String, Integer, nil] assigned value # @return [String, Integer, nil] state name, or the original type's cast # in passthrough mode def cast(value) states = named_states return @raw_type.cast(value) if passthrough?(states) return nil if value.nil? state = states.detect { |s| s.name.to_s == value.to_s } state ||= states.detect { |s| state_integer(s, states) == value.to_i } if value.respond_to?(:to_i) state ? state.name.to_s : value.to_s end # Converts a state name string to its integer for the database write. # # @param value [String, Symbol, Integer, nil] in-memory value # @return [Integer, nil] integer to store def serialize(value) states = named_states return @raw_type.serialize(value) if passthrough?(states) return nil if value.nil? state = states.detect { |s| s.name.to_s == value.to_s } state ? state_integer(state, states) : value end # @return [Symbol] the ActiveModel type identifier def type :integer end private # All non-nil states in definition order, not memoized because states are # added to the collection after the type is instantiated. # # @return [Array] def named_states @states.reject { |s| s.name.nil? } end # Whether the machine was defined for raw integer storage, in which case # conversion would change documented behavior. True when every named state # declares an explicit integer value. Evaluated lazily on every call # because states (and their values) are defined after the type is # registered. Uses value(false) so dynamic (Proc) state values are never # evaluated for this metadata decision. # # @param states [Array] pre-computed named states # @return [Boolean] def passthrough?(states) states.any? && states.all? { |s| s.value(false).is_a?(::Integer) } end # The integer to use for storage: the explicit state value if set # (e.g. state :pending, value: 2), otherwise the index position among # named states. # # @param state [StateMachines::State] # @param states [Array] pre-computed named states # @return [Integer] def state_integer(state, states) value = state.value(false) value.is_a?(::Integer) ? value : states.index(state) end end end end version.rb000066400000000000000000000002161521305501100355470ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/lib/state_machines/integrations/active_record# frozen_string_literal: true module StateMachines module Integrations module ActiveRecord VERSION = '0.200.0' end end end state-machines-state_machines-activerecord-b9cb1e5/log/000077500000000000000000000000001521305501100233225ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/log/.gitkeep000066400000000000000000000000001521305501100247410ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/release-please-config.json000066400000000000000000000003151521305501100275650ustar00rootroot00000000000000{ "packages": { ".": { "release-type": "ruby", "package-name": "state_machines-activerecord", "version-file": "lib/state_machines/integrations/active_record/version.rb" } } } state-machines-state_machines-activerecord-b9cb1e5/state_machines-activerecord.gemspec000066400000000000000000000023501521305501100315450ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'lib/state_machines/integrations/active_record/version' Gem::Specification.new do |spec| spec.name = 'state_machines-activerecord' spec.version = StateMachines::Integrations::ActiveRecord::VERSION spec.authors = ['Abdelkader Boudih', 'Aaron Pfeifer'] spec.email = %w[terminale@gmail.com aaron@pluginaweek.org] spec.summary = 'State machines Active Record Integration' spec.description = 'Adds support for creating state machines for attributes on ActiveRecord' spec.homepage = 'https://github.com/state-machines/state_machines-activerecord/' spec.license = 'MIT' spec.files = Dir['{lib}/**/*', 'LICENSE.txt', 'README.md'] spec.required_ruby_version = '>= 3.2' spec.require_paths = ['lib'] spec.add_dependency 'activerecord', '>= 7.2' spec.add_dependency 'state_machines-activemodel', '>= 0.200.0' spec.add_development_dependency 'appraisal', '>= 1' spec.add_development_dependency 'minitest', '= 5.27.0' spec.add_development_dependency 'minitest-reporters' spec.add_development_dependency 'rake', '~> 13.0' spec.add_development_dependency 'sqlite3', '~> 2.1' spec.metadata['rubygems_mfa_required'] = 'true' end state-machines-state_machines-activerecord-b9cb1e5/test/000077500000000000000000000000001521305501100235205ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test/files/000077500000000000000000000000001521305501100246225ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test/files/en.yml000066400000000000000000000001371521305501100257500ustar00rootroot00000000000000en: activerecord: errors: messages: invalid_transition: "cannot transition"state-machines-state_machines-activerecord-b9cb1e5/test/files/models/000077500000000000000000000000001521305501100261055ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test/files/models/post.rb000066400000000000000000000003601521305501100274160ustar00rootroot00000000000000# frozen_string_literal: true ActiveRecord::Base.connection.create_table(:posts, force: true) do |t| t.string :title t.string :content t.string :state end class Post < ActiveRecord::Base state_machine initial: :draft do end end state-machines-state_machines-activerecord-b9cb1e5/test/integration_test.rb000066400000000000000000000027541521305501100274370ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class IntegrationTest < BaseTestCase def teardown StateMachines::Integrations::ActiveRecord.auto_convert_integer_state_attributes = true super end def test_should_have_an_integration_name assert_equal :active_record, StateMachines::Integrations::ActiveRecord.integration_name end def test_should_be_before_activemodel integrations = StateMachines::Integrations.list.to_a assert StateMachines::Integrations::ActiveRecord, integrations.first assert StateMachines::Integrations::ActiveModel, integrations.last end def test_should_match_if_class_inherits_from_active_record assert StateMachines::Integrations::ActiveRecord.matches?(new_model) end def test_should_not_match_if_class_does_not_inherit_from_active_record refute StateMachines::Integrations::ActiveRecord.matches?(Class.new) end def test_should_have_defaults assert_equal({ action: :save, use_transactions: true }, StateMachines::Integrations::ActiveRecord.defaults) end def test_should_auto_convert_integer_state_attributes_by_default assert_equal true, StateMachines::Integrations::ActiveRecord.auto_convert_integer_state_attributes end def test_should_allow_integer_state_attribute_conversion_to_be_disabled StateMachines::Integrations::ActiveRecord.auto_convert_integer_state_attributes = false assert_equal false, StateMachines::Integrations::ActiveRecord.auto_convert_integer_state_attributes end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_by_default_test.rb000066400000000000000000000005671521305501100307160ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineByDefaultTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model) end def test_should_use_save_as_action assert_equal :save, @machine.action end def test_should_use_transactions assert_equal true, @machine.use_transactions end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_errors_test.rb000066400000000000000000000011371521305501100301060ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineErrorsTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model) @record = @model.new end def test_should_be_able_to_describe_current_errors @record.errors.add(:id, 'cannot be blank') @record.errors.add(:state, 'is invalid') assert_equal ['Id cannot be blank', 'State is invalid'], @machine.errors_for(@record).split(', ').sort end def test_should_describe_as_halted_with_no_errors assert_equal 'Transition halted', @machine.errors_for(@record) end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_multiple_test.rb000066400000000000000000000010261521305501100304220ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineMultipleTest < BaseTestCase def setup @model = new_model do connection.add_column table_name, :status, :string end @state_machine = StateMachines::Machine.new(@model, initial: :parked) @status_machine = StateMachines::Machine.new(@model, :status, initial: :idling) end def test_should_should_initialize_each_state record = @model.new assert_equal 'parked', record.state assert_equal 'idling', record.status end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_nested_action_test.rb000066400000000000000000000016341521305501100314130ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineNestedActionTest < BaseTestCase def setup @callbacks = [] @model = new_model @machine = StateMachines::Machine.new(@model) @machine.event :ignite do transition parked: :idling end @record = @model.new(state: 'parked') end def test_should_allow_transition_prior_to_creation_if_skipping_action record = @record @model.before_create { record.ignite(false) } result = @record.save assert_equal true, result assert_equal 'idling', @record.state @record.reload assert_equal 'idling', @record.state end def test_should_allow_transition_after_creation record = @record @model.after_create { record.ignite } result = @record.save assert_equal true, result assert_equal 'idling', @record.state @record.reload assert_equal 'idling', @record.state end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_unmigrated_test.rb000066400000000000000000000006251521305501100307320ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineUnmigratedTest < BaseTestCase def setup @model = new_model(false) # Drop the table so that it definitely doesn't exist @model.connection.drop_table(@model.table_name) if @model.table_exists? end def test_should_allow_machine_creation assert_nothing_raised { StateMachines::Machine.new(@model) } end end machine_with_after_commit_transition_callbacks_test.rb000066400000000000000000000222661521305501100364760ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' class MachineWithAfterCommitTransitionCallbacksTest < BaseTestCase def setup @model = new_model do attr_reader :callback_log def positional_method(transition) log_callback(:positional, transition) end def do_method(transition) log_callback(:do, transition) end def log_callback(name, transition) (@callback_log ||= []) << [name, transition.event] end end @machine = StateMachines::Machine.new(@model, initial: :parked) @machine.other_states :idling @machine.event :ignite do transition parked: :idling end end def test_should_run_callback_after_commit order = [] @machine.after_transition { order << :after_transition } @machine.after_transition(after_commit: true) { order << :deferred } @model.after_commit { order << :model_after_commit } record = @model.create order.clear record.ignite assert_equal %i[after_transition model_after_commit deferred], order end def test_should_defer_callback_until_outer_transaction_commits called = false @machine.after_transition(after_commit: true) { called = true } record = @model.create called = false @model.transaction do record.ignite refute called, 'Callback should not run before the outer transaction commits' end assert called end def test_should_discard_callback_on_rollback called = false @machine.after_transition(after_commit: true) { called = true } record = @model.create called = false @model.transaction do record.ignite raise ActiveRecord::Rollback end refute called assert_equal 'parked', record.reload.state end def test_should_pass_record_and_transition_to_callback callback_args = nil @machine.after_transition(after_commit: true) { |*args| callback_args = args } record = @model.create record.ignite object, transition = callback_args assert_equal record, object assert_instance_of StateMachines::Transition, transition assert_equal :ignite, transition.event assert_equal 'parked', transition.from assert_equal 'idling', transition.to end def test_should_run_outside_the_transaction plain_in_transaction = nil deferred_in_transaction = nil @machine.after_transition { |object| plain_in_transaction = object.class.connection.transaction_open? } @machine.after_transition(after_commit: true) { |object| deferred_in_transaction = object.class.connection.transaction_open? } record = @model.create record.ignite assert plain_in_transaction, 'Plain after_transition should run inside the transaction' refute deferred_in_transaction, 'Deferred callback should run after the transaction has committed' end def test_should_support_do_option_with_method_symbol @machine.after_transition(after_commit: true, do: :do_method) record = @model.create record.ignite assert_equal [[:do, :ignite]], record.callback_log end def test_should_support_positional_method_symbol @machine.after_transition(:positional_method, after_commit: true) record = @model.create record.ignite assert_equal [[:positional, :ignite]], record.callback_log end def test_should_prefer_positional_methods_over_do_option # Core's parse_callback_arguments drops :do when positional methods are # given; the deferred path must match to avoid double-running side effects @machine.after_transition(:positional_method, after_commit: true, do: :do_method) record = @model.create record.ignite assert_equal [[:positional, :ignite]], record.callback_log end def test_should_bind_block_to_object_when_requested callback_self = nil callback_args = nil @machine.after_transition(after_commit: true, bind_to_object: true) do |*args| callback_self = self callback_args = args end record = @model.create record.ignite assert_equal record, callback_self assert_equal 1, callback_args.length assert_instance_of StateMachines::Transition, callback_args.first end def test_should_work_with_global_bind_to_object_default original = StateMachines::Callback.bind_to_object StateMachines::Callback.bind_to_object = true callback_self = nil transition_arg = nil @machine.after_transition(after_commit: true) do |transition| callback_self = self transition_arg = transition end record = @model.create record.ignite assert_equal record, callback_self assert_instance_of StateMachines::Transition, transition_arg ensure StateMachines::Callback.bind_to_object = original end def test_should_detect_flag_in_positional_options_hash called = false @machine.after_transition({ after_commit: true }) { called = true } record = @model.create called = false @model.transaction do record.ignite refute called end assert called end def test_should_respect_branch_requirements fired = [] @machine.state :stalled @machine.event :crash do transition idling: :stalled end @machine.after_transition(on: :ignite, after_commit: true) { fired << :ignite_callback } @machine.after_transition(on: :crash, after_commit: true) { fired << :crash_callback } record = @model.create record.ignite assert_equal [:ignite_callback], fired record.crash assert_equal %i[ignite_callback crash_callback], fired end def test_should_run_immediately_when_no_transaction_is_open # With no action, the transition opens no transaction at all, so the # callback must execute synchronously via the null transaction model = new_model machine = StateMachines::Machine.new(model, initial: :parked, action: nil) machine.other_states :idling machine.event :ignite do transition parked: :idling end called_during_event = false machine.after_transition(after_commit: true) do |object| called_during_event = true refute object.class.connection.transaction_open? end record = model.create record.ignite assert called_during_event end def test_should_not_affect_transition_result @machine.after_transition(after_commit: true) { throw :halt } record = @model.create assert record.ignite assert_equal 'idling', record.state end def test_should_report_exception_and_preserve_committed_state subscriber = Class.new do attr_reader :errors def initialize @errors = [] end def report(error, **) @errors << error end end.new ActiveSupport.error_reporter.subscribe(subscriber) @machine.after_transition(after_commit: true) { raise 'boom' } record = @model.create assert record.ignite, 'Event should succeed despite the deferred callback raising' assert_equal 'idling', record.state, 'In-memory state must not be rolled back' assert_equal 'idling', record.reload.state assert_equal ['boom'], subscriber.errors.map(&:message) ensure ActiveSupport.error_reporter.unsubscribe(subscriber) end def test_should_not_mutate_do_option_array do_methods = [:do_method].freeze @machine.after_transition(after_commit: true, do: do_methods) record = @model.create record.ignite assert_equal [[:do, :ignite]], record.callback_log assert_equal [:do_method], do_methods end def test_should_match_model_after_commit_semantics_under_non_joinable_wrapper # joinable: false wrappers (e.g. transactional test fixtures) are # transparent to all of Rails' commit callbacks; ours must behave the same fired = [] @model.after_commit { fired << :model } @machine.after_transition(after_commit: true) { fired << :deferred } record = @model.create fired.clear @model.transaction(joinable: false) do record.ignite raise ActiveRecord::Rollback end assert_equal %i[model deferred], fired end def test_should_keep_state_named_after_commit_as_branch_requirement fired = [] @machine.state :after_commit, :done @machine.event :finish do transition after_commit: :done end @machine.after_transition(after_commit: :done) { fired << :scoped } record = @model.create record.ignite assert_empty fired, 'Callback scoped to after_commit => done must not fire for parked => idling' record.update!(state: 'after_commit') record.finish assert_equal [:scoped], fired end def test_should_treat_false_flag_as_regular_callback in_transaction = nil @machine.after_transition(after_commit: false) { |object| in_transaction = object.class.connection.transaction_open? } record = @model.create record.ignite assert in_transaction, 'after_commit: false should behave as a plain after_transition' end def test_should_raise_without_methods assert_raises(ArgumentError) { @machine.after_transition(after_commit: true) } end def test_should_not_alter_plain_after_transition called = false @machine.after_transition { called = true } record = @model.create called = false @model.transaction do record.ignite assert called, 'Plain after_transition should still run inside the transaction' end end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_aliased_attribute_test.rb000066400000000000000000000010651521305501100333120ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithAliasedAttributeTest < BaseTestCase def setup @model = new_model do alias_attribute :vehicle_status, :state end @machine = StateMachines::Machine.new(@model, :status, attribute: :vehicle_status) @machine.state :parked @record = @model.new end def test_should_check_custom_attribute_for_predicate @record.vehicle_status = nil refute @record.status?(:parked) @record.vehicle_status = 'parked' assert @record.status?(:parked) end end machine_with_auto_indexed_integer_column_default_test.rb000066400000000000000000000030241521305501100370110ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' require 'stringio' # Auto-indexed integer machines store the first state as 0, so a column # default of 0 matches the initial state and must not trigger the # conflicting-default warning. A genuinely different default still warns. class MachineWithSameAutoIndexedIntegerColumnDefaultTest < BaseTestCase def setup @original_stderr = $stderr $stderr = StringIO.new @model = new_model do connection.add_column table_name, :status, :integer, default: 0 end @machine = StateMachines::Machine.new(@model, :status, initial: :open) do state :open state :closed end @record = @model.new end def teardown $stderr = @original_stderr super end def test_should_use_machine_default assert_equal 'open', @record.status end def test_should_not_generate_a_warning assert_no_match(/have defined a different default/, $stderr.string) end end class MachineWithDifferentAutoIndexedIntegerColumnDefaultTest < BaseTestCase def setup @original_stderr = $stderr $stderr = StringIO.new @model = new_model do connection.add_column table_name, :status, :integer, default: 1 end @machine = StateMachines::Machine.new(@model, :status, initial: :open) do state :open state :closed end @record = @model.new end def teardown $stderr = @original_stderr super end def test_should_generate_a_warning assert_match(/have defined a different default/, $stderr.string) end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_callbacks_test.rb000066400000000000000000000111461521305501100315450ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithCallbacksTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model, initial: :parked) @machine.other_states :idling @machine.event :ignite @record = @model.new(state: 'parked') @transition = StateMachines::Transition.new(@record, @machine, :ignite, :parked, :idling) end def test_should_run_before_callbacks called = false @machine.before_transition { called = true } @transition.perform assert called end def test_should_pass_record_to_before_callbacks_with_one_argument record = nil @machine.before_transition { |arg| record = arg } @transition.perform assert_equal @record, record end def test_should_pass_record_and_transition_to_before_callbacks_with_multiple_arguments callback_args = nil @machine.before_transition { |*args| callback_args = args } @transition.perform assert_equal [@record, @transition], callback_args end def test_should_run_before_callbacks_outside_the_context_of_the_record context = nil @machine.before_transition { context = self } @transition.perform assert_equal self, context end def test_should_run_after_callbacks called = false @machine.after_transition { called = true } @transition.perform assert called end def test_should_pass_record_to_after_callbacks_with_one_argument record = nil @machine.after_transition { |arg| record = arg } @transition.perform assert_equal @record, record end def test_should_pass_record_and_transition_to_after_callbacks_with_multiple_arguments callback_args = nil @machine.after_transition { |*args| callback_args = args } @transition.perform assert_equal [@record, @transition], callback_args end def test_should_run_after_callbacks_outside_the_context_of_the_record context = nil @machine.after_transition { context = self } @transition.perform assert_equal self, context end def test_should_run_after_callbacks_if_model_callback_added_prior_to_state_machine_definition model = new_model do after_save { nil } end machine = StateMachines::Machine.new(model, initial: :parked) machine.other_states :idling machine.event :ignite after_called = false machine.after_transition { after_called = true } record = model.new(state: 'parked') transition = StateMachines::Transition.new(record, machine, :ignite, :parked, :idling) transition.perform assert_equal true, after_called end def test_should_run_around_callbacks before_called = false after_called = false ensure_called = 0 @machine.around_transition do |block| before_called = true begin block.call ensure ensure_called += 1 end after_called = true end @transition.perform assert before_called assert after_called assert_equal ensure_called, 1 end def test_should_include_transition_states_in_known_states @machine.before_transition to: :first_gear, do: -> {} assert_equal(%i[parked idling first_gear], @machine.states.map { |state| state.name }) end def test_should_allow_symbolic_callbacks callback_args = nil klass = class << @record self end klass.send(:define_method, :after_ignite) do |*args| callback_args = args end @machine.before_transition(:after_ignite) @transition.perform assert_equal [@transition], callback_args end def test_should_allow_string_callbacks class << @record attr_reader :callback_result end @machine.before_transition('@callback_result = [1, 2, 3]') @transition.perform assert_equal [1, 2, 3], @record.callback_result end def test_should_run_in_expected_order expected = %i[ before_transition before_validation after_validation before_save before_create after_create after_save after_transition ] callbacks = [] @model.before_validation { callbacks << :before_validation } @model.after_validation { callbacks << :after_validation } @model.before_save { callbacks << :before_save } @model.before_create { callbacks << :before_create } @model.after_create { callbacks << :after_create } @model.after_save { callbacks << :after_save } @model.after_commit { callbacks << :after_commit } expected << :after_commit @machine.before_transition { callbacks << :before_transition } @machine.after_transition { callbacks << :after_transition } @transition.perform assert_equal expected, callbacks end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_column_state_attribute_test.rb000066400000000000000000000022171521305501100344050ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithColumnStateAttributeTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model, initial: :parked) @machine.other_states(:idling) @record = @model.new end def test_should_not_override_the_column_reader @record[:state] = 'parked' assert_equal 'parked', @record.state end def test_should_not_override_the_column_writer @record.state = 'parked' assert_equal 'parked', @record[:state] end def test_should_have_an_attribute_predicate assert @record.respond_to?(:state?) end def test_should_test_for_existence_on_predicate_without_parameters assert @record.state? @record.state = nil refute @record.state? end def test_should_return_false_for_predicate_if_does_not_match_current_value refute @record.state?(:idling) end def test_should_return_true_for_predicate_if_matches_current_value assert @record.state?(:parked) end def test_should_raise_exception_for_predicate_if_invalid_state_specified assert_raises(IndexError) { @record.state?(:invalid) } end end machine_with_complex_pluralization_scopes_test.rb000066400000000000000000000006461521305501100355520ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' class MachineWithComplexPluralizationScopesTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model, :status) end def test_should_create_singular_with_scope assert @model.respond_to?(:with_status) end def test_should_create_plural_with_scope assert @model.respond_to?(:with_statuses) end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_conflicting_predicate_test.rb000066400000000000000000000005761521305501100341520ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithConflictingPredicateTest < BaseTestCase def setup @model = new_model do def state?(*_args) true end end @machine = StateMachines::Machine.new(@model) @record = @model.new end def test_should_not_define_attribute_predicate assert @record.state? end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_conflicting_state_name_test.rb000066400000000000000000000015431521305501100343250ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' require 'stringio' class MachineWithConflictingStateNameTest < BaseTestCase def setup @original_stderr = $stderr $stderr = StringIO.new @model = new_model end def test_should_output_warning_with_same_machine_name @machine = StateMachines::Machine.new(@model) @machine.state :state assert_match(/^Instance method "state\?" is already defined in .+, use generic helper instead.*\n$/, $stderr.string) end def test_should_output_warning_with_same_machine_attribute @machine = StateMachines::Machine.new(@model, :public_state, attribute: :state) @machine.state :state assert_match(/^Instance method "state\?" is already defined in .+, use generic helper instead.*\n$/, $stderr.string) end def teardown $stderr = @original_stderr super end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_custom_attribute_test.rb000066400000000000000000000010471521305501100332220ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' require 'stringio' class MachineWithCustomAttributeTest < BaseTestCase def setup @original_stderr = $stderr $stderr = StringIO.new @model = new_model @machine = StateMachines::Machine.new(@model, :public_state, attribute: :state) @record = @model.new end def test_should_not_delegate_attribute_predicate_with_different_attribute assert_raise(ArgumentError) { @record.public_state? } end def teardown $stderr = @original_stderr super end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_default_scope_test.rb000066400000000000000000000007201521305501100324370ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithDefaultScopeTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model, initial: :parked) @machine.state :idling @model.class_eval do default_scope { with_state(:parked, :idling) } end end def test_should_set_initial_state_on_created_object object = @model.new assert_equal 'parked', object.state end end machine_with_different_column_default_test.rb000066400000000000000000000015041521305501100345730ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' require 'stringio' class MachineWithDifferentColumnDefaultTest < BaseTestCase def setup @original_stderr = $stderr $stderr = StringIO.new @model = new_model do connection.add_column table_name, :status, :string, default: 'idling' end @machine = StateMachines::Machine.new(@model, :status, initial: :parked) @record = @model.new end def test_should_use_machine_default assert_equal 'parked', @record.status end def test_should_generate_a_warning assert_match( /Both Foo and its :status machine have defined a different default for "status". Use only one or the other for defining defaults to avoid unexpected behaviors\./, $stderr.string ) end def teardown $stderr = @original_stderr super end end machine_with_different_integer_column_default_test.rb000066400000000000000000000015431521305501100363130ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' require 'stringio' class MachineWithDifferentIntegerColumnDefaultTest < BaseTestCase def setup @original_stderr = $stderr $stderr = StringIO.new @model = new_model do connection.add_column table_name, :status, :integer, default: 0 end @machine = StateMachines::Machine.new(@model, :status, initial: :parked) @machine.state :parked, value: 1 @record = @model.new end def test_should_use_machine_default assert_equal 1, @record.status end def test_should_generate_a_warning assert_match( /Both Foo and its :status machine have defined a different default for "status". Use only one or the other for defining defaults to avoid unexpected behaviors\./, $stderr.string ) end def teardown $stderr = @original_stderr super end end machine_with_dirty_attribute_and_custom_attributes_during_loopback_test.rb000066400000000000000000000013071521305501100426670ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' class MachineWithDirtyAttributeAndCustomAttributesDuringLoopbackTest < BaseTestCase def setup @model = new_model do connection.add_column table_name, :status, :string end @machine = StateMachines::Machine.new(@model, :status, initial: :parked) @machine.event :park @record = @model.create @transition = StateMachines::Transition.new(@record, @machine, :park, :parked, :parked) @transition.perform(false) end def test_should_not_include_state_in_changed_attributes assert_equal [], @record.changed end def test_should_not_track_attribute_changes assert_nil @record.changes['status'] end end machine_with_dirty_attribute_and_state_events_test.rb000066400000000000000000000010161521305501100363660ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' class MachineWithDirtyAttributeAndStateEventsTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model, initial: :parked) @machine.event :ignite @record = @model.create @record.state_event = 'ignite' end def test_should_not_include_state_in_changed_attributes assert_equal [], @record.changed end def test_should_not_track_attribute_change assert_nil @record.changes['state'] end end machine_with_dirty_attributes_and_custom_attribute_test.rb000066400000000000000000000017511521305501100374500ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' class MachineWithDirtyAttributesAndCustomAttributeTest < BaseTestCase def setup @model = new_model do connection.add_column table_name, :status, :string end @machine = StateMachines::Machine.new(@model, :status, initial: :parked) @machine.event :ignite @machine.state :idling @record = @model.create @transition = StateMachines::Transition.new(@record, @machine, :ignite, :parked, :idling) @transition.perform(false) end def test_should_include_state_in_changed_attributes assert_equal %w[status], @record.changed end def test_should_track_attribute_change assert_equal %w[parked idling], @record.changes['status'] end def test_should_not_reset_changes_on_multiple_transitions transition = StateMachines::Transition.new(@record, @machine, :ignite, :idling, :idling) transition.perform(false) assert_equal %w[parked idling], @record.changes['status'] end end machine_with_dirty_attributes_during_loopback_test.rb000066400000000000000000000011471521305501100363720ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' class MachineWithDirtyAttributesDuringLoopbackTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model, initial: :parked) @machine.event :park @record = @model.create @transition = StateMachines::Transition.new(@record, @machine, :park, :parked, :parked) @transition.perform(false) end def test_should_not_include_state_in_changed_attributes assert_equal [], @record.changed end def test_should_not_track_attribute_changes assert_nil @record.changes['state'] end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_dirty_attributes_test.rb000066400000000000000000000020131521305501100332200ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithDirtyAttributesTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model, initial: :parked) @machine.event :ignite @machine.state :idling @record = @model.create @transition = StateMachines::Transition.new(@record, @machine, :ignite, :parked, :idling) @transition.perform(false) end def test_should_include_state_in_changed_attributes assert_equal %w[state], @record.changed end def test_should_track_attribute_change assert_equal %w[parked idling], @record.changes['state'] end def test_should_not_reset_changes_on_multiple_transitions transition = StateMachines::Transition.new(@record, @machine, :ignite, :idling, :idling) transition.perform(false) assert_equal %w[parked idling], @record.changes['state'] end def test_should_not_have_changes_when_loaded_from_database record = @model.find(@record.id) refute record.changed? end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_dynamic_initial_state_test.rb000066400000000000000000000045601521305501100341650ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithDynamicInitialStateTest < BaseTestCase def setup @model = new_model do attr_accessor :value end @machine = StateMachines::Machine.new(@model, initial: ->(_object) { :parked }) @machine.state :parked end def test_should_set_initial_state_on_created_object record = @model.new assert_equal 'parked', record.state end def test_should_still_set_attributes record = @model.new(value: 1) assert_equal 1, record.value end def test_should_still_allow_initialize_blocks block_args = nil record = @model.new do |*args| block_args = args end assert_equal [record], block_args end def test_should_set_attributes_prior_to_initialize_block state = nil @model.new do |record| state = record.state end assert_equal 'parked', state end def test_should_set_attributes_prior_to_after_initialize_hook state = nil @model.after_initialize do |record| state = record.state end @model.new assert_equal 'parked', state end def test_should_set_initial_state_after_setting_attributes @model.class_eval do attr_accessor :state_during_setter remove_method :value= define_method(:value=) do |_value| self.state_during_setter = state || 'nil' end end record = @model.new(value: 1) assert_equal 'nil', record.state_during_setter end def test_should_not_set_initial_state_after_already_initialized record = @model.new(value: 1) assert_equal 'parked', record.state record.state = 'idling' record.attributes = {} assert_equal 'idling', record.state end def test_should_persist_initial_state record = @model.new record.save record.reload assert_equal 'parked', record.state end def test_should_persist_initial_state_on_dup record = @model.create.dup record.save record.reload assert_equal 'parked', record.state end def test_should_use_stored_values_when_loading_from_database @machine.state :idling record = @model.find(@model.create(state: 'idling').id) assert_equal 'idling', record.state end def test_should_use_stored_values_when_loading_from_database_with_nil_state @machine.state nil record = @model.find(@model.create(state: nil).id) assert_nil record.state end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_enum_integration_test.rb000066400000000000000000000166731521305501100332070ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithEnumIntegrationTest < BaseTestCase def setup @original_stderr = $stderr $stderr = StringIO.new @model = new_model do connection.add_column table_name, :status, :integer, default: 0 enum :status, { pending: 0, processing: 1, completed: 2, failed: 3 } end end def teardown StateMachines::Integrations::ActiveRecord.auto_convert_integer_state_attributes = true $stderr = @original_stderr super end test 'should auto detect enum integration' do @machine = StateMachines::Machine.new(@model, :status) @machine.state :pending, :processing, :completed, :failed # Test enum integration detection assert @machine.respond_to?(:enum_integrated?), 'Machine should respond to enum_integrated?' assert @machine.enum_integrated? assert_equal({ 'pending' => 0, 'processing' => 1, 'completed' => 2, 'failed' => 3 }, @machine.enum_mapping) # Test that states are properly defined using shared assertions assert_sm_states_list(@machine, %i[pending processing completed failed]) end test 'should keep enum integration when integer conversion is disabled' do StateMachines::Integrations::ActiveRecord.auto_convert_integer_state_attributes = false machine = @model.state_machine(:status) do state :pending, :processing, :completed, :failed end assert machine.enum_integrated? refute machine.integer_type_registered? assert_equal({ 'pending' => 0, 'processing' => 1, 'completed' => 2, 'failed' => 3 }, machine.enum_mapping) end test 'should not auto detect when no enum exists' do @model_without_enum = new_model machine = @model_without_enum.state_machine(:status) do state :pending, :processing, :completed, :failed end assert_not machine.enum_integrated? # Test that states are still properly defined even without enum integration assert_sm_states_list(machine, %i[pending processing completed failed]) end test 'should detect existing enum methods' do machine = @model.state_machine(:status) do state :pending, :processing, :completed, :failed end original_methods = machine.original_enum_methods assert_includes original_methods, 'pending?' assert_includes original_methods, 'processing?' assert_includes original_methods, 'completed?' assert_includes original_methods, 'failed?' end test 'should generate prefixed method names for enum integration' do machine = @model.state_machine(:status) do state :pending, :processing, :completed, :failed end assert_equal 'status_pending?', machine.send(:generate_state_method_name, 'pending', :predicate) assert_equal 'status_processing!', machine.send(:generate_state_method_name, 'processing', :bang) assert_equal 'status_completed', machine.send(:generate_state_method_name, 'completed', :scope) end test 'should store default enum integration metadata' do machine = @model.state_machine(:status) do state :pending, :processing, :completed, :failed end config = machine.enum_integration assert_equal true, config[:prefix] assert_equal false, config[:suffix] assert_equal true, config[:scopes] end test 'should generate prefixed predicate methods' do @model.state_machine(:status) do state :pending, :processing, :completed, :failed end record = @model.create(status: :pending) # Test using shared state machine assertions assert_sm_state(record, :pending, machine_name: :status) # Original enum methods should still work assert record.pending? assert_not record.processing? # Prefixed state machine methods should be generated assert record.respond_to?(:status_pending?) assert record.respond_to?(:status_processing?) assert record.respond_to?(:status_completed?) assert record.respond_to?(:status_failed?) # Prefixed methods should work correctly assert record.status_pending? assert_not record.status_processing? assert_not record.status_completed? assert_not record.status_failed? end test 'should generate prefixed bang methods' do @model.state_machine(:status) do state :pending, :processing, :completed, :failed event :process do transition pending: :processing end event :complete do transition processing: :completed end event :fail do transition %i[pending processing] => :failed end end record = @model.create(status: :pending) # Prefixed bang methods should be available assert record.respond_to?(:status_processing!) assert record.respond_to?(:status_completed!) assert record.respond_to?(:status_failed!) # Bang methods should exist but raise an exception (conflict resolution, not full functionality) assert_raises(RuntimeError) do record.status_failed! end end test 'should generate prefixed scope methods' do @model.state_machine(:status) do state :pending, :processing, :completed, :failed end pending_record = @model.create(status: :pending) processing_record = @model.create(status: :processing) completed_record = @model.create(status: :completed) # Test record states using shared assertions assert_sm_state(pending_record, :pending, machine_name: :status) assert_sm_state(processing_record, :processing, machine_name: :status) assert_sm_state(completed_record, :completed, machine_name: :status) # Test state persistence using shared assertions assert_sm_state_persisted(pending_record, 'pending', :status) assert_sm_state_persisted(processing_record, 'processing', :status) assert_sm_state_persisted(completed_record, 'completed', :status) # Prefixed scope methods should be available assert @model.respond_to?(:status_pending) assert @model.respond_to?(:status_processing) assert @model.respond_to?(:status_completed) assert @model.respond_to?(:status_failed) # Negative scope methods should be available assert @model.respond_to?(:not_status_pending) assert @model.respond_to?(:not_status_processing) # Scopes should work correctly assert_equal [pending_record], @model.status_pending.to_a assert_equal [processing_record], @model.status_processing.to_a assert_equal [completed_record], @model.status_completed.to_a assert_equal [], @model.status_failed.to_a # Negative scopes should work assert_equal [processing_record, completed_record], @model.not_status_pending.order(:id).to_a end test 'should track generated methods for introspection' do machine = @model.state_machine(:status) do state :pending, :processing, :completed, :failed end generated_methods = machine.state_machine_methods # Should track all generated prefixed methods assert_includes generated_methods, 'status_pending?' assert_includes generated_methods, 'status_processing?' assert_includes generated_methods, 'status_completed?' assert_includes generated_methods, 'status_failed?' assert_includes generated_methods, 'status_pending!' assert_includes generated_methods, 'status_processing!' assert_includes generated_methods, 'status_completed!' assert_includes generated_methods, 'status_failed!' assert_includes generated_methods, 'status_pending' assert_includes generated_methods, 'status_processing' assert_includes generated_methods, 'status_completed' assert_includes generated_methods, 'status_failed' end end machine_with_event_attributes_on_autosave_test.rb000066400000000000000000000027411521305501100355420ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' class MachineWithEventAttributesOnAutosaveTest < BaseTestCase def setup @vehicle_model = new_model(:vehicle) do connection.add_column table_name, :owner_id, :integer end MachineWithEventAttributesOnAutosaveTest.const_set('Vehicle', @vehicle_model) @owner_model = new_model(:owner) MachineWithEventAttributesOnAutosaveTest.const_set('Owner', @owner_model) machine = StateMachines::Machine.new(@vehicle_model) machine.event :ignite do transition parked: :idling end @owner = @owner_model.create @vehicle = @vehicle_model.create(state: 'parked', owner_id: @owner.id) end def test_should_persist_has_one_autosave @owner_model.has_one :vehicle, class_name: 'MachineWithEventAttributesOnAutosaveTest::Vehicle', autosave: true @owner.vehicle.state_event = 'ignite' @owner.save @vehicle.reload assert_equal 'idling', @vehicle.state end def test_should_persist_has_many_autosave @owner_model.has_many :vehicles, class_name: 'MachineWithEventAttributesOnAutosaveTest::Vehicle', autosave: true @owner.vehicles[0].state_event = 'ignite' @owner.save @vehicle.reload assert_equal 'idling', @vehicle.state end def teardown MachineWithEventAttributesOnAutosaveTest.class_eval do remove_const('Vehicle') remove_const('Owner') end clear_active_support_dependencies super end end machine_with_event_attributes_on_custom_action_test.rb000066400000000000000000000016621521305501100365630ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' class MachineWithEventAttributesOnCustomActionTest < BaseTestCase def setup @superclass = new_model do def persist create_or_update end end @model = Class.new(@superclass) @machine = StateMachines::Machine.new(@model, action: :persist) @machine.event :ignite do transition parked: :idling end @record = @model.new @record.state = 'parked' @record.state_event = 'ignite' end def test_should_not_transition_on_valid? @record.valid? assert_equal 'parked', @record.state end def test_should_not_transition_on_save @record.save assert_equal 'parked', @record.state end def test_should_not_transition_on_save! @record.save! assert_equal 'parked', @record.state end def test_should_transition_on_custom_action @record.persist assert_equal 'idling', @record.state end end machine_with_event_attributes_on_save_bang_test.rb000066400000000000000000000040441521305501100356360ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' class MachineWithEventAttributesOnSaveBangTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model) @machine.event :ignite do transition parked: :idling end @record = @model.new @record.state = 'parked' @record.state_event = 'ignite' end def test_should_fail_if_event_is_invalid @record.state_event = 'invalid' assert_raise(ActiveRecord::RecordInvalid) { @record.save! } end def test_should_fail_if_event_has_no_transition @record.state = 'idling' assert_raise(ActiveRecord::RecordInvalid) { @record.save! } end def test_should_be_successful_if_event_has_transition assert_equal true, @record.save! end def test_should_run_before_callbacks ran_callback = false @machine.before_transition { ran_callback = true } @record.save! assert ran_callback end def test_should_run_before_callbacks_once before_count = 0 @machine.before_transition { before_count += 1 } @record.save! assert_equal 1, before_count end def test_should_run_around_callbacks_before_yield ran_callback = false @machine.around_transition do |block| ran_callback = true block.call end @record.save! assert ran_callback end def test_should_run_around_callbacks_before_yield_once around_before_count = 0 @machine.around_transition do |block| around_before_count += 1 block.call end @record.save! assert_equal 1, around_before_count end def test_should_persist_new_state @record.save! assert_equal 'idling', @record.state end def test_should_run_after_callbacks ran_callback = false @machine.after_transition { ran_callback = true } @record.save! assert ran_callback end def test_should_run_around_callbacks_after_yield ran_callback = false @machine.around_transition do |block| block.call ran_callback = true end @record.save! assert ran_callback end end machine_with_event_attributes_on_save_test.rb000066400000000000000000000146041521305501100346520ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' class MachineWithEventAttributesOnSaveTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model) @machine.event :ignite do transition parked: :idling end @record = @model.new @record.state = 'parked' @record.state_event = 'ignite' end def test_should_fail_if_event_is_invalid @record.state_event = 'invalid' assert_equal false, @record.save end def test_should_fail_if_event_has_no_transition @record.state = 'idling' assert_equal false, @record.save end def test_should_run_before_callbacks ran_callback = false @machine.before_transition { ran_callback = true } @record.save assert ran_callback end def test_should_run_before_callbacks_once before_count = 0 @machine.before_transition { before_count += 1 } @record.save assert_equal 1, before_count end def test_should_run_around_callbacks_before_yield ran_callback = false @machine.around_transition do |block| ran_callback = true block.call end @record.save assert ran_callback end def test_should_run_around_callbacks_before_yield_once around_before_count = 0 @machine.around_transition do |block| around_before_count += 1 block.call end @record.save assert_equal 1, around_before_count end def test_should_persist_new_state @record.save assert_equal 'idling', @record.state end def test_should_run_after_callbacks ran_callback = false @machine.after_transition { ran_callback = true } @record.save assert ran_callback end def test_should_not_run_after_callbacks_with_failures_disabled_if_fails @model.before_create { |_record| abort_from_callback } ran_callback = false @machine.after_transition { ran_callback = true } begin @record.save rescue StandardError end refute ran_callback end def test_should_run_failure_callbacks__if_fails @model.before_create { |_record| abort_from_callback } ran_callback = false @machine.after_failure { ran_callback = true } begin @record.save rescue StandardError end assert ran_callback end def test_should_not_run_around_callbacks_if_fails @model.before_create { |_record| abort_from_callback } ran_callback = false @machine.around_transition do |block| block.call ran_callback = true end begin @record.save rescue StandardError end refute ran_callback end def test_should_run_around_callbacks_after_yield ran_callback = false @machine.around_transition do |block| block.call ran_callback = true end @record.save assert ran_callback end def test_should_run_before_transitions_within_transaction @machine.before_transition do @model.create raise ActiveRecord::Rollback end begin @record.save rescue Exception end assert_equal 0, @model.count end def test_should_run_after_transitions_within_transaction @machine.after_transition do @model.create raise ActiveRecord::Rollback end begin @record.save rescue Exception end assert_equal 0, @model.count end def test_should_run_around_transition_within_transaction @machine.around_transition do @model.create raise ActiveRecord::Rollback end begin @record.save rescue Exception end assert_equal 0, @model.count end def test_should_allow_additional_transitions_to_new_state_in_after_transitions @machine.event :park do transition idling: :parked end @machine.after_transition(on: :ignite) { @record.park } @record.save assert_equal 'parked', @record.state @record.reload assert_equal 'parked', @record.state end def test_should_allow_additional_transitions_to_previous_state_in_after_transitions @machine.event :shift_up do transition idling: :first_gear end @machine.after_transition(on: :ignite) { @record.shift_up } @record.save assert_equal 'first_gear', @record.state @record.reload assert_equal 'first_gear', @record.state end def test_should_yield_one_model! assert_equal true, @record.save! assert_equal 1, @model.count end # explicit tests of #save and #save! to ensure expected behavior def test_should_yield_two_models_with_before @machine.before_transition { @model.create! } assert_equal true, @record.save assert_equal 2, @model.count end def test_should_yield_two_models_with_before! @machine.before_transition { @model.create! } assert_equal true, @record.save! assert_equal 2, @model.count end def test_should_raise_on_around_transition_rollback! @machine.before_transition { @model.create! } @machine.around_transition do @model.create! raise ActiveRecord::Rollback end raised = false begin @record.save! rescue Exception raised = true end assert_equal true, raised assert_equal 0, @model.count end def test_should_return_nil_on_around_transition_rollback @machine.before_transition { @model.create! } @machine.around_transition do @model.create! raise ActiveRecord::Rollback end assert_nil @record.save assert_equal 0, @model.count end def test_should_return_nil_on_before_transition_rollback @machine.before_transition { raise ActiveRecord::Rollback } assert_nil @record.save assert_equal 0, @model.count end # # @rosskevin - This fails and I'm not sure why, it was existing behavior. # see: https://github.com/state-machines/state_machines-activerecord/pull/26#issuecomment-112911886 # # def test_should_yield_three_models_with_before_and_around_save # @machine.before_transition { @model.create!; puts "before ran, now #{@model.count}" } # @machine.around_transition { @model.create!; puts "around ran, now #{@model.count}" } # # assert_equal true, @record.save # assert_equal 3, @model.count # end # # def test_should_yield_three_models_with_before_and_around_save! # @machine.before_transition { @model.create!; puts "before ran, now #{@model.count}" } # @machine.around_transition { @model.create!; puts "around ran, now #{@model.count}" } # # assert_equal true, @record.save! # assert_equal 3, @model.count # end end machine_with_event_attributes_on_validation_test.rb000066400000000000000000000065541521305501100360530ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' class MachineWithEventAttributesOnValidationTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model) @machine.event :ignite do transition parked: :idling end @record = @model.new @record.state = 'parked' @record.state_event = 'ignite' end def test_should_fail_if_event_is_invalid @record.state_event = 'invalid' refute @record.valid? assert_equal ['State event is invalid'], @record.errors.full_messages end def test_should_fail_if_event_has_no_transition @record.state = 'idling' refute @record.valid? assert_equal ['State event cannot transition when idling'], @record.errors.full_messages end def test_should_be_successful_if_event_has_transition assert @record.valid? end def test_should_run_before_callbacks ran_callback = false @machine.before_transition { ran_callback = true } @record.valid? assert ran_callback end def test_should_run_around_callbacks_before_yield ran_callback = false @machine.around_transition do |block| ran_callback = true block.call end begin @record.valid? rescue ArgumentError raise if StateMachines::Transition.pause_supported? end assert ran_callback end def test_should_persist_new_state @record.valid? assert_equal 'idling', @record.state end def test_should_not_run_after_callbacks ran_callback = false @machine.after_transition { ran_callback = true } @record.valid? refute ran_callback end def test_should_not_run_after_callbacks_with_failures_disabled_if_validation_fails @model.class_eval do attr_accessor :seatbelt validates :seatbelt, presence: true end ran_callback = false @machine.after_transition { ran_callback = true } @record.valid? refute ran_callback end def test_should_run_after_callbacks_if_validation_fails @model.class_eval do attr_accessor :seatbelt validates :seatbelt, presence: true end ran_callback = false @machine.after_failure { ran_callback = true } @record.valid? assert ran_callback end def test_should_not_run_around_callbacks_after_yield ran_callback = false @machine.around_transition do |block| block.call ran_callback = true end begin @record.valid? rescue ArgumentError raise if StateMachines::Transition.pause_supported? end refute ran_callback end def test_should_not_run_around_callbacks_after_yield_with_failures_disabled_if_validation_fails @model.class_eval do attr_accessor :seatbelt validates :seatbelt, presence: true end ran_callback = false @machine.around_transition do |block| block.call ran_callback = true end @record.valid? refute ran_callback end def test_should_rollback_before_transitions_with_raise @machine.before_transition do @model.create raise ActiveRecord::Rollback end begin @record.valid? rescue Exception end assert_equal 0, @model.count end def test_should_rollback_before_transitions_with_false @machine.before_transition do @model.create false end begin @record.valid? rescue Exception end assert_equal 0, @model.count end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_events_test.rb000066400000000000000000000005171521305501100311320ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithEventsTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model) @machine.event :shift_up end def test_should_humanize_name assert_equal 'shift up', @machine.event(:shift_up).human_name end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_explicit_integer_values_test.rb000066400000000000000000000050111521305501100345350ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' # Regression test for https://github.com/state-machines/state_machines-activerecord/issues/132 # # Machines whose named states all declare explicit integer values # (state :pending, value: 0) are defined for raw integer storage: reads return # the integer, status_name returns the state name. The custom integer type # must pass values through untouched for these machines instead of converting # them to state name strings. class MachineWithExplicitIntegerValuesTest < BaseTestCase def setup @model = new_model do connection.add_column table_name, :status, :integer, default: 0 end @machine = StateMachines::Machine.new(@model, :status, initial: :pending) do state :pending, value: 0 state :approved, value: 1 state :declined, value: 2 event :approve do transition pending: :approved end event :decline do transition pending: :declined end end @record = @model.new @record.save! end def test_should_read_raw_integer_value assert_equal 0, @record.status end def test_status_name_returns_correct_symbol assert_equal :pending, @record.status_name end def test_should_accept_integer_on_write @record.status = 2 assert_equal 2, @record.status assert_equal :declined, @record.status_name end def test_should_accept_numeric_string_on_write @record.status = '1' assert_equal 1, @record.status assert_equal :approved, @record.status_name end def test_should_persist_explicit_integer_on_save @record.approve! raw = @model.connection.select_value("SELECT status FROM #{@model.quoted_table_name} WHERE id = #{@record.id}") assert_equal 1, raw.to_i end def test_should_reload_as_raw_integer @record.decline! reloaded = @model.find(@record.id) assert_equal 2, reloaded.status end def test_transition_fires_correctly @record.approve! assert_equal 1, @record.status end def test_predicate_returns_correct_result assert @record.pending? refute @record.approved? end def test_scope_returns_correct_records approved = @model.new approved.save! approved.approve! assert_includes @model.with_status(:pending), @record assert_includes @model.with_status(:approved), approved refute_includes @model.with_status(:approved), @record end def test_scope_uses_raw_integer_relation_condition assert_equal({ 'status' => 1 }, @model.with_status(:approved).where_values_hash) end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_failed_action_test.rb000066400000000000000000000022731521305501100324100ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithFailedActionTest < BaseTestCase def setup @model = new_model do validates_inclusion_of :state, in: %w[first_gear] end @machine = StateMachines::Machine.new(@model) @machine.state :parked, :idling @machine.event :ignite @callbacks = [] @machine.before_transition { @callbacks << :before } @machine.after_transition { @callbacks << :after } @machine.after_failure { @callbacks << :after_failure } @machine.around_transition do |block| @callbacks << :around_before block.call @callbacks << :around_after end @record = @model.new(state: 'parked') @transition = StateMachines::Transition.new(@record, @machine, :ignite, :parked, :idling) @result = @transition.perform end def test_should_not_be_successful refute @result end def test_should_not_change_current_state assert_equal 'parked', @record.state end def test_should_not_save_record assert @record.new_record? end def test_should_run_before_callbacks_and_after_callbacks_with_failures assert_equal %i[before around_before after_failure], @callbacks end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_failed_after_callbacks_test.rb000066400000000000000000000020731521305501100342310ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithFailedAfterCallbacksTest < BaseTestCase def setup @callbacks = [] @model = new_model @machine = StateMachines::Machine.new(@model) @machine.state :parked, :idling @machine.event :ignite @machine.after_transition do @callbacks << :after_1 false end @machine.after_transition { @callbacks << :after_2 } @machine.around_transition do |block| @callbacks << :around_before block.call @callbacks << :around_after end @record = @model.new(state: 'parked') @transition = StateMachines::Transition.new(@record, @machine, :ignite, :parked, :idling) @result = @transition.perform end def test_should_be_successful assert @result end def test_should_change_current_state assert_equal 'idling', @record.state end def test_should_save_record refute @record.new_record? end def test_should_not_run_further_after_callbacks assert_equal %i[around_before around_after after_1], @callbacks end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_failed_before_callbacks_test.rb000066400000000000000000000021411521305501100343660ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithFailedBeforeCallbacksTest < BaseTestCase def setup @callbacks = [] @model = new_model @machine = StateMachines::Machine.new(@model) @machine.state :parked, :idling @machine.event :ignite @machine.before_transition do @callbacks << :before_1 false end @machine.before_transition { @callbacks << :before_2 } @machine.after_transition { @callbacks << :after } @machine.around_transition do |block| @callbacks << :around_before block.call @callbacks << :around_after end @record = @model.new(state: 'parked') @transition = StateMachines::Transition.new(@record, @machine, :ignite, :parked, :idling) @result = @transition.perform end def test_should_not_be_successful refute @result end def test_should_not_change_current_state assert_equal 'parked', @record.state end def test_should_not_run_action assert @record.new_record? end def test_should_not_run_further_callbacks assert_equal [:before_1], @callbacks end end machine_with_initialized_aliased_attribute_test.rb000066400000000000000000000007651521305501100356260ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/testrequire_relative 'test_helper' class MachineWithInitializedAliasedAttributeTest < BaseTestCase def setup @model = new_model do alias_attribute :custom_status, :state end @machine = StateMachines::Machine.new(@model, :initial => :parked, :attribute => :state) @machine.state :started @record = @model.new(:custom_status => :started) end def test_should_match_original_attribute_value refute @record.state?(:parked) assert @record.state?(:started) end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_initialized_state_test.rb000066400000000000000000000021301521305501100333240ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithInitializedStateTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model, initial: :parked) @machine.state :idling end def test_should_allow_nil_initial_state_when_static @machine.state nil record = @model.new(state: nil) assert_nil record.state end def test_should_allow_nil_initial_state_when_dynamic @machine.state nil @machine.initial_state = -> { :parked } record = @model.new(state: nil) assert_nil record.state end def test_should_allow_different_initial_state_when_static record = @model.new(state: 'idling') assert_equal 'idling', record.state end def test_should_allow_different_initial_state_when_using_create_with record = @model.create_with(state: 'idling').new assert_equal 'idling', record.state end def test_should_allow_different_initial_state_when_dynamic @machine.initial_state = -> { :parked } record = @model.new(state: 'idling') assert_equal 'idling', record.state end end machine_with_integer_column_conversion_disabled_test.rb000066400000000000000000000062661521305501100366640ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' class MachineWithIntegerColumnConversionDisabledTest < BaseTestCase def setup StateMachines::Integrations::ActiveRecord.auto_convert_integer_state_attributes = false @model = new_model do connection.add_column table_name, :status, :integer, default: 0 end @machine = StateMachines::Machine.new(@model, :status, initial: :pending) do state :pending, value: 0 state :approved, value: 1 state :declined, value: 2 event :approve do transition pending: :approved end event :decline do transition pending: :declined end end @record = @model.new @record.save! end def teardown StateMachines::Integrations::ActiveRecord.auto_convert_integer_state_attributes = true super end def test_should_not_register_custom_integer_type refute @machine.integer_type_registered? assert_equal :integer, @model.type_for_attribute('status').type end def test_should_read_raw_integer_value assert_equal 0, @record.status end def test_machine_read_uses_raw_integer_value assert_equal 0, @machine.read(@record, :state) end def test_status_name_returns_symbol assert_equal :pending, @record.status_name end def test_should_not_convert_state_name_assignment @record.status = :approved assert_nil @record.status assert_equal :approved, @record.status_before_type_cast end def test_transition_fires_correctly_with_raw_integer_values @record.approve! assert_equal 1, @record.status end def test_predicate_returns_correct_result assert @record.pending? refute @record.approved? end def test_scope_returns_correct_records approved = @model.new approved.save! approved.approve! assert_includes @model.with_status(:pending), @record assert_includes @model.with_status(:approved), approved refute_includes @model.with_status(:approved), @record end def test_scope_uses_raw_integer_relation_condition assert_equal({ 'status' => 1 }, @model.with_status(:approved).where_values_hash) end def test_plural_scope_uses_raw_integer_relation_condition assert_equal({ 'status' => [0, 2] }, @model.with_statuses(:pending, :declined).where_values_hash) end def test_without_scope_excludes_records_using_raw_integer_values approved = @model.new approved.save! approved.approve! assert_includes @model.without_status(:approved), @record refute_includes @model.without_status(:approved), approved end def test_scope_on_custom_integer_attribute_uses_raw_integer_relation_condition model = new_model do connection.add_column table_name, :state_cd, :integer, default: 1 end model.state_machine(:state, attribute: :state_cd, initial: :enabled) do state :disabled, value: 0 state :enabled, value: 1 end assert_equal({ 'state_cd' => 1 }, model.with_state(:enabled).where_values_hash) end def test_should_persist_raw_integer_on_save @record.approve! @record.save! raw = @model.connection.select_value("SELECT status FROM #{@model.quoted_table_name} WHERE id = #{@record.id}") assert_equal 1, raw.to_i end end machine_with_integer_column_sti_subclass_test.rb000066400000000000000000000037571521305501100353500ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' # Subclasses that extend an inherited machine get a cloned state collection, # but they inherit the parent's custom integer attribute type, which references # the parent machine's states. The integration must re-register the type on the # subclass so subclass-added states serialize to their own integers instead of # silently coercing to 0. class MachineWithIntegerColumnStiSubclassTest < BaseTestCase def setup @base = new_model do connection.add_column table_name, :status, :integer, default: 0 end @base_machine = StateMachines::Machine.new(@base, :status, initial: :pending) do state :pending state :shipped event :ship do transition pending: :shipped end end @subclass = Class.new(@base) @subclass_machine = @subclass.state_machine(:status) do state :returned event :return_order do transition shipped: :returned end end @record = @subclass.new @record.ship! end def test_subclass_machine_is_a_copy refute_same @base_machine, @subclass_machine end def test_subclass_added_state_persists_its_own_integer @record.return_order! raw = @subclass.connection.select_value("SELECT status FROM #{@subclass.quoted_table_name} WHERE id = #{@record.id}") assert_equal 2, raw.to_i end def test_subclass_added_state_reads_back @record.return_order! reloaded = @subclass.find(@record.id) assert_equal 'returned', reloaded.status assert_equal :returned, reloaded.status_name end def test_inherited_states_still_convert_on_subclass assert_equal 'shipped', @record.status assert @record.shipped? end def test_base_class_type_unaffected base_record = @base.new base_record.ship! raw = @base.connection.select_value("SELECT status FROM #{@base.quoted_table_name} WHERE id = #{base_record.id}") assert_equal 1, raw.to_i assert_equal 'shipped', @base.find(base_record.id).status end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_integer_column_test.rb000066400000000000000000000035021521305501100326350ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithIntegerColumnTest < BaseTestCase def setup @model = new_model do connection.add_column table_name, :status, :integer end @machine = StateMachines::Machine.new(@model, :status, initial: :pending) @machine.state :pending @machine.state :approved @machine.state :declined @machine.event :approve do transition pending: :approved end @machine.event :decline do transition pending: :declined end @record = @model.new @record.save! end def test_should_return_state_name_on_read assert_equal 'pending', @record.status end def test_should_accept_symbol_on_write @record.status = :declined assert_equal 'declined', @record.status end def test_should_accept_string_on_write @record.status = 'approved' assert_equal 'approved', @record.status end def test_should_persist_state_as_integer @record.status = :approved @record.save! raw = @model.connection.select_value("SELECT status FROM #{@model.quoted_table_name} WHERE id = #{@record.id}") assert_equal 1, raw.to_i end def test_should_reload_as_state_name @record.status = :declined @record.save! reloaded = @model.find(@record.id) assert_equal 'declined', reloaded.status end def test_status_name_returns_symbol assert_equal :pending, @record.status_name end def test_transition_fires_correctly @record.approve! assert_equal 'approved', @record.status end def test_scope_with_status_returns_correct_records approved = @model.new approved.save! approved.approve! assert_includes @model.with_status(:pending), @record refute_includes @model.with_status(:pending), approved assert_includes @model.with_status(:approved), approved end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_internationalization_test.rb000066400000000000000000000144501521305501100340740ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithInternationalizationTest < BaseTestCase def setup I18n.backend = I18n::Backend::Simple.new # Initialize the backend StateMachines::Machine.new(new_model) @model = new_model end def test_should_use_defaults I18n.backend.store_translations(:en, { activerecord: { errors: { messages: { invalid_transition: "cannot #{interpolation_key('event')}" } } } }) machine = StateMachines::Machine.new(@model) machine.state :parked, :idling machine.event :ignite record = @model.new(state: 'idling') machine.invalidate(record, :state, :invalid_transition, [[:event, 'ignite']]) assert_equal ['State cannot transition via "ignite"'], record.errors.full_messages end def test_should_allow_customized_error_key I18n.backend.store_translations(:en, { activerecord: { errors: { messages: { bad_transition: "cannot #{interpolation_key('event')}" } } } }) machine = StateMachines::Machine.new(@model, messages: { invalid_transition: :bad_transition }) machine.state :parked, :idling record = @model.new(state: 'idling') machine.invalidate(record, :state, :invalid_transition, [[:event, 'ignite']]) assert_equal ['State cannot ignite'], record.errors.full_messages end def test_should_allow_customized_error_string machine = StateMachines::Machine.new(@model, messages: { invalid_transition: "cannot #{interpolation_key('event')}" }) machine.state :parked, :idling record = @model.new(state: 'idling') machine.invalidate(record, :state, :invalid_transition, [[:event, 'ignite']]) assert_equal ['State cannot ignite'], record.errors.full_messages end def test_should_allow_customized_state_key_scoped_to_class_and_machine I18n.backend.store_translations(:en, { activerecord: { state_machines: { foo: { state: { states: { parked: 'shutdown' } } } } } }) machine = StateMachines::Machine.new(@model) machine.state :parked assert_equal 'shutdown', machine.state(:parked).human_name end def test_should_allow_customized_state_key_scoped_to_class I18n.backend.store_translations(:en, { activerecord: { state_machines: { foo: { states: { parked: 'shutdown' } } } } }) machine = StateMachines::Machine.new(@model) machine.state :parked assert_equal 'shutdown', machine.state(:parked).human_name end def test_should_allow_customized_state_key_scoped_to_machine I18n.backend.store_translations(:en, { activerecord: { state_machines: { state: { states: { parked: 'shutdown' } } } } }) machine = StateMachines::Machine.new(@model) machine.state :parked assert_equal 'shutdown', machine.state(:parked).human_name end def test_should_allow_customized_state_key_unscoped I18n.backend.store_translations(:en, { activerecord: { state_machines: { states: { parked: 'shutdown' } } } }) machine = StateMachines::Machine.new(@model) machine.state :parked assert_equal 'shutdown', machine.state(:parked).human_name end def test_should_support_nil_state_key I18n.backend.store_translations(:en, { activerecord: { state_machines: { states: { nil: 'empty' } } } }) machine = StateMachines::Machine.new(@model) assert_equal 'empty', machine.state(nil).human_name end def test_should_allow_customized_event_key_scoped_to_class_and_machine I18n.backend.store_translations(:en, { activerecord: { state_machines: { foo: { state: { events: { park: 'stop' } } } } } }) machine = StateMachines::Machine.new(@model) machine.event :park assert_equal 'stop', machine.event(:park).human_name end def test_should_allow_customized_event_key_scoped_to_class I18n.backend.store_translations(:en, { activerecord: { state_machines: { foo: { events: { park: 'stop' } } } } }) machine = StateMachines::Machine.new(@model) machine.event :park assert_equal 'stop', machine.event(:park).human_name end def test_should_allow_customized_event_key_scoped_to_machine I18n.backend.store_translations(:en, { activerecord: { state_machines: { state: { events: { park: 'stop' } } } } }) machine = StateMachines::Machine.new(@model) machine.event :park assert_equal 'stop', machine.event(:park).human_name end def test_should_allow_customized_event_key_unscoped I18n.backend.store_translations(:en, { activerecord: { state_machines: { events: { park: 'stop' } } } }) machine = StateMachines::Machine.new(@model) machine.event :park assert_equal 'stop', machine.event(:park).human_name end def test_should_only_add_locale_once_in_load_path assert_equal 1, I18n.load_path.select { |path| path =~ %r{active_record/locale\.rb$} }.length # Create another ActiveRecord model that will trigger the i18n feature new_model assert_equal 1, I18n.load_path.select { |path| path =~ %r{active_record/locale\.rb$} }.length end def test_should_prefer_other_locales_first @original_load_path = I18n.load_path I18n.backend = I18n::Backend::Simple.new I18n.load_path = [File.dirname(__FILE__) + '/files/en.yml'] machine = StateMachines::Machine.new(@model) machine.state :parked, :idling machine.event :ignite record = @model.new(state: 'idling') machine.invalidate(record, :state, :invalid_transition, [[:event, 'ignite']]) assert_equal ['State cannot transition'], record.errors.full_messages ensure I18n.load_path = @original_load_path end private def interpolation_key(key) "%{#{key}}" end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_loopback_test.rb000066400000000000000000000011541521305501100314160ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithLoopbackTest < BaseTestCase def setup @model = new_model do connection.add_column table_name, :updated_at, :datetime end @machine = StateMachines::Machine.new(@model, initial: :parked) @machine.event :park @record = @model.create(updated_at: Time.now - 1) @transition = StateMachines::Transition.new(@record, @machine, :park, :parked, :parked) @timestamp = @record.updated_at @transition.perform end def test_should_not_update_record assert_equal @timestamp, @record.updated_at end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_mixed_integer_values_test.rb000066400000000000000000000031631521305501100340300ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' # Machines mixing explicit integer values with auto-indexed states keep the # transparent name conversion, while machine internals match each state on its # canonical state.value (integer for explicit states, name string for # auto-indexed ones). class MachineWithMixedIntegerValuesTest < BaseTestCase def setup @model = new_model do connection.add_column table_name, :status, :integer, default: 0 end @machine = StateMachines::Machine.new(@model, :status, initial: :pending) do state :pending # auto-indexed => 0 state :approved, value: 5 # explicit event :approve do transition pending: :approved end end @record = @model.new @record.save! end def test_should_read_state_name assert_equal 'pending', @record.status end def test_status_name_returns_correct_symbol assert_equal :pending, @record.status_name end def test_predicate_returns_correct_result assert @record.pending? refute @record.approved? end def test_transition_fires_correctly @record.approve! assert_equal 'approved', @record.status assert @record.approved? end def test_should_persist_explicit_integer_on_save @record.approve! raw = @model.connection.select_value("SELECT status FROM #{@model.quoted_table_name} WHERE id = #{@record.id}") assert_equal 5, raw.to_i end def test_should_reload_as_state_name @record.approve! reloaded = @model.find(@record.id) assert_equal 'approved', reloaded.status assert_equal :approved, reloaded.status_name end end machine_with_non_column_state_attribute_defined_test.rb000066400000000000000000000014741521305501100366620ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' class MachineWithNonColumnStateAttributeDefinedTest < BaseTestCase def setup @model = new_model do attr_accessor :status end @machine = StateMachines::Machine.new(@model, :status, initial: :parked) @machine.other_states(:idling) @record = @model.new end def test_should_return_false_for_predicate_if_does_not_match_current_value refute @record.status?(:idling) end def test_should_return_true_for_predicate_if_matches_current_value assert @record.status?(:parked) end def test_should_raise_exception_for_predicate_if_invalid_state_specified assert_raise(IndexError) { @record.status?(:invalid) } end def test_should_set_initial_state_on_created_object assert_equal 'parked', @record.status end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_same_column_default_test.rb000066400000000000000000000012501521305501100336270ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithSameColumnDefaultTest < BaseTestCase def setup @original_stderr = $stderr $stderr = StringIO.new @model = new_model do connection.add_column table_name, :status, :string, default: 'parked' end @machine = StateMachines::Machine.new(@model, :status, initial: :parked) @record = @model.new end def test_should_use_machine_default assert_equal 'parked', @record.status end def test_should_not_generate_a_warning assert_no_match(/have defined a different default/, $stderr.string) end def teardown $stderr = @original_stderr super end end machine_with_same_integer_column_default_test.rb000066400000000000000000000013361521305501100352720ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' require 'stringio' class MachineWithSameIntegerColumnDefaultTest < BaseTestCase def setup @original_stderr = $stderr $stderr = StringIO.new @model = new_model do connection.add_column table_name, :status, :integer, default: 1 end @machine = StateMachines::Machine.new(@model, :status, initial: :parked) do state :parked, value: 1 end @record = @model.new end def test_should_use_machine_default assert_equal 1, @record.status end def test_should_not_generate_a_warning assert_no_match(/have defined a different default/, $stderr.string) end def teardown $stderr = @original_stderr super end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_scopes_and_joins_test.rb000066400000000000000000000024631521305501100331500ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithScopesAndJoinsTest < BaseTestCase def setup @company = new_model(:company) MachineWithScopesAndJoinsTest.const_set('Company', @company) @vehicle = new_model(:vehicle) do connection.add_column table_name, :company_id, :integer belongs_to :company, class_name: 'MachineWithScopesAndJoinsTest::Company' end MachineWithScopesAndJoinsTest.const_set('Vehicle', @vehicle) @company_machine = StateMachines::Machine.new(@company, initial: :active) @vehicle_machine = StateMachines::Machine.new(@vehicle, initial: :parked) @vehicle_machine.state :idling @ford = @company.create @mustang = @vehicle.create(company: @ford) end def test_should_find_records_in_with_scope assert_equal [@mustang], @vehicle.with_states(:parked).joins(:company).where("#{@company.table_name}.state = \"active\"") end def test_should_find_records_in_without_scope assert_equal [@mustang], @vehicle.without_states(:idling).joins(:company).where("#{@company.table_name}.state = \"active\"") end def teardown MachineWithScopesAndJoinsTest.class_eval do remove_const('Vehicle') remove_const('Company') end clear_active_support_dependencies end end machine_with_scopes_and_owner_subclass_test.rb000066400000000000000000000016401521305501100347740ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' class MachineWithScopesAndOwnerSubclassTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model, :state) @subclass = Class.new(@model) @subclass_machine = @subclass.state_machine(:state) {} @subclass_machine.state :parked, :idling, :first_gear end def test_should_only_include_records_with_subclass_states_in_with_scope parked = @subclass.create state: 'parked' idling = @subclass.create state: 'idling' assert_equal [parked, idling], @subclass.with_states(:parked, :idling).all end def test_should_only_include_records_without_subclass_states_in_without_scope parked = @subclass.create state: 'parked' idling = @subclass.create state: 'idling' @subclass.create state: 'first_gear' assert_equal [parked, idling], @subclass.without_states(:first_gear).all end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_scopes_test.rb000066400000000000000000000065441521305501100311300ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithScopesTest < BaseTestCase def setup @model = new_model do connection.add_column table_name, :name, :string end @machine = StateMachines::Machine.new(@model) @machine.state :parked, :first_gear @machine.state :idling, value: -> { 'idling' } end def test_should_allow_chaining_scopes_with_queries named = @model.create state: 'parked', name: 'a_name' @model.create state: 'parked' assert_equal [named], @model.where(name: 'a_name').with_state(:parked) end def test_should_create_singular_with_scope assert @model.respond_to?(:with_state) end def test_should_only_include_records_with_state_in_singular_with_scope parked = @model.create state: 'parked' @model.create state: 'idling' assert_equal [parked], @model.with_state(:parked).all end def test_should_allow_transparent_with_state_in_singular_with_scope @model.create state: 'parked' @model.create state: 'idling' assert_equal @model.all, @model.with_state(nil).all end def test_should_create_plural_with_scope assert @model.respond_to?(:with_states) end def test_should_only_include_records_with_states_in_plural_with_scope parked = @model.create state: 'parked' idling = @model.create state: 'idling' assert_equal [parked, idling], @model.with_states(:parked, :idling).all end def test_should_allow_transparent_with_states_in_plural_with_scope @model.create state: 'parked' @model.create state: 'idling' assert_equal @model.all, @model.with_states(nil).all end def test_should_allow_lookup_by_string_name parked = @model.create state: 'parked' idling = @model.create state: 'idling' assert_equal [parked, idling], @model.with_states('parked', 'idling').all end def test_should_create_singular_without_scope assert @model.respond_to?(:without_state) end def test_should_only_include_records_without_state_in_singular_without_scope parked = @model.create state: 'parked' @model.create state: 'idling' assert_equal [parked], @model.without_state(:idling).all end def test_allow_transparent_without_state_in_singular_without_scope @model.create state: 'parked' @model.create state: 'idling' assert_equal @model.all, @model.without_state(nil).all end def test_should_create_plural_without_scope assert @model.respond_to?(:without_states) end def test_should_only_include_records_without_states_in_plural_without_scope parked = @model.create state: 'parked' idling = @model.create state: 'idling' @model.create state: 'first_gear' assert_equal [parked, idling], @model.without_states(:first_gear).all end def test_allow_transparent_without_states_in_plural_without_scope @model.create state: 'parked' @model.create state: 'idling' @model.create state: 'first_gear' assert_equal @model.all, @model.without_states(nil).all end def test_should_allow_chaining_scopes @model.create state: 'parked' idling = @model.create state: 'idling' assert_equal [idling], @model.without_state(:parked).with_state(:idling).all end def test_should_allow_chaining_transparent_scopes @model.create state: 'parked' idling = @model.create state: 'idling' assert_equal [idling], @model.with_state(nil).with_state(:idling).all end end machine_with_state_driven_validations_test.rb000066400000000000000000000015451521305501100346350ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' class MachineWithStateDrivenValidationsTest < BaseTestCase def setup @model = new_model do attr_accessor :seatbelt end @machine = StateMachines::Machine.new(@model) @machine.state :first_gear, :second_gear do validates :seatbelt, presence: true end @machine.other_states :parked end def test_should_be_valid_if_validation_fails_outside_state_scope record = @model.new(state: 'parked', seatbelt: nil) assert record.valid? end def test_should_be_invalid_if_validation_fails_within_state_scope record = @model.new(state: 'first_gear', seatbelt: nil) refute record.valid? end def test_should_be_valid_if_validation_succeeds_within_state_scope record = @model.new(state: 'second_gear', seatbelt: true) assert record.valid? end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_states_test.rb000066400000000000000000000005251521305501100311300ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithStatesTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model) @machine.state :first_gear end def test_should_humanize_name assert_equal 'first gear', @machine.state(:first_gear).human_name end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_static_initial_state_test.rb000066400000000000000000000111721521305501100340250ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithStaticInitialStateTest < BaseTestCase def setup @model = new_model(:vehicle) do attr_accessor :value end @machine = StateMachines::Machine.new(@model, initial: :parked) end def test_should_set_initial_state_on_created_object record = @model.new assert_equal 'parked', record.state end def test_should_set_initial_state_with_nil_attributes record = @model.new(nil) assert_equal 'parked', record.state end def test_should_still_set_attributes record = @model.new(value: 1) assert_equal 1, record.value end def test_should_still_allow_initialize_blocks block_args = nil record = @model.new do |*args| block_args = args end assert_equal [record], block_args end def test_should_set_attributes_prior_to_initialize_block state = nil @model.new do |record| state = record.state end assert_equal 'parked', state end def test_should_set_attributes_prior_to_after_initialize_hook state = nil @model.after_initialize do |record| state = record.state end @model.new assert_equal 'parked', state end def test_should_set_initial_state_before_setting_attributes @model.class_eval do attr_accessor :state_during_setter remove_method :value= define_method(:value=) do |_value| self.state_during_setter = state end end record = @model.new record.value = 1 assert_equal 'parked', record.state_during_setter end def test_should_not_set_initial_state_after_already_initialized record = @model.new(value: 1) assert_equal 'parked', record.state record.state = 'idling' record.attributes = {} assert_equal 'idling', record.state end def test_should_persist_initial_state record = @model.new record.save record.reload assert_equal 'parked', record.state end def test_should_persist_initial_state_on_dup record = @model.create.dup record.save record.reload assert_equal 'parked', record.state end def test_should_use_stored_values_when_loading_from_database @machine.state :idling record = @model.find(@model.create(state: 'idling').id) assert_equal 'idling', record.state end def test_should_use_stored_values_when_loading_from_database_with_nil_state @machine.state nil record = @model.find(@model.create(state: nil).id) assert_nil record.state end def test_should_use_stored_values_when_loading_for_many_association @machine.state :idling @model.connection.add_column @model.table_name, :owner_id, :integer @model.reset_column_information MachineWithStaticInitialStateTest.const_set('Vehicle', @model) owner_model = new_model(:owner) do has_many :vehicles, class_name: 'MachineWithStaticInitialStateTest::Vehicle' end MachineWithStaticInitialStateTest.const_set('Owner', owner_model) owner = owner_model.create @model.create(state: 'idling', owner_id: owner.id) assert_equal 'idling', owner.vehicles[0].state end def test_should_use_stored_values_when_loading_for_one_association @machine.state :idling @model.connection.add_column @model.table_name, :owner_id, :integer @model.reset_column_information MachineWithStaticInitialStateTest.const_set('Vehicle', @model) owner_model = new_model(:owner) do has_one :vehicle, class_name: 'MachineWithStaticInitialStateTest::Vehicle' end MachineWithStaticInitialStateTest.const_set('Owner', owner_model) owner = owner_model.create @model.create(state: 'idling', owner_id: owner.id) assert_equal 'idling', owner.vehicle.state end def test_should_use_stored_values_when_loading_for_belongs_to_association @machine.state :idling MachineWithStaticInitialStateTest.const_set('Vehicle', @model) driver_model = new_model(:driver) do connection.add_column table_name, :vehicle_id, :integer belongs_to :vehicle, class_name: 'MachineWithStaticInitialStateTest::Vehicle' end MachineWithStaticInitialStateTest.const_set('Driver', driver_model) record = @model.create(state: 'idling') driver = driver_model.create(vehicle_id: record.id) assert_equal 'idling', driver.vehicle.state end def teardown MachineWithStaticInitialStateTest.class_eval do remove_const('Vehicle') if defined?(MachineWithStaticInitialStateTest::Vehicle) remove_const('Owner') if defined?(MachineWithStaticInitialStateTest::Owner) remove_const('Driver') if defined?(MachineWithStaticInitialStateTest::Driver) end clear_active_support_dependencies super end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_transactions_test.rb000066400000000000000000000011111521305501100323250ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithTransactionsTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model, use_transactions: true) end def test_should_rollback_transaction_if_false @machine.within_transaction(@model.new) do @model.create false end assert_equal 0, @model.count end def test_should_not_rollback_transaction_if_true @machine.within_transaction(@model.new) do @model.create true end assert_equal 1, @model.count end end machine_with_validations_and_custom_attribute_test.rb000066400000000000000000000010511521305501100363550ustar00rootroot00000000000000state-machines-state_machines-activerecord-b9cb1e5/test# frozen_string_literal: true require_relative 'test_helper' class MachineWithValidationsAndCustomAttributeTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model, :status, attribute: :state) @machine.state :parked @record = @model.new end def test_should_add_validation_errors_to_custom_attribute @record.state = 'invalid' refute @record.valid? assert_equal ['State is invalid'], @record.errors.full_messages @record.state = 'parked' assert @record.valid? end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_with_validations_test.rb000066400000000000000000000023271521305501100321440ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithValidationsTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model) @machine.state :parked @record = @model.new end def test_should_invalidate_using_errors I18n.backend = I18n::Backend::Simple.new @record.state = 'parked' @machine.invalidate(@record, :state, :invalid_transition, [[:event, 'park']]) assert_equal ['State cannot transition via "park"'], @record.errors.full_messages end def test_should_auto_prefix_custom_attributes_on_invalidation @machine.invalidate(@record, :event, :invalid) assert_equal ['State event is invalid'], @record.errors.full_messages end def test_should_clear_errors_on_reset @record.state = 'parked' @record.errors.add(:state, 'is invalid') @machine.reset(@record) assert_equal [], @record.errors.full_messages end def test_should_be_valid_if_state_is_known @record.state = 'parked' assert @record.valid? end def test_should_not_be_valid_if_state_is_unknown @record.state = 'invalid' refute @record.valid? assert_equal ['State is invalid'], @record.errors.full_messages end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_without_database_test.rb000066400000000000000000000007461521305501100321260ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithoutDatabaseTest < BaseTestCase def setup @model = new_model(false) do # Simulate the database not being available entirely def self.connection raise ActiveRecord::ConnectionNotEstablished end def self.connected? false end end end def test_should_allow_machine_creation assert_nothing_raised { StateMachines::Machine.new(@model) } end end state-machines-state_machines-activerecord-b9cb1e5/test/machine_without_transactions_test.rb000066400000000000000000000011211521305501100330560ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineWithoutTransactionsTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model, use_transactions: false) end def test_should_not_rollback_transaction_if_false @machine.within_transaction(@model.new) do @model.create false end assert_equal 1, @model.count end def test_should_not_rollback_transaction_if_true @machine.within_transaction(@model.new) do @model.create true end assert_equal 1, @model.count end end state-machines-state_machines-activerecord-b9cb1e5/test/model_test.rb000066400000000000000000000006201521305501100262020ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' require_relative 'files/models/post' class ModelTest < ActiveSupport::TestCase def test_should_have_draft_state_in_defaut_machine assert_equal 'draft', Post.new.state end def test_should_have_the_correct_integration assert_equal StateMachines::Integrations::ActiveRecord, StateMachines::Integrations.match(Post) end end state-machines-state_machines-activerecord-b9cb1e5/test/test_helper.rb000066400000000000000000000034471521305501100263730ustar00rootroot00000000000000# frozen_string_literal: true require 'debug' if RUBY_ENGINE == 'ruby' require 'minitest/reporters' Minitest::Reporters.use!(Minitest::Reporters::SpecReporter.new) # Ensure our local lib directory is loaded first $LOAD_PATH.unshift File.expand_path('../lib', __dir__) require 'state_machines-activerecord' require 'state_machines/test_helper' require 'minitest/autorun' require 'securerandom' # Establish database connection ActiveRecord::Base.establish_connection('adapter' => 'sqlite3', 'database' => ':memory:') ActiveRecord::Base.logger = Logger.new("#{File.dirname(__FILE__)}/../log/active_record.log") ActiveSupport.test_order = :random class BaseTestCase < ActiveSupport::TestCase include StateMachines::TestHelper protected # Creates a new ActiveRecord model (and the associated table) def new_model(create_table = :foo, &) name = create_table || :foo table_name = "#{name}_#{SecureRandom.hex(6)}" model = Class.new(ActiveRecord::Base) do self.table_name = table_name.to_s connection.create_table(table_name, force: true) { |t| t.string(:state) } if create_table define_method(:abort_from_callback) do throw :abort end ( class << self self end).class_eval do define_method(:name) { name.to_s.capitalize } end end model.class_eval(&) if block_given? model.reset_column_information if create_table model end def clear_active_support_dependencies return unless defined?(ActiveSupport::Dependencies) if ActiveSupport::Dependencies.respond_to?(:autoloader=) ActiveSupport::Dependencies.autoloader ||= stubbed_autoloader end ActiveSupport::Dependencies.clear end def stubbed_autoloader Object.new.tap do |obj| obj.define_singleton_method(:reload) {} end end end