stateful_models 0.0.1 → 0.0.3

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 (27) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -1
  3. data/README.md +1 -1
  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 +3 -2
  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 +41 -0
  8. data/lib/has_states/configuration/model_configuration.rb +16 -0
  9. data/lib/has_states/state.rb +1 -37
  10. data/lib/has_states/stateable.rb +11 -7
  11. data/lib/has_states/version.rb +1 -1
  12. data/lib/has_states.rb +2 -1
  13. data/spec/dummy/Gemfile.lock +2 -2
  14. data/spec/dummy/config/initializers/has_states.rb +45 -0
  15. data/spec/{generators/tmp/db/migrate/20241223020432_create_has_states_states.rb → dummy/db/migrate/20241223212128_create_has_states_states.rb} +2 -1
  16. data/spec/dummy/db/migrate/20250114175939_create_indexes_on_has_states_states.rb +10 -0
  17. data/spec/dummy/db/schema.rb +26 -22
  18. data/spec/dummy/log/development.log +203 -0
  19. data/spec/dummy/log/test.log +7151 -0
  20. data/spec/dummy/storage/development.sqlite3 +0 -0
  21. data/spec/dummy/storage/test.sqlite3 +0 -0
  22. data/spec/{dummy/db/migrate/20241221183116_create_has_states_tables.rb → generators/tmp/db/migrate/20250114180401_create_has_states_states.rb} +4 -5
  23. data/spec/generators/tmp/db/migrate/20250114180401_create_indexes_on_has_states_states.rb +11 -0
  24. data/spec/has_states/state_spec.rb +96 -12
  25. data/spec/has_states/stateable_spec.rb +184 -0
  26. data/spec/rails_helper.rb +1 -0
  27. metadata +10 -4
Binary file
Binary file
@@ -1,8 +1,7 @@
1
- # frozen_string_literal: true
2
-
3
- class CreateHasStatesTables < ActiveRecord::Migration[7.1]
1
+ class CreateHasStatesStates < ActiveRecord::Migration[8.0]
4
2
  def change
5
3
  create_table :has_states_states do |t|
4
+ t.string :type, null: false
6
5
  t.string :state_type
7
6
  t.string :status, null: false
8
7
 
@@ -10,10 +9,10 @@ class CreateHasStatesTables < ActiveRecord::Migration[7.1]
10
9
 
11
10
  t.references :stateable, polymorphic: true, null: false
12
11
 
13
- t.datetime :completed_at
14
12
  t.timestamps
15
13
 
14
+ t.index %i[type stateable_id]
16
15
  t.index %i[stateable_type stateable_id]
17
16
  end
18
17
  end
19
- end
18
+ end
@@ -0,0 +1,11 @@
1
+ class CreateIndexesOnHasStatesStates < ActiveRecord::Migration[8.0]
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
@@ -22,6 +22,28 @@ RSpec.describe HasStates::State, type: :model do
22
22
  end
23
23
  end
24
24
 
25
+ describe 'indexes' do
26
+ it 'defines an index on stateable_type and stateable_id' do
27
+ expect(subject).to have_db_index(%i[stateable_type stateable_id])
28
+ end
29
+
30
+ it 'defines an index on stateable_id and state_type' do
31
+ expect(subject).to have_db_index(%i[stateable_id state_type])
32
+ end
33
+
34
+ it 'defines an index on stateable_id, state_type, and status' do
35
+ expect(subject).to have_db_index(%i[stateable_id state_type status])
36
+ end
37
+
38
+ it 'defines an index on stateable_id, state_type, and created_at' do
39
+ expect(subject).to have_db_index(%i[stateable_id state_type created_at])
40
+ end
41
+
42
+ it 'defines an index on stateable_id, state_type, status, and created_at' do
43
+ expect(subject).to have_db_index(%i[stateable_id state_type status created_at])
44
+ end
45
+ end
46
+
25
47
  describe 'validations' do
26
48
  context 'status' do
27
49
  it 'validates status presence' do
@@ -63,22 +85,22 @@ RSpec.describe HasStates::State, type: :model do
63
85
  describe 'instance methods' do
64
86
  let(:state) { create(:state, state_type: 'kyc', status: 'pending') }
65
87
  let(:model_config) { HasStates.configuration.model_configurations[User] }
