stateful_models 0.0.2 → 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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -2
  3. data/README.md +68 -0
  4. data/lib/generators/has_states/install/install_generator.rb +27 -9
  5. data/lib/generators/has_states/install/templates/create_has_states_states.rb.erb +1 -1
  6. data/lib/generators/has_states/install/templates/create_indexes_on_has_states_states.rb.erb +11 -0
  7. data/lib/has_states/base.rb +30 -1
  8. data/lib/has_states/configuration/model_configuration.rb +16 -0
  9. data/lib/has_states/configuration/state_type_configuration.rb +3 -1
  10. data/lib/has_states/configuration.rb +75 -16
  11. data/lib/has_states/state.rb +1 -2
  12. data/lib/has_states/stateable.rb +11 -8
  13. data/lib/has_states/version.rb +1 -1
  14. data/lib/has_states.rb +1 -1
  15. data/spec/dummy/Gemfile.lock +123 -101
  16. data/spec/dummy/config/initializers/has_states.rb +44 -41
  17. data/spec/dummy/db/migrate/20241223212128_create_has_states_states.rb +2 -2
  18. data/spec/dummy/db/migrate/20250114175939_create_indexes_on_has_states_states.rb +10 -0
  19. data/spec/dummy/db/schema.rb +26 -22
  20. data/spec/dummy/log/development.log +88 -0
  21. data/spec/dummy/log/test.log +33161 -0
  22. data/spec/dummy/storage/development.sqlite3 +0 -0
  23. data/spec/dummy/storage/test.sqlite3 +0 -0
  24. data/spec/generators/has_states/install_generator_spec.rb +1 -1
  25. data/spec/generators/{tmp/db/migrate/20241223213845_create_has_states_states.rb → templates/db/migrate/20250322001530_create_has_states_states.rb} +1 -1
  26. data/spec/generators/templates/db/migrate/20250322001530_create_indexes_on_has_states_states.rb +11 -0
  27. data/spec/has_states/configuration_spec.rb +57 -0
  28. data/spec/has_states/state_limit_spec.rb +107 -0
  29. data/spec/has_states/state_metadata_schema_spec.rb +75 -0
  30. data/spec/has_states/state_spec.rb +38 -15
  31. data/spec/has_states/stateable_spec.rb +183 -0
  32. data/spec/rails_helper.rb +1 -0
  33. metadata +26 -6
  34. /data/spec/generators/{tmp → templates}/config/initializers/has_states.rb +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 140dacad43c4287b2317ab10567b808c4c6c751715a640d04560ea9396f15b11
4
- data.tar.gz: b0590f0190d823fad1693ebec1a65e933d465693ac0ecb2688f045fab2f84474
3
+ metadata.gz: 78629281fcf54961d33cb8c85161df6a357b432b87bff61d5ff769d5491a7667
4
+ data.tar.gz: 63f984222fb3aad0f086f4d65f263e8134b0243f7186ea5c8818a4ab046890bc
5
5
  SHA512:
