familia 2.0.0.pre23 → 2.0.0.pre25
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.rst +71 -0
- data/CLAUDE.md +1 -1
- data/Gemfile.lock +1 -1
- data/docs/guides/feature-relationships-indexing.md +104 -9
- data/docs/guides/feature-relationships-methods.md +37 -5
- data/docs/overview.md +9 -0
- data/lib/familia/base.rb +0 -2
- data/lib/familia/data_type/serialization.rb +8 -9
- data/lib/familia/data_type/settings.rb +0 -8
- data/lib/familia/data_type/types/json_stringkey.rb +155 -0
- data/lib/familia/data_type.rb +5 -4
- data/lib/familia/features/relationships/indexing/multi_index_generators.rb +281 -15
- data/lib/familia/features/relationships/indexing.rb +57 -27
- data/lib/familia/features/safe_dump.rb +0 -3
- data/lib/familia/horreum/management.rb +36 -3
- data/lib/familia/horreum/persistence.rb +4 -1
- data/lib/familia/horreum/settings.rb +2 -10
- data/lib/familia/horreum.rb +1 -2
- data/lib/familia/version.rb +1 -1
- data/try/edge_cases/find_by_dbkey_race_condition_try.rb +248 -0
- data/try/features/relationships/class_level_multi_index_auto_try.rb +318 -0
- data/try/features/relationships/class_level_multi_index_rebuild_try.rb +393 -0
- data/try/features/relationships/class_level_multi_index_try.rb +349 -0
- data/try/integration/familia_extended_try.rb +1 -1
- data/try/integration/scenarios_try.rb +4 -3
- data/try/unit/data_types/json_stringkey_try.rb +431 -0
- data/try/unit/horreum/settings_try.rb +0 -11
- metadata +7 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# try/edge_cases/find_by_dbkey_race_condition_try.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# Test race condition handling in find_by_dbkey where a key can expire
|
|
6
|
+
# between the EXISTS check and HGETALL retrieval. Also tests lazy cleanup
|
|
7
|
+
# of stale instances entries.
|
|
8
|
+
#
|
|
9
|
+
# The race condition scenario:
|
|
10
|
+
# 1. EXISTS check passes (key exists)
|
|
11
|
+
# 2. Key expires via TTL (or is deleted) before HGETALL
|
|
12
|
+
# 3. HGETALL returns empty hash {}
|
|
13
|
+
# 4. Without fix: instantiate_from_hash({}) creates object with nil identifier
|
|
14
|
+
# 5. With fix: returns nil and cleans up stale instances entry
|
|
15
|
+
|
|
16
|
+
require_relative '../support/helpers/test_helpers'
|
|
17
|
+
|
|
18
|
+
RaceConditionUser = Class.new(Familia::Horreum) do
|
|
19
|
+
identifier_field :user_id
|
|
20
|
+
field :user_id
|
|
21
|
+
field :name
|
|
22
|
+
field :email
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
RaceConditionSession = Class.new(Familia::Horreum) do
|
|
26
|
+
identifier_field :session_id
|
|
27
|
+
field :session_id
|
|
28
|
+
field :data
|
|
29
|
+
feature :expiration
|
|
30
|
+
default_expiration 300
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# --- Empty Hash Handling Tests ---
|
|
34
|
+
|
|
35
|
+
## find_by_dbkey returns nil for empty hash when check_exists: true
|
|
36
|
+
# Simulate race condition: add stale entry to instances, then try to load
|
|
37
|
+
RaceConditionUser.instances.add('stale_user_1', Familia.now)
|
|
38
|
+
initial_count = RaceConditionUser.instances.size
|
|
39
|
+
result = RaceConditionUser.find_by_dbkey(RaceConditionUser.dbkey('stale_user_1'), check_exists: true)
|
|
40
|
+
result
|
|
41
|
+
#=> nil
|
|
42
|
+
|
|
43
|
+
## find_by_dbkey returns nil for empty hash when check_exists: false
|
|
44
|
+
RaceConditionUser.instances.add('stale_user_2', Familia.now)
|
|
45
|
+
result = RaceConditionUser.find_by_dbkey(RaceConditionUser.dbkey('stale_user_2'), check_exists: false)
|
|
46
|
+
result
|
|
47
|
+
#=> nil
|
|
48
|
+
|
|
49
|
+
## find_by_dbkey handles both check_exists modes consistently for non-existent keys
|
|
50
|
+
result_true = RaceConditionUser.find_by_dbkey(RaceConditionUser.dbkey('nonexistent_1'), check_exists: true)
|
|
51
|
+
result_false = RaceConditionUser.find_by_dbkey(RaceConditionUser.dbkey('nonexistent_2'), check_exists: false)
|
|
52
|
+
[result_true, result_false]
|
|
53
|
+
#=> [nil, nil]
|
|
54
|
+
|
|
55
|
+
# --- Lazy Cleanup Tests ---
|
|
56
|
+
|
|
57
|
+
## lazy cleanup removes stale entry from instances when loading fails
|
|
58
|
+
RaceConditionUser.instances.clear
|
|
59
|
+
RaceConditionUser.instances.add('phantom_user_1', Familia.now)
|
|
60
|
+
before_count = RaceConditionUser.instances.size
|
|
61
|
+
RaceConditionUser.find_by_dbkey(RaceConditionUser.dbkey('phantom_user_1'))
|
|
62
|
+
after_count = RaceConditionUser.instances.size
|
|
63
|
+
[before_count, after_count]
|
|
64
|
+
#=> [1, 0]
|
|
65
|
+
|
|
66
|
+
## lazy cleanup handles multiple stale entries
|
|
67
|
+
RaceConditionUser.instances.clear
|
|
68
|
+
RaceConditionUser.instances.add('phantom_a', Familia.now)
|
|
69
|
+
RaceConditionUser.instances.add('phantom_b', Familia.now)
|
|
70
|
+
RaceConditionUser.instances.add('phantom_c', Familia.now)
|
|
71
|
+
RaceConditionUser.find_by_dbkey(RaceConditionUser.dbkey('phantom_a'))
|
|
72
|
+
RaceConditionUser.find_by_dbkey(RaceConditionUser.dbkey('phantom_b'))
|
|
73
|
+
remaining = RaceConditionUser.instances.size
|
|
74
|
+
remaining
|
|
75
|
+
#=> 1
|
|
76
|
+
|
|
77
|
+
## lazy cleanup only removes the specific stale entry
|
|
78
|
+
RaceConditionUser.instances.clear
|
|
79
|
+
real_user = RaceConditionUser.new(user_id: 'real_user_1', name: 'Real', email: 'real@example.com')
|
|
80
|
+
real_user.save
|
|
81
|
+
RaceConditionUser.instances.add('phantom_mixed', Familia.now)
|
|
82
|
+
before = RaceConditionUser.instances.members.sort
|
|
83
|
+
RaceConditionUser.find_by_dbkey(RaceConditionUser.dbkey('phantom_mixed'))
|
|
84
|
+
after = RaceConditionUser.instances.members.sort
|
|
85
|
+
real_user.destroy!
|
|
86
|
+
[before.include?('phantom_mixed'), before.include?('real_user_1'), after.include?('phantom_mixed'), after.include?('real_user_1')]
|
|
87
|
+
#=> [true, true, false, true]
|
|
88
|
+
|
|
89
|
+
# --- Race Condition Simulation Tests ---
|
|
90
|
+
|
|
91
|
+
## simulated race: key deleted between conceptual EXISTS and actual load
|
|
92
|
+
# This simulates what happens when a key expires between EXISTS and HGETALL
|
|
93
|
+
user = RaceConditionUser.new(user_id: 'race_user_1', name: 'Race', email: 'race@example.com')
|
|
94
|
+
user.save
|
|
95
|
+
dbkey = RaceConditionUser.dbkey('race_user_1')
|
|
96
|
+
|
|
97
|
+
# Verify key exists
|
|
98
|
+
exists_before = Familia.dbclient.exists(dbkey).positive?
|
|
99
|
+
|
|
100
|
+
# Simulate TTL expiration by directly deleting the key but leaving instances entry
|
|
101
|
+
Familia.dbclient.del(dbkey)
|
|
102
|
+
|
|
103
|
+
# Now find_by_dbkey should return nil and clean up instances
|
|
104
|
+
result = RaceConditionUser.find_by_dbkey(dbkey)
|
|
105
|
+
exists_after = RaceConditionUser.instances.members.include?('race_user_1')
|
|
106
|
+
[exists_before, result, exists_after]
|
|
107
|
+
#=> [true, nil, false]
|
|
108
|
+
|
|
109
|
+
## simulated race with check_exists: false also handles cleanup
|
|
110
|
+
user2 = RaceConditionUser.new(user_id: 'race_user_2', name: 'Race2', email: 'race2@example.com')
|
|
111
|
+
user2.save
|
|
112
|
+
dbkey2 = RaceConditionUser.dbkey('race_user_2')
|
|
113
|
+
|
|
114
|
+
# Delete key but leave instances entry
|
|
115
|
+
Familia.dbclient.del(dbkey2)
|
|
116
|
+
|
|
117
|
+
result = RaceConditionUser.find_by_dbkey(dbkey2, check_exists: false)
|
|
118
|
+
cleaned = !RaceConditionUser.instances.members.include?('race_user_2')
|
|
119
|
+
[result, cleaned]
|
|
120
|
+
#=> [nil, true]
|
|
121
|
+
|
|
122
|
+
# --- TTL Expiration Tests ---
|
|
123
|
+
|
|
124
|
+
## TTL expiration leaves stale instances entry (demonstrating the problem)
|
|
125
|
+
session = RaceConditionSession.new(session_id: 'ttl_session_1', data: 'test data')
|
|
126
|
+
session.save
|
|
127
|
+
session.expire(1) # 1 second TTL
|
|
128
|
+
|
|
129
|
+
# Verify it's in instances
|
|
130
|
+
in_instances_before = RaceConditionSession.instances.members.include?('ttl_session_1')
|
|
131
|
+
|
|
132
|
+
# Wait for TTL to expire
|
|
133
|
+
sleep(1.5)
|
|
134
|
+
|
|
135
|
+
# Key is gone but instances entry remains (this is the stale entry problem)
|
|
136
|
+
key_exists = Familia.dbclient.exists(RaceConditionSession.dbkey('ttl_session_1')).positive?
|
|
137
|
+
in_instances_still = RaceConditionSession.instances.members.include?('ttl_session_1')
|
|
138
|
+
[in_instances_before, key_exists, in_instances_still]
|
|
139
|
+
#=> [true, false, true]
|
|
140
|
+
|
|
141
|
+
## lazy cleanup fixes stale entry after TTL expiration
|
|
142
|
+
# Now when we try to load, it should clean up the stale entry
|
|
143
|
+
result = RaceConditionSession.find_by_dbkey(RaceConditionSession.dbkey('ttl_session_1'))
|
|
144
|
+
in_instances_after = RaceConditionSession.instances.members.include?('ttl_session_1')
|
|
145
|
+
[result, in_instances_after]
|
|
146
|
+
#=> [nil, false]
|
|
147
|
+
|
|
148
|
+
## find methods clean up stale entries after TTL expiration
|
|
149
|
+
session2 = RaceConditionSession.new(session_id: 'ttl_session_2', data: 'test data 2')
|
|
150
|
+
session2.save
|
|
151
|
+
session2.expire(1)
|
|
152
|
+
sleep(1.5)
|
|
153
|
+
|
|
154
|
+
# Use find_by_id (which calls find_by_dbkey internally)
|
|
155
|
+
result = RaceConditionSession.find_by_id('ttl_session_2')
|
|
156
|
+
cleaned = !RaceConditionSession.instances.members.include?('ttl_session_2')
|
|
157
|
+
[result, cleaned]
|
|
158
|
+
#=> [nil, true]
|
|
159
|
+
|
|
160
|
+
# --- Count Consistency Tests ---
|
|
161
|
+
|
|
162
|
+
## count reflects reality after lazy cleanup
|
|
163
|
+
RaceConditionUser.instances.clear
|
|
164
|
+
# Create real user
|
|
165
|
+
real = RaceConditionUser.new(user_id: 'count_real', name: 'Real', email: 'real@example.com')
|
|
166
|
+
real.save
|
|
167
|
+
|
|
168
|
+
# Add phantom entries
|
|
169
|
+
RaceConditionUser.instances.add('count_phantom_1', Familia.now)
|
|
170
|
+
RaceConditionUser.instances.add('count_phantom_2', Familia.now)
|
|
171
|
+
|
|
172
|
+
count_before = RaceConditionUser.count
|
|
173
|
+
|
|
174
|
+
# Trigger lazy cleanup by attempting to load phantoms
|
|
175
|
+
RaceConditionUser.find_by_id('count_phantom_1')
|
|
176
|
+
RaceConditionUser.find_by_id('count_phantom_2')
|
|
177
|
+
|
|
178
|
+
count_after = RaceConditionUser.count
|
|
179
|
+
real.destroy!
|
|
180
|
+
[count_before, count_after]
|
|
181
|
+
#=> [3, 1]
|
|
182
|
+
|
|
183
|
+
## keys_count vs count after lazy cleanup
|
|
184
|
+
RaceConditionUser.instances.clear
|
|
185
|
+
real2 = RaceConditionUser.new(user_id: 'keys_count_real', name: 'Real', email: 'real@example.com')
|
|
186
|
+
real2.save
|
|
187
|
+
RaceConditionUser.instances.add('keys_count_phantom', Familia.now)
|
|
188
|
+
|
|
189
|
+
# Before cleanup: count includes phantom, keys_count doesn't
|
|
190
|
+
count_before = RaceConditionUser.count
|
|
191
|
+
keys_count_before = RaceConditionUser.keys_count
|
|
192
|
+
|
|
193
|
+
# Trigger lazy cleanup
|
|
194
|
+
RaceConditionUser.find_by_id('keys_count_phantom')
|
|
195
|
+
|
|
196
|
+
# After cleanup: both should match
|
|
197
|
+
count_after = RaceConditionUser.count
|
|
198
|
+
keys_count_after = RaceConditionUser.keys_count
|
|
199
|
+
|
|
200
|
+
real2.destroy!
|
|
201
|
+
[count_before, keys_count_before, count_after, keys_count_after]
|
|
202
|
+
#=> [2, 1, 1, 1]
|
|
203
|
+
|
|
204
|
+
# --- Edge Cases ---
|
|
205
|
+
|
|
206
|
+
## empty identifier in key doesn't cause issues
|
|
207
|
+
# Key format with empty identifier would be "prefix::suffix"
|
|
208
|
+
# This shouldn't happen in practice, but we handle it gracefully
|
|
209
|
+
malformed_key = "#{RaceConditionUser.prefix}::object"
|
|
210
|
+
result = RaceConditionUser.find_by_dbkey(malformed_key)
|
|
211
|
+
result
|
|
212
|
+
#=> nil
|
|
213
|
+
|
|
214
|
+
## key with unusual identifier characters
|
|
215
|
+
RaceConditionUser.instances.add('user:with:colons', Familia.now)
|
|
216
|
+
result = RaceConditionUser.find_by_dbkey(RaceConditionUser.dbkey('user:with:colons'))
|
|
217
|
+
# Should return nil (key doesn't exist) and attempt cleanup
|
|
218
|
+
# Note: cleanup may not work perfectly for identifiers with delimiters
|
|
219
|
+
result
|
|
220
|
+
#=> nil
|
|
221
|
+
|
|
222
|
+
## concurrent load attempts on same stale entry
|
|
223
|
+
RaceConditionUser.instances.clear
|
|
224
|
+
RaceConditionUser.instances.add('concurrent_phantom', Familia.now)
|
|
225
|
+
|
|
226
|
+
threads = []
|
|
227
|
+
results = []
|
|
228
|
+
mutex = Mutex.new
|
|
229
|
+
|
|
230
|
+
5.times do
|
|
231
|
+
threads << Thread.new do
|
|
232
|
+
r = RaceConditionUser.find_by_id('concurrent_phantom')
|
|
233
|
+
mutex.synchronize { results << r }
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
threads.each(&:join)
|
|
238
|
+
|
|
239
|
+
# All should return nil, and instances should be cleaned
|
|
240
|
+
all_nil = results.all?(&:nil?)
|
|
241
|
+
cleaned = !RaceConditionUser.instances.members.include?('concurrent_phantom')
|
|
242
|
+
[all_nil, cleaned, results.size]
|
|
243
|
+
#=> [true, true, 5]
|
|
244
|
+
|
|
245
|
+
# --- Cleanup ---
|
|
246
|
+
|
|
247
|
+
RaceConditionUser.instances.clear
|
|
248
|
+
RaceConditionSession.instances.clear
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# try/features/relationships/class_level_multi_index_auto_try.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# Tests for class-level multi_index auto-indexing and helper methods
|
|
6
|
+
#
|
|
7
|
+
# This file tests:
|
|
8
|
+
# 1. Auto-indexing on save - objects should automatically be added to class-level indexes
|
|
9
|
+
# 2. Index updates when field values change
|
|
10
|
+
# 3. Helper methods (update_all_indexes, remove_from_all_indexes, current_indexings)
|
|
11
|
+
#
|
|
12
|
+
# Auto-indexing for class-level indexes (within: :class) should work similarly
|
|
13
|
+
# to unique_index (within: nil), calling add_to_class_* methods on save.
|
|
14
|
+
|
|
15
|
+
require_relative '../../support/helpers/test_helpers'
|
|
16
|
+
|
|
17
|
+
# Test class for auto-indexing with class-level multi_index
|
|
18
|
+
class ::AutoIndexCustomer < Familia::Horreum
|
|
19
|
+
feature :relationships
|
|
20
|
+
include Familia::Features::Relationships::Indexing
|
|
21
|
+
|
|
22
|
+
identifier_field :custid
|
|
23
|
+
field :custid
|
|
24
|
+
field :name
|
|
25
|
+
field :status
|
|
26
|
+
field :tier
|
|
27
|
+
|
|
28
|
+
# Class-level multi_index (within: :class is default)
|
|
29
|
+
multi_index :status, :status_index
|
|
30
|
+
|
|
31
|
+
# Explicit within: :class for second index
|
|
32
|
+
multi_index :tier, :tier_index, within: :class
|
|
33
|
+
|
|
34
|
+
# Track instances for rebuild testing
|
|
35
|
+
class_sorted_set :instances, class: self, reference: true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Clean up stale data from previous runs
|
|
39
|
+
%w[active pending inactive premium standard basic].each do |value|
|
|
40
|
+
AutoIndexCustomer.dbclient.del(AutoIndexCustomer.status_index_for(value).dbkey)
|
|
41
|
+
AutoIndexCustomer.dbclient.del(AutoIndexCustomer.tier_index_for(value).dbkey)
|
|
42
|
+
end
|
|
43
|
+
# Clean up any stale customer objects
|
|
44
|
+
%w[auto_001 auto_002 auto_003 auto_004 auto_nil auto_empty auto_ws].each do |custid|
|
|
45
|
+
existing = AutoIndexCustomer.find_by_identifier(custid, check_exists: false)
|
|
46
|
+
existing&.delete! if existing&.exists?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# =============================================
|
|
50
|
+
# 1. Auto-Indexing on Save Tests
|
|
51
|
+
# =============================================
|
|
52
|
+
|
|
53
|
+
## New object saved - auto-indexed in status_index
|
|
54
|
+
@auto1 = AutoIndexCustomer.new(custid: 'auto_001', name: 'AutoAlice', status: 'active', tier: 'premium')
|
|
55
|
+
@auto1.save
|
|
56
|
+
AutoIndexCustomer.status_index_for('active').members.include?('auto_001')
|
|
57
|
+
#=> true
|
|
58
|
+
|
|
59
|
+
## Auto-indexing works for tier_index too (explicit within: :class)
|
|
60
|
+
AutoIndexCustomer.tier_index_for('premium').members.include?('auto_001')
|
|
61
|
+
#=> true
|
|
62
|
+
|
|
63
|
+
## Second object saved - joins same status index
|
|
64
|
+
@auto2 = AutoIndexCustomer.new(custid: 'auto_002', name: 'AutoBob', status: 'active', tier: 'standard')
|
|
65
|
+
@auto2.save
|
|
66
|
+
AutoIndexCustomer.status_index_for('active').members.sort
|
|
67
|
+
#=> ["auto_001", "auto_002"]
|
|
68
|
+
|
|
69
|
+
## Different tier index is separate
|
|
70
|
+
AutoIndexCustomer.tier_index_for('standard').members
|
|
71
|
+
#=> ["auto_002"]
|
|
72
|
+
|
|
73
|
+
## Object with different status creates new index entry
|
|
74
|
+
@auto3 = AutoIndexCustomer.new(custid: 'auto_003', name: 'AutoCharlie', status: 'pending', tier: 'basic')
|
|
75
|
+
@auto3.save
|
|
76
|
+
AutoIndexCustomer.status_index_for('pending').members
|
|
77
|
+
#=> ["auto_003"]
|
|
78
|
+
|
|
79
|
+
## Active status index unchanged by new pending customer
|
|
80
|
+
AutoIndexCustomer.status_index_for('active').size
|
|
81
|
+
#=> 2
|
|
82
|
+
|
|
83
|
+
# =============================================
|
|
84
|
+
# 2. Query After Auto-Indexing
|
|
85
|
+
# =============================================
|
|
86
|
+
|
|
87
|
+
## find_all_by_status returns auto-indexed customers
|
|
88
|
+
active_customers = AutoIndexCustomer.find_all_by_status('active')
|
|
89
|
+
active_customers.map(&:custid).sort
|
|
90
|
+
#=> ["auto_001", "auto_002"]
|
|
91
|
+
|
|
92
|
+
## sample_from_status works with auto-indexed data
|
|
93
|
+
sample = AutoIndexCustomer.sample_from_status('active', 1)
|
|
94
|
+
['auto_001', 'auto_002'].include?(sample.first&.custid)
|
|
95
|
+
#=> true
|
|
96
|
+
|
|
97
|
+
## find_all_by_tier returns correct customers
|
|
98
|
+
premium_customers = AutoIndexCustomer.find_all_by_tier('premium')
|
|
99
|
+
premium_customers.map(&:custid)
|
|
100
|
+
#=> ["auto_001"]
|
|
101
|
+
|
|
102
|
+
# =============================================
|
|
103
|
+
# 3. Field Change and Index Update
|
|
104
|
+
# =============================================
|
|
105
|
+
|
|
106
|
+
## Change status field and save - verify old index is NOT automatically updated
|
|
107
|
+
# Note: auto_update_class_indexes calls add_to_class_*, which is idempotent
|
|
108
|
+
# but does NOT remove from old index. Manual update is needed for field changes.
|
|
109
|
+
@old_status = @auto1.status
|
|
110
|
+
@auto1.status = 'inactive'
|
|
111
|
+
@auto1.save
|
|
112
|
+
|
|
113
|
+
# After save, customer is added to new index
|
|
114
|
+
AutoIndexCustomer.status_index_for('inactive').members.include?('auto_001')
|
|
115
|
+
#=> true
|
|
116
|
+
|
|
117
|
+
## Old index still contains the entry (save doesn't auto-remove from old)
|
|
118
|
+
# This is expected behavior - save is idempotent add, not update
|
|
119
|
+
AutoIndexCustomer.status_index_for('active').members.include?('auto_001')
|
|
120
|
+
#=> true
|
|
121
|
+
|
|
122
|
+
## Use update_in_class_status_index to properly move between indexes
|
|
123
|
+
@auto1.update_in_class_status_index(@old_status)
|
|
124
|
+
AutoIndexCustomer.status_index_for('active').members.include?('auto_001')
|
|
125
|
+
#=> false
|
|
126
|
+
|
|
127
|
+
## Customer now only in inactive index
|
|
128
|
+
AutoIndexCustomer.status_index_for('inactive').members
|
|
129
|
+
#=> ["auto_001"]
|
|
130
|
+
|
|
131
|
+
# =============================================
|
|
132
|
+
# 4. Helper Methods - update_all_indexes
|
|
133
|
+
# =============================================
|
|
134
|
+
|
|
135
|
+
## update_all_indexes method exists
|
|
136
|
+
@auto2.respond_to?(:update_all_indexes)
|
|
137
|
+
#=> true
|
|
138
|
+
|
|
139
|
+
## update_all_indexes updates both status and tier indexes
|
|
140
|
+
@old_values = { status: @auto2.status, tier: @auto2.tier }
|
|
141
|
+
@auto2.status = 'pending'
|
|
142
|
+
@auto2.tier = 'premium'
|
|
143
|
+
@auto2.update_all_indexes(@old_values)
|
|
144
|
+
|
|
145
|
+
# Check status index updated
|
|
146
|
+
AutoIndexCustomer.status_index_for('active').members.include?('auto_002')
|
|
147
|
+
#=> false
|
|
148
|
+
|
|
149
|
+
## New status index contains customer
|
|
150
|
+
AutoIndexCustomer.status_index_for('pending').members.include?('auto_002')
|
|
151
|
+
#=> true
|
|
152
|
+
|
|
153
|
+
## Old tier index no longer contains customer
|
|
154
|
+
AutoIndexCustomer.tier_index_for('standard').members.include?('auto_002')
|
|
155
|
+
#=> false
|
|
156
|
+
|
|
157
|
+
## New tier index contains customer
|
|
158
|
+
AutoIndexCustomer.tier_index_for('premium').members.include?('auto_002')
|
|
159
|
+
#=> true
|
|
160
|
+
|
|
161
|
+
# =============================================
|
|
162
|
+
# 5. Helper Methods - remove_from_all_indexes
|
|
163
|
+
# =============================================
|
|
164
|
+
|
|
165
|
+
## remove_from_all_indexes method exists
|
|
166
|
+
@auto3.respond_to?(:remove_from_all_indexes)
|
|
167
|
+
#=> true
|
|
168
|
+
|
|
169
|
+
## Verify customer is in indexes before removal
|
|
170
|
+
[
|
|
171
|
+
AutoIndexCustomer.status_index_for('pending').members.include?('auto_003'),
|
|
172
|
+
AutoIndexCustomer.tier_index_for('basic').members.include?('auto_003')
|
|
173
|
+
]
|
|
174
|
+
#=> [true, true]
|
|
175
|
+
|
|
176
|
+
## remove_from_all_indexes removes from all class-level indexes
|
|
177
|
+
@auto3.remove_from_all_indexes
|
|
178
|
+
[
|
|
179
|
+
AutoIndexCustomer.status_index_for('pending').members.include?('auto_003'),
|
|
180
|
+
AutoIndexCustomer.tier_index_for('basic').members.include?('auto_003')
|
|
181
|
+
]
|
|
182
|
+
#=> [false, false]
|
|
183
|
+
|
|
184
|
+
# =============================================
|
|
185
|
+
# 6. Helper Methods - current_indexings
|
|
186
|
+
# =============================================
|
|
187
|
+
|
|
188
|
+
## current_indexings method exists
|
|
189
|
+
@auto1.respond_to?(:current_indexings)
|
|
190
|
+
#=> true
|
|
191
|
+
|
|
192
|
+
## Re-add auto1 to indexes for testing current_indexings
|
|
193
|
+
@auto1.add_to_class_status_index
|
|
194
|
+
@auto1.add_to_class_tier_index
|
|
195
|
+
@indexings = @auto1.current_indexings
|
|
196
|
+
@indexings.length
|
|
197
|
+
#=> 2
|
|
198
|
+
|
|
199
|
+
## current_indexings returns correct info for status_index
|
|
200
|
+
@status_indexing = @indexings.find { |i| i[:index_name] == :status_index }
|
|
201
|
+
[@status_indexing[:field], @status_indexing[:cardinality], @status_indexing[:type]]
|
|
202
|
+
#=> [:status, :multi, "multi_index"]
|
|
203
|
+
|
|
204
|
+
## current_indexings returns correct info for tier_index
|
|
205
|
+
@tier_indexing = @indexings.find { |i| i[:index_name] == :tier_index }
|
|
206
|
+
[@tier_indexing[:field], @tier_indexing[:cardinality], @tier_indexing[:type]]
|
|
207
|
+
#=> [:tier, :multi, "multi_index"]
|
|
208
|
+
|
|
209
|
+
## current_indexings shows scope_class for class-level indexes
|
|
210
|
+
@status_indexing[:scope_class]
|
|
211
|
+
#=> "class"
|
|
212
|
+
|
|
213
|
+
# =============================================
|
|
214
|
+
# 7. Helper Methods - indexed_in?
|
|
215
|
+
# =============================================
|
|
216
|
+
|
|
217
|
+
## indexed_in? method exists
|
|
218
|
+
@auto1.respond_to?(:indexed_in?)
|
|
219
|
+
#=> true
|
|
220
|
+
|
|
221
|
+
## indexed_in? returns true for indexes where customer is present
|
|
222
|
+
@auto1.indexed_in?(:status_index)
|
|
223
|
+
#=> true
|
|
224
|
+
|
|
225
|
+
## indexed_in? returns true for tier_index too
|
|
226
|
+
@auto1.indexed_in?(:tier_index)
|
|
227
|
+
#=> true
|
|
228
|
+
|
|
229
|
+
## indexed_in? returns false for non-existent index
|
|
230
|
+
@auto1.indexed_in?(:nonexistent_index)
|
|
231
|
+
#=> false
|
|
232
|
+
|
|
233
|
+
## After removal, indexed_in? returns false
|
|
234
|
+
@auto1.remove_from_class_status_index
|
|
235
|
+
@auto1.indexed_in?(:status_index)
|
|
236
|
+
#=> false
|
|
237
|
+
|
|
238
|
+
# =============================================
|
|
239
|
+
# 8. Delete and Index Cleanup
|
|
240
|
+
# =============================================
|
|
241
|
+
|
|
242
|
+
## Create a new customer for delete testing
|
|
243
|
+
@auto4 = AutoIndexCustomer.new(custid: 'auto_004', name: 'AutoDiana', status: 'active', tier: 'standard')
|
|
244
|
+
@auto4.save
|
|
245
|
+
AutoIndexCustomer.status_index_for('active').members.include?('auto_004')
|
|
246
|
+
#=> true
|
|
247
|
+
|
|
248
|
+
## Delete does NOT automatically remove from indexes (by design)
|
|
249
|
+
# Applications should call remove_from_all_indexes before delete if needed
|
|
250
|
+
@auto4.delete!
|
|
251
|
+
AutoIndexCustomer.status_index_for('active').members.include?('auto_004')
|
|
252
|
+
#=> true
|
|
253
|
+
|
|
254
|
+
## Manual cleanup required after delete
|
|
255
|
+
# Note: This tests that stale entries exist until explicitly cleaned
|
|
256
|
+
# The find_by methods handle stale entries gracefully
|
|
257
|
+
stale_customer = AutoIndexCustomer.find_by_identifier('auto_004')
|
|
258
|
+
stale_customer
|
|
259
|
+
#=> nil
|
|
260
|
+
|
|
261
|
+
# =============================================
|
|
262
|
+
# 9. Edge Cases
|
|
263
|
+
# =============================================
|
|
264
|
+
|
|
265
|
+
## Nil field value - should not be indexed
|
|
266
|
+
@auto_nil = AutoIndexCustomer.new(custid: 'auto_nil', name: 'NilStatus', status: nil, tier: 'basic')
|
|
267
|
+
@auto_nil.save
|
|
268
|
+
# Should not create an entry in the nil index (empty string key)
|
|
269
|
+
AutoIndexCustomer.status_index_for('').members.include?('auto_nil')
|
|
270
|
+
#=> false
|
|
271
|
+
|
|
272
|
+
## Empty string field value - should not be indexed
|
|
273
|
+
@auto_empty = AutoIndexCustomer.new(custid: 'auto_empty', name: 'EmptyStatus', status: '', tier: 'basic')
|
|
274
|
+
@auto_empty.save
|
|
275
|
+
AutoIndexCustomer.status_index_for('').members.include?('auto_empty')
|
|
276
|
+
#=> false
|
|
277
|
+
|
|
278
|
+
## Whitespace-only field value - should not be indexed
|
|
279
|
+
@auto_ws = AutoIndexCustomer.new(custid: 'auto_ws', name: 'WSStatus', status: ' ', tier: 'basic')
|
|
280
|
+
@auto_ws.save
|
|
281
|
+
AutoIndexCustomer.status_index_for(' ').members.include?('auto_ws')
|
|
282
|
+
#=> false
|
|
283
|
+
|
|
284
|
+
# =============================================
|
|
285
|
+
# 10. Idempotent Save Behavior
|
|
286
|
+
# =============================================
|
|
287
|
+
|
|
288
|
+
## Saving same object multiple times doesn't duplicate index entries
|
|
289
|
+
@auto2.save
|
|
290
|
+
@auto2.save
|
|
291
|
+
@auto2.save
|
|
292
|
+
# Should still only have one entry
|
|
293
|
+
AutoIndexCustomer.status_index_for(@auto2.status).members.count { |m| m == 'auto_002' }
|
|
294
|
+
#=> 1
|
|
295
|
+
|
|
296
|
+
## UnsortedSet inherently prevents duplicates
|
|
297
|
+
AutoIndexCustomer.status_index_for('pending').size
|
|
298
|
+
#=> 1
|
|
299
|
+
|
|
300
|
+
# Teardown
|
|
301
|
+
# Clean up test objects
|
|
302
|
+
[@auto1, @auto2, @auto3, @auto_nil, @auto_empty, @auto_ws].compact.each do |obj|
|
|
303
|
+
obj.delete! if obj.respond_to?(:exists?) && obj.exists?
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Clean up index keys
|
|
307
|
+
%w[active pending inactive premium standard basic].each do |value|
|
|
308
|
+
AutoIndexCustomer.dbclient.del(AutoIndexCustomer.status_index_for(value).dbkey)
|
|
309
|
+
AutoIndexCustomer.dbclient.del(AutoIndexCustomer.tier_index_for(value).dbkey)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Clean up edge case indexes
|
|
313
|
+
['', ' '].each do |value|
|
|
314
|
+
AutoIndexCustomer.dbclient.del(AutoIndexCustomer.status_index_for(value).dbkey)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Clean up instances collection
|
|
318
|
+
AutoIndexCustomer.instances.clear if AutoIndexCustomer.respond_to?(:instances)
|