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
@@ -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
|