6
- metadata.gz: c0e603d13345f3c36c68dcea633b2d0d37d1de41d5de961b63f81970b572e90e76a2b2f642c2868a94f1b440400f795d4243490159ed58265ab45770a27cb5d2
7
- data.tar.gz: dc8688d4ebc992dc7cf32a92dc17d1bda1c2de58ea6747292fd746b529b3a5fdccbe9295090854b252b6b42689fed613b30821655d2a8deb4462b4daabaa13d5
6
+ metadata.gz: 802ccd260a0560dbb9a4968641230c8d3c4452fd81c856f1dcc500e7f37f0d1e29eb9613f7c4581cdcc18a3ac6e9f1017662cf9790874562228b133d1bd8538b
7
+ data.tar.gz: 3db09b9db294f748590d3800175512fc555eb84296799b7242479f4727e33088e7d6dd1d0457ef586b1bb043912415ed3cbee95b7210e129802330d6e589d04b
data/CHANGELOG.md CHANGED
@@ -1,19 +1,36 @@
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`
13
15
  - Updated `add_state` method to support custom state classes
14
16
  - Improved test coverage for inheritance and custom state types
15
17
 
16
- ## [0.1.0] - 2024-12-21
18
+ ## [0.0.3] - 2024-01-14
19
+
20
+ - Adding `current_state(state_name)` method
21
+ - Adding DB indexes for state lookups
22
+ - Adding query methods for state types on stateable (`stateable.state_type` and `stateable.state_types`)
23
+
24
+ ## [0.0.2] - 2024-12-23
25
+
26
+ - Added test coverage
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
32
+
33
+ ## [0.0.1] - 2024-12-21
17
34
 
18
35
  - Initial release
19
36
  - See READme.md
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.
@@ -6,20 +6,38 @@ module HasStates
6
6
  class InstallGenerator < Rails::Generators::Base
7
7
  source_root File.expand_path('templates', __dir__)
8
8
 
9
+ TEMPLATES = [
10
+ {
11
+ source: 'create_has_states_states.rb.erb',
12
+ destination: 'db/migrate/%s_create_has_states_states.rb'
13
+ },
14
+ {
15
+ source: 'create_indexes_on_has_states_states.rb.erb',
16
+ destination: 'db/migrate/%s_create_indexes_on_has_states_states.rb'
17
+ },
18
+ {
19
+ source: 'initializer.rb.erb',
20
+ destination: 'config/initializers/has_states.rb'
21
+ }
22
+ ].freeze
23
+
9
24
  def install
10
25
  puts 'Installing HasStates...'
11
26
 
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
- )
27
+ TEMPLATES.each do |template|
28
+ make_template(**template)
29
+ end
21
30
 
22
31
  puts 'HasStates installed successfully!'
23
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
24
42
  end
25
43
  end
@@ -1,4 +1,4 @@
1
- class CreateHasStatesStates < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
1
+ class CreateHasStatesStates < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
2
  def change
3
3
  create_table :has_states_states do |t|
4
4
  t.string :type, null: false
@@ -0,0 +1,11 @@
1
+ class CreateIndexesOnHasStatesStates < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ change_table :has_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
@@ -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
@@ -17,8 +17,12 @@ module HasStates
17
17
 
18
18
  @state_types[name.to_s] = type
19
19
 
20
+ # HasStates::State model method generators
20
21
  generate_state_type_scope(name)
21
22
  generate_status_predicates(type.statuses)
23
+
24
+ # Included model method generators
25
+ generate_state_type_queries(name)
22
26
  generate_state_type_status_predicates(name, type.statuses)
23
27
  end
24
28
 
@@ -36,6 +40,18 @@ module HasStates
36
40
  end
37
41
  end
38
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
+
39
55
  def generate_state_type_status_predicates(state_type, statuses)
40
56
  statuses.each do |status_name|
41
57
  @model_class.define_method(:"#{state_type}_#{status_name}?") do
@@ -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,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HasStates
4
- class State < Base
5
- end
4
+ class State < Base; end
6
5
  end
@@ -6,18 +6,21 @@ module HasStates
6
6
 
7
7
  included do
8
8
  has_many :states, class_name: 'HasStates::Base',
9
- as: :stateable,
10
- dependent: :destroy
9
+ as: :stateable,
10
+ dependent: :destroy
11
11
  end
12
12
 
13
13
  # Instance methods for managing states
14
14
  def add_state(type, status: 'pending', metadata: {}, state_class: HasStates::State)
15
- states.create!(
16
- type: state_class.name,
17
- state_type: type,
18
- status: status,
19
- metadata: metadata
20
- )
15
+ states.create!(type: state_class.name, state_type: type, status: status, metadata: metadata)
16
+ end
17
+
18
+ def current_state(type)
19
+ states.where(state_type: type).order(created_at: :desc).first
20
+ end
21
+
22
+ def current_states(type)
23
+ states.where(state_type: type).order(created_at: :desc)
21
24
  end
22
25
  end
23
26
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HasStates
4
- VERSION = '0.0.2'
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