├── .github └── workflows │ └── main.yml ├── .gitignore ├── Gemfile ├── MIT-LICENSE ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── generators │ └── stateful_enum │ │ ├── graph_generator.rb │ │ └── plantuml_generator.rb ├── stateful_enum.rb └── stateful_enum │ ├── active_record_extension.rb │ ├── machine.rb │ ├── railtie.rb │ ├── state_inspection.rb │ └── version.rb ├── stateful_enum.gemspec └── test ├── dummy ├── .gitignore ├── Rakefile ├── app │ ├── assets │ │ └── config │ │ │ └── manifest.js │ └── models │ │ ├── .keep │ │ ├── bug.rb │ │ └── user.rb ├── bin │ └── rails ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ └── test.rb │ └── secrets.yml ├── db │ └── migrate │ │ ├── 20160307200506_create_users.rb │ │ └── 20160307203948_create_bugs.rb ├── doc │ └── .keep ├── log │ └── .keep └── tmp │ └── .keep ├── graph_test.rb ├── mechanic_machine_test.rb ├── plantuml_test.rb ├── state_inspection_test.rb └── test_helper.rb /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | strategy: 8 | matrix: 9 | ruby_version: [ruby-head, '3.4', '3.3', '3.2', '3.1'] 10 | rails_version: [edge, '8.0', '7.2', '7.1', '7.0', '6.1'] 11 | 12 | include: 13 | - ruby_version: '3.0' 14 | rails_version: '7.1' 15 | - ruby_version: '3.0' 16 | rails_version: '7.0' 17 | - ruby_version: '3.0' 18 | rails_version: '6.1' 19 | - ruby_version: '3.0' 20 | rails_version: '6.0' 21 | 22 | - ruby_version: '2.7' 23 | rails_version: '7.1' 24 | - ruby_version: '2.7' 25 | rails_version: '7.0' 26 | - ruby_version: '2.7' 27 | rails_version: '6.1' 28 | - ruby_version: '2.7' 29 | rails_version: '6.0' 30 | 31 | - ruby_version: '2.6' 32 | rails_version: '6.1' 33 | - ruby_version: '2.6' 34 | rails_version: '6.0' 35 | - ruby_version: '2.6' 36 | rails_version: '5.2' 37 | - ruby_version: '2.6' 38 | rails_version: '5.1' 39 | - ruby_version: '2.6' 40 | rails_version: '5.0' 41 | - ruby_version: '2.6' 42 | rails_version: '4.2' 43 | bundler_version: '1' 44 | 45 | - ruby_version: '2.5' 46 | rails_version: '5.2' 47 | - ruby_version: '2.5' 48 | rails_version: '4.2' 49 | bundler_version: '1' 50 | 51 | - ruby_version: '2.4' 52 | rails_version: '5.2' 53 | - ruby_version: '2.4' 54 | rails_version: '4.2' 55 | bundler_version: '1' 56 | 57 | - ruby_version: '2.3' 58 | rails_version: '4.1' 59 | bundler_version: '1' 60 | 61 | - ruby_version: '2.2' 62 | rails_version: '5.2' 63 | bundler_version: '1' 64 | 65 | exclude: 66 | - ruby_version: '3.1' 67 | rails_version: 'edge' 68 | - ruby_version: '3.1' 69 | rails_version: '8.0' 70 | 71 | env: 72 | RAILS_VERSION: ${{ matrix.rails_version }} 73 | 74 | runs-on: ubuntu-24.04 75 | 76 | steps: 77 | - uses: actions/checkout@v4 78 | 79 | - uses: ruby/setup-ruby@v1 80 | with: 81 | ruby-version: "${{ matrix.ruby_version }}" 82 | rubygems: ${{ matrix.ruby_version < '2.7' && 'default' || 'latest' }} 83 | bundler: "${{ matrix.bundler_version }}" 84 | bundler-cache: true 85 | continue-on-error: ${{ (matrix.ruby_version == 'ruby-head') || (matrix.rails_version == 'edge') || (matrix.allow_failures == 'true') }} 86 | 87 | - run: sudo apt-get install graphviz 88 | 89 | - run: bundle exec rake 90 | continue-on-error: ${{ (matrix.ruby_version == 'ruby-head') || (matrix.rails_version == 'edge') || (matrix.allow_failures == 'true') }} 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | gemfiles/*.lock 5 | /_yardoc/ 6 | /coverage/ 7 | /doc/ 8 | /pkg/ 9 | /spec/reports/ 10 | /tmp/ 11 | /log/ 12 | /test/dummy/log/ 13 | /test/dummy/tmp/ 14 | 15 | .byebug_history 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in stateful_enum.gemspec 6 | gemspec 7 | 8 | if ENV['RAILS_VERSION'] == 'edge' 9 | gem 'rails', git: 'https://github.com/rails/rails.git' 10 | elsif ENV['RAILS_VERSION'] 11 | gem 'rails', "~> #{ENV['RAILS_VERSION']}.0" 12 | if ENV['RAILS_VERSION'] <= '5.0' 13 | gem 'sqlite3', '< 1.4' 14 | elsif ENV['RAILS_VERSION'] <= '7.1' 15 | gem 'sqlite3', '~> 1.4' 16 | end 17 | else 18 | gem 'rails' 19 | end 20 | 21 | gem 'selenium-webdriver' 22 | gem 'nokogiri', RUBY_VERSION < '2.1' ? '~> 1.6.0' : '>= 1.7' 23 | gem 'loofah', RUBY_VERSION < '2.5' ? '< 2.21.0' : '>= 0' 24 | 25 | if RUBY_VERSION >= '3.3' 26 | gem 'bigdecimal' 27 | gem 'mutex_m' 28 | end 29 | 30 | gem 'benchmark' if RUBY_VERSION >= '3.5' 31 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Akira Matsuda 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # StatefulEnum [![Build Status](https://github.com/amatsuda/stateful_enum/actions/workflows/main.yml/badge.svg)](https://github.com/amatsuda/stateful_enum/actions) 2 | 3 | stateful_enum is a state machine gem built on top of ActiveRecord's built-in ActiveRecord::Enum. 4 | 5 | 6 | ## Installation 7 | 8 | Add this line to your Rails app's Gemfile: 9 | 10 | ```ruby 11 | gem 'stateful_enum' 12 | ``` 13 | 14 | And bundle. 15 | 16 | 17 | ## Motivation 18 | 19 | ### You Ain't Gonna Need Abstraction 20 | 21 | stateful_enum depends on ActiveRecord. If you prefer a "well-abstracted" state machine library that supports multiple datastores, or Plain Old Ruby Objects (who needs that feature?), I'm sorry but this gem is not for you. 22 | 23 | ### I Hate Saving States in a VARCHAR Column 24 | 25 | From a database design point of view, I prefer to save state data in an INTEGER column rather than saving the state name directly in a VARCHAR column. 26 | 27 | ### :heart: ActiveRecord::Enum 28 | 29 | ActiveRecord 4.1+ has a very simple and useful built-in Enum DSL that provides human-friendly API over integer values in DB. 30 | 31 | ### Method Names Should be Verbs 32 | 33 | AR::Enum automatically defines Ruby methods per each label. However, Enum labels are in most cases adjectives or past participle, which often creates weird method names. 34 | What we really want to define as methods are the transition events between states, and not the states themselves. 35 | 36 | 37 | ## Usage 38 | 39 | The stateful_enum gem extends AR::Enum definition to take a block with a similar DSL to the [state_machine](https://github.com/pluginaweek/state_machine) gem. 40 | 41 | Example: 42 | ```ruby 43 | class Bug < ApplicationRecord 44 | enum :status, {unassigned: 0, assigned: 1, resolved: 2, closed: 3} do 45 | event :assign do 46 | transition :unassigned => :assigned 47 | end 48 | 49 | event :resolve do 50 | before do 51 | self.resolved_at = Time.zone.now 52 | end 53 | 54 | transition [:unassigned, :assigned] => :resolved 55 | end 56 | 57 | event :close do 58 | after do 59 | Notifier.notify "Bug##{id} has been closed." 60 | end 61 | 62 | transition all - [:closed] => :closed 63 | end 64 | end 65 | end 66 | ``` 67 | 68 | ### Defining the States 69 | 70 | Just call the AR::Enum's `enum` method. The only difference from the original `enum` method is that our `enum` call takes a block. 71 | Please see the full API documentation of [AR::Enum](http://edgeapi.rubyonrails.org/classes/ActiveRecord/Enum.html) for more information. 72 | 73 | ### Defining the Events 74 | 75 | You can declare events through `event` method inside of an `enum` block. Then stateful_enum defines the following methods per each event: 76 | 77 | **An instance method to fire the event** 78 | 79 | ```ruby 80 | @bug.assign # does nothing and returns false if a valid transition for the current state is not defined 81 | ``` 82 | 83 | **An instance method with `!` to fire the event** 84 | ```ruby 85 | @bug.assign! # raises if a valid transition for the current state is not defined 86 | ``` 87 | 88 | **A predicate method that returns if the event is fireable** 89 | ```ruby 90 | @bug.can_assign? # returns if the `assign` event can be called on this bug or not 91 | ``` 92 | 93 | **An instance method that returns the state name after an event** 94 | ```ruby 95 | @bug.assign_transition #=> :assigned 96 | ``` 97 | 98 | ### Defining the Transitions 99 | 100 | You can define state transitions through `transition` method inside of an `event` block. 101 | 102 | There are a few important details to note regarding this feature: 103 | 104 | * The `transition` method takes a Hash each key of which is state "from" transitions to the Hash value. 105 | * The "from" states and the "to" states should both be given in Symbols. 106 | * The "from" state can be multiple states, in which case the key can be given as an Array of states, as shown in the usage example. 107 | * The "from" state can be `all` that means all defined states. 108 | * The "from" state can be an exception of Array of states, in this case the key can be a subtraction of `all` with the state to be excluded, as shown in the usage example. 109 | 110 | ### :if and :unless Condition 111 | 112 | The `transition` method takes an `:if` or `:unless` option as a Proc. 113 | 114 | Example: 115 | ```ruby 116 | event :assign do 117 | transition :unassigned => :assigned, if: -> { !!assigned_to } 118 | end 119 | ``` 120 | 121 | ### Event Hooks 122 | 123 | You can define `before` and `after` event hooks inside of an `event` block. 124 | 125 | ### Inspecting All Defined Events And Current Possible Events 126 | 127 | You can get the list of defined events from the model class: 128 | 129 | ```ruby 130 | Bug.stateful_enum.events 131 | #=> an Array of all defined StatefulEnum::Machine::Event objects 132 | ``` 133 | 134 | And you can get the list of possible event definitions from the model instance: 135 | 136 | ```ruby 137 | Bug.new(status: :assigned).stateful_enum.possible_events 138 | #=> an Array of StatefulEnum::Machine::Event objects that are callable from the receiver object 139 | ``` 140 | 141 | Maybe what you really need for your app is the list of possible event "names": 142 | 143 | ```ruby 144 | Bug.new(status: :assigned).stateful_enum.possible_event_names 145 | #=> [:resolve, :close] 146 | ``` 147 | 148 | You can get the list of next possible state names as well: 149 | 150 | ```ruby 151 | Bug.new(status: :assigned).stateful_enum.possible_states 152 | #=> [:resolved, :closed] 153 | ``` 154 | 155 | These features would help some kind of metaprogramming over state transitions. 156 | 157 | 158 | ## Generating State Machine Diagrams 159 | 160 | stateful_enum includes a Rails generator that generates a state machine diagram. 161 | Note that you need to bundle the ruby-graphviz gem (and its dependencies) for the development env in order to run the generator. 162 | 163 | ```bash 164 | % rails g stateful_enum:graph bug 165 | ``` 166 | 167 | You can specify relative or absolute output path via environment variable `DEST_DIR`. 168 | 169 | ```bash 170 | % DEST_DIR=doc rails g stateful_enum:graph bug 171 | ``` 172 | 173 | ## TODO 174 | 175 | * Better Error handling 176 | 177 | 178 | ## Support Rails Versions 179 | 180 | * Rails 4.1.x, 4.2.x, 5.0, 5.1, 5.2, 6.0, 6.1, 7.0, 7.1, 7.2, 8.0, and 8.1 (edge) 181 | 182 | 183 | ## Contributing 184 | 185 | Pull requests are welcome on GitHub at https://github.com/amatsuda/stateful_enum. 186 | 187 | 188 | ## License 189 | 190 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 191 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | require 'yaml' 6 | 7 | Rake::TestTask.new(:test) do |t| 8 | t.libs << "test" 9 | t.libs << "lib" 10 | t.test_files = FileList['test/**/*_test.rb'] 11 | t.warning = true 12 | t.verbose = true 13 | end 14 | 15 | task default: :test 16 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "stateful_enum" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/generators/stateful_enum/graph_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/named_base' 4 | 5 | module StatefulEnum 6 | module Generators 7 | class GraphGenerator < ::Rails::Generators::NamedBase 8 | desc 'Draws a state machine diagram as a PNG file' 9 | def draw 10 | require 'ruby-graphviz' 11 | StatefulEnum::Machine.prepend StatefulEnum::Graph 12 | class_name.constantize 13 | end 14 | end 15 | end 16 | 17 | module Graph 18 | def initialize(model, column, states, prefix, suffix, &block) 19 | super 20 | GraphDrawer.new model, column, states, @prefix, @suffix, &block 21 | end 22 | 23 | class GraphDrawer 24 | def initialize(model, column, states, prefix, suffix, &block) 25 | @states, @prefix, @suffix = states, prefix, suffix 26 | @g = ::GraphViz.new 'G', rankdir: 'TB' 27 | 28 | states.each do |state| 29 | @g.add_node state.to_s, label: state.to_s, width: '1', height: '1', shape: 'ellipse' 30 | end 31 | if (default_value = model.columns_hash[column.to_s].default) 32 | default_label = model.defined_enums[column.to_s].key default_value.to_i # SQLite returns the default value in String 33 | @g.add_edge @g.add_node('start state', shape: 'point'), @g.get_node(default_label) 34 | end 35 | 36 | instance_eval(&block) 37 | 38 | (@g.each_edge.map(&:node_two).uniq - @g.each_edge.map(&:node_one).uniq).each do |final| 39 | @g.get_node(final) {|n| n['shape'] = 'doublecircle' } 40 | end 41 | 42 | @g.output png: File.join((ENV['DEST_DIR'] || Dir.pwd), "#{model.name}_#{column}.png") 43 | end 44 | 45 | def event(name, &block) 46 | EventDrawer.new @g, @states, @prefix, @suffix, name, &block 47 | end 48 | end 49 | 50 | class EventDrawer < ::StatefulEnum::Machine::Event 51 | def initialize(g, states, prefix, suffix, name, &block) 52 | @g, @states, @prefix, @suffix, @name = g, states, prefix, suffix, name 53 | @before, @after = [], [] 54 | 55 | instance_eval(&block) if block 56 | end 57 | 58 | def transition(transitions, options = {}) 59 | if options.blank? 60 | transitions.delete :if 61 | transitions.delete :unless 62 | end 63 | 64 | transitions.each_pair do |from, to| 65 | Array(from).each do |f| 66 | @g.add_edge f.to_s, to.to_s, label: "#{@prefix}#{@name}#{@suffix}" 67 | end 68 | end 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/generators/stateful_enum/plantuml_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/named_base' 4 | 5 | module StatefulEnum 6 | module Generators 7 | class PlantumlGenerator < ::Rails::Generators::NamedBase 8 | desc 'Draws a PlantUML state machine diagram' 9 | def plantuml 10 | StatefulEnum::Machine.prepend StatefulEnum::PlantUML 11 | class_name.constantize 12 | end 13 | end 14 | end 15 | 16 | module PlantUML 17 | def initialize(model, column, states, prefix, suffix, &block) 18 | super 19 | UmlWriter.new model, column, states, @prefix, @suffix, &block 20 | end 21 | 22 | class UmlWriter 23 | def initialize(model, column, states, prefix, suffix, &block) 24 | @states, @prefix, @suffix = states, prefix, suffix 25 | @items = [] 26 | 27 | if (default_value = model.columns_hash[column.to_s].default) 28 | default_label = model.defined_enums[column.to_s].key default_value.to_i # SQLite returns the default value in String 29 | end 30 | 31 | instance_eval(&block) 32 | 33 | lines = default_label ? ["[*] --> #{default_label}"] : [] 34 | lines.concat @items.map {|item| "#{item.from} --> #{item.to} :#{item.label}" } 35 | (@items.map(&:to).uniq - @items.map(&:from).uniq).each do |final| 36 | lines.push "#{final} --> [*]" 37 | end 38 | 39 | File.write(File.join((ENV['DEST_DIR'] || Dir.pwd), "#{model.name}.puml"), lines.join("\n") << "\n") 40 | end 41 | 42 | def event(name, &block) 43 | EventStore.new @items, @states, @prefix, @suffix, name, &block 44 | end 45 | end 46 | 47 | class EventStore < ::StatefulEnum::Machine::Event 48 | def initialize(items, states, prefix, suffix, name, &block) 49 | @items, @states, @prefix, @suffix, @name, @before, @after = items, states, prefix, suffix, name, [], [] 50 | 51 | instance_eval(&block) if block 52 | end 53 | 54 | def transition(transitions, options = {}) 55 | if options.blank? 56 | transitions.delete :if 57 | transitions.delete :unless 58 | end 59 | 60 | transitions.each_pair do |from, to| 61 | Array(from).each do |f| 62 | @items.push Item.new(f, to, "#{@prefix}#{@name}#{@suffix}") 63 | end 64 | end 65 | end 66 | end 67 | 68 | Item = Struct.new(:from, :to, :label) 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/stateful_enum.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'stateful_enum/version' 4 | require 'stateful_enum/railtie' 5 | -------------------------------------------------------------------------------- /lib/stateful_enum/active_record_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'stateful_enum/machine' 4 | 5 | module StatefulEnum 6 | module ActiveRecordEnumExtension 7 | # enum status: {unassigned: 0, assigned: 1, resolved: 2, closed: 3} do 8 | # event :assign do 9 | # transition :unassigned => :assigned 10 | # end 11 | # end 12 | if Rails::VERSION::MAJOR >= 7 13 | def enum(name = nil, values = nil, **options, &block) 14 | return super unless block 15 | 16 | definitions = super name, values, **options 17 | 18 | if name 19 | (@_defined_stateful_enums ||= []) << StatefulEnum::Machine.new(self, name, (definitions.is_a?(Hash) ? definitions.keys : definitions), options[:prefix], options[:suffix], &block) 20 | else 21 | definitions.each do |column, states| 22 | (@_defined_stateful_enums ||= []) << StatefulEnum::Machine.new(self, column, (states.is_a?(Hash) ? states.keys : states), options[:_prefix], options[:_suffix], &block) 23 | end 24 | end 25 | 26 | definitions 27 | end 28 | else 29 | def enum(definitions, &block) 30 | return super unless block 31 | 32 | # Preserving prefix and suffix values before calling super because the super destroys the given Hash 33 | prefix, suffix = definitions[:_prefix], definitions[:_suffix] if Rails::VERSION::MAJOR >= 5 34 | enum_values = super definitions 35 | 36 | enum_values.each_pair do |column, states| 37 | (@_defined_stateful_enums ||= []) << StatefulEnum::Machine.new(self, column, (states.is_a?(Hash) ? states.keys : states), prefix, suffix, &block) 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/stateful_enum/machine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module StatefulEnum 4 | class Machine 5 | attr_reader :events 6 | 7 | def initialize(model, column, states, prefix, suffix, &block) 8 | @model, @column, @states, @events = model, column, states.map(&:to_sym), [] 9 | @prefix = if prefix 10 | prefix == true ? "#{column}_" : "#{prefix}_" 11 | end 12 | @suffix = if suffix 13 | suffix == true ? "_#{column}" : "_#{suffix}" 14 | end 15 | 16 | # undef non-verb methods e.g. Model#active! 17 | states.each do |state| 18 | @model.send :undef_method, "#{@prefix}#{state}#{@suffix}!" 19 | end 20 | 21 | instance_eval(&block) if block 22 | end 23 | 24 | def event(name, &block) 25 | raise ArgumentError, "event: :#{name} has already been defined." if @events.map(&:name).include? name 26 | @events << Event.new(@model, @column, @states, @prefix, @suffix, name, &block) 27 | end 28 | 29 | class Event 30 | attr_reader :name, :value_method_name, :transitions 31 | 32 | def initialize(model, column, states, prefix, suffix, name, &block) 33 | @states, @name, @transitions, @before, @after = states, name, {}, [], [] 34 | 35 | instance_eval(&block) if block 36 | 37 | transitions, before, after = @transitions, @before, @after 38 | @value_method_name = value_method_name = :"#{prefix}#{name}#{suffix}" 39 | 40 | # defining event methods 41 | model.class_eval do 42 | # def assign() 43 | detect_enum_conflict! column, value_method_name 44 | 45 | # defining callbacks 46 | define_callbacks value_method_name 47 | before.each do |before_callback| 48 | model.set_callback value_method_name, :before, before_callback 49 | end 50 | after.each do |after_callback| 51 | model.set_callback value_method_name, :after, after_callback 52 | end 53 | 54 | define_method value_method_name do 55 | to, condition = transitions[send(column).to_sym] 56 | #TODO better error 57 | if to && (condition.nil? || instance_exec(&condition)) 58 | #TODO transaction? 59 | run_callbacks value_method_name do 60 | original_method = self.class.send(:_enum_methods_module).instance_method "#{prefix}#{to}#{suffix}!" 61 | original_method.bind(self).call 62 | end 63 | else 64 | false 65 | end 66 | end 67 | 68 | # def assign!() 69 | detect_enum_conflict! column, "#{value_method_name}!" 70 | define_method "#{value_method_name}!" do 71 | send(value_method_name) || raise('Invalid transition') 72 | end 73 | 74 | # def can_assign?() 75 | detect_enum_conflict! column, "can_#{value_method_name}?" 76 | define_method "can_#{value_method_name}?" do 77 | state = send(column).to_sym 78 | return false unless transitions.key? state 79 | _to, condition = transitions[state] 80 | condition.nil? || instance_exec(&condition) 81 | end 82 | 83 | # def assign_transition() 84 | detect_enum_conflict! column, "#{value_method_name}_transition" 85 | define_method "#{value_method_name}_transition" do 86 | transitions[send(column).to_sym].try! :first 87 | end 88 | end 89 | end 90 | 91 | def transition(transitions, options = {}) 92 | if options.blank? 93 | options[:if] = transitions.delete :if 94 | #TODO should err if if & unless were specified together? 95 | if (unless_condition = transitions.delete :unless) 96 | options[:if] = -> { !instance_exec(&unless_condition) } 97 | end 98 | end 99 | transitions.each_pair do |from, to| 100 | raise "Undefined state #{to}" unless @states.include? to 101 | Array(from).each do |f| 102 | raise "Undefined state #{f}" unless @states.include? f 103 | raise "Duplicate entry: Transition from #{f} to #{@transitions[f].first} has already been defined." if @transitions[f] 104 | @transitions[f] = [to, options[:if]] 105 | end 106 | end 107 | end 108 | 109 | def all 110 | @states 111 | end 112 | 113 | def before(&block) 114 | @before << block 115 | end 116 | 117 | def after(&block) 118 | @after << block 119 | end 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/stateful_enum/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'stateful_enum/active_record_extension' 4 | require 'stateful_enum/state_inspection' 5 | 6 | module StatefulEnum 7 | class Railtie < ::Rails::Railtie 8 | initializer 'stateful_enum' do 9 | ActiveSupport.on_load :active_record do 10 | ::ActiveRecord::Base.extend StatefulEnum::ActiveRecordEnumExtension 11 | ::ActiveRecord::Base.include StatefulEnum::StateInspection 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/stateful_enum/state_inspection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'stateful_enum/machine' 4 | 5 | module StatefulEnum 6 | module StateInspection 7 | extend ActiveSupport::Concern 8 | 9 | module ClassMethods 10 | def stateful_enum 11 | @_defined_stateful_enums 12 | end 13 | end 14 | 15 | def stateful_enum 16 | StateInspector.new(self.class.stateful_enum, self) 17 | end 18 | end 19 | 20 | class StateInspector 21 | def initialize(defined_stateful_enums, model_instance) 22 | @defined_stateful_enums, @model_instance = defined_stateful_enums, model_instance 23 | end 24 | 25 | # List of possible events from the current state 26 | def possible_events 27 | @defined_stateful_enums.flat_map {|se| se.events.select {|e| @model_instance.send("can_#{e.value_method_name}?") } } 28 | end 29 | 30 | # List of possible event names from the current state 31 | def possible_event_names 32 | possible_events.map(&:value_method_name) 33 | end 34 | 35 | # List of transitionable states from the current state 36 | def possible_states 37 | @defined_stateful_enums.flat_map do |stateful_enum| 38 | col = stateful_enum.instance_variable_get :@column 39 | pe = stateful_enum.events.select {|e| @model_instance.send("can_#{e.value_method_name}?") } 40 | pe.flat_map {|e| e.transitions[@model_instance.send(col).to_sym].first } 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/stateful_enum/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module StatefulEnum 4 | VERSION = '0.7.0' 5 | end 6 | -------------------------------------------------------------------------------- /stateful_enum.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'stateful_enum/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "stateful_enum" 8 | spec.version = StatefulEnum::VERSION 9 | spec.authors = ["Akira Matsuda"] 10 | spec.email = ["ronnie@dio.jp"] 11 | 12 | spec.summary = 'A state machine plugin on top of ActiveRecord::Enum' 13 | spec.description = 'A state machine plugin on top of ActiveRecord::Enum' 14 | spec.homepage = 'https://github.com/amatsuda/stateful_enum' 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_development_dependency 'bundler' 23 | spec.add_development_dependency 'rake' 24 | spec.add_development_dependency 'test-unit-rails' 25 | spec.add_development_dependency 'rails' 26 | spec.add_development_dependency 'sqlite3' 27 | spec.add_development_dependency 'ruby-graphviz' 28 | end 29 | -------------------------------------------------------------------------------- /test/dummy/.gitignore: -------------------------------------------------------------------------------- 1 | Bug.png 2 | Bug.puml 3 | /db/test.sqlite3 4 | -------------------------------------------------------------------------------- /test/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path('../config/application', __FILE__) 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /test/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amatsuda/stateful_enum/ffe42d80c1c479592dd6163c12957aec1f69c6e0/test/dummy/app/assets/config/manifest.js -------------------------------------------------------------------------------- /test/dummy/app/models/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amatsuda/stateful_enum/ffe42d80c1c479592dd6163c12957aec1f69c6e0/test/dummy/app/models/.keep -------------------------------------------------------------------------------- /test/dummy/app/models/bug.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Bug < ActiveRecord::Base 4 | belongs_to :assigned_to, class_name: 'User' 5 | 6 | block = ->(_) { 7 | event :assign do 8 | transition :unassigned => :assigned, if: -> { !!assigned_to } 9 | end 10 | # for testing the :unless option 11 | event :assign_with_unless do 12 | transition :unassigned => :assigned, unless: -> { !assigned_to } 13 | end 14 | 15 | event :resolve do 16 | before do 17 | self.resolved_at = Time.zone.now 18 | end 19 | 20 | transition [:unassigned, :assigned] => :resolved 21 | end 22 | 23 | event :close do 24 | after do 25 | Notifier.notify "Bug##{id} has been closed." 26 | end 27 | 28 | transition all - [:closed] => :closed 29 | end 30 | } 31 | 32 | if Rails::VERSION::MAJOR >= 7 33 | enum :status, {unassigned: 0, assigned: 1, resolved: 2, closed: 3}, &block 34 | else 35 | enum status: {unassigned: 0, assigned: 1, resolved: 2, closed: 3}, &block 36 | end 37 | 38 | class Notifier 39 | cattr_accessor(:messages) { [] } 40 | class << self 41 | def notify(msg) 42 | self.messages << msg 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class User < ActiveRecord::Base 4 | if Rails::VERSION::MAJOR >= 7 5 | enum :account_status, {active: 0, withdrawn: 1} do 6 | event :withdraw do 7 | transition :active => :withdrawn 8 | end 9 | end 10 | 11 | enum :player_status, {normal: 0, poison: 1, dead: 2} do 12 | event :die do 13 | transition [:normal, :poison] => :dead 14 | end 15 | end 16 | else 17 | enum account_status: {active: 0, withdrawn: 1} do 18 | event :withdraw do 19 | transition :active => :withdrawn 20 | end 21 | end 22 | 23 | enum player_status: {normal: 0, poison: 1, dead: 2} do 24 | event :die do 25 | transition [:normal, :poison] => :dead 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../../config/application', __FILE__) 3 | require_relative '../config/boot' 4 | require 'logger' 5 | require 'rails/commands' 6 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run Rails.application 5 | -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path('../boot', __FILE__) 4 | 5 | require 'active_record/railtie' 6 | require 'rails/test_unit/railtie' 7 | 8 | Bundler.require(*Rails.groups) 9 | require "stateful_enum" 10 | 11 | module Dummy 12 | class Application < Rails::Application 13 | config.load_defaults "#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}" if config.respond_to? :load_defaults 14 | # Settings in config/environments/* take precedence over those specified here. 15 | # Application configuration should go into files in config/initializers 16 | # -- all .rb files in that directory are automatically loaded. 17 | 18 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. 19 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. 20 | # config.time_zone = 'Central Time (US & Canada)' 21 | 22 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. 23 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] 24 | # config.i18n.default_locale = :de 25 | 26 | # Do not swallow errors in after_commit/after_rollback callbacks. 27 | config.active_record.raise_in_transactional_callbacks = true if Rails::VERSION::STRING =~ /^4.2/ 28 | config.active_record.sqlite3.represent_boolean_as_integer = true if config.active_record.sqlite3.respond_to?(:represent_boolean_as_integer) && (Rails::VERSION::MAJOR < 6) 29 | config.active_record.belongs_to_required_by_default = false if Rails::VERSION::MAJOR >= 5 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Set up gems listed in the Gemfile. 4 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../../Gemfile', __FILE__) 5 | ENV['BUNDLE_GEMFILE'] = File.expand_path(ENV['BUNDLE_GEMFILE'], Dir.pwd) 6 | 7 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 8 | $LOAD_PATH.unshift File.expand_path('../../../../lib', __FILE__) 9 | -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite version 3.x 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: 5 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: ':memory:' 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: ':memory:' 26 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require File.expand_path('../application', __FILE__) 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # The test environment is used exclusively to run your application's 7 | # test suite. You never need to work with it otherwise. Remember that 8 | # your test database is "scratch space" for the test suite and is wiped 9 | # and recreated between test runs. Don't rely on the data there! 10 | config.cache_classes = true 11 | 12 | # Do not eager load code on boot. This avoids loading your whole application 13 | # just for the purpose of running a single test. If you are using a tool that 14 | # preloads Rails for running tests, you may have to set it to true. 15 | config.eager_load = false 16 | 17 | # Configure static file server for tests with Cache-Control for performance. 18 | if Rails::VERSION::MAJOR >= 5 19 | config.public_file_server.enabled = true 20 | config.public_file_server.headers = { 'Cache-Control' => 'public, max-age=3600' } 21 | else 22 | config.serve_static_files = true 23 | config.static_cache_control = 'public, max-age=3600' 24 | end 25 | 26 | # Show full error reports and disable caching. 27 | config.consider_all_requests_local = true 28 | config.action_controller.perform_caching = false 29 | 30 | # Raise exceptions instead of rendering exception templates. 31 | config.action_dispatch.show_exceptions = false 32 | 33 | # Disable request forgery protection in test environment. 34 | config.action_controller.allow_forgery_protection = false 35 | 36 | # Tell Action Mailer not to deliver emails to the real world. 37 | # The :test delivery method accumulates sent emails in the 38 | # ActionMailer::Base.deliveries array. 39 | # config.action_mailer.delivery_method = :test 40 | 41 | # Randomize the order test cases are executed. 42 | config.active_support.test_order = :random 43 | 44 | # Print deprecation notices to the stderr. 45 | config.active_support.deprecation = :stderr 46 | 47 | # Raises error for missing translations 48 | # config.action_view.raise_on_missing_translations = true 49 | end 50 | -------------------------------------------------------------------------------- /test/dummy/config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rake secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | development: 14 | secret_key_base: 6fc5ad9e2470989abbe1440f5bff9a2f42c6b7e8e724c4f7e0a3677b8190b93e62ec9c7a875b42d39634e462a3590cf04f06c800a8180e2e37a0da2336c4960e 15 | 16 | test: 17 | secret_key_base: 3abb0512274f36f664575c5bc13e3fbab77b17168c0b0812ec6ea0a10f5b60e1efc4539a9ad40f29c5f556e0403b1b40a6328161492e3dd05fc03e0a674e7d97 18 | 19 | # Do not keep production secrets in the repository, 20 | # instead read values from the environment. 21 | production: 22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 23 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20160307200506_create_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateUsers < (Rails::VERSION::MAJOR >= 5 ? ActiveRecord::Migration[5.0] : ActiveRecord::Migration) 4 | def change 5 | create_table :users do |t| 6 | t.string :name 7 | t.integer :account_status 8 | t.integer :player_status 9 | 10 | t.timestamps null: false 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20160307203948_create_bugs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateBugs < (Rails::VERSION::MAJOR >= 5 ? ActiveRecord::Migration[5.0] : ActiveRecord::Migration) 4 | def change 5 | create_table :bugs do |t| 6 | t.string :title 7 | t.string :description 8 | t.integer :status, default: 0 9 | t.integer :assigned_to_id 10 | t.datetime :resolved_at 11 | 12 | t.timestamps null: false 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/dummy/doc/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amatsuda/stateful_enum/ffe42d80c1c479592dd6163c12957aec1f69c6e0/test/dummy/doc/.keep -------------------------------------------------------------------------------- /test/dummy/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amatsuda/stateful_enum/ffe42d80c1c479592dd6163c12957aec1f69c6e0/test/dummy/log/.keep -------------------------------------------------------------------------------- /test/dummy/tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amatsuda/stateful_enum/ffe42d80c1c479592dd6163c12957aec1f69c6e0/test/dummy/tmp/.keep -------------------------------------------------------------------------------- /test/graph_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class GraphTest < ActiveSupport::TestCase 6 | def test_graph 7 | FileUtils.rm_f Rails.root.join('Bug_status.png') 8 | FileUtils.rm_f Rails.root.join('.smdconfig') 9 | 10 | Dir.chdir Rails.root do 11 | `rails g stateful_enum:graph bug` 12 | end 13 | 14 | assert File.exist?(Rails.root.join('Bug_status.png')) 15 | end 16 | 17 | def test_graph_to_a_relative_dest_dir 18 | FileUtils.rm_f Rails.root.join('tmp', 'Bug_status.png') 19 | 20 | Dir.chdir Rails.root do 21 | `DEST_DIR=tmp rails g stateful_enum:graph bug` 22 | end 23 | 24 | assert File.exist?(Rails.root.join('tmp', 'Bug_status.png')) 25 | end 26 | 27 | def test_graph_to_an_absolute_dest_dir 28 | FileUtils.rm_f Rails.root.join('doc', 'Bug_status.png') 29 | 30 | Dir.chdir Rails.root do 31 | `DEST_DIR=#{Rails.root.join('doc')} rails g stateful_enum:graph bug` 32 | end 33 | 34 | assert File.exist?(Rails.root.join('doc', 'Bug_status.png')) 35 | end 36 | 37 | def test_graph_of_multiple_enums 38 | FileUtils.rm_f Rails.root.join('User_account_status.png') 39 | FileUtils.rm_f Rails.root.join('User_player_status.png') 40 | FileUtils.rm_f Rails.root.join('.smdconfig') 41 | 42 | Dir.chdir Rails.root do 43 | `rails g stateful_enum:graph user` 44 | end 45 | 46 | assert File.exist?(Rails.root.join('User_account_status.png')) 47 | assert File.exist?(Rails.root.join('User_player_status.png')) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/mechanic_machine_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class StatefulEnumTest < ActiveSupport::TestCase 6 | def test_transition 7 | bug = Bug.new 8 | assert_equal 'unassigned', bug.status 9 | bug.assigned_to = User.create!(name: 'user 1') 10 | bug.assign 11 | assert_equal 'assigned', bug.status 12 | end 13 | 14 | def test_transition! 15 | bug = Bug.new 16 | bug.assigned_to = User.create!(name: 'user 1') 17 | bug.assign! 18 | assert_equal 'assigned', bug.status 19 | end 20 | 21 | def test_transition_from_all 22 | bug = Bug.new 23 | bug.close 24 | assert_equal 'closed', bug.status 25 | 26 | bug = Bug.new 27 | bug.assigned_to = User.create!(name: 'user 1') 28 | bug.assign! 29 | bug.close 30 | assert_equal 'closed', bug.status 31 | 32 | bug = Bug.new 33 | bug.resolve! 34 | bug.close 35 | assert_equal 'closed', bug.status 36 | end 37 | 38 | def test_invalid_transition 39 | bug = Bug.new 40 | bug.resolve! 41 | assert_equal false, bug.assign 42 | assert_equal 'resolved', bug.status 43 | end 44 | 45 | def test_invalid_transition! 46 | bug = Bug.new 47 | bug.resolve! 48 | assert_raises do 49 | bug.assign! 50 | end 51 | assert_equal 'resolved', bug.status 52 | end 53 | 54 | def test_can_xxxx? 55 | bug = Bug.new 56 | assert bug.can_resolve? 57 | bug.resolve! 58 | refute bug.can_resolve? 59 | end 60 | 61 | def test_if_condition_can_xxxx? 62 | bug = Bug.new 63 | refute bug.can_assign? 64 | bug.assigned_to = User.new 65 | assert bug.can_assign? 66 | bug.resolve! 67 | refute bug.can_assign? 68 | end 69 | 70 | def test_unless_condition_can_xxxx? 71 | bug = Bug.new 72 | refute bug.can_assign_with_unless? 73 | bug.assigned_to = User.new 74 | assert bug.can_assign_with_unless? 75 | bug.resolve! 76 | refute bug.can_assign_with_unless? 77 | end 78 | 79 | def test_xxxx_transition 80 | bug = Bug.new 81 | assert_equal :assigned, bug.assign_transition 82 | end 83 | 84 | def test_non_verb_methods_are_undefined 85 | bug = Bug.new 86 | refute bug.respond_to? :assigned! 87 | end 88 | 89 | def test_before_transition_hook 90 | bug = Bug.new 91 | assert_nil bug.resolved_at 92 | bug.resolve 93 | refute_nil bug.resolved_at 94 | end 95 | 96 | def test_after_transition_hook 97 | bug = Bug.new 98 | assert_difference 'Bug::Notifier.messages.count' do 99 | bug.close 100 | end 101 | end 102 | 103 | def test_if_condition 104 | bug = Bug.new 105 | assert_raises do 106 | bug.assign! 107 | end 108 | bug.assigned_to = User.create!(name: 'user 1') 109 | assert_nothing_raised do 110 | bug.assign! 111 | end 112 | end 113 | 114 | def test_unless_condition 115 | bug = Bug.new 116 | assert_raises do 117 | bug.assign_with_unless! 118 | end 119 | bug.assigned_to = User.create!(name: 'user 1') 120 | assert_nothing_raised do 121 | bug.assign_with_unless! 122 | end 123 | end 124 | 125 | def test_enum_definition_with_array 126 | ActiveRecord::Migration.create_table(:array_enum_test) {|t| t.integer :col } 127 | tes = Class.new(ActiveRecord::Base) do 128 | self.table_name = 'array_enum_test' 129 | enum(:col, [:foo, :bar]) { event(:e) { transition(foo: :bar) } } 130 | end.new col: 'foo' 131 | tes.e 132 | assert_equal 'bar', tes.col 133 | end 134 | 135 | def test_duplicate_from_in_one_event 136 | assert_raises do 137 | Class.new(ActiveRecord::Base) do 138 | enum :status, {unassigned: 0, assigned: 1, resolved: 2, closed: 3} do 139 | event :assign do 140 | transition :unassigned => :assigned 141 | transition :unassigned => :resolved 142 | end 143 | end 144 | end 145 | end 146 | end 147 | 148 | def test_not_duplicate_from_in_one_event 149 | assert_nothing_raised do 150 | Class.new(ActiveRecord::Base) do 151 | enum :status, {unassigned: 0, assigned: 1, resolved: 2, closed: 3} do 152 | event :toggle_assignment do 153 | transition :unassigned => :assigned 154 | transition :assigned => :unassigned 155 | end 156 | end 157 | end 158 | end 159 | end 160 | 161 | def test_error_raised_when_states_are_duplicated_with_another_enum_states 162 | assert_raises ArgumentError do 163 | Class.new(ActiveRecord::Base) do 164 | enum :status, {unassigned: 0, assigned: 1, resolved: 2, closed: 3} do 165 | event :toggle_assignment do 166 | transition :unassigned => :assigned 167 | transition :assigned => :unassigned 168 | end 169 | end 170 | 171 | enum :another_status, {unassigned: 0, assigned: 1, resolved: 2, closed: 3} do 172 | event :toggle_assignment do 173 | transition :unassigned => :assigned 174 | transition :assigned => :unassigned 175 | end 176 | end 177 | end 178 | end 179 | end 180 | 181 | def test_error_raised_when_states_are_duplicated_with_normal_enum_entry 182 | assert_raises ArgumentError do 183 | Class.new(ActiveRecord::Base) do 184 | enum :status, {unassigned: 0, assigned: 1, resolved: 2, closed: 3} do 185 | event :toggle_assignment do 186 | transition :unassigned => :assigned 187 | transition :assigned => :unassigned 188 | end 189 | end 190 | 191 | enum :another_status, {unassigned: 0, assigned: 1, resolved: 2, closed: 3} 192 | end 193 | end 194 | end 195 | 196 | if Rails::VERSION::MAJOR >= 5 197 | def test_enum_definition_with_prefix 198 | ActiveRecord::Migration.create_table(:enum_prefix_test) do |t| 199 | t.integer :status 200 | t.integer :comments_status 201 | end 202 | tes = Class.new(ActiveRecord::Base) do 203 | self.table_name = 'enum_prefix_test' 204 | enum(:status, [:active, :archived], prefix: true) { event(:archive) { transition(active: :archived) } } 205 | enum(:comments_status, [:active, :inactive], prefix: :comments) { event(:close) { transition(active: :inactive) } } 206 | end.new status: :active, comments_status: :active 207 | 208 | assert_equal :archived, tes.status_archive_transition 209 | assert tes.can_status_archive? 210 | tes.status_archive 211 | assert_equal 'archived', tes.status 212 | refute tes.can_status_archive? 213 | 214 | assert_equal :inactive, tes.comments_close_transition 215 | assert tes.can_comments_close? 216 | tes.comments_close! 217 | assert_equal 'inactive', tes.comments_status 218 | refute tes.can_comments_close? 219 | end 220 | 221 | def test_enum_definition_with_suffix 222 | ActiveRecord::Migration.create_table(:enum_suffix_test) do |t| 223 | t.integer :status 224 | t.integer :comments_status 225 | end 226 | tes = Class.new(ActiveRecord::Base) do 227 | self.table_name = 'enum_suffix_test' 228 | enum(:status, [:active, :archived], suffix: true) { event(:archive) { transition(active: :archived) } } 229 | enum(:comments_status, [:active, :inactive], suffix: :comments) { event(:close) { transition(active: :inactive) } } 230 | end.new status: :active, comments_status: :active 231 | 232 | assert_equal :archived, tes.archive_status_transition 233 | assert tes.can_archive_status? 234 | tes.archive_status 235 | assert_equal 'archived', tes.status 236 | refute tes.can_archive_status? 237 | 238 | assert_equal :inactive, tes.close_comments_transition 239 | assert tes.can_close_comments? 240 | tes.close_comments! 241 | assert_equal 'inactive', tes.comments_status 242 | refute tes.can_close_comments? 243 | end 244 | 245 | def test_enum_definition_with_prefix_and_suffix 246 | ActiveRecord::Migration.create_table(:enum_prefix_and_suffix_test) { |t| t.integer :status } 247 | tes = Class.new(ActiveRecord::Base) do 248 | self.table_name = 'enum_prefix_and_suffix_test' 249 | enum(:status, [:active, :archived], prefix: :prefix, suffix: :suffix) { event(:archive) { transition(active: :archived) } } 250 | end.new status: :active 251 | tes.prefix_archive_suffix 252 | assert_equal 'archived', tes.status 253 | end 254 | end 255 | end 256 | -------------------------------------------------------------------------------- /test/plantuml_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class PlantumlTest < ActiveSupport::TestCase 6 | def test_plantuml 7 | FileUtils.rm_f Rails.root.join('Bug.puml') 8 | 9 | Dir.chdir Rails.root do 10 | `rails g stateful_enum:plantuml bug` 11 | end 12 | 13 | assert File.exist?(Rails.root.join('Bug.puml')) 14 | end 15 | 16 | def test_plantuml_to_a_relative_dest_dir 17 | FileUtils.rm_f Rails.root.join('tmp', 'Bug.puml') 18 | 19 | Dir.chdir Rails.root do 20 | `DEST_DIR=tmp rails g stateful_enum:plantuml bug` 21 | end 22 | 23 | assert File.exist?(Rails.root.join('tmp', 'Bug.puml')) 24 | end 25 | 26 | def test_plantuml_to_an_absolute_dest_dir 27 | FileUtils.rm_f Rails.root.join('doc', 'Bug.puml') 28 | 29 | Dir.chdir Rails.root do 30 | `DEST_DIR=#{Rails.root.join('doc')} rails g stateful_enum:plantuml bug` 31 | end 32 | 33 | assert File.exist?(Rails.root.join('doc', 'Bug.puml')) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/state_inspection_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class StateInspectionTest < ActiveSupport::TestCase 6 | def test_possible_events 7 | unassigned_bug = Bug.new status: :assigned, assigned_to: User.create!(name: 'matz') 8 | assert_equal %i[resolve close], unassigned_bug.stateful_enum.possible_events.map(&:value_method_name) 9 | end 10 | 11 | def test_possible_event_names 12 | unassigned_bug = Bug.new status: :assigned, assigned_to: User.create!(name: 'matz') 13 | assert_equal %i[resolve close], unassigned_bug.stateful_enum.possible_event_names 14 | end 15 | 16 | def test_possible_states 17 | unassigned_bug = Bug.new status: :assigned, assigned_to: User.create!(name: 'matz') 18 | assert_equal %i[resolved closed], unassigned_bug.stateful_enum.possible_states 19 | end 20 | 21 | def test_possible_events_on_models_that_have_multiple_enums 22 | user = User.new account_status: :active, player_status: :normal 23 | assert_equal %i[withdraw die], user.stateful_enum.possible_events.map(&:value_method_name) 24 | end 25 | 26 | def test_possible_event_names_on_models_that_have_multiple_enums 27 | user = User.new account_status: :active, player_status: :normal 28 | assert_equal %i[withdraw die], user.stateful_enum.possible_event_names 29 | end 30 | 31 | def test_possible_states_on_models_that_have_multiple_enums 32 | user = User.new account_status: :active, player_status: :normal 33 | assert_equal %i[withdrawn dead], user.stateful_enum.possible_states 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Configure Rails Environment 4 | ENV["RAILS_ENV"] = "test" 5 | 6 | # require logger before requiring rails, or Rails 6 fails to boot 7 | require 'logger' 8 | # Then load Rails 9 | require 'rails' 10 | require 'active_record' 11 | 12 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 13 | require 'stateful_enum' 14 | require 'stateful_enum/railtie' 15 | 16 | if ActiveRecord::VERSION::MAJOR < 7 17 | Class.new(::Rails::Railtie) do 18 | module EnumSyntaxConverter 19 | def enum(name = nil, values = nil, **options, &block) 20 | return super options, &block if name == nil 21 | 22 | new_options = {} 23 | if (prefix = options.delete :prefix) 24 | new_options[:_prefix] = prefix 25 | end 26 | if (suffix = options.delete :suffix) 27 | new_options[:_suffix] = suffix 28 | end 29 | super new_options.merge(name => (values || options)), &block 30 | end 31 | end 32 | 33 | initializer :convert_old_enum_syntax, after: 'stateful_enum' do 34 | ActiveSupport.on_load :active_record do 35 | ::ActiveRecord::Base.extend EnumSyntaxConverter 36 | end 37 | end 38 | end 39 | end 40 | 41 | require File.expand_path("../../test/dummy/config/environment.rb", __FILE__) 42 | 43 | ActiveRecord::Migration.verbose = false 44 | verbosity, ENV['VERBOSE'] = ENV['VERBOSE'], 'false' 45 | if ActiveRecord::Migrator.respond_to? :migrate 46 | ActiveRecord::Migrator.migrate(ActiveRecord::Migrator.migrations_paths = [File.expand_path("../../test/dummy/db/migrate", __FILE__)]) 47 | else 48 | ActiveRecord::Migrator.migrations_paths << File.expand_path('../dummy/db/migrate', __FILE__) 49 | ActiveRecord::Tasks::DatabaseTasks.drop_current 'test' 50 | ActiveRecord::Tasks::DatabaseTasks.create_current 'test' 51 | ActiveRecord::Tasks::DatabaseTasks.migrate 52 | end 53 | ENV['VERBOSE'] = verbosity 54 | 55 | require 'test/unit/rails/test_help' 56 | 57 | 58 | # Load fixtures from the engine 59 | if ActiveSupport::TestCase.respond_to?(:fixture_path=) 60 | ActiveSupport::TestCase.fixture_path = File.expand_path("../fixtures", __FILE__) 61 | ActionDispatch::IntegrationTest.fixture_path = ActiveSupport::TestCase.fixture_path 62 | ActiveSupport::TestCase.fixtures :all 63 | end 64 | --------------------------------------------------------------------------------