├── .github └── workflows │ └── test.yml ├── .gitignore ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── gemfiles ├── activerecord-6.0.Gemfile ├── activerecord-6.1.Gemfile ├── activerecord-7.0.Gemfile └── activerecord-7.1.Gemfile ├── lib ├── workflow-activerecord.rb ├── workflow-activerecord │ └── version.rb └── workflow_activerecord.rb ├── test ├── active_record_scopes_test.rb ├── adapter_hook_test.rb ├── advanced_hooks_and_validation_test.rb ├── attr_protected_test.rb ├── main_test.rb ├── multiple_workflows_test.rb ├── persistence_test.rb └── test_helper.rb └── workflow_activerecord.gemspec /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | pull_request: 5 | jobs: 6 | test: 7 | name: Test 8 | runs-on: ubuntu-latest 9 | timeout-minutes: 15 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | ruby: [3.3] 14 | gemfile: [activerecord-6.0.Gemfile, activerecord-6.1.Gemfile, activerecord-7.0.Gemfile, activerecord-7.1.Gemfile] 15 | env: 16 | BUNDLE_GEMFILE: gemfiles/${{matrix.gemfile}} 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | - name: Set up Ruby 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: ${{ matrix.ruby }} 24 | bundler-cache: true # Also installs gems 25 | - name: Run tests 26 | run: bundle exec rake test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.rbc 2 | capybara-*.html 3 | .rspec 4 | /log 5 | /tmp 6 | /pkg 7 | /db/*.sqlite3 8 | /db/*.sqlite3-journal 9 | /public/system 10 | /coverage/ 11 | /spec/tmp 12 | **.orig 13 | rerun.txt 14 | pickle-email-*.html 15 | *.gem 16 | 17 | # TODO Comment out these rules if you are OK with secrets being uploaded to the repo 18 | config/initializers/secret_token.rb 19 | config/secrets.yml 20 | 21 | # dotenv 22 | # TODO Comment out this rule if environment variables can be committed 23 | .env 24 | 25 | ## Environment normalization: 26 | /.bundle 27 | /vendor/bundle 28 | 29 | *Gemfile.lock 30 | .ruby-version 31 | .ruby-gemset 32 | 33 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 34 | .rvmrc 35 | 36 | # if using bower-rails ignore default bower_components path bower.json files 37 | /vendor/assets/bower_components 38 | *.bowerrc 39 | bower.json 40 | 41 | # Ignore pow environment settings 42 | .powenv 43 | 44 | # Ignore Byebug command history file. 45 | .byebug_history 46 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :test do 6 | gem 'simplecov' 7 | end 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016-2024 Vladimir Dobriakov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Version](https://img.shields.io/gem/v/workflow-activerecord.svg)](https://rubygems.org/gems/workflow-activerecord) 2 | [![Test](https://github.com/geekq/workflow-activerecord/actions/workflows/test.yml/badge.svg)](https://github.com/geekq/workflow-activerecord/actions/workflows/test.yml) 3 | [![Code Climate](https://codeclimate.com/github/geekq/workflow-activerecord/badges/gpa.svg)](https://codeclimate.com/github/geekq/workflow-activerecord) 4 | [![Test Coverage](https://codeclimate.com/github/geekq/workflow-activerecord/badges/coverage.svg)](https://codeclimate.com/github/geekq/workflow-activerecord/coverage) 5 | 6 | # workflow-activerecord 7 | 8 | **ActiveRecord/Rails Integration for the Workflow library** 9 | 10 | Major+minor versions of workflow-activerecord are based on the oldest 11 | compatible ActiveRecord API. To use [`workflow`][workflow] with 12 | Rails/ActiveRecord 6.*, 7.* please use: 13 | 14 | gem 'workflow-activerecord', '~> 6.0' 15 | 16 | This will also automatically include the newest compatible version of 17 | the core 'workflow' gem. But you can also choose a specific version: 18 | 19 | gem 'workflow', '~> 2.0' 20 | gem 'workflow-activerecord', '~> 4.1' 21 | 22 | Please also have a look at [the sample application][]! 23 | 24 | For detailed introduction into workflow DSL please read the 25 | [`workflow` README][workflow]! 26 | 27 | [workflow]: https://github.com/geekq/workflow 28 | [the sample application]: https://github.com/geekq/workflow-rails-sample 29 | 30 | 31 | State persistence with ActiveRecord 32 | ----------------------------------- 33 | 34 | Workflow library can handle the state persistence fully automatically. You 35 | only need to define a string field on the table called `workflow_state` 36 | and include the workflow mixin in your model class as usual: 37 | 38 | class Order < ApplicationRecord 39 | include WorkflowActiverecord 40 | workflow do 41 | # list states and transitions here 42 | end 43 | end 44 | 45 | On a database record loading all the state check methods e.g. 46 | `article.state`, `article.awaiting_review?` are immediately available. 47 | For new records or if the `workflow_state` field is not set the state 48 | defaults to the first state declared in the workflow specification. In 49 | our example it is `:new`, so `Article.new.new?` returns true and 50 | `Article.new.approved?` returns false. 51 | 52 | At the end of a successful state transition like `article.approve!` the 53 | new state is immediately saved in the database. 54 | 55 | You can change this behaviour by overriding `persist_workflow_state` 56 | method. 57 | 58 | ### Scopes 59 | 60 | Workflow library also adds automatically generated scopes with names based on 61 | states names: 62 | 63 | class Order < ApplicationRecord 64 | include WorkflowActiverecord 65 | workflow do 66 | state :approved 67 | state :pending 68 | end 69 | end 70 | 71 | # returns all orders with `approved` state 72 | Order.with_approved_state 73 | 74 | # returns all orders with `pending` state 75 | Order.with_pending_state 76 | 77 | 78 | ### Custom workflow database column 79 | 80 | [meuble](http://imeuble.info/) contributed a solution for using 81 | custom persistence column easily, e.g. for a legacy database schema: 82 | 83 | class LegacyOrder < ApplicationRecord 84 | include WorkflowActiverecord 85 | 86 | workflow_column :foo_bar # use this legacy database column for 87 | # persistence 88 | end 89 | 90 | 91 | 92 | ### Single table inheritance 93 | 94 | Single table inheritance is also supported. Descendant classes can either 95 | inherit the workflow definition from the parent or override with its own 96 | definition. 97 | 98 | 99 | Custom Versions of Existing Adapters 100 | ------------------------------------ 101 | 102 | Other adapters (such as a custom ActiveRecord plugin) can be selected by adding a `workflow_adapter` class method, eg. 103 | 104 | ```ruby 105 | class Example < ApplicationRecord 106 | def self.workflow_adapter 107 | MyCustomAdapter 108 | end 109 | include Workflow 110 | 111 | # ... 112 | end 113 | ``` 114 | 115 | (The above will include `MyCustomAdapter` *instead* of the default 116 | `WorkflowActiverecord` adapter.) 117 | 118 | 119 | Multiple Workflows 120 | ------------------ 121 | 122 | I am frequently asked if it's possible to represent multiple "workflows" 123 | in an ActiveRecord class. 124 | 125 | The solution depends on your business logic and how you want to 126 | structure your implementation. 127 | 128 | ### Use Single Table Inheritance 129 | 130 | One solution can be to do it on the class level and use a class 131 | hierarchy. You can use [single table inheritance][STI] so there is only 132 | single `orders` table in the database. Read more in the chapter "Single 133 | Table Inheritance" of the [ActiveRecord documentation][ActiveRecord]. 134 | Then you define your different classes: 135 | 136 | class Order < ActiveRecord::Base 137 | include WorkflowActiverecord 138 | end 139 | 140 | class SmallOrder < Order 141 | workflow do 142 | # workflow definition for small orders goes here 143 | end 144 | end 145 | 146 | class BigOrder < Order 147 | workflow do 148 | # workflow for big orders, probably with a longer approval chain 149 | end 150 | end 151 | 152 | 153 | ### Individual workflows for objects 154 | 155 | Another solution would be to connect different workflows to object 156 | instances via metaclass, e.g. 157 | 158 | # Load an object from the database 159 | booking = Booking.find(1234) 160 | 161 | # Now define a workflow - exclusively for this object, 162 | # probably depending on some condition or database field 163 | if # some condition 164 | class << booking 165 | include WorkflowActiverecord 166 | workflow do 167 | state :state1 168 | state :state2 169 | end 170 | end 171 | # if some other condition, use a different workflow 172 | 173 | You can also encapsulate this in a class method or even put in some 174 | ActiveRecord callback. Please also have a look at [the full working 175 | example][multiple_workflow_test]! 176 | 177 | ### on_transition 178 | 179 | You can have a look at an advanced [`on_transition`][] example in 180 | [this test file][advanced_hooks_and_validation_test]. 181 | 182 | [STI]: http://www.martinfowler.com/eaaCatalog/singleTableInheritance.html 183 | [ActiveRecord]: http://api.rubyonrails.org/classes/ActiveRecord/Base.html 184 | [multiple_workflow_test]: https://github.com/geekq/workflow-activerecord/blob/develop/test/multiple_workflows_test.rb 185 | [`on_transition`]: https://github.com/geekq/workflow#on_transition 186 | [advanced_hooks_and_validation_test]: http://github.com/geekq/workflow-activerecord/blob/develop/test/advanced_hooks_and_validation_test.rb 187 | 188 | 189 | ### Handling the state transition in a transaction 190 | 191 | You might want to perform the state transition in a database transaction, 192 | so that the state is persisted atomically with all the other attributes. 193 | To do so, define a module: 194 | 195 | ```ruby 196 | module TransitionTransaction 197 | def process_event!(name, *, **) 198 | transaction { super(name, *, **) } 199 | end 200 | end 201 | ``` 202 | 203 | and then prepend it in your model: 204 | 205 | ```ruby 206 | class Task 207 | workflow do 208 | ... 209 | end 210 | 211 | prepend TransitionTransaction 212 | end 213 | ``` 214 | 215 | Changelog 216 | --------- 217 | 218 | ### New in the version 6.0.0 219 | 220 | * GH-14 retire Ruby 2.6 and Rails 5.* and older since they have reached end of 221 | live; please use workflow-activerecord 4.1.9, if you still depend on 222 | those versions 223 | 224 | ### New in the version 4.1.9 225 | 226 | * GH-13 Switch CI (continuous integration) from travis-CI to GitHub 227 | * Tested Rails 7.0 support 228 | 229 | 230 | Support 231 | ------- 232 | 233 | ### Reporting bugs 234 | 235 | 236 | 237 | 238 | About 239 | ----- 240 | 241 | Author: Vladimir Dobriakov, 242 | 243 | Copyright (c) 2010-2024 Vladimir Dobriakov and Contributors 244 | 245 | Copyright (c) 2008-2009 Vodafone 246 | 247 | Copyright (c) 2007-2008 Ryan Allen, FlashDen Pty Ltd 248 | 249 | Based on the work of Ryan Allen and Scott Barron 250 | 251 | Licensed under MIT license, see the LICENSE file. 252 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | require 'rdoc/task' 4 | 5 | Rake::TestTask.new(:test) do |t| 6 | t.libs << "test" 7 | t.libs << "lib" 8 | t.verbose = true 9 | t.warning = true 10 | t.test_files = FileList["test/**/*_test.rb"] 11 | end 12 | 13 | Rake::RDocTask.new do |rdoc| 14 | rdoc.rdoc_files.include("lib/**/*.rb") 15 | rdoc.options << "-S" 16 | end 17 | 18 | task :default => :test 19 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.0.Gemfile: -------------------------------------------------------------------------------- 1 | eval_gemfile File.join(File.dirname(__FILE__), "../Gemfile") 2 | 3 | gem 'activerecord', '~> 6.0.0' 4 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.1.Gemfile: -------------------------------------------------------------------------------- 1 | eval_gemfile File.join(File.dirname(__FILE__), "../Gemfile") 2 | 3 | gem 'activerecord', '~> 6.1.0' 4 | -------------------------------------------------------------------------------- /gemfiles/activerecord-7.0.Gemfile: -------------------------------------------------------------------------------- 1 | eval_gemfile File.join(File.dirname(__FILE__), "../Gemfile") 2 | gem 'activerecord', '~> 7.0.0' 3 | -------------------------------------------------------------------------------- /gemfiles/activerecord-7.1.Gemfile: -------------------------------------------------------------------------------- 1 | eval_gemfile File.join(File.dirname(__FILE__), "../Gemfile") 2 | gem 'activerecord', '~> 7.1.0' 3 | -------------------------------------------------------------------------------- /lib/workflow-activerecord.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'workflow' 3 | require 'workflow/specification' 4 | 5 | module WorkflowActiverecord 6 | def self.included(klass) 7 | klass.send :include, ::Workflow 8 | klass.send :include, InstanceMethods 9 | klass.send :extend, Scopes 10 | klass.before_validation :write_initial_state 11 | end 12 | 13 | module InstanceMethods 14 | def load_workflow_state 15 | read_attribute(self.class.workflow_column) 16 | end 17 | 18 | # On transition the new workflow state is immediately saved in the 19 | # database. 20 | def persist_workflow_state(new_value) 21 | # Rails 3.1 or newer 22 | update_column self.class.workflow_column, new_value 23 | end 24 | 25 | private 26 | 27 | # Motivation: even if NULL is stored in the workflow_state database column, 28 | # the current_state is correctly recognized in the Ruby code. The problem 29 | # arises when you want to SELECT records filtering by the value of initial 30 | # state. That's why it is important to save the string with the name of the 31 | # initial state in all the new records. 32 | def write_initial_state 33 | write_attribute self.class.workflow_column, current_state.to_s 34 | end 35 | end 36 | 37 | # This module will automatically generate ActiveRecord scopes based on workflow states. 38 | # The name of each generated scope will be something like `with__state` 39 | # 40 | # Examples: 41 | # 42 | # Article.with_pending_state # => ActiveRecord::Relation 43 | # Payment.without_refunded_state # => ActiveRecord::Relation 44 | #` 45 | # Example above just adds `where(:state_column_name => 'pending')` or 46 | # `where.not(:state_column_name => 'pending')` to AR query and returns 47 | # ActiveRecord::Relation. 48 | module Scopes 49 | def self.extended(object) 50 | class << object 51 | alias_method :workflow_without_scopes, :workflow unless method_defined?(:workflow_without_scopes) 52 | alias_method :workflow, :workflow_with_scopes 53 | end 54 | end 55 | 56 | def workflow_with_scopes(&specification) 57 | workflow_without_scopes(&specification) 58 | states = workflow_spec.states.values 59 | 60 | states.each do |state| 61 | define_singleton_method("with_#{state}_state") do 62 | where("#{table_name}.#{self.workflow_column.to_sym} = ?", state.to_s) 63 | end 64 | 65 | define_singleton_method("without_#{state}_state") do 66 | where.not("#{table_name}.#{self.workflow_column.to_sym} = ?", state.to_s) 67 | end 68 | end 69 | end 70 | 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/workflow-activerecord/version.rb: -------------------------------------------------------------------------------- 1 | module WorkflowActiverecord 2 | VERSION = "6.0.1" 3 | end 4 | -------------------------------------------------------------------------------- /lib/workflow_activerecord.rb: -------------------------------------------------------------------------------- 1 | warn < :last } 15 | state(:last) 16 | end 17 | end 18 | 19 | # Ensure custom persistence work, even if ActiveRecord integration is magically provided 20 | class ChosenByHookAdapter < ActiveRecord::Base 21 | self.table_name = :examples 22 | attr_reader :foo 23 | def self.workflow_adapter 24 | Module.new do 25 | def load_workflow_state 26 | @foo if defined?(@foo) 27 | end 28 | def persist_workflow_state(new_value) 29 | @foo = new_value 30 | end 31 | end 32 | end 33 | 34 | include Workflow 35 | workflow do 36 | state(:initial) { event :progress, :transitions_to => :last } 37 | state(:last) 38 | end 39 | end 40 | 41 | default = DefaultAdapter.create 42 | assert default.initial? 43 | default.progress! 44 | assert default.last? 45 | assert DefaultAdapter.find(default.id).last?, 'should have persisted via ActiveRecord' 46 | 47 | hook = ChosenByHookAdapter.create 48 | assert hook.initial? 49 | hook.progress! 50 | assert_equal hook.foo, 'last', 'should have "persisted" with custom adapter' 51 | assert ChosenByHookAdapter.find(hook.id).initial?, 'should not have persisted via ActiveRecord' 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/advanced_hooks_and_validation_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper') 2 | 3 | $VERBOSE = false 4 | require 'active_record' 5 | require 'sqlite3' 6 | require 'workflow' 7 | 8 | ActiveRecord::Migration.verbose = false 9 | 10 | # Transition based validation 11 | # --------------------------- 12 | # If you are using ActiveRecord you might want to define different validations 13 | # for different transitions. There is a `validates_presence_of` hook that let's 14 | # you specify the attributes that need to be present for an successful transition. 15 | # If the object is not valid at the end of the transition event the transition 16 | # is halted and a TransitionHalted exception is thrown. 17 | # 18 | # Here is a sample that illustrates how to use the presence validation: 19 | # (use case suggested by http://github.com/southdesign) 20 | class Article < ActiveRecord::Base 21 | include WorkflowActiverecord 22 | workflow do 23 | state :new do 24 | event :accept, :transitions_to => :accepted, :meta => {:validates_presence_of => [:title, :body]} 25 | event :reject, :transitions_to => :rejected 26 | end 27 | state :accepted do 28 | event :blame, :transitions_to => :blamed, :meta => {:validates_presence_of => [:title, :body, :blame_reason]} 29 | event :delete, :transitions_to => :deleted 30 | end 31 | state :rejected do 32 | event :delete, :transitions_to => :deleted 33 | end 34 | state :blamed do 35 | event :delete, :transitions_to => :deleted 36 | end 37 | state :deleted do 38 | event :accept, :transitions_to => :accepted 39 | end 40 | 41 | on_transition do |from, to, triggering_event, *event_args| 42 | if self.class.superclass.to_s.split("::").first == "ActiveRecord" 43 | singleton = class << self; self end 44 | validations = Proc.new {} 45 | 46 | meta = Article.workflow_spec.states[from].events[triggering_event].first.meta 47 | fields_to_validate = meta[:validates_presence_of] 48 | if fields_to_validate 49 | validations = Proc.new { 50 | Array(fields_to_validate).each do |attribute| 51 | value = self.send(:read_attribute_for_validation, attribute) 52 | errors.add(attribute, :blank) if value.blank? 53 | end 54 | } 55 | end 56 | 57 | singleton.send :define_method, :validate_for_transition, &validations 58 | validate_for_transition 59 | halt! "Event[#{triggering_event}]'s transitions_to[#{to}] is not valid." unless self.errors.empty? 60 | end 61 | end 62 | end 63 | end 64 | 65 | class AdvancedHooksAndValidationTest < ActiveRecordTestCase 66 | 67 | def setup 68 | super 69 | 70 | ActiveRecord::Schema.define do 71 | create_table :articles do |t| 72 | t.string :title 73 | t.string :body 74 | t.string :blame_reason 75 | t.string :reject_reason 76 | t.string :workflow_state 77 | end 78 | end 79 | 80 | exec "INSERT INTO articles(title, body, blame_reason, reject_reason, workflow_state) VALUES('new1', NULL, NULL, NULL, 'new')" 81 | exec "INSERT INTO articles(title, body, blame_reason, reject_reason, workflow_state) VALUES('new2', 'some content', NULL, NULL, 'new')" 82 | exec "INSERT INTO articles(title, body, blame_reason, reject_reason, workflow_state) VALUES('accepted1', 'some content', NULL, NULL, 'accepted')" 83 | 84 | end 85 | 86 | def assert_state(title, expected_state, klass = Order) 87 | o = klass.find_by_title(title) 88 | assert_equal expected_state, o.read_attribute(klass.workflow_column) 89 | o 90 | end 91 | 92 | test 'deny transition from new to accepted because of the missing presence of the body' do 93 | a = Article.find_by_title('new1'); 94 | assert_raises Workflow::TransitionHalted do 95 | a.accept! 96 | end 97 | assert_state 'new1', 'new', Article 98 | end 99 | 100 | test 'allow transition from new to accepted because body is present this time' do 101 | a = Article.find_by_title('new2'); 102 | assert a.accept! 103 | assert_state 'new2', 'accepted', Article 104 | end 105 | 106 | test 'allow transition from accepted to blamed because of a blame_reason' do 107 | a = Article.find_by_title('accepted1'); 108 | a.blame_reason = "Provocant thesis" 109 | assert a.blame! 110 | assert_state 'accepted1', 'blamed', Article 111 | end 112 | 113 | test 'deny transition from accepted to blamed because of no blame_reason' do 114 | a = Article.find_by_title('accepted1'); 115 | assert_raises Workflow::TransitionHalted do 116 | assert a.blame! 117 | end 118 | assert_state 'accepted1', 'accepted', Article 119 | end 120 | 121 | end 122 | 123 | -------------------------------------------------------------------------------- /test/attr_protected_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | $VERBOSE = false 4 | require 'active_record' 5 | require 'logger' 6 | require 'sqlite3' 7 | require 'workflow' 8 | require 'mocha/minitest' 9 | require 'stringio' 10 | 11 | require 'protected_attributes' if ActiveRecord::VERSION::MAJOR == 4 12 | 13 | # protected_attributes only supported until Rails 5.0 14 | if ActiveRecord::VERSION::MAJOR <= 4 15 | 16 | ActiveRecord::Migration.verbose = false 17 | 18 | class AttrProtectedTestOrder < ActiveRecord::Base 19 | include WorkflowActiverecord 20 | 21 | workflow do 22 | state :submitted do 23 | event :accept, :transitions_to => :accepted, :meta => {:doc_weight => 8} do |reviewer, args| 24 | end 25 | end 26 | state :accepted do 27 | event :ship, :transitions_to => :shipped 28 | end 29 | state :shipped 30 | end 31 | 32 | end 33 | 34 | AttrProtectedTestOrder.logger = Logger.new(STDOUT) # active_record 2.3 expects a logger instance 35 | AttrProtectedTestOrder.logger.level = Logger::WARN # switch to Logger::DEBUG to see the SQL statements 36 | 37 | class AttrProtectedTest < ActiveRecordTestCase 38 | 39 | def setup 40 | super 41 | 42 | ActiveRecord::Schema.define do 43 | create_table :attr_protected_test_orders do |t| 44 | t.string :title, :null => false 45 | t.string :workflow_state 46 | end 47 | end 48 | 49 | exec "INSERT INTO attr_protected_test_orders(title, workflow_state) VALUES('order1', 'submitted')" 50 | exec "INSERT INTO attr_protected_test_orders(title, workflow_state) VALUES('order2', 'accepted')" 51 | exec "INSERT INTO attr_protected_test_orders(title, workflow_state) VALUES('order3', 'accepted')" 52 | exec "INSERT INTO attr_protected_test_orders(title, workflow_state) VALUES('order4', 'accepted')" 53 | exec "INSERT INTO attr_protected_test_orders(title, workflow_state) VALUES('order5', 'accepted')" 54 | exec "INSERT INTO attr_protected_test_orders(title, workflow_state) VALUES('protected order', 'submitted')" 55 | end 56 | 57 | def assert_state(title, expected_state, klass = AttrProtectedTestOrder) 58 | o = klass.find_by_title(title) 59 | assert_equal expected_state, o.read_attribute(klass.workflow_column) 60 | o 61 | end 62 | 63 | test 'cannot mass-assign workflow_state if attr_protected' do 64 | o = AttrProtectedTestOrder.find_by_title('order1') 65 | assert_equal 'submitted', o.read_attribute(:workflow_state) 66 | AttrProtectedTestOrder.logger.level = Logger::ERROR # ignore warnings 67 | o.update_attributes :workflow_state => 'some_bad_value' 68 | AttrProtectedTestOrder.logger.level = Logger::WARN 69 | assert_equal 'submitted', o.read_attribute(:workflow_state) 70 | o.update_attribute :workflow_state, 'some_overridden_value' 71 | assert_equal 'some_overridden_value', o.read_attribute(:workflow_state) 72 | end 73 | 74 | test 'immediately save the new workflow_state on state machine transition' do 75 | o = assert_state 'order2', 'accepted' 76 | assert o.ship! 77 | assert_state 'order2', 'shipped' 78 | end 79 | 80 | test 'persist workflow_state in the db and reload' do 81 | o = assert_state 'order3', 'accepted' 82 | assert_equal :accepted, o.current_state.name 83 | o.ship! # should save in the database, no `o.save!` needed 84 | 85 | assert_state 'order3', 'shipped' 86 | 87 | o.reload 88 | assert_equal 'shipped', o.read_attribute(:workflow_state) 89 | end 90 | 91 | test 'default workflow column should be workflow_state' do 92 | o = assert_state 'order4', 'accepted' 93 | assert_equal :workflow_state, o.class.workflow_column 94 | end 95 | 96 | test 'access workflow specification' do 97 | assert_equal 3, AttrProtectedTestOrder.workflow_spec.states.length 98 | assert_equal ['submitted', 'accepted', 'shipped'].sort, 99 | AttrProtectedTestOrder.workflow_spec.state_names.map{|n| n.to_s}.sort 100 | end 101 | 102 | test 'current state object' do 103 | o = assert_state 'order5', 'accepted' 104 | assert_equal 'accepted', o.current_state.to_s 105 | assert_equal 1, o.current_state.events.length 106 | end 107 | 108 | end 109 | 110 | end 111 | -------------------------------------------------------------------------------- /test/main_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper') 2 | 3 | $VERBOSE = false 4 | require 'active_record' 5 | require 'sqlite3' 6 | require 'workflow' 7 | require 'mocha/minitest' 8 | require 'stringio' 9 | 10 | ActiveRecord::Migration.verbose = false 11 | 12 | class Order < ActiveRecord::Base 13 | include WorkflowActiverecord 14 | workflow do 15 | state :submitted do 16 | event :accept, :transitions_to => :accepted, :meta => {:weight => 8} do |reviewer, args| 17 | end 18 | end 19 | state :accepted do 20 | event :ship, :transitions_to => :shipped 21 | end 22 | state :shipped 23 | end 24 | end 25 | 26 | class LegacyOrder < ActiveRecord::Base 27 | include WorkflowActiverecord 28 | 29 | workflow_column :foo_bar # use this legacy database column for persistence 30 | 31 | workflow do 32 | state :submitted do 33 | event :accept, :transitions_to => :accepted, :meta => {:weight => 8} do |reviewer, args| 34 | end 35 | end 36 | state :accepted do 37 | event :ship, :transitions_to => :shipped 38 | end 39 | state :shipped 40 | end 41 | end 42 | 43 | class Image < ActiveRecord::Base 44 | include WorkflowActiverecord 45 | 46 | workflow_column :status 47 | 48 | workflow do 49 | state :unconverted do 50 | event :convert, :transitions_to => :converted 51 | end 52 | state :converted 53 | end 54 | end 55 | 56 | class SmallImage < Image 57 | end 58 | 59 | class SpecialSmallImage < SmallImage 60 | end 61 | 62 | class MainTest < ActiveRecordTestCase 63 | 64 | def setup 65 | super 66 | 67 | ActiveRecord::Schema.define do 68 | create_table :orders do |t| 69 | t.string :title, :null => false 70 | t.string :workflow_state 71 | end 72 | end 73 | 74 | exec "INSERT INTO orders(title, workflow_state) VALUES('some order', 'accepted')" 75 | 76 | ActiveRecord::Schema.define do 77 | create_table :legacy_orders do |t| 78 | t.string :title, :null => false 79 | t.string :foo_bar 80 | end 81 | end 82 | 83 | exec "INSERT INTO legacy_orders(title, foo_bar) VALUES('some order', 'accepted')" 84 | 85 | ActiveRecord::Schema.define do 86 | create_table :images do |t| 87 | t.string :title, :null => false 88 | t.string :state 89 | t.string :type 90 | end 91 | end 92 | end 93 | 94 | def assert_state(title, expected_state, klass = Order) 95 | o = klass.find_by_title(title) 96 | assert_equal expected_state, o.read_attribute(klass.workflow_column) 97 | o 98 | end 99 | 100 | test 'immediately save the new workflow_state on state machine transition' do 101 | o = assert_state 'some order', 'accepted' 102 | assert o.ship! 103 | assert_state 'some order', 'shipped' 104 | end 105 | 106 | test 'immediately save the new workflow_state on state machine transition with custom column name' do 107 | o = assert_state 'some order', 'accepted', LegacyOrder 108 | assert o.ship! 109 | assert_state 'some order', 'shipped', LegacyOrder 110 | end 111 | 112 | test 'persist workflow_state in the db and reload' do 113 | o = assert_state 'some order', 'accepted' 114 | assert_equal :accepted, o.current_state.name 115 | o.ship! 116 | o.save! 117 | 118 | assert_state 'some order', 'shipped' 119 | 120 | o.reload 121 | assert_equal 'shipped', o.read_attribute(:workflow_state) 122 | end 123 | 124 | test 'persist workflow_state in the db with_custom_name and reload' do 125 | o = assert_state 'some order', 'accepted', LegacyOrder 126 | assert_equal :accepted, o.current_state.name 127 | o.ship! 128 | o.save! 129 | 130 | assert_state 'some order', 'shipped', LegacyOrder 131 | 132 | o.reload 133 | assert_equal 'shipped', o.read_attribute(:foo_bar) 134 | end 135 | 136 | test 'default workflow column should be workflow_state' do 137 | o = assert_state 'some order', 'accepted' 138 | assert_equal :workflow_state, o.class.workflow_column 139 | end 140 | 141 | test 'custom workflow column should be foo_bar' do 142 | o = assert_state 'some order', 'accepted', LegacyOrder 143 | assert_equal :foo_bar, o.class.workflow_column 144 | end 145 | 146 | test 'access workflow specification' do 147 | assert_equal 3, Order.workflow_spec.states.length 148 | assert_equal ['submitted', 'accepted', 'shipped'].sort, 149 | Order.workflow_spec.state_names.map{|n| n.to_s}.sort 150 | end 151 | 152 | test 'current state object' do 153 | o = assert_state 'some order', 'accepted' 154 | assert_equal 'accepted', o.current_state.to_s 155 | assert_equal 1, o.current_state.events.length 156 | end 157 | 158 | test 'nil as initial state' do 159 | exec "INSERT INTO orders(title, workflow_state) VALUES('nil state', NULL)" 160 | o = Order.find_by_title('nil state') 161 | assert o.submitted?, 'if workflow_state is nil, the initial state should be assumed' 162 | assert !o.shipped? 163 | end 164 | 165 | test 'initial state immediately set as ActiveRecord attribute for new objects' do 166 | o = Order.create(:title => 'new object') 167 | assert_equal 'submitted', o.read_attribute(:workflow_state) 168 | end 169 | 170 | test 'question methods for state' do 171 | o = assert_state 'some order', 'accepted' 172 | assert o.accepted? 173 | assert !o.shipped? 174 | end 175 | 176 | test 'correct exception for event, that is not allowed in current state' do 177 | o = assert_state 'some order', 'accepted' 178 | assert_raises Workflow::NoTransitionAllowed do 179 | o.accept! 180 | end 181 | end 182 | 183 | test 'STI when parent changed the workflow_state column' do 184 | assert_equal 'status', Image.workflow_column.to_s 185 | assert_equal 'status', SmallImage.workflow_column.to_s 186 | assert_equal 'status', SpecialSmallImage.workflow_column.to_s 187 | end 188 | 189 | test 'Single table inheritance (STI)' do 190 | class BigOrder < Order 191 | end 192 | 193 | bo = BigOrder.new 194 | assert bo.submitted? 195 | assert !bo.accepted? 196 | end 197 | 198 | test 'Two-level inheritance' do 199 | class BigOrder < Order 200 | end 201 | 202 | class EvenBiggerOrder < BigOrder 203 | end 204 | 205 | assert EvenBiggerOrder.new.submitted? 206 | end 207 | 208 | test 'Iheritance with workflow definition override' do 209 | class BigOrder < Order 210 | end 211 | 212 | class SpecialBigOrder < BigOrder 213 | workflow do 214 | state :start_big 215 | end 216 | end 217 | 218 | special = SpecialBigOrder.new 219 | assert_equal 'start_big', special.current_state.to_s 220 | end 221 | end 222 | -------------------------------------------------------------------------------- /test/multiple_workflows_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper') 2 | require 'workflow' 3 | class MultipleWorkflowsTest < ActiveRecordTestCase 4 | 5 | test 'multiple workflows' do 6 | 7 | ActiveRecord::Schema.define do 8 | create_table :bookings do |t| 9 | t.string :title, :null => false 10 | t.string :workflow_state 11 | t.string :workflow_type 12 | end 13 | end 14 | 15 | exec "INSERT INTO bookings(title, workflow_state, workflow_type) VALUES('booking1', 'initial', 'workflow_1')" 16 | exec "INSERT INTO bookings(title, workflow_state, workflow_type) VALUES('booking2', 'initial', 'workflow_2')" 17 | 18 | class Booking < ActiveRecord::Base 19 | 20 | include WorkflowActiverecord 21 | 22 | def initialize_workflow 23 | # define workflow per object instead of per class 24 | case workflow_type 25 | when 'workflow_1' 26 | class << self 27 | workflow do 28 | state :initial do 29 | event :progress, :transitions_to => :last 30 | end 31 | state :last 32 | end 33 | end 34 | when 'workflow_2' 35 | class << self 36 | workflow do 37 | state :initial do 38 | event :progress, :transitions_to => :intermediate 39 | end 40 | state :intermediate 41 | state :last 42 | end 43 | end 44 | end 45 | end 46 | 47 | def metaclass; class << self; self; end; end 48 | 49 | def workflow_spec 50 | metaclass.workflow_spec 51 | end 52 | 53 | end 54 | 55 | booking1 = Booking.find_by_title('booking1') 56 | booking1.initialize_workflow 57 | 58 | booking2 = Booking.find_by_title('booking2') 59 | booking2.initialize_workflow 60 | 61 | assert booking1.initial? 62 | booking1.progress! 63 | assert booking1.last?, 'booking1 should transition to the "last" state' 64 | 65 | assert booking2.initial? 66 | booking2.progress! 67 | assert booking2.intermediate?, 'booking2 should transition to the "intermediate" state' 68 | 69 | assert booking1.workflow_spec, 'can access the individual workflow specification' 70 | assert_equal 2, booking1.workflow_spec.states.length 71 | assert_equal 3, booking2.workflow_spec.states.length 72 | 73 | # check persistence 74 | booking2reloaded = Booking.find_by_title('booking2') 75 | booking2reloaded.initialize_workflow 76 | assert booking2reloaded.intermediate?, 'persistence of workflow state does not work' 77 | end 78 | 79 | class Object 80 | # The hidden singleton lurks behind everyone 81 | def metaclass; class << self; self; end; end 82 | end 83 | 84 | end 85 | -------------------------------------------------------------------------------- /test/persistence_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'active_record' 3 | require 'logger' 4 | require 'sqlite3' 5 | require 'workflow' 6 | require 'mocha/minitest' 7 | require 'stringio' 8 | 9 | ActiveRecord::Migration.verbose = false 10 | 11 | class PersistenceTestOrder < ActiveRecord::Base 12 | include WorkflowActiverecord 13 | 14 | workflow do 15 | state :submitted do 16 | event :accept, :transitions_to => :accepted, :meta => {:doc_weight => 8} do |reviewer, args| 17 | end 18 | end 19 | state :accepted do 20 | event :ship, :transitions_to => :shipped 21 | end 22 | state :shipped 23 | end 24 | 25 | end 26 | 27 | PersistenceTestOrder.logger = Logger.new(STDOUT) # active_record 2.3 expects a logger instance 28 | PersistenceTestOrder.logger.level = Logger::WARN # switch to Logger::DEBUG to see the SQL statements 29 | 30 | class PersistenceTest < ActiveRecordTestCase 31 | 32 | def setup 33 | super 34 | 35 | ActiveRecord::Schema.define do 36 | create_table :persistence_test_orders do |t| 37 | t.string :title, :null => false 38 | t.string :workflow_state 39 | end 40 | end 41 | 42 | exec "INSERT INTO persistence_test_orders(title, workflow_state) VALUES('order6', 'accepted')" 43 | end 44 | 45 | def assert_state(title, expected_state, klass = PersistenceTestOrder) 46 | o = klass.find_by_title(title) 47 | assert_equal expected_state, o.read_attribute(klass.workflow_column) 48 | o 49 | end 50 | 51 | test 'ensure other dirty attributes are not saved on state change' do 52 | o = assert_state 'order6', 'accepted' 53 | o.title = 'going to change the title' 54 | assert o.changed? 55 | o.ship! 56 | assert o.changed?, 'title should not be saved and the change still stay pending' 57 | end 58 | 59 | end 60 | 61 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Require and start simplecov BEFORE minitest/autorun loads ./lib to get correct test results. 2 | # Otherwise lot of executed lines are not detected. 3 | require 'simplecov' 4 | SimpleCov.start do 5 | add_filter 'test' 6 | end 7 | 8 | require 'minitest/autorun' 9 | require 'active_record' 10 | require 'workflow' 11 | require 'workflow-activerecord' 12 | 13 | class << Minitest::Test 14 | def test(name, &block) 15 | test_name = :"test_#{name.gsub(' ','_')}" 16 | raise ArgumentError, "#{test_name} is already defined" if self.instance_methods.include? test_name.to_s 17 | if block 18 | define_method test_name, &block 19 | else 20 | puts "PENDING: #{name}" 21 | end 22 | end 23 | end 24 | 25 | class ActiveRecordTestCase < Minitest::Test 26 | def exec(sql) 27 | ActiveRecord::Base.connection.execute sql 28 | end 29 | 30 | def setup 31 | ActiveRecord::Base.establish_connection( 32 | :adapter => "sqlite3", 33 | :database => ":memory:" #"tmp/test" 34 | ) 35 | 36 | # eliminate ActiveRecord warning. TODO: delete as soon as ActiveRecord is fixed 37 | ActiveRecord::Base.connection.reconnect! 38 | end 39 | 40 | def teardown 41 | ActiveRecord::Base.connection.disconnect! 42 | end 43 | 44 | def default_test 45 | end 46 | end 47 | 48 | -------------------------------------------------------------------------------- /workflow_activerecord.gemspec: -------------------------------------------------------------------------------- 1 | require_relative 'lib/workflow-activerecord/version' 2 | 3 | Gem::Specification.new do |gem| 4 | gem.name = "workflow-activerecord" 5 | gem.version = WorkflowActiverecord::VERSION 6 | gem.authors = ["Vladimir Dobriakov"] 7 | gem.email = ["vladimir@geekq.net"] 8 | gem.description = <<~DESC 9 | ActiveRecord/Rails Integration for the Workflow library. 10 | Workflow is a finite-state-machine-inspired API for modeling and interacting 11 | with what we tend to refer to as 'workflow'. 12 | DESC 13 | gem.summary = %q{ActiveRecord/Rails Integration for the Workflow library.} 14 | gem.licenses = ['MIT'] 15 | gem.homepage = "https://github.com/geekq/workflow-activerecord" 16 | 17 | gem.files = Dir['CHANGELOG.md', 'README.md', 'LICENSE', 'lib/**/*'] 18 | gem.executables = gem.files.grep(%r{^bin/}) { |f| File.basename(f) } 19 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 20 | gem.require_paths = ['lib'] 21 | 22 | gem.extra_rdoc_files = [ 23 | "README.md" 24 | ] 25 | 26 | rails_versions = ['>= 6.0'] 27 | 28 | gem.required_ruby_version = '>= 2.7' 29 | 30 | gem.add_runtime_dependency 'workflow', '~> 3.0' 31 | gem.add_runtime_dependency 'activerecord', rails_versions 32 | 33 | gem.add_development_dependency 'rdoc', '~> 6.4' 34 | gem.add_development_dependency 'bundler', '~> 2.3' 35 | gem.add_development_dependency 'mocha', '~> 2.2' 36 | gem.add_development_dependency 'rake', '~> 13.1' 37 | gem.add_development_dependency 'minitest', '~> 5.21' 38 | gem.add_development_dependency 'sqlite3', '~> 1.3' 39 | end 40 | 41 | --------------------------------------------------------------------------------