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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8eb127e7632f183232f299936b44df5fc17f3208decb1da2863c802e8f4f2b69
4
- data.tar.gz: bb6062a0184b16afa0a933f8e9c35fd9ca90ad1e8129b6de95ce6d1c1433ffb2
3
+ metadata.gz: 78629281fcf54961d33cb8c85161df6a357b432b87bff61d5ff769d5491a7667
4
+ data.tar.gz: 63f984222fb3aad0f086f4d65f263e8134b0243f7186ea5c8818a4ab046890bc
5
5
  SHA512:
6
- metadata.gz: b254a3c531422e64ec011fae308ab9999b812f1b85fea61bceb159656091ac9d9fab2723b5b0e8e0802944a1c92d29d3c94822e89f5cbc772cbf06218487f903
7
- data.tar.gz: d27742123bce580f7813de17adcee9c5485d5bfc60e2a628fa8f068ce3a6874af8a0e372127b89b86454263738a8563c22e2fb8455fa2c3dda75ef6a6b5d33df
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.2] - 2024-12-23
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: "db/migrate/%s_create_has_states_states.rb"
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: "db/migrate/%s_create_indexes_on_has_states_states.rb"
16
+ destination: 'db/migrate/%s_create_indexes_on_has_states_states.rb'
17
17
  },
18
18
  {
19
19
  source: 'initializer.rb.erb',
20
- destination: "config/initializers/has_states.rb"
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 = destination % timestamp if destination.include?('%s')
38
+ destination %= timestamp if destination.include?('%s')
39
39
 
40
40
  template(source, destination)
41
41
  end
@@ -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&.[](state_type.to_s)
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}"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HasStates
4
- VERSION = '0.0.3'
4
+ VERSION = '0.0.4'
5
5
  end
data/lib/has_states.rb CHANGED
@@ -11,7 +11,7 @@ module HasStates
11
11
  def configure
12
12
  yield(configuration)
13
13
  end
14
-
14
+
15
15
  def configuration
16
16
  Configuration.instance
17
17
  end