66
- let(:all_statuses) do
67
- model_config.state_types.values.flat_map(&:statuses).uniq
68
- end
88
+ let(:all_statuses) { model_config.state_types.values.flat_map(&:statuses).uniq }
69
89
 
70
- it 'defines predicate methods for all configured statuses' do
71
- all_statuses.each do |status|
72
- expect(state).to respond_to("#{status}?")
90
+ context 'predicate methods' do
91
+ it 'defines predicate methods for all configured statuses' do
92
+ all_statuses.each do |status|
93
+ expect(state).to respond_to("#{status}?")
94
+ end
73
95
  end
74
- end
75
96
 
76
- it 'returns true if the status is pending' do
77
- expect(state.pending?).to be(true)
78
- end
97
+ it 'returns true if the status is pending' do
98
+ expect(state.pending?).to be(true)
99
+ end
79
100
 
80
- it 'returns false if the status is not completed' do
81
- expect(state.completed?).to be(false)
101
+ it 'returns false if the status is not completed' do
102
+ expect(state.completed?).to be(false)
103
+ end
82
104
  end
83
105
  end
84
106
 
@@ -261,4 +283,66 @@ RSpec.describe HasStates::State, type: :model do
261
283
  end.to change { HasStates.configuration.callbacks.size }.from(1).to(0)
262
284
  end
263
285
  end
286
+
287
+ describe 'custom state types' do
288
+ # Create a custom state class for testing
289
+ class KYCState < HasStates::Base
290
+ validates :metadata, presence: true
291
+ validate :required_metadata_fields
292
+
293
+ private
294
+
295
+ def required_metadata_fields
296
+ return if metadata&.key?('document_type')
297
+ errors.add(:metadata, 'must include document_type')
298
+ end
299
+ end
300
+
301
+ let(:user) { create(:user) }
302
+
303
+ it 'allows creation of custom state types' do
304
+ state = user.add_state(
305
+ 'kyc',
306
+ status: 'pending',
307
+ metadata: { document_type: 'passport' },
308
+ state_class: KYCState
309
+ )
310
+
311
+ expect(state).to be_valid
312
+ expect(state).to be_a(KYCState)
313
+ end
314
+
315
+ it 'enforces custom validations' do
316
+ expect do
317
+ user.add_state(
318
+ 'kyc',
319
+ status: 'pending',
320
+ metadata: { other_field: 'value' },
321
+ state_class: KYCState
322
+ )
323
+ end.to raise_error(
324
+ ActiveRecord::RecordInvalid,
325
+ /Metadata must include document_type/
326
+ )
327
+ end
328
+
329
+ it 'defaults to HasStates::State when no state_class specified' do
330
+ state = user.add_state('kyc', status: 'pending')
331
+
332
+ expect(state).to be_a(HasStates::State)
333
+ expect(state).to be_valid
334
+ end
335
+
336
+ it 'maintains STI type across database reads' do
337
+ state = user.add_state(
338
+ 'kyc',
339
+ status: 'pending',
340
+ metadata: { document_type: 'passport' },
341
+ state_class: KYCState
342
+ )
343
+
344
+ reloaded_state = HasStates::Base.find(state.id)
345
+ expect(reloaded_state).to be_a(KYCState)
346
+ end
347
+ end
264
348
  end
