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
@@ -1,17 +1,15 @@
1
1
  # lib/familia/features.rb
2
2
 
3
3
  module Familia
4
-
5
4
  FeatureDefinition = Data.define(:name, :depends_on)
6
5
 
7
6
  # Familia::Features
8
7
  #
9
8
  module Features
10
-
11
9
  @features_enabled = nil
12
10
  attr_reader :features_enabled
13
11
 
14
- def feature(feature_name = nil)
12
+ def feature(feature_name = nil, **options)
15
13
  @features_enabled ||= []
16
14
 
17
15
  return features_enabled if feature_name.nil?
@@ -28,22 +26,28 @@ module Familia
28
26
  return
29
27
  end
30
28
 
31
- if Familia.debug?
32
- Familia.trace :FEATURE, nil, "#{self} includes #{feature_name.inspect}", caller(1..1)
29
+ Familia.trace :FEATURE, nil, "#{self} includes #{feature_name.inspect}", caller(1..1) if Familia.debug?
30
+
31
+ # Check dependencies and raise error if missing
32
+ feature_def = Familia::Base.feature_definitions[feature_name]
33
+ if feature_def&.depends_on&.any?
34
+ missing = feature_def.depends_on - features_enabled
35
+ if missing.any?
36
+ raise Familia::Problem,
37
+ "Feature #{feature_name} requires missing dependencies: #{missing.join(', ')}"
38
+ end
33
39
  end
34
40
 
35
41
  # Add it to the list available features_enabled for Familia::Base classes.
36
42
  features_enabled << feature_name
37
43
 
38
- klass = Familia::Base.features_available[feature_name]
39
-
40
- # Validate dependencies
41
- feature_def = Familia::Base.feature_definitions[feature_name]
42
- if feature_def&.depends_on&.any?
43
- missing = feature_def.depends_on - features_enabled
44
- raise Familia::Problem, "#{feature_name} requires: #{missing.join(', ')}" if missing.any?
44
+ # Store feature options if any were provided using the new pattern
45
+ if options.any?
46
+ add_feature_options(feature_name, **options)
45
47
  end
46
48
 
49
+ klass = Familia::Base.features_available[feature_name]
50
+
47
51
  # Extend the Familia::Base subclass (e.g. Customer) with the feature module
48
52
  include klass
49
53
 
@@ -58,15 +62,15 @@ module Familia
58
62
  # We'd need to extend the DataType instances for each Horreum subclass. That
59
63
  # avoids it getting included multiple times per DataType
60
64
  end
61
-
62
65
  end
63
-
64
66
  end
65
67
 
66
68
  # Load all feature files from the features directory
67
69
  features_dir = File.join(__dir__, 'features')
70
+ Familia.ld "[DEBUG] Loading features from #{features_dir}"
68
71
  if Dir.exist?(features_dir)
69
72
  Dir.glob(File.join(features_dir, '*.rb')).each do |feature_file|
73
+ Familia.ld "[DEBUG] Loading feature #{feature_file}"
70
74
  require_relative feature_file
71
75
  end
72
76
  end
@@ -135,7 +135,7 @@ module Familia
135
135
  multi.hmset(dbkey, to_h_for_storage)
136
136
  end
137
137
 
138
- result.is_a?(Array) # transaction succeeded
138
+ result.is_a?(Array) # transaction succeeded
139
139
  end
140
140
  end
141
141
 
@@ -463,9 +463,7 @@ module Familia
463
463
  #
464
464
  def serialize_value(val)
465
465
  # Security: Handle ConcealedString safely - extract encrypted data for storage
466
- if val.respond_to?(:encrypted_value)
467
- return val.encrypted_value
468
- end
466
+ return val.encrypted_value if val.respond_to?(:encrypted_value)
469
467
 
470
468
  prepared = Familia.distinguisher(val, strict_values: false)
471
469
 
@@ -530,6 +528,5 @@ module Familia
530
528
  end
531
529
  end
532
530
  end
533
-
534
531
  end
535
532
  end
@@ -169,7 +169,7 @@ module Familia
169
169
  @related_fields
170
170
  end
171
171
 
