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.
- checksums.yaml +4 -4
- data/.github/workflows/claude-code-review.yml +57 -0
- data/.github/workflows/claude.yml +71 -0
- data/.gitignore +5 -1
- data/.rubocop.yml +3 -0
- data/CLAUDE.md +32 -13
- data/Gemfile +2 -2
- data/Gemfile.lock +2 -2
- data/docs/wiki/Feature-System-Guide.md +36 -5
- data/docs/wiki/Home.md +30 -20
- data/docs/wiki/Relationships-Guide.md +684 -0
- data/examples/bit_encoding_integration.rb +237 -0
- data/examples/redis_command_validation_example.rb +231 -0
- data/examples/relationships_basic.rb +273 -0
- data/lib/familia/connection.rb +3 -3
- data/lib/familia/data_type.rb +7 -4
- data/lib/familia/features/encrypted_fields/concealed_string.rb +21 -23
- data/lib/familia/features/encrypted_fields.rb +413 -4
- data/lib/familia/features/expiration.rb +319 -33
- data/lib/familia/features/quantization.rb +385 -44
- data/lib/familia/features/relationships/cascading.rb +438 -0
- data/lib/familia/features/relationships/indexing.rb +370 -0
- data/lib/familia/features/relationships/membership.rb +503 -0
- data/lib/familia/features/relationships/permission_management.rb +264 -0
- data/lib/familia/features/relationships/querying.rb +620 -0
- data/lib/familia/features/relationships/redis_operations.rb +274 -0
- data/lib/familia/features/relationships/score_encoding.rb +442 -0
- data/lib/familia/features/relationships/tracking.rb +379 -0
- data/lib/familia/features/relationships.rb +466 -0
- data/lib/familia/features/transient_fields.rb +192 -10
- data/lib/familia/features.rb +2 -1
- data/lib/familia/horreum/subclass/definition.rb +1 -1
- data/lib/familia/validation/command_recorder.rb +336 -0
- data/lib/familia/validation/expectations.rb +519 -0
- data/lib/familia/validation/test_helpers.rb +443 -0
- data/lib/familia/validation/validator.rb +412 -0
- data/lib/familia/validation.rb +140 -0
- data/lib/familia/version.rb +1 -1
- data/try/edge_cases/hash_symbolization_try.rb +1 -0
- data/try/edge_cases/reserved_keywords_try.rb +1 -0
- data/try/edge_cases/string_coercion_try.rb +2 -0
- data/try/encryption/encryption_core_try.rb +3 -1
- data/try/features/categorical_permissions_try.rb +515 -0
- data/try/features/encryption_fields/concealed_string_core_try.rb +3 -0
- data/try/features/encryption_fields/context_isolation_try.rb +1 -0
- data/try/features/relationships_edge_cases_try.rb +145 -0
- data/try/features/relationships_performance_minimal_try.rb +132 -0
- data/try/features/relationships_performance_simple_try.rb +155 -0
- data/try/features/relationships_performance_try.rb +420 -0
- data/try/features/relationships_performance_working_try.rb +144 -0
- data/try/features/relationships_try.rb +237 -0
- data/try/features/safe_dump_try.rb +3 -0
- data/try/features/transient_fields/redacted_string_try.rb +2 -0
- data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
- data/try/helpers/test_helpers.rb +1 -1
- data/try/horreum/base_try.rb +14 -8
- data/try/horreum/enhanced_conflict_handling_try.rb +2 -0
- data/try/horreum/relations_try.rb +1 -1
- data/try/validation/atomic_operations_try.rb.disabled +320 -0
- data/try/validation/command_validation_try.rb.disabled +207 -0
- data/try/validation/performance_validation_try.rb.disabled +324 -0
- data/try/validation/real_world_scenarios_try.rb.disabled +390 -0
- metadata +32 -4
- data/docs/wiki/RelatableObjects-Guide.md +0 -563
- data/lib/familia/features/relatable_objects.rb +0 -125
- data/try/features/relatable_objects_try.rb +0 -220
data/lib/familia/features.rb
CHANGED
@@ -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
|
@@ -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
|