stateful_models 0.0.3 → 0.0.4
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 +4 -4
- data/CHANGELOG.md +8 -2
- data/README.md +68 -0
- data/lib/generators/has_states/install/install_generator.rb +6 -6
- data/lib/has_states/base.rb +30 -1
- data/lib/has_states/configuration/state_type_configuration.rb +3 -1
- data/lib/has_states/configuration.rb +75 -16
- data/lib/has_states/version.rb +1 -1
- data/lib/has_states.rb +1 -1
- data/spec/dummy/Gemfile.lock +123 -101
- data/spec/dummy/config/initializers/has_states.rb +3 -3
- data/spec/dummy/db/migrate/20241223212128_create_has_states_states.rb +2 -2
- data/spec/dummy/db/migrate/20250114175939_create_indexes_on_has_states_states.rb +3 -3
- data/spec/dummy/db/schema.rb +26 -26
- data/spec/dummy/log/development.log +2 -0
- data/spec/dummy/log/test.log +27507 -0
- data/spec/generators/has_states/install_generator_spec.rb +1 -1
- data/spec/has_states/configuration_spec.rb +57 -0
- data/spec/has_states/state_limit_spec.rb +107 -0
- data/spec/has_states/state_metadata_schema_spec.rb +75 -0
- data/spec/has_states/state_spec.rb +4 -3
- data/spec/has_states/stateable_spec.rb +9 -10
- metadata +23 -7
- /data/spec/generators/{tmp → templates}/config/initializers/has_states.rb +0 -0
- /data/spec/generators/{tmp/db/migrate/20250114180401_create_has_states_states.rb → templates/db/migrate/20250322001530_create_has_states_states.rb} +0 -0
- /data/spec/generators/{tmp/db/migrate/20250114180401_create_indexes_on_has_states_states.rb → templates/db/migrate/20250322001530_create_indexes_on_has_states_states.rb} +0 -0
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 78629281fcf54961d33cb8c85161df6a357b432b87bff61d5ff769d5491a7667
|
4
|
+
data.tar.gz: 63f984222fb3aad0f086f4d65f263e8134b0243f7186ea5c8818a4ab046890bc
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 802ccd260a0560dbb9a4968641230c8d3c4452fd81c856f1dcc500e7f37f0d1e29eb9613f7c4581cdcc18a3ac6e9f1017662cf9790874562228b133d1bd8538b
|
7
|
+
data.tar.gz: 3db09b9db294f748590d3800175512fc555eb84296799b7242479f4727e33088e7d6dd1d0457ef586b1bb043912415ed3cbee95b7210e129802330d6e589d04b
|
data/CHANGELOG.md
CHANGED
@@ -1,12 +1,14 @@
|
|
1
1
|
## Released
|
2
2
|
|
3
|
-
## [0.0.
|
3
|
+
## [0.0.4] - 2025-03-21
|
4
4
|
|
5
5
|
### Added
|
6
6
|
- Single Table Inheritance (STI) support for custom state types
|
7
7
|
- Ability to create custom state classes by inheriting from `HasStates::Base`
|
8
8
|
- Default state class `HasStates::State` for basic state management
|
9
9
|
- Example implementation of custom state types in documentation
|
10
|
+
- `limit` option for state types to limit the number of states on a record
|
11
|
+
- `metadata_schema` option for state types to validate metadata
|
10
12
|
|
11
13
|
### Changed
|
12
14
|
- Refactored base state functionality into `HasStates::Base`
|
@@ -15,7 +17,7 @@
|
|
15
17
|
|
16
18
|
## [0.0.3] - 2024-01-14
|
17
19
|
|
18
|
-
- Adding current_state method
|
20
|
+
- Adding `current_state(state_name)` method
|
19
21
|
- Adding DB indexes for state lookups
|
20
22
|
- Adding query methods for state types on stateable (`stateable.state_type` and `stateable.state_types`)
|
21
23
|
|
@@ -23,6 +25,10 @@
|
|
23
25
|
|
24
26
|
- Added test coverage
|
25
27
|
- Refactor of State model into a inheritable Base class
|
28
|
+
- Single Table Inheritance (STI) support for custom state types
|
29
|
+
- Ability to create custom state classes by inheriting from `HasStates::Base`
|
30
|
+
- Default state class `HasStates::State` for basic state management
|
31
|
+
- Example implementation of custom state types in documentation
|
26
32
|
|
27
33
|
## [0.0.1] - 2024-12-21
|
28
34
|
|
data/README.md
CHANGED
@@ -130,6 +130,64 @@ state.metadata['documents']['passport']['status'] # => "verified"
|
|
130
130
|
state.metadata['risk_score'] # => 85
|
131
131
|
```
|
132
132
|
|
133
|
+
## Metadata Validations
|
134
|
+
|
135
|
+
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.
|
136
|
+
|
137
|
+
```ruby
|
138
|
+
HasStates.configure do |config|
|
139
|
+
config.configure_model User do |model|
|
140
|
+
model.state_type :kyc do |type|
|
141
|
+
type.statuses = [
|
142
|
+
'pending',
|
143
|
+
'approved',
|
144
|
+
'rejected'
|
145
|
+
]
|
146
|
+
|
147
|
+
type.metadata_schema = {
|
148
|
+
type: :object,
|
149
|
+
properties: {
|
150
|
+
name: { type: :string },
|
151
|
+
age: {
|
152
|
+
type: :integer,
|
153
|
+
minimum: 18
|
154
|
+
},
|
155
|
+
},
|
156
|
+
required: [:name, :age],
|
157
|
+
additionalProperties: false,
|
158
|
+
}
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
user = User.create!
|
164
|
+
|
165
|
+
# Invalid metadata (too young < 18)
|
166
|
+
user.add_state('kyc', status: 'pending', metadata: { name: 'John Doe', age: 17 })
|
167
|
+
# ActiveRecord::RecordInvalid: Validation failed: Metadata is invalid (minimum value of 18)
|
168
|
+
|
169
|
+
# Valid metadata
|
170
|
+
user.add_state('kyc', status: 'pending', metadata: { name: 'John Doe', age: 25 })
|
171
|
+
# => #<HasStates::State...>
|
172
|
+
```
|
173
|
+
|
174
|
+
## State Limits
|
175
|
+
|
176
|
+
You can optionally limit the number of states a record can have for a specific state type:
|
177
|
+
|
178
|
+
```ruby
|
179
|
+
HasStates.configure do |config|
|
180
|
+
config.configure_model User do |model|
|
181
|
+
model.state_type :kyc do |type|
|
182
|
+
type.statuses = %w[pending completed]
|
183
|
+
type.limit = 1 # Limit to only one KYC state per user
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
```
|
188
|
+
|
189
|
+
When set, the limit is checked when a new state is added. If the limit is exceeded, an ActiveRecord::RecordInvalid error is raised.
|
190
|
+
|
133
191
|
### Callbacks
|
134
192
|
|
135
193
|
Register callbacks that execute when states change:
|
@@ -171,6 +229,16 @@ HasStates::State.kyc # All KYC states
|
|
171
229
|
HasStates::State.onboarding # All onboarding states
|
172
230
|
```
|
173
231
|
|
232
|
+
### Class Inheritance
|
233
|
+
|
234
|
+
HasStates lets you inherit from the `HasStates::Base` class to create custom state classes. This makes validations and custom methods on specific state types easy.
|
235
|
+
|
236
|
+
```ruby
|
237
|
+
class MyState < HasStates::Base
|
238
|
+
# Add validations or methods
|
239
|
+
end
|
240
|
+
```
|
241
|
+
|
174
242
|
## Contributing
|
175
243
|
|
176
244
|
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/has_states.
|
@@ -9,33 +9,33 @@ module HasStates
|
|
9
9
|
TEMPLATES = [
|
10
10
|
{
|
11
11
|
source: 'create_has_states_states.rb.erb',
|
12
|
-
destination:
|
12
|
+
destination: 'db/migrate/%s_create_has_states_states.rb'
|
13
13
|
},
|
14
14
|
{
|
15
15
|
source: 'create_indexes_on_has_states_states.rb.erb',
|
16
|
-
destination:
|
16
|
+
destination: 'db/migrate/%s_create_indexes_on_has_states_states.rb'
|
17
17
|
},
|
18
18
|
{
|
19
19
|
source: 'initializer.rb.erb',
|
20
|
-
destination:
|
20
|
+
destination: 'config/initializers/has_states.rb'
|
21
21
|
}
|
22
22
|
].freeze
|
23
23
|
|
24
24
|
def install
|
25
25
|
puts 'Installing HasStates...'
|
26
26
|
|
27
|
-
TEMPLATES.each do |template|
|
27
|
+
TEMPLATES.each do |template|
|
28
28
|
make_template(**template)
|
29
29
|
end
|
30
30
|
|
31
31
|
puts 'HasStates installed successfully!'
|
32
32
|
end
|
33
33
|
|
34
|
-
private
|
34
|
+
private
|
35
35
|
|
36
36
|
def make_template(source:, destination:)
|
37
37
|
timestamp = Time.now.utc.strftime('%Y%m%d%H%M%S')
|
38
|
-
destination
|
38
|
+
destination %= timestamp if destination.include?('%s')
|
39
39
|
|
40
40
|
template(source, destination)
|
41
41
|
end
|
data/lib/has_states/base.rb
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'json-schema'
|
4
|
+
|
3
5
|
module HasStates
|
4
6
|
class Base < ActiveRecord::Base
|
5
7
|
self.table_name = 'has_states_states'
|
@@ -8,6 +10,8 @@ module HasStates
|
|
8
10
|
|
9
11
|
validate :status_is_configured
|
10
12
|
validate :state_type_is_configured
|
13
|
+
validate :state_limit_not_exceeded, on: :create
|
14
|
+
validate :metadata_conforms_to_schema, if: -> { metadata.present? }
|
11
15
|
|
12
16
|
after_save :trigger_callbacks, if: :saved_change_to_status?
|
13
17
|
|
@@ -32,10 +36,35 @@ module HasStates
|
|
32
36
|
errors.add(:state_type, 'is not configured')
|
33
37
|
end
|
34
38
|
|
39
|
+
def state_limit_not_exceeded
|
40
|
+
return unless (limit = HasStates.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 = HasStates.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
|
+
|
35
64
|
def trigger_callbacks
|
36
65
|
HasStates.configuration.matching_callbacks(self).each do |callback|
|
37
66
|
callback.call(self)
|
38
67
|
end
|
39
68
|
end
|
40
69
|
end
|
41
|
-
end
|
70
|
+
end
|
@@ -4,11 +4,13 @@ module HasStates
|
|
4
4
|
class Configuration
|
5
5
|
class StateTypeConfiguration
|
6
6
|
attr_reader :name
|
7
|
-
attr_accessor :statuses
|
7
|
+
attr_accessor :statuses, :limit, :metadata_schema
|
8
8
|
|
9
9
|
def initialize(name)
|
10
10
|
@name = name
|
11
11
|
@statuses = []
|
12
|
+
@limit = nil
|
13
|
+
@metadata_schema = nil
|
12
14
|
end
|
13
15
|
end
|
14
16
|
end
|
@@ -12,6 +12,10 @@ module HasStates
|
|
12
12
|
@model_configurations = {}
|
13
13
|
end
|
14
14
|
|
15
|
+
# Configure a model to use HasStates
|
16
|
+
# @param model_class [Class] The model class to configure
|
17
|
+
# @return [Configuration::ModelConfiguration] The model configuration
|
18
|
+
# @raise [ArgumentError] If the model class is not an ActiveRecord model
|
15
19
|
def configure_model(model_class)
|
16
20
|
unless model_class.is_a?(Class) && model_class < ActiveRecord::Base
|
17
21
|
raise ArgumentError, "#{model_class} must be an ActiveRecord model"
|
@@ -25,16 +29,76 @@ module HasStates
|
|
25
29
|
model_class.include(Stateable) unless model_class.included_modules.include?(Stateable)
|
26
30
|
end
|
27
31
|
|
32
|
+
# Check if a status is valid for a given state type
|
33
|
+
# @param model_class [Class] The model class
|
34
|
+
# @param state_type [String] The state type
|
35
|
+
# @param status [String] The status
|
36
|
+
# @return [Boolean] True if the status is valid, false otherwise
|
28
37
|
def valid_status?(model_class, state_type, status)
|
29
|
-
return false unless @model_configurations[model_class]&.state_types&.
|
38
|
+
return false unless @model_configurations[model_class]&.state_types&.dig(state_type.to_s)
|
30
39
|
|
31
40
|
@model_configurations[model_class].state_types[state_type.to_s].statuses.include?(status)
|
32
41
|
end
|
33
42
|
|
43
|
+
# Check if a state type is valid for a given model
|
44
|
+
# @param model_class [Class] The model class
|
45
|
+
# @param state_type [String] The state type
|
46
|
+
# @return [Boolean] True if the state type is valid, false otherwise
|
34
47
|
def valid_state_type?(model_class, state_type)
|
35
48
|
@model_configurations[model_class]&.state_types&.key?(state_type.to_s)
|
36
49
|
end
|
37
50
|
|
51
|
+
# Get the configuration for a given state type
|
52
|
+
# @param model_class [Class] The model class
|
53
|
+
# @param state_type [String] The state type
|
54
|
+
# @return [Configuration::StateTypeConfiguration] The state type configuration
|
55
|
+
def config_for(model_class, state_type)
|
56
|
+
@model_configurations[model_class]&.state_types&.dig(state_type.to_s)
|
57
|
+
end
|
58
|
+
|
59
|
+
# Get the state types for a given model
|
60
|
+
# @param model_class [Class] The model class
|
61
|
+
# @return [Hash] The state types for the model
|
62
|
+
def state_types_for(model_class)
|
63
|
+
@model_configurations[model_class]&.state_types
|
64
|
+
end
|
65
|
+
|
66
|
+
# Get the statuses for a given state type
|
67
|
+
# @param model_class [Class] The model class
|
68
|
+
# @param state_type [String] The state type
|
69
|
+
# @return [Array] The statuses for the state type
|
70
|
+
def statuses_for(model_class, state_type)
|
71
|
+
return nil unless (config = config_for(model_class, state_type))
|
72
|
+
|
73
|
+
config.statuses
|
74
|
+
end
|
75
|
+
|
76
|
+
# Get the limit for a given state type
|
77
|
+
# @param model_class [Class] The model class
|
78
|
+
# @param state_type [String] The state type
|
79
|
+
# @return [Integer] The limit for the state type
|
80
|
+
def limit_for(model_class, state_type)
|
81
|
+
return nil unless (config = config_for(model_class, state_type))
|
82
|
+
|
83
|
+
config.limit
|
84
|
+
end
|
85
|
+
|
86
|
+
# Get the metadata schema for a given state type
|
87
|
+
# @param model_class [Class] The model class
|
88
|
+
# @param state_type [String] The state type
|
89
|
+
# @return [Hash] The metadata schema for the state type
|
90
|
+
def metadata_schema_for(model_class, state_type)
|
91
|
+
return nil unless (config = config_for(model_class, state_type))
|
92
|
+
|
93
|
+
config.metadata_schema
|
94
|
+
end
|
95
|
+
|
96
|
+
# Register a callback for a given state type
|
97
|
+
# @param state_type [String] The state type
|
98
|
+
# @param id [Symbol] The callback id
|
99
|
+
# @param conditions [Hash] The conditions for the callback
|
100
|
+
# @param block [Proc] The callback block
|
101
|
+
# @return [Callback] The callback
|
38
102
|
def on(state_type, id: nil, **conditions, &block)
|
39
103
|
callback = Callback.new(state_type, conditions, block)
|
40
104
|
callback_id = id&.to_sym || generate_callback_id
|
@@ -42,6 +106,9 @@ module HasStates
|
|
42
106
|
callback
|
43
107
|
end
|
44
108
|
|
109
|
+
# Remove a callback by id or callback object
|
110
|
+
# @param callback_or_id [Symbol, Callback] The callback id or callback object
|
111
|
+
# @return [Callback] The removed callback
|
45
112
|
def off(callback_or_id)
|
46
113
|
if callback_or_id.is_a?(Callback)
|
47
114
|
@callbacks.delete_if { |_, cb| cb == callback_or_id }
|
@@ -50,31 +117,23 @@ module HasStates
|
|
50
117
|
end
|
51
118
|
end
|
52
119
|
|
120
|
+
# Get the callbacks that match a given state
|
121
|
+
# @param state [State] The state
|
122
|
+
# @return [Array] The matching callbacks
|
53
123
|
def matching_callbacks(state)
|
54
124
|
@callbacks.values.select { |callback| callback.matches?(state) }
|
55
125
|
end
|
56
126
|
|
127
|
+
# Clear all callbacks
|
128
|
+
# @return [void]
|
57
129
|
def clear_callbacks!
|
58
130
|
@callbacks = {}
|
59
131
|
end
|
60
132
|
|
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
133
|
private
|
77
134
|
|
135
|
+
# Generate a unique callback id
|
136
|
+
# @return [Symbol] The generated callback id
|
78
137
|
def generate_callback_id
|
79
138
|
@next_callback_id += 1
|
80
139
|
:"callback_#{@next_callback_id}"
|
data/lib/has_states/version.rb
CHANGED