stateful_models 0.0.3 → 0.0.5

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: ea223431133002302cf358288dc451d095400476158ae0ec3ecbca053ca83ab9
4
+ data.tar.gz: 16e622543d2566a058a0d95f5e92fcd40f050639e7e46adf570da7d6d1a2b33c
5
5
  SHA512:
6
- metadata.gz: b254a3c531422e64ec011fae308ab9999b812f1b85fea61bceb159656091ac9d9fab2723b5b0e8e0802944a1c92d29d3c94822e89f5cbc772cbf06218487f903
7
- data.tar.gz: d27742123bce580f7813de17adcee9c5485d5bfc60e2a628fa8f068ce3a6874af8a0e372127b89b86454263738a8563c22e2fb8455fa2c3dda75ef6a6b5d33df
6
+ metadata.gz: 1833b2d00efb841019aa486993080b7c57fdeb3c521dc0110fd39c9d999bf83b2027f9a256094cfcc0a8a50bf1390c71c1fde0254d0adb24f9c34dcd136f144f
7
+ data.tar.gz: 50d3df26b5d77592237a73ad84ec68807e2650250c802db1d50e7dd8b90d2753aba1fb021492fe629dbc358f2ea2299898bd6cca91c32098297bb959ea475041
data/CHANGELOG.md CHANGED
@@ -1,12 +1,19 @@
1
1
  ## Released
2
2
 
3
- ## [0.0.2] - 2024-12-23
3
+ ## [0.0.5] - 2025-04-14
4
+
5
+ ## Change
6
+ - Changed callback action on base class from `after_save` to `after_commit`
7
+
8
+ ## [0.0.4] - 2025-03-21
4
9
 
5
10
  ### Added
6
11
  - Single Table Inheritance (STI) support for custom state types
7
12
  - Ability to create custom state classes by inheriting from `HasStates::Base`
8
13
  - Default state class `HasStates::State` for basic state management
9
14
  - Example implementation of custom state types in documentation
15
+ - `limit` option for state types to limit the number of states on a record
16
+ - `metadata_schema` option for state types to validate metadata
10
17
 
11
18
  ### Changed
12
19
  - Refactored base state functionality into `HasStates::Base`
@@ -15,7 +22,7 @@
15
22
 
16
23
  ## [0.0.3] - 2024-01-14
17
24
 
18
- - Adding current_state method
25
+ - Adding `current_state(state_name)` method
19
26
  - Adding DB indexes for state lookups
20
27
  - Adding query methods for state types on stateable (`stateable.state_type` and `stateable.state_types`)
21
28
 
@@ -23,6 +30,10 @@
23
30
 
24
31
  - Added test coverage
25
32
  - Refactor of State model into a inheritable Base class
33
+ - Single Table Inheritance (STI) support for custom state types
34
+ - Ability to create custom state classes by inheriting from `HasStates::Base`
35
+ - Default state class `HasStates::State` for basic state management
36
+ - Example implementation of custom state types in documentation
26
37
 
27
38
  ## [0.0.1] - 2024-12-21
28
39
 
data/README.md CHANGED
@@ -85,8 +85,10 @@ state = user.add_state('kyc', status: 'pending', metadata: {
85
85
  notes: 'Awaiting document submission'
86
86
  })
87
87
 
88
- # Check current state
88
+ # Load state(s) also by name as methods on the record
89
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
90
92
 
91
93
  # Predicate methods are generated for every status.
92
94
  current_kyc.pending? # => true
@@ -130,6 +132,64 @@ state.metadata['documents']['passport']['status'] # => "verified"
130
132
  state.metadata['risk_score'] # => 85
131
133
  ```
132
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
+ HasStates.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
+ # => #<HasStates::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
+ HasStates.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
+
133
193
  ### Callbacks
134
194
 
135
195
  Register callbacks that execute when states change:
@@ -171,6 +231,20 @@ HasStates::State.kyc # All KYC states
171
231
  HasStates::State.onboarding # All onboarding states
172
232
  ```
173
233
 
234
+ ### Class Inheritance
235
+
236
+ 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.
237
+
238
+ ```ruby
239
+ class MyState < HasStates::Base
240
+ # Add validations or methods
241
+
242
+ def do_something
243
+ # Custom method on state
244
+ end
245
+ end
246
+ ```
247
+
174
248
  ## Contributing
175
249
 
176
250
  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,8 +10,10 @@ 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
- after_save :trigger_callbacks, if: :saved_change_to_status?
16
+ after_commit :trigger_callbacks, if: :saved_change_to_status?
13
17
 
14
18
  private
15
19
 
@@ -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.5'
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