familia 2.0.0.pre6 → 2.0.0.pre8

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 (96) 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 +3 -3
  9. data/README.md +35 -0
  10. data/docs/wiki/Feature-System-Guide.md +36 -20
  11. data/docs/wiki/Home.md +30 -20
  12. data/docs/wiki/Relationships-Guide.md +684 -0
  13. data/examples/bit_encoding_integration.rb +237 -0
  14. data/examples/redis_command_validation_example.rb +231 -0
  15. data/examples/relationships_basic.rb +273 -0
  16. data/lib/familia/connection.rb +3 -3
  17. data/lib/familia/data_type.rb +7 -4
  18. data/lib/familia/features/encrypted_fields/concealed_string.rb +21 -23
  19. data/lib/familia/features/encrypted_fields.rb +413 -4
  20. data/lib/familia/features/expiration.rb +319 -33
  21. data/lib/familia/features/external_identifiers/external_identifier_field_type.rb +120 -0
  22. data/lib/familia/features/external_identifiers.rb +111 -0
  23. data/lib/familia/features/object_identifiers/object_identifier_field_type.rb +91 -0
  24. data/lib/familia/features/object_identifiers.rb +194 -0
  25. data/lib/familia/features/quantization.rb +385 -44
  26. data/lib/familia/features/relationships/cascading.rb +437 -0
  27. data/lib/familia/features/relationships/indexing.rb +369 -0
  28. data/lib/familia/features/relationships/membership.rb +502 -0
  29. data/lib/familia/features/relationships/permission_management.rb +264 -0
  30. data/lib/familia/features/relationships/querying.rb +615 -0
  31. data/lib/familia/features/relationships/redis_operations.rb +274 -0
  32. data/lib/familia/features/relationships/score_encoding.rb +440 -0
  33. data/lib/familia/features/relationships/tracking.rb +378 -0
  34. data/lib/familia/features/relationships.rb +466 -0
  35. data/lib/familia/features/transient_fields.rb +190 -10
  36. data/lib/familia/features.rb +18 -14
  37. data/lib/familia/horreum/core/serialization.rb +2 -5
  38. data/lib/familia/horreum/subclass/definition.rb +35 -1
  39. data/lib/familia/validation/command_recorder.rb +336 -0
  40. data/lib/familia/validation/expectations.rb +519 -0
  41. data/lib/familia/validation/test_helpers.rb +443 -0
  42. data/lib/familia/validation/validator.rb +412 -0
  43. data/lib/familia/validation.rb +140 -0
  44. data/lib/familia/version.rb +1 -3
  45. data/try/core/errors_try.rb +1 -1
  46. data/try/edge_cases/hash_symbolization_try.rb +1 -0
  47. data/try/edge_cases/reserved_keywords_try.rb +1 -0
  48. data/try/edge_cases/string_coercion_try.rb +2 -0
  49. data/try/encryption/encryption_core_try.rb +3 -1
  50. data/try/features/{encryption_fields → encrypted_fields}/concealed_string_core_try.rb +3 -0
  51. data/try/features/{encryption_fields → encrypted_fields}/context_isolation_try.rb +1 -0
  52. data/try/features/{encrypted_fields_core_try.rb → encrypted_fields/encrypted_fields_core_try.rb} +1 -1
  53. data/try/features/{encrypted_fields_integration_try.rb → encrypted_fields/encrypted_fields_integration_try.rb} +1 -1
  54. data/try/features/{encrypted_fields_no_cache_security_try.rb → encrypted_fields/encrypted_fields_no_cache_security_try.rb} +1 -1
  55. data/try/features/{encrypted_fields_security_try.rb → encrypted_fields/encrypted_fields_security_try.rb} +1 -1
  56. data/try/features/{expiration_try.rb → expiration/expiration_try.rb} +1 -1
  57. data/try/features/external_identifiers/external_identifiers_try.rb +203 -0
  58. data/try/features/object_identifiers/object_identifiers_integration_try.rb +289 -0
  59. data/try/features/object_identifiers/object_identifiers_try.rb +191 -0
  60. data/try/features/{quantization_try.rb → quantization/quantization_try.rb} +1 -1
  61. data/try/features/relationships/categorical_permissions_try.rb +515 -0
  62. data/try/features/relationships/relationships_edge_cases_try.rb +145 -0
  63. data/try/features/relationships/relationships_performance_minimal_try.rb +132 -0
  64. data/try/features/relationships/relationships_performance_simple_try.rb +155 -0
  65. data/try/features/relationships/relationships_performance_try.rb +420 -0
  66. data/try/features/relationships/relationships_performance_working_try.rb +144 -0
  67. data/try/features/relationships/relationships_try.rb +237 -0
  68. data/try/features/{safe_dump_advanced_try.rb → safe_dump/safe_dump_advanced_try.rb} +1 -1
  69. data/try/features/{safe_dump_try.rb → safe_dump/safe_dump_try.rb} +4 -1
  70. data/try/features/transient_fields/redacted_string_try.rb +2 -0
  71. data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
  72. data/try/features/{transient_fields_core_try.rb → transient_fields/transient_fields_core_try.rb} +1 -1
  73. data/try/features/{transient_fields_integration_try.rb → transient_fields/transient_fields_integration_try.rb} +1 -1
  74. data/try/helpers/test_helpers.rb +1 -1
  75. data/try/horreum/base_try.rb +14 -8
  76. data/try/horreum/enhanced_conflict_handling_try.rb +2 -0
  77. data/try/horreum/relations_try.rb +1 -1
  78. data/try/validation/atomic_operations_try.rb.disabled +320 -0
  79. data/try/validation/command_validation_try.rb.disabled +207 -0
  80. data/try/validation/performance_validation_try.rb.disabled +324 -0
  81. data/try/validation/real_world_scenarios_try.rb.disabled +390 -0
  82. metadata +62 -27
  83. data/docs/wiki/RelatableObjects-Guide.md +0 -563
  84. data/lib/familia/features/relatable_objects.rb +0 -125
  85. data/try/features/relatable_objects_try.rb +0 -220
  86. /data/try/features/{encryption_fields → encrypted_fields}/aad_protection_try.rb +0 -0
  87. /data/try/features/{encryption_fields → encrypted_fields}/error_conditions_try.rb +0 -0
  88. /data/try/features/{encryption_fields → encrypted_fields}/fresh_key_derivation_try.rb +0 -0
  89. /data/try/features/{encryption_fields → encrypted_fields}/fresh_key_try.rb +0 -0
  90. /data/try/features/{encryption_fields → encrypted_fields}/key_rotation_try.rb +0 -0
  91. /data/try/features/{encryption_fields → encrypted_fields}/memory_security_try.rb +0 -0
  92. /data/try/features/{encryption_fields → encrypted_fields}/missing_current_key_version_try.rb +0 -0
  93. /data/try/features/{encryption_fields → encrypted_fields}/nonce_uniqueness_try.rb +0 -0
  94. /data/try/features/{encryption_fields → encrypted_fields}/secure_by_default_behavior_try.rb +0 -0
  95. /data/try/features/{encryption_fields → encrypted_fields}/thread_safety_try.rb +0 -0
  96. /data/try/features/{encryption_fields → encrypted_fields}/universal_serialization_safety_try.rb +0 -0
