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,349 @@
|
|
|
1
|
+
# try/features/relationships/class_level_multi_index_try.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# Tests for class-level multi_index functionality (within: :class)
|
|
6
|
+
# This feature allows multi-value indexes at the class level, similar to how
|
|
7
|
+
# unique_index works without a within: parameter.
|
|
8
|
+
#
|
|
9
|
+
# Example: multi_index :role, :role_index (within: :class is the default)
|
|
10
|
+
# Creates class methods like Customer.find_all_by_role('admin')
|
|
11
|
+
|
|
12
|
+
require_relative '../../support/helpers/test_helpers'
|
|
13
|
+
|
|
14
|
+
# Test class with class-level multi_index
|
|
15
|
+
class ::ClassLevelCustomer < Familia::Horreum
|
|
16
|
+
feature :relationships
|
|
17
|
+
include Familia::Features::Relationships::Indexing
|
|
18
|
+
|
|
19
|
+
identifier_field :custid
|
|
20
|
+
field :custid
|
|
21
|
+
field :name
|
|
22
|
+
field :role
|
|
23
|
+
field :region
|
|
24
|
+
|
|
25
|
+
# Class-level multi_index (default: within: :class)
|
|
26
|
+
multi_index :role, :role_index
|
|
27
|
+
|
|
28
|
+
# Explicit within: :class (same behavior)
|
|
29
|
+
multi_index :region, :region_index, within: :class
|
|
30
|
+
|
|
31
|
+
# Multi_index with query: false
|
|
32
|
+
multi_index :name, :name_index, query: false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Clean up any stale data from previous runs
|
|
36
|
+
%w[admin user superadmin].each do |role|
|
|
37
|
+
ClassLevelCustomer.dbclient.del(ClassLevelCustomer.role_index_for(role).dbkey)
|
|
38
|
+
end
|
|
39
|
+
%w[west east].each do |region|
|
|
40
|
+
ClassLevelCustomer.dbclient.del(ClassLevelCustomer.region_index_for(region).dbkey)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Setup - create test customers with various roles and regions
|
|
44
|
+
@cust1 = ClassLevelCustomer.new(custid: 'cust_001', name: 'Alice', role: 'admin', region: 'west')
|
|
45
|
+
@cust1.save
|
|
46
|
+
@cust2 = ClassLevelCustomer.new(custid: 'cust_002', name: 'Bob', role: 'user', region: 'east')
|
|
47
|
+
@cust2.save
|
|
48
|
+
@cust3 = ClassLevelCustomer.new(custid: 'cust_003', name: 'Charlie', role: 'admin', region: 'west')
|
|
49
|
+
@cust3.save
|
|
50
|
+
@cust4 = ClassLevelCustomer.new(custid: 'cust_004', name: 'Diana', role: 'user', region: 'east')
|
|
51
|
+
@cust4.save
|
|
52
|
+
|
|
53
|
+
# =============================================
|
|
54
|
+
# 1. Class-Level Multi-Index Registration
|
|
55
|
+
# =============================================
|
|
56
|
+
|
|
57
|
+
## Class-level multi_index relationships are registered
|
|
58
|
+
ClassLevelCustomer.indexing_relationships.length
|
|
59
|
+
#=> 3
|
|
60
|
+
|
|
61
|
+
## First multi_index relationship has correct configuration
|
|
62
|
+
config = ClassLevelCustomer.indexing_relationships.first
|
|
63
|
+
[config.field, config.index_name, config.cardinality, config.within]
|
|
64
|
+
#=> [:role, :role_index, :multi, :class]
|
|
65
|
+
|
|
66
|
+
## Second multi_index relationship (explicit within: :class)
|
|
67
|
+
config = ClassLevelCustomer.indexing_relationships[1]
|
|
68
|
+
[config.field, config.index_name, config.cardinality, config.within]
|
|
69
|
+
#=> [:region, :region_index, :multi, :class]
|
|
70
|
+
|
|
71
|
+
## Third multi_index has query disabled
|
|
72
|
+
config = ClassLevelCustomer.indexing_relationships.last
|
|
73
|
+
[config.field, config.index_name, config.query]
|
|
74
|
+
#=> [:name, :name_index, false]
|
|
75
|
+
|
|
76
|
+
# =============================================
|
|
77
|
+
# 2. Generated Class Methods
|
|
78
|
+
# =============================================
|
|
79
|
+
|
|
80
|
+
## Factory method is generated on the class
|
|
81
|
+
ClassLevelCustomer.respond_to?(:role_index_for)
|
|
82
|
+
#=> true
|
|
83
|
+
|
|
84
|
+
## Factory method returns UnsortedSet
|
|
85
|
+
ClassLevelCustomer.role_index_for('admin').class
|
|
86
|
+
#=> Familia::UnsortedSet
|
|
87
|
+
|
|
88
|
+
## Query method find_all_by_role is generated
|
|
89
|
+
ClassLevelCustomer.respond_to?(:find_all_by_role)
|
|
90
|
+
#=> true
|
|
91
|
+
|
|
92
|
+
## Query method sample_from_role is generated
|
|
93
|
+
ClassLevelCustomer.respond_to?(:sample_from_role)
|
|
94
|
+
#=> true
|
|
95
|
+
|
|
96
|
+
## Rebuild method is generated
|
|
97
|
+
ClassLevelCustomer.respond_to?(:rebuild_role_index)
|
|
98
|
+
#=> true
|
|
99
|
+
|
|
100
|
+
## No query methods when query: false
|
|
101
|
+
ClassLevelCustomer.respond_to?(:find_all_by_name)
|
|
102
|
+
#=> false
|
|
103
|
+
|
|
104
|
+
## But factory method is still available even with query: false
|
|
105
|
+
ClassLevelCustomer.respond_to?(:name_index_for)
|
|
106
|
+
#=> true
|
|
107
|
+
|
|
108
|
+
# =============================================
|
|
109
|
+
# 3. Generated Instance Methods
|
|
110
|
+
# =============================================
|
|
111
|
+
|
|
112
|
+
## Add method is generated on instances
|
|
113
|
+
@cust1.respond_to?(:add_to_class_role_index)
|
|
114
|
+
#=> true
|
|
115
|
+
|
|
116
|
+
## Remove method is generated on instances
|
|
117
|
+
@cust1.respond_to?(:remove_from_class_role_index)
|
|
118
|
+
#=> true
|
|
119
|
+
|
|
120
|
+
## Update method is generated on instances
|
|
121
|
+
@cust1.respond_to?(:update_in_class_role_index)
|
|
122
|
+
#=> true
|
|
123
|
+
|
|
124
|
+
# =============================================
|
|
125
|
+
# 4. Manual Indexing Operations
|
|
126
|
+
# =============================================
|
|
127
|
+
|
|
128
|
+
## Add customer to class index manually
|
|
129
|
+
@cust1.add_to_class_role_index
|
|
130
|
+
ClassLevelCustomer.role_index_for('admin').members.include?('cust_001')
|
|
131
|
+
#=> true
|
|
132
|
+
|
|
133
|
+
## Add multiple customers to index
|
|
134
|
+
@cust2.add_to_class_role_index
|
|
135
|
+
@cust3.add_to_class_role_index
|
|
136
|
+
@cust4.add_to_class_role_index
|
|
137
|
+
ClassLevelCustomer.role_index_for('admin').size
|
|
138
|
+
#=> 2
|
|
139
|
+
|
|
140
|
+
## Users index has correct members
|
|
141
|
+
ClassLevelCustomer.role_index_for('user').size
|
|
142
|
+
#=> 2
|
|
143
|
+
|
|
144
|
+
# =============================================
|
|
145
|
+
# 5. Query Operations
|
|
146
|
+
# =============================================
|
|
147
|
+
|
|
148
|
+
## find_all_by_role returns all matching customers
|
|
149
|
+
admins = ClassLevelCustomer.find_all_by_role('admin')
|
|
150
|
+
admins.map(&:custid).sort
|
|
151
|
+
#=> ["cust_001", "cust_003"]
|
|
152
|
+
|
|
153
|
+
## find_all_by_role returns empty array for non-existent role
|
|
154
|
+
ClassLevelCustomer.find_all_by_role('superadmin')
|
|
155
|
+
#=> []
|
|
156
|
+
|
|
157
|
+
## sample_from_role returns random customer
|
|
158
|
+
sample = ClassLevelCustomer.sample_from_role('admin', 1)
|
|
159
|
+
['cust_001', 'cust_003'].include?(sample.first&.custid)
|
|
160
|
+
#=> true
|
|
161
|
+
|
|
162
|
+
## sample_from_role with count > 1
|
|
163
|
+
samples = ClassLevelCustomer.sample_from_role('user', 2)
|
|
164
|
+
samples.length
|
|
165
|
+
#=> 2
|
|
166
|
+
|
|
167
|
+
# =============================================
|
|
168
|
+
# 6. Update Operations
|
|
169
|
+
# =============================================
|
|
170
|
+
|
|
171
|
+
## Update method moves customer between indexes
|
|
172
|
+
old_role = @cust1.role
|
|
173
|
+
@cust1.role = 'superadmin'
|
|
174
|
+
@cust1.update_in_class_role_index(old_role)
|
|
175
|
+
ClassLevelCustomer.role_index_for('admin').members.include?('cust_001')
|
|
176
|
+
#=> false
|
|
177
|
+
|
|
178
|
+
## Customer is now in new index
|
|
179
|
+
ClassLevelCustomer.role_index_for('superadmin').members.include?('cust_001')
|
|
180
|
+
#=> true
|
|
181
|
+
|
|
182
|
+
# =============================================
|
|
183
|
+
# 7. Remove Operations
|
|
184
|
+
# =============================================
|
|
185
|
+
|
|
186
|
+
## Remove customer from index
|
|
187
|
+
@cust4.remove_from_class_role_index
|
|
188
|
+
ClassLevelCustomer.role_index_for('user').members.include?('cust_004')
|
|
189
|
+
#=> false
|
|
190
|
+
|
|
191
|
+
## Other customer in same index is unaffected
|
|
192
|
+
ClassLevelCustomer.role_index_for('user').members.include?('cust_002')
|
|
193
|
+
#=> true
|
|
194
|
+
|
|
195
|
+
# =============================================
|
|
196
|
+
# 8. Redis Key Pattern
|
|
197
|
+
# =============================================
|
|
198
|
+
|
|
199
|
+
## Redis key follows class-level pattern
|
|
200
|
+
index_set = ClassLevelCustomer.role_index_for('admin')
|
|
201
|
+
index_set.dbkey
|
|
202
|
+
#=~ /classlevelcustomer:role_index:admin/
|
|
203
|
+
|
|
204
|
+
## Different field values have different keys
|
|
205
|
+
key1 = ClassLevelCustomer.role_index_for('admin').dbkey
|
|
206
|
+
key2 = ClassLevelCustomer.role_index_for('user').dbkey
|
|
207
|
+
key1 != key2
|
|
208
|
+
#=> true
|
|
209
|
+
|
|
210
|
+
# =============================================
|
|
211
|
+
# 9. Region Index (explicit within: :class)
|
|
212
|
+
# =============================================
|
|
213
|
+
|
|
214
|
+
## Verify region field values before indexing
|
|
215
|
+
[@cust1.region, @cust2.region, @cust3.region]
|
|
216
|
+
#=> ["west", "east", "west"]
|
|
217
|
+
|
|
218
|
+
## Verify the add_to_class_region_index method exists
|
|
219
|
+
@cust1.respond_to?(:add_to_class_region_index)
|
|
220
|
+
#=> true
|
|
221
|
+
|
|
222
|
+
## Debug: Check what the region_index IndexingRelationship looks like
|
|
223
|
+
region_config = ClassLevelCustomer.indexing_relationships.find { |c| c.index_name == :region_index }
|
|
224
|
+
[region_config.field, region_config.index_name, region_config.cardinality]
|
|
225
|
+
#=> [:region, :region_index, :multi]
|
|
226
|
+
|
|
227
|
+
## Region index is auto-populated on save (cust1 and cust3 are 'west')
|
|
228
|
+
# Note: Auto-indexing is now enabled for class-level multi_index
|
|
229
|
+
ClassLevelCustomer.region_index_for('west').members.sort
|
|
230
|
+
#=> ["cust_001", "cust_003"]
|
|
231
|
+
|
|
232
|
+
## East region is also auto-populated (cust2 and cust4 are 'east')
|
|
233
|
+
ClassLevelCustomer.region_index_for('east').members.sort
|
|
234
|
+
#=> ["cust_002", "cust_004"]
|
|
235
|
+
|
|
236
|
+
## Manual add_to is idempotent (no duplicate entries)
|
|
237
|
+
@cust1.add_to_class_region_index
|
|
238
|
+
ClassLevelCustomer.region_index_for('west').members.sort
|
|
239
|
+
#=> ["cust_001", "cust_003"]
|
|
240
|
+
|
|
241
|
+
## Verify all region indexes after auto-indexing
|
|
242
|
+
[ClassLevelCustomer.region_index_for('west').members.sort, ClassLevelCustomer.region_index_for('east').members.sort]
|
|
243
|
+
#=> [["cust_001", "cust_003"], ["cust_002", "cust_004"]]
|
|
244
|
+
|
|
245
|
+
## Query by region works
|
|
246
|
+
west_customers = ClassLevelCustomer.find_all_by_region('west')
|
|
247
|
+
west_customers.map(&:custid).sort
|
|
248
|
+
#=> ["cust_001", "cust_003"]
|
|
249
|
+
|
|
250
|
+
## East region also works
|
|
251
|
+
east_customers = ClassLevelCustomer.find_all_by_region('east')
|
|
252
|
+
east_customers.map(&:custid).sort
|
|
253
|
+
#=> ["cust_002", "cust_004"]
|
|
254
|
+
|
|
255
|
+
# =============================================
|
|
256
|
+
# 10. Edge Cases and Nil Handling
|
|
257
|
+
# =============================================
|
|
258
|
+
|
|
259
|
+
## Adding to index with nil field value does nothing (no error)
|
|
260
|
+
@cust_nil = ClassLevelCustomer.new(custid: 'cust_nil', name: 'NilRole', role: nil, region: 'west')
|
|
261
|
+
@cust_nil.save
|
|
262
|
+
result = @cust_nil.add_to_class_role_index
|
|
263
|
+
result.nil?
|
|
264
|
+
#=> true
|
|
265
|
+
|
|
266
|
+
## Nil role customer is not in any role index
|
|
267
|
+
ClassLevelCustomer.role_index_for('').members.include?('cust_nil')
|
|
268
|
+
#=> false
|
|
269
|
+
|
|
270
|
+
## Adding to index with empty string field value does nothing
|
|
271
|
+
@cust_empty = ClassLevelCustomer.new(custid: 'cust_empty', name: 'EmptyRole', role: '', region: 'east')
|
|
272
|
+
@cust_empty.save
|
|
273
|
+
result = @cust_empty.add_to_class_role_index
|
|
274
|
+
result.nil?
|
|
275
|
+
#=> true
|
|
276
|
+
|
|
277
|
+
## Adding to index with whitespace-only field value does nothing
|
|
278
|
+
@cust_whitespace = ClassLevelCustomer.new(custid: 'cust_ws', name: 'WhitespaceRole', role: ' ', region: 'east')
|
|
279
|
+
@cust_whitespace.save
|
|
280
|
+
result = @cust_whitespace.add_to_class_role_index
|
|
281
|
+
result.nil?
|
|
282
|
+
#=> true
|
|
283
|
+
|
|
284
|
+
## find_all_by_* with nil value returns empty array
|
|
285
|
+
ClassLevelCustomer.find_all_by_role(nil)
|
|
286
|
+
#=> []
|
|
287
|
+
|
|
288
|
+
## find_all_by_* with empty string returns empty array
|
|
289
|
+
ClassLevelCustomer.find_all_by_role('')
|
|
290
|
+
#=> []
|
|
291
|
+
|
|
292
|
+
## sample_from_* with nil value returns empty array
|
|
293
|
+
ClassLevelCustomer.sample_from_role(nil, 1)
|
|
294
|
+
#=> []
|
|
295
|
+
|
|
296
|
+
## sample_from_* with empty string returns empty array
|
|
297
|
+
ClassLevelCustomer.sample_from_role('', 1)
|
|
298
|
+
#=> []
|
|
299
|
+
|
|
300
|
+
## sample_from_* with count=0 returns empty array
|
|
301
|
+
ClassLevelCustomer.sample_from_role('admin', 0)
|
|
302
|
+
#=> []
|
|
303
|
+
|
|
304
|
+
## Update with same old and new value does nothing (no-op)
|
|
305
|
+
# First ensure cust3 is in the admin index
|
|
306
|
+
@cust3.role = 'admin'
|
|
307
|
+
@cust3.add_to_class_role_index
|
|
308
|
+
admin_count_before = ClassLevelCustomer.role_index_for('admin').size
|
|
309
|
+
@cust3.update_in_class_role_index('admin') # same value
|
|
310
|
+
admin_count_after = ClassLevelCustomer.role_index_for('admin').size
|
|
311
|
+
admin_count_before == admin_count_after
|
|
312
|
+
#=> true
|
|
313
|
+
|
|
314
|
+
## Update when field becomes nil removes from old index only
|
|
315
|
+
@cust_update = ClassLevelCustomer.new(custid: 'cust_update', name: 'WillBeNil', role: 'tempuser', region: 'west')
|
|
316
|
+
@cust_update.save
|
|
317
|
+
@cust_update.add_to_class_role_index
|
|
318
|
+
ClassLevelCustomer.role_index_for('tempuser').members.include?('cust_update')
|
|
319
|
+
#=> true
|
|
320
|
+
|
|
321
|
+
## After setting role to nil and updating, customer is removed from old index
|
|
322
|
+
old_role = @cust_update.role
|
|
323
|
+
@cust_update.role = nil
|
|
324
|
+
@cust_update.update_in_class_role_index(old_role)
|
|
325
|
+
ClassLevelCustomer.role_index_for('tempuser').members.include?('cust_update')
|
|
326
|
+
#=> false
|
|
327
|
+
|
|
328
|
+
## Update with nil old_value returns early (no-op)
|
|
329
|
+
@cust3.role = 'admin'
|
|
330
|
+
result = @cust3.update_in_class_role_index(nil)
|
|
331
|
+
result.nil?
|
|
332
|
+
#=> true
|
|
333
|
+
|
|
334
|
+
# Teardown
|
|
335
|
+
@cust1.delete!
|
|
336
|
+
@cust2.delete!
|
|
337
|
+
@cust3.delete!
|
|
338
|
+
@cust4.delete!
|
|
339
|
+
@cust_nil&.delete!
|
|
340
|
+
@cust_empty&.delete!
|
|
341
|
+
@cust_whitespace&.delete!
|
|
342
|
+
@cust_update&.delete!
|
|
343
|
+
# Clean up index keys
|
|
344
|
+
ClassLevelCustomer.dbclient.del(ClassLevelCustomer.role_index_for('admin').dbkey)
|
|
345
|
+
ClassLevelCustomer.dbclient.del(ClassLevelCustomer.role_index_for('user').dbkey)
|
|
346
|
+
ClassLevelCustomer.dbclient.del(ClassLevelCustomer.role_index_for('superadmin').dbkey)
|
|
347
|
+
ClassLevelCustomer.dbclient.del(ClassLevelCustomer.role_index_for('tempuser').dbkey)
|
|
348
|
+
ClassLevelCustomer.dbclient.del(ClassLevelCustomer.region_index_for('west').dbkey)
|
|
349
|
+
ClassLevelCustomer.dbclient.del(ClassLevelCustomer.region_index_for('east').dbkey)
|
|
@@ -11,7 +11,7 @@ require_relative '../support/helpers/test_helpers'
|
|
|
11
11
|
## Has all datatype relativess
|
|
12
12
|
registered_types = Familia::DataType.registered_types.keys
|
|
13
13
|
registered_types.collect(&:to_s).sort
|
|
14
|
-
#=> ["counter", "hash", "hashkey", "list", "listkey", "lock", "set", "sorted_set", "string", "stringkey", "unsorted_set", "zset"]
|
|
14
|
+
#=> ["counter", "hash", "hashkey", "json_string", "json_stringkey", "jsonkey", "list", "listkey", "lock", "set", "sorted_set", "string", "stringkey", "unsorted_set", "zset"]
|
|
15
15
|
|
|
16
16
|
## Familia created class methods for datatype list class
|
|
17
17
|
Familia::Horreum::DefinitionMethods.public_method_defined? :list?
|
|
@@ -65,7 +65,7 @@ rescue StandardError => e
|
|
|
65
65
|
end
|
|
66
66
|
#=> false
|
|
67
67
|
|
|
68
|
-
## serialization
|
|
68
|
+
## JSON serialization is hard-coded for consistency
|
|
69
69
|
begin
|
|
70
70
|
custom_class = Class.new(Familia::Horreum) do
|
|
71
71
|
identifier_field :id
|
|
@@ -73,8 +73,9 @@ begin
|
|
|
73
73
|
field :data
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
#
|
|
77
|
-
|
|
76
|
+
# Verify that custom serialization methods have been removed
|
|
77
|
+
# dump_method and load_method are no longer available
|
|
78
|
+
!custom_class.respond_to?(:dump_method) && !custom_class.respond_to?(:load_method)
|
|
78
79
|
rescue StandardError => e
|
|
79
80
|
false
|
|
80
81
|
end
|