familia 2.0.0.pre6 → 2.0.0.pre7

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/claude-code-review.yml +57 -0
  3. data/.github/workflows/claude.yml +71 -0
  4. data/.gitignore +5 -1
  5. data/.rubocop.yml +3 -0
  6. data/CLAUDE.md +32 -13
  7. data/Gemfile +2 -2
  8. data/Gemfile.lock +2 -2
  9. data/docs/wiki/Feature-System-Guide.md +36 -5
  10. data/docs/wiki/Home.md +30 -20
  11. data/docs/wiki/Relationships-Guide.md +684 -0
  12. data/examples/bit_encoding_integration.rb +237 -0
  13. data/examples/redis_command_validation_example.rb +231 -0
  14. data/examples/relationships_basic.rb +273 -0
  15. data/lib/familia/connection.rb +3 -3
  16. data/lib/familia/data_type.rb +7 -4
  17. data/lib/familia/features/encrypted_fields/concealed_string.rb +21 -23
  18. data/lib/familia/features/encrypted_fields.rb +413 -4
  19. data/lib/familia/features/expiration.rb +319 -33
  20. data/lib/familia/features/quantization.rb +385 -44
  21. data/lib/familia/features/relationships/cascading.rb +438 -0
  22. data/lib/familia/features/relationships/indexing.rb +370 -0
  23. data/lib/familia/features/relationships/membership.rb +503 -0
  24. data/lib/familia/features/relationships/permission_management.rb +264 -0
  25. data/lib/familia/features/relationships/querying.rb +620 -0
  26. data/lib/familia/features/relationships/redis_operations.rb +274 -0
  27. data/lib/familia/features/relationships/score_encoding.rb +442 -0
  28. data/lib/familia/features/relationships/tracking.rb +379 -0
  29. data/lib/familia/features/relationships.rb +466 -0
  30. data/lib/familia/features/transient_fields.rb +192 -10
  31. data/lib/familia/features.rb +2 -1
  32. data/lib/familia/horreum/subclass/definition.rb +1 -1
  33. data/lib/familia/validation/command_recorder.rb +336 -0
  34. data/lib/familia/validation/expectations.rb +519 -0
  35. data/lib/familia/validation/test_helpers.rb +443 -0
  36. data/lib/familia/validation/validator.rb +412 -0
  37. data/lib/familia/validation.rb +140 -0
  38. data/lib/familia/version.rb +1 -1
  39. data/try/edge_cases/hash_symbolization_try.rb +1 -0
  40. data/try/edge_cases/reserved_keywords_try.rb +1 -0
  41. data/try/edge_cases/string_coercion_try.rb +2 -0
  42. data/try/encryption/encryption_core_try.rb +3 -1
  43. data/try/features/categorical_permissions_try.rb +515 -0
  44. data/try/features/encryption_fields/concealed_string_core_try.rb +3 -0
  45. data/try/features/encryption_fields/context_isolation_try.rb +1 -0
  46. data/try/features/relationships_edge_cases_try.rb +145 -0
  47. data/try/features/relationships_performance_minimal_try.rb +132 -0
  48. data/try/features/relationships_performance_simple_try.rb +155 -0
  49. data/try/features/relationships_performance_try.rb +420 -0
  50. data/try/features/relationships_performance_working_try.rb +144 -0
  51. data/try/features/relationships_try.rb +237 -0
  52. data/try/features/safe_dump_try.rb +3 -0
  53. data/try/features/transient_fields/redacted_string_try.rb +2 -0
  54. data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
  55. data/try/helpers/test_helpers.rb +1 -1
  56. data/try/horreum/base_try.rb +14 -8
  57. data/try/horreum/enhanced_conflict_handling_try.rb +2 -0
  58. data/try/horreum/relations_try.rb +1 -1
  59. data/try/validation/atomic_operations_try.rb.disabled +320 -0
  60. data/try/validation/command_validation_try.rb.disabled +207 -0
  61. data/try/validation/performance_validation_try.rb.disabled +324 -0
  62. data/try/validation/real_world_scenarios_try.rb.disabled +390 -0
  63. metadata +32 -4
  64. data/docs/wiki/RelatableObjects-Guide.md +0 -563
  65. data/lib/familia/features/relatable_objects.rb +0 -125
  66. data/try/features/relatable_objects_try.rb +0 -220
