familia 2.0.0.pre5 → 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 (151) 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 -10
  7. data/Gemfile +2 -2
  8. data/Gemfile.lock +4 -3
  9. data/docs/wiki/API-Reference.md +95 -18
  10. data/docs/wiki/Connection-Pooling-Guide.md +437 -0
  11. data/docs/wiki/Encrypted-Fields-Overview.md +40 -3
  12. data/docs/wiki/Expiration-Feature-Guide.md +596 -0
  13. data/docs/wiki/Feature-System-Guide.md +631 -0
  14. data/docs/wiki/Features-System-Developer-Guide.md +892 -0
  15. data/docs/wiki/Field-System-Guide.md +784 -0
  16. data/docs/wiki/Home.md +82 -15
  17. data/docs/wiki/Implementation-Guide.md +126 -33
  18. data/docs/wiki/Quantization-Feature-Guide.md +721 -0
  19. data/docs/wiki/Relationships-Guide.md +684 -0
  20. data/docs/wiki/Security-Model.md +65 -25
  21. data/docs/wiki/Transient-Fields-Guide.md +280 -0
  22. data/examples/bit_encoding_integration.rb +237 -0
  23. data/examples/redis_command_validation_example.rb +231 -0
  24. data/examples/relationships_basic.rb +273 -0
  25. data/lib/familia/base.rb +1 -1
  26. data/lib/familia/connection.rb +3 -3
  27. data/lib/familia/data_type/types/counter.rb +38 -0
  28. data/lib/familia/data_type/types/hashkey.rb +18 -0
  29. data/lib/familia/data_type/types/lock.rb +43 -0
  30. data/lib/familia/data_type/types/string.rb +9 -2
  31. data/lib/familia/data_type.rb +9 -6
  32. data/lib/familia/encryption/encrypted_data.rb +137 -0
  33. data/lib/familia/encryption/manager.rb +21 -4
  34. data/lib/familia/encryption/providers/aes_gcm_provider.rb +20 -0
  35. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +20 -0
  36. data/lib/familia/encryption.rb +1 -1
  37. data/lib/familia/errors.rb +17 -3
  38. data/lib/familia/features/encrypted_fields/concealed_string.rb +293 -0
  39. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +94 -26
  40. data/lib/familia/features/encrypted_fields.rb +413 -4
  41. data/lib/familia/features/expiration.rb +319 -33
  42. data/lib/familia/features/quantization.rb +385 -44
  43. data/lib/familia/features/relationships/cascading.rb +438 -0
  44. data/lib/familia/features/relationships/indexing.rb +370 -0
  45. data/lib/familia/features/relationships/membership.rb +503 -0
  46. data/lib/familia/features/relationships/permission_management.rb +264 -0
  47. data/lib/familia/features/relationships/querying.rb +620 -0
  48. data/lib/familia/features/relationships/redis_operations.rb +274 -0
  49. data/lib/familia/features/relationships/score_encoding.rb +442 -0
  50. data/lib/familia/features/relationships/tracking.rb +379 -0
  51. data/lib/familia/features/relationships.rb +466 -0
  52. data/lib/familia/features/safe_dump.rb +1 -1
  53. data/lib/familia/features/transient_fields/redacted_string.rb +1 -1
  54. data/lib/familia/features/transient_fields.rb +192 -10
  55. data/lib/familia/features.rb +2 -1
  56. data/lib/familia/field_type.rb +5 -2
  57. data/lib/familia/horreum/{connection.rb → core/connection.rb} +2 -8
  58. data/lib/familia/horreum/{database_commands.rb → core/database_commands.rb} +14 -3
  59. data/lib/familia/horreum/core/serialization.rb +535 -0
  60. data/lib/familia/horreum/{utils.rb → core/utils.rb} +0 -2
  61. data/lib/familia/horreum/core.rb +21 -0
  62. data/lib/familia/horreum/{settings.rb → shared/settings.rb} +0 -2
  63. data/lib/familia/horreum/{definition_methods.rb → subclass/definition.rb} +45 -29
  64. data/lib/familia/horreum/{management_methods.rb → subclass/management.rb} +9 -8
  65. data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
  66. data/lib/familia/horreum.rb +17 -17
  67. data/lib/familia/validation/command_recorder.rb +336 -0
  68. data/lib/familia/validation/expectations.rb +519 -0
  69. data/lib/familia/validation/test_helpers.rb +443 -0
  70. data/lib/familia/validation/validator.rb +412 -0
  71. data/lib/familia/validation.rb +140 -0
  72. data/lib/familia/version.rb +1 -1
  73. data/lib/familia.rb +1 -1
  74. data/try/core/create_method_try.rb +240 -0
  75. data/try/core/database_consistency_try.rb +299 -0
  76. data/try/core/errors_try.rb +25 -4
  77. data/try/core/familia_try.rb +1 -1
  78. data/try/core/persistence_operations_try.rb +297 -0
  79. data/try/data_types/counter_try.rb +93 -0
  80. data/try/data_types/lock_try.rb +133 -0
  81. data/try/debugging/debug_aad_process.rb +82 -0
  82. data/try/debugging/debug_concealed_internal.rb +59 -0
  83. data/try/debugging/debug_concealed_reveal.rb +61 -0
  84. data/try/debugging/debug_context_aad.rb +68 -0
  85. data/try/debugging/debug_context_simple.rb +80 -0
  86. data/try/debugging/debug_cross_context.rb +62 -0
  87. data/try/debugging/debug_database_load.rb +64 -0
  88. data/try/debugging/debug_encrypted_json_check.rb +53 -0
  89. data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
  90. data/try/debugging/debug_exists_lifecycle.rb +54 -0
  91. data/try/debugging/debug_field_decrypt.rb +74 -0
  92. data/try/debugging/debug_fresh_cross_context.rb +73 -0
  93. data/try/debugging/debug_load_path.rb +66 -0
  94. data/try/debugging/debug_method_definition.rb +46 -0
  95. data/try/debugging/debug_method_resolution.rb +41 -0
  96. data/try/debugging/debug_minimal.rb +24 -0
  97. data/try/debugging/debug_provider.rb +68 -0
  98. data/try/debugging/debug_secure_behavior.rb +73 -0
  99. data/try/debugging/debug_string_class.rb +46 -0
  100. data/try/debugging/debug_test.rb +46 -0
  101. data/try/debugging/debug_test_design.rb +80 -0
  102. data/try/edge_cases/hash_symbolization_try.rb +1 -0
  103. data/try/edge_cases/reserved_keywords_try.rb +1 -0
  104. data/try/edge_cases/string_coercion_try.rb +2 -0
  105. data/try/encryption/encryption_core_try.rb +6 -4
  106. data/try/features/categorical_permissions_try.rb +515 -0
  107. data/try/features/encrypted_fields_core_try.rb +19 -11
  108. data/try/features/encrypted_fields_integration_try.rb +66 -70
  109. data/try/features/encrypted_fields_no_cache_security_try.rb +22 -8
  110. data/try/features/encrypted_fields_security_try.rb +151 -144
  111. data/try/features/encryption_fields/aad_protection_try.rb +108 -23
  112. data/try/features/encryption_fields/concealed_string_core_try.rb +253 -0
  113. data/try/features/encryption_fields/context_isolation_try.rb +30 -8
  114. data/try/features/encryption_fields/error_conditions_try.rb +6 -6
  115. data/try/features/encryption_fields/fresh_key_derivation_try.rb +20 -14
  116. data/try/features/encryption_fields/fresh_key_try.rb +27 -22
  117. data/try/features/encryption_fields/key_rotation_try.rb +16 -10
  118. data/try/features/encryption_fields/nonce_uniqueness_try.rb +15 -13
  119. data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
  120. data/try/features/encryption_fields/thread_safety_try.rb +6 -6
  121. data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
  122. data/try/features/feature_dependencies_try.rb +3 -3
  123. data/try/features/relationships_edge_cases_try.rb +145 -0
  124. data/try/features/relationships_performance_minimal_try.rb +132 -0
  125. data/try/features/relationships_performance_simple_try.rb +155 -0
  126. data/try/features/relationships_performance_try.rb +420 -0
  127. data/try/features/relationships_performance_working_try.rb +144 -0
  128. data/try/features/relationships_try.rb +237 -0
  129. data/try/features/safe_dump_try.rb +3 -0
  130. data/try/features/transient_fields/redacted_string_try.rb +2 -0
  131. data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
  132. data/try/features/transient_fields_core_try.rb +1 -1
  133. data/try/features/transient_fields_integration_try.rb +1 -1
  134. data/try/helpers/test_helpers.rb +26 -1
  135. data/try/horreum/base_try.rb +14 -8
  136. data/try/horreum/enhanced_conflict_handling_try.rb +3 -1
  137. data/try/horreum/initialization_try.rb +1 -1
  138. data/try/horreum/relations_try.rb +2 -2
  139. data/try/horreum/serialization_persistent_fields_try.rb +8 -8
  140. data/try/horreum/serialization_try.rb +39 -4
  141. data/try/models/customer_safe_dump_try.rb +1 -1
  142. data/try/models/customer_try.rb +1 -1
  143. data/try/validation/atomic_operations_try.rb.disabled +320 -0
  144. data/try/validation/command_validation_try.rb.disabled +207 -0
  145. data/try/validation/performance_validation_try.rb.disabled +324 -0
  146. data/try/validation/real_world_scenarios_try.rb.disabled +390 -0
  147. metadata +81 -12
  148. data/TEST_COVERAGE.md +0 -40
  149. data/lib/familia/features/relatable_objects.rb +0 -125
  150. data/lib/familia/horreum/serialization.rb +0 -473
  151. data/try/features/relatable_objects_try.rb +0 -220
