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,379 @@
1
+ # lib/familia/features/relationships/tracking.rb
2
+
3
+ module Familia
4
+ module Features
5
+ module Relationships
6
+ # Tracking module for tracked_in relationships using Redis sorted sets
7
+ # Provides multi-presence support where objects can exist in multiple collections
8
+ module Tracking
9
+ # Class-level tracking configurations
10
+ def self.included(base)
11
+ base.extend ClassMethods
12
+ base.include InstanceMethods
13
+ super
14
+ end
15
+
16
+ module ClassMethods
17
+ # Simple singularize method (basic implementation)
18
+ def singularize_word(word)
19
+ word = word.to_s
20
+ # Basic English pluralization rules (simplified)
21
+ if word.end_with?('ies')
22
+ "#{word[0..-4]}y"
23
+ elsif word.end_with?('es') && word.length > 3
24
+ word[0..-3]
25
+ elsif word.end_with?('s') && word.length > 1
26
+ word[0..-2]
27
+ else
28
+ word
29
+ end
30
+ end
31
+
32
+ # Simple camelize method (basic implementation)
33
+ def camelize_word(word)
34
+ word.to_s.split('_').map(&:capitalize).join
35
+ end
36
+
37
+ # Define a tracked_in relationship
38
+ #
39
+ # @param context_class [Class, Symbol] The class that owns the collection
40
+ # @param collection_name [Symbol] Name of the collection
41
+ # @param score [Symbol, Proc, nil] How to calculate the score
42
+ # @param on_destroy [Symbol] What to do when object is destroyed (:remove, :ignore)
43
+ #
44
+ # @example Basic tracking
45
+ # tracked_in Customer, :domains, score: :created_at
46
+ #
47
+ # @example Multi-presence tracking
48
+ # tracked_in Customer, :domains, score: -> { permission_encode(created_at, permission_level) }
49
+ # tracked_in Team, :domains, score: :added_at
50
+ # tracked_in Organization, :all_domains, score: :created_at
51
+ def tracked_in(context_class, collection_name, score: nil, on_destroy: :remove)
52
+ # Handle special :global context
53
+ if context_class == :global
54
+ context_class_name = 'Global'
55
+ elsif context_class.is_a?(Class)
56
+ class_name = context_class.name
57
+ context_class_name = if class_name.include?('::')
58
+ # Extract the last part after the last ::
59
+ class_name.split('::').last
60
+ else
61
+ class_name
62
+ end
63
+ # Extract just the class name, handling anonymous classes
64
+ else
65
+ context_class_name = camelize_word(context_class)
66
+ end
67
+
68
+ # Store metadata for this tracking relationship
69
+ tracking_relationships << {
70
+ context_class: context_class,
71
+ context_class_name: context_class_name,
72
+ collection_name: collection_name,
73
+ score: score,
74
+ on_destroy: on_destroy
75
+ }
76
+
77
+ # Generate class methods on the context class (skip for global)
78
+ if context_class == :global
79
+ generate_global_class_methods(self, collection_name)
80
+ else
81
+ generate_context_class_methods(context_class, collection_name)
82
+ end
83
+
84
+ # Generate instance methods on this class
85
+ generate_tracking_instance_methods(context_class_name, collection_name, score)
86
+ end
87
+
88
+ # Get all tracking relationships for this class
89
+ def tracking_relationships
90
+ @tracking_relationships ||= []
91
+ end
92
+
93
+ private
94
+
95
+ # Generate global collection methods (e.g., Domain.global_all_domains)
96
+ def generate_global_class_methods(target_class, collection_name)
97
+ # Generate global collection getter method
98
+ target_class.define_singleton_method("global_#{collection_name}") do
99
+ collection_key = "global:#{collection_name}"
100
+ Familia::SortedSet.new(nil, dbkey: collection_key, logical_database: logical_database)
101
+ end
102
+
103
+ # Generate global add method (e.g., Domain.add_to_global_all_domains)
104
+ target_class.define_singleton_method("add_to_#{collection_name}") do |item, score = nil|
105
+ collection = send("global_#{collection_name}")
106
+
107
+ # Calculate score if not provided
108
+ score ||= if item.respond_to?(:calculate_tracking_score)
109
+ item.calculate_tracking_score(:global, collection_name)
110
+ else
111
+ item.current_score
112
+ end
113
+
114
+ # Ensure score is never nil
115
+ score = item.current_score if score.nil?
116
+
117
+ collection.add(score, item.identifier)
118
+ end
119
+
120
+ # Generate global remove method
121
+ target_class.define_singleton_method("remove_from_#{collection_name}") do |item|
122
+ collection = send("global_#{collection_name}")
123
+ collection.delete(item.identifier)
124
+ end
125
+ end
126
+
127
+ # Generate methods on the context class (e.g., Customer.domains)
128
+ def generate_context_class_methods(context_class, collection_name)
129
+ # Resolve context class if it's a symbol/string
130
+ actual_context_class = context_class.is_a?(Class) ? context_class : Object.const_get(camelize_word(context_class))
131
+
132
+ # Generate collection getter method
133
+ actual_context_class.define_method(collection_name) do
134
+ collection_key = "#{self.class.name.downcase}:#{identifier}:#{collection_name}"
135
+ Familia::SortedSet.new(nil, dbkey: collection_key, logical_database: self.class.logical_database)
136
+ end
137
+
138
+ # Generate add method (e.g., Customer#add_domain)
139
+ actual_context_class.define_method("add_#{singularize_word(collection_name)}") do |item, score = nil|
140
+ collection = send(collection_name)
141
+
142
+ # Calculate score if not provided
143
+ score ||= if item.respond_to?(:calculate_tracking_score)
144
+ item.calculate_tracking_score(self.class, collection_name)
145
+ else
146
+ item.current_score
147
+ end
148
+
149
+ # Ensure score is never nil
150
+ score = item.current_score if score.nil?
151
+
152
+ collection.add(score, item.identifier)
153
+ end
154
+
155
+ # Generate remove method (e.g., Customer#remove_domain)
156
+ actual_context_class.define_method("remove_#{singularize_word(collection_name)}") do |item|
157
+ collection = send(collection_name)
158
+ collection.delete(item.identifier)
159
+ end
160
+
161
+ # Generate bulk add method (e.g., Customer#add_domains)
162
+ actual_context_class.define_method("add_#{collection_name}") do |items|
163
+ return if items.empty?
164
+
165
+ collection = send(collection_name)
166
+
167
+ # Prepare batch data
168
+ batch_data = items.map do |item|
169
+ score = if item.respond_to?(:calculate_tracking_score)
170
+ item.calculate_tracking_score(self.class, collection_name)
171
+ else
172
+ item.current_score
173
+ end
174
+ # Ensure score is never nil
175
+ score = item.current_score if score.nil?
176
+ { member: item.identifier, score: score }
177
+ end
178
+
179
+ # Use batch operation from RedisOperations
180
+ collection.dbclient.pipelined do |pipeline|
181
+ batch_data.each do |data|
182
+ pipeline.zadd(collection.rediskey, data[:score], data[:member])
183
+ end
184
+ end
185
+ end
186
+
187
+ # Generate query methods with score filtering
188
+ actual_context_class.define_method("#{collection_name}_with_permission") do |min_permission = :read|
189
+ collection = send(collection_name)
190
+ permission_score = ScoreEncoding.permission_encode(0, min_permission)
191
+
192
+ collection.zrangebyscore(permission_score, '+inf', with_scores: true)
193
+ end
194
+ end
195
+
196
+ # Generate instance methods on the tracked class
197
+ def generate_tracking_instance_methods(context_class_name, collection_name, _score_calculator)
198
+ # Method to check if this object is in a specific collection
199
+ # e.g., domain.in_customer_domains?(customer)
200
+ define_method("in_#{context_class_name.downcase}_#{collection_name}?") do |context_instance|
201
+ collection_key = "#{context_class_name.downcase}:#{context_instance.identifier}:#{collection_name}"
202
+ dbclient.zscore(collection_key, identifier) != nil
203
+ end
204
+
205
+ # Method to add this object to a specific collection
206
+ # e.g., domain.add_to_customer_domains(customer, score)
207
+ define_method("add_to_#{context_class_name.downcase}_#{collection_name}") do |context_instance, score = nil|
208
+ collection_key = "#{context_class_name.downcase}:#{context_instance.identifier}:#{collection_name}"
209
+
210
+ score ||= calculate_tracking_score(context_class_name, collection_name)
211
+
212
+ # Ensure score is never nil
213
+ score = current_score if score.nil?
214
+
215
+ dbclient.zadd(collection_key, score, identifier)
216
+ end
217
+
218
+ # Method to remove this object from a specific collection
219
+ # e.g., domain.remove_from_customer_domains(customer)
220
+ define_method("remove_from_#{context_class_name.downcase}_#{collection_name}") do |context_instance|
221
+ collection_key = "#{context_class_name.downcase}:#{context_instance.identifier}:#{collection_name}"
222
+ dbclient.zrem(collection_key, identifier)
223
+ end
224
+
225
+ # Method to get score in a specific collection
226
+ # e.g., domain.score_in_customer_domains(customer)
227
+ define_method("score_in_#{context_class_name.downcase}_#{collection_name}") do |context_instance|
228
+ collection_key = "#{context_class_name.downcase}:#{context_instance.identifier}:#{collection_name}"
229
+ dbclient.zscore(collection_key, identifier)
230
+ end
231
+
232
+ # Method to update score in a specific collection
233
+ # e.g., domain.update_score_in_customer_domains(customer, new_score)
234
+ define_method("update_score_in_#{context_class_name.downcase}_#{collection_name}") do |context_instance, new_score|
235
+ collection_key = "#{context_class_name.downcase}:#{context_instance.identifier}:#{collection_name}"
236
+ dbclient.zadd(collection_key, new_score, identifier, xx: true) # Only update existing
237
+ end
238
+ end
239
+ end
240
+
241
+ # Instance methods for tracked objects
242
+ module InstanceMethods
243
+ # Calculate the appropriate score for a tracking relationship
244
+ #
245
+ # @param context_class [Class] The context class (e.g., Customer)
246
+ # @param collection_name [Symbol] The collection name (e.g., :domains)
247
+ # @return [Float] Calculated score
248
+ def calculate_tracking_score(context_class, collection_name)
249
+ # Find the tracking configuration
250
+ tracking_config = self.class.tracking_relationships.find do |config|
251
+ config[:context_class] == context_class && config[:collection_name] == collection_name
252
+ end
253
+
254
+ return current_score unless tracking_config
255
+
256
+ score_calculator = tracking_config[:score]
257
+
258
+ case score_calculator
259
+ when Symbol
260
+ # Field name or method name
261
+ if respond_to?(score_calculator)
262
+ value = send(score_calculator)
263
+ if value.respond_to?(:to_f)
264
+ value.to_f
265
+ elsif value.respond_to?(:to_i)
266
+ encode_score(value, 0)
267
+ else
268
+ current_score
269
+ end
270
+ else
271
+ current_score
272
+ end
273
+ when Proc
274
+ # Execute proc in context of this instance
275
+ result = instance_exec(&score_calculator)
276
+ # Ensure we get a numeric result
277
+ if result.nil?
278
+ current_score
279
+ elsif result.respond_to?(:to_f)
280
+ result.to_f
281
+ else
282
+ current_score
283
+ end
284
+ when Numeric
285
+ score_calculator.to_f
286
+ else
287
+ current_score
288
+ end
289
+ end
290
+
291
+ # Update presence in all tracked collections atomically
292
+ def update_all_tracking_collections
293
+ return unless self.class.respond_to?(:tracking_relationships)
294
+
295
+ []
296
+
297
+ self.class.tracking_relationships.each do |config|
298
+ config[:context_class_name]
299
+ config[:collection_name]
300
+
301
+ # This is a simplified version - in practice, you'd need to know
302
+ # which specific instances this object should be tracked in
303
+ # For now, we'll skip the automatic update and rely on explicit calls
304
+ end
305
+ end
306
+
307
+ # Remove from all tracking collections (used during destroy)
308
+ def remove_from_all_tracking_collections
309
+ return unless self.class.respond_to?(:tracking_relationships)
310
+
311
+ # Get all possible collection keys this object might be in
312
+ # This is expensive but necessary for cleanup
313
+ redis_conn = redis
314
+ pattern = '*:*:*' # This could be optimized with better key patterns
315
+
316
+ cursor = 0
317
+ matching_keys = []
318
+
319
+ loop do
320
+ cursor, keys = redis_conn.scan(cursor, match: pattern, count: 1000)
321
+ matching_keys.concat(keys)
322
+ break if cursor.zero?
323
+ end
324
+
325
+ # Filter keys that might contain this object and remove it
326
+ redis_conn.pipelined do |pipeline|
327
+ matching_keys.each do |key|
328
+ # Check if this key matches any of our tracking relationships
329
+ self.class.tracking_relationships.each do |config|
330
+ context_class_name = config[:context_class_name].downcase
331
+ collection_name = config[:collection_name]
332
+
333
+ if key.include?(context_class_name) && key.include?(collection_name.to_s)
334
+ pipeline.zrem(key, identifier)
335
+ end
336
+ end
337
+ end
338
+ end
339
+ end
340
+
341
+ # Get all collections this object appears in
342
+ #
343
+ # @return [Array<Hash>] Array of collection information
344
+ def tracking_collections_membership
345
+ return [] unless self.class.respond_to?(:tracking_relationships)
346
+
347
+ memberships = []
348
+
349
+ self.class.tracking_relationships.each do |config|
350
+ context_class_name = config[:context_class_name]
351
+ collection_name = config[:collection_name]
352
+
353
+ # Find all instances of context_class where this object appears
354
+ # This is simplified - in practice you'd need a more efficient approach
355
+ pattern = "#{context_class_name.downcase}:*:#{collection_name}"
356
+
357
+ dbclient.scan_each(match: pattern) do |key|
358
+ score = dbclient.zscore(key, identifier)
359
+ if score
360
+ context_id = key.split(':')[1]
361
+ memberships << {
362
+ context_class: context_class_name,
363
+ context_id: context_id,
364
+ collection_name: collection_name,
365
+ score: score,
366
+ decoded_score: decode_score(score)
367
+ }
368
+ end
369
+ end
370
+ end
371
+
372
+ memberships
373
+ end
374
+ end
375
+
376
+ end
377
+ end
378
+ end
379
+ end