stateful_models 0.0.1
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 +7 -0
- data/CHANGELOG.md +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +181 -0
- data/lib/generators/has_states/install/install_generator.rb +25 -0
- data/lib/generators/has_states/install/templates/create_has_states_states.rb.erb +17 -0
- data/lib/generators/has_states/install/templates/initializer.rb.erb +42 -0
- data/lib/has_states/callback.rb +58 -0
- data/lib/has_states/configuration/model_configuration.rb +48 -0
- data/lib/has_states/configuration/state_type_configuration.rb +15 -0
- data/lib/has_states/configuration.rb +83 -0
- data/lib/has_states/railtie.rb +8 -0
- data/lib/has_states/state.rb +41 -0
- data/lib/has_states/stateable.rb +22 -0
- data/lib/has_states/version.rb +5 -0
- data/lib/has_states.rb +24 -0
- data/spec/dummy/Gemfile +40 -0
- data/spec/dummy/Gemfile.lock +286 -0
- data/spec/dummy/README.md +24 -0
- data/spec/dummy/Rakefile +8 -0
- data/spec/dummy/app/controllers/application_controller.rb +4 -0
- data/spec/dummy/app/jobs/application_job.rb +9 -0
- data/spec/dummy/app/models/application_record.rb +5 -0
- data/spec/dummy/app/models/company.rb +3 -0
- data/spec/dummy/app/models/user.rb +3 -0
- data/spec/dummy/bin/brakeman +9 -0
- data/spec/dummy/bin/dev +4 -0
- data/spec/dummy/bin/docker-entrypoint +14 -0
- data/spec/dummy/bin/rails +6 -0
- data/spec/dummy/bin/rake +6 -0
- data/spec/dummy/bin/rubocop +10 -0
- data/spec/dummy/bin/setup +36 -0
- data/spec/dummy/bin/thrust +7 -0
- data/spec/dummy/config/application.rb +27 -0
- data/spec/dummy/config/boot.rb +5 -0
- data/spec/dummy/config/credentials.yml.enc +1 -0
- data/spec/dummy/config/database.yml +37 -0
- data/spec/dummy/config/environment.rb +7 -0
- data/spec/dummy/config/environments/development.rb +54 -0
- data/spec/dummy/config/environments/production.rb +69 -0
- data/spec/dummy/config/environments/test.rb +44 -0
- data/spec/dummy/config/initializers/cors.rb +18 -0
- data/spec/dummy/config/initializers/filter_parameter_logging.rb +10 -0
- data/spec/dummy/config/initializers/inflections.rb +18 -0
- data/spec/dummy/config/locales/en.yml +31 -0
- data/spec/dummy/config/master.key +1 -0
- data/spec/dummy/config/puma.rb +43 -0
- data/spec/dummy/config/routes.rb +12 -0
- data/spec/dummy/config.ru +8 -0
- data/spec/dummy/db/migrate/20241221171423_create_test_models.rb +15 -0
- data/spec/dummy/db/migrate/20241221183116_create_has_states_tables.rb +19 -0
- data/spec/dummy/db/schema.rb +40 -0
- data/spec/dummy/db/seeds.rb +11 -0
- data/spec/dummy/log/development.log +142 -0
- data/spec/dummy/log/test.log +16652 -0
- data/spec/dummy/public/robots.txt +1 -0
- data/spec/dummy/storage/development.sqlite3 +0 -0
- data/spec/dummy/storage/test.sqlite3 +0 -0
- data/spec/dummy/tmp/local_secret.txt +1 -0
- data/spec/factories/has_states.rb +19 -0
- data/spec/generators/has_states/install_generator_spec.rb +27 -0
- data/spec/generators/tmp/config/initializers/has_states.rb +42 -0
- data/spec/generators/tmp/db/migrate/20241223020432_create_has_states_states.rb +17 -0
- data/spec/has_states/callback_spec.rb +92 -0
- data/spec/has_states/configuration_spec.rb +161 -0
- data/spec/has_states/state_spec.rb +264 -0
- data/spec/has_states_spec.rb +52 -0
- data/spec/rails_helper.rb +18 -0
- data/spec/spec_helper.rb +16 -0
- data/spec/support/database_cleaner.rb +17 -0
- data/spec/support/factory_bot.rb +8 -0
- data/spec/support/shoulda_matchers.rb +10 -0
- data/spec/tmp/config/initializers/has_states.rb +12 -0
- data/spec/tmp/db/migrate/20241223004024_create_has_states_states.rb +20 -0
- metadata +122 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 58bd1cdf2dd440bf776a3fd5607b2b965d2dc4a47b03174e2ef542777b62f95d
|
4
|
+
data.tar.gz: 94f8c8473326519ba676df54e010037f699b0985976a68d09832f1a42ebb567a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 436f47aaac1ba1216157ed927ea9ba758570b8c36b75f77e6ad3e21d905070f34f9beca5e4065b247306b10898fbf356a99a4935d89917c1ed8df296543f27ba
|
7
|
+
data.tar.gz: 629afd3e4a9280c03571bbcfcb12b9da2d9d7da6c53bbbd2f98d3bc902164e10dcb3fba6a079b29c2d7f7a133895ad8d8f3d56d5645ca9d64198b02e5551690c
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2024 Sebastian Scholl
|
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,181 @@
|
|
1
|
+
# HasStates
|
2
|
+
|
3
|
+
HasStates is a flexible state management gem for Ruby on Rails that allows you to add multiple state machines to your models. It provides a simple way to track state transitions, add metadata, and execute callbacks.
|
4
|
+
|
5
|
+
## Features
|
6
|
+
|
7
|
+
- Multiple state types per model
|
8
|
+
- Model-specific state configurations
|
9
|
+
- JSON metadata storage for each state
|
10
|
+
- Configurable callbacks with conditions
|
11
|
+
- Limited execution callbacks
|
12
|
+
- Automatic scope generation
|
13
|
+
- Simple state transition tracking
|
14
|
+
|
15
|
+
## Installation
|
16
|
+
|
17
|
+
Add this line to your application's Gemfile:
|
18
|
+
|
19
|
+
```ruby
|
20
|
+
gem 'has_states'
|
21
|
+
```
|
22
|
+
|
23
|
+
Then execute:
|
24
|
+
```bash
|
25
|
+
$ bundle install
|
26
|
+
```
|
27
|
+
|
28
|
+
Generate the required migration and initializer:
|
29
|
+
```bash
|
30
|
+
$ rails generate has_states:install
|
31
|
+
```
|
32
|
+
|
33
|
+
Finally, run the migration:
|
34
|
+
```bash
|
35
|
+
$ rails db:migrate
|
36
|
+
```
|
37
|
+
|
38
|
+
## Configuration
|
39
|
+
|
40
|
+
Configure your models and their state types in `config/initializers/has_states.rb`:
|
41
|
+
|
42
|
+
```ruby
|
43
|
+
HasStates.configure do |config|
|
44
|
+
# Configure states on any model
|
45
|
+
config.configure_model User do |model|
|
46
|
+
# Define state type and its allowed statuses
|
47
|
+
model.state_type :kyc do |type|
|
48
|
+
type.statuses = [
|
49
|
+
'pending', # Initial state
|
50
|
+
'documents_required', # Waiting for documents
|
51
|
+
'under_review', # Documents being reviewed
|
52
|
+
'approved', # KYC completed successfully
|
53
|
+
'rejected' # KYC failed
|
54
|
+
]
|
55
|
+
end
|
56
|
+
|
57
|
+
# Define multiple state types per model with different statuses
|
58
|
+
model.state_type :onboarding do |type|
|
59
|
+
type.statuses = [
|
60
|
+
'pending', # Just started
|
61
|
+
'email_verified', # Email verification complete
|
62
|
+
'completed' # Onboarding finished
|
63
|
+
]
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Configure multiple models
|
68
|
+
config.configure_model Company do |model|
|
69
|
+
model.state_type :verification do |type|
|
70
|
+
type.statuses = ['pending', 'verified', 'rejected']
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
```
|
75
|
+
|
76
|
+
## Usage
|
77
|
+
|
78
|
+
### Basic State Management
|
79
|
+
|
80
|
+
```ruby
|
81
|
+
user = User.create!(name: 'John')
|
82
|
+
# Add a new state
|
83
|
+
state = user.add_state('kyc', status: 'pending', metadata: {
|
84
|
+
documents: ['passport', 'utility_bill'],
|
85
|
+
notes: 'Awaiting document submission'
|
86
|
+
})
|
87
|
+
|
88
|
+
# Check current state
|
89
|
+
current_kyc = user.current_state('kyc')
|
90
|
+
|
91
|
+
# Predicate methods are generated for every status.
|
92
|
+
current_kyc.pending? # => true
|
93
|
+
current_kyc.approved? # => false
|
94
|
+
|
95
|
+
# Update state
|
96
|
+
current_kyc.update!(status: 'under_review')
|
97
|
+
|
98
|
+
# Check state for record
|
99
|
+
user.kyc_pending? # => true
|
100
|
+
user.kyc_completed? # => false
|
101
|
+
|
102
|
+
# See all states for record
|
103
|
+
user.states # => [#<HasStates::State...>]
|
104
|
+
```
|
105
|
+
|
106
|
+
### Working with Metadata
|
107
|
+
|
108
|
+
Each state can store arbitrary metadata as JSON:
|
109
|
+
|
110
|
+
```ruby
|
111
|
+
# Store complex metadata
|
112
|
+
state = user.add_state('kyc', metadata: {
|
113
|
+
documents: {
|
114
|
+
passport: {
|
115
|
+
status: 'verified',
|
116
|
+
verified_at: Time.current,
|
117
|
+
verified_by: 'admin@example.com'
|
118
|
+
},
|
119
|
+
utility_bill: {
|
120
|
+
status: 'rejected',
|
121
|
+
reason: 'Document expired'
|
122
|
+
}
|
123
|
+
},
|
124
|
+
risk_score: 85,
|
125
|
+
notes: ['Requires additional verification', 'High-risk jurisdiction']
|
126
|
+
})
|
127
|
+
|
128
|
+
# Access metadata
|
129
|
+
state.metadata['documents']['passport']['status'] # => "verified"
|
130
|
+
state.metadata['risk_score'] # => 85
|
131
|
+
```
|
132
|
+
|
133
|
+
### Callbacks
|
134
|
+
|
135
|
+
Register callbacks that execute when states change:
|
136
|
+
|
137
|
+
```ruby
|
138
|
+
HasStates.configure do |config|
|
139
|
+
# Basic callback
|
140
|
+
config.on(:kyc, to: 'completed') do |state|
|
141
|
+
UserMailer.kyc_completed(state.stateable).deliver_later
|
142
|
+
end
|
143
|
+
|
144
|
+
# Callback with custom ID for easy removal
|
145
|
+
config.on(:kyc, id: :notify_admin, to: 'rejected') do |state|
|
146
|
+
AdminNotifier.kyc_rejected(state)
|
147
|
+
end
|
148
|
+
|
149
|
+
# Callback that runs only once
|
150
|
+
config.on(:onboarding, to: 'completed', times: 1) do |state|
|
151
|
+
WelcomeMailer.send_welcome(state.stateable)
|
152
|
+
end
|
153
|
+
|
154
|
+
# Callback with from/to conditions
|
155
|
+
config.on(:kyc, from: 'pending', to: 'under_review') do |state|
|
156
|
+
NotificationService.notify_review_started(state)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
# Remove callbacks
|
161
|
+
HasStates.configuration.off(:notify_admin) # Remove by ID
|
162
|
+
HasStates.configuration.off(callback) # Remove by callback object
|
163
|
+
```
|
164
|
+
|
165
|
+
### Scopes
|
166
|
+
|
167
|
+
HasStates automatically generates scopes for your state types:
|
168
|
+
|
169
|
+
```ruby
|
170
|
+
HasStates::State.kyc # All KYC states
|
171
|
+
HasStates::State.onboarding # All onboarding states
|
172
|
+
```
|
173
|
+
|
174
|
+
## Contributing
|
175
|
+
|
176
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/has_states.
|
177
|
+
|
178
|
+
## License
|
179
|
+
|
180
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
181
|
+
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/generators'
|
4
|
+
|
5
|
+
module HasStates
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
7
|
+
source_root File.expand_path('templates', __dir__)
|
8
|
+
|
9
|
+
def install
|
10
|
+
puts 'Installing HasStates...'
|
11
|
+
|
12
|
+
template(
|
13
|
+
'create_has_states_states.rb.erb',
|
14
|
+
"db/migrate/#{Time.now.utc.strftime('%Y%m%d%H%M%S')}_create_has_states_states.rb"
|
15
|
+
)
|
16
|
+
|
17
|
+
template(
|
18
|
+
'initializer.rb.erb',
|
19
|
+
'config/initializers/has_states.rb'
|
20
|
+
)
|
21
|
+
|
22
|
+
puts 'HasStates installed successfully!'
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
class CreateHasStatesStates < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
2
|
+
def change
|
3
|
+
create_table :has_states_states do |t|
|
4
|
+
t.string :state_type
|
5
|
+
t.string :status, null: false
|
6
|
+
|
7
|
+
t.json :metadata, null: false, default: {}
|
8
|
+
|
9
|
+
t.references :stateable, polymorphic: true, null: false
|
10
|
+
|
11
|
+
t.datetime :completed_at
|
12
|
+
t.timestamps
|
13
|
+
|
14
|
+
t.index %i[stateable_type stateable_id]
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
HasStates.configure do |config|
|
4
|
+
# Configure your models and their state types below
|
5
|
+
#
|
6
|
+
# Example configuration:
|
7
|
+
#
|
8
|
+
# config.configure_model User do |model|
|
9
|
+
# # KYC state type with its allowed statuses
|
10
|
+
# model.state_type :kyc do |type|
|
11
|
+
# type.statuses = [
|
12
|
+
# 'pending', # Initial state
|
13
|
+
# 'documents_required', # Waiting for user documents
|
14
|
+
# 'under_review', # Documents being reviewed
|
15
|
+
# 'approved', # KYC process completed successfully
|
16
|
+
# 'rejected' # KYC process failed
|
17
|
+
# ]
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
# # Onboarding state type with different statuses
|
21
|
+
# model.state_type :onboarding do |type|
|
22
|
+
# type.statuses = [
|
23
|
+
# 'pending', # Just started
|
24
|
+
# 'email_verified', # Email verification complete
|
25
|
+
# 'profile_complete', # User filled all required fields
|
26
|
+
# 'completed' # Onboarding finished
|
27
|
+
# ]
|
28
|
+
# end
|
29
|
+
# end
|
30
|
+
#
|
31
|
+
# config.configure_model Company do |model|
|
32
|
+
# model.state_type :verification do |type|
|
33
|
+
# type.statuses = [
|
34
|
+
# 'pending',
|
35
|
+
# 'documents_submitted',
|
36
|
+
# 'under_review',
|
37
|
+
# 'verified',
|
38
|
+
# 'rejected'
|
39
|
+
# ]
|
40
|
+
# end
|
41
|
+
# end
|
42
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HasStates
|
4
|
+
class Callback
|
5
|
+
attr_reader :state_type, :conditions, :block, :max_executions
|
6
|
+
attr_accessor :execution_count
|
7
|
+
|
8
|
+
def initialize(state_type, conditions, block)
|
9
|
+
@state_type = state_type
|
10
|
+
@conditions = conditions
|
11
|
+
@block = block
|
12
|
+
@max_executions = conditions.delete(:times)
|
13
|
+
@execution_count = 0
|
14
|
+
end
|
15
|
+
|
16
|
+
def matches?(state)
|
17
|
+
return false unless state.state_type == state_type.to_s
|
18
|
+
|
19
|
+
conditions.all? do |key, expected_value|
|
20
|
+
case key
|
21
|
+
when :to
|
22
|
+
state.status == expected_value
|
23
|
+
when :from
|
24
|
+
state.status_before_last_save == expected_value
|
25
|
+
else
|
26
|
+
state.public_send(key) == expected_value
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def call(state)
|
32
|
+
result = block.call(state)
|
33
|
+
@execution_count += 1
|
34
|
+
|
35
|
+
# Remove self from configuration if this was the last execution
|
36
|
+
HasStates.configuration.off(self) if expired?
|
37
|
+
|
38
|
+
result
|
39
|
+
end
|
40
|
+
|
41
|
+
def expired?
|
42
|
+
max_executions && execution_count >= max_executions
|
43
|
+
end
|
44
|
+
|
45
|
+
def ==(other)
|
46
|
+
other.is_a?(self.class) &&
|
47
|
+
other.state_type == state_type &&
|
48
|
+
other.conditions == conditions &&
|
49
|
+
other.block == block
|
50
|
+
end
|
51
|
+
|
52
|
+
alias eql? ==
|
53
|
+
|
54
|
+
def hash
|
55
|
+
[state_type, conditions, block].hash
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HasStates
|
4
|
+
class Configuration
|
5
|
+
class ModelConfiguration
|
6
|
+
attr_reader :model_class, :state_types
|
7
|
+
|
8
|
+
def initialize(model_class)
|
9
|
+
@model_class = model_class
|
10
|
+
@state_types = {}
|
11
|
+
end
|
12
|
+
|
13
|
+
def state_type(name)
|
14
|
+
type = StateTypeConfiguration.new(name)
|
15
|
+
|
16
|
+
yield(type) if block_given?
|
17
|
+
|
18
|
+
@state_types[name.to_s] = type
|
19
|
+
|
20
|
+
generate_state_type_scope(name)
|
21
|
+
generate_status_predicates(type.statuses)
|
22
|
+
generate_state_type_status_predicates(name, type.statuses)
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def generate_state_type_scope(state_type)
|
28
|
+
HasStates::State.scope state_type, -> { where(state_type: state_type) }
|
29
|
+
end
|
30
|
+
|
31
|
+
def generate_status_predicates(statuses)
|
32
|
+
statuses.each do |status_name|
|
33
|
+
HasStates::State.define_method(:"#{status_name}?") do
|
34
|
+
status == status_name
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def generate_state_type_status_predicates(state_type, statuses)
|
40
|
+
statuses.each do |status_name|
|
41
|
+
@model_class.define_method(:"#{state_type}_#{status_name}?") do
|
42
|
+
states.where(state_type: state_type, status: status_name).exists?
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HasStates
|
4
|
+
class Configuration
|
5
|
+
include Singleton
|
6
|
+
|
7
|
+
attr_reader :callbacks, :model_configurations
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@callbacks = {}
|
11
|
+
@next_callback_id = 0
|
12
|
+
@model_configurations = {}
|
13
|
+
end
|
14
|
+
|
15
|
+
def configure_model(model_class)
|
16
|
+
unless model_class.is_a?(Class) && model_class < ActiveRecord::Base
|
17
|
+
raise ArgumentError, "#{model_class} must be an ActiveRecord model"
|
18
|
+
end
|
19
|
+
|
20
|
+
model_config = Configuration::ModelConfiguration.new(model_class)
|
21
|
+
|
22
|
+
yield(model_config)
|
23
|
+
|
24
|
+
@model_configurations[model_class] = model_config
|
25
|
+
model_class.include(Stateable) unless model_class.included_modules.include?(Stateable)
|
26
|
+
end
|
27
|
+
|
28
|
+
def valid_status?(model_class, state_type, status)
|
29
|
+
return false unless @model_configurations[model_class]&.state_types&.[](state_type.to_s)
|
30
|
+
|
31
|
+
@model_configurations[model_class].state_types[state_type.to_s].statuses.include?(status)
|
32
|
+
end
|
33
|
+
|
34
|
+
def valid_state_type?(model_class, state_type)
|
35
|
+
@model_configurations[model_class]&.state_types&.key?(state_type.to_s)
|
36
|
+
end
|
37
|
+
|
38
|
+
def on(state_type, id: nil, **conditions, &block)
|
39
|
+
callback = Callback.new(state_type, conditions, block)
|
40
|
+
callback_id = id&.to_sym || generate_callback_id
|
41
|
+
@callbacks[callback_id] = callback
|
42
|
+
callback
|
43
|
+
end
|
44
|
+
|
45
|
+
def off(callback_or_id)
|
46
|
+
if callback_or_id.is_a?(Callback)
|
47
|
+
@callbacks.delete_if { |_, cb| cb == callback_or_id }
|
48
|
+
else
|
49
|
+
@callbacks.delete(callback_or_id)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def matching_callbacks(state)
|
54
|
+
@callbacks.values.select { |callback| callback.matches?(state) }
|
55
|
+
end
|
56
|
+
|
57
|
+
def clear_callbacks!
|
58
|
+
@callbacks = {}
|
59
|
+
end
|
60
|
+
|
61
|
+
def state_types_for(model_class)
|
62
|
+
@model_configurations[model_class]&.state_types
|
63
|
+
end
|
64
|
+
|
65
|
+
def statuses_for(model_class, state_type)
|
66
|
+
config = @model_configurations[model_class]
|
67
|
+
return nil unless config
|
68
|
+
|
69
|
+
state_types = config.state_types
|
70
|
+
return nil unless state_types
|
71
|
+
|
72
|
+
state_type_config = state_types[state_type.to_s]
|
73
|
+
state_type_config&.statuses
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
def generate_callback_id
|
79
|
+
@next_callback_id += 1
|
80
|
+
:"callback_#{@next_callback_id}"
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HasStates
|
4
|
+
class State < ActiveRecord::Base
|
5
|
+
self.table_name = 'has_states_states'
|
6
|
+
|
7
|
+
belongs_to :stateable, polymorphic: true
|
8
|
+
|
9
|
+
validate :status_is_configured
|
10
|
+
validate :state_type_is_configured
|
11
|
+
|
12
|
+
after_save :trigger_callbacks, if: :saved_change_to_status?
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def status_is_configured
|
17
|
+
return if HasStates.configuration.valid_status?(
|
18
|
+
stateable_type.constantize,
|
19
|
+
state_type,
|
20
|
+
status
|
21
|
+
)
|
22
|
+
|
23
|
+
errors.add(:status, 'is not configured')
|
24
|
+
end
|
25
|
+
|
26
|
+
def state_type_is_configured
|
27
|
+
return if HasStates.configuration.valid_state_type?(
|
28
|
+
stateable_type.constantize,
|
29
|
+
state_type
|
30
|
+
)
|
31
|
+
|
32
|
+
errors.add(:state_type, 'is not configured')
|
33
|
+
end
|
34
|
+
|
35
|
+
def trigger_callbacks
|
36
|
+
HasStates.configuration.matching_callbacks(self).each do |callback|
|
37
|
+
callback.call(self)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HasStates
|
4
|
+
module Stateable
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
included do
|
8
|
+
has_many :states, class_name: 'HasStates::State',
|
9
|
+
as: :stateable,
|
10
|
+
dependent: :destroy
|
11
|
+
end
|
12
|
+
|
13
|
+
# Instance methods for managing states
|
14
|
+
def add_state(type, status: 'pending', metadata: {})
|
15
|
+
states.create!(
|
16
|
+
state_type: type,
|
17
|
+
status: status,
|
18
|
+
metadata: metadata
|
19
|
+
)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
data/lib/has_states.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_record'
|
4
|
+
require 'has_states/version'
|
5
|
+
require 'has_states/configuration'
|
6
|
+
require 'has_states/configuration/model_configuration'
|
7
|
+
require 'has_states/configuration/state_type_configuration'
|
8
|
+
|
9
|
+
module HasStates
|
10
|
+
class << self
|
11
|
+
def configure
|
12
|
+
yield(configuration)
|
13
|
+
end
|
14
|
+
|
15
|
+
def configuration
|
16
|
+
Configuration.instance
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
require 'has_states/state'
|
22
|
+
require 'has_states/callback'
|
23
|
+
require 'has_states/stateable'
|
24
|
+
require 'has_states/railtie' if defined?(Rails)
|
data/spec/dummy/Gemfile
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
source 'https://rubygems.org'
|
4
|
+
|
5
|
+
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
|
6
|
+
gem 'rails', '~> 8.0.1'
|
7
|
+
# Use sqlite3 as the database for Active Record
|
8
|
+
gem 'sqlite3', '>= 2.1'
|
9
|
+
# Use the Puma web server [https://github.com/puma/puma]
|
10
|
+
gem 'puma', '>= 5.0'
|
11
|
+
|
12
|
+
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
|
13
|
+
# gem "bcrypt", "~> 3.1.7"
|
14
|
+
|
15
|
+
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
16
|
+
gem 'tzinfo-data', platforms: %i[windows jruby]
|
17
|
+
|
18
|
+
# Use the database-backed adapters for Rails.cache and Active Job
|
19
|
+
gem 'solid_cache'
|
20
|
+
gem 'solid_queue'
|
21
|
+
|
22
|
+
# Deploy this application anywhere as a Docker container [https://kamal-deploy.org]
|
23
|
+
gem 'kamal', require: false
|
24
|
+
|
25
|
+
# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/]
|
26
|
+
gem 'thruster', require: false
|
27
|
+
|
28
|
+
# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin Ajax possible
|
29
|
+
# gem "rack-cors"
|
30
|
+
|
31
|
+
group :development, :test do
|
32
|
+
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
33
|
+
gem 'debug', platforms: %i[mri windows], require: 'debug/prelude'
|
34
|
+
|
35
|
+
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
|
36
|
+
gem 'brakeman', require: false
|
37
|
+
end
|
38
|
+
|
39
|
+
# HasStates gem for state management and event system capabilities
|
40
|
+
gem 'stateful_models', path: '../..' # This points to the root of your gem
|