active_state 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +141 -0
- data/Rakefile +2 -0
- data/active_state.gemspec +40 -0
- data/lib/active_state.rb +7 -0
- data/lib/active_state/base.rb +15 -0
- data/lib/active_state/model.rb +47 -0
- data/lib/active_state/state_validator.rb +16 -0
- data/lib/active_state/version.rb +3 -0
- metadata +99 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: ebbbe4c732963820345cfb88829d080694dd096be118a75d9dec49473326dedd
|
4
|
+
data.tar.gz: 7d420aa35bfb7977fa7a2562cb8e0877bc0d881a9c6296343e504a6da4bdf358
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 39961d4d6fd20f06d087acb2b9db11c10b89a50c69905ecfed4ae6e13e28e23b8a214f0804446b92113dda55b3b35249143b55129d4ddac7a6201a7f15d2b740
|
7
|
+
data.tar.gz: 95f5dfd26a34845aeea6c5ce1a5361aa2532ae896ba4e0d151ab84f71f8247c4568acdd76c372c24ddbe320e2a6f0dcf8e781c1aafb1a749d276cd393eb05b23
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2019 Matouš Vokál
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,141 @@
|
|
1
|
+
# ActiveState
|
2
|
+
|
3
|
+
A tiny gem for easily using the state design pattern with ActiveRecord models. State objects will be automatically created when the model is instantiated. States can use the `ActiveModel::Validations` DSL for creating validations, which will be run when the model is validated. Scopes for individual states can easily be created on the model.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
gem 'active_state'
|
11
|
+
```
|
12
|
+
|
13
|
+
And then execute:
|
14
|
+
|
15
|
+
$ bundle
|
16
|
+
|
17
|
+
Or install it yourself as:
|
18
|
+
|
19
|
+
$ gem install active_state
|
20
|
+
|
21
|
+
## Usage
|
22
|
+
|
23
|
+
Let's assume that we have a model called `Report`. Every `Report` has an author - a `User`. Reports also have a url leading to an attached file (this example simply uses a string).
|
24
|
+
|
25
|
+
Reports are approved or rejected by a `User`. If a `Report` is rejected, the reviewer has to provide a reason for rejecting it. Reports cannot be reviewed by the `User` who created them.
|
26
|
+
|
27
|
+
A `Report` can therefore be in one of three states - `Pending`, `Approved` or `Rejected`. In every state, different validations have to be run and methods will behave differently. Instead of polluting the model with many conditionals, the state pattern can be used to split the code into multiple short and readable classes.
|
28
|
+
|
29
|
+
#### The migration
|
30
|
+
|
31
|
+
```ruby
|
32
|
+
class CreateReports < ActiveRecord::Migration[6.0]
|
33
|
+
def change
|
34
|
+
create_table :reports do |t|
|
35
|
+
t.references :author, foreign_key: { to_table: :users }
|
36
|
+
t.references :reviewer, foreign_key: { to_table: :users }
|
37
|
+
t.string :file_url
|
38
|
+
t.datetime :reviewed_at
|
39
|
+
t.text :reason_for_rejection
|
40
|
+
t.string :state_name # This is for ActiveState
|
41
|
+
|
42
|
+
t.timestamps
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
```
|
47
|
+
|
48
|
+
#### The model
|
49
|
+
|
50
|
+
```ruby
|
51
|
+
class Report < ApplicationRecord
|
52
|
+
include ActiveState::Model
|
53
|
+
self.initial_state = Pending
|
54
|
+
|
55
|
+
belongs_to :author, class_name: 'User'
|
56
|
+
belongs_to :reviewer, class_name: 'User', optional: true
|
57
|
+
|
58
|
+
# a url has to be present in all states
|
59
|
+
validates_presence_of :file_url
|
60
|
+
|
61
|
+
# create scopes for different states
|
62
|
+
# you can now call Report.pending and Report.approved
|
63
|
+
# custom names for scopes are also possible
|
64
|
+
# Report.thrown_out_the_window will return reports in the Rejected state
|
65
|
+
scope_for_state Pending, Approved, thrown_out_the_window: Rejected
|
66
|
+
|
67
|
+
# delegate methods that will be implemented in the state objects
|
68
|
+
delegate :approve_by, :reject_by, to: :state
|
69
|
+
end
|
70
|
+
```
|
71
|
+
|
72
|
+
#### The states
|
73
|
+
|
74
|
+
The states should probably be put inside a namespace to avoid naming collisions. In this case, they are put inside the Report class, but that is not required for ActiveState to work. To satisfy the Rails autoloading mechanism, these files need to be put in `app/*/reports`. It is up to you if you put the `reports` directory into `app/models` or into a new directory in `app` (possibly something like `app/states`).
|
75
|
+
|
76
|
+
Pending:
|
77
|
+
```ruby
|
78
|
+
class Report
|
79
|
+
class Pending < ActiveState::Base
|
80
|
+
def approve_by(user)
|
81
|
+
review_by user
|
82
|
+
context.state = Approved.new context
|
83
|
+
end
|
84
|
+
|
85
|
+
def reject_by(user, reason:)
|
86
|
+
review_by user
|
87
|
+
context.reason_for_rejection = reason
|
88
|
+
context.state = Rejected.new context
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
def review_by(user)
|
94
|
+
context.reviewer = user
|
95
|
+
context.reviewed_at = Time.now
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
```
|
100
|
+
|
101
|
+
Because some validations and implementations of methods will be the same in both `Approved` and `Rejected` states, we can make a common superclass for both. This is done simply to show that such a thing is possible, but it is up to us to make sure that the `Reviewed` state will never actually be assigned to a model. Treat it like an abstract class.
|
102
|
+
|
103
|
+
```ruby
|
104
|
+
class Report
|
105
|
+
class Reviewed < ActiveState::Base
|
106
|
+
# For validating attributes of the model, we need to get access to them
|
107
|
+
delegate :reviewer, :reviewed_at, to: :context
|
108
|
+
validates_presence_of :reviewer, :reviewed_at
|
109
|
+
|
110
|
+
def approve_by(_user)
|
111
|
+
raise StandardError, 'Report already reviewed'
|
112
|
+
end
|
113
|
+
|
114
|
+
def approve_by(_user, _reason)
|
115
|
+
raise StandardError, 'Report already reviewed'
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
```
|
120
|
+
|
121
|
+
The `Approved` state does not need to do anything more than the `Reviewed` state. However, it would not make sense to have the state representing rejection subclass a state representing approval. This is why creating an empty subclass might be justifiable in this situation.
|
122
|
+
```ruby
|
123
|
+
class Report
|
124
|
+
class Approved < Reviewed
|
125
|
+
end
|
126
|
+
end
|
127
|
+
```
|
128
|
+
|
129
|
+
Rejected:
|
130
|
+
```ruby
|
131
|
+
class Report
|
132
|
+
class Rejected < Reviewed
|
133
|
+
delegate :reason_for_rejection, to: :context
|
134
|
+
validates_presence_of :reason_for_rejection
|
135
|
+
end
|
136
|
+
end
|
137
|
+
```
|
138
|
+
|
139
|
+
## License
|
140
|
+
|
141
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
|
2
|
+
lib = File.expand_path("../lib", __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require "active_state/version"
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "active_state"
|
8
|
+
spec.version = ActiveState::VERSION
|
9
|
+
spec.authors = ["Matous Vokal"]
|
10
|
+
spec.email = ["vokalmat@gmail.com"]
|
11
|
+
|
12
|
+
spec.summary = "State design pattern for ActiveRecord"
|
13
|
+
spec.description = "A simple gem for easily using the state design pattern in Rails."
|
14
|
+
spec.homepage = "https://github.com/Sorc96/active_state"
|
15
|
+
spec.license = "MIT"
|
16
|
+
|
17
|
+
# Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
|
18
|
+
# to allow pushing to a single host or delete this section to allow pushing to any host.
|
19
|
+
if spec.respond_to?(:metadata)
|
20
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
21
|
+
|
22
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
23
|
+
spec.metadata["source_code_uri"] = "https://github.com/Sorc96/active_state"
|
24
|
+
else
|
25
|
+
raise "RubyGems 2.0 or newer is required to protect against " \
|
26
|
+
"public gem pushes."
|
27
|
+
end
|
28
|
+
|
29
|
+
# Specify which files should be added to the gem when it is released.
|
30
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
31
|
+
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
32
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
33
|
+
end
|
34
|
+
spec.require_paths = ["lib"]
|
35
|
+
|
36
|
+
spec.add_development_dependency "bundler", "~> 2.0"
|
37
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
38
|
+
|
39
|
+
spec.add_dependency "activemodel"
|
40
|
+
end
|
data/lib/active_state.rb
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
module ActiveState
|
2
|
+
module Model
|
3
|
+
module ClassMethods
|
4
|
+
attr_accessor :initial_state
|
5
|
+
|
6
|
+
##
|
7
|
+
# Creates scopes for the given states in this form:
|
8
|
+
# scope :my_state, -> { where state_name: MyState.name }
|
9
|
+
#
|
10
|
+
# Also accepts pairs of scope_name: StateClass pairs:
|
11
|
+
# scope :scope_name, -> { where state_name: StateClass.name }
|
12
|
+
def scope_for_state(*states, **named)
|
13
|
+
states.each do |state|
|
14
|
+
named[state.name.demodulize.underscore] = state
|
15
|
+
end
|
16
|
+
named.each do |k, v|
|
17
|
+
scope k, -> { where state_name: v.name }
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
attr_accessor :state
|
23
|
+
|
24
|
+
def self.included(mod)
|
25
|
+
mod.extend ClassMethods
|
26
|
+
mod.validates_with StateValidator
|
27
|
+
mod.after_initialize :create_state_object
|
28
|
+
mod.before_save :synchronize_state_column
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def create_state_object
|
34
|
+
state_class =
|
35
|
+
if state_name.nil?
|
36
|
+
self.class.initial_state
|
37
|
+
else
|
38
|
+
Object.const_get state_name
|
39
|
+
end
|
40
|
+
@state = state_class.new self
|
41
|
+
end
|
42
|
+
|
43
|
+
def synchronize_state_column
|
44
|
+
self.state_name = state.class.name
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module ActiveState
|
2
|
+
class StateValidator < ActiveModel::Validator
|
3
|
+
def validate(record)
|
4
|
+
state = record.state
|
5
|
+
unless state.respond_to? :valid?
|
6
|
+
raise NoMethodError, "State object #{state.inspect} does not respond to :valid?"
|
7
|
+
end
|
8
|
+
|
9
|
+
return if state.valid?
|
10
|
+
|
11
|
+
state.errors.each do |attribute, message|
|
12
|
+
record.errors.add attribute, message
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
metadata
ADDED
@@ -0,0 +1,99 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: active_state
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Matous Vokal
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2019-11-02 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '2.0'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '2.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rake
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '10.0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '10.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: activemodel
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
description: A simple gem for easily using the state design pattern in Rails.
|
56
|
+
email:
|
57
|
+
- vokalmat@gmail.com
|
58
|
+
executables: []
|
59
|
+
extensions: []
|
60
|
+
extra_rdoc_files: []
|
61
|
+
files:
|
62
|
+
- ".gitignore"
|
63
|
+
- Gemfile
|
64
|
+
- LICENSE.txt
|
65
|
+
- README.md
|
66
|
+
- Rakefile
|
67
|
+
- active_state.gemspec
|
68
|
+
- lib/active_state.rb
|
69
|
+
- lib/active_state/base.rb
|
70
|
+
- lib/active_state/model.rb
|
71
|
+
- lib/active_state/state_validator.rb
|
72
|
+
- lib/active_state/version.rb
|
73
|
+
homepage: https://github.com/Sorc96/active_state
|
74
|
+
licenses:
|
75
|
+
- MIT
|
76
|
+
metadata:
|
77
|
+
allowed_push_host: https://rubygems.org
|
78
|
+
homepage_uri: https://github.com/Sorc96/active_state
|
79
|
+
source_code_uri: https://github.com/Sorc96/active_state
|
80
|
+
post_install_message:
|
81
|
+
rdoc_options: []
|
82
|
+
require_paths:
|
83
|
+
- lib
|
84
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
85
|
+
requirements:
|
86
|
+
- - ">="
|
87
|
+
- !ruby/object:Gem::Version
|
88
|
+
version: '0'
|
89
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
90
|
+
requirements:
|
91
|
+
- - ">="
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
version: '0'
|
94
|
+
requirements: []
|
95
|
+
rubygems_version: 3.0.3
|
96
|
+
signing_key:
|
97
|
+
specification_version: 4
|
98
|
+
summary: State design pattern for ActiveRecord
|
99
|
+
test_files: []
|