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
@@ -60,13 +60,14 @@ module Familia
60
60
  end
61
61
 
62
62
  end
63
-
64
63
  end
65
64
 
66
65
  # Load all feature files from the features directory
67
66
  features_dir = File.join(__dir__, 'features')
67
+ Familia.ld "[DEBUG] Loading features from #{features_dir}"
68
68
  if Dir.exist?(features_dir)
69
69
  Dir.glob(File.join(features_dir, '*.rb')).each do |feature_file|
70
+ Familia.ld "[DEBUG] Loading feature #{feature_file}"
70
71
  require_relative feature_file
71
72
  end
72
73
  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
 
@@ -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