@@ -0,0 +1,620 @@
1
+ # lib/familia/features/relationships/querying.rb
2
+
3
+ module Familia
4
+ module Features
5
+ module Relationships
6
+ # Querying module for advanced Redis set operations on relationship collections
7
+ # Provides union, intersection, difference operations with permission filtering
8
+ module Querying
9
+ # Class-level querying capabilities
10
+ def self.included(base)
11
+ base.extend ClassMethods
12
+ base.include InstanceMethods
13
+ super
14
+ end
15
+
16
+ # Querying::ClassMethods
17
+ #
18
+ module ClassMethods
19
+ # Union of multiple collections (accessible items across multiple sources)
20
+ #
21
+ # @param collections [Array<Hash>] Collection configurations
22
+ # @param min_permission [Symbol] Minimum required permission
23
+ # @param ttl [Integer] TTL for result set in seconds
24
+ # @return [Familia::SortedSet] Temporary sorted set with results
25
+ #
26
+ # @example Union of accessible domains
27
+ # Domain.union_collections([
28
+ # { owner: customer, collection: :domains },
29
+ # { owner: team, collection: :domains },
30
+ # { owner: org, collection: :all_domains }
31
+ # ], min_permission: :read, ttl: 300)
32
+ def union_collections(collections, min_permission: nil, ttl: 300, aggregate: :sum)
33
+ return empty_result_set if collections.empty?
34
+
35
+ temp_key = create_temp_key("union_#{name.downcase}", ttl)
36
+ source_keys = build_collection_keys(collections)
37
+
38
+ # Apply permission filtering if needed
39
+ source_keys = filter_keys_by_permission(source_keys, min_permission, temp_key) if min_permission
40
+
41
+ return empty_result_set if source_keys.empty?
42
+
43
+ dbclient.zunionstore(temp_key, source_keys, aggregate: aggregate)
44
+ dbclient.expire(temp_key, ttl)
45
+
46
+ Familia::SortedSet.new(nil, dbkey: temp_key, logical_database: logical_database)
47
+ end
48
+
49
+ # Intersection of multiple collections (items present in ALL collections)
50
+ #
51
+ # @param collections [Array<Hash>] Collection configurations
52
+ # @param min_permission [Symbol] Minimum required permission
53
+ # @param ttl [Integer] TTL for result set in seconds
54
+ # @return [Familia::SortedSet] Temporary sorted set with results
55
+ def intersection_collections(collections, min_permission: nil, ttl: 300, aggregate: :sum)
56
+ return empty_result_set if collections.empty?
57
+
58
+ temp_key = create_temp_key("intersection_#{name.downcase}", ttl)
59
+ source_keys = build_collection_keys(collections)
60
+
61
+ # Apply permission filtering if needed
62
+ source_keys = filter_keys_by_permission(source_keys, min_permission, temp_key) if min_permission
63
+
64
+ return empty_result_set if source_keys.empty?
65
+
66
+ dbclient.zinterstore(temp_key, source_keys, aggregate: aggregate)
67
+ dbclient.expire(temp_key, ttl)
68
+
69
+ Familia::SortedSet.new(nil, dbkey: temp_key, logical_database: logical_database)
70
+ end
71
+
72
+ # Difference of collections (items in first collection but not in others)
73
+ #
74
+ # @param base_collection [Hash] Base collection configuration
75
+ # @param exclude_collections [Array<Hash>] Collections to exclude
76
+ # @param min_permission [Symbol] Minimum required permission
77
+ # @param ttl [Integer] TTL for result set in seconds
78
+ # @return [Familia::SortedSet] Temporary sorted set with results
79
+ def difference_collections(base_collection, exclude_collections = [], min_permission: nil, ttl: 300)
80
+ temp_key = create_temp_key("difference_#{name.downcase}", ttl)
81
+
82
+ base_key = build_collection_key(base_collection)
83
+ exclude_keys = build_collection_keys(exclude_collections)
84
+
85
+ # Apply permission filtering if needed
86
+ if min_permission
87
+ base_key = filter_key_by_permission(base_key, min_permission, "#{temp_key}_base")
88
+ exclude_keys = filter_keys_by_permission(exclude_keys, min_permission, temp_key)
89
+ end
90
+
91
+ # Start with base collection
92
+ dbclient.zunionstore(temp_key, [base_key])
93
+
94
+ # Remove elements from exclude collections
95
+ exclude_keys.each do |exclude_key|
96
+ members_to_remove = dbclient.zrange(exclude_key, 0, -1)
97
+ dbclient.zrem(temp_key, members_to_remove) if members_to_remove.any?
98
+ end
99
+
100
+ dbclient.expire(temp_key, ttl)
101
+
102
+ Familia::SortedSet.new(nil, dbkey: temp_key, logical_database: logical_database)
103
+ end
104
+
105
+ # Find collections with shared members
106
+ #
107
+ # @param collections [Array<Hash>] Collection configurations
108
+ # @param min_shared [Integer] Minimum number of shared members
109
+ # @param ttl [Integer] TTL for result set in seconds
110
+ # @return [Hash] Map of collection pairs to shared member counts
111
+ def shared_members(collections, min_shared: 1, ttl: 300)
112
+ return {} if collections.length < 2
113
+
114
+ shared_results = {}
115
+ collections.map { |c| build_collection_key(c) }
116
+
117
+ # Compare each pair of collections
118
+ collections.combination(2).each do |coll1, coll2|
119
+ key1 = build_collection_key(coll1)
120
+ key2 = build_collection_key(coll2)
121
+
122
+ temp_key = create_temp_key("shared_#{SecureRandom.hex(4)}", ttl)
123
+
124
+ # Use intersection to find shared members
125
+ shared_count = dbclient.zinterstore(temp_key, [key1, key2])
126
+
127
+ if shared_count >= min_shared
128
+ shared_members_list = dbclient.zrange(temp_key, 0, -1, with_scores: true)
129
+ shared_results["#{format_collection(coll1)} ∩ #{format_collection(coll2)}"] = {
130
+ count: shared_count,
131
+ members: shared_members_list
132
+ }
133
+ end
134
+
135
+ dbclient.del(temp_key)
136
+ end
137
+
138
+ shared_results
139
+ end
140
+
141
+ # Query collections with complex filters
142
+ #
143
+ # @param collections [Array<Hash>] Collection configurations
144
+ # @param filters [Hash] Query filters
145
+ # @param ttl [Integer] TTL for result set in seconds
146
+ # @return [Familia::SortedSet] Filtered result set
147
+ #
148
+ # @example Complex query
149
+ # Domain.query_collections([
150
+ # { owner: customer, collection: :domains },
151
+ # { owner: team, collection: :domains }
152
+ # ], {
153
+ # min_permission: :write,
154
+ # score_range: [1.week.ago.to_i, Time.now.to_i],
155
+ # limit: 50,
156
+ # operation: :union
157
+ # })
158
+ def query_collections(collections, filters = {}, ttl: 300)
159
+ return empty_result_set if collections.empty?
160
+
161
+ operation = filters[:operation] || :union
162
+ min_permission = filters[:min_permission]
163
+ score_range = filters[:score_range]
164
+ limit = filters[:limit]
165
+ offset = filters[:offset] || 0
166
+
167
+ temp_key = create_temp_key("query_#{name.downcase}", ttl)
168
+ source_keys = build_collection_keys(collections)
169
+
170
+ # Apply permission filtering
171
+ source_keys = filter_keys_by_permission(source_keys, min_permission, temp_key) if min_permission
172
+
173
+ return empty_result_set if source_keys.empty?
174
+
175
+ # Perform set operation
176
+ case operation
177
+ when :union
178
+ dbclient.zunionstore(temp_key, source_keys)
179
+ when :intersection
180
+ dbclient.zinterstore(temp_key, source_keys)
181
+ end
182
+
183
+ # Apply score range filtering
184
+ if score_range
185
+ min_score, max_score = score_range
186
+ # Remove elements outside the score range
187
+ dbclient.zremrangebyscore(temp_key, '-inf', "(#{min_score}")
188
+ dbclient.zremrangebyscore(temp_key, "(#{max_score}", '+inf')
189
+ end
190
+
191
+ # Apply limit
192
+ if limit
193
+ total_count = dbclient.zcard(temp_key)
194
+ if total_count > offset + limit
195
+ # Keep only the requested range
196
+ dbclient.zremrangebyrank(temp_key, offset + limit, -1)
197
+ end
198
+ dbclient.zremrangebyrank(temp_key, 0, offset - 1) if offset.positive?
199
+ end
200
+
201
+ dbclient.expire(temp_key, ttl)
202
+
203
+ Familia::SortedSet.new(nil, dbkey: temp_key, logical_database: logical_database)
204
+ end
205
+
206
+ # Get collection statistics
207
+ #
208
+ # @param collections [Array<Hash>] Collection configurations
209
+ # @return [Hash] Statistics about the collections
210
+ def collection_statistics(collections)
211
+ stats = {
212
+ total_collections: collections.length,
213
+ collection_sizes: {},
214
+ total_unique_members: 0,
215
+ total_members: 0,
216
+ score_ranges: {}
217
+ }
218
+
219
+ all_members = Set.new
220
+
221
+ collections.each do |collection|
222
+ key = build_collection_key(collection)
223
+ collection_name = format_collection(collection)
224
+
225
+ size = dbclient.zcard(key)
226
+ stats[:collection_sizes][collection_name] = size
227
+ stats[:total_members] += size
228
+
229
+ next unless size.positive?
230
+
231
+ # Get score range
232
+ min_score = dbclient.zrange(key, 0, 0, with_scores: true).first&.last
233
+ max_score = dbclient.zrange(key, -1, -1, with_scores: true).first&.last
234
+
235
+ stats[:score_ranges][collection_name] = {
236
+ min: min_score,
237
+ max: max_score,
238
+ min_decoded: min_score ? decode_score(min_score) : nil,
239
+ max_decoded: max_score ? decode_score(max_score) : nil
240
+ }
241
+
242
+ # Track unique members
243
+ members = dbclient.zrange(key, 0, -1)
244
+ all_members.merge(members)
245
+ end
246
+
247
+ stats[:total_unique_members] = all_members.size
248
+ stats[:overlap_ratio] = if stats[:total_members].positive?
249
+ (stats[:total_members] - stats[:total_unique_members]).to_f / stats[:total_members]
250
+ else
251
+ 0
252
+ end
253
+
254
+ stats
255
+ end
256
+
257
+ private
258
+
259
+ # Build Redis key for a collection
260
+ def build_collection_key(collection)
261
+ if collection[:owner]
262
+ owner = collection[:owner]
263
+ collection_name = collection[:collection]
264
+ "#{owner.class.name.downcase}:#{owner.identifier}:#{collection_name}"
265
+ elsif collection[:key]
266
+ collection[:key]
267
+ else
268
+ raise ArgumentError, 'Collection must have :owner and :collection or :key'
269
+ end
270
+ end
271
+
272
+ # Build Redis keys for multiple collections
273
+ def build_collection_keys(collections)
274
+ collections.map { |collection| build_collection_key(collection) }
275
+ end
276
+
277
+ # Filter collections by permission requirements using bitwise operations
278
+ def filter_keys_by_permission(keys, min_permission, temp_prefix)
279
+ return keys unless min_permission
280
+
281
+ begin
282
+ required_bits = ScoreEncoding.permission_level_value(min_permission)
283
+ rescue ArgumentError
284
+ # Invalid permission symbol - return empty array (no access)
285
+ return []
286
+ end
287
+ filtered_keys = []
288
+
289
+ keys.each_with_index do |key, index|
290
+ filtered_key = "#{temp_prefix}_filtered_#{index}"
291
+
292
+ # Copy all elements first
293
+ dbclient.zunionstore(filtered_key, [key])
294
+
295
+ # Get all members with their scores for bitwise filtering
296
+ members_with_scores = dbclient.zrange(filtered_key, 0, -1, with_scores: true)
297
+ dbclient.del(filtered_key) # Clear temp key
298
+
299
+ # Filter members that have required permission bits
300
+ valid_members = []
301
+ members_with_scores.each_slice(2) do |member, score|
302
+ decoded = decode_score(score)
303
+ permission_bits = decoded[:permissions]
304
+
305
+ # Check if this member has the required permission bits
306
+ if (permission_bits & required_bits) == required_bits
307
+ valid_members << [score, member]
308
+ end
309
+ end
310
+
311
+ # Recreate filtered collection if we have valid members
312
+ if valid_members.any?
313
+ dbclient.zadd(filtered_key, valid_members)
314
+ dbclient.expire(filtered_key, 300) # Temporary key cleanup
315
+ filtered_keys << filtered_key
316
+ end
317
+ end
318
+
319
+ filtered_keys
320
+ end
321
+
322
+ # Filter single key by permission using bitwise operations
323
+ def filter_key_by_permission(key, min_permission, temp_key)
324
+ return key unless min_permission
325
+
326
+ begin
327
+ required_bits = ScoreEncoding.permission_level_value(min_permission)
328
+ rescue ArgumentError
329
+ # Invalid permission symbol - return empty temp key (no access)
330
+ dbclient.del(temp_key) if dbclient.exists(temp_key) == 1
331
+ return temp_key
332
+ end
333
+
334
+ # Get all members with their scores for bitwise filtering
335
+ members_with_scores = dbclient.zrange(key, 0, -1, with_scores: true)
336
+
337
+ # Filter members that have required permission bits
338
+ valid_members = []
339
+ members_with_scores.each_slice(2) do |member, score|
340
+ decoded = decode_score(score)
341
+ permission_bits = decoded[:permissions]
342
+
343
+ # Check if this member has the required permission bits
344
+ if (permission_bits & required_bits) == required_bits
345
+ valid_members << [score, member]
346
+ end
347
+ end
348
+
349
+ # Create filtered collection
350
+ if valid_members.any?
351
+ dbclient.zadd(temp_key, valid_members)
352
+ else
353
+ dbclient.zadd(temp_key, []) # Create empty sorted set
354
+ end
355
+ dbclient.expire(temp_key, 300)
356
+
357
+ temp_key
358
+ end
359
+
360
+ # Format collection for display
361
+ def format_collection(collection)
362
+ if collection[:owner]
363
+ owner = collection[:owner]
364
+ "#{owner.class.name}:#{owner.identifier}:#{collection[:collection]}"
365
+ elsif collection[:key]
366
+ collection[:key]
367
+ else
368
+ collection.to_s
369
+ end
370
+ end
371
+
372
+ # Create empty result set
373
+ def empty_result_set
374
+ temp_key = create_temp_key("empty_#{name.downcase}", 60)
375
+ # Create an actual empty zset
376
+ dbclient.zadd(temp_key, 0, '__nil__')
377
+ dbclient.zrem(temp_key, '__nil__')
378
+ dbclient.expire(temp_key, 60)
379
+ Familia::SortedSet.new(nil, dbkey: temp_key, logical_database: logical_database)
380
+ end
381
+ end
382
+
383
+ # Instance methods for querying relationships
384
+ module InstanceMethods
385
+ # Find all collections this object appears in with specific permissions
386
+ #
387
+ # @param min_permission [Symbol] Minimum required permission
388
+ # @return [Array<Hash>] Collections this object is a member of
389
+ def accessible_collections(min_permission: nil)
390
+ collections = []
391
+
392
+ # Check tracking relationships
393
+ if self.class.respond_to?(:tracking_relationships)
394
+ collections.concat(find_tracking_collections(min_permission))
395
+ end
396
+
397
+ # Check membership relationships
398
+ if self.class.respond_to?(:membership_relationships)
399
+ collections.concat(find_membership_collections(min_permission))
400
+ end
401
+
402
+ collections
403
+ end
404
+
405
+ # Get permission bits in a specific collection
406
+ #
407
+ # @param owner [Object] Collection owner
408
+ # @param collection_name [Symbol] Collection name
409
+ # @return [Integer, nil] Permission bits or nil if not a member
410
+ def permission_in_collection(owner, collection_name)
411
+ collection_key = "#{owner.class.name.downcase}:#{owner.identifier}:#{collection_name}"
412
+ score = dbclient.zscore(collection_key, identifier)
413
+
414
+ return nil unless score
415
+
416
+ decoded = permission_decode(score)
417
+ decoded[:permissions]
418
+ end
419
+
420
+ # Check if this object has specific permission in a collection
421
+ #
422
+ # @param owner [Object] Collection owner
423
+ # @param collection_name [Symbol] Collection name
424
+ # @param required_permission [Symbol] Required permission
425
+ # @return [Boolean] True if object has required permission
426
+ def permission_in_collection?(owner, collection_name, required_permission)
427
+ current_bits = permission_in_collection(owner, collection_name)
428
+ return false if current_bits.nil? # Not in collection
429
+
430
+ begin
431
+ required_bits = ScoreEncoding.permission_level_value(required_permission)
432
+ rescue ArgumentError
433
+ # Invalid permission symbol - deny access
434
+ return false
435
+ end
436
+
437
+ # Check if current permissions include the required permission using bitwise AND
438
+ # Note: 0 bits means exists in collection but no permissions
439
+ (current_bits & required_bits) == required_bits
440
+ end
441
+
442
+ # Find similar objects based on shared collection membership
443
+ #
444
+ # @param min_shared_collections [Integer] Minimum shared collections
445
+ # @return [Array<Hash>] Similar objects with similarity scores
446
+ def find_similar_objects(min_shared_collections: 1)
447
+ my_collections = accessible_collections
448
+ return [] if my_collections.empty?
449
+
450
+ similar_objects = {}
451
+
452
+ my_collections.each do |collection_info|
453
+ collection_key = collection_info[:key]
454
+
455
+ # Get all members of this collection
456
+ other_members = dbclient.zrange(collection_key, 0, -1)
457
+ other_members.delete(identifier) # Remove self
458
+
459
+ other_members.each do |other_identifier|
460
+ similar_objects[other_identifier] ||= {
461
+ shared_collections: 0,
462
+ collections: [],
463
+ identifier: other_identifier
464
+ }
465
+ similar_objects[other_identifier][:shared_collections] += 1
466
+ similar_objects[other_identifier][:collections] << collection_info
467
+ end
468
+ end
469
+
470
+ # Filter by minimum shared collections and calculate similarity
471
+ results = similar_objects.values
472
+ .select { |obj| obj[:shared_collections] >= min_shared_collections }
473
+
474
+ results.each do |obj|
475
+ obj[:similarity] = obj[:shared_collections].to_f / my_collections.length
476
+ end
477
+
478
+ results.sort_by { |obj| -obj[:similarity] }
479
+ end
480
+
481
+ private
482
+
483
+ # Find tracking collections this object is in
484
+ def find_tracking_collections(min_permission)
485
+ collections = []
486
+
487
+ self.class.tracking_relationships.each do |config|
488
+ context_class_name = config[:context_class_name]
489
+ collection_name = config[:collection_name]
490
+
491
+ pattern = "#{context_class_name.downcase}:*:#{collection_name}"
492
+
493
+ dbclient.scan_each(match: pattern) do |key|
494
+ score = dbclient.zscore(key, identifier)
495
+ next unless score
496
+
497
+ # Decode permission once for reuse
498
+ decoded = permission_decode(score)
499
+ actual_bits = decoded[:permissions]
500
+
501
+ # Check permission if required
502
+ if min_permission
503
+ begin
504
+ required_bits = ScoreEncoding.permission_level_value(min_permission)
505
+ # Skip if required permission bits are not present
506
+ next if (actual_bits & required_bits) != required_bits
507
+ rescue ArgumentError
508
+ # Invalid permission symbol - skip this collection
509
+ next
510
+ end
511
+ end
512
+
513
+ context_id = key.split(':')[1]
514
+ collections << {
515
+ type: :tracking,
516
+ context_class: context_class_name,
517
+ context_id: context_id,
518
+ collection_name: collection_name,
519
+ key: key,
520
+ score: score,
521
+ permission_bits: actual_bits,
522
+ permissions: decoded[:permission_list]
523
+ }
524
+ end
525
+ end
526
+
527
+ collections
528
+ end
529
+
530
+ # Find membership collections this object is in
531
+ def find_membership_collections(min_permission)
532
+ collections = []
533
+
534
+ self.class.membership_relationships.each do |config|
535
+ collections.concat(process_membership_relationship(config, min_permission))
536
+ end
537
+
538
+ collections
539
+ end
540
+
541
+ # Process a single membership relationship configuration
542
+ def process_membership_relationship(config, min_permission)
543
+ collections = []
544
+ owner_class_name = config[:owner_class_name]
545
+ collection_name = config[:collection_name]
546
+ type = config[:type]
547
+
548
+ pattern = "#{owner_class_name.downcase}:*:#{collection_name}"
549
+
550
+ dbclient.scan_each(match: pattern) do |key|
551
+ collection_info = process_membership_key(key, type, min_permission)
552
+ next unless collection_info
553
+
554
+ owner_id = key.split(':')[1]
555
+ collection_info.merge!(
556
+ type: :membership,
557
+ owner_class: owner_class_name,
558
+ owner_id: owner_id,
559
+ collection_name: collection_name,
560
+ collection_type: type,
561
+ key: key
562
+ )
563
+
564
+ collections << collection_info
565
+ end
566
+
567
+ collections
568
+ end
569
+
570
+ # Process membership for a specific key
571
+ def process_membership_key(key, type, min_permission)
572
+ is_member = false
573
+ score = nil
574
+
575
+ case type
576
+ when :sorted_set
577
+ score = dbclient.zscore(key, identifier)
578
+ is_member = !score.nil?
579
+ when :set
580
+ is_member = dbclient.sismember(key, identifier)
581
+ when :list
582
+ is_member = !dbclient.lpos(key, identifier).nil?
583
+ end
584
+
585
+ return nil unless is_member
586
+
587
+ # Decode score once if we have one (for permission check and result)
588
+ decoded = nil
589
+ if score
590
+ decoded = permission_decode(score)
591
+
592
+ # Check permission for sorted sets
593
+ if min_permission && type == :sorted_set
594
+ begin
595
+ required_bits = ScoreEncoding.permission_level_value(min_permission)
596
+ actual_bits = decoded[:permissions]
597
+ # Return nil if required permission bits are not present
598
+ return nil if (actual_bits & required_bits) != required_bits
599
+ rescue ArgumentError
600
+ # Invalid permission symbol - deny access
601
+ return nil
602
+ end
603
+ end
604
+ end
605
+
606
+ collection_info = {}
607
+ if score && decoded
608
+ collection_info[:score] = score
609
+ collection_info[:permission_bits] = decoded[:permissions]
610
+ collection_info[:permissions] = decoded[:permission_list]
611
+ end
612
+
613
+ collection_info
614
+ end
615
+ end
616
+
617
+ end
618
+ end
619
+ end
620
+ end