@@ -0,0 +1,615 @@
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
+ valid_members << [score, member] if (permission_bits & required_bits) == required_bits
307
+ end
308
+
309
+ # Recreate filtered collection if we have valid members
310
+ next unless valid_members.any?
311
+
312
+ dbclient.zadd(filtered_key, valid_members)
313
+ dbclient.expire(filtered_key, 300) # Temporary key cleanup
314
+ filtered_keys << filtered_key
315
+ end
316
+
317
+ filtered_keys
318
+ end
319
+
320
+ # Filter single key by permission using bitwise operations
321
+ def filter_key_by_permission(key, min_permission, temp_key)
322
+ return key unless min_permission
323
+
324
+ begin
325
+ required_bits = ScoreEncoding.permission_level_value(min_permission)
326
+ rescue ArgumentError
327
+ # Invalid permission symbol - return empty temp key (no access)
328
+ dbclient.del(temp_key) if dbclient.exists(temp_key) == 1
329
+ return temp_key
330
+ end
331
+
332
+ # Get all members with their scores for bitwise filtering
333
+ members_with_scores = dbclient.zrange(key, 0, -1, with_scores: true)
334
+
335
+ # Filter members that have required permission bits
336
+ valid_members = []
337
+ members_with_scores.each_slice(2) do |member, score|
338
+ decoded = decode_score(score)
339
+ permission_bits = decoded[:permissions]
340
+
341
+ # Check if this member has the required permission bits
342
+ valid_members << [score, member] if (permission_bits & required_bits) == required_bits
343
+ end
344
+
345
+ # Create filtered collection
346
+ if valid_members.any?
347
+ dbclient.zadd(temp_key, valid_members)
348
+ else
349
+ dbclient.zadd(temp_key, []) # Create empty sorted set
350
+ end
351
+ dbclient.expire(temp_key, 300)
352
+
353
+ temp_key
354
+ end
355
+
356
+ # Format collection for display
357
+ def format_collection(collection)
358
+ if collection[:owner]
359
+ owner = collection[:owner]
360
+ "#{owner.class.name}:#{owner.identifier}:#{collection[:collection]}"
361
+ elsif collection[:key]
362
+ collection[:key]
363
+ else
364
+ collection.to_s
365
+ end
366
+ end
367
+
368
+ # Create empty result set
369
+ def empty_result_set
370
+ temp_key = create_temp_key("empty_#{name.downcase}", 60)
371
+ # Create an actual empty zset
372
+ dbclient.zadd(temp_key, 0, '__nil__')
373
+ dbclient.zrem(temp_key, '__nil__')
374
+ dbclient.expire(temp_key, 60)
375
+ Familia::SortedSet.new(nil, dbkey: temp_key, logical_database: logical_database)
376
+ end
377
+ end
378
+
379
+ # Instance methods for querying relationships
380
+ module InstanceMethods
381
+ # Find all collections this object appears in with specific permissions
382
+ #
383
+ # @param min_permission [Symbol] Minimum required permission
384
+ # @return [Array<Hash>] Collections this object is a member of
385
+ def accessible_collections(min_permission: nil)
386
+ collections = []
387
+
388
+ # Check tracking relationships
389
+ if self.class.respond_to?(:tracking_relationships)
390
+ collections.concat(find_tracking_collections(min_permission))
391
+ end
392
+
393
+ # Check membership relationships
394
+ if self.class.respond_to?(:membership_relationships)
395
+ collections.concat(find_membership_collections(min_permission))
396
+ end
397
+
398
+ collections
399
+ end
400
+
401
+ # Get permission bits in a specific collection
402
+ #
403
+ # @param owner [Object] Collection owner
404
+ # @param collection_name [Symbol] Collection name
405
+ # @return [Integer, nil] Permission bits or nil if not a member
406
+ def permission_in_collection(owner, collection_name)
407
+ collection_key = "#{owner.class.name.downcase}:#{owner.identifier}:#{collection_name}"
408
+ score = dbclient.zscore(collection_key, identifier)
409
+
410
+ return nil unless score
411
+
412
+ decoded = permission_decode(score)
413
+ decoded[:permissions]
414
+ end
415
+
416
+ # Check if this object has specific permission in a collection
417
+ #
418
+ # @param owner [Object] Collection owner
419
+ # @param collection_name [Symbol] Collection name
420
+ # @param required_permission [Symbol] Required permission
421
+ # @return [Boolean] True if object has required permission
422
+ def permission_in_collection?(owner, collection_name, required_permission)
423
+ current_bits = permission_in_collection(owner, collection_name)
424
+ return false if current_bits.nil? # Not in collection
425
+
426
+ begin
427
+ required_bits = ScoreEncoding.permission_level_value(required_permission)
428
+ rescue ArgumentError
429
+ # Invalid permission symbol - deny access
430
+ return false
431
+ end
432
+
433
+ # Check if current permissions include the required permission using bitwise AND
434
+ # Note: 0 bits means exists in collection but no permissions
435
+ (current_bits & required_bits) == required_bits
436
+ end
437
+
438
+ # Find similar objects based on shared collection membership
439
+ #
440
+ # @param min_shared_collections [Integer] Minimum shared collections
441
+ # @return [Array<Hash>] Similar objects with similarity scores
442
+ def find_similar_objects(min_shared_collections: 1)
443
+ my_collections = accessible_collections
444
+ return [] if my_collections.empty?
445
+
446
+ similar_objects = {}
447
+
448
+ my_collections.each do |collection_info|
449
+ collection_key = collection_info[:key]
450
+
451
+ # Get all members of this collection
452
+ other_members = dbclient.zrange(collection_key, 0, -1)
453
+ other_members.delete(identifier) # Remove self
454
+
455
+ other_members.each do |other_identifier|
456
+ similar_objects[other_identifier] ||= {
457
+ shared_collections: 0,
458
+ collections: [],
459
+ identifier: other_identifier
460
+ }
461
+ similar_objects[other_identifier][:shared_collections] += 1
462
+ similar_objects[other_identifier][:collections] << collection_info
463
+ end
464
+ end
465
+
466
+ # Filter by minimum shared collections and calculate similarity
467
+ results = similar_objects.values
468
+ .select { |obj| obj[:shared_collections] >= min_shared_collections }
469
+
470
+ results.each do |obj|
471
+ obj[:similarity] = obj[:shared_collections].to_f / my_collections.length
472
+ end
473
+
474
+ results.sort_by { |obj| -obj[:similarity] }
475
+ end
476
+
477
+ private
478
+
479
+ # Find tracking collections this object is in
480
+ def find_tracking_collections(min_permission)
481
+ collections = []
482
+
483
+ self.class.tracking_relationships.each do |config|
484
+ context_class_name = config[:context_class_name]
485
+ collection_name = config[:collection_name]
486
+
487
+ pattern = "#{context_class_name.downcase}:*:#{collection_name}"
488
+
489
+ dbclient.scan_each(match: pattern) do |key|
490
+ score = dbclient.zscore(key, identifier)
491
+ next unless score
492
+
493
+ # Decode permission once for reuse
494
+ decoded = permission_decode(score)
495
+ actual_bits = decoded[:permissions]
496
+
497
+ # Check permission if required
498
+ if min_permission
499
+ begin
500
+ required_bits = ScoreEncoding.permission_level_value(min_permission)
501
+ # Skip if required permission bits are not present
502
+ next if (actual_bits & required_bits) != required_bits
503
+ rescue ArgumentError
504
+ # Invalid permission symbol - skip this collection
505
+ next
506
+ end
507
+ end
508
+
509
+ context_id = key.split(':')[1]
510
+ collections << {
511
+ type: :tracking,
512
+ context_class: context_class_name,
513
+ context_id: context_id,
514
+ collection_name: collection_name,
515
+ key: key,
516
+ score: score,
517
+ permission_bits: actual_bits,
518
+ permissions: decoded[:permission_list]
519
+ }
520
+ end
521
+ end
522
+
523
+ collections
524
+ end
525
+
526
+ # Find membership collections this object is in
527
+ def find_membership_collections(min_permission)
528
+ collections = []
529
+
530
+ self.class.membership_relationships.each do |config|
531
+ collections.concat(process_membership_relationship(config, min_permission))
532
+ end
533
+
534
+ collections
535
+ end
536
+
537
+ # Process a single membership relationship configuration
538
+ def process_membership_relationship(config, min_permission)
539
+ collections = []
540
+ owner_class_name = config[:owner_class_name]
541
+ collection_name = config[:collection_name]
542
+ type = config[:type]
543
+
544
+ pattern = "#{owner_class_name.downcase}:*:#{collection_name}"
545
+
546
+ dbclient.scan_each(match: pattern) do |key|
547
+ collection_info = process_membership_key(key, type, min_permission)
548
+ next unless collection_info
549
+
550
+ owner_id = key.split(':')[1]
551
+ collection_info.merge!(
552
+ type: :membership,
553
+ owner_class: owner_class_name,
554
+ owner_id: owner_id,
555
+ collection_name: collection_name,
556
+ collection_type: type,
557
+ key: key
558
+ )
559
+
560
+ collections << collection_info
561
+ end
562
+
563
+ collections
564
+ end
565
+
566
+ # Process membership for a specific key
567
+ def process_membership_key(key, type, min_permission)
568
+ is_member = false
569
+ score = nil
570
+
571
+ case type
572
+ when :sorted_set
573
+ score = dbclient.zscore(key, identifier)
574
+ is_member = !score.nil?
575
+ when :set
576
+ is_member = dbclient.sismember(key, identifier)
577
+ when :list
578
+ is_member = !dbclient.lpos(key, identifier).nil?
579
+ end
580
+
581
+ return nil unless is_member
582
+
583
+ # Decode score once if we have one (for permission check and result)
584
+ decoded = nil
585
+ if score
586
+ decoded = permission_decode(score)
587
+
588
+ # Check permission for sorted sets
589
+ if min_permission && type == :sorted_set
590
+ begin
591
+ required_bits = ScoreEncoding.permission_level_value(min_permission)
592
+ actual_bits = decoded[:permissions]
593
+ # Return nil if required permission bits are not present
594
+ return nil if (actual_bits & required_bits) != required_bits
595
+ rescue ArgumentError
596
+ # Invalid permission symbol - deny access
597
+ return nil
598
+ end
599
+ end
600
+ end
601
+
602
+ collection_info = {}
603
+ if score && decoded
604
+ collection_info[:score] = score
605
+ collection_info[:permission_bits] = decoded[:permissions]
606
+ collection_info[:permissions] = decoded[:permission_list]
607
+ end
608
+
609
+ collection_info
610
+ end
611
+ end
612
+ end
613
+ end
614
+ end
615
+ end