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,370 @@
1
+ # lib/familia/features/relationships/indexing.rb
2
+
3
+ module Familia
4
+ module Features
5
+ module Relationships
6
+ # Indexing module for indexed_by relationships using Redis hashes
7
+ # Provides O(1) lookups for finding objects by field values
8
+ module Indexing
9
+ # Class-level indexing configurations
10
+ def self.included(base)
11
+ base.extend ClassMethods
12
+ base.include InstanceMethods
13
+ super
14
+ end
15
+
16
+ module ClassMethods
17
+ # Define an indexed_by relationship for fast lookups
18
+ #
19
+ # @param field [Symbol] The field to index on
20
+ # @param context [Class, Symbol] The context class that owns the index
21
+ # @param index_name [Symbol] Name of the index hash
22
+ # @param finder [Boolean] Whether to generate finder methods
23
+ #
24
+ # @example Basic indexing
25
+ # indexed_by :display_name, context: Customer, index_name: :domain_index
26
+ #
27
+ # @example Global indexing
28
+ # indexed_by :domain_id, context: :global, index_name: :domain_lookup
29
+ def indexed_by(field, index_name, context:, finder: true)
30
+ context_class = context == :global ? :global : context
31
+ context_class_name = if context_class == :global
32
+ 'global'
33
+ else
34
+ (context_class.is_a?(Class) ? context_class.name : context_class.to_s.camelize)
35
+ end
36
+
37
+ # Store metadata for this indexing relationship
38
+ indexing_relationships << {
39
+ field: field,
40
+ context_class: context_class,
41
+ context_class_name: context_class_name,
42
+ index_name: index_name,
43
+ finder: finder
44
+ }
45
+
46
+ # Generate finder methods on the context class
47
+ if finder && context_class != :global
48
+ generate_context_finder_methods(context_class, field, index_name)
49
+ elsif finder && context_class == :global
50
+ generate_global_finder_methods(field, index_name)
51
+ end
52
+
53
+ # Generate instance methods for maintaining the index
54
+ generate_indexing_instance_methods(context_class_name, field, index_name)
55
+ end
56
+
57
+ # Get all indexing relationships for this class
58
+ def indexing_relationships
59
+ @indexing_relationships ||= []
60
+ end
61
+
62
+ private
63
+
64
+ # Generate finder methods on the context class (e.g., Customer.find_by_display_name)
65
+ def generate_context_finder_methods(context_class, field, index_name)
66
+ # Resolve context class if it's a symbol/string
67
+ actual_context_class = context_class.is_a?(Class) ? context_class : Object.const_get(context_class.to_s.camelize)
68
+
69
+ # Generate finder method (e.g., Customer.find_by_display_name)
70
+ actual_context_class.define_method("find_by_#{field}") do |field_value|
71
+ index_key = "#{self.class.name.downcase}:#{identifier}:#{index_name}"
72
+ object_id = dbclient.hget(index_key, field_value.to_s)
73
+
74
+ return nil unless object_id
75
+
76
+ # Find the indexed class and instantiate the object
77
+ indexed_class = nil
78
+ self.class.const_get(:INDEXED_CLASSES, false)&.each do |klass|
79
+ if klass.indexing_relationships.any? { |rel| rel[:index_name] == index_name }
80
+ indexed_class = klass
81
+ break
82
+ end
83
+ end
84
+
85
+ indexed_class&.new(identifier: object_id)
86
+ end
87
+
88
+ # Generate bulk finder method (e.g., Customer.find_all_by_display_name)
89
+ actual_context_class.define_method("find_all_by_#{field}") do |field_values|
90
+ return [] if field_values.empty?
91
+
92
+ index_key = "#{self.class.name.downcase}:#{identifier}:#{index_name}"
93
+ object_ids = dbclient.hmget(index_key, *field_values.map(&:to_s))
94
+
95
+ # Filter out nil values and instantiate objects
96
+ found_objects = object_ids.compact.filter_map do |object_id|
97
+ # Find the indexed class and instantiate the object
98
+ indexed_class = nil
99
+ self.class.const_get(:INDEXED_CLASSES, false)&.each do |klass|
100
+ if klass.indexing_relationships.any? { |rel| rel[:index_name] == index_name }
101
+ indexed_class = klass
102
+ break
103
+ end
104
+ end
105
+
106
+ indexed_class&.new(identifier: object_id)
107
+ end
108
+
109
+ found_objects
110
+ end
111
+
112
+ # Generate method to get the index hash directly
113
+ actual_context_class.define_method(index_name) do
114
+ index_key = "#{self.class.name.downcase}:#{identifier}:#{index_name}"
115
+ Familia::HashKey.new(nil, dbkey: index_key, logical_database: self.class.logical_database)
116
+ end
117
+
118
+ # Generate method to rebuild the index
119
+ actual_context_class.define_method("rebuild_#{index_name}") do
120
+ index_key = "#{self.class.name.downcase}:#{identifier}:#{index_name}"
121
+
122
+ # Clear existing index
123
+ dbclient.del(index_key)
124
+
125
+ # This is a simplified version - in practice, you'd need to iterate
126
+ # through all objects that should be in this index
127
+ # Implementation would depend on how you track which objects belong to this context
128
+ end
129
+ end
130
+
131
+ # Generate global finder methods (when context is :global)
132
+ def generate_global_finder_methods(field, index_name)
133
+ # Generate global finder method (e.g., Domain.find_by_display_name_globally)
134
+ define_method("find_by_#{field}_globally") do |field_value|
135
+ index_key = "global:#{index_name}"
136
+ object_id = dbclient.hget(index_key, field_value.to_s)
137
+
138
+ return nil unless object_id
139
+
140
+ new(identifier: object_id)
141
+ end
142
+
143
+ # Generate global bulk finder method
144
+ define_method("find_all_by_#{field}_globally") do |field_values|
145
+ return [] if field_values.empty?
146
+
147
+ index_key = "global:#{index_name}"
148
+ object_ids = dbclient.hmget(index_key, *field_values.map(&:to_s))
149
+
150
+ # Filter out nil values and instantiate objects
151
+ object_ids.compact.map { |object_id| new(identifier: object_id) }
152
+ end
153
+
154
+ # Generate method to get the global index hash directly
155
+ define_method("global_#{index_name}") do
156
+ index_key = "global:#{index_name}"
157
+ Familia::HashKey.new(nil, dbkey: index_key, logical_database: logical_database)
158
+ end
159
+
160
+ # Generate method to rebuild the global index
161
+ define_method("rebuild_global_#{index_name}") do
162
+ index_key = "global:#{index_name}"
163
+
164
+ # Clear existing index
165
+ dbclient.del(index_key)
166
+
167
+ # Rebuild from all existing objects
168
+ # This would need to scan through all objects of this class
169
+ # Implementation depends on how objects are stored/tracked
170
+ end
171
+ end
172
+
173
+ # Generate instance methods for maintaining indexes
174
+ def generate_indexing_instance_methods(context_class_name, field, index_name)
175
+ # Method to add this object to a specific index
176
+ # e.g., domain.add_to_customer_domain_index(customer)
177
+ if context_class_name == 'global'
178
+ # Global index methods
179
+ define_method("add_to_global_#{index_name}") do
180
+ index_key = "global:#{index_name}"
181
+ field_value = send(field)
182
+
183
+ return unless field_value
184
+
185
+ dbclient.hset(index_key, field_value.to_s, identifier)
186
+ end
187
+
188
+ define_method("remove_from_global_#{index_name}") do
189
+ index_key = "global:#{index_name}"
190
+ field_value = send(field)
191
+
192
+ return unless field_value
193
+
194
+ dbclient.hdel(index_key, field_value.to_s)
195
+ end
196
+
197
+ define_method("update_in_global_#{index_name}") do |old_field_value = nil|
198
+ index_key = "global:#{index_name}"
199
+ new_field_value = send(field)
200
+
201
+ dbclient.multi do |tx|
202
+ # Remove old value if provided
203
+ tx.hdel(index_key, old_field_value.to_s) if old_field_value
204
+
205
+ # Add new value if present
206
+ tx.hset(index_key, new_field_value.to_s, identifier) if new_field_value
207
+ end
208
+ end
209
+ else
210
+ define_method("add_to_#{context_class_name.downcase}_#{index_name}") do |context_instance|
211
+ index_key = "#{context_class_name.downcase}:#{context_instance.identifier}:#{index_name}"
212
+ field_value = send(field)
213
+
214
+ return unless field_value
215
+
216
+ dbclient.hset(index_key, field_value.to_s, identifier)
217
+ end
218
+
219
+ # Method to remove this object from a specific index
220
+ define_method("remove_from_#{context_class_name.downcase}_#{index_name}") do |context_instance|
221
+ index_key = "#{context_class_name.downcase}:#{context_instance.identifier}:#{index_name}"
222
+ field_value = send(field)
223
+
224
+ return unless field_value
225
+
226
+ dbclient.hdel(index_key, field_value.to_s)
227
+ end
228
+
229
+ # Method to update this object in a specific index (handles field value changes)
230
+ define_method("update_in_#{context_class_name.downcase}_#{index_name}") do |context_instance, old_field_value = nil|
231
+ index_key = "#{context_class_name.downcase}:#{context_instance.identifier}:#{index_name}"
232
+ new_field_value = send(field)
233
+
234
+ dbclient.multi do |tx|
235
+ # Remove old value if provided
236
+ tx.hdel(index_key, old_field_value.to_s) if old_field_value
237
+
238
+ # Add new value if present
239
+ tx.hset(index_key, new_field_value.to_s, identifier) if new_field_value
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
245
+
246
+ # Instance methods for indexed objects
247
+ module InstanceMethods
248
+ # Update all indexes that this object participates in
249
+ def update_all_indexes(old_values = {})
250
+ return unless self.class.respond_to?(:indexing_relationships)
251
+
252
+ self.class.indexing_relationships.each do |config|
253
+ field = config[:field]
254
+ context_class_name = config[:context_class_name]
255
+ index_name = config[:index_name]
256
+
257
+ old_field_value = old_values[field]
258
+
259
+ if context_class_name == 'global'
260
+ send("update_in_global_#{index_name}", old_field_value)
261
+ else
262
+ # For non-global indexes, we'd need to know which context instances
263
+ # this object should be indexed in. This is a simplified approach.
264
+ # In practice, you'd need to track relationships or pass context.
265
+ end
266
+ end
267
+ end
268
+
269
+ # Remove from all indexes (used during destroy)
270
+ def remove_from_all_indexes
271
+ return unless self.class.respond_to?(:indexing_relationships)
272
+
273
+ self.class.indexing_relationships.each do |config|
274
+ field = config[:field]
275
+ context_class_name = config[:context_class_name]
276
+ index_name = config[:index_name]
277
+
278
+ if context_class_name == 'global'
279
+ send("remove_from_global_#{index_name}")
280
+ else
281
+ # For non-global indexes, we'd need to find all context instances
282
+ # that have this object indexed. This is expensive but necessary for cleanup.
283
+ pattern = "#{context_class_name.downcase}:*:#{index_name}"
284
+ field_value = send(field)
285
+
286
+ next unless field_value
287
+
288
+ dbclient.scan_each(match: pattern) do |key|
289
+ dbclient.hdel(key, field_value.to_s)
290
+ end
291
+ end
292
+ end
293
+ end
294
+
295
+ # Get all indexes this object appears in
296
+ #
297
+ # @return [Array<Hash>] Array of index information
298
+ def indexing_memberships
299
+ return [] unless self.class.respond_to?(:indexing_relationships)
300
+
301
+ memberships = []
302
+
303
+ self.class.indexing_relationships.each do |config|
304
+ field = config[:field]
305
+ context_class_name = config[:context_class_name]
306
+ index_name = config[:index_name]
307
+ field_value = send(field)
308
+
309
+ next unless field_value
310
+
311
+ if context_class_name == 'global'
312
+ index_key = "global:#{index_name}"
313
+ if dbclient.hexists(index_key, field_value.to_s)
314
+ memberships << {
315
+ context_class: 'global',
316
+ index_name: index_name,
317
+ field: field,
318
+ field_value: field_value,
319
+ index_key: index_key
320
+ }
321
+ end
322
+ else
323
+ # Scan for all context instances that have this object indexed
324
+ pattern = "#{context_class_name.downcase}:*:#{index_name}"
325
+
326
+ dbclient.scan_each(match: pattern) do |key|
327
+ if dbclient.hexists(key, field_value.to_s)
328
+ context_id = key.split(':')[1]
329
+ memberships << {
330
+ context_class: context_class_name,
331
+ context_id: context_id,
332
+ index_name: index_name,
333
+ field: field,
334
+ field_value: field_value,
335
+ index_key: key
336
+ }
337
+ end
338
+ end
339
+ end
340
+ end
341
+
342
+ memberships
343
+ end
344
+
345
+ # Check if this object is indexed in a specific context
346
+ def indexed_in?(context_instance, index_name)
347
+ return false unless self.class.respond_to?(:indexing_relationships)
348
+
349
+ config = self.class.indexing_relationships.find { |rel| rel[:index_name] == index_name }
350
+ return false unless config
351
+
352
+ field = config[:field]
353
+ field_value = send(field)
354
+ return false unless field_value
355
+
356
+ if config[:context_class_name] == 'global'
357
+ index_key = "global:#{index_name}"
358
+ else
359
+ context_class_name = config[:context_class_name]
360
+ index_key = "#{context_class_name.downcase}:#{context_instance.identifier}:#{index_name}"
361
+ end
362
+
363
+ dbclient.hexists(index_key, field_value.to_s)
364
+ end
365
+ end
366
+
367
+ end
368
+ end
369
+ end
370
+ end