@@ -0,0 +1,184 @@
1
+ require 'rails_helper'
2
+
3
+ RSpec.describe HasStates::Stateable do
4
+ # Create a test class that includes the concern
5
+ let(:test_class) do
6
+ Class.new do
7
+ include HasStates::Stateable
8
+ include ActiveRecord::Model
9
+ end
10
+ end
11
+
12
+ let(:user) { create(:user) } # Assuming you have a User factory
13
+
14
+ before do
15
+ # HasStates.configuration.clear_callbacks!
16
+ # HasStates.configuration.model_configurations.clear
17
+
18
+ HasStates.configure do |config|
19
+ config.configure_model User do |model|
20
+ model.state_type :test_type do |type|
21
+ type.statuses = %w[pending completed]
22
+ end
23
+
24
+ model.state_type :other_test_type do |type|
25
+ type.statuses = %w[pending completed]
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ describe 'associations' do
32
+ it 'has many states' do
33
+ association = User.reflect_on_association(:states)
34
+
35
+ expect(association.macro).to eq :has_many
36
+ expect(association.options[:as]).to eq :stateable
37
+ expect(association.options[:dependent]).to eq :destroy
38
+ expect(association.options[:class_name]).to eq 'HasStates::Base'
39
+ end
40
+ end
41
+
42
+ describe '#add_state' do
43
+ context 'with default parameters' do
44
+ it 'creates a new state with default values' do
45
+ state = user.add_state('test_type')
46
+
47
+ expect(state).to be_persisted
48
+ expect(state.metadata).to eq({})
49
+ expect(state.status).to eq 'pending'
50
+ expect(state.state_type).to eq 'test_type'
51
+ expect(state.type).to eq 'HasStates::State'
52
+ end
53
+ end
54
+
55
+ context 'with custom parameters' do
56
+ let(:metadata) { { key: 'value' } }
57
+
58
+ it 'creates a new state with provided values' do
59
+ state = user.add_state('test_type', status: 'completed', metadata: metadata)
60
+
61
+ expect(state).to be_persisted
62
+ expect(state.status).to eq 'completed'
63
+ expect(state.state_type).to eq 'test_type'
64
+ expect(state.type).to eq 'HasStates::State'
65
+ expect(state.metadata).to eq metadata.as_json
66
+ end
67
+ end
68
+
69
+ context 'with custom state class' do
70
+ before(:all) do
71
+ module HasStates
72
+ class CustomState < State; end
73
+ end
74
+ end
75
+
76
+ after(:all) do
77
+ HasStates.send(:remove_const, :CustomState)
78
+ end
79
+
80
+ it 'creates a new state with the specified class' do
81
+ state = user.add_state('test_type', state_class: HasStates::CustomState)
82
+
83
+ expect(state).to be_persisted
84
+ expect(state.type).to eq 'HasStates::CustomState'
85
+ end
86
+ end
87
+
88
+ context 'when validation fails' do
89
+ before do
90
+ allow_any_instance_of(HasStates::Base).to receive(:save!).and_raise(ActiveRecord::RecordInvalid)
91
+ end
92
+
93
+ it 'raises an ActiveRecord::RecordInvalid error' do
94
+ expect { user.add_state('test_type') }.to raise_error(ActiveRecord::RecordInvalid)
95
+ end
96
+ end
97
+ end
98
+
99
+ describe '#current_state' do
100
+ let!(:latest_state) { user.add_state('test_type', status: 'completed') }
101
+
102
+ before do
103
+ travel_to 1.day.ago do
104
+ 3.times { user.add_state('test_type', status: 'pending') }
105
+ 2.times { user.add_state('other_test_type', status: 'pending') }
106
+ end
107
+ end
108
+
109
+ it 'returns the most recent state for the given type' do
110
+ expect(user.current_state('test_type')).to eq latest_state
111
+ end
112
+
113
+
114
+ it 'returns nil when no state exists for the given type' do
115
+ expect(user.current_state('nonexistent')).to be_nil
116
+ end
117
+ end
118
+
119
+ describe '#current_states' do
120
+ before do
121
+ 3.times { user.add_state('test_type', status: 'pending') }
122
+ 3.times { user.add_state('other_test_type', status: 'completed') }
123
+ end
124
+
125
+ it 'returns all states for the given type ordered by creation time' do
126
+ test_types = user.current_states('test_type')
127
+
128
+ expect(test_types.size).to eq 3
129
+ expect(test_types).to eq test_types.sort_by(&:created_at).reverse
130
+ end
131
+
132
+ it 'returns empty relation when no states exist for the given type' do
133
+ nonexistent_states = user.current_states('nonexistent')
134
+ expect(nonexistent_states).to be_empty
135
+ expect(nonexistent_states).to be_a(ActiveRecord::Relation)
136
+ end
137
+ end
138
+
139
+ describe 'query methods' do
140
+ before do
141
+ 2.times { user.add_state('test_type', status: 'pending') }
142
+ 2.times { user.add_state('other_test_type', status: 'pending') }
143
+ end
144
+
145
+ context 'find one methods' do
146
+ it 'defines query one methods for configured states' do
147
+ expect(user).to respond_to("test_type")
148
+ expect(user).to respond_to("other_test_type")
149
+ end
150
+
151
+ it 'returns the most recent state for the given status' do
152
+ expect(user.test_type).to eq user.current_state('test_type')
153
+ expect(user.other_test_type).to eq user.current_state('other_test_type')
154
+ end
155
+ end
156
+
157
+ context 'find many methods' do
158
+ it 'defines query all methods for configured states' do
159
+ expect(user).to respond_to("test_types")
160
+ expect(user).to respond_to("other_test_types")
161
+ end
162
+
163
+ it 'returns all states for the given status ordered by creation time' do
164
+ test_types = user.test_types
165
+ other_test_types = user.other_test_types
166
+
167
+ expect(test_types.size).to eq 2
168
+ expect(other_test_types.size).to eq 2
169
+
170
+ expect(test_types).to eq test_types.sort_by(&:created_at).reverse
171
+ expect(other_test_types).to eq other_test_types.sort_by(&:created_at).reverse
172
+ end
173
+ end
174
+ end
175
+
176
+ # Optional: Test the destruction of associated states
177
+ describe 'destroying the stateable object' do
178
+ let!(:state) { user.add_state('test_type') }
179
+
180
+ it 'destroys associated states when the user is destroyed' do
181
+ expect { user.destroy }.to change { HasStates::Base.count }.by(-1)
182
+ end
183
+ end
184
+ end
data/spec/rails_helper.rb CHANGED
@@ -14,5 +14,6 @@ require_relative 'support/database_cleaner'
14
14
  RSpec.configure do |config|
