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,519 @@
1
+ # lib/familia/validation/expectations.rb
2
+
3
+ module Familia
4
+ module Validation
5
+ # Fluent DSL for defining expected Redis command sequences with support
6
+ # for transaction and pipeline validation. Provides a readable way to
7
+ # specify expected database operations and verify they execute correctly.
8
+ #
9
+ # @example Basic command expectations
10
+ # expectation = CommandExpectations.new
11
+ # expectation.hset("user:123", "name", "John")
12
+ # .incr("user:counter")
13
+ # .expire("user:123", 3600)
14
+ #
15
+ # # Validate against actual commands
16
+ # result = expectation.validate(actual_commands)
17
+ # result.valid? #=> true/false
18
+ #
19
+ # @example Transaction expectations
20
+ # expectation = CommandExpectations.new
21
+ # expectation.transaction do |tx|
22
+ # tx.hset("user:123", "name", "John")
23
+ # .incr("counter")
24
+ # end
25
+ #
26
+ # @example Pattern matching
27
+ # expectation = CommandExpectations.new
28
+ # expectation.hset(any_string, "name", any_string)
29
+ # .match_pattern(/^INCR/)
30
+ #
31
+ class CommandExpectations
32
+ attr_reader :expected_commands, :transaction_blocks, :pipeline_blocks
33
+
34
+ def initialize
35
+ @expected_commands = []
36
+ @transaction_blocks = []
37
+ @pipeline_blocks = []
38
+ @current_transaction = nil
39
+ @current_pipeline = nil
40
+ @options = {
41
+ strict_order: true,
42
+ exact_match: true,
43
+ allow_extra_commands: false
44
+ }
45
+ end
46
+
47
+ # Configuration methods
48
+ def strict_order(enabled = true)
49
+ @options[:strict_order] = enabled
50
+ self
51
+ end
52
+
53
+ def exact_match(enabled = true)
54
+ @options[:exact_match] = enabled
55
+ self
56
+ end
57
+
58
+ def allow_extra_commands(enabled = true)
59
+ @options[:allow_extra_commands] = enabled
60
+ self
61
+ end
62
+
63
+ # Transaction block expectation
64
+ def transaction(&block)
65
+ @current_transaction = TransactionExpectation.new(@options)
66
+ @transaction_blocks << @current_transaction
67
+
68
+ if block_given?
69
+ block.call(@current_transaction)
70
+ @current_transaction = nil
71
+ end
72
+
73
+ self
74
+ end
75
+
76
+ # Pipeline block expectation
77
+ def pipeline(&block)
78
+ @current_pipeline = PipelineExpectation.new(@options)
79
+ @pipeline_blocks << @current_pipeline
80
+
81
+ if block_given?
82
+ block.call(@current_pipeline)
83
+ @current_pipeline = nil
84
+ end
85
+
86
+ self
87
+ end
88
+
89
+ # Redis command expectations - these can be chained
90
+ %w[
91
+ get set del exists expire ttl type incr decr
92
+ hget hset hdel hexists hkeys hvals hlen hmget hmset
93
+ lpush rpush lpop rpop llen lrange lindex lset lrem
94
+ sadd srem sismember smembers scard sdiff sinter sunion
95
+ zadd zrem zscore zrange zrank zcard zcount
96
+ multi exec discard
97
+ ].each do |cmd|
98
+ define_method(cmd.to_sym) do |*args|
99
+ add_command_expectation(cmd.upcase, args)
100
+ end
101
+ end
102
+
103
+ # Generic command expectation
104
+ def command(cmd, *args)
105
+ add_command_expectation(cmd.to_s.upcase, args)
106
+ end
107
+
108
+ # Pattern matching for flexible expectations
109
+ def match_pattern(pattern, description = nil)
110
+ expectation = PatternExpectation.new(pattern, description)
111
+ add_expectation(expectation)
112
+ end
113
+
114
+ # Validate against actual recorded commands
115
+ def validate(command_sequence)
116
+ ValidationResult.new(self, command_sequence).validate
117
+ end
118
+
119
+ # Helper methods for common patterns
120
+ def any_string
121
+ ArgumentMatcher.new(:any_string)
122
+ end
123
+
124
+ def any_number
125
+ ArgumentMatcher.new(:any_number)
126
+ end
127
+
128
+ def any_value
129
+ ArgumentMatcher.new(:any_value)
130
+ end
131
+
132
+ def match_regex(pattern)
133
+ ArgumentMatcher.new(:regex, pattern)
134
+ end
135
+
136
+ private
137
+
138
+ def add_command_expectation(cmd, args)
139
+ expectation = CommandExpectation.new(cmd, args)
140
+ add_expectation(expectation)
141
+ end
142
+
143
+ def add_expectation(expectation)
144
+ target = @current_transaction || @current_pipeline || self
145
+
146
+ if target == self
147
+ @expected_commands << expectation
148
+ else
149
+ target.add_expectation(expectation)
150
+ end
151
+
152
+ self
153
+ end
154
+ end
155
+
156
+ # Represents an expected Redis command
157
+ class CommandExpectation
158
+ attr_reader :command, :args, :options
159
+
160
+ def initialize(command, args, options = {})
161
+ @command = command.to_s.upcase
162
+ @args = args
163
+ @options = options
164
+ end
165
+
166
+ def matches?(recorded_command)
167
+ return false unless command_matches?(recorded_command)
168
+ return false unless args_match?(recorded_command)
169
+
170
+ true
171
+ end
172
+
173
+ def to_s
174
+ args_str = @args.map { |arg| format_arg(arg) }.join(', ')
175
+ "#{@command}(#{args_str})"
176
+ end
177
+
178
+ private
179
+
180
+ def command_matches?(recorded_command)
181
+ @command == recorded_command.command
182
+ end
183
+
184
+ def args_match?(recorded_command)
185
+ return true if @args.empty? && recorded_command.args.empty?
186
+ return false if @args.length != recorded_command.args.length
187
+
188
+ @args.zip(recorded_command.args).all? do |expected, actual|
189
+ argument_matches?(expected, actual)
190
+ end
191
+ end
192
+
193
+ def argument_matches?(expected, actual)
194
+ case expected
195
+ when ArgumentMatcher
196
+ expected.matches?(actual)
197
+ else
198
+ expected.to_s == actual.to_s
199
+ end
200
+ end
201
+
202
+ def format_arg(arg)
203
+ case arg
204
+ when ArgumentMatcher
205
+ arg.to_s
206
+ else
207
+ arg.inspect
208
+ end
209
+ end
210
+ end
211
+
212
+ # Represents a pattern-based expectation
213
+ class PatternExpectation
214
+ attr_reader :pattern, :description
215
+
216
+ def initialize(pattern, description = nil)
217
+ @pattern = pattern
218
+ @description = description || pattern.to_s
219
+ end
220
+
221
+ def matches?(recorded_command)
222
+ case @pattern
223
+ when Regexp
224
+ @pattern.match?(recorded_command.to_s)
225
+ when String
226
+ recorded_command.to_s.include?(@pattern)
227
+ when Proc
228
+ @pattern.call(recorded_command)
229
+ else
230
+ false
231
+ end
232
+ end
233
+
234
+ def to_s
235
+ "pattern(#{@description})"
236
+ end
237
+ end
238
+
239
+ # Transaction expectation block
240
+ class TransactionExpectation
241
+ attr_reader :expected_commands
242
+
243
+ def initialize(options = {})
244
+ @expected_commands = []
245
+ @options = options
246
+ end
247
+
248
+ def add_expectation(expectation)
249
+ @expected_commands << expectation
250
+ self
251
+ end
252
+
253
+ # Support all the same command methods
254
+ CommandExpectations.instance_methods(false).each do |method|
255
+ next if [:validate, :transaction, :pipeline].include?(method)
256
+ next unless method.to_s.match?(/^[a-z]/)
257
+
258
+ define_method(method) do |*args|
259
+ # Ensure command name is normalized to uppercase for matching
260
+ add_expectation(CommandExpectation.new(method.to_s.upcase, args))
261
+ end
262
+ end
263
+
264
+ def validate_transaction(transaction_block)
265
+ return false unless transaction_block.valid?
266
+
267
+ expected_count = @expected_commands.length
268
+ actual_count = transaction_block.command_count
269
+
270
+ return false if @options[:exact_match] && expected_count != actual_count
271
+ return false if expected_count > actual_count
272
+
273
+ if @options[:strict_order]
274
+ validate_strict_order(transaction_block.commands)
275
+ else
276
+ validate_flexible_order(transaction_block.commands)
277
+ end
278
+ end
279
+
280
+ private
281
+
282
+ def validate_strict_order(commands)
283
+ commands.zip(@expected_commands).all? do |actual, expected|
284
+ expected&.matches?(actual)
285
+ end
286
+ end
287
+
288
+ def validate_flexible_order(commands)
289
+ expected_copy = @expected_commands.dup
290
+
291
+ commands.all? do |actual|
292
+ match_index = expected_copy.find_index { |expected| expected.matches?(actual) }
293
+ next false unless match_index
294
+
295
+ expected_copy.delete_at(match_index)
296
+ true
297
+ end
298
+ end
299
+ end
300
+
301
+ # Pipeline expectation block (similar to transaction but for pipelines)
302
+ class PipelineExpectation < TransactionExpectation
303
+ def validate_pipeline(pipeline_block)
304
+ expected_count = @expected_commands.length
305
+ actual_count = pipeline_block.command_count
306
+
307
+ return false if @options[:exact_match] && expected_count != actual_count
308
+ return false if expected_count > actual_count
309
+
310
+ if @options[:strict_order]
311
+ validate_strict_order(pipeline_block.commands)
312
+ else
313
+ validate_flexible_order(pipeline_block.commands)
314
+ end
315
+ end
316
+ end
317
+
318
+ # Argument matcher for flexible command argument validation
319
+ class ArgumentMatcher
320
+ attr_reader :type, :options
321
+
322
+ def initialize(type, *options)
323
+ @type = type
324
+ @options = options
325
+ end
326
+
327
+ def matches?(value)
328
+ case @type
329
+ when :any_string
330
+ value.is_a?(String)
331
+ when :any_number
332
+ value.to_s.match?(/^\d+$/)
333
+ when :any_value
334
+ true
335
+ when :regex
336
+ @options.first.match?(value.to_s)
337
+ else
338
+ false
339
+ end
340
+ end
341
+
342
+ def to_s
343
+ case @type
344
+ when :any_string
345
+ '<any_string>'
346
+ when :any_number
347
+ '<any_number>'
348
+ when :any_value
349
+ '<any_value>'
350
+ when :regex
351
+ "<match:#{@options.first}>"
352
+ else
353
+ "<#{@type}>"
354
+ end
355
+ end
356
+ end
357
+
358
+ # Result of validation with detailed information
359
+ class ValidationResult
360
+ attr_reader :expectations, :command_sequence, :errors, :warnings
361
+
362
+ def initialize(expectations, command_sequence)
363
+ @expectations = expectations
364
+ @command_sequence = command_sequence
365
+ @errors = []
366
+ @warnings = []
367
+ @valid = nil
368
+ end
369
+
370
+ def validate
371
+ @valid = perform_validation
372
+ self
373
+ end
374
+
375
+ def valid?
376
+ @valid == true
377
+ end
378
+
379
+ def error_messages
380
+ @errors
381
+ end
382
+
383
+ def warning_messages
384
+ @warnings
385
+ end
386
+
387
+ def summary
388
+ {
389
+ valid: valid?,
390
+ expected_commands: @expectations.expected_commands.length,
391
+ actual_commands: @command_sequence.command_count,
392
+ expected_transactions: @expectations.transaction_blocks.length,
393
+ actual_transactions: @command_sequence.transaction_count,
394
+ errors: @errors.length,
395
+ warnings: @warnings.length
396
+ }
397
+ end
398
+
399
+ def detailed_report
400
+ report = ["Redis Command Validation Report", "=" * 40]
401
+ report << "Status: #{valid? ? 'PASS' : 'FAIL'}"
402
+ report << ""
403
+
404
+ if valid?
405
+ report << "All expectations matched successfully!"
406
+ else
407
+ report << "Validation Errors:"
408
+ @errors.each_with_index do |error, i|
409
+ report << " #{i + 1}. #{error}"
410
+ end
411
+ end
412
+
413
+ if @warnings.any?
414
+ report << ""
415
+ report << "Warnings:"
416
+ @warnings.each_with_index do |warning, i|
417
+ report << " #{i + 1}. #{warning}"
418
+ end
419
+ end
420
+
421
+ report << ""
422
+ report << "Summary:"
423
+ summary.each do |key, value|
424
+ report << " #{key}: #{value}"
425
+ end
426
+
427
+ report.join("\n")
428
+ end
429
+
430
+ private
431
+
432
+ def perform_validation
433
+ validate_command_count
434
+ validate_transaction_blocks
435
+ validate_command_sequence
436
+
437
+ @errors.empty?
438
+ end
439
+
440
+ def validate_command_count
441
+ expected = @expectations.expected_commands.length
442
+ actual = @command_sequence.command_count
443
+
444
+ if expected > actual
445
+ @errors << "Expected #{expected} commands, but only #{actual} were executed"
446
+ elsif expected < actual && !@expectations.instance_variable_get(:@options)[:allow_extra_commands]
447
+ @warnings << "Expected #{expected} commands, but #{actual} were executed (extra commands)"
448
+ end
449
+ end
450
+
451
+ def validate_transaction_blocks
452
+ expected_tx = @expectations.transaction_blocks
453
+ actual_tx = @command_sequence.transaction_blocks
454
+
455
+ if expected_tx.length != actual_tx.length
456
+ @errors << "Expected #{expected_tx.length} transactions, but #{actual_tx.length} were executed"
457
+ return
458
+ end
459
+
460
+ expected_tx.zip(actual_tx).each_with_index do |(expected, actual), i|
461
+ unless expected.validate_transaction(actual)
462
+ @errors << "Transaction #{i + 1} did not match expectations"
463
+ end
464
+ end
465
+ end
466
+
467
+ def validate_command_sequence
468
+ return if @expectations.expected_commands.empty?
469
+
470
+ expected_commands = @expectations.expected_commands
471
+ actual_commands = @command_sequence.commands
472
+
473
+ if @expectations.instance_variable_get(:@options)[:strict_order]
474
+ validate_strict_command_order(expected_commands, actual_commands)
475
+ else
476
+ validate_flexible_command_order(expected_commands, actual_commands)
477
+ end
478
+ end
479
+
480
+ def validate_strict_command_order(expected, actual)
481
+ expected.each_with_index do |expected_cmd, i|
482
+ actual_cmd = actual[i]
483
+
484
+ unless actual_cmd
485
+ @errors << "Expected command #{expected_cmd} at position #{i + 1}, but no command was executed"
486
+ next
487
+ end
488
+
489
+ unless expected_cmd.matches?(actual_cmd)
490
+ @errors << "Command mismatch at position #{i + 1}: expected #{expected_cmd}, got #{actual_cmd}"
491
+ end
492
+ end
493
+ end
494
+
495
+ def validate_flexible_command_order(expected, actual)
496
+ expected_copy = expected.dup
497
+ unmatched_actual = []
498
+
499
+ actual.each do |actual_cmd|
500
+ match_index = expected_copy.find_index { |expected_cmd| expected_cmd.matches?(actual_cmd) }
501
+
502
+ if match_index
503
+ expected_copy.delete_at(match_index)
504
+ else
505
+ unmatched_actual << actual_cmd
506
+ end
507
+ end
508
+
509
+ expected_copy.each do |unmatched_expected|
510
+ @errors << "Expected command #{unmatched_expected} was not executed"
511
+ end
512
+
513
+ unmatched_actual.each do |unexpected_actual|
514
+ @warnings << "Unexpected command executed: #{unexpected_actual}"
515
+ end
516
+ end
517
+ end
518
+ end
519
+ end