Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add prefix functionality to scopes to facilitate multiple state machines on one model. #14

Merged
merged 12 commits into from
Jun 26, 2024
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
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
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'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you'll need to re-run bundle install and bundle exec appraisal install (and bundle exec rubocop -A if appraisal introduces linter errors, lol), and then commit those changes. That should be the last piece of this that will unblock your build.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I started on this yesterday and got sidetracked - should be incoming!

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
Loading