172
- def has_relations?
172
+ def relations?
173
173
  @has_relations ||= false
174
174
  end
175
175
 
@@ -235,6 +235,40 @@ module Familia
235
235
  field_types[field_type.name] = field_type
236
236
  end
237
237
 
238
+ # Get feature options for a specific feature or all features
239
+ #
240
+ # @param feature_name [Symbol, nil] The feature name to get options for
241
+ # @return [Hash] The options hash for the feature, or empty hash if none
242
+ #
243
+ def feature_options(feature_name = nil)
244
+ @feature_options ||= {}
245
+ return @feature_options if feature_name.nil?
246
+
247
+ @feature_options[feature_name.to_sym] || {}
248
+ end
249
+
250
+ # Add feature options for a specific feature
251
+ #
252
+ # This method provides a clean way for features to set their default options
253
+ # without worrying about initialization state. Similar to register_field_type
254
+ # for field types.
255
+ #
256
+ # @param feature_name [Symbol] The feature name
257
+ # @param options [Hash] The options to add/merge
258
+ # @return [Hash] The updated options for the feature
259
+ #
260
+ def add_feature_options(feature_name, **options)
261
+ @feature_options ||= {}
262
+ @feature_options[feature_name.to_sym] ||= {}
263
+
264
+ # Only set defaults for options that don't already exist
265
+ options.each do |key, value|
266
+ @feature_options[feature_name.to_sym][key] ||= value
267
+ end
268
+
269
+ @feature_options[feature_name.to_sym]
270
+ end
271
+
238
272
  # Create and register a transient field type
239
273
  #
240
274
  # @param name [Symbol] The field name
