├── .github └── workflows │ └── main.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── docs └── MIGRATING_FROM_RAILS_STRING_ENUM.ru.md ├── enum_machine.gemspec ├── lib ├── enum_machine.rb └── enum_machine │ ├── attribute_persistence_methods.rb │ ├── build_enum_class.rb │ ├── build_value_class.rb │ ├── driver_active_record.rb │ ├── driver_simple_class.rb │ ├── machine.rb │ └── version.rb ├── spec ├── enum_machine │ ├── active_record_enum_spec.rb │ ├── active_record_machine_spec.rb │ ├── driver_simple_class_spec.rb │ └── machine_spec.rb ├── locales │ └── ru.yml ├── spec_helper.rb └── support │ ├── active_record.rb │ └── test_model.rb └── test └── performance.rb /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | ruby: 17 | - 3.1 18 | - 3.2 19 | - 3.3 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Set up Ruby 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | bundler-cache: true 28 | - name: Run the default task 29 | run: bundle exec rake 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /.idea/ 10 | # rspec failure tracking 11 | .rspec_status 12 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-gp: 3 | - ./config/default.yml 4 | 5 | AllCops: 6 | TargetRubyVersion: 3.0 7 | 8 | Rails/ApplicationRecord: 9 | Enabled: false 10 | 11 | Rails/Output: 12 | Enabled: false 13 | 14 | Metrics/ParameterLists: 15 | CountKeywordArgs: false 16 | 17 | Style/Next: 18 | Enabled: false 19 | 20 | Style/ClassVars: 21 | Enabled: false 22 | 23 | Gp/UnsafeYamlMarshal: 24 | Enabled: true 25 | Exclude: 26 | - spec/**/*.rb 27 | 28 | Gp/OptArgParameters: 29 | Enabled: false 30 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | gem "dry-types" 8 | gem "priscilla", github: "corp-gp/priscilla" 9 | gem "pry", "~> 0.12" 10 | gem "rake", "~> 13.0" 11 | gem "rspec", "~> 3.9" 12 | gem "rubocop-gp", github: "corp-gp/rubocop-gp" 13 | gem "sqlite3", "~> 1.4" 14 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: https://github.com/corp-gp/priscilla.git 3 | revision: 46ba7460df8209dfe80e344beed07dfe56648aaf 4 | specs: 5 | priscilla (1.0.3) 6 | colorize (~> 0.7) 7 | rumoji (~> 0.3) 8 | 9 | GIT 10 | remote: https://github.com/corp-gp/rubocop-gp.git 11 | revision: 867f7e1351c3730897cacdc20ce2d727604de245 12 | specs: 13 | rubocop-gp (0.0.4) 14 | rubocop 15 | rubocop-capybara 16 | rubocop-factory_bot 17 | rubocop-performance 18 | rubocop-rails 19 | rubocop-rspec 20 | rubocop-rspec_rails 21 | 22 | PATH 23 | remote: . 24 | specs: 25 | enum_machine (2.1.0) 26 | activemodel 27 | activerecord 28 | activesupport 29 | 30 | GEM 31 | remote: https://rubygems.org/ 32 | specs: 33 | activemodel (7.2.1) 34 | activesupport (= 7.2.1) 35 | activerecord (7.2.1) 36 | activemodel (= 7.2.1) 37 | activesupport (= 7.2.1) 38 | timeout (>= 0.4.0) 39 | activesupport (7.2.1) 40 | base64 41 | bigdecimal 42 | concurrent-ruby (~> 1.0, >= 1.3.1) 43 | connection_pool (>= 2.2.5) 44 | drb 45 | i18n (>= 1.6, < 2) 46 | logger (>= 1.4.2) 47 | minitest (>= 5.1) 48 | securerandom (>= 0.3) 49 | tzinfo (~> 2.0, >= 2.0.5) 50 | ast (2.4.2) 51 | base64 (0.2.0) 52 | bigdecimal (3.1.8) 53 | coderay (1.1.3) 54 | colorize (0.8.1) 55 | concurrent-ruby (1.3.4) 56 | connection_pool (2.4.1) 57 | diff-lcs (1.5.1) 58 | drb (2.2.1) 59 | dry-core (1.0.1) 60 | concurrent-ruby (~> 1.0) 61 | zeitwerk (~> 2.6) 62 | dry-inflector (1.1.0) 63 | dry-logic (1.5.0) 64 | concurrent-ruby (~> 1.0) 65 | dry-core (~> 1.0, < 2) 66 | zeitwerk (~> 2.6) 67 | dry-types (1.7.2) 68 | bigdecimal (~> 3.0) 69 | concurrent-ruby (~> 1.0) 70 | dry-core (~> 1.0) 71 | dry-inflector (~> 1.0) 72 | dry-logic (~> 1.4) 73 | zeitwerk (~> 2.6) 74 | i18n (1.14.6) 75 | concurrent-ruby (~> 1.0) 76 | json (2.9.0) 77 | language_server-protocol (3.17.0.3) 78 | logger (1.6.1) 79 | method_source (1.1.0) 80 | minitest (5.25.2) 81 | parallel (1.26.3) 82 | parser (3.3.6.0) 83 | ast (~> 2.4.1) 84 | racc 85 | pry (0.14.2) 86 | coderay (~> 1.1) 87 | method_source (~> 1.0) 88 | racc (1.8.1) 89 | rack (3.1.8) 90 | rainbow (3.1.1) 91 | rake (13.2.1) 92 | regexp_parser (2.9.3) 93 | rspec (3.13.0) 94 | rspec-core (~> 3.13.0) 95 | rspec-expectations (~> 3.13.0) 96 | rspec-mocks (~> 3.13.0) 97 | rspec-core (3.13.1) 98 | rspec-support (~> 3.13.0) 99 | rspec-expectations (3.13.3) 100 | diff-lcs (>= 1.2.0, < 2.0) 101 | rspec-support (~> 3.13.0) 102 | rspec-mocks (3.13.1) 103 | diff-lcs (>= 1.2.0, < 2.0) 104 | rspec-support (~> 3.13.0) 105 | rspec-support (3.13.1) 106 | rubocop (1.69.2) 107 | json (~> 2.3) 108 | language_server-protocol (>= 3.17.0) 109 | parallel (~> 1.10) 110 | parser (>= 3.3.0.2) 111 | rainbow (>= 2.2.2, < 4.0) 112 | regexp_parser (>= 2.9.3, < 3.0) 113 | rubocop-ast (>= 1.36.2, < 2.0) 114 | ruby-progressbar (~> 1.7) 115 | unicode-display_width (>= 2.4.0, < 4.0) 116 | rubocop-ast (1.37.0) 117 | parser (>= 3.3.1.0) 118 | rubocop-capybara (2.21.0) 119 | rubocop (~> 1.41) 120 | rubocop-factory_bot (2.26.1) 121 | rubocop (~> 1.61) 122 | rubocop-performance (1.23.0) 123 | rubocop (>= 1.48.1, < 2.0) 124 | rubocop-ast (>= 1.31.1, < 2.0) 125 | rubocop-rails (2.27.0) 126 | activesupport (>= 4.2.0) 127 | rack (>= 1.1) 128 | rubocop (>= 1.52.0, < 2.0) 129 | rubocop-ast (>= 1.31.1, < 2.0) 130 | rubocop-rspec (3.2.0) 131 | rubocop (~> 1.61) 132 | rubocop-rspec_rails (2.30.0) 133 | rubocop (~> 1.61) 134 | rubocop-rspec (~> 3, >= 3.0.1) 135 | ruby-progressbar (1.13.0) 136 | rumoji (0.5.0) 137 | securerandom (0.3.2) 138 | sqlite3 (1.7.3-arm64-darwin) 139 | sqlite3 (1.7.3-x86_64-darwin) 140 | sqlite3 (1.7.3-x86_64-linux) 141 | timeout (0.4.2) 142 | tzinfo (2.0.6) 143 | concurrent-ruby (~> 1.0) 144 | unicode-display_width (3.1.2) 145 | unicode-emoji (~> 4.0, >= 4.0.4) 146 | unicode-emoji (4.0.4) 147 | zeitwerk (2.6.18) 148 | 149 | PLATFORMS 150 | arm64-darwin-21 151 | arm64-darwin-23 152 | x86_64-darwin-21 153 | x86_64-linux 154 | 155 | DEPENDENCIES 156 | dry-types 157 | enum_machine! 158 | priscilla! 159 | pry (~> 0.12) 160 | rake (~> 13.0) 161 | rspec (~> 3.9) 162 | rubocop-gp! 163 | sqlite3 (~> 1.4) 164 | 165 | BUNDLED WITH 166 | 2.3.15 167 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Ermolaev Andrey 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 | # Enum Machine 2 | 3 | `Enum Machine` is a library for defining enums and setting state machines for attributes in ActiveRecord models and plain Ruby classes. 4 | 5 | You can visualize transitions map with [enum_machine-contrib](https://github.com/corp-gp/enum_machine-contrib) 6 | 7 | ## Why is `enum_machine` better then [state_machines](https://github.com/state-machines/state_machines) / [aasm](https://github.com/aasm/aasm)? 8 | 9 | - faster [5x](#Benchmarks) 10 | - code lines: `enum_machine` - 348, `AASM` - 2139 11 | - namespaced (via attr) by default: `order.state.to_collected` 12 | - [aliases](Aliases) 13 | - guarantees of existing transitions 14 | - simple run transitions with callbacks `order.update(state: "collected")` or `order.state.to_collected` 15 | - `aasm` / `state_machines` **event driven**, `enum_machine` **state driven** 16 | 17 | ```ruby 18 | # aasm 19 | event :complete do # complete/collected - dichotomy between states and events 20 | before { puts "event complete" } 21 | transitions from: :collecting, to: :collected 22 | end 23 | 24 | # pay/archived difficult to remember the relationship between statuses and events 25 | # try to explain this to the logic of business stakeholders 26 | event :pay do 27 | transitions from: [:created, :collected], to: :archived 28 | end 29 | 30 | order = Order.create(state: "collecting") 31 | order.update(state: "archived") # not check transitions, invalid logic 32 | order.update(state: "collected") # not run callbacks 33 | order.complete # need use event for transition, but your object in UI and DB have only states 34 | 35 | # enum_machine 36 | transitions( # simple readable transitions map 37 | "collecting" => "collected", 38 | "collected" => "archived", 39 | ) 40 | before_transition("collecting" => "collected") { puts "event complete" } 41 | 42 | order = Order.create(state: "collecting") 43 | order.update(state: "archived") # checked transitions, raise exception 44 | order.update(state: "collected") # run callbacks 45 | ``` 46 | 47 | ## Installation 48 | 49 | Add to your Gemfile: 50 | 51 | ```ruby 52 | gem "enum_machine" 53 | ``` 54 | 55 | ## Usage 56 | 57 | ### Enums 58 | 59 | ```ruby 60 | # With ActiveRecord 61 | class Product < ActiveRecord::Base 62 | enum_machine :color, %w[red green] 63 | end 64 | 65 | # Or with plain class 66 | class Product 67 | # attributes must be defined before including the EnumMachine module 68 | attr_accessor :color 69 | 70 | include EnumMachine[color: { enum: %w[red green] }] 71 | # or reuse from model 72 | Product::COLOR.enum_decorator 73 | end 74 | 75 | Product::COLOR.values # => ["red", "green"] 76 | Product::COLOR::RED # => "red" 77 | Product::COLOR::RED__GREEN # => ["red", "green"] 78 | 79 | Product::COLOR["red"].red? # => true 80 | Product::COLOR["red"].human_name # => "Красный" 81 | 82 | product = Product.new 83 | product.color # => nil 84 | product.color = "red" 85 | product.color.red? # => true 86 | product.color.human_name # => "Красный" 87 | ``` 88 | 89 | ### Aliases 90 | 91 | ```ruby 92 | class Product < ActiveRecord::Base 93 | enum_machine :state, %w[created approved published] do 94 | aliases( 95 | "forming" => %w[created approved], 96 | ) 97 | end 98 | end 99 | 100 | Product::STATE.forming # => %w[created approved] 101 | 102 | product = Product.new(state: "created") 103 | product.state.forming? # => true 104 | ``` 105 | 106 | ### Value decorator 107 | 108 | You can extend value object with decorator 109 | 110 | ```ruby 111 | # Value classes nested from base class 112 | module ColorDecorator 113 | def hex 114 | case self 115 | when Product::COLOR::RED then "#ff0000" 116 | when Product::COLOR::GREEN then "#00ff00" 117 | end 118 | end 119 | end 120 | 121 | class Product 122 | attr_accessor :color 123 | 124 | include EnumMachine[color: { 125 | enum: %w[red green], 126 | value_decorator: ColorDecorator 127 | }] 128 | end 129 | 130 | product = Product.new 131 | product.color = "red" 132 | product.color.hex # => "#ff0000" 133 | ``` 134 | 135 | ### Transitions 136 | 137 | ```ruby 138 | class Product < ActiveRecord::Base 139 | enum_machine :color, %w[red green blue] 140 | enum_machine :state, %w[created approved cancelled activated] do 141 | # transitions(any => any) - allow all transitions 142 | transitions( 143 | nil => "created", 144 | "created" => [nil, "approved"], 145 | %w[cancelled approved] => "activated", 146 | "activated" => %w[created cancelled], 147 | ) 148 | 149 | # Will be executed in `before_save` callback 150 | before_transition "created" => "approved" do |product| 151 | product.color = "green" if product.color.red? 152 | end 153 | 154 | # Will be executed in `after_save` callback 155 | after_transition %w[created] => %w[approved] do |product| 156 | product.color = "red" 157 | end 158 | 159 | after_transition any => "cancelled" do |product| 160 | product.cancelled_at = Time.zone.now 161 | end 162 | end 163 | end 164 | 165 | product = Product.create(state: "created") 166 | product.state.possible_transitions # => [nil, "approved"] 167 | product.state.can_activated? # => false 168 | product.state.to_activated! # => EnumMachine::Error: transition "created" => "activated" not defined in enum_machine 169 | product.state.to_approved! # => true; equal to `product.update!(state: "approve")` 170 | ``` 171 | 172 | #### Skip transitions 173 | ```ruby 174 | product = Product.new(state: "created") 175 | product.skip_state_transitions { product.save } 176 | ``` 177 | 178 | method generated as `skip_#{enum_name}_transitions` 179 | 180 | #### Skip in factories 181 | ```ruby 182 | FactoryBot.define do 183 | factory :product do 184 | name { Faker::Commerce.product_name } 185 | to_create { |product| product.skip_state_transitions { product.save! } } 186 | end 187 | end 188 | ``` 189 | 190 | ### I18n 191 | 192 | **ru.yml** 193 | ```yml 194 | ru: 195 | enums: 196 | product: 197 | color: 198 | red: Красный 199 | green: Зеленый 200 | ``` 201 | 202 | ```ruby 203 | # ActiveRecord 204 | class Product < ActiveRecord::Base 205 | enum_machine :color, %w[red green] 206 | end 207 | 208 | # Plain class 209 | class Product 210 | # attributes must be defined before including the EnumMachine module 211 | attr_accessor :color 212 | # `i18n_scope` option must be explicitly set to use methods below 213 | include EnumMachine[color: { enum: %w[red green], i18n_scope: "product" }] 214 | end 215 | 216 | Product::COLOR.human_name_for("red") # => "Красный" 217 | Product::COLOR.values_for_form # => [["Красный", "red"], ["Зеленый", "green"]] 218 | 219 | product = Product.new(color: "red") 220 | product.color.human_name # => "Красный" 221 | ``` 222 | 223 | I18n scope can be changed with `i18n_scope` option: 224 | 225 | ```ruby 226 | # For AciveRecord 227 | class Product < ActiveRecord::Base 228 | enum_machine :color, %w[red green], i18n_scope: "users.product" 229 | end 230 | 231 | # For plain class 232 | class Product 233 | include EnumMachine[color: { enum: %w[red green], i18n_scope: "users.product" }] 234 | end 235 | ``` 236 | 237 | ## Benchmarks 238 | [test/performance.rb](../master/test/performance.rb) 239 | 240 | | Gem | Method | | 241 | | :--- | ---: | :--- | 242 | | enum_machine | order.state.forming? | 894921.3 i/s | 243 | | state_machines | order.forming? | 189901.8 i/s - 4.71x slower | 244 | | aasm | order.forming? | 127073.7 i/s - 7.04x slower | 245 | | | | | 246 | | enum_machine | order.state.can_closed? | 473150.4 i/s | 247 | | aasm | order.may_to_closed? | 24459.1 i/s - 19.34x slower | 248 | | state_machines | order.can_to_closed? | 12136.8 i/s - 38.98x slower | 249 | | | | | 250 | | enum_machine | Order::STATE.values | 6353820.4 i/s | 251 | | aasm | Order.aasm(:state).states.map(&:name) | 131390.5 i/s - 48.36x slower | 252 | | state_machines | Order.state_machines[:state].states.map(&:value) | 108449.7 i/s - 58.59x slower | 253 | | | | | 254 | | enum_machine | order.state = "forming" and order.valid? | 13873.4 i/s | 255 | | state_machines | order.state_event = "to_forming" and order.valid? | 6173.6 i/s - 2.25x slower | 256 | | aasm | order.to_forming | 3095.9 i/s - 4.48x slower | 257 | 258 | 259 | ## License 260 | 261 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 262 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | require "rubocop/rake_task" 9 | 10 | RuboCop::RakeTask.new 11 | 12 | task default: %i[spec rubocop] 13 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "enum_machine" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/MIGRATING_FROM_RAILS_STRING_ENUM.ru.md: -------------------------------------------------------------------------------- 1 | # Переход с `rails_string_enum` на `enum_machine` 2 | 3 | ### 1. Объявление в классе 4 | ```ruby 5 | class Product 6 | string_enum :color, %w[red green] # Было 7 | enum_machine :color, %w[red green] # Стало 8 | end 9 | ``` 10 | 11 | ### 2. Константы 12 | 13 | Все константы находятся в Product::COLOR 14 | 15 | * `Product::RED` => `Product::COLOR::RED` 16 | * `Product::RED__GREEN` => `Product::COLOR::RED__GREEN` 17 | * `Product::COLORS` => `Product::COLOR.values` 18 | 19 | ### 3. Методы инстанса 20 | 21 | * `@product.red?` => `@product.color.red?` 22 | 23 | ### 4. I18n хелперы 24 | 25 | * `Product.colors_i18n` => `Product::COLOR.values_for_form` 26 | * `Product.color_i18n_for('red')` => `Product::COLOR.human_name_for('red')` 27 | * `@product.color_i18n` => `@product.color.human_name` 28 | 29 | ### 5. scopes 30 | 31 | В `enum_machine` нет опции `scopes`, нужно задать необходимые вручную 32 | 33 | ```ruby 34 | class Product 35 | # Было 36 | string_enum :color, %w[red green], scopes: true 37 | 38 | # Стало 39 | enum_machine :color, %w[red green] 40 | scope :only_red, -> { where(color: COLOR::RED) } 41 | end 42 | ``` 43 | 44 | ### 6. Интеграция с `simple_form` 45 | 46 | `enum_machine` не предоставляет интеграцию с `simple_form`, тип инпута и коллекцию нужно передавать самостоятельно 47 | 48 | ```ruby 49 | # Было 50 | f.input :color 51 | 52 | # Стало 53 | f.input :color, as: :select, collection: Product::COLOR.values_for_form 54 | ``` 55 | -------------------------------------------------------------------------------- /enum_machine.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/enum_machine/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "enum_machine" 7 | spec.version = EnumMachine::VERSION 8 | spec.authors = ["Ermolaev Andrey"] 9 | spec.email = ["andruhafirst@yandex.ru"] 10 | 11 | spec.summary = "fast and siple usage state machine in your app" 12 | spec.description = "Enum machine is a library for defining enums and setting state machines for attributes in ActiveRecord models and plain Ruby classes." 13 | spec.homepage = "https://github.com/corp-gp/enum_machine" 14 | spec.license = "MIT" 15 | spec.required_ruby_version = ">= 3.0.0" 16 | 17 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 18 | 19 | spec.metadata["homepage_uri"] = spec.homepage 20 | spec.metadata["source_code_uri"] = "https://github.com/corp-gp/enum_machine" 21 | 22 | # Specify which files should be added to the gem when it is released. 23 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 24 | spec.files = 25 | Dir.chdir(File.expand_path(__dir__)) do 26 | `git ls-files -z`.split("\x0").reject do |f| 27 | (f == __FILE__) || f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) 28 | end 29 | end 30 | spec.bindir = "exe" 31 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 32 | spec.require_paths = ["lib"] 33 | 34 | spec.add_dependency "activemodel" 35 | spec.add_dependency "activerecord" 36 | spec.add_dependency "activesupport" 37 | 38 | # For more information and examples about making a new gem, checkout our 39 | # guide at: https://bundler.io/guides/creating_gem.html 40 | spec.metadata["rubygems_mfa_required"] = "true" 41 | end 42 | -------------------------------------------------------------------------------- /lib/enum_machine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "enum_machine/version" 4 | require_relative "enum_machine/driver_simple_class" 5 | require_relative "enum_machine/build_value_class" 6 | require_relative "enum_machine/attribute_persistence_methods" 7 | require_relative "enum_machine/build_enum_class" 8 | require_relative "enum_machine/machine" 9 | require "active_support" 10 | 11 | module EnumMachine 12 | class Error < StandardError; end 13 | 14 | class InvalidTransition < Error 15 | attr_reader :from, :to, :enum_const 16 | 17 | def initialize(machine, from, to) 18 | @from = from 19 | @to = to 20 | @enum_const = 21 | begin 22 | machine.base_klass.const_get(machine.enum_const_name) 23 | rescue NameError # rubocop:disable Lint/SuppressedException 24 | end 25 | super("Transition #{from.inspect} => #{to.inspect} not defined in enum_machine :#{machine.attr_name}") 26 | end 27 | end 28 | 29 | def self.[](args) 30 | DriverSimpleClass.call(args) 31 | end 32 | end 33 | 34 | ActiveSupport.on_load(:active_record) do 35 | require_relative "enum_machine/driver_active_record" 36 | ActiveSupport.on_load(:active_record) { extend EnumMachine::DriverActiveRecord } 37 | end 38 | -------------------------------------------------------------------------------- /lib/enum_machine/attribute_persistence_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EnumMachine 4 | module AttributePersistenceMethods 5 | def self.[](attr, enum_values) 6 | Module.new do 7 | define_singleton_method(:extended) do |klass| 8 | klass.attr_accessor :parent 9 | 10 | enum_values.each do |enum_value| 11 | enum_name = enum_value.underscore 12 | 13 | klass.class_eval <<-RUBY, __FILE__, __LINE__ + 1 14 | # def to_created! 15 | # parent.update!('state' => 'created') 16 | # end 17 | 18 | def to_#{enum_name}! 19 | parent.update!('#{attr}' => '#{enum_value}') 20 | end 21 | RUBY 22 | end 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/enum_machine/build_enum_class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EnumMachine 4 | module BuildEnumClass 5 | def self.call(enum_values:, i18n_scope:, value_class:, machine: nil) 6 | aliases = machine&.instance_variable_get(:@aliases) || {} 7 | 8 | Class.new do 9 | const_set(:VALUE_CLASS, value_class) 10 | 11 | define_singleton_method(:machine) { machine } if machine 12 | define_singleton_method(:values) { enum_values.map { value_class.new(_1).freeze } } 13 | 14 | value_attribute_mapping = values.to_h { [_1.to_s, _1] } 15 | define_singleton_method(:value_attribute_mapping) { value_attribute_mapping } 16 | define_singleton_method(:[]) do |enum_value| 17 | key = enum_value.to_s 18 | # Check for key existence because `[]` will call `default_proc`, and we don’t want that 19 | value_attribute_mapping[key] if value_attribute_mapping.key?(key) 20 | end 21 | 22 | if i18n_scope 23 | def self.values_for_form(specific_values = nil) 24 | (specific_values || values).map { |v| [human_name_for(v), v] } 25 | end 26 | 27 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 28 | # def self.human_name_for(name) 29 | # ::I18n.t(name, scope: "enums.test_model", default: name) 30 | # end 31 | 32 | def self.human_name_for(name) 33 | ::I18n.t(name, scope: "enums.#{i18n_scope}", default: name) 34 | end 35 | RUBY 36 | end 37 | 38 | enum_values.each do |enum_value| 39 | const_set enum_value.underscore.upcase, enum_value.to_s.freeze 40 | end 41 | 42 | aliases.each_key do |key| 43 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 44 | # def self.forming 45 | # @alias_forming ||= machine.fetch_alias('forming').freeze 46 | # end 47 | 48 | def self.#{key} 49 | @alias_#{key} ||= machine.fetch_alias('#{key}').freeze 50 | end 51 | RUBY 52 | end 53 | 54 | private_class_method def self.const_missing(name) 55 | name_s = name.to_s 56 | return super unless name_s.include?("__") 57 | 58 | const_set name_s, name_s.split("__").map { |i| const_get(i) }.freeze 59 | end 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/enum_machine/build_value_class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EnumMachine 4 | module BuildValueClass 5 | def self.call(enum_values:, i18n_scope:, value_decorator:, machine: nil) 6 | aliases = machine&.instance_variable_get(:@aliases) || {} 7 | 8 | Class.new(String) do 9 | include(value_decorator) if value_decorator 10 | 11 | define_method(:machine) { machine } 12 | define_method(:enum_values) { enum_values } 13 | private :enum_values, :machine 14 | 15 | def inspect 16 | "#" 17 | end 18 | 19 | if machine&.transitions? 20 | def possible_transitions 21 | machine.possible_transitions(self) 22 | end 23 | 24 | def can?(enum_value) 25 | possible_transitions.include?(enum_value) 26 | end 27 | end 28 | 29 | enum_values.each do |enum_value| 30 | enum_name = enum_value.underscore 31 | 32 | define_method(:"#{enum_name}?") do 33 | self == enum_value 34 | end 35 | 36 | if machine&.transitions? 37 | define_method(:"can_#{enum_name}?") do 38 | possible_transitions.include?(enum_value) 39 | end 40 | end 41 | end 42 | 43 | aliases.each_key do |key| 44 | define_method(:"#{key}?") do 45 | machine.fetch_alias(key).include?(self) 46 | end 47 | end 48 | 49 | if i18n_scope 50 | full_scope = "enums.#{i18n_scope}" 51 | define_method(:human_name) do 52 | ::I18n.t(self, scope: full_scope, default: self) 53 | end 54 | end 55 | 56 | def respond_to_missing?(method_name, _include_private = false) 57 | method_name = method_name.name if method_name.is_a?(Symbol) 58 | 59 | method_name.end_with?("?") && 60 | method_name.include?("__") && 61 | (method_name.delete_suffix("?").split("__") - enum_values).empty? 62 | end 63 | 64 | def method_missing(method_name) 65 | return super unless respond_to_missing?(method_name) 66 | 67 | m_enums = method_name.name.delete_suffix("?").split("__") 68 | self.class.define_method(method_name) { m_enums.include?(self) } 69 | send(method_name) 70 | end 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/enum_machine/driver_active_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EnumMachine 4 | module DriverActiveRecord 5 | def enum_machine(attr, enum_values, i18n_scope: nil, value_decorator: nil, &block) 6 | klass = self 7 | 8 | i18n_scope ||= "#{klass.base_class.to_s.underscore}.#{attr}" 9 | 10 | enum_const_name = attr.to_s.upcase 11 | machine = Machine.new(enum_values, klass, enum_const_name, attr) 12 | machine.instance_eval(&block) if block 13 | 14 | value_class = BuildValueClass.call(enum_values: enum_values, i18n_scope: i18n_scope, machine: machine, value_decorator: value_decorator) 15 | enum_class = BuildEnumClass.call(enum_values: enum_values, i18n_scope: i18n_scope, machine: machine, value_class: value_class) 16 | 17 | value_class.extend(AttributePersistenceMethods[attr, enum_values]) 18 | 19 | # default_proc for working with custom values not defined in enum list but may exists in db 20 | enum_class.value_attribute_mapping.default_proc = 21 | proc do |hash, enum_value| 22 | hash[enum_value] = value_class.new(enum_value).freeze 23 | end 24 | 25 | if machine.transitions? 26 | klass.class_eval <<-RUBY, __FILE__, __LINE__ + 1 # rubocop:disable Style/DocumentDynamicEvalDefinition 27 | before_save :__enum_machine_#{attr}_before_save 28 | after_save :__enum_machine_#{attr}_after_save 29 | 30 | def __enum_machine_#{attr}_before_save 31 | if (attr_changes = changes['#{attr}']) && !@__enum_machine_#{attr}_skip_transitions 32 | value_was, value_new = *attr_changes 33 | self.class::#{enum_const_name}.machine.fetch_before_transitions(attr_changes).each do |block| 34 | @__enum_machine_#{attr}_forced_value = value_was 35 | instance_exec(self, value_was, value_new, &block) 36 | ensure 37 | @__enum_machine_#{attr}_forced_value = nil 38 | end 39 | end 40 | end 41 | 42 | def __enum_machine_#{attr}_after_save 43 | if (attr_changes = previous_changes['#{attr}']) && !@__enum_machine_#{attr}_skip_transitions 44 | self.class::#{enum_const_name}.machine.fetch_after_transitions(attr_changes).each { |block| instance_exec(self, *attr_changes, &block) } 45 | end 46 | end 47 | RUBY 48 | end 49 | 50 | define_methods = Module.new 51 | define_methods.class_eval <<-RUBY, __FILE__, __LINE__ + 1 52 | # def state 53 | # enum_value = @__enum_machine_state_forced_value || super() 54 | # return unless enum_value 55 | # 56 | # unless @__enum_value_state == enum_value 57 | # @__enum_value_state = self.class::STATE.value_attribute_mapping[enum_value].dup 58 | # @__enum_value_state.parent = self 59 | # @__enum_value_state.freeze 60 | # end 61 | # 62 | # @__enum_value_state 63 | # end 64 | # 65 | # def skip_state_transitions 66 | # @__enum_machine_state_skip_transitions = true 67 | # yield 68 | # ensure 69 | # @__enum_machine_state_skip_transitions = false 70 | # end 71 | # 72 | # def initialize_dup(other) 73 | # @__enum_value_state = nil 74 | # super 75 | # end 76 | 77 | def #{attr} 78 | enum_value = @__enum_machine_#{attr}_forced_value || super() 79 | return unless enum_value 80 | 81 | unless @__enum_value_#{attr} == enum_value 82 | @__enum_value_#{attr} = self.class::#{enum_const_name}.value_attribute_mapping[enum_value].dup 83 | @__enum_value_#{attr}.parent = self 84 | @__enum_value_#{attr}.freeze 85 | end 86 | 87 | @__enum_value_#{attr} 88 | end 89 | 90 | def skip_#{attr}_transitions 91 | @__enum_machine_#{attr}_skip_transitions = true 92 | yield 93 | ensure 94 | @__enum_machine_#{attr}_skip_transitions = false 95 | end 96 | 97 | def initialize_dup(other) 98 | @__enum_value_#{attr} = nil 99 | super 100 | end 101 | RUBY 102 | 103 | enum_decorator = 104 | Module.new do 105 | define_singleton_method(:included) do |decorating_class| 106 | decorating_class.prepend define_methods 107 | decorating_class.const_set enum_const_name, enum_class 108 | end 109 | end 110 | enum_class.define_singleton_method(:enum_decorator) { enum_decorator } 111 | 112 | klass.include(enum_decorator) 113 | 114 | enum_decorator 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/enum_machine/driver_simple_class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EnumMachine 4 | module DriverSimpleClass 5 | # include EnumMachine[ 6 | # state: { enum: %w[choice in_delivery], i18n_scope: 'line_item.state' }, 7 | # color: { enum: %w[red green yellow] }, 8 | # type: { enum: %w[CartType BookmarkType] }, 9 | # ] 10 | def self.call(args) 11 | Module.new do 12 | define_singleton_method(:included) do |klass| 13 | args.each do |attr, params| 14 | enum_values = params.fetch(:enum) 15 | i18n_scope = params.fetch(:i18n_scope, nil) 16 | value_decorator = params.fetch(:value_decorator, nil) 17 | 18 | if defined?(ActiveRecord) && klass <= ActiveRecord::Base 19 | klass.enum_machine(attr, enum_values, i18n_scope: i18n_scope) 20 | else 21 | enum_const_name = attr.to_s.upcase 22 | value_class = BuildValueClass.call(enum_values: enum_values, i18n_scope: i18n_scope, value_decorator: value_decorator) 23 | enum_class = BuildEnumClass.call(enum_values: enum_values, i18n_scope: i18n_scope, value_class: value_class) 24 | 25 | define_methods = 26 | Module.new do 27 | define_method(attr) do 28 | enum_value = super() 29 | return unless enum_value 30 | 31 | enum_class.value_attribute_mapping.fetch(enum_value) 32 | end 33 | end 34 | 35 | enum_decorator = 36 | Module.new do 37 | define_singleton_method(:included) do |decorating_class| 38 | decorating_class.prepend define_methods 39 | decorating_class.const_set enum_const_name, enum_class 40 | end 41 | end 42 | enum_class.define_singleton_method(:enum_decorator) { enum_decorator } 43 | 44 | klass.include(enum_decorator) 45 | enum_decorator 46 | end 47 | end 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/enum_machine/machine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EnumMachine 4 | class Machine 5 | attr_reader :enum_values, :base_klass, :enum_const_name, :attr_name 6 | 7 | def initialize(enum_values, base_klass = nil, enum_const_name = nil, attr_name = nil) 8 | @enum_values = enum_values 9 | @base_klass = base_klass 10 | @enum_const_name = enum_const_name 11 | @attr_name = attr_name 12 | @transitions = {} 13 | @before_transition = {} 14 | @after_transition = {} 15 | @aliases = {} 16 | end 17 | 18 | # public api 19 | # transitions('s1' => 's2', %w[s3 s3] => 's4') 20 | def transitions(from__to_hash) 21 | validate_state!(from__to_hash) 22 | 23 | from__to_hash.each do |from_arr, to_arr| 24 | array_wrap(from_arr).product(array_wrap(to_arr)).each do |from, to| 25 | @transitions[from] ||= [] 26 | @transitions[from] << to 27 | end 28 | end 29 | end 30 | 31 | # public api 32 | # before_transition('s1' => 's4') 33 | # before_transition(%w[s1 s2] => %w[s3 s4]) 34 | def before_transition(from__to_hash, &block) 35 | validate_state!(from__to_hash) 36 | 37 | filter_transitions(from__to_hash).each do |from_pair_to| 38 | @before_transition[from_pair_to] ||= [] 39 | @before_transition[from_pair_to] << block 40 | end 41 | end 42 | 43 | # public api 44 | # after_transition('s1' => 's4') 45 | # after_transition(%w[s1 s2] => %w[s3 s4]) 46 | def after_transition(from__to_hash, &block) 47 | validate_state!(from__to_hash) 48 | 49 | filter_transitions(from__to_hash).each do |from_pair_to| 50 | @after_transition[from_pair_to] ||= [] 51 | @after_transition[from_pair_to] << block 52 | end 53 | end 54 | 55 | # public api 56 | def any 57 | @any ||= AnyEnumValues.new(enum_values + [nil]) 58 | end 59 | 60 | def aliases(hash) 61 | @aliases = hash 62 | end 63 | 64 | def fetch_aliases 65 | @aliases 66 | end 67 | 68 | def transitions? 69 | @transitions.present? 70 | end 71 | 72 | def fetch_transitions 73 | @transitions 74 | end 75 | 76 | # internal api 77 | def fetch_before_transitions(from__to) 78 | validate_transition!(from__to) 79 | @before_transition.fetch(from__to, []) 80 | end 81 | 82 | # internal api 83 | def fetch_after_transitions(from__to) 84 | @after_transition.fetch(from__to, []) 85 | end 86 | 87 | # internal api 88 | def fetch_alias(alias_key) 89 | array_wrap(@aliases.fetch(alias_key)) 90 | end 91 | 92 | # internal api 93 | def possible_transitions(from) 94 | @transitions.fetch(from, []) 95 | end 96 | 97 | private def validate_state!(object_with_values) 98 | unless (undefined = object_with_values.to_a.flatten - enum_values - [nil]).empty? 99 | raise EnumMachine::Error, "values #{undefined} not defined in enum_machine" 100 | end 101 | end 102 | 103 | private def filter_transitions(from__to_hash) 104 | from_arr, to_arr = from__to_hash.to_a.first 105 | is_any_enum_values = from_arr.is_a?(AnyEnumValues) || to_arr.is_a?(AnyEnumValues) 106 | 107 | array_wrap(from_arr).product(array_wrap(to_arr)).filter do |from__to| 108 | if is_any_enum_values 109 | from, to = from__to 110 | possible_transitions(from).include?(to) 111 | else 112 | validate_transition!(from__to) 113 | true 114 | end 115 | end 116 | end 117 | 118 | private def validate_transition!(from__to) 119 | from, to = from__to 120 | unless possible_transitions(from).include?(to) 121 | raise EnumMachine::InvalidTransition.new(self, from, to) 122 | end 123 | end 124 | 125 | private def array_wrap(value) 126 | if value.nil? 127 | [nil] 128 | else 129 | Array(value) 130 | end 131 | end 132 | 133 | class AnyEnumValues < Array 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /lib/enum_machine/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EnumMachine 4 | VERSION = "2.1.0" 5 | end 6 | -------------------------------------------------------------------------------- /spec/enum_machine/active_record_enum_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "DriverActiveRecord", :ar do 4 | model = 5 | Class.new(TestModel) do 6 | enum_machine :state, %w[choice in_delivery] 7 | enum_machine :color, %w[red green blue] 8 | end 9 | 10 | it "check answer methods" do 11 | m = model.new(state: "choice", color: "red") 12 | 13 | expect(m.state).to be_choice 14 | expect(m.color).to be_red 15 | expect(m.state).not_to be_in_delivery 16 | expect(m.color).not_to be_blue 17 | end 18 | 19 | it "assign new value" do 20 | m = model.new(color: "red") 21 | expect(m.color).not_to be_blue 22 | 23 | m.color = "blue" 24 | expect(m.color).to be_blue 25 | expect(m.color.frozen?).to be true 26 | end 27 | 28 | it "works with custom value, not defined in enum list" do 29 | m = model.new(color: "wrong") 30 | 31 | expect(m.color).to eq("wrong") 32 | expect(m.color.red?).to be(false) 33 | expect(m.color.frozen?).to be true 34 | expect { m.color.wrong? }.to raise_error(NoMethodError) 35 | end 36 | 37 | it "test I18n" do 38 | I18n.load_path = Dir["#{File.expand_path('spec/locales')}/*.yml"] 39 | I18n.default_locale = :ru 40 | 41 | m = model.new(color: "red") 42 | expect(m.color.human_name).to eq "Красный" 43 | expect(model::COLOR.human_name_for("red")).to eq "Красный" 44 | end 45 | 46 | context "when enum in CamelCase" do 47 | model_camel = 48 | Class.new(TestModel) do 49 | enum_machine :state, %w[OrderCourier OrderPost] 50 | end 51 | 52 | it "check answer methods" do 53 | m = model_camel.new(state: "OrderCourier") 54 | 55 | expect(m.state).to be_order_courier 56 | expect(m.state).not_to be_order_post 57 | end 58 | 59 | it "returns state string" do 60 | expect(model_camel::STATE::ORDER_COURIER).to eq "OrderCourier" 61 | expect(model_camel::STATE::ORDER_COURIER__ORDER_POST).to eq %w[OrderCourier OrderPost] 62 | end 63 | end 64 | 65 | context "when enum applied on store field" do 66 | model_store = 67 | Class.new(TestModel) do 68 | store :params, accessors: [:fine_tuning], coder: JSON 69 | enum_machine :fine_tuning, %w[good excellent] 70 | enum_machine :state, %w[choice in_delivery] 71 | end 72 | 73 | it "set store field" do 74 | m = model_store.new(fine_tuning: "good", state: "choice") 75 | 76 | expect(m.fine_tuning).to be_good 77 | expect(m.fine_tuning).not_to be_excellent 78 | end 79 | end 80 | 81 | context "when with value_decorator" do 82 | let(:decorator) do 83 | Module.new do 84 | def am_i_choice? 85 | self == "choice" 86 | end 87 | end 88 | end 89 | 90 | let(:model_with_decorator) do 91 | value_decorator = decorator 92 | Class.new(TestModel) do 93 | enum_machine :state, %w[choice in_delivery], value_decorator: value_decorator 94 | end 95 | end 96 | 97 | it "decorates enum value for new record" do 98 | expect(model_with_decorator.new(state: "choice").state.am_i_choice?).to be(true) 99 | expect(model_with_decorator.new(state: "in_delivery").state.am_i_choice?).to be(false) 100 | end 101 | 102 | it "decorates enum value for existing record" do 103 | model_with_decorator.create(state: "choice") 104 | m = model_with_decorator.find_by(state: "choice") 105 | expect(m.state.am_i_choice?).to be(true) 106 | end 107 | end 108 | 109 | describe("#enum_decorator") do 110 | let(:model) do 111 | Class.new(TestModel) do 112 | enum_machine :state, %w[choice in_delivery] 113 | include EnumMachine[color: { enum: %w[red green blue] }] 114 | end 115 | end 116 | 117 | let(:klass) do 118 | Class.new(TestModel) do 119 | enum_machine :state, %w[choice in_delivery] 120 | include EnumMachine[color: { enum: %w[red green blue] }] 121 | end 122 | end 123 | 124 | it "decorates plain class from ar" do 125 | decorating_model = model 126 | decorated_class = 127 | Class.new do 128 | include decorating_model::STATE.enum_decorator 129 | include decorating_model::COLOR.enum_decorator 130 | attr_accessor :state, :color 131 | end 132 | 133 | decorated_item = decorated_class.new 134 | decorated_item.state = "choice" 135 | decorated_item.color = "red" 136 | 137 | expect(decorated_item.state).to be_choice 138 | expect(decorated_item.color).to be_red 139 | expect(decorated_class::STATE::CHOICE).to eq "choice" 140 | expect(decorated_class::COLOR::RED).to eq "red" 141 | end 142 | 143 | it "decorates ar from plain class" do 144 | decorating_class = klass 145 | decorated_model = 146 | Class.new(TestModel) do 147 | include decorating_class::STATE.enum_decorator 148 | include decorating_class::COLOR.enum_decorator 149 | end 150 | 151 | decorated_item = decorated_model.new(state: "choice", color: "red") 152 | 153 | expect(decorated_item.state).to be_choice 154 | expect(decorated_item.color).to be_red 155 | expect(decorated_model::STATE::CHOICE).to eq "choice" 156 | expect(decorated_model::COLOR::RED).to eq "red" 157 | end 158 | 159 | it "decorates ar from ar" do 160 | decorating_model = model 161 | decorated_model = 162 | Class.new(TestModel) do 163 | include decorating_model::STATE.enum_decorator 164 | include decorating_model::COLOR.enum_decorator 165 | end 166 | 167 | decorated_item = decorated_model.new(state: "choice", color: "red") 168 | 169 | expect(decorated_item.state).to be_choice 170 | expect(decorated_item.color).to be_red 171 | expect(decorated_model::STATE::CHOICE).to eq "choice" 172 | expect(decorated_model::COLOR::RED).to eq "red" 173 | end 174 | end 175 | 176 | it "serialize model" do 177 | Object.const_set(:TestModelSerialize, model) 178 | m = TestModelSerialize.create(state: "choice", color: "wrong") 179 | 180 | unserialized_m = Marshal.load(Marshal.dump(m)) 181 | 182 | expect(unserialized_m.state).to be_choice 183 | expect(unserialized_m.class::STATE::CHOICE).to eq("choice") 184 | expect(unserialized_m.color).to eq("wrong") 185 | expect(unserialized_m.color.red?).to be(false) 186 | end 187 | 188 | it "serialize value" do 189 | Object.const_set(:TestModelSerialize, model) 190 | value = TestModelSerialize::STATE["choice"] 191 | value_after_serialize = Marshal.load(Marshal.dump(value)) 192 | 193 | expect(value_after_serialize).to eq(value) 194 | expect(value_after_serialize.choice?).to be(true) 195 | end 196 | 197 | it "returns state value by []" do 198 | expect(model::STATE["in_delivery"]).to eq "in_delivery" 199 | expect(model::STATE["in_delivery"].in_delivery?).to be(true) 200 | expect(model::STATE["in_delivery"].choice?).to be(false) 201 | expect(model::STATE["wrong"]).to be_nil 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /spec/enum_machine/active_record_machine_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "DriverActiveRecord", :ar do 4 | model = 5 | Class.new(TestModel) do 6 | enum_machine :color, %w[red green blue] 7 | 8 | enum_machine :state, %w[created approved cancelled activated cancelled] do 9 | transitions( 10 | nil => "created", 11 | "created" => [nil, "approved"], 12 | %w[cancelled approved] => "activated", 13 | "activated" => %w[created cancelled], 14 | ) 15 | aliases( 16 | "forming" => %w[created approved], 17 | "pending" => nil, 18 | ) 19 | before_transition "created" => "approved" do |item| 20 | item.errors.add(:state, :invalid, message: "invalid transition") if item.color.red? 21 | end 22 | after_transition "created" => "approved" do |item| 23 | item.message = "after_approved" 24 | end 25 | after_transition %w[created] => %w[approved] do |item| 26 | item.color = "red" 27 | end 28 | end 29 | end 30 | 31 | it "before_transition is runnable" do 32 | m = model.create(state: "created", color: "red") 33 | 34 | m.update(state: "approved") 35 | expect(m.errors.messages).to eq({ state: ["invalid transition"] }) 36 | end 37 | 38 | it "after_transition is runnable" do 39 | m = model.create(state: "created", color: "green") 40 | 41 | m.state.to_approved! 42 | 43 | expect(m.message).to eq "after_approved" 44 | expect(m.color).to eq "red" 45 | 46 | m.reload 47 | 48 | expect(m.message).to be_nil 49 | expect(m.color).to eq "green" 50 | end 51 | 52 | it "check can_ methods" do 53 | m = model.new(state: "created", color: "red") 54 | expect(m.state.can?("approved")).to be true 55 | expect(m.state.can_approved?).to be true 56 | expect(m.state.can_cancelled?).to be false 57 | expect(m.state.can_activated?).to be false 58 | end 59 | 60 | it "check enum value comparsion" do 61 | m = model.new(state: "created", color: "red") 62 | 63 | expect(m.state).to eq "created" 64 | expect(m.state).to eq model::STATE::CREATED 65 | expect(model::STATE::CREATED).to eq "created" 66 | expect({ m.state => 1 }["created"]).to eq 1 67 | 68 | m.state = nil 69 | expect(m.state).to be_nil 70 | end 71 | 72 | it "possible_transitions returns next states" do 73 | expect(model.new(state: "created").state.possible_transitions).to eq [nil, "approved"] 74 | expect(model.new(state: "activated").state.possible_transitions).to eq %w[created cancelled] 75 | end 76 | 77 | it "raise when changed state is not defined in transitions" do 78 | m = model.create(state: "created") 79 | expect { m.update(state: "activated") }.to raise_error(EnumMachine::InvalidTransition) do |e| 80 | expect(e.message).to include('Transition "created" => "activated" not defined in enum_machine') 81 | expect(e.from).to eq "created" 82 | expect(e.to).to eq "activated" 83 | expect(e.enum_const.values).not_to be_empty 84 | end 85 | end 86 | 87 | it "test alias" do 88 | m = model.new(state: "created") 89 | 90 | expect(m.state.forming?).to be true 91 | expect(model::STATE.forming).to eq %w[created approved] 92 | end 93 | 94 | it "coerces states type" do 95 | state_enum = model.new(state: "created").state 96 | expect(model.new(message: state_enum).message).to eq "created" 97 | end 98 | 99 | context "when state is changed" do 100 | it "returns changed state string" do 101 | m = model.create(state: "created") 102 | state_was = m.state 103 | 104 | m.state = "approved" 105 | 106 | expect(m.state).to eq "approved" 107 | expect(state_was).to eq "created" 108 | end 109 | end 110 | 111 | context "when check skip transitions" do 112 | it "create record if transition is skipped" do 113 | m = model.new(state: "activated") 114 | 115 | m.skip_state_transitions { m.save! } 116 | 117 | expect(m.message).to be_nil 118 | 119 | expect { m.update(state: "approved") }.to raise_error(EnumMachine::InvalidTransition) do |e| 120 | expect(e.message).to include('Transition "activated" => "approved" not defined in enum_machine') 121 | end 122 | end 123 | 124 | it "checks skip context" do 125 | def a 126 | 1 127 | end 128 | 129 | m = model.new(state: "activated") 130 | 131 | expect { m.skip_state_transitions { a + 1 } } 132 | .not_to raise_error 133 | end 134 | 135 | it "raise when simple create record" do 136 | expect { model.create(state: "activated") }.to raise_error(EnumMachine::InvalidTransition) do |e| 137 | expect(e.from).to be_nil 138 | expect(e.message).to include('Transition nil => "activated" not defined in enum_machine') 139 | end 140 | end 141 | end 142 | 143 | it "checks callbacks context" do 144 | semaphore = 145 | Class.new(TestModel) do 146 | enum_machine :color, %w[green orange red] do 147 | transitions( 148 | [nil, "red"] => "green", 149 | "green" => "orange", 150 | "orange" => "red", 151 | ) 152 | before_transition "green" => "orange" do |item, from, to| 153 | item.message = "#{from} => #{to}" 154 | end 155 | before_transition "orange" => "red" do |item, _from, to| 156 | item.message = "#{item.color} => #{to}" 157 | end 158 | after_transition "red" => "green" do |item, _from, to| 159 | item.message = "#{item.color} => #{to}" 160 | end 161 | end 162 | end 163 | 164 | m = semaphore.create!(color: "green") 165 | 166 | expect { m.update!(color: "orange") }.to change(m, :message).to "green => orange" 167 | expect { m.update!(color: "red") }.to change(m, :message).to "orange => red" 168 | expect { m.update!(color: "green") }.to change(m, :message).to "green => green" 169 | end 170 | 171 | it "check error when empty transition" do 172 | expect { 173 | Class.new(TestModel) do 174 | enum_machine :state, %w[picked completed] do 175 | after_transition(nil => "picked") { 1 } 176 | end 177 | end 178 | }.to raise_error(EnumMachine::InvalidTransition) { |e| 179 | expect(e.from).to be_nil 180 | expect(e.message).to include('Transition nil => "picked" not defined in enum_machine') 181 | } 182 | end 183 | 184 | it "dup AR object not linked with original" do 185 | m = model.create(state: "created", color: "green") 186 | m.state # init parent 187 | m_dup = m.dup 188 | 189 | expect(m_dup.state.parent).to eq m_dup 190 | 191 | m_dup.skip_state_transitions { m_dup.state.to_approved! } 192 | 193 | expect(m.state).to eq "created" 194 | expect(m_dup.state).to eq "approved" 195 | end 196 | end 197 | -------------------------------------------------------------------------------- /spec/enum_machine/driver_simple_class_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TestClass 4 | attr_accessor :state 5 | 6 | def initialize(state) 7 | @state = state 8 | end 9 | 10 | include EnumMachine[state: { enum: %w[choice in_delivery lost] }] 11 | end 12 | 13 | module ValueDecorator 14 | def am_i_choice? 15 | self == "choice" 16 | end 17 | end 18 | 19 | class TestClassWithDecorator 20 | attr_accessor :state 21 | 22 | def initialize(state) 23 | @state = state 24 | end 25 | 26 | include EnumMachine[state: { enum: %w[choice in_delivery lost], value_decorator: ValueDecorator }] 27 | end 28 | 29 | RSpec.describe "DriverSimpleClass" do 30 | subject(:item) { TestClass.new("choice") } 31 | 32 | it { expect(item.state.choice?).to be true } 33 | it { expect(item.state.in_delivery?).to be false } 34 | it { expect(item.state.choice__in_delivery?).to be true } 35 | it { expect(item.state.lost__in_delivery?).to be false } 36 | it { expect { item.state.last__in_delivery? }.to raise_error(NoMethodError) } 37 | it { expect(item.state).to eq "choice" } 38 | it { expect(item.state.frozen?).to be true } 39 | 40 | describe "module" do 41 | it "returns state string" do 42 | expect(TestClass::STATE::IN_DELIVERY).to eq "in_delivery" 43 | expect(TestClass::STATE::IN_DELIVERY).to be_frozen 44 | end 45 | 46 | it "returns state array" do 47 | expect(TestClass::STATE::CHOICE__IN_DELIVERY).to eq %w[choice in_delivery] 48 | expect(TestClass::STATE::CHOICE__IN_DELIVERY).to be_frozen 49 | end 50 | 51 | it "raise exceptions unexists state" do 52 | expect { TestClass::STATE::CHOICE__CANCELLED }.to raise_error(NameError, "uninitialized constant TestClass::STATE::CANCELLED") 53 | end 54 | 55 | it "pretty print errors" do 56 | expect { item.state.human_name }.to raise_error(NoMethodError, /undefined method/) 57 | end 58 | 59 | context "when state is changed" do 60 | it "returns changed state string" do 61 | item.state = "choice" 62 | state_was = item.state 63 | 64 | item.state = "in_delivery" 65 | 66 | expect(item.state).to eq "in_delivery" 67 | expect(state_was).to eq "choice" 68 | end 69 | end 70 | end 71 | 72 | describe "TestClass::STATE const" do 73 | it "#values" do 74 | expect(TestClass::STATE.values).to eq(%w[choice in_delivery lost]) 75 | end 76 | 77 | it "#[]" do 78 | expect(TestClass::STATE["in_delivery"]).to eq "in_delivery" 79 | expect(TestClass::STATE["in_delivery"].in_delivery?).to be(true) 80 | expect(TestClass::STATE["in_delivery"].choice?).to be(false) 81 | expect(TestClass::STATE["in_delivery"].in_delivery__choice?).to be(true) 82 | expect(TestClass::STATE["in_delivery"].lost__choice?).to be(false) 83 | expect(TestClass::STATE["wrong"]).to be_nil 84 | end 85 | 86 | it "#enum_decorator" do 87 | decorated_klass = 88 | Class.new do 89 | include TestClassWithDecorator::STATE.enum_decorator 90 | attr_accessor :state 91 | end 92 | 93 | decorated_item = decorated_klass.new 94 | decorated_item.state = "choice" 95 | 96 | expect(decorated_item.state).to be_choice 97 | expect(decorated_klass::STATE::CHOICE).to eq "choice" 98 | end 99 | end 100 | 101 | context "when definition order is changed" do 102 | let(:invert_definition_class) do 103 | Class.new do 104 | include EnumMachine[state: { enum: %w[choice in_delivery] }] 105 | attr_accessor :state 106 | end 107 | end 108 | 109 | it "nothing raised" do 110 | expect { invert_definition_class }.not_to raise_error 111 | 112 | item = invert_definition_class.new 113 | item.state = "choice" 114 | 115 | expect(item.state).to be_choice 116 | end 117 | end 118 | 119 | context "when with decorator" do 120 | it "decorates enum values" do 121 | expect(TestClassWithDecorator.new("choice").state.am_i_choice?).to be(true) 122 | expect(TestClassWithDecorator.new("in_delivery").state.am_i_choice?).to be(false) 123 | end 124 | 125 | it "decorates enum values in enum const" do 126 | expect(TestClassWithDecorator::STATE.values.map(&:am_i_choice?)).to eq([true, false, false]) 127 | expect((TestClassWithDecorator::STATE.values & ["in_delivery"]).map(&:am_i_choice?)).to eq([false]) 128 | end 129 | 130 | it "keeps decorating on serialization" do 131 | m = TestClassWithDecorator.new("choice") 132 | unserialized_m = Marshal.load(Marshal.dump(m)) 133 | expect(unserialized_m.state.am_i_choice?).to be(true) 134 | end 135 | 136 | it "keeps decorating on #enum_decorator" do 137 | decorated_klass = 138 | Class.new do 139 | include TestClassWithDecorator::STATE.enum_decorator 140 | attr_accessor :state 141 | end 142 | 143 | decorated_item = decorated_klass.new 144 | decorated_item.state = "choice" 145 | 146 | expect(decorated_item.state.am_i_choice?).to be(true) 147 | end 148 | end 149 | 150 | it "serialize class" do 151 | m = TestClass.new("choice") 152 | 153 | unserialized_m = Marshal.load(Marshal.dump(m)) 154 | 155 | expect(unserialized_m.state).to be_choice 156 | expect(unserialized_m.class::STATE::CHOICE).to eq "choice" 157 | end 158 | 159 | it "serialize value" do 160 | value = TestClass::STATE["choice"] 161 | value_after_serialize = Marshal.load(Marshal.dump(value)) 162 | 163 | expect(value_after_serialize).to eq(value) 164 | expect(value_after_serialize.choice?).to be(true) 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /spec/enum_machine/machine_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rspec" 4 | 5 | RSpec.describe EnumMachine::Machine do 6 | subject(:item) do 7 | m = described_class.new(%w[created approved cancelled activated]) 8 | m.transitions( 9 | nil => "created", 10 | "created" => "approved", 11 | %w[cancelled approved] => "activated", 12 | %w[created approved] => "cancelled", 13 | ) 14 | m 15 | end 16 | 17 | describe "#transitions" do 18 | it "valid transitions map" do 19 | expected = { 20 | nil => %w[created], 21 | "approved" => %w[activated cancelled], 22 | "cancelled" => %w[activated], 23 | "created" => %w[approved cancelled], 24 | } 25 | expect(item.instance_variable_get(:@transitions)).to eq(expected) 26 | end 27 | 28 | it "raise when state undefined" do 29 | m = described_class.new(%w[s1 s2 s3]) 30 | expect { 31 | m.transitions("s1" => "s2", %w[s2 s3] => "s4", %w[s5 s6] => "s7") 32 | }.to raise_error(EnumMachine::Error, 'values ["s4", "s5", "s6", "s7"] not defined in enum_machine') 33 | end 34 | end 35 | 36 | describe "#before_transition" do 37 | it "finds before transition code blocks" do 38 | item.before_transition(%w[cancelled approved] => "activated") { 1 } 39 | item.before_transition("approved" => "activated") { 2 } 40 | 41 | expect(item.fetch_before_transitions(%w[approved activated]).map(&:call)).to eq [1, 2] 42 | end 43 | 44 | it "raise when state undefined" do 45 | m = described_class.new(%w[s1 s2 s3]) 46 | expect { 47 | m.before_transition(%w[s3 s4] => %w[s1 s5]) 48 | }.to raise_error(EnumMachine::Error, 'values ["s4", "s5"] not defined in enum_machine') 49 | end 50 | end 51 | 52 | describe "#after_transition" do 53 | it "finds after transition code blocks" do 54 | item.after_transition(%w[cancelled approved] => "activated") { 1 } 55 | item.after_transition("approved" => "activated") { 2 } 56 | 57 | expect(item.fetch_after_transitions(%w[approved activated]).map(&:call)).to eq [1, 2] 58 | end 59 | 60 | it "raise when state undefined" do 61 | m = described_class.new(%w[s1 s2 s3]) 62 | expect { 63 | m.after_transition(%w[s3 s4] => %w[s1 s5]) 64 | }.to raise_error(EnumMachine::Error, 'values ["s4", "s5"] not defined in enum_machine') 65 | end 66 | end 67 | 68 | describe "#all" do 69 | it "defines callbacks from any states" do 70 | item.before_transition(item.any => "created") 71 | item.before_transition(item.any => "activated") 72 | 73 | expect(item.instance_variable_get(:@before_transition).keys).to eq([[nil, "created"], %w[approved activated], %w[cancelled activated]]) 74 | end 75 | 76 | it "defines callbacks to any states" do 77 | item.after_transition("created" => item.any) 78 | expect(item.instance_variable_get(:@after_transition).keys).to eq([%w[created approved], %w[created cancelled]]) 79 | end 80 | end 81 | 82 | it "finds before and after transition code blocks" do 83 | item.before_transition("approved" => "activated") { 1 } 84 | item.after_transition("approved" => "activated") { 2 } 85 | 86 | expect(item.fetch_before_transitions(%w[approved activated]).map(&:call)).to eq [1] 87 | expect(item.fetch_after_transitions(%w[approved activated]).map(&:call)).to eq [2] 88 | end 89 | 90 | describe "#possible_transitions" do 91 | it "finds after transition code blocks" do 92 | expect(item.possible_transitions("created")).to eq %w[approved cancelled] 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /spec/locales/ru.yml: -------------------------------------------------------------------------------- 1 | ru: 2 | enums: 3 | test_model: 4 | color: 5 | red: Красный 6 | blue: Голубой -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "enum_machine" 4 | require "bundler" 5 | 6 | Bundler.require :default 7 | require "support/active_record" 8 | require "support/test_model" 9 | 10 | RSpec.configure do |config| 11 | # Enable flags like --only-failures and --next-failure 12 | config.example_status_persistence_file_path = ".rspec_status" 13 | 14 | # Disable RSpec exposing methods globally on `Module` and `main` 15 | config.disable_monkey_patching! 16 | 17 | config.expect_with :rspec do |c| 18 | c.syntax = :expect 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/support/active_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_record" 4 | 5 | ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") 6 | ActiveRecord::Migration.verbose = false 7 | 8 | ActiveRecord::Schema.define do 9 | create_table(:test_models, force: true) do |t| 10 | t.string :state 11 | t.string :color 12 | t.text :message 13 | t.text :params 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/test_model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_record" 4 | 5 | class TestModel < ActiveRecord::Base 6 | def self.model_name 7 | ActiveModel::Name.new(self, nil, "test_model") 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/performance.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/inline" 4 | 5 | gemfile(true) do 6 | source "https://rubygems.org" 7 | 8 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 9 | 10 | gem "rails", "~> 7.0" 11 | gem "sqlite3" 12 | gem "state_machines", github: "state-machines/state_machines" 13 | gem "state_machines-activerecord", github: "state-machines/state_machines-activerecord" 14 | gem "aasm", github: "aasm/aasm" 15 | gem "enum_machine", github: "corp-gp/enum_machine" 16 | gem "benchmark-ips" 17 | end 18 | 19 | require "active_record" 20 | require "benchmark/ips" 21 | 22 | ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") 23 | # ActiveRecord::Base.logger = Logger.new(STDOUT) 24 | 25 | ActiveRecord::Schema.define do 26 | create_table :orders, force: true do |t| 27 | t.string :name 28 | t.string :state 29 | end 30 | end 31 | 32 | STATES_IN_TRANSIT = %w[shipped delivered_to_office delivered_to_courier_city].freeze 33 | 34 | class OrderEnumMachine < ActiveRecord::Base 35 | self.table_name = :orders 36 | 37 | enum_machine :state, %w[ 38 | forming confirmed ready_for_collecting collecting collected packed wait_shipment back_picking cancelled shipped 39 | delivered_to_office delivered_to_courier_city part_obtain obtain overdue rejection closed returned merged searched lost 40 | ] do 41 | transitions( 42 | [nil] | %w[confirmed ready_for_collecting] => "forming", 43 | [nil] | %w[forming confirmed] => "ready_for_collecting", 44 | [nil] | %w[forming ready_for_collecting] => "confirmed", 45 | "ready_for_collecting" => "collecting", 46 | "collecting" => "collected", 47 | %w[collecting collected] => "packed", 48 | "packed" => "wait_shipment", 49 | %w[forming confirmed ready_for_collecting collecting packed wait_shipment cancelled] => "back_picking", 50 | %w[forming confirmed ready_for_collecting collecting collected packed wait_shipment] => "cancelled", 51 | "wait_shipment" => "shipped", 52 | %w[wait_shipment shipped] => %w[delivered_to_office delivered_to_courier_city], 53 | %w[wait_shipment overdue rejection returned searched lost obtain] | STATES_IN_TRANSIT => "part_obtain", 54 | %w[wait_shipment overdue rejection returned searched lost part_obtain] | STATES_IN_TRANSIT => "obtain", 55 | %w[wait_shipment obtain searched] | STATES_IN_TRANSIT => "overdue", 56 | %w[wait_shipment obtain part_obtain overdue searched] | STATES_IN_TRANSIT => "rejection", 57 | %w[overdue rejection searched lost] => "returned", 58 | %w[part_obtain obtain searched lost] => "closed", 59 | %w[forming confirmed ready_for_collecting packed wait_shipment] => "merged", 60 | %w[wait_shipment shipped part_obtain obtain overdue rejection lost] | STATES_IN_TRANSIT => "searched", 61 | %w[wait_shipment shipped part_obtain obtain overdue rejection searched] | STATES_IN_TRANSIT => "lost", 62 | ) 63 | end 64 | end 65 | 66 | class OrderAasm < ActiveRecord::Base 67 | include AASM 68 | 69 | self.table_name = :orders 70 | 71 | aasm :state do # rubocop:disable Metrics/BlockLength 72 | state :default, initial: true 73 | state :forming 74 | state :confirmed 75 | state :ready_for_collecting 76 | state :collecting 77 | state :collected 78 | state :packed 79 | state :wait_shipment 80 | state :back_picking 81 | state :cancelled 82 | state :shipped 83 | state :delivered_to_office 84 | state :delivered_to_courier_city 85 | state :part_obtain 86 | state :obtain 87 | state :overdue 88 | state :rejection 89 | state :closed 90 | state :returned 91 | state :merged 92 | state :searched 93 | state :lost 94 | 95 | event :to_forming do 96 | transitions from: %i[default confirmed ready_for_collecting], to: :forming 97 | end 98 | 99 | event :to_ready_for_collecting do 100 | transitions from: %i[default forming confirmed], to: :ready_for_collecting 101 | end 102 | 103 | event :to_confirmed do 104 | transitions from: %i[default forming ready_for_collecting], to: :confirmed 105 | end 106 | 107 | event :to_collecting do 108 | transitions from: :ready_for_collecting, to: :collecting 109 | end 110 | 111 | event :to_collected do 112 | transitions from: :collecting, to: :collected 113 | end 114 | 115 | event :to_packed do 116 | transitions from: %i[collecting collected], to: :packed 117 | end 118 | 119 | event :to_wait_shipment do 120 | transitions from: :packed, to: :wait_shipment 121 | end 122 | 123 | event :to_back_picking do 124 | transitions from: %i[forming confirmed ready_for_collecting collecting packed wait_shipment cancelled], to: :back_picking 125 | end 126 | 127 | event :to_cancelled do 128 | transitions from: %i[forming confirmed ready_for_collecting collecting collected packed wait_shipment], to: :cancelled 129 | end 130 | 131 | event :to_shipped do 132 | transitions from: :wait_shipment, to: :shipped 133 | end 134 | 135 | event :to_delivered do 136 | transitions from: %i[wait_shipment shipped], to: %i[delivered_to_office delivered_to_courier_city] 137 | end 138 | 139 | event :to_part_obtain do 140 | transitions from: %i[wait_shipment overdue rejection returned searched lost obtain] | STATES_IN_TRANSIT, to: :part_obtain 141 | end 142 | 143 | event :to_obtain do 144 | transitions from: %i[wait_shipment overdue rejection returned searched lost part_obtain] | STATES_IN_TRANSIT, to: :obtain 145 | end 146 | 147 | event :to_overdue do 148 | transitions from: %i[wait_shipment obtain searched] | STATES_IN_TRANSIT, to: :overdue 149 | end 150 | 151 | event :to_rejection do 152 | transitions from: %i[wait_shipment obtain part_obtain overdue searched] | STATES_IN_TRANSIT, to: :rejection 153 | end 154 | 155 | event :to_returned do 156 | transitions from: %i[overdue rejection searched lost], to: :returned 157 | end 158 | 159 | event :to_closed do 160 | transitions from: %i[part_obtain obtain searched lost], to: :closed 161 | end 162 | 163 | event :to_merged do 164 | transitions from: %i[forming confirmed ready_for_collecting packed wait_shipment], to: :merged 165 | end 166 | 167 | event :to_searched do 168 | transitions from: %i[wait_shipment shipped part_obtain obtain overdue rejection lost] | STATES_IN_TRANSIT, to: :searched 169 | end 170 | 171 | event :to_lost do 172 | transitions from: %i[wait_shipment shipped part_obtain obtain overdue rejection searched] | STATES_IN_TRANSIT, to: :lost 173 | end 174 | end 175 | end 176 | 177 | class OrderStateMachines < ActiveRecord::Base 178 | self.table_name = :orders 179 | 180 | state_machine :state, initial: nil do 181 | event :to_forming do 182 | transition [nil, "confirmed", "ready_for_collecting"] => "forming" 183 | end 184 | 185 | event :to_ready_for_collecting do 186 | transition [nil, "forming", "confirmed"] => "ready_for_collecting" 187 | end 188 | 189 | event :to_confirmed do 190 | transition [nil, "forming", "ready_for_collecting"] => "confirmed" 191 | end 192 | 193 | event :to_collecting do 194 | transition "ready_for_collecting" => "collecting" 195 | end 196 | 197 | event :to_collected do 198 | transition "collecting" => "collected" 199 | end 200 | 201 | event :to_packed do 202 | transition %w[collecting collected] => "packed" 203 | end 204 | 205 | event :to_wait_shipment do 206 | transition "packed" => "wait_shipment" 207 | end 208 | 209 | event :to_back_picking do 210 | transition %w[forming confirmed ready_for_collecting collecting packed wait_shipment cancelled] => "back_picking" 211 | end 212 | 213 | event :to_cancelled do 214 | transition %w[forming confirmed ready_for_collecting collecting collected packed wait_shipment] => "cancelled" 215 | end 216 | 217 | event :to_shipped do 218 | transition "wait_shipment" => "shipped" 219 | end 220 | event :to_delivered do 221 | transition %w[wait_shipment shipped] => %w[delivered_to_office delivered_to_courier_city] 222 | end 223 | 224 | event :to_part_obtain do 225 | transition %w[wait_shipment overdue rejection returned searched lost obtain] | STATES_IN_TRANSIT => "part_obtain" 226 | end 227 | 228 | event :to_obtain do 229 | transition %w[wait_shipment overdue rejection returned searched lost part_obtain] | STATES_IN_TRANSIT => "obtain" 230 | end 231 | 232 | event :to_overdue do 233 | transition %w[wait_shipment obtain searched] | STATES_IN_TRANSIT => "overdue" 234 | end 235 | 236 | event :to_rejection do 237 | transition %w[wait_shipment obtain part_obtain overdue searched] | STATES_IN_TRANSIT => "rejection" 238 | end 239 | 240 | event :to_returned do 241 | transition %w[overdue rejection searched lost] => "returned" 242 | end 243 | 244 | event :to_closed do 245 | transition %w[part_obtain obtain searched lost] => "closed" 246 | end 247 | 248 | event :to_merged do 249 | transition %w[forming confirmed ready_for_collecting packed wait_shipment] => "merged" 250 | end 251 | 252 | event :to_searched do 253 | transition %w[wait_shipment shipped part_obtain obtain overdue rejection lost] | STATES_IN_TRANSIT => "searched" 254 | end 255 | 256 | event :to_lost do 257 | transition %w[wait_shipment shipped part_obtain obtain overdue rejection searched] | STATES_IN_TRANSIT => "lost" 258 | end 259 | end 260 | end 261 | 262 | def pp_title(name, stmt) 263 | "#{name.rjust(15, ' ')} |#{stmt.rjust(50)}" 264 | end 265 | 266 | order_attrs = { state: "confirmed", name: "Petrov" } 267 | 268 | order_enum_machine = OrderEnumMachine.create!(order_attrs) 269 | order_state_machines = OrderStateMachines.create!(order_attrs) 270 | order_aasm = OrderAasm.create!(order_attrs) 271 | 272 | Benchmark.ips(quiet: true) do |x| 273 | x.report(pp_title("enum_machine", "order.state.can_closed?")) do 274 | order_enum_machine.state.can_closed? 275 | end 276 | 277 | x.report(pp_title("state_machines", "order.can_to_closed?")) do 278 | order_state_machines.can_to_closed? 279 | end 280 | 281 | x.report(pp_title("aasm", "order.may_to_closed?")) do 282 | order_aasm.may_to_closed? 283 | end 284 | 285 | x.compare! 286 | end 287 | 288 | Benchmark.ips(quiet: true) do |x| 289 | x.report(pp_title("enum_machine", "order.state.forming?")) do 290 | order_enum_machine.state.forming? 291 | end 292 | 293 | x.report(pp_title("state_machines", "order.forming?")) do 294 | order_state_machines.forming? 295 | end 296 | 297 | x.report(pp_title("aasm", "order.forming?")) do 298 | order_aasm.forming? 299 | end 300 | 301 | x.compare! 302 | end 303 | 304 | Benchmark.ips(quiet: true) do |x| 305 | x.report(pp_title("enum_machine", "Order::STATE.values")) do 306 | OrderEnumMachine::STATE.values 307 | end 308 | 309 | x.report(pp_title("state_machines", "Order.state_machines[:state].states.map(&:value)")) do 310 | OrderStateMachines.state_machines[:state].states.map(&:value) 311 | end 312 | 313 | x.report(pp_title("aasm", "Order.aasm(:state).states.map(&:name)")) do 314 | OrderAasm.aasm(:state).states.map(&:name) 315 | end 316 | 317 | x.compare! 318 | end 319 | 320 | Benchmark.ips(quiet: true) do |x| 321 | x.report(pp_title("enum_machine", 'order.state = "forming" and order.valid?')) do 322 | order = order_enum_machine.dup 323 | order.state = "forming" and order.valid? 324 | end 325 | 326 | x.report(pp_title("state_machines", 'order.state_event = "to_forming" and order.valid?')) do 327 | order = order_state_machines.dup 328 | order.state_event = "to_forming" and order.valid? 329 | end 330 | 331 | x.report(pp_title("aasm", "order.to_forming")) do 332 | order = order_aasm.dup 333 | order.to_forming 334 | end 335 | 336 | x.compare! 337 | end 338 | --------------------------------------------------------------------------------