Skip to content

Commit

Permalink
Add prefix functionality to scopes to facilitate multiple state machi…
Browse files Browse the repository at this point in the history
…nes on one model. (#14)

When using multiple state machines on a single model, one might want more control over their scopes.  In order to allow that, this PR adds handling a hash with some prefix options that works similar to how delegate in rails works.  You can pass in `scopes: { prefix: true }` to get your scope prefixed with your attribute or `scopes: { prefix: 'some_custom_prefix' }` to get your scope prefixed with a custom prefix.

This PR also adds associated tests and updates the Readme.
  • Loading branch information
nicholalexander authored Jun 26, 2024
1 parent 8446f53 commit 5340b91
Show file tree
Hide file tree
Showing 8 changed files with 135 additions and 50 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ and this project aims to adhere to [Semantic Versioning](http://semver.org/spec/
### Removed <!-- for now removed features. -->
### Fixed <!-- for any bug fixes. -->

## [1.2.0] - 2024-06-14
### Added
- Added ability for scopes to use a named prefix, which will be useful when
dealing with multiple state machines on one object.

## [1.1.0] - 2023-12-29
### Added
- Added testing support for Ruby 3.2.
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
steady_state (1.1.0)
steady_state (1.2.0)
activemodel (>= 5.2)
activesupport (>= 5.2)

Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,22 @@ steady_state :step, scopes: false do
end
```

`steady_state` also follows the same `prefix` api as `delegate` in Rails. You may optionally define your scopes to be prefixed to the name of the state machine with `prefix: true`, or you may provide a custom prefix with `prefix: :some_custom_name`. This may be useful when dealing with multiple state machines on one object.

```ruby
steady_state :temperature, scopes: { prefix: true } do
state 'cold', default: true
end

steady_state :color_temperature, scopes: { prefix: 'color' } do
state 'cold', default: true
end

Material.solid # => query for 'solid' records
Material.temperature_cold # => query for records with a cold temperature
Material.color_cold # => query for for records with a cold color temperature
```

### Next and Previous States

The `may_become?` method can be used to see if setting the state to a particular value would be allowed (ignoring all other validations):
Expand Down Expand Up @@ -277,7 +293,7 @@ class Material
self.state = 'liquid'
valid? # will return `false` if state transition is invalid
end

def melt!
self.state = 'liquid'
validate! # will raise an exception if state transition is invalid
Expand Down
4 changes: 2 additions & 2 deletions gemfiles/rails_6_1.gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: ..
specs:
steady_state (1.1.0)
steady_state (1.2.0)
activemodel (>= 5.2)
activesupport (>= 5.2)

Expand Down Expand Up @@ -104,10 +104,10 @@ GEM
zeitwerk (2.6.15)

PLATFORMS
arm64-darwin-22
x86_64-darwin-21
x86_64-darwin-22
x86_64-linux
arm64-darwin-22

DEPENDENCIES
activemodel (~> 6.1.0)
Expand Down
4 changes: 2 additions & 2 deletions gemfiles/rails_7_0.gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: ..
specs:
steady_state (1.1.0)
steady_state (1.2.0)
activemodel (>= 5.2)
activesupport (>= 5.2)

Expand Down Expand Up @@ -102,10 +102,10 @@ GEM
unicode-display_width (2.5.0)

PLATFORMS
arm64-darwin-22
x86_64-darwin-21
x86_64-darwin-22
x86_64-linux
arm64-darwin-22

DEPENDENCIES
activemodel (~> 7.0.0)
Expand Down
13 changes: 12 additions & 1 deletion lib/steady_state/attribute.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,25 @@ def steady_state(attr_name, predicates: true, states_getter: true, scopes: Stead

delegate(*state_machines[attr_name].predicates, to: attr_name, allow_nil: true) if predicates
if scopes
scopes = {} unless scopes.is_a?(Hash)
prefix = SteadyState::Attribute.build_prefix(attr_name, **scopes)

state_machines[attr_name].states.each do |state|
scope state.to_sym, -> { where(attr_name.to_sym => state) }
scope :"#{prefix}#{state}", -> { where(attr_name.to_sym => state) }
end
end

validates :"#{attr_name}", 'steady_state/attribute/transition' => true,
inclusion: { in: state_machines[attr_name].states }
end
end

def self.build_prefix(attr_name, prefix: false)
if prefix
"#{prefix == true ? attr_name : prefix}_"
else
""
end
end
end
end
2 changes: 1 addition & 1 deletion lib/steady_state/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module SteadyState
VERSION = '1.1.0'
VERSION = '1.2.0'
end
137 changes: 95 additions & 42 deletions spec/steady_state/attribute_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -326,45 +326,62 @@ def state
end

context 'with the scopes option' do
let(:query_object) { double(where: []) } # rubocop:disable RSpec/VerifiedDoubles
context "when the scopes are properly defined" do
let(:query_object) { double(where: []) } # rubocop:disable RSpec/VerifiedDoubles

before do
options = opts
steady_state_class.module_eval do
attr_accessor :car
before do
options = opts
steady_state_class.module_eval do
attr_accessor :car

def self.defined_scopes
@defined_scopes ||= {}
end
def self.defined_scopes
@defined_scopes ||= {}
end

def self.scope(name, callable)
defined_scopes[name] ||= callable
end
def self.scope(name, callable)
defined_scopes[name] ||= callable
end

steady_state :car, **options do
state 'driving', default: true
state 'stopped', from: 'driving'
state 'parked', from: 'stopped'
steady_state :car, **options do
state 'driving', default: true
state 'stopped', from: 'driving'
state 'parked', from: 'stopped'
end
end
end
end

context 'default' do
let(:opts) { {} }
context 'default' do
let(:opts) { {} }

it 'does not define scope methods' do
expect(steady_state_class.defined_scopes.keys).to eq []
end
it 'does not define scope methods' do
expect(steady_state_class.defined_scopes.keys).to eq []
end

context 'on an ActiveRecord' do
let(:steady_state_class) do
stub_const('ActiveRecord::Base', Class.new)

Class.new(ActiveRecord::Base) do
include ActiveModel::Model
include SteadyState
end
end

context 'on an ActiveRecord' do
let(:steady_state_class) do
stub_const('ActiveRecord::Base', Class.new)
it 'defines a scope for each state' do
expect(steady_state_class.defined_scopes.keys).to eq %i(driving stopped parked)

Class.new(ActiveRecord::Base) do
include ActiveModel::Model
include SteadyState
expect(query_object).to receive(:where).with(car: 'driving')
query_object.instance_exec(&steady_state_class.defined_scopes[:driving])
expect(query_object).to receive(:where).with(car: 'stopped')
query_object.instance_exec(&steady_state_class.defined_scopes[:stopped])
expect(query_object).to receive(:where).with(car: 'parked')
query_object.instance_exec(&steady_state_class.defined_scopes[:parked])
end
end
end

context 'enabled' do
let(:opts) { { scopes: true } }

it 'defines a scope for each state' do
expect(steady_state_class.defined_scopes.keys).to eq %i(driving stopped parked)
Expand All @@ -377,28 +394,64 @@ def self.scope(name, callable)
query_object.instance_exec(&steady_state_class.defined_scopes[:parked])
end
end
end

context 'enabled' do
let(:opts) { { scopes: true } }
context 'enabled with prefix: true' do
let(:opts) { { scopes: { prefix: true } } }

it 'defines a scope for each state, prefixed with the name of the state machine' do
expect(steady_state_class.defined_scopes.keys).to eq %i(car_driving car_stopped car_parked)

expect(query_object).to receive(:where).with(car: 'driving')
query_object.instance_exec(&steady_state_class.defined_scopes[:car_driving])
expect(query_object).to receive(:where).with(car: 'stopped')
query_object.instance_exec(&steady_state_class.defined_scopes[:car_stopped])
expect(query_object).to receive(:where).with(car: 'parked')
query_object.instance_exec(&steady_state_class.defined_scopes[:car_parked])
end
end

context 'enabled with a custom prefix such as prefix: :automobile' do
let(:opts) { { scopes: { prefix: :automobile } } }

it 'defines a scope for each state with the custom prefix' do
expect(steady_state_class.defined_scopes.keys).to eq %i(automobile_driving automobile_stopped automobile_parked)

expect(query_object).to receive(:where).with(car: 'driving')
query_object.instance_exec(&steady_state_class.defined_scopes[:automobile_driving])
expect(query_object).to receive(:where).with(car: 'stopped')
query_object.instance_exec(&steady_state_class.defined_scopes[:automobile_stopped])
expect(query_object).to receive(:where).with(car: 'parked')
query_object.instance_exec(&steady_state_class.defined_scopes[:automobile_parked])
end
end

it 'defines a scope for each state' do
expect(steady_state_class.defined_scopes.keys).to eq %i(driving stopped parked)
context 'disabled' do
let(:opts) { { scopes: false } }

expect(query_object).to receive(:where).with(car: 'driving')
query_object.instance_exec(&steady_state_class.defined_scopes[:driving])
expect(query_object).to receive(:where).with(car: 'stopped')
query_object.instance_exec(&steady_state_class.defined_scopes[:stopped])
expect(query_object).to receive(:where).with(car: 'parked')
query_object.instance_exec(&steady_state_class.defined_scopes[:parked])
it 'does not define scope methods' do
expect(steady_state_class.defined_scopes.keys).to eq []
end
end
end

context 'disabled' do
let(:opts) { { scopes: false } }
context "when the scopes are not properly defined" do
let(:opts) { { scopes: { prexxfixx: :typo } } }

let(:evaluated_steady_state_class) do
options = opts
steady_state_class.module_eval do
attr_accessor :car

def self.scope(*); end

steady_state :car, **options do
state 'driving', default: true
end
end
end

it 'does not define scope methods' do
expect(steady_state_class.defined_scopes.keys).to eq []
it 'raises an error' do
expect { evaluated_steady_state_class }.to raise_error(ArgumentError, /unknown keyword: :prexxfixx/)
end
end
end
Expand Down

0 comments on commit 5340b91

Please sign in to comment.