@@ -0,0 +1,503 @@
1
+ # lib/familia/features/relationships/membership.rb
2
+
3
+ module Familia
4
+ module Features
5
+ module Relationships
6
+ # Membership module for member_of relationships
7
+ # Provides collision-free method naming by including collection names
8
+ module Membership
9
+ # Class-level membership configurations
10
+ def self.included(base)
11
+ base.extend ClassMethods
12
+ base.include InstanceMethods
13
+ super
14
+ end
15
+
16
+ module ClassMethods
17
+ # Define a member_of relationship
18
+ #
19
+ # @param owner_class [Class] The class that owns the collection
20
+ # @param collection_name [Symbol] Name of the collection on the owner
21
+ # @param score [Symbol, Proc, nil] How to calculate the score for sorted sets
22
+ # @param type [Symbol] Type of Redis collection (:sorted_set, :set, :list)
23
+ #
24
+ # @example Basic membership
25
+ # member_of Customer, :domains
26
+ #
27
+ # @example Membership with scoring
28
+ # member_of Team, :projects, score: -> { permission_encode(Time.now, permission_level) }
29
+ def member_of(owner_class, collection_name, score: nil, type: :sorted_set)
30
+ owner_class_name = owner_class.is_a?(Class) ? owner_class.name : owner_class.to_s.camelize
31
+
32
+ # Store metadata for this membership relationship
33
+ membership_relationships << {
34
+ owner_class: owner_class,
35
+ owner_class_name: owner_class_name,
36
+ collection_name: collection_name,
37
+ score: score,
38
+ type: type
39
+ }
40
+
41
+ # Generate instance methods with collision-free naming
42
+ owner_class_name_lower = owner_class_name.downcase
43
+
44
+ # Method to add this object to the owner's collection
45
+ # e.g., domain.add_to_customer_domains(customer)
46
+ define_method("add_to_#{owner_class_name_lower}_#{collection_name}") do |owner_instance, score = nil|
47
+ collection_key = "#{owner_class_name_lower}:#{owner_instance.identifier}:#{collection_name}"
48
+
49
+ case type
50
+ when :sorted_set
51
+ score ||= calculate_membership_score(owner_class, collection_name)
52
+ dbclient.zadd(collection_key, score, identifier)
53
+ when :set
54
+ dbclient.sadd(collection_key, identifier)
55
+ when :list
56
+ dbclient.lpush(collection_key, identifier)
57
+ end
58
+ end
59
+
60
+ # Method to remove this object from the owner's collection
61
+ # e.g., domain.remove_from_customer_domains(customer)
62
+ define_method("remove_from_#{owner_class_name_lower}_#{collection_name}") do |owner_instance|
63
+ collection_key = "#{owner_class_name_lower}:#{owner_instance.identifier}:#{collection_name}"
64
+
65
+ case type
66
+ when :sorted_set
67
+ dbclient.zrem(collection_key, identifier)
68
+ when :set
69
+ dbclient.srem(collection_key, identifier)
70
+ when :list
71
+ dbclient.lrem(collection_key, 0, identifier)
72
+ end
73
+ end
74
+
75
+ # Method to check if this object is in the owner's collection
76
+ # e.g., domain.in_customer_domains?(customer)
77
+ define_method("in_#{owner_class_name_lower}_#{collection_name}?") do |owner_instance|
78
+ collection_key = "#{owner_class_name_lower}:#{owner_instance.identifier}:#{collection_name}"
79
+
80
+ case type
81
+ when :sorted_set
82
+ !dbclient.zscore(collection_key, identifier).nil?
83
+ when :set
84
+ dbclient.sismember(collection_key, identifier)
85
+ when :list
86
+ dbclient.lpos(collection_key, identifier) != nil
87
+ end
88
+ end
89
+
90
+ # Method to get score in the owner's collection (for sorted sets)
91
+ # e.g., domain.score_in_customer_domains(customer)
92
+ if type == :sorted_set
93
+ define_method("score_in_#{owner_class_name_lower}_#{collection_name}") do |owner_instance|
94
+ collection_key = "#{owner_class_name_lower}:#{owner_instance.identifier}:#{collection_name}"
95
+ dbclient.zscore(collection_key, identifier)
96
+ end
97
+ end
98
+
99
+ # Method to get position in the owner's collection (for lists)
100
+ # e.g., domain.position_in_customer_domain_list(customer)
101
+ return unless type == :list
102
+
103
+ define_method("position_in_#{owner_class_name_lower}_#{collection_name}") do |owner_instance|
104
+ collection_key = "#{owner_class_name_lower}:#{owner_instance.identifier}:#{collection_name}"
105
+ position = dbclient.lpos(collection_key, identifier)
106
+ position
107
+ end
108
+ end
109
+
110
+ # Get all membership relationships for this class
111
+ def membership_relationships
112
+ @membership_relationships ||= []
113
+ end
114
+
115
+ private
116
+
117
+ # Generate collision-free instance methods for membership
118
+ def generate_membership_instance_methods(owner_class_name, collection_name, _score_calculator, type)
119
+ owner_class_name_lower = owner_class_name.downcase
120
+
121
+ # Method to add this object to the owner's collection
122
+ # e.g., domain.add_to_customer_domains(customer)
123
+ define_method("add_to_#{owner_class_name_lower}_#{collection_name}") do |owner_instance, score = nil|
124
+ collection_key = "#{owner_class_name_lower}:#{owner_instance.identifier}:#{collection_name}"
125
+
126
+ case type
127
+ when :sorted_set
128
+ # Find the owner class from the stored config
129
+ membership_config = self.class.membership_relationships.find do |config|
130
+ config[:owner_class_name] == owner_class_name && config[:collection_name] == collection_name
131
+ end
132
+ owner_class = membership_config[:owner_class] if membership_config
133
+ score ||= calculate_membership_score(owner_class, collection_name)
134
+ dbclient.zadd(collection_key, score, identifier)
135
+ when :set
136
+ dbclient.sadd(collection_key, identifier)
137
+ when :list
138
+ dbclient.lpush(collection_key, identifier)
139
+ end
140
+ end
141
+
142
+ # Method to remove this object from the owner's collection
143
+ # e.g., domain.remove_from_customer_domains(customer)
144
+ define_method("remove_from_#{owner_class_name_lower}_#{collection_name}") do |owner_instance|
145
+ collection_key = "#{owner_class_name_lower}:#{owner_instance.identifier}:#{collection_name}"
146
+
147
+ case type
148
+ when :sorted_set
149
+ dbclient.zrem(collection_key, identifier)
150
+ when :set
151
+ dbclient.srem(collection_key, identifier)
152
+ when :list
153
+ dbclient.lrem(collection_key, 0, identifier) # Remove all occurrences
154
+ end
155
+ end
156
+
157
+ # Method to check if this object is in the owner's collection
158
+ # e.g., domain.in_customer_domains?(customer)
159
+ define_method("in_#{owner_class_name_lower}_#{collection_name}?") do |owner_instance|
160
+ collection_key = "#{owner_class_name_lower}:#{owner_instance.identifier}:#{collection_name}"
161
+
162
+ case type
163
+ when :sorted_set
164
+ dbclient.zscore(collection_key, identifier) != nil
165
+ when :set
166
+ dbclient.sismember(collection_key, identifier)
167
+ when :list
168
+ dbclient.lpos(collection_key, identifier) != nil
169
+ end
170
+ end
171
+
172
+ # For sorted sets, add methods to get and update scores
173
+ if type == :sorted_set
174
+ # Method to get score in the owner's collection
175
+ # e.g., domain.score_in_customer_domains(customer)
176
+ define_method("score_in_#{owner_class_name_lower}_#{collection_name}") do |owner_instance|
177
+ collection_key = "#{owner_class_name_lower}:#{owner_instance.identifier}:#{collection_name}"
178
+ dbclient.zscore(collection_key, identifier)
179
+ end
180
+
181
+ # Method to update score in the owner's collection
182
+ # e.g., domain.update_score_in_customer_domains(customer, new_score)
183
+ define_method("update_score_in_#{owner_class_name_lower}_#{collection_name}") do |owner_instance, new_score|
184
+ collection_key = "#{owner_class_name_lower}:#{owner_instance.identifier}:#{collection_name}"
185
+ dbclient.zadd(collection_key, new_score, identifier, xx: true) # Only update existing
186
+ end
187
+
188
+ # Method to get rank in the owner's collection
189
+ # e.g., domain.rank_in_customer_domains(customer)
190
+ define_method("rank_in_#{owner_class_name_lower}_#{collection_name}") do |owner_instance, reverse: false|
191
+ collection_key = "#{owner_class_name_lower}:#{owner_instance.identifier}:#{collection_name}"
192
+ if reverse
193
+ dbclient.zrevrank(collection_key, identifier)
194
+ else
195
+ dbclient.zrank(collection_key, identifier)
196
+ end
197
+ end
198
+ end
199
+
200
+ # For lists, add position-related methods
201
+ if type == :list
202
+ # Method to get position in the owner's list
203
+ # e.g., domain.position_in_customer_domains(customer)
204
+ define_method("position_in_#{owner_class_name_lower}_#{collection_name}") do |owner_instance|
205
+ collection_key = "#{owner_class_name_lower}:#{owner_instance.identifier}:#{collection_name}"
206
+ dbclient.lpos(collection_key, identifier)
207
+ end
208
+
209
+ # Method to move to specific position in the owner's list
210
+ # e.g., domain.move_in_customer_domains(customer, new_position)
211
+ define_method("move_in_#{owner_class_name_lower}_#{collection_name}") do |owner_instance, new_position|
212
+ collection_key = "#{owner_class_name_lower}:#{owner_instance.identifier}:#{collection_name}"
213
+
214
+ # Remove and re-insert at new position
215
+ dbclient.multi do |tx|
216
+ tx.lrem(collection_key, 1, identifier)
217
+
218
+ if new_position.zero?
219
+ tx.lpush(collection_key, identifier)
220
+ elsif new_position == -1
221
+ tx.rpush(collection_key, identifier)
222
+ else
223
+ # For arbitrary positions, we need to use a more complex approach
224
+ # This is simplified - proper implementation would handle edge cases
225
+ tx.linsert(collection_key, 'BEFORE', dbclient.lindex(collection_key, new_position), identifier)
226
+ end
227
+ end
228
+ end
229
+ end
230
+
231
+ # Method to get all owners that contain this object in the specified collection
232
+ # e.g., domain.all_customer_domains_owners
233
+ define_method("all_#{owner_class_name_lower}_#{collection_name}_owners") do
234
+ owners = []
235
+ pattern = "#{owner_class_name_lower}:*:#{collection_name}"
236
+
237
+ dbclient.scan_each(match: pattern) do |key|
238
+ owner_id = key.split(':')[1]
239
+
240
+ # Check if this object is in this collection
241
+ is_member = case type
242
+ when :sorted_set
243
+ dbclient.zscore(key, identifier) != nil
244
+ when :set
245
+ dbclient.sismember(key, identifier)
246
+ when :list
247
+ dbclient.lpos(key, identifier) != nil
248
+ end
249
+
250
+ if is_member
251
+ # Try to instantiate the owner object
252
+ begin
253
+ owners << owner_class.new(identifier: owner_id)
254
+ rescue NameError
255
+ # Owner class not available, just store the ID
256
+ owners << { class: owner_class_name, id: owner_id }
257
+ end
258
+ end
259
+ end
260
+
261
+ owners
262
+ end
263
+
264
+ # Batch method to add to multiple owners' collections at once
265
+ # e.g., domain.add_to_multiple_customer_domains([customer1, customer2])
266
+ define_method("add_to_multiple_#{owner_class_name_lower}_#{collection_name}") do |owner_instances, score = nil|
267
+ return if owner_instances.empty?
268
+
269
+ dbclient.pipelined do |pipeline|
270
+ owner_instances.each do |owner_instance|
271
+ collection_key = "#{owner_class_name_lower}:#{owner_instance.identifier}:#{collection_name}"
272
+
273
+ case type
274
+ when :sorted_set
275
+ # Find the owner class from the stored config
276
+ membership_config = self.class.membership_relationships.find do |config|
277
+ config[:owner_class_name] == owner_class_name && config[:collection_name] == collection_name
278
+ end
279
+ owner_class = membership_config[:owner_class] if membership_config
280
+ calculated_score = score || calculate_membership_score(owner_class, collection_name)
281
+ pipeline.zadd(collection_key, calculated_score, identifier)
282
+ when :set
283
+ pipeline.sadd(collection_key, identifier)
284
+ when :list
285
+ pipeline.lpush(collection_key, identifier)
286
+ end
287
+ end
288
+ end
289
+ end
290
+
291
+ # Batch method to remove from multiple owners' collections at once
292
+ # e.g., domain.remove_from_multiple_customer_domains([customer1, customer2])
293
+ define_method("remove_from_multiple_#{owner_class_name_lower}_#{collection_name}") do |owner_instances|
294
+ return if owner_instances.empty?
295
+
296
+ dbclient.pipelined do |pipeline|
297
+ owner_instances.each do |owner_instance|
298
+ collection_key = "#{owner_class_name_lower}:#{owner_instance.identifier}:#{collection_name}"
299
+
300
+ case type
301
+ when :sorted_set
302
+ pipeline.zrem(collection_key, identifier)
303
+ when :set
304
+ pipeline.srem(collection_key, identifier)
305
+ when :list
306
+ pipeline.lrem(collection_key, 0, identifier)
307
+ end
308
+ end
309
+ end
310
+ end
311
+ end
312
+ end
313
+
314
+ # Instance methods for objects with membership relationships
315
+ module InstanceMethods
316
+ # Calculate the appropriate score for a membership relationship
317
+ #
318
+ # @param owner_class [Class] The owner class (e.g., Customer)
319
+ # @param collection_name [Symbol] The collection name (e.g., :domains)
320
+ # @return [Float] Calculated score
321
+ def calculate_membership_score(owner_class, collection_name)
322
+ # Find the membership configuration
323
+ membership_config = self.class.membership_relationships.find do |config|
324
+ config[:owner_class] == owner_class && config[:collection_name] == collection_name
325
+ end
326
+
327
+ return default_score unless membership_config
328
+
329
+ score_calculator = membership_config[:score]
330
+
331
+ # Extract the score calculation logic to reduce complexity
332
+ calculated_score = extract_score_from_calculator(score_calculator)
333
+ calculated_score || default_score
334
+ end
335
+
336
+ private
337
+
338
+ def extract_score_from_calculator(score_calculator)
339
+ case score_calculator
340
+ when Symbol
341
+ extract_score_from_symbol(score_calculator)
342
+ when Proc
343
+ extract_score_from_proc(score_calculator)
344
+ when Numeric
345
+ score_calculator.to_f
346
+ end
347
+ end
348
+
349
+ def extract_score_from_symbol(symbol)
350
+ return nil unless respond_to?(symbol)
351
+
352
+ value = send(symbol)
353
+ if value.respond_to?(:to_f)
354
+ value.to_f
355
+ elsif value.respond_to?(:to_i)
356
+ encode_score(value, 0)
357
+ end
358
+ end
359
+
360
+ def extract_score_from_proc(proc)
361
+ result = instance_exec(&proc)
362
+ return nil if result.nil?
363
+
364
+ result.respond_to?(:to_f) ? result.to_f : nil
365
+ end
366
+
367
+ def default_score
368
+ respond_to?(:current_score) ? current_score : Time.now.to_f
369
+ end
370
+
371
+ # Update membership in all collections atomically
372
+ def update_all_memberships(_action = :add)
373
+ nil unless self.class.respond_to?(:membership_relationships)
374
+
375
+ # This is a simplified version - in practice, you'd need to know
376
+ # which specific owner instances this object should be a member of
377
+ # For now, we'll skip the automatic update and rely on explicit calls
378
+ end
379
+
380
+ # Remove from all membership collections (used during destroy)
381
+ def remove_from_all_memberships
382
+ return unless self.class.respond_to?(:membership_relationships)
383
+
384
+ self.class.membership_relationships.each do |config|
385
+ owner_class_name = config[:owner_class_name]
386
+ collection_name = config[:collection_name]
387
+ type = config[:type]
388
+
389
+ # Find all collections this object is a member of
390
+ pattern = "#{owner_class_name.downcase}:*:#{collection_name}"
391
+
392
+ dbclient.scan_each(match: pattern) do |key|
393
+ case type
394
+ when :sorted_set
395
+ dbclient.zrem(key, identifier)
396
+ when :set
397
+ dbclient.srem(key, identifier)
398
+ when :list
399
+ dbclient.lrem(key, 0, identifier)
400
+ end
401
+ end
402
+ end
403
+ end
404
+
405
+ # Get all memberships this object has
406
+ #
407
+ # @return [Array<Hash>] Array of membership information
408
+ def membership_collections
409
+ return [] unless self.class.respond_to?(:membership_relationships)
410
+
411
+ memberships = []
412
+
413
+ self.class.membership_relationships.each do |config|
414
+ owner_class_name = config[:owner_class_name]
415
+ collection_name = config[:collection_name]
416
+ type = config[:type]
417
+
418
+ # Find all collections this object is a member of
419
+ pattern = "#{owner_class_name.downcase}:*:#{collection_name}"
420
+
421
+ dbclient.scan_each(match: pattern) do |key|
422
+ is_member = case type
423
+ when :sorted_set
424
+ score = dbclient.zscore(key, identifier)
425
+ next unless score
426
+
427
+ { score: score, decoded_score: decode_score(score) }
428
+ when :set
429
+ next unless dbclient.sismember(key, identifier)
430
+
431
+ {}
432
+ when :list
433
+ position = dbclient.lpos(key, identifier)
434
+ next unless position
435
+
436
+ { position: position }
437
+ end
438
+
439
+ if is_member
440
+ owner_id = key.split(':')[1]
441
+ memberships << {
442
+ owner_class: owner_class_name,
443
+ owner_id: owner_id,
444
+ collection_name: collection_name,
445
+ type: type,
446
+ key: key
447
+ }.merge(is_member)
448
+ end
449
+ end
450
+ end
451
+
452
+ memberships
453
+ end
454
+
455
+ # Transfer membership from one owner to another
456
+ #
457
+ # @param from_owner [Object] Source owner instance
458
+ # @param to_owner [Object] Target owner instance
459
+ # @param collection_name [Symbol] Collection to transfer membership in
460
+ def transfer_membership(from_owner, to_owner, collection_name)
461
+ # Find the membership configuration
462
+ config = self.class.membership_relationships.find do |rel|
463
+ rel[:collection_name] == collection_name &&
464
+ (rel[:owner_class] == from_owner.class || rel[:owner_class_name] == from_owner.class.name)
465
+ end
466
+
467
+ return false unless config
468
+
469
+ owner_class_name = config[:owner_class_name].downcase
470
+ type = config[:type]
471
+
472
+ from_key = "#{owner_class_name}:#{from_owner.identifier}:#{collection_name}"
473
+ to_key = "#{owner_class_name}:#{to_owner.identifier}:#{collection_name}"
474
+
475
+ dbclient.multi do |tx|
476
+ case type
477
+ when :sorted_set
478
+ score = dbclient.zscore(from_key, identifier)
479
+ if score
480
+ tx.zrem(from_key, identifier)
481
+ tx.zadd(to_key, score, identifier)
482
+ end
483
+ when :set
484
+ if dbclient.sismember(from_key, identifier)
485
+ tx.srem(from_key, identifier)
486
+ tx.sadd(to_key, identifier)
487
+ end
488
+ when :list
489
+ if dbclient.lpos(from_key, identifier)
490
+ tx.lrem(from_key, 1, identifier)
491
+ tx.lpush(to_key, identifier)
492
+ end
493
+ end
494
+ end
495
+
496
+ true
497
+ end
498
+ end
499
+
500
+ end
501
+ end
502
+ end
503
+ end