police_state 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 2cb2c1110ee730d95c0736792fe123449bb87b02
4
+ data.tar.gz: 7a757181a7fb2ebf15f4d98930c56c6249f3a238
5
+ SHA512:
6
+ metadata.gz: 2a14b69fa95a25bb675c66d4bb2b9fc1a0ad6caa310343eefc1d04a3fac46df5229894a88721378d9e4dcc218d916ab7d676ac21591613b92e5179c121708ddb
7
+ data.tar.gz: fa2f01744990415a13690664aef9d4cd652b5c03c94291a94fab1da7af7e560f284b111f045e36c7a5f7784320486dfa43c1ccb31008853568e7b12a1ed12444
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2017 Ian Purvis
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,147 @@
1
+ [![Build Status](https://travis-ci.org/ianpurvis/police_state.svg?branch=master)](https://travis-ci.org/ianpurvis/police_state)
2
+ [![Doc Status](http://inch-ci.org/github/ianpurvis/police_state.svg?branch=master)](http://inch-ci.org/github/ianpurvis/police_state)
3
+
4
+ # Police State
5
+ Lightweight state machine for Active Record and Active Model.
6
+
7
+
8
+ ## Background
9
+ After experimenting with state machines in a recent project, I became interested in a workflow that felt more natural for rails. In particular, I wanted to reduce architectural overlap incurred by flow control, guard, and callback workflows.
10
+
11
+ The goal of Police State is to let you easily work with state machines based on `ActiveModel::Dirty`, `ActiveModel::Validation`, and `ActiveModel::Callbacks`
12
+
13
+
14
+ ## Usage
15
+ Police State revolves around the use of `TransitionValidator` and two helper methods, `attribute_transitioning?` and `attribute_transitioned?`.
16
+
17
+ To get started, just include `PoliceState` in your model and define a set of valid transitions:
18
+
19
+ ```ruby
20
+ class Model < ApplicationRecord
21
+ include PoliceState
22
+
23
+ enum status: {
24
+ queued: 0,
25
+ active: 1,
26
+ complete: 2,
27
+ failed: 3
28
+ }
29
+
30
+ validates :status, transition: { from: nil, to: :queued }
31
+ validates :status, transition: { from: :queued, to: :active }
32
+ validates :status, transition: { from: :active, to: :complete }
33
+ validates :status, transition: { from: [:queued, :active], to: :failed }
34
+ end
35
+ ```
36
+
37
+ ### Committing a Transition
38
+ One aspect of Police State that will feel different than other ruby state machines is the idea that in-memory state has not fully transitioned until it is persisted to the database. This lets you operate within a traditional Active Record workflow:
39
+
40
+ ```ruby
41
+ model = Model.new(status: :complete)
42
+ # => #<Model:0x007fa94844d088 @status=:complete>
43
+
44
+ model.status_transitioning?(from: nil)
45
+ # => true
46
+
47
+ model.status_transitioning?(to: :complete)
48
+ # => true
49
+
50
+ model.valid?
51
+ # => false
52
+
53
+ model.errors.to_hash
54
+ # => {:status=>["can't transition to complete"]}
55
+
56
+ model.save
57
+ # => false
58
+
59
+ model.save!
60
+ # => ActiveRecord::RecordInvalid: Validation failed: Status can't transition to complete
61
+
62
+ model.status = :queued
63
+ # => :queued
64
+
65
+ model.valid?
66
+ # => true
67
+
68
+ model.save
69
+ # => true
70
+
71
+ model.status_transitioned?(from: nil, to: :queued)
72
+ # => true
73
+
74
+ ```
75
+
76
+
77
+ ### Guard Conditions
78
+ Guard conditions can be introduced for a state by adding a conditional ActiveRecord validation:
79
+
80
+ ```ruby
81
+ validates :another_field, :presence, if: -> { queued? }
82
+ ```
83
+
84
+ ### Callbacks
85
+
86
+ Callbacks can be attached to specific transitions by adding a condition on `attribute_transitioned?`. If the callback needs to occur before persistence, `attribute_transitioning?` can also be used.
87
+
88
+ ```ruby
89
+ after_commit :notify, if: -> { status_transitioned?(to: :complete) }
90
+ after_commit :alert, if: -> { status_transitioned?(from: :active, to: :failed) }
91
+ after_commit :log, if: -> { status_transitioned? }
92
+ ```
93
+
94
+ ### Events
95
+ Explicit event languge can be added to models by wrapping `update` and / or `update!`
96
+
97
+ ```ruby
98
+ def run
99
+ update(status: :active)
100
+ end
101
+
102
+ def run!
103
+ update!(status: :active)
104
+ end
105
+ ```
106
+
107
+ The bang methods defined by `ActiveRecord::Enum` work as well:
108
+
109
+ ```ruby
110
+ model.active!
111
+ # => ActiveRecord::RecordInvalid: Validation failed: Status can't transition to active
112
+ ```
113
+
114
+ ### Validation Logic
115
+ One important note about `TransitionValidator` is that it performs a unidirectional validation. For example, the following ensures that the `active` state can only be reached from the `queued` state:
116
+
117
+ ```ruby
118
+ validates :status, transition: { from: :queued, to: :active }
119
+ ```
120
+
121
+ However, this does not prevent `queued` from transitioning to other states. Those states must be controlled by their own validators.
122
+
123
+
124
+ ### Active Model
125
+ If you are using Active Model, make sure your class correctly implements `ActiveModel::Dirty`. For an example, check out [spec/test_model.rb](spec/test_model.rb)
126
+
127
+
128
+ ## Installation
129
+ Add this line to your application's Gemfile:
130
+
131
+ ```ruby
132
+ gem 'police_state'
133
+ ```
134
+
135
+ And then execute:
136
+ ```bash
137
+ $ bundle
138
+ ```
139
+
140
+ Or install it yourself as:
141
+ ```bash
142
+ $ gem install police_state
143
+ ```
144
+
145
+
146
+ ## License
147
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,26 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'PoliceState'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.md')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+ require 'bundler/gem_tasks'
18
+
19
+ begin
20
+ require 'rspec/core/rake_task'
21
+ RSpec::Core::RakeTask.new(:spec)
22
+ task default: :spec
23
+ rescue LoadError
24
+ puts 'Could not load RSpec'
25
+ end
26
+
@@ -0,0 +1,38 @@
1
+ #--
2
+ # Copyright (c) 2017 Ian Purvis
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining
5
+ # a copy of this software and associated documentation files (the
6
+ # "Software"), to deal in the Software without restriction, including
7
+ # without limitation the rights to use, copy, modify, merge, publish,
8
+ # distribute, sublicense, and/or sell copies of the Software, and to
9
+ # permit persons to whom the Software is furnished to do so, subject to
10
+ # the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be
13
+ # included in all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
19
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
20
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
21
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22
+ #++
23
+
24
+ require "active_model"
25
+ require "active_support/core_ext/array/wrap.rb"
26
+ require "active_support/core_ext/hash/slice.rb"
27
+ require "police_state/transition_helpers"
28
+ require "police_state/transition_validator"
29
+ require "police_state/validation_helpers"
30
+
31
+ module PoliceState
32
+ extend ActiveSupport::Concern
33
+
34
+ included do
35
+ include PoliceState::TransitionHelpers
36
+ extend PoliceState::ValidationHelpers
37
+ end
38
+ end
@@ -0,0 +1,71 @@
1
+ module PoliceState
2
+
3
+ # == Police State Transition Helpers
4
+ #
5
+ # Provides a way to monitor attribute state transitions for Active Record and Active Model objects.
6
+ #
7
+ # Note: If using with Active Model, make sure that your class implements +ActiveModel::Dirty+.
8
+ module TransitionHelpers
9
+ extend ActiveSupport::Concern
10
+ include ActiveModel::AttributeMethods
11
+
12
+ # :stopdoc:
13
+ included do
14
+ raise ArgumentError, "Including class must implement ActiveModel::Dirty" unless include?(ActiveModel::Dirty)
15
+ attribute_method_suffix "_transitioned?", "_transitioning?"
16
+ end
17
+ # :startdoc:
18
+
19
+ # Returns +true+ if +attribute+ transitioned at the last save event, otherwise +false+.
20
+ #
21
+ # You can specify origin and destination states using the +:from+ and +:to+
22
+ # options. If +attribute+ is an +ActiveRecord::Enum+, these may be
23
+ # specified as either symbol or their native enum value.
24
+ #
25
+ # model = Model.create!(status: :complete)
26
+ # # => #<Model:0x007fa94844d088 @status=:complete>
27
+ #
28
+ # model.status_transitioned? # => true
29
+ # model.status_transitioned?(from: nil) # => true
30
+ # model.status_transitioned?(to: :complete) # => true
31
+ # model.status_transitioned?(to: "complete") # => true
32
+ # model.status_transitioned?(from: nil, to: :complete) # => true
33
+ def attribute_transitioned?(attr, options={})
34
+ options = _transform_options_for_attribute(attr, options)
35
+ !!previous_changes_include?(attr) &&
36
+ (!options.include?(:to) || options[:to] == previous_changes[attr].last) &&
37
+ (!options.include?(:from) || options[:from] == previous_changes[attr].first)
38
+ end
39
+
40
+ # Returns +true+ if +attribute+ is currently transitioning but not saved, otherwise +false+.
41
+ #
42
+ # You can specify origin and destination states using the +:from+ and +:to+
43
+ # options. If +attribute+ is an +ActiveRecord::Enum+, these may be
44
+ # specified as either symbol or their native enum value.
45
+ #
46
+ # model = Model.new(status: :complete)
47
+ # # => #<Model:0x007fa94844d088 @status=:complete>
48
+ #
49
+ # model.status_transitioning? # => true
50
+ # model.status_transitioning?(from: nil) # => true
51
+ # model.status_transitioning?(to: :complete) # => true
52
+ # model.status_transitioning?(to: "complete") # => true
53
+ # model.status_transitioning?(from: nil, to: :complete) # => true
54
+ def attribute_transitioning?(attr, options={})
55
+ options = _transform_options_for_attribute(attr, options)
56
+ attribute_changed?(attr, options)
57
+ end
58
+
59
+
60
+ private
61
+
62
+ # Facilitates easier change checking for ActiveRecord::Enum attributes
63
+ # by casting any symbolized :to and :from values into their native strings.
64
+ def _transform_options_for_attribute(attr, options={})
65
+ return options unless self.class.respond_to?(:attribute_types)
66
+ options.transform_values {|value|
67
+ self.class.attribute_types.with_indifferent_access[attr].cast(value)
68
+ }
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,32 @@
1
+ module PoliceState
2
+ class TransitionValidator < ActiveModel::EachValidator # :nodoc:
3
+
4
+ def check_validity!
5
+ raise ArgumentError, "Options must include :from" unless options.include?(:from)
6
+ raise ArgumentError, "Options must include :to" unless options.include?(:to)
7
+ raise ArgumentError, "Options cannot specify array for :to" if options[:to].is_a?(Array)
8
+ end
9
+
10
+ def validate_each(record, attr_name, value)
11
+ unless transition_allowed?(record, attr_name)
12
+ record.errors.add(attr_name, "can't transition to #{value}", options)
13
+ end
14
+ end
15
+
16
+
17
+ private
18
+
19
+ def destination
20
+ options[:to]
21
+ end
22
+
23
+ def origins
24
+ options[:from].instance_eval {nil? ? [nil] : Array.wrap(self)}
25
+ end
26
+
27
+ def transition_allowed?(record, attr_name)
28
+ !record.attribute_transitioning?(attr_name, to: destination) ||
29
+ origins.any? {|origin| record.attribute_transitioning?(attr_name, from: origin)}
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,34 @@
1
+ module PoliceState
2
+
3
+ # == Police State Validation Helpers
4
+ module ValidationHelpers
5
+
6
+ # Validates that the specified attribute is transitioning to a
7
+ # destination state from one or more origin states.
8
+ #
9
+ # class Model < ActiveRecord::Base
10
+ # validates_transition_of :status, from: nil, to: :queued
11
+ # validates_transition_of :status, from: :queued, to: :active
12
+ # validates_transition_of :status, from: :active, to: :complete
13
+ # validates_transition_of :status, from: [:queued, :active], to: :failed
14
+ # end
15
+ #
16
+ # Note: This check is only performed if the attribute is transitioning to the
17
+ # destination state.
18
+ #
19
+ # You can specify +nil+ states for nullable attributes.
20
+ #
21
+ # Configuration options:
22
+ #
23
+ # * <tt>:from</tt> - The origin state(s) of the attribute. This can be supplied as a
24
+ # single value or an array.
25
+ # * <tt>:to</tt> - The destination state of the attribute.
26
+ #
27
+ # There is also a list of default options supported by every validator:
28
+ # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+.
29
+ # See <tt>ActiveModel::Validation#validates</tt> for more information
30
+ def validates_transition_of(*attr_names)
31
+ validates_with PoliceState::TransitionValidator, _merge_attributes(attr_names)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,3 @@
1
+ module PoliceState
2
+ VERSION = '0.3.0' # :nodoc:
3
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :police_state do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,95 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: police_state
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Ian Purvis
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-09-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 5.0.2
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 5.0.2
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec-rails
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: sqlite3
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description:
56
+ email:
57
+ - ian@purvisresearch.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - MIT-LICENSE
63
+ - README.md
64
+ - Rakefile
65
+ - lib/police_state.rb
66
+ - lib/police_state/transition_helpers.rb
67
+ - lib/police_state/transition_validator.rb
68
+ - lib/police_state/validation_helpers.rb
69
+ - lib/police_state/version.rb
70
+ - lib/tasks/police_state_tasks.rake
71
+ homepage:
72
+ licenses:
73
+ - MIT
74
+ metadata: {}
75
+ post_install_message:
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubyforge_project:
91
+ rubygems_version: 2.6.13
92
+ signing_key:
93
+ specification_version: 4
94
+ summary: Lightweight state machine for Active Record and Active Model.
95
+ test_files: []