diff --git a/Gemfile.lock b/Gemfile.lock index 302d361..101aceb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,7 +8,7 @@ GIT GIT remote: https://github.com/corp-gp/rubocop-gp.git - revision: 61d6ca243e1a5f7d209e9619417c8fc1d303d181 + revision: 867f7e1351c3730897cacdc20ce2d727604de245 specs: rubocop-gp (0.0.4) rubocop @@ -73,20 +73,20 @@ GEM zeitwerk (~> 2.6) i18n (1.14.6) concurrent-ruby (~> 1.0) - json (2.7.2) + json (2.8.2) language_server-protocol (3.17.0.3) logger (1.6.1) method_source (1.1.0) - minitest (5.25.1) + minitest (5.25.2) parallel (1.26.3) - parser (3.3.5.0) + parser (3.3.6.0) ast (~> 2.4.1) racc pry (0.14.2) coderay (~> 1.1) method_source (~> 1.0) racc (1.8.1) - rack (3.1.7) + rack (3.1.8) rainbow (3.1.1) rake (13.2.1) regexp_parser (2.9.2) @@ -103,49 +103,52 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.1) - rubocop (1.66.1) + rubocop (1.69.0) 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 (>= 2.4, < 3.0) - rubocop-ast (>= 1.32.2, < 2.0) + rubocop-ast (>= 1.36.1, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.32.3) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.36.1) parser (>= 3.3.1.0) rubocop-capybara (2.21.0) rubocop (~> 1.41) rubocop-factory_bot (2.26.1) rubocop (~> 1.61) - rubocop-performance (1.22.1) + rubocop-performance (1.23.0) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.26.2) + rubocop-rails (2.27.0) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.52.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rspec (3.0.5) + rubocop-rspec (3.2.0) rubocop (~> 1.61) rubocop-rspec_rails (2.30.0) rubocop (~> 1.61) rubocop-rspec (~> 3, >= 3.0.1) ruby-progressbar (1.13.0) rumoji (0.5.0) - securerandom (0.3.1) + securerandom (0.3.2) sqlite3 (1.7.3-arm64-darwin) sqlite3 (1.7.3-x86_64-darwin) sqlite3 (1.7.3-x86_64-linux) timeout (0.4.1) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (2.6.0) + unicode-display_width (3.1.2) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) zeitwerk (2.6.18) PLATFORMS arm64-darwin-21 + arm64-darwin-23 x86_64-darwin-21 x86_64-linux diff --git a/README.md b/README.md index 1af78a1..38583ad 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Enum machine +# Enum Machine -Enum machine is a library for defining enums and setting state machines for attributes in ActiveRecord models and plain Ruby classes. +`Enum Machine` is a library for defining enums and setting state machines for attributes in ActiveRecord models and plain Ruby classes. You can visualize transitions map with [enum_machine-contrib](https://github.com/corp-gp/enum_machine-contrib) @@ -29,7 +29,7 @@ end order = Order.create(state: "collecting") order.update(state: "archived") # not check transitions, invalid logic -order.update(state: "collected") # not run callbacks +order.update(state: "collected") # not run callbacks order.complete # need use event for transition, but your object in UI and DB have only states # enum_machine @@ -66,7 +66,7 @@ end class Product # attributes must be defined before including the EnumMachine module attr_accessor :color - + include EnumMachine[color: { enum: %w[red green] }] # or reuse from model Product::COLOR.decorator_module @@ -76,6 +76,9 @@ Product::COLOR.values # => ["red", "green"] Product::COLOR::RED # => "red" Product::COLOR::RED__GREEN # => ["red", "green"] +Product::COLOR["red"].red? # => true +Product::COLOR["red"].human_name # => "Красный" + product = Product.new product.color # => nil product.color = "red" @@ -100,6 +103,35 @@ product = Product.new(state: "created") product.state.forming? # => true ``` +### Value decorator + +You can extend value object with decorator + +```ruby +# Value classes nested from base class +module ColorDecorator + def hex + case self + when Product::COLOR::RED then "#ff0000" + when Product::COLOR::GREEN then "#00ff00" + end + end +end + +class Product + attr_accessor :color + + include EnumMachine[color: { + enum: %w[red green], + decorator: ColorDecorator + }] +end + +product = Product.new +product.color = "red" +product.color.hex # => "#ff0000" +``` + ### Transitions ```ruby diff --git a/lib/enum_machine.rb b/lib/enum_machine.rb index 63c32c7..a5db28c 100644 --- a/lib/enum_machine.rb +++ b/lib/enum_machine.rb @@ -9,11 +9,9 @@ require "active_support" module EnumMachine - class Error < StandardError; end class InvalidTransition < Error - attr_reader :from, :to, :enum_const def initialize(machine, from, to) @@ -26,13 +24,11 @@ def initialize(machine, from, to) end super("Transition #{from.inspect} => #{to.inspect} not defined in enum_machine :#{machine.attr_name}") end - end def self.[](args) DriverSimpleClass.call(args) end - end ActiveSupport.on_load(:active_record) do diff --git a/lib/enum_machine/attribute_persistence_methods.rb b/lib/enum_machine/attribute_persistence_methods.rb index e845ba7..8d1bfce 100644 --- a/lib/enum_machine/attribute_persistence_methods.rb +++ b/lib/enum_machine/attribute_persistence_methods.rb @@ -2,16 +2,11 @@ module EnumMachine module AttributePersistenceMethods - def self.[](attr, enum_values) Module.new do define_singleton_method(:extended) do |klass| klass.attr_accessor :parent - klass.define_method(:inspect) do - "#" - end - enum_values.each do |enum_value| enum_name = enum_value.underscore @@ -28,6 +23,5 @@ def to_#{enum_name}! end end end - end end diff --git a/lib/enum_machine/build_attribute.rb b/lib/enum_machine/build_attribute.rb index 539b0bc..e810a69 100644 --- a/lib/enum_machine/build_attribute.rb +++ b/lib/enum_machine/build_attribute.rb @@ -2,15 +2,16 @@ module EnumMachine module BuildAttribute - - def self.call(enum_values:, i18n_scope:, machine: nil) + def self.call(enum_values:, i18n_scope:, decorator:, machine: nil) aliases = machine&.instance_variable_get(:@aliases) || {} Class.new(String) do + include(decorator) if decorator + define_method(:machine) { machine } if machine def inspect - "#" + "#" end if machine&.transitions? @@ -74,6 +75,5 @@ def human_name end end end - end end diff --git a/lib/enum_machine/build_class.rb b/lib/enum_machine/build_class.rb index b3b8129..fc2667a 100644 --- a/lib/enum_machine/build_class.rb +++ b/lib/enum_machine/build_class.rb @@ -2,13 +2,20 @@ module EnumMachine module BuildClass - - def self.call(enum_values:, i18n_scope:, machine: nil) + def self.call(enum_values:, i18n_scope:, value_class:, machine: nil) aliases = machine&.instance_variable_get(:@aliases) || {} Class.new do define_singleton_method(:machine) { machine } if machine - define_singleton_method(:values) { enum_values } + define_singleton_method(:values) { enum_values.map { value_class.new(_1).freeze } } + + value_attribute_mapping = values.to_h { [_1.to_s, _1] } + define_singleton_method(:value_attribute_mapping) { value_attribute_mapping } + define_singleton_method(:[]) do |enum_value| + key = enum_value.to_s + # Check for key existence because `[]` will call `default_proc`, and we don’t want that + value_attribute_mapping[key] if value_attribute_mapping.key?(key) + end if i18n_scope def self.values_for_form(specific_values = nil) # rubocop:disable Gp/OptArgParameters @@ -27,7 +34,7 @@ def self.human_name_for(name) end enum_values.each do |enum_value| - const_set enum_value.underscore.upcase, enum_value.freeze + const_set enum_value.underscore.upcase, enum_value.to_s.freeze end aliases.each_key do |key| @@ -50,6 +57,5 @@ def self.#{key} end end end - end end diff --git a/lib/enum_machine/driver_active_record.rb b/lib/enum_machine/driver_active_record.rb index 3de63d0..5368db3 100644 --- a/lib/enum_machine/driver_active_record.rb +++ b/lib/enum_machine/driver_active_record.rb @@ -2,8 +2,7 @@ module EnumMachine module DriverActiveRecord - - def enum_machine(attr, enum_values, i18n_scope: nil, &block) + def enum_machine(attr, enum_values, i18n_scope: nil, decorator: nil, &block) klass = self i18n_scope ||= "#{klass.base_class.to_s.underscore}.#{attr}" @@ -12,16 +11,16 @@ def enum_machine(attr, enum_values, i18n_scope: nil, &block) machine = Machine.new(enum_values, klass, enum_const_name, attr) machine.instance_eval(&block) if block - enum_klass = BuildClass.call(enum_values: enum_values, i18n_scope: i18n_scope, machine: machine) - - enum_value_klass = BuildAttribute.call(enum_values: enum_values, i18n_scope: i18n_scope, machine: machine) - enum_value_klass.extend(AttributePersistenceMethods[attr, enum_values]) + value_class = BuildAttribute.call(enum_values: enum_values, i18n_scope: i18n_scope, machine: machine, decorator: decorator) + enum_klass = BuildClass.call(enum_values: enum_values, i18n_scope: i18n_scope, machine: machine, value_class: value_class) - enum_klass.const_set :VALUE_KLASS, enum_value_klass + value_class.extend(AttributePersistenceMethods[attr, enum_values]) - # Hash.new with default_proc for working with custom values not defined in enum list - value_attribute_mapping = Hash.new { |hash, enum_value| hash[enum_value] = enum_klass::VALUE_KLASS.new(enum_value).freeze } - enum_klass.define_singleton_method(:value_attribute_mapping) { value_attribute_mapping } + # default_proc for working with custom values not defined in enum list but may exists in db + enum_klass.value_attribute_mapping.default_proc = + proc do |hash, enum_value| + hash[enum_value] = value_class.new(enum_value).freeze + end if machine.transitions? klass.class_eval <<-RUBY, __FILE__, __LINE__ + 1 # rubocop:disable Style/DocumentDynamicEvalDefinition @@ -114,6 +113,5 @@ def initialize_dup(other) enum_decorator end - end end diff --git a/lib/enum_machine/driver_simple_class.rb b/lib/enum_machine/driver_simple_class.rb index 2d81fd9..6a566c5 100644 --- a/lib/enum_machine/driver_simple_class.rb +++ b/lib/enum_machine/driver_simple_class.rb @@ -2,7 +2,6 @@ module EnumMachine module DriverSimpleClass - # include EnumMachine[ # state: { enum: %w[choice in_delivery], i18n_scope: 'line_item.state' }, # color: { enum: %w[red green yellow] }, @@ -14,17 +13,14 @@ def self.call(args) args.each do |attr, params| enum_values = params.fetch(:enum) i18n_scope = params.fetch(:i18n_scope, nil) + decorator = params.fetch(:decorator, nil) if defined?(ActiveRecord) && klass <= ActiveRecord::Base klass.enum_machine(attr, enum_values, i18n_scope: i18n_scope) else enum_const_name = attr.to_s.upcase - enum_klass = BuildClass.call(enum_values: enum_values, i18n_scope: i18n_scope) - - enum_value_klass = BuildAttribute.call(enum_values: enum_values, i18n_scope: i18n_scope) - enum_klass.const_set :VALUE_KLASS, enum_value_klass - - value_attribute_mapping = enum_values.to_h { |enum_value| [enum_value, enum_klass::VALUE_KLASS.new(enum_value).freeze] } + value_class = BuildAttribute.call(enum_values: enum_values, i18n_scope: i18n_scope, decorator: decorator) + enum_klass = BuildClass.call(enum_values: enum_values, i18n_scope: i18n_scope, value_class: value_class) define_methods = Module.new do @@ -32,7 +28,7 @@ def self.call(args) enum_value = super() return unless enum_value - value_attribute_mapping.fetch(enum_value) + enum_klass.value_attribute_mapping.fetch(enum_value) end end @@ -52,6 +48,5 @@ def self.call(args) end end end - end end diff --git a/lib/enum_machine/machine.rb b/lib/enum_machine/machine.rb index c7e5118..7e5dea5 100644 --- a/lib/enum_machine/machine.rb +++ b/lib/enum_machine/machine.rb @@ -2,7 +2,6 @@ module EnumMachine class Machine - attr_reader :enum_values, :base_klass, :enum_const_name, :attr_name def initialize(enum_values, base_klass = nil, enum_const_name = nil, attr_name = nil) # rubocop:disable Gp/OptArgParameters @@ -132,8 +131,6 @@ def possible_transitions(from) end class AnyEnumValues < Array - end - end end diff --git a/lib/enum_machine/version.rb b/lib/enum_machine/version.rb index 8c8bb34..171f209 100644 --- a/lib/enum_machine/version.rb +++ b/lib/enum_machine/version.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true module EnumMachine - VERSION = "1.0.0" - end diff --git a/spec/enum_machine/active_record_enum_spec.rb b/spec/enum_machine/active_record_enum_spec.rb index fd2daa8..66f8a50 100644 --- a/spec/enum_machine/active_record_enum_spec.rb +++ b/spec/enum_machine/active_record_enum_spec.rb @@ -34,11 +34,6 @@ expect { m.color.wrong? }.to raise_error(NoMethodError) end - it "pretty print inspect" do - m = model.new(state: "choice") - expect(m.state.inspect).to match(/EnumMachine:BuildAttribute.+value=choice parent=/) - end - it "test I18n" do I18n.load_path = Dir["#{File.expand_path('spec/locales')}/*.yml"] I18n.default_locale = :ru @@ -83,6 +78,34 @@ end end + context "when with decorator" do + let(:decorator_module) do + Module.new do + def am_i_choice? + self == "choice" + end + end + end + + let(:model_with_decorator) do + decorator = decorator_module + Class.new(TestModel) do + enum_machine :state, %w[choice in_delivery], decorator: decorator + end + end + + it "decorates enum value for new record" do + expect(model_with_decorator.new(state: "choice").state.am_i_choice?).to be(true) + expect(model_with_decorator.new(state: "in_delivery").state.am_i_choice?).to be(false) + end + + it "decorates enum value for existing record" do + model_with_decorator.create(state: "choice") + m = model_with_decorator.find_by(state: "choice") + expect(m.state.am_i_choice?).to be(true) + end + end + it "serialize model" do Object.const_set(:TestModelSerialize, model) m = TestModelSerialize.create(state: "choice", color: "wrong") @@ -118,4 +141,11 @@ expect(decorated_klass::STATE::CHOICE).to eq "choice" expect(decorated_klass::COLOR::RED).to eq "red" end + + it "returns state value by []" do + expect(model::STATE["in_delivery"]).to eq "in_delivery" + expect(model::STATE["in_delivery"].in_delivery?).to be(true) + expect(model::STATE["in_delivery"].choice?).to be(false) + expect(model::STATE["wrong"]).to be_nil + end end diff --git a/spec/enum_machine/driver_simple_class_spec.rb b/spec/enum_machine/driver_simple_class_spec.rb index dfd13cb..66a7253 100644 --- a/spec/enum_machine/driver_simple_class_spec.rb +++ b/spec/enum_machine/driver_simple_class_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class TestClass - attr_accessor :state def initialize(state) @@ -9,7 +8,22 @@ def initialize(state) end include EnumMachine[state: { enum: %w[choice in_delivery] }] +end +module Decorator + def am_i_choice? + self == "choice" + end +end + +class TestClassWithDecorator + attr_accessor :state + + def initialize(state) + @state = state + end + + include EnumMachine[state: { enum: %w[choice in_delivery], decorator: Decorator }] end RSpec.describe "DriverSimpleClass" do @@ -52,6 +66,33 @@ def initialize(state) end end + describe "TestClass::STATE const" do + it "#values" do + expect(TestClass::STATE.values).to eq(%w[choice in_delivery]) + end + + it "#[]" do + expect(TestClass::STATE["in_delivery"]).to eq "in_delivery" + expect(TestClass::STATE["in_delivery"].in_delivery?).to be(true) + expect(TestClass::STATE["in_delivery"].choice?).to be(false) + expect(TestClass::STATE["wrong"]).to be_nil + end + + it "#decorator_module" do + decorated_klass = + Class.new do + include TestClass::STATE.decorator_module + attr_accessor :state + end + + decorated_item = decorated_klass.new + decorated_item.state = "choice" + + expect(decorated_item.state).to be_choice + expect(decorated_klass::STATE::CHOICE).to eq "choice" + end + end + context "when definition order is changed" do let(:invert_definition_class) do Class.new do @@ -70,6 +111,24 @@ def initialize(state) end end + context "when with decorator" do + it "decorates enum values" do + expect(TestClassWithDecorator.new("choice").state.am_i_choice?).to be(true) + expect(TestClassWithDecorator.new("in_delivery").state.am_i_choice?).to be(false) + end + + it "decorates enum values in enum const" do + expect(TestClassWithDecorator::STATE.values.map(&:am_i_choice?)).to eq([true, false]) + expect((TestClassWithDecorator::STATE.values & ["in_delivery"]).map(&:am_i_choice?)).to eq([false]) + end + + it "keeps decorating on serialization" do + m = TestClassWithDecorator.new("choice") + unserialized_m = Marshal.load(Marshal.dump(m)) # rubocop:disable Gp/UnsafeYamlMarshal + expect(unserialized_m.state.am_i_choice?).to be(true) + end + end + it "serialize class" do m = TestClass.new("choice") @@ -78,18 +137,4 @@ def initialize(state) expect(unserialized_m.state).to be_choice expect(unserialized_m.class::STATE::CHOICE).to eq "choice" end - - it "test decorator" do - decorated_klass = - Class.new do - include TestClass::STATE.decorator_module - attr_accessor :state - end - - decorated_item = decorated_klass.new - decorated_item.state = "choice" - - expect(decorated_item.state).to be_choice - expect(decorated_klass::STATE::CHOICE).to eq "choice" - end end diff --git a/spec/support/test_model.rb b/spec/support/test_model.rb index c81d481..0b4a516 100644 --- a/spec/support/test_model.rb +++ b/spec/support/test_model.rb @@ -3,9 +3,7 @@ require "active_record" class TestModel < ActiveRecord::Base - def self.model_name ActiveModel::Name.new(self, nil, "test_model") end - end diff --git a/test/performance.rb b/test/performance.rb index e842f7f..8206117 100644 --- a/test/performance.rb +++ b/test/performance.rb @@ -32,7 +32,6 @@ STATES_IN_TRANSIT = %w[shipped delivered_to_office delivered_to_courier_city].freeze class OrderEnumMachine < ActiveRecord::Base - self.table_name = :orders enum_machine :state, %w[ @@ -62,11 +61,9 @@ class OrderEnumMachine < ActiveRecord::Base %w[wait_shipment shipped part_obtain obtain overdue rejection searched] | STATES_IN_TRANSIT => "lost", ) end - end class OrderAasm < ActiveRecord::Base - include AASM self.table_name = :orders @@ -175,11 +172,9 @@ class OrderAasm < ActiveRecord::Base transitions from: %i[wait_shipment shipped part_obtain obtain overdue rejection searched] | STATES_IN_TRANSIT, to: :lost end end - end class OrderStateMachines < ActiveRecord::Base - self.table_name = :orders state_machine :state, initial: nil do @@ -262,7 +257,6 @@ class OrderStateMachines < ActiveRecord::Base transition %w[wait_shipment shipped part_obtain obtain overdue rejection searched] | STATES_IN_TRANSIT => "lost" end end - end def pp_title(name, stmt)