@@ -0,0 +1,336 @@
1
+ # lib/familia/validation/command_recorder.rb
2
+
3
+ require 'concurrent-ruby'
4
+
5
+ module Familia
6
+ module Validation
7
+ # Enhanced command recorder that captures Redis commands with full context
8
+ # for validation purposes. Extends the existing DatabaseLogger functionality
9
+ # to provide detailed command tracking including transaction boundaries.
10
+ #
11
+ # @example Basic usage
12
+ # CommandRecorder.start_recording
13
+ # # ... perform Redis operations
14
+ # commands = CommandRecorder.stop_recording
15
+ # puts commands.map(&:to_s)
16
+ #
17
+ # @example Transaction recording
18
+ # CommandRecorder.start_recording
19
+ # Familia.transaction do |conn|
20
+ # conn.hset("key", "field", "value")
21
+ # conn.incr("counter")
22
+ # end
23
+ # commands = CommandRecorder.stop_recording
24
+ # commands.transaction_blocks.length #=> 1
25
+ # commands.transaction_blocks.first.commands.length #=> 2
26
+ #
27
+ module CommandRecorder
28
+ extend self
29
+
30
+ # Thread-safe recording state
31
+ @recording_state = Concurrent::ThreadLocalVar.new { false }
32
+ @recorded_commands = Concurrent::ThreadLocalVar.new { CommandSequence.new }
33
+ @transaction_stack = Concurrent::ThreadLocalVar.new { [] }
34
+ @pipeline_stack = Concurrent::ThreadLocalVar.new { [] }
35
+
36
+ # Represents a single Redis command with full context
37
+ class RecordedCommand
38
+ attr_reader :command, :args, :result, :timestamp, :duration_us, :context, :command_type
39
+
40
+ def initialize(command:, args:, result:, timestamp:, duration_us:, context: {})
41
+ @command = command.to_s.upcase
42
+ @args = args.dup.freeze
43
+ @result = result
44
+ @timestamp = timestamp
45
+ @duration_us = duration_us
46
+ @context = context.dup.freeze
47
+ @command_type = determine_command_type
48
+ end
49
+
50
+ def to_s
51
+ args_str = @args.map(&:inspect).join(', ')
52
+ "#{@command}(#{args_str})"
53
+ end
54
+
55
+ def to_h
56
+ {
57
+ command: @command,
58
+ args: @args,
59
+ result: @result,
60
+ timestamp: @timestamp,
61
+ duration_us: @duration_us,
62
+ context: @context,
63
+ command_type: @command_type
64
+ }
65
+ end
66
+
67
+ def transaction_command?
68
+ %w[MULTI EXEC DISCARD].include?(@command)
69
+ end
70
+
71
+ def pipeline_command?
72
+ @context[:pipeline] == true
73
+ end
74
+
75
+ def atomic_command?
76
+ @context[:transaction] == true
77
+ end
78
+
79
+ private
80
+
81
+ def determine_command_type
82
+ case @command
83
+ when 'MULTI', 'EXEC', 'DISCARD'
84
+ :transaction_control
85
+ when 'PIPELINE'
86
+ :pipeline_control
87
+ when /^H(GET|SET|DEL|EXISTS|KEYS|LEN|MGET|MSET)/
88
+ :hash
89
+ when /^(L|R)(PUSH|POP|LEN|RANGE|INDEX|SET|REM)/
90
+ :list
91
+ when /^S(ADD|REM|MEMBERS|CARD|ISMEMBER|DIFF|INTER|UNION)/
92
+ :set
93
+ when /^Z(ADD|REM|RANGE|SCORE|CARD|COUNT|RANK|INCR)/
94
+ :sorted_set
95
+ when /^(GET|SET|DEL|EXISTS|EXPIRE|TTL|TYPE|INCR|DECR)/
96
+ :string
97
+ else
98
+ :other
99
+ end
100
+ end
101
+ end
102
+
103
+ # Represents a sequence of Redis commands with transaction boundaries
104
+ class CommandSequence
105
+ attr_reader :commands, :transaction_blocks, :pipeline_blocks
106
+
107
+ def initialize
108
+ @commands = []
109
+ @transaction_blocks = []
110
+ @pipeline_blocks = []
111
+ end
112
+
113
+ def add_command(recorded_command)
114
+ @commands << recorded_command
115
+ end
116
+
117
+ def start_transaction(context = {})
118
+ @transaction_blocks << TransactionBlock.new(context)
119
+ end
120
+
121
+ def end_transaction
122
+ return unless current_transaction
123
+
124
+ current_transaction.finalize(@commands)
125
+ end
126
+
127
+ def start_pipeline(context = {})
128
+ @pipeline_blocks << PipelineBlock.new(context)
129
+ end
130
+
131
+ def end_pipeline
132
+ return unless current_pipeline
133
+
134
+ current_pipeline.finalize(@commands)
135
+ end
136
+
137
+ def current_transaction
138
+ @transaction_blocks.last
139
+ end
140
+
141
+ def current_pipeline
142
+ @pipeline_blocks.last
143
+ end
144
+
145
+ def command_count
146
+ @commands.length
147
+ end
148
+
149
+ def transaction_count
150
+ @transaction_blocks.length
151
+ end
152
+
153
+ def pipeline_count
154
+ @pipeline_blocks.length
155
+ end
156
+
157
+ def to_a
158
+ @commands
159
+ end
160
+
161
+ def clear
162
+ @commands.clear
163
+ @transaction_blocks.clear
164
+ @pipeline_blocks.clear
165
+ end
166
+ end
167
+
168
+ # Represents a transaction block (MULTI/EXEC)
169
+ class TransactionBlock
170
+ attr_reader :start_index, :end_index, :commands, :context, :started_at
171
+
172
+ def initialize(context = {})
173
+ @context = context
174
+ @started_at = Time.now
175
+ @start_index = nil
176
+ @end_index = nil
177
+ @commands = []
178
+ end
179
+
180
+ def finalize(all_commands)
181
+ # Find MULTI and EXEC commands
182
+ multi_index = all_commands.rindex { |cmd| cmd.command == 'MULTI' }
183
+ exec_index = all_commands.rindex { |cmd| cmd.command == 'EXEC' }
184
+
185
+ return unless multi_index && exec_index && exec_index > multi_index
186
+
187
+ @start_index = multi_index
188
+ @end_index = exec_index
189
+ @commands = all_commands[(multi_index + 1)...exec_index]
190
+ end
191
+
192
+ def valid?
193
+ @start_index && @end_index && @commands.any?
194
+ end
195
+
196
+ def command_count
197
+ @commands.length
198
+ end
199
+ end
200
+
201
+ # Represents a pipeline block
202
+ class PipelineBlock
203
+ attr_reader :commands, :context, :started_at
204
+
205
+ def initialize(context = {})
206
+ @context = context
207
+ @started_at = Time.now
208
+ @commands = []
209
+ end
210
+
211
+ def finalize(all_commands)
212
+ # Pipeline commands are those executed within pipeline context
213
+ @commands = all_commands.select(&:pipeline_command?)
214
+ end
215
+
216
+ def command_count
217
+ @commands.length
218
+ end
219
+ end
220
+
221
+ # Start recording Redis commands for the current thread
222
+ def start_recording
223
+ @recording_state.value = true
224
+ @recorded_commands.value = CommandSequence.new
225
+ @transaction_stack.value = []
226
+ @pipeline_stack.value = []
227
+ end
228
+
229
+ # Stop recording and return the recorded command sequence
230
+ def stop_recording
231
+ @recording_state.value = false
232
+ sequence = @recorded_commands.value
233
+ @recorded_commands.value = CommandSequence.new
234
+ sequence
235
+ end
236
+
237
+ # Check if currently recording
238
+ def recording?
239
+ @recording_state.value == true
240
+ end
241
+
242
+ # Record a Redis command with full context
243
+ def record_command(command:, args:, result:, timestamp:, duration_us:, context: {})
244
+ return unless recording?
245
+
246
+ # Enhance context with transaction/pipeline state
247
+ enhanced_context = context.merge(
248
+ transaction: in_transaction?,
249
+ pipeline: in_pipeline?,
250
+ transaction_depth: transaction_depth,
251
+ pipeline_depth: pipeline_depth
252
+ )
253
+
254
+ recorded_cmd = RecordedCommand.new(
255
+ command: command,
256
+ args: args,
257
+ result: result,
258
+ timestamp: timestamp,
259
+ duration_us: duration_us,
260
+ context: enhanced_context
261
+ )
262
+
263
+ sequence = @recorded_commands.value
264
+ sequence.add_command(recorded_cmd)
265
+
266
+ # Handle transaction boundaries
267
+ case recorded_cmd.command
268
+ when 'MULTI'
269
+ sequence.start_transaction(enhanced_context)
270
+ @transaction_stack.value.push(Time.now)
271
+ when 'EXEC', 'DISCARD'
272
+ sequence.end_transaction if sequence.current_transaction
273
+ @transaction_stack.value.pop
274
+ end
275
+ end
276
+
277
+ # Check if we're currently in a transaction
278
+ def in_transaction?
279
+ @transaction_stack.value.any?
280
+ end
281
+
282
+ # Check if we're currently in a pipeline
283
+ def in_pipeline?
284
+ @pipeline_stack.value.any?
285
+ end
286
+
287
+ # Get current transaction nesting depth
288
+ def transaction_depth
289
+ @transaction_stack.value.length
290
+ end
291
+
292
+ # Get current pipeline nesting depth
293
+ def pipeline_depth
294
+ @pipeline_stack.value.length
295
+ end
296
+
297
+ # Get the current command sequence (for inspection during recording)
298
+ def current_sequence
299
+ @recorded_commands.value
300
+ end
301
+
302
+ # Clear all recorded data
303
+ def clear
304
+ @recorded_commands.value.clear
305
+ end
306
+
307
+ # Enhanced middleware that integrates with DatabaseLogger
308
+ module Middleware
309
+ def self.call(command, config)
310
+ return yield unless CommandRecorder.recording?
311
+
312
+ timestamp = Time.now
313
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
314
+
315
+ result = yield
316
+
317
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond) - start_time
318
+
319
+ CommandRecorder.record_command(
320
+ command: command[0],
321
+ args: command[1..-1],
322
+ result: result,
323
+ timestamp: timestamp,
324
+ duration_us: duration,
325
+ context: {
326
+ config: config,
327
+ thread_id: Thread.current.object_id
328
+ }
329
+ )
330
+
331
+ result
332
+ end
333
+ end
334
+ end
335
+ end
336
+ end