diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..37a9318 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,27 @@ +name: Ruby + +on: + push: + branches: + - main + + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + name: Ruby ${{ matrix.ruby }} + strategy: + matrix: + ruby: + - '3.3.0' + + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Run the default task + run: bundle exec rake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9106b2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ diff --git a/.standard.yml b/.standard.yml new file mode 100644 index 0000000..6d67c99 --- /dev/null +++ b/.standard.yml @@ -0,0 +1,3 @@ +# For available configuration options, see: +# https://github.com/standardrb/standard +ruby_version: 3.0 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..13a6c2f --- /dev/null +++ b/Gemfile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Specify your gem's dependencies in state_machine_enum.gemspec +gemspec + +gem "rake", "~> 13.0" + +gem "minitest", "~> 5.16" + +gem "standard", "~> 1.3" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..787f86e --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,88 @@ +PATH + remote: . + specs: + state_machine_enum (0.1.0) + activesupport (>= 6.0) + +GEM + remote: https://rubygems.org/ + specs: + activesupport (7.1.3.3) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + minitest (>= 5.1) + mutex_m + tzinfo (~> 2.0) + ast (2.4.2) + base64 (0.2.0) + bigdecimal (3.1.8) + concurrent-ruby (1.3.1) + connection_pool (2.4.1) + drb (2.2.1) + i18n (1.14.5) + concurrent-ruby (~> 1.0) + json (2.7.2) + language_server-protocol (3.17.0.3) + lint_roller (1.1.0) + minitest (5.23.1) + mutex_m (0.2.0) + parallel (1.24.0) + parser (3.3.2.0) + ast (~> 2.4.1) + racc + racc (1.8.0) + rainbow (3.1.1) + rake (13.2.1) + regexp_parser (2.9.2) + rexml (3.2.8) + strscan (>= 3.0.9) + rubocop (1.63.5) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.31.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.31.3) + parser (>= 3.3.1.0) + rubocop-performance (1.21.0) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + ruby-progressbar (1.13.0) + standard (1.36.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.63.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.4) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.4.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.21.0) + strscan (3.1.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (2.5.0) + +PLATFORMS + arm64-darwin-23 + ruby + +DEPENDENCIES + minitest (~> 5.16) + rake (~> 13.0) + standard (~> 1.3) + state_machine_enum! + +BUNDLED WITH + 2.5.11 diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..4ac45c9 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 Stanislav Katkov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d84422 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# StateMachineEnum + +This concern adds a method called "state_machine_enum" useful for defining an enum using string values along with valid state transitions. Validations will be added for the state transitions and a proper enum is going to be defined. For example: + +```ruby +state_machine_enum :state do |states| + states.permit_transition(:created, :approved_pending_settlement) + states.permit_transition(:approved_pending_settlement, :rejected) + states.permit_transition(:created, :rejected) + states.permit_transition(:approved_pending_settlement, :settled) +end +``` + +## Installation + +Install the gem and add to the application's Gemfile by executing: + + $ bundle add state_machine_enum + +If bundler is not being used to manage dependencies, install the gem by executing: + + $ gem install state_machine_enum + +## Usage + +StateMachineEnum needs to be extended and then it could be used, as example in AR model. + +``` +class User < ApplicationRecord + include StateMachineEnum + + state_machine_enum :state do |s| + s.permit_transition(:registered, :active) + s.permit_transition(:active, :banned) + s.permit_transition(:banned, :active) + s.permit_transition(:active, :deleted) + end +end +``` + +And then it will offer bunch of convenient methods and callbacks that ensure proper state transitions. + +``` +user = User.new(state: 'registered') +user.active? +user.registered! # throws InvalidState error, because state can not transition to "registered". +``` +## Development + +After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. + +To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/cheddar-me/state_machine_enum. + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..f32601d --- /dev/null +++ b/Rakefile @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "minitest/test_task" + +Minitest::TestTask.create + +require "standard/rake" + +task default: %i[test standard] diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..278edb6 --- /dev/null +++ b/bin/console @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "state_machine_enum" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +require "irb" +IRB.start(__FILE__) diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/lib/state_machine_enum.rb b/lib/state_machine_enum.rb new file mode 100644 index 0000000..88e0e20 --- /dev/null +++ b/lib/state_machine_enum.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require_relative "state_machine_enum/version" +require "active_support/concern" + +# This concern adds a method called "state_machine_enum" useful for defining an enum using +# string values along with valid state transitions. Validations will be added for the +# state transitions and a proper enum is going to be defined. For example: +# +# state_machine_enum :state do |states| +# states.permit_transition(:created, :approved_pending_settlement) +# states.permit_transition(:approved_pending_settlement, :rejected) +# states.permit_transition(:created, :rejected) +# states.permit_transition(:approved_pending_settlement, :settled) +# end + +module StateMachineEnum + extend ActiveSupport::Concern + + class StatesCollector + attr_reader :states + attr_reader :after_commit_hooks + attr_reader :common_after_commit_hooks + attr_reader :after_attribute_write_hooks + attr_reader :common_after_write_hooks + + def initialize + @transitions = Set.new + @states = Set.new + @after_commit_hooks = {} + @common_after_commit_hooks = [] + @after_attribute_write_hooks = {} + @common_after_write_hooks = [] + end + + def permit_transition(from, to) + @states << from.to_s << to.to_s + @transitions << [from.to_s, to.to_s] + end + + def may_transition?(from, to) + @transitions.include?([from.to_s, to.to_s]) + end + + def after_inline_transition_to(target_state, &blk) + @after_attribute_write_hooks[target_state.to_s] ||= [] + @after_attribute_write_hooks[target_state.to_s] << blk.to_proc + end + + def after_committed_transition_to(target_state, &blk) + @after_commit_hooks[target_state.to_s] ||= [] + @after_commit_hooks[target_state.to_s] << blk.to_proc + end + + def after_any_committed_transition(&blk) + @common_after_commit_hooks << blk.to_proc + end + + def validate(model, attribute_name) + return unless model.persisted? + + was = model.attribute_was(attribute_name) + is = model[attribute_name] + + unless was == is || @transitions.include?([was, is]) + model.errors.add(attribute_name, "Invalid transition from #{was} to #{is}") + end + end + end + + class InvalidState < StandardError + end + + class_methods do + def state_machine_enum(attribute_name, **options_for_enum) + # Collect the states + collector = StatesCollector.new + yield(collector).tap do + # Define the enum using labels, with string values + enum_map = collector.states.map(&:to_sym).zip(collector.states.to_a).to_h + enum(attribute_name, enum_map, **options_for_enum) + + # Define validations for transitions + validates attribute_name, presence: true + validate { |model| collector.validate(model, attribute_name) } + + # Define inline hooks + before_save do |model| + _value_was, value_has_become = model.changes[attribute_name] + next unless value_has_become + hook_procs = collector.after_attribute_write_hooks[value_has_become].to_a + collector.common_after_write_hooks.to_a + hook_procs.each do |hook_proc| + hook_proc.call(model) + end + end + + # Define after commit hooks + after_commit do |model| + _value_was, value_has_become = model.previous_changes[attribute_name] + next unless value_has_become + hook_procs = collector.after_commit_hooks[value_has_become].to_a + collector.common_after_commit_hooks.to_a + hook_procs.each do |hook_proc| + hook_proc.call(model) + end + end + + # Define the check methods + define_method(:"ensure_#{attribute_name}_one_of!") do |*allowed_states| + val = self[attribute_name] + return if Set.new(allowed_states.map(&:to_s)).include?(val) + raise InvalidState, "#{attribute_name} must be one of #{allowed_states.inspect} but was #{val.inspect}" + end + + define_method(:"ensure_#{attribute_name}_may_transition_to!") do |next_state| + val = self[attribute_name] + raise InvalidState, "#{attribute_name} already is #{val.inspect}" if next_state.to_s == val + end + + define_method(:"#{attribute_name}_may_transition_to?") do |next_state| + val = self[attribute_name] + return false if val == next_state.to_s + collector.may_transition?(val, next_state) + end + end + end + end +end diff --git a/lib/state_machine_enum/version.rb b/lib/state_machine_enum/version.rb new file mode 100644 index 0000000..49ab2c2 --- /dev/null +++ b/lib/state_machine_enum/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module StateMachineEnum + VERSION = "0.1.0" +end diff --git a/sig/state_machine_enum.rbs b/sig/state_machine_enum.rbs new file mode 100644 index 0000000..6a3c77b --- /dev/null +++ b/sig/state_machine_enum.rbs @@ -0,0 +1,4 @@ +module StateMachineEnum + VERSION: String + # See the writing guide of rbs: https://github.com/ruby/rbs#guides +end diff --git a/state_machine_enum.gemspec b/state_machine_enum.gemspec new file mode 100644 index 0000000..c59e4f9 --- /dev/null +++ b/state_machine_enum.gemspec @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require_relative "lib/state_machine_enum/version" + +Gem::Specification.new do |spec| + spec.name = "state_machine_enum" + spec.version = StateMachineEnum::VERSION + spec.authors = ["Stanislav Katkov"] + spec.email = ["skatkov@cheddar.me"] + + spec.summary = "Define possible state transitions for a field" + spec.description = "Concern that makes it easy to define and enforce possibe state transitions for a field/object" + spec.homepage = "https://cheddar.me" + spec.license = "MIT" + spec.required_ruby_version = ">= 3.0.0" + + # spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "https://github.com/cheddar-me/state_machine_enum" + #spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + gemspec = File.basename(__FILE__) + spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| + ls.readlines("\x0", chomp: true).reject do |f| + (f == gemspec) || + f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile]) + end + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + spec.add_dependency "activesupport", ">= 6.0" +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..18a5dd3 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "state_machine_enum" + +require "minitest/autorun" diff --git a/test/test_state_machine_enum.rb b/test/test_state_machine_enum.rb new file mode 100644 index 0000000..c5c1dc2 --- /dev/null +++ b/test/test_state_machine_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "test_helper" + +class TestStateMachineEnum < Minitest::Test + def test_that_it_has_a_version_number + refute_nil ::StateMachineEnum::VERSION + end + + def test_it_does_something_useful + assert false + end +end