meta_states 0.1.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 +7 -0
- data/CHANGELOG.md +46 -0
- data/LICENSE.txt +21 -0
- data/README.md +255 -0
- data/lib/generators/meta_states/install/install_generator.rb +43 -0
- data/lib/generators/meta_states/install/templates/create_indexes_on_meta_states_states.rb.erb +11 -0
- data/lib/generators/meta_states/install/templates/create_meta_states_states.rb.erb +18 -0
- data/lib/generators/meta_states/install/templates/initializer.rb.erb +42 -0
- data/lib/meta_states/base.rb +70 -0
- data/lib/meta_states/callback.rb +58 -0
- data/lib/meta_states/configuration/model_configuration.rb +64 -0
- data/lib/meta_states/configuration/state_type_configuration.rb +17 -0
- data/lib/meta_states/configuration.rb +142 -0
- data/lib/meta_states/railtie.rb +8 -0
- data/lib/meta_states/state.rb +5 -0
- data/lib/meta_states/stateable.rb +26 -0
- data/lib/meta_states/version.rb +5 -0
- data/lib/meta_states.rb +25 -0
- data/spec/dummy/Gemfile +40 -0
- data/spec/dummy/Gemfile.lock +308 -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/initializers/meta_states.rb +45 -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/20241223212128_create_has_states_states.rb +18 -0
- data/spec/dummy/db/migrate/20250114175939_create_indexes_on_has_states_states.rb +10 -0
- data/spec/dummy/db/schema.rb +44 -0
- data/spec/dummy/db/seeds.rb +11 -0
- data/spec/dummy/log/development.log +416 -0
- data/spec/dummy/log/test.log +55143 -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/meta_states.rb +19 -0
- data/spec/generators/meta_states/install_generator_spec.rb +27 -0
- data/spec/generators/templates/config/initializers/meta_states.rb +42 -0
- data/spec/generators/templates/db/migrate/20250605170637_create_indexes_on_meta_states_states.rb +11 -0
- data/spec/generators/templates/db/migrate/20250605170637_create_meta_states_states.rb +18 -0
- data/spec/meta_states/callback_spec.rb +92 -0
- data/spec/meta_states/configuration_spec.rb +218 -0
- data/spec/meta_states/state_limit_spec.rb +107 -0
- data/spec/meta_states/state_metadata_schema_spec.rb +75 -0
- data/spec/meta_states/state_spec.rb +349 -0
- data/spec/meta_states/stateable_spec.rb +183 -0
- data/spec/meta_states_spec.rb +52 -0
- data/spec/rails_helper.rb +19 -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 +141 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 48579ba5da3df5f8b0d86db0991a0a6cbed1d1139504373f10490147a7f26d97
|
|
4
|
+
data.tar.gz: 84df5ed976adf93421e3fac0f27c2f9d43265283c0338d1f5541e08e0f876860
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4d796e212f4476184e6d9b02b7661245215ad80ac4635477bcc9d30db7d13160948ea934fc8084771cf7dc10be1ce46397212fdcfdf514cdc820e9cf6cbf0408
|
|
7
|
+
data.tar.gz: 92257aede1c5829d5d1dc1de19653664f642cb59fcf7e0fa3edd4e8634ae4a871992f64b8a8f48736039d58404a1cff7749993a6cd00848734cb9a012c8c6f45
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
## Released
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2025-06-05
|
|
4
|
+
|
|
5
|
+
### Change
|
|
6
|
+
- Renaming gem from `meta_states` to `meta_states`.
|
|
7
|
+
|
|
8
|
+
## [0.0.5] - 2025-04-14
|
|
9
|
+
|
|
10
|
+
## Change
|
|
11
|
+
- Changed callback action on base class from `after_save` to `after_commit`
|
|
12
|
+
|
|
13
|
+
## [0.0.4] - 2025-03-21
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- Single Table Inheritance (STI) support for custom state types
|
|
17
|
+
- Ability to create custom state classes by inheriting from `MetaStates::Base`
|
|
18
|
+
- Default state class `MetaStates::State` for basic state management
|
|
19
|
+
- Example implementation of custom state types in documentation
|
|
20
|
+
- `limit` option for state types to limit the number of states on a record
|
|
21
|
+
- `metadata_schema` option for state types to validate metadata
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- Refactored base state functionality into `MetaStates::Base`
|
|
25
|
+
- Updated `add_state` method to support custom state classes
|
|
26
|
+
- Improved test coverage for inheritance and custom state types
|
|
27
|
+
|
|
28
|
+
## [0.0.3] - 2024-01-14
|
|
29
|
+
|
|
30
|
+
- Adding `current_state(state_name)` method
|
|
31
|
+
- Adding DB indexes for state lookups
|
|
32
|
+
- Adding query methods for state types on stateable (`stateable.state_type` and `stateable.state_types`)
|
|
33
|
+
|
|
34
|
+
## [0.0.2] - 2024-12-23
|
|
35
|
+
|
|
36
|
+
- Added test coverage
|
|
37
|
+
- Refactor of State model into a inheritable Base class
|
|
38
|
+
- Single Table Inheritance (STI) support for custom state types
|
|
39
|
+
- Ability to create custom state classes by inheriting from `MetaStates::Base`
|
|
40
|
+
- Default state class `MetaStates::State` for basic state management
|
|
41
|
+
- Example implementation of custom state types in documentation
|
|
42
|
+
|
|
43
|
+
## [0.0.1] - 2024-12-21
|
|
44
|
+
|
|
45
|
+
- Initial release
|
|
46
|
+
- See READme.md
|
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,255 @@
|
|
|
1
|
+
# MetaStates
|
|
2
|
+
|
|
3
|
+
MetaStates 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 'stateful_models'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then execute:
|
|
24
|
+
```bash
|
|
25
|
+
$ bundle install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Generate the required migration and initializer:
|
|
29
|
+
```bash
|
|
30
|
+
$ rails generate meta_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/meta_states.rb`:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
MetaStates.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
|
+
# Load state(s) also by name as methods on the record
|
|
89
|
+
current_kyc = user.current_state('kyc')
|
|
90
|
+
current_kyc = user.kyc # Returns most recent state of type kyc if multiple states of the same type exist
|
|
91
|
+
all_kyc = user.kycs
|
|
92
|
+
|
|
93
|
+
# Predicate methods are generated for every status.
|
|
94
|
+
current_kyc.pending? # => true
|
|
95
|
+
current_kyc.approved? # => false
|
|
96
|
+
|
|
97
|
+
# Update state
|
|
98
|
+
current_kyc.update!(status: 'under_review')
|
|
99
|
+
|
|
100
|
+
# Check state for record
|
|
101
|
+
user.kyc_pending? # => true
|
|
102
|
+
user.kyc_completed? # => false
|
|
103
|
+
|
|
104
|
+
# See all states for record
|
|
105
|
+
user.states # => [#<MetaStates::State...>]
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Working with Metadata
|
|
109
|
+
|
|
110
|
+
Each state can store arbitrary metadata as JSON:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
# Store complex metadata
|
|
114
|
+
state = user.add_state('kyc', metadata: {
|
|
115
|
+
documents: {
|
|
116
|
+
passport: {
|
|
117
|
+
status: 'verified',
|
|
118
|
+
verified_at: Time.current,
|
|
119
|
+
verified_by: 'admin@example.com'
|
|
120
|
+
},
|
|
121
|
+
utility_bill: {
|
|
122
|
+
status: 'rejected',
|
|
123
|
+
reason: 'Document expired'
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
risk_score: 85,
|
|
127
|
+
notes: ['Requires additional verification', 'High-risk jurisdiction']
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
# Access metadata
|
|
131
|
+
state.metadata['documents']['passport']['status'] # => "verified"
|
|
132
|
+
state.metadata['risk_score'] # => 85
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Metadata Validations
|
|
136
|
+
|
|
137
|
+
You can define a JSON Schema for validating the metadata of states. This allows you to ensure that the metadata follows a specific structure and contains required fields, leveraging the power of JSON Schema.
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
MetaStates.configure do |config|
|
|
141
|
+
config.configure_model User do |model|
|
|
142
|
+
model.state_type :kyc do |type|
|
|
143
|
+
type.statuses = [
|
|
144
|
+
'pending',
|
|
145
|
+
'approved',
|
|
146
|
+
'rejected'
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
type.metadata_schema = {
|
|
150
|
+
type: :object,
|
|
151
|
+
properties: {
|
|
152
|
+
name: { type: :string },
|
|
153
|
+
age: {
|
|
154
|
+
type: :integer,
|
|
155
|
+
minimum: 18
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
required: [:name, :age],
|
|
159
|
+
additionalProperties: false,
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
user = User.create!
|
|
166
|
+
|
|
167
|
+
# Invalid metadata (too young < 18)
|
|
168
|
+
user.add_state('kyc', status: 'pending', metadata: { name: 'John Doe', age: 17 })
|
|
169
|
+
# ActiveRecord::RecordInvalid: Validation failed: Metadata is invalid (minimum value of 18)
|
|
170
|
+
|
|
171
|
+
# Valid metadata
|
|
172
|
+
user.add_state('kyc', status: 'pending', metadata: { name: 'John Doe', age: 25 })
|
|
173
|
+
# => #<MetaStates::State...>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## State Limits
|
|
177
|
+
|
|
178
|
+
You can optionally limit the number of states a record can have for a specific state type:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
MetaStates.configure do |config|
|
|
182
|
+
config.configure_model User do |model|
|
|
183
|
+
model.state_type :kyc do |type|
|
|
184
|
+
type.statuses = %w[pending completed]
|
|
185
|
+
type.limit = 1 # Limit to only one KYC state per user
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
When set, the limit is checked when a new state is added. If the limit is exceeded, an ActiveRecord::RecordInvalid error is raised.
|
|
192
|
+
|
|
193
|
+
### Callbacks
|
|
194
|
+
|
|
195
|
+
Register callbacks that execute when states change:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
MetaStates.configure do |config|
|
|
199
|
+
# Basic callback
|
|
200
|
+
config.on(:kyc, to: 'completed') do |state|
|
|
201
|
+
UserMailer.kyc_completed(state.stateable).deliver_later
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Callback with custom ID for easy removal
|
|
205
|
+
config.on(:kyc, id: :notify_admin, to: 'rejected') do |state|
|
|
206
|
+
AdminNotifier.kyc_rejected(state)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Callback that runs only once
|
|
210
|
+
config.on(:onboarding, to: 'completed', times: 1) do |state|
|
|
211
|
+
WelcomeMailer.send_welcome(state.stateable)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Callback with from/to conditions
|
|
215
|
+
config.on(:kyc, from: 'pending', to: 'under_review') do |state|
|
|
216
|
+
NotificationService.notify_review_started(state)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Remove callbacks
|
|
221
|
+
MetaStates.configuration.off(:notify_admin) # Remove by ID
|
|
222
|
+
MetaStates.configuration.off(callback) # Remove by callback object
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Scopes
|
|
226
|
+
|
|
227
|
+
MetaStates automatically generates scopes for your state types:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
MetaStates::State.kyc # All KYC states
|
|
231
|
+
MetaStates::State.onboarding # All onboarding states
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Class Inheritance
|
|
235
|
+
|
|
236
|
+
MetaStates lets you inherit from the `MetaStates::Base` class to create custom state classes. This makes validations and custom methods on specific state types easy.
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
class MyState < MetaStates::Base
|
|
240
|
+
# Add validations or methods
|
|
241
|
+
|
|
242
|
+
def do_something
|
|
243
|
+
# Custom method on state
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Contributing
|
|
249
|
+
|
|
250
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/meta_states.
|
|
251
|
+
|
|
252
|
+
## License
|
|
253
|
+
|
|
254
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
255
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
|
|
5
|
+
module MetaStates
|
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
|
7
|
+
source_root File.expand_path('templates', __dir__)
|
|
8
|
+
|
|
9
|
+
TEMPLATES = [
|
|
10
|
+
{
|
|
11
|
+
source: 'create_meta_states_states.rb.erb',
|
|
12
|
+
destination: 'db/migrate/%s_create_meta_states_states.rb'
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
source: 'create_indexes_on_meta_states_states.rb.erb',
|
|
16
|
+
destination: 'db/migrate/%s_create_indexes_on_meta_states_states.rb'
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
source: 'initializer.rb.erb',
|
|
20
|
+
destination: 'config/initializers/meta_states.rb'
|
|
21
|
+
}
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
def install
|
|
25
|
+
puts 'Installing MetaStates...'
|
|
26
|
+
|
|
27
|
+
TEMPLATES.each do |template|
|
|
28
|
+
make_template(**template)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
puts 'MetaStates installed successfully!'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def make_template(source:, destination:)
|
|
37
|
+
timestamp = Time.now.utc.strftime('%Y%m%d%H%M%S')
|
|
38
|
+
destination %= timestamp if destination.include?('%s')
|
|
39
|
+
|
|
40
|
+
template(source, destination)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class CreateIndexesOnMetaStatesStates < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
change_table :meta_states_states do |t|
|
|
4
|
+
t.index %i[stateable_id state_type]
|
|
5
|
+
t.index %i[stateable_id state_type status]
|
|
6
|
+
t.index %i[stateable_id state_type created_at]
|
|
7
|
+
t.index %i[stateable_id state_type status created_at]
|
|
8
|
+
t.index %i[stateable_type stateable_id]
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class CreateMetaStatesStates < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :meta_states_states do |t|
|
|
4
|
+
t.string :type, null: false
|
|
5
|
+
t.string :state_type
|
|
6
|
+
t.string :status, null: false
|
|
7
|
+
|
|
8
|
+
t.json :metadata, null: false, default: {}
|
|
9
|
+
|
|
10
|
+
t.references :stateable, polymorphic: true, null: false
|
|
11
|
+
|
|
12
|
+
t.timestamps
|
|
13
|
+
|
|
14
|
+
t.index %i[type stateable_id]
|
|
15
|
+
t.index %i[stateable_type stateable_id]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
MetaStates.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,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json-schema'
|
|
4
|
+
|
|
5
|
+
module MetaStates
|
|
6
|
+
class Base < ActiveRecord::Base
|
|
7
|
+
self.table_name = 'meta_states_states'
|
|
8
|
+
|
|
9
|
+
belongs_to :stateable, polymorphic: true
|
|
10
|
+
|
|
11
|
+
validate :status_is_configured
|
|
12
|
+
validate :state_type_is_configured
|
|
13
|
+
validate :state_limit_not_exceeded, on: :create
|
|
14
|
+
validate :metadata_conforms_to_schema, if: -> { metadata.present? }
|
|
15
|
+
|
|
16
|
+
after_commit :trigger_callbacks, if: :saved_change_to_status?
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def status_is_configured
|
|
21
|
+
return if MetaStates.configuration.valid_status?(
|
|
22
|
+
stateable_type.constantize,
|
|
23
|
+
state_type,
|
|
24
|
+
status
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
errors.add(:status, 'is not configured')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def state_type_is_configured
|
|
31
|
+
return if MetaStates.configuration.valid_state_type?(
|
|
32
|
+
stateable_type.constantize,
|
|
33
|
+
state_type
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
errors.add(:state_type, 'is not configured')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def state_limit_not_exceeded
|
|
40
|
+
return unless (limit = MetaStates.configuration.limit_for(
|
|
41
|
+
stateable_type.constantize,
|
|
42
|
+
state_type
|
|
43
|
+
))
|
|
44
|
+
|
|
45
|
+
return unless stateable
|
|
46
|
+
|
|
47
|
+
current_count = stateable.states.where(state_type: state_type).count
|
|
48
|
+
return if current_count < limit
|
|
49
|
+
|
|
50
|
+
errors.add(:base, "maximum number of #{state_type} states (#{limit}) reached")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def metadata_conforms_to_schema
|
|
54
|
+
return unless (schema = MetaStates.configuration.metadata_schema_for(
|
|
55
|
+
stateable_type.constantize,
|
|
56
|
+
state_type
|
|
57
|
+
))
|
|
58
|
+
|
|
59
|
+
JSON::Validator.validate!(schema, metadata, strict: true)
|
|
60
|
+
rescue JSON::Schema::ValidationError => e
|
|
61
|
+
errors.add(:metadata, "does not conform to schema: #{e.message}")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def trigger_callbacks
|
|
65
|
+
MetaStates.configuration.matching_callbacks(self).each do |callback|
|
|
66
|
+
callback.call(self)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetaStates
|
|
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
|
+
MetaStates.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,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetaStates
|
|
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
|
+
# MetaStates::State model method generators
|
|
21
|
+
generate_state_type_scope(name)
|
|
22
|
+
generate_status_predicates(type.statuses)
|
|
23
|
+
|
|
24
|
+
# Included model method generators
|
|
25
|
+
generate_state_type_queries(name)
|
|
26
|
+
generate_state_type_status_predicates(name, type.statuses)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def generate_state_type_scope(state_type)
|
|
32
|
+
MetaStates::State.scope state_type, -> { where(state_type: state_type) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def generate_status_predicates(statuses)
|
|
36
|
+
statuses.each do |status_name|
|
|
37
|
+
MetaStates::State.define_method(:"#{status_name}?") do
|
|
38
|
+
status == status_name
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def generate_state_type_queries(state_type)
|
|
44
|
+
# Singular for finding the most recent state of a given type and status
|
|
45
|
+
@model_class.define_method(:"#{state_type}") do
|
|
46
|
+
states.where(state_type: state_type).order(created_at: :desc).first
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Plural for finding all states of a given type and status
|
|
50
|
+
@model_class.define_method(:"#{ActiveSupport::Inflector.pluralize(state_type)}") do
|
|
51
|
+
states.where(state_type: state_type).order(created_at: :desc)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def generate_state_type_status_predicates(state_type, statuses)
|
|
56
|
+
statuses.each do |status_name|
|
|
57
|
+
@model_class.define_method(:"#{state_type}_#{status_name}?") do
|
|
58
|
+
states.where(state_type: state_type, status: status_name).exists?
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetaStates
|
|
4
|
+
class Configuration
|
|
5
|
+
class StateTypeConfiguration
|
|
6
|
+
attr_reader :name
|
|
7
|
+
attr_accessor :statuses, :limit, :metadata_schema
|
|
8
|
+
|
|
9
|
+
def initialize(name)
|
|
10
|
+
@name = name
|
|
11
|
+
@statuses = []
|
|
12
|
+
@limit = nil
|
|
13
|
+
@metadata_schema = nil
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|