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.
- checksums.yaml +4 -4
- data/.github/workflows/claude-code-review.yml +57 -0
- data/.github/workflows/claude.yml +71 -0
- data/.gitignore +5 -1
- data/.rubocop.yml +3 -0
- data/CLAUDE.md +32 -10
- data/Gemfile +2 -2
- data/Gemfile.lock +4 -3
- data/docs/wiki/API-Reference.md +95 -18
- data/docs/wiki/Connection-Pooling-Guide.md +437 -0
- data/docs/wiki/Encrypted-Fields-Overview.md +40 -3
- data/docs/wiki/Expiration-Feature-Guide.md +596 -0
- data/docs/wiki/Feature-System-Guide.md +631 -0
- data/docs/wiki/Features-System-Developer-Guide.md +892 -0
- data/docs/wiki/Field-System-Guide.md +784 -0
- data/docs/wiki/Home.md +82 -15
- data/docs/wiki/Implementation-Guide.md +126 -33
- data/docs/wiki/Quantization-Feature-Guide.md +721 -0
- data/docs/wiki/Relationships-Guide.md +684 -0
- data/docs/wiki/Security-Model.md +65 -25
- data/docs/wiki/Transient-Fields-Guide.md +280 -0
- data/examples/bit_encoding_integration.rb +237 -0
- data/examples/redis_command_validation_example.rb +231 -0
- data/examples/relationships_basic.rb +273 -0
- data/lib/familia/base.rb +1 -1
- data/lib/familia/connection.rb +3 -3
- data/lib/familia/data_type/types/counter.rb +38 -0
- data/lib/familia/data_type/types/hashkey.rb +18 -0
- data/lib/familia/data_type/types/lock.rb +43 -0
- data/lib/familia/data_type/types/string.rb +9 -2
- data/lib/familia/data_type.rb +9 -6
- data/lib/familia/encryption/encrypted_data.rb +137 -0
- data/lib/familia/encryption/manager.rb +21 -4
- data/lib/familia/encryption/providers/aes_gcm_provider.rb +20 -0
- data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +20 -0
- data/lib/familia/encryption.rb +1 -1
- data/lib/familia/errors.rb +17 -3
- data/lib/familia/features/encrypted_fields/concealed_string.rb +293 -0
- data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +94 -26
- data/lib/familia/features/encrypted_fields.rb +413 -4
- data/lib/familia/features/expiration.rb +319 -33
- data/lib/familia/features/quantization.rb +385 -44
- data/lib/familia/features/relationships/cascading.rb +438 -0
- data/lib/familia/features/relationships/indexing.rb +370 -0
- data/lib/familia/features/relationships/membership.rb +503 -0
- data/lib/familia/features/relationships/permission_management.rb +264 -0
- data/lib/familia/features/relationships/querying.rb +620 -0
- data/lib/familia/features/relationships/redis_operations.rb +274 -0
- data/lib/familia/features/relationships/score_encoding.rb +442 -0
- data/lib/familia/features/relationships/tracking.rb +379 -0
- data/lib/familia/features/relationships.rb +466 -0
- data/lib/familia/features/safe_dump.rb +1 -1
- data/lib/familia/features/transient_fields/redacted_string.rb +1 -1
- data/lib/familia/features/transient_fields.rb +192 -10
- data/lib/familia/features.rb +2 -1
- data/lib/familia/field_type.rb +5 -2
- data/lib/familia/horreum/{connection.rb → core/connection.rb} +2 -8
- data/lib/familia/horreum/{database_commands.rb → core/database_commands.rb} +14 -3
- data/lib/familia/horreum/core/serialization.rb +535 -0
- data/lib/familia/horreum/{utils.rb → core/utils.rb} +0 -2
- data/lib/familia/horreum/core.rb +21 -0
- data/lib/familia/horreum/{settings.rb → shared/settings.rb} +0 -2
- data/lib/familia/horreum/{definition_methods.rb → subclass/definition.rb} +45 -29
- data/lib/familia/horreum/{management_methods.rb → subclass/management.rb} +9 -8
- data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
- data/lib/familia/horreum.rb +17 -17
- data/lib/familia/validation/command_recorder.rb +336 -0
- data/lib/familia/validation/expectations.rb +519 -0
- data/lib/familia/validation/test_helpers.rb +443 -0
- data/lib/familia/validation/validator.rb +412 -0
- data/lib/familia/validation.rb +140 -0
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +1 -1
- data/try/core/create_method_try.rb +240 -0
- data/try/core/database_consistency_try.rb +299 -0
- data/try/core/errors_try.rb +25 -4
- data/try/core/familia_try.rb +1 -1
- data/try/core/persistence_operations_try.rb +297 -0
- data/try/data_types/counter_try.rb +93 -0
- data/try/data_types/lock_try.rb +133 -0
- data/try/debugging/debug_aad_process.rb +82 -0
- data/try/debugging/debug_concealed_internal.rb +59 -0
- data/try/debugging/debug_concealed_reveal.rb +61 -0
- data/try/debugging/debug_context_aad.rb +68 -0
- data/try/debugging/debug_context_simple.rb +80 -0
- data/try/debugging/debug_cross_context.rb +62 -0
- data/try/debugging/debug_database_load.rb +64 -0
- data/try/debugging/debug_encrypted_json_check.rb +53 -0
- data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
- data/try/debugging/debug_exists_lifecycle.rb +54 -0
- data/try/debugging/debug_field_decrypt.rb +74 -0
- data/try/debugging/debug_fresh_cross_context.rb +73 -0
- data/try/debugging/debug_load_path.rb +66 -0
- data/try/debugging/debug_method_definition.rb +46 -0
- data/try/debugging/debug_method_resolution.rb +41 -0
- data/try/debugging/debug_minimal.rb +24 -0
- data/try/debugging/debug_provider.rb +68 -0
- data/try/debugging/debug_secure_behavior.rb +73 -0
- data/try/debugging/debug_string_class.rb +46 -0
- data/try/debugging/debug_test.rb +46 -0
- data/try/debugging/debug_test_design.rb +80 -0
- data/try/edge_cases/hash_symbolization_try.rb +1 -0
- data/try/edge_cases/reserved_keywords_try.rb +1 -0
- data/try/edge_cases/string_coercion_try.rb +2 -0
- data/try/encryption/encryption_core_try.rb +6 -4
- data/try/features/categorical_permissions_try.rb +515 -0
- data/try/features/encrypted_fields_core_try.rb +19 -11
- data/try/features/encrypted_fields_integration_try.rb +66 -70
- data/try/features/encrypted_fields_no_cache_security_try.rb +22 -8
- data/try/features/encrypted_fields_security_try.rb +151 -144
- data/try/features/encryption_fields/aad_protection_try.rb +108 -23
- data/try/features/encryption_fields/concealed_string_core_try.rb +253 -0
- data/try/features/encryption_fields/context_isolation_try.rb +30 -8
- data/try/features/encryption_fields/error_conditions_try.rb +6 -6
- data/try/features/encryption_fields/fresh_key_derivation_try.rb +20 -14
- data/try/features/encryption_fields/fresh_key_try.rb +27 -22
- data/try/features/encryption_fields/key_rotation_try.rb +16 -10
- data/try/features/encryption_fields/nonce_uniqueness_try.rb +15 -13
- data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
- data/try/features/encryption_fields/thread_safety_try.rb +6 -6
- data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
- data/try/features/feature_dependencies_try.rb +3 -3
- data/try/features/relationships_edge_cases_try.rb +145 -0
- data/try/features/relationships_performance_minimal_try.rb +132 -0
- data/try/features/relationships_performance_simple_try.rb +155 -0
- data/try/features/relationships_performance_try.rb +420 -0
- data/try/features/relationships_performance_working_try.rb +144 -0
- data/try/features/relationships_try.rb +237 -0
- data/try/features/safe_dump_try.rb +3 -0
- data/try/features/transient_fields/redacted_string_try.rb +2 -0
- data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
- data/try/features/transient_fields_core_try.rb +1 -1
- data/try/features/transient_fields_integration_try.rb +1 -1
- data/try/helpers/test_helpers.rb +26 -1
- data/try/horreum/base_try.rb +14 -8
- data/try/horreum/enhanced_conflict_handling_try.rb +3 -1
- data/try/horreum/initialization_try.rb +1 -1
- data/try/horreum/relations_try.rb +2 -2
- data/try/horreum/serialization_persistent_fields_try.rb +8 -8
- data/try/horreum/serialization_try.rb +39 -4
- data/try/models/customer_safe_dump_try.rb +1 -1
- data/try/models/customer_try.rb +1 -1
- data/try/validation/atomic_operations_try.rb.disabled +320 -0
- data/try/validation/command_validation_try.rb.disabled +207 -0
- data/try/validation/performance_validation_try.rb.disabled +324 -0
- data/try/validation/real_world_scenarios_try.rb.disabled +390 -0
- metadata +81 -12
- data/TEST_COVERAGE.md +0 -40
- data/lib/familia/features/relatable_objects.rb +0 -125
- data/lib/familia/horreum/serialization.rb +0 -473
- 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
|