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,274 @@
1
+ # lib/familia/features/relationships/redis_operations.rb
2
+
3
+ module Familia
4
+ module Features
5
+ module Relationships
6
+ # Redis operations module providing atomic multi-collection operations
7
+ # and native Redis set operations for relationships
8
+ module RedisOperations
9
+ # Execute multiple Redis operations atomically using MULTI/EXEC
10
+ #
11
+ # @param redis [Redis] Redis connection to use
12
+ # @yield [Redis] Yields Redis connection in transaction context
13
+ # @return [Array] Results from Redis transaction
14
+ #
15
+ # @example Atomic multi-collection update
16
+ # atomic_operation(redis) do |tx|
17
+ # tx.zadd("customer:123:domains", score, domain_id)
18
+ # tx.zadd("team:456:domains", score, domain_id)
19
+ # tx.hset("domain_index", domain_name, domain_id)
20
+ # end
21
+ def atomic_operation(redis = nil)
22
+ redis ||= redis_connection
23
+
24
+ redis.multi do |tx|
25
+ yield tx if block_given?
26
+ end
27
+ end
28
+
29
+ # Update object presence in multiple collections atomically
30
+ #
31
+ # @param collections [Array<Hash>] Array of collection configurations
32
+ # @param action [Symbol] Action to perform (:add, :remove)
33
+ # @param identifier [String] Object identifier
34
+ # @param default_score [Float] Default score if not specified per collection
35
+ #
36
+ # @example Update presence in multiple collections
37
+ # update_multiple_presence([
38
+ # { key: "customer:123:domains", score: current_score },
39
+ # { key: "team:456:domains", score: permission_encode(Time.now, :read) },
40
+ # { key: "org:789:all_domains", score: current_score }
41
+ # ], :add, domain.identifier)
42
+ def update_multiple_presence(collections, action, identifier, default_score = nil)
43
+ return unless collections&.any?
44
+
45
+ redis = self.class.dbclient
46
+
47
+ atomic_operation(redis) do |tx|
48
+ collections.each do |collection_config|
49
+ redis_key = collection_config[:key]
50
+ score = collection_config[:score] || default_score || current_score
51
+
52
+ case action
53
+ when :add
54
+ tx.zadd(redis_key, score, identifier)
55
+ when :remove
56
+ tx.zrem(redis_key, identifier)
57
+ when :update
58
+ # Use ZADD with XX flag to only update existing members
59
+ tx.zadd(redis_key, score, identifier, xx: true)
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ # Perform Redis set operations (union, intersection, difference) on sorted sets
66
+ #
67
+ # @param operation [Symbol] Operation type (:union, :intersection, :difference)
68
+ # @param destination [String] Redis key for result storage
69
+ # @param source_keys [Array<String>] Source Redis keys to operate on
70
+ # @param weights [Array<Float>] Optional weights for union operations
71
+ # @param aggregate [Symbol] Aggregation method (:sum, :min, :max)
72
+ # @param ttl [Integer] TTL for destination key in seconds
73
+ # @return [Integer] Number of elements in resulting set
74
+ #
75
+ # @example Union of accessible domains
76
+ # set_operation(:union, "temp:accessible_domains:#{user_id}",
77
+ # ["customer:domains", "team:domains", "org:domains"],
78
+ # ttl: 300)
79
+ def set_operation(operation, destination, source_keys, weights: nil, aggregate: :sum, ttl: nil)
80
+ return 0 if source_keys.empty?
81
+
82
+ redis = redis_connection
83
+
84
+ atomic_operation(redis) do |tx|
85
+ case operation
86
+ when :union
87
+ if weights
88
+ tx.zunionstore(destination, source_keys.zip(weights).to_h, aggregate: aggregate)
89
+ else
90
+ tx.zunionstore(destination, source_keys, aggregate: aggregate)
91
+ end
92
+ when :intersection
93
+ if weights
94
+ tx.zinterstore(destination, source_keys.zip(weights).to_h, aggregate: aggregate)
95
+ else
96
+ tx.zinterstore(destination, source_keys, aggregate: aggregate)
97
+ end
98
+ when :difference
99
+ first_key = source_keys.first
100
+ other_keys = source_keys[1..] || []
101
+
102
+ tx.zunionstore(destination, [first_key])
103
+ other_keys.each do |key|
104
+ members = redis.zrange(key, 0, -1)
105
+ tx.zrem(destination, members) if members.any?
106
+ end
107
+ end
108
+
109
+ tx.expire(destination, ttl) if ttl
110
+ end
111
+
112
+ redis.zcard(destination)
113
+ end
114
+
115
+ # Create temporary Redis key with automatic cleanup
116
+ #
117
+ # @param base_name [String] Base name for the temporary key
118
+ # @param ttl [Integer] TTL in seconds (default: 300)
119
+ # @return [String] Generated temporary key name
120
+ #
121
+ # @example
122
+ # temp_key = create_temp_key("user_accessible_domains", 600)
123
+ # #=> "temp:user_accessible_domains:1704067200:abc123"
124
+ def create_temp_key(base_name, ttl = 300)
125
+ timestamp = Time.now.to_i
126
+ random_suffix = SecureRandom.hex(3)
127
+ temp_key = "temp:#{base_name}:#{timestamp}:#{random_suffix}"
128
+
129
+ # Set immediate expiry to ensure cleanup even if operation fails
130
+ redis_connection.expire(temp_key, ttl)
131
+
132
+ temp_key
133
+ end
134
+
135
+ # Batch add multiple items to a sorted set
136
+ #
137
+ # @param redis_key [String] Redis sorted set key
138
+ # @param items [Array<Hash>] Array of {member: String, score: Float} hashes
139
+ # @param mode [Symbol] Add mode (:normal, :nx, :xx, :lt, :gt)
140
+ #
141
+ # @example Batch add domains with scores
142
+ # batch_zadd("customer:domains", [
143
+ # { member: "domain1", score: encode_score(Time.now, permission: :read) },
144
+ # { member: "domain2", score: encode_score(Time.now, permission: :write) }
145
+ # ])
146
+ def batch_zadd(redis_key, items, mode: :normal)
147
+ return 0 if items.empty?
148
+
149
+ redis = redis_connection
150
+ zadd_args = items.flat_map { |item| [item[:score], item[:member]] }
151
+
152
+ case mode
153
+ when :nx
154
+ redis.zadd(redis_key, zadd_args, nx: true)
155
+ when :xx
156
+ redis.zadd(redis_key, zadd_args, xx: true)
157
+ when :lt
158
+ redis.zadd(redis_key, zadd_args, lt: true)
159
+ when :gt
160
+ redis.zadd(redis_key, zadd_args, gt: true)
161
+ else
162
+ redis.zadd(redis_key, zadd_args)
163
+ end
164
+ end
165
+
166
+ # Query sorted set with score filtering and permission checking
167
+ #
168
+ # @param redis_key [String] Redis sorted set key
169
+ # @param start_score [Float] Minimum score (inclusive)
170
+ # @param end_score [Float] Maximum score (inclusive)
171
+ # @param offset [Integer] Offset for pagination
172
+ # @param count [Integer] Maximum number of results
173
+ # @param with_scores [Boolean] Include scores in results
174
+ # @param min_permission [Symbol] Minimum permission level required
175
+ # @return [Array] Query results
176
+ #
177
+ # @example Query domains with read permission or higher
178
+ # query_by_score("customer:domains",
179
+ # encode_score(1.hour.ago, 0),
180
+ # encode_score(Time.now, MAX_METADATA),
181
+ # min_permission: :read)
182
+ def query_by_score(redis_key, start_score = '-inf', end_score = '+inf',
183
+ offset: 0, count: -1, with_scores: false, min_permission: nil)
184
+ self.class.dbclient
185
+
186
+ # Adjust score range for permission filtering
187
+ if min_permission
188
+ permission_value = ScoreEncoding.permission_level_value(min_permission)
189
+ # Ensure minimum score includes required permission level
190
+ if start_score.is_a?(Numeric)
191
+ decoded = decode_score(start_score)
192
+ if decoded[:permissions] < permission_value
193
+ start_score = encode_score(decoded[:timestamp],
194
+ permission_value)
195
+ end
196
+ else
197
+ start_score = encode_score(0, permission_value)
198
+ end
199
+ end
200
+
201
+ options = {
202
+ limit: (count.positive? ? [offset, count] : nil),
203
+ with_scores: with_scores
204
+ }.compact
205
+
206
+ results = dbclient.zrangebyscore(redis_key, start_score, end_score, **options)
207
+
208
+ # Filter results by permission if needed using correct bitwise operations
209
+ if min_permission && with_scores
210
+ permission_mask = ScoreEncoding.permission_level_value(min_permission)
211
+ results = results.select do |_member, score|
212
+ decoded = decode_score(score)
213
+ # Use bitwise AND to check if permission mask is satisfied
214
+ decoded[:permissions].allbits?(permission_mask)
215
+ end
216
+ end
217
+
218
+ results
219
+ end
220
+
221
+ # Clean up expired temporary keys
222
+ #
223
+ # @param pattern [String] Pattern to match temporary keys
224
+ # @param batch_size [Integer] Number of keys to process at once
225
+ #
226
+ # @example Clean up old temporary keys
227
+ # cleanup_temp_keys("temp:user_*", 100)
228
+ def cleanup_temp_keys(pattern = 'temp:*', batch_size = 100)
229
+ self.class.dbclient
230
+ cursor = 0
231
+
232
+ loop do
233
+ cursor, keys = dbclient.scan(cursor, match: pattern, count: batch_size)
234
+
235
+ if keys.any?
236
+ # Check TTL and remove keys that should have expired
237
+ keys.each_slice(batch_size) do |key_batch|
238
+ dbclient.pipelined do |pipeline|
239
+ key_batch.each do |key|
240
+ ttl = dbclient.ttl(key)
241
+ pipeline.del(key) if ttl == -1 # Key exists but has no TTL
242
+ end
243
+ end
244
+ end
245
+ end
246
+
247
+ break if cursor.zero?
248
+ end
249
+ end
250
+
251
+ # Get Redis connection for the current class or instance
252
+ def redis_connection
253
+ if self.class.respond_to?(:dbclient)
254
+ self.class.dbclient
255
+ elsif respond_to?(:dbclient)
256
+ dbclient
257
+ else
258
+ Familia.dbclient
259
+ end
260
+ end
261
+
262
+ private
263
+
264
+ # Validate Redis key format
265
+ def validate_redis_key(key)
266
+ raise ArgumentError, 'Redis key cannot be nil or empty' if key.nil? || key.empty?
267
+ raise ArgumentError, 'Redis key must be a string' unless key.is_a?(String)
268
+
269
+ key
270
+ end
271
+ end
272
+ end
273
+ end
274
+ end