15
15
  config.include(Shoulda::Matchers::ActiveModel, type: :model)
16
16
  config.include(Shoulda::Matchers::ActiveRecord, type: :model)
17
+ config.include(ActiveSupport::Testing::TimeHelpers)
17
18
  # ... rest of your RSpec configuration
18
19
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stateful_models
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Scholl
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-12-23 00:00:00.000000000 Z
11
+ date: 2025-01-14 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: "\n HasStates provides state management and event system capabilities
14
14
  for Ruby objects.\n It allows tracking states, state transitions, and triggering
@@ -24,8 +24,10 @@ files:
24
24
  - README.md
25
25
  - lib/generators/has_states/install/install_generator.rb
26
26
  - lib/generators/has_states/install/templates/create_has_states_states.rb.erb
27
+ - lib/generators/has_states/install/templates/create_indexes_on_has_states_states.rb.erb
27
28
  - lib/generators/has_states/install/templates/initializer.rb.erb
28
29
  - lib/has_states.rb
30
+ - lib/has_states/base.rb
29
31
  - lib/has_states/callback.rb
30
32
  - lib/has_states/configuration.rb
31
33
  - lib/has_states/configuration/model_configuration.rb
@@ -62,13 +64,15 @@ files:
62
64
  - spec/dummy/config/environments/test.rb
63
65
  - spec/dummy/config/initializers/cors.rb
64
66
  - spec/dummy/config/initializers/filter_parameter_logging.rb
67
+ - spec/dummy/config/initializers/has_states.rb
65
68
  - spec/dummy/config/initializers/inflections.rb
66
69
  - spec/dummy/config/locales/en.yml
67
70
  - spec/dummy/config/master.key
68
71
  - spec/dummy/config/puma.rb
69
72
  - spec/dummy/config/routes.rb
70
73
  - spec/dummy/db/migrate/20241221171423_create_test_models.rb
71
- - spec/dummy/db/migrate/20241221183116_create_has_states_tables.rb
74
+ - spec/dummy/db/migrate/20241223212128_create_has_states_states.rb
75
+ - spec/dummy/db/migrate/20250114175939_create_indexes_on_has_states_states.rb
72
76
  - spec/dummy/db/schema.rb
73
77
  - spec/dummy/db/seeds.rb
74
78
  - spec/dummy/log/development.log
@@ -80,10 +84,12 @@ files:
80
84
  - spec/factories/has_states.rb
81
85
  - spec/generators/has_states/install_generator_spec.rb
82
86
  - spec/generators/tmp/config/initializers/has_states.rb
83
- - spec/generators/tmp/db/migrate/20241223020432_create_has_states_states.rb
87
+ - spec/generators/tmp/db/migrate/20250114180401_create_has_states_states.rb
88
+ - spec/generators/tmp/db/migrate/20250114180401_create_indexes_on_has_states_states.rb
84
89
  - spec/has_states/callback_spec.rb
85
90
  - spec/has_states/configuration_spec.rb
86
91
  - spec/has_states/state_spec.rb
92
+ - spec/has_states/stateable_spec.rb
87
93
  - spec/has_states_spec.rb
88
94
  - spec/rails_helper.rb
89
95
  - spec/spec_helper.rb