active_state 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -0,0 +1,8 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in active_state.gemspec
4
+ gemspec
@@ -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.
@@ -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).
@@ -0,0 +1,2 @@
1
+ require "bundler/gem_tasks"
2
+ task :default => :spec
@@ -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
@@ -0,0 +1,7 @@
1
+ require 'active_state/version'
2
+ require 'active_state/base'
3
+ require 'active_state/model'
4
+ require 'active_state/state_validator'
5
+
6
+ module ActiveState
7
+ end
@@ -0,0 +1,15 @@
1
+ module ActiveState
2
+ class Base
3
+ include ActiveModel::Validations
4
+
5
+ attr_reader :context
6
+
7
+ def initialize(context)
8
+ @context = context
9
+ end
10
+
11
+ def state_name
12
+ self.class.name.demodulize
13
+ end
14
+ end
15
+ end
@@ -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
@@ -0,0 +1,3 @@
1
+ module ActiveState
2
+ VERSION = "0.1.0"
3
+ 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: []