odin-foundation 1.0.0
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 +7 -0
- data/lib/odin/diff/differ.rb +115 -0
- data/lib/odin/diff/patcher.rb +64 -0
- data/lib/odin/export.rb +330 -0
- data/lib/odin/parsing/parser.rb +1193 -0
- data/lib/odin/parsing/token.rb +26 -0
- data/lib/odin/parsing/token_type.rb +40 -0
- data/lib/odin/parsing/tokenizer.rb +825 -0
- data/lib/odin/parsing/value_parser.rb +322 -0
- data/lib/odin/resolver/import_resolver.rb +137 -0
- data/lib/odin/serialization/canonicalize.rb +112 -0
- data/lib/odin/serialization/stringify.rb +582 -0
- data/lib/odin/transform/format_exporters.rb +819 -0
- data/lib/odin/transform/source_parsers.rb +385 -0
- data/lib/odin/transform/transform_engine.rb +2837 -0
- data/lib/odin/transform/transform_parser.rb +979 -0
- data/lib/odin/transform/transform_types.rb +278 -0
- data/lib/odin/transform/verb_context.rb +87 -0
- data/lib/odin/transform/verbs/aggregation_verbs.rb +106 -0
- data/lib/odin/transform/verbs/collection_verbs.rb +640 -0
- data/lib/odin/transform/verbs/datetime_verbs.rb +602 -0
- data/lib/odin/transform/verbs/financial_verbs.rb +356 -0
- data/lib/odin/transform/verbs/geo_verbs.rb +125 -0
- data/lib/odin/transform/verbs/numeric_verbs.rb +434 -0
- data/lib/odin/transform/verbs/object_verbs.rb +123 -0
- data/lib/odin/types/array_item.rb +42 -0
- data/lib/odin/types/diff.rb +89 -0
- data/lib/odin/types/directive.rb +28 -0
- data/lib/odin/types/document.rb +92 -0
- data/lib/odin/types/document_builder.rb +67 -0
- data/lib/odin/types/dyn_value.rb +270 -0
- data/lib/odin/types/errors.rb +149 -0
- data/lib/odin/types/modifiers.rb +45 -0
- data/lib/odin/types/ordered_map.rb +79 -0
- data/lib/odin/types/schema.rb +262 -0
- data/lib/odin/types/value_type.rb +28 -0
- data/lib/odin/types/values.rb +618 -0
- data/lib/odin/types.rb +12 -0
- data/lib/odin/utils/format_utils.rb +186 -0
- data/lib/odin/utils/path_utils.rb +25 -0
- data/lib/odin/utils/security_limits.rb +17 -0
- data/lib/odin/validation/format_validators.rb +238 -0
- data/lib/odin/validation/redos_protection.rb +102 -0
- data/lib/odin/validation/schema_parser.rb +813 -0
- data/lib/odin/validation/schema_serializer.rb +262 -0
- data/lib/odin/validation/validator.rb +1061 -0
- data/lib/odin/version.rb +5 -0
- data/lib/odin.rb +90 -0
- metadata +160 -0
|
@@ -0,0 +1,1061 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Odin
|
|
4
|
+
module Validation
|
|
5
|
+
class Validator
|
|
6
|
+
# Validate an OdinDocument against an OdinSchema
|
|
7
|
+
# Returns ValidationResult
|
|
8
|
+
def validate(doc, schema, options = {})
|
|
9
|
+
@errors = []
|
|
10
|
+
@doc = doc
|
|
11
|
+
@schema = schema
|
|
12
|
+
@strict = options.fetch(:strict, false)
|
|
13
|
+
|
|
14
|
+
# V001: Required fields
|
|
15
|
+
check_required_fields
|
|
16
|
+
|
|
17
|
+
# V002: Type matches
|
|
18
|
+
check_type_matches
|
|
19
|
+
|
|
20
|
+
# V003: Bounds constraints
|
|
21
|
+
check_bounds_constraints
|
|
22
|
+
|
|
23
|
+
# V004: Pattern constraints
|
|
24
|
+
check_pattern_constraints
|
|
25
|
+
|
|
26
|
+
# V004 (format): Format constraints
|
|
27
|
+
check_format_constraints
|
|
28
|
+
|
|
29
|
+
# V005: Enum constraints
|
|
30
|
+
check_enum_constraints
|
|
31
|
+
|
|
32
|
+
# V006: Array length constraints
|
|
33
|
+
check_array_lengths
|
|
34
|
+
|
|
35
|
+
# V007: Uniqueness constraints
|
|
36
|
+
check_uniqueness
|
|
37
|
+
|
|
38
|
+
# V008: Invariant validation
|
|
39
|
+
check_invariants
|
|
40
|
+
|
|
41
|
+
# V009: Cardinality constraints
|
|
42
|
+
check_cardinality
|
|
43
|
+
|
|
44
|
+
# V010: Conditional requirements
|
|
45
|
+
check_conditionals
|
|
46
|
+
|
|
47
|
+
# V011: Unknown fields (strict mode)
|
|
48
|
+
check_unknown_fields if @strict
|
|
49
|
+
|
|
50
|
+
# V012: Circular references
|
|
51
|
+
check_circular_references
|
|
52
|
+
|
|
53
|
+
# V013: Unresolved references
|
|
54
|
+
check_unresolved_references
|
|
55
|
+
|
|
56
|
+
Errors::ValidationResult.new(@errors)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def add_error(code:, path:, message:, expected: nil, actual: nil)
|
|
62
|
+
@errors << Errors::ValidationError.new(
|
|
63
|
+
code: code,
|
|
64
|
+
path: path,
|
|
65
|
+
message: message,
|
|
66
|
+
expected: expected,
|
|
67
|
+
actual: actual
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# ── V001: Required field missing ──
|
|
72
|
+
|
|
73
|
+
def check_required_fields
|
|
74
|
+
# Check root-level fields
|
|
75
|
+
@schema.fields.each do |path, field|
|
|
76
|
+
next unless field.required
|
|
77
|
+
next if field.computed
|
|
78
|
+
next if has_active_conditional?(field) # handled by V010
|
|
79
|
+
|
|
80
|
+
unless doc_has_value?(path)
|
|
81
|
+
add_error(
|
|
82
|
+
code: Errors::ValidationErrorCode::REQUIRED_FIELD_MISSING,
|
|
83
|
+
path: path,
|
|
84
|
+
message: "Required field '#{path}' is missing",
|
|
85
|
+
expected: "present"
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Check type-level fields — only when the type is used as an inline object
|
|
91
|
+
# in the document (not when it's just a type definition via {@ ...}).
|
|
92
|
+
# Type definitions like {@address} define structure but don't require
|
|
93
|
+
# the fields to exist at the type-name path. They are checked when
|
|
94
|
+
# a field references the type (e.g., home = @address means check
|
|
95
|
+
# home.street, home.city).
|
|
96
|
+
@schema.types.each do |type_name, schema_type|
|
|
97
|
+
# Find all fields that reference this type
|
|
98
|
+
type_usage_paths = find_type_usage_paths(type_name)
|
|
99
|
+
|
|
100
|
+
if type_usage_paths.empty?
|
|
101
|
+
# Check if the type is used directly in the document at its own path
|
|
102
|
+
schema_type.fields.each do |field_name, field|
|
|
103
|
+
next unless field.required
|
|
104
|
+
next if field.computed
|
|
105
|
+
next if has_active_conditional?(field)
|
|
106
|
+
|
|
107
|
+
full_path = "#{type_name}.#{field_name}"
|
|
108
|
+
# Only check if the type section actually exists in the document
|
|
109
|
+
next unless doc_section_exists?(type_name)
|
|
110
|
+
unless doc_has_value?(full_path)
|
|
111
|
+
add_error(
|
|
112
|
+
code: Errors::ValidationErrorCode::REQUIRED_FIELD_MISSING,
|
|
113
|
+
path: full_path,
|
|
114
|
+
message: "Required field '#{full_path}' is missing",
|
|
115
|
+
expected: "present"
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
else
|
|
120
|
+
# Check required fields at each usage path
|
|
121
|
+
type_usage_paths.each do |usage_path|
|
|
122
|
+
schema_type.fields.each do |field_name, field|
|
|
123
|
+
next unless field.required
|
|
124
|
+
next if field.computed
|
|
125
|
+
next if has_active_conditional?(field)
|
|
126
|
+
|
|
127
|
+
full_path = "#{usage_path}.#{field_name}"
|
|
128
|
+
unless doc_has_value?(full_path)
|
|
129
|
+
add_error(
|
|
130
|
+
code: Errors::ValidationErrorCode::REQUIRED_FIELD_MISSING,
|
|
131
|
+
path: full_path,
|
|
132
|
+
message: "Required field '#{full_path}' is missing",
|
|
133
|
+
expected: "present"
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Check array item fields
|
|
142
|
+
@schema.arrays.each do |array_path, schema_array|
|
|
143
|
+
check_array_item_required_fields(array_path, schema_array)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def check_array_item_required_fields(array_path, schema_array)
|
|
148
|
+
# Find all array items in the document
|
|
149
|
+
item_count = count_array_items(array_path)
|
|
150
|
+
return if item_count == 0
|
|
151
|
+
|
|
152
|
+
schema_array.item_fields.each do |field_name, field|
|
|
153
|
+
next unless field.required
|
|
154
|
+
next if field.computed
|
|
155
|
+
|
|
156
|
+
item_count.times do |i|
|
|
157
|
+
# Try both path formats
|
|
158
|
+
full_path = "#{array_path}[#{i}].#{field_name}"
|
|
159
|
+
alt_path = "#{array_path}[].[#{i}].#{field_name}"
|
|
160
|
+
unless doc_has_value?(full_path) || doc_has_value?(alt_path)
|
|
161
|
+
add_error(
|
|
162
|
+
code: Errors::ValidationErrorCode::REQUIRED_FIELD_MISSING,
|
|
163
|
+
path: full_path,
|
|
164
|
+
message: "Required field '#{field_name}' is missing in #{array_path}[#{i}]",
|
|
165
|
+
expected: "present"
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# ── V002: Type mismatch ──
|
|
173
|
+
|
|
174
|
+
def check_type_matches
|
|
175
|
+
each_schema_field do |path, field, value|
|
|
176
|
+
next if value.nil? || value.null?
|
|
177
|
+
expected_type = field.field_type
|
|
178
|
+
next if expected_type == Types::SchemaFieldType::ANY
|
|
179
|
+
|
|
180
|
+
actual_type = value_to_schema_type(value)
|
|
181
|
+
next if types_compatible?(expected_type, actual_type, value)
|
|
182
|
+
|
|
183
|
+
add_error(
|
|
184
|
+
code: Errors::ValidationErrorCode::TYPE_MISMATCH,
|
|
185
|
+
path: path,
|
|
186
|
+
message: "Expected type '#{expected_type}' but got '#{actual_type}' at '#{path}'",
|
|
187
|
+
expected: expected_type.to_s,
|
|
188
|
+
actual: actual_type.to_s
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def types_compatible?(expected, actual, value)
|
|
194
|
+
return true if expected == actual
|
|
195
|
+
return true if expected == Types::SchemaFieldType::ANY
|
|
196
|
+
|
|
197
|
+
# Number accepts integer
|
|
198
|
+
return true if expected == Types::SchemaFieldType::NUMBER &&
|
|
199
|
+
actual == Types::SchemaFieldType::INTEGER
|
|
200
|
+
|
|
201
|
+
# Currency is a numeric type
|
|
202
|
+
return true if expected == Types::SchemaFieldType::NUMBER &&
|
|
203
|
+
actual == Types::SchemaFieldType::CURRENCY
|
|
204
|
+
|
|
205
|
+
# String accepts date, timestamp, time, duration (they are string subtypes)
|
|
206
|
+
return true if expected == Types::SchemaFieldType::STRING &&
|
|
207
|
+
[Types::SchemaFieldType::DATE, Types::SchemaFieldType::TIMESTAMP,
|
|
208
|
+
Types::SchemaFieldType::TIME, Types::SchemaFieldType::DURATION].include?(actual)
|
|
209
|
+
|
|
210
|
+
# Nullable fields accept null
|
|
211
|
+
return true if actual == Types::SchemaFieldType::NULL
|
|
212
|
+
|
|
213
|
+
false
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def value_to_schema_type(value)
|
|
217
|
+
case value
|
|
218
|
+
when Types::OdinString then Types::SchemaFieldType::STRING
|
|
219
|
+
when Types::OdinInteger then Types::SchemaFieldType::INTEGER
|
|
220
|
+
when Types::OdinNumber then Types::SchemaFieldType::NUMBER
|
|
221
|
+
when Types::OdinBoolean then Types::SchemaFieldType::BOOLEAN
|
|
222
|
+
when Types::OdinCurrency then Types::SchemaFieldType::CURRENCY
|
|
223
|
+
when Types::OdinPercent then Types::SchemaFieldType::PERCENT
|
|
224
|
+
when Types::OdinDate then Types::SchemaFieldType::DATE
|
|
225
|
+
when Types::OdinTimestamp then Types::SchemaFieldType::TIMESTAMP
|
|
226
|
+
when Types::OdinTime then Types::SchemaFieldType::TIME
|
|
227
|
+
when Types::OdinDuration then Types::SchemaFieldType::DURATION
|
|
228
|
+
when Types::OdinReference then Types::SchemaFieldType::REFERENCE
|
|
229
|
+
when Types::OdinBinary then Types::SchemaFieldType::BINARY
|
|
230
|
+
when Types::OdinNull then Types::SchemaFieldType::NULL
|
|
231
|
+
else Types::SchemaFieldType::STRING
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# ── V003: Value out of bounds ──
|
|
236
|
+
|
|
237
|
+
def check_bounds_constraints
|
|
238
|
+
each_schema_field_with_constraints(:bounds) do |path, field, value, constraint|
|
|
239
|
+
next if value.nil? || value.null?
|
|
240
|
+
check_single_bounds(path, field, value, constraint)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def check_single_bounds(path, field, value, constraint)
|
|
245
|
+
if value.numeric?
|
|
246
|
+
num = value.value.to_f
|
|
247
|
+
check_numeric_bounds(path, num, constraint)
|
|
248
|
+
elsif value.string?
|
|
249
|
+
len = value.value.length
|
|
250
|
+
check_numeric_bounds(path, len, constraint, label: "length")
|
|
251
|
+
elsif value.date? || value.timestamp?
|
|
252
|
+
check_date_bounds(path, value, constraint)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def check_numeric_bounds(path, num, constraint, label: "value")
|
|
257
|
+
if constraint.min
|
|
258
|
+
min_val = constraint.min.to_f
|
|
259
|
+
if constraint.exclusive_min
|
|
260
|
+
unless num > min_val
|
|
261
|
+
add_error(
|
|
262
|
+
code: Errors::ValidationErrorCode::VALUE_OUT_OF_BOUNDS,
|
|
263
|
+
path: path,
|
|
264
|
+
message: "#{label.capitalize} #{num} must be greater than #{constraint.min} at '#{path}'",
|
|
265
|
+
expected: "> #{constraint.min}",
|
|
266
|
+
actual: num.to_s
|
|
267
|
+
)
|
|
268
|
+
end
|
|
269
|
+
else
|
|
270
|
+
unless num >= min_val
|
|
271
|
+
add_error(
|
|
272
|
+
code: Errors::ValidationErrorCode::VALUE_OUT_OF_BOUNDS,
|
|
273
|
+
path: path,
|
|
274
|
+
message: "#{label.capitalize} #{num} is below minimum #{constraint.min} at '#{path}'",
|
|
275
|
+
expected: ">= #{constraint.min}",
|
|
276
|
+
actual: num.to_s
|
|
277
|
+
)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
if constraint.max
|
|
283
|
+
max_val = constraint.max.to_f
|
|
284
|
+
if constraint.exclusive_max
|
|
285
|
+
unless num < max_val
|
|
286
|
+
add_error(
|
|
287
|
+
code: Errors::ValidationErrorCode::VALUE_OUT_OF_BOUNDS,
|
|
288
|
+
path: path,
|
|
289
|
+
message: "#{label.capitalize} #{num} must be less than #{constraint.max} at '#{path}'",
|
|
290
|
+
expected: "< #{constraint.max}",
|
|
291
|
+
actual: num.to_s
|
|
292
|
+
)
|
|
293
|
+
end
|
|
294
|
+
else
|
|
295
|
+
unless num <= max_val
|
|
296
|
+
add_error(
|
|
297
|
+
code: Errors::ValidationErrorCode::VALUE_OUT_OF_BOUNDS,
|
|
298
|
+
path: path,
|
|
299
|
+
message: "#{label.capitalize} #{num} exceeds maximum #{constraint.max} at '#{path}'",
|
|
300
|
+
expected: "<= #{constraint.max}",
|
|
301
|
+
actual: num.to_s
|
|
302
|
+
)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def check_date_bounds(path, value, constraint)
|
|
309
|
+
val_str = value.to_s
|
|
310
|
+
if constraint.min && val_str < constraint.min.to_s
|
|
311
|
+
add_error(
|
|
312
|
+
code: Errors::ValidationErrorCode::VALUE_OUT_OF_BOUNDS,
|
|
313
|
+
path: path,
|
|
314
|
+
message: "Date #{val_str} is before minimum #{constraint.min} at '#{path}'",
|
|
315
|
+
expected: ">= #{constraint.min}",
|
|
316
|
+
actual: val_str
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
if constraint.max && val_str > constraint.max.to_s
|
|
320
|
+
add_error(
|
|
321
|
+
code: Errors::ValidationErrorCode::VALUE_OUT_OF_BOUNDS,
|
|
322
|
+
path: path,
|
|
323
|
+
message: "Date #{val_str} is after maximum #{constraint.max} at '#{path}'",
|
|
324
|
+
expected: "<= #{constraint.max}",
|
|
325
|
+
actual: val_str
|
|
326
|
+
)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# ── V004: Pattern mismatch ──
|
|
331
|
+
|
|
332
|
+
def check_pattern_constraints
|
|
333
|
+
each_schema_field_with_constraints(:pattern) do |path, field, value, constraint|
|
|
334
|
+
next if value.nil? || value.null?
|
|
335
|
+
next unless value.string?
|
|
336
|
+
|
|
337
|
+
# ReDoS check
|
|
338
|
+
unless ReDoSProtection.safe?(constraint.pattern)
|
|
339
|
+
add_error(
|
|
340
|
+
code: Errors::ValidationErrorCode::PATTERN_MISMATCH,
|
|
341
|
+
path: path,
|
|
342
|
+
message: "Unsafe regex pattern rejected at '#{path}'"
|
|
343
|
+
)
|
|
344
|
+
next
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
begin
|
|
348
|
+
regex = Regexp.new(constraint.pattern)
|
|
349
|
+
result = ReDoSProtection.safe_test(regex, value.value)
|
|
350
|
+
if result[:reason] == :value_too_long
|
|
351
|
+
add_error(
|
|
352
|
+
code: Errors::ValidationErrorCode::PATTERN_MISMATCH,
|
|
353
|
+
path: path,
|
|
354
|
+
message: "Value too long for pattern validation at '#{path}'"
|
|
355
|
+
)
|
|
356
|
+
elsif result[:timed_out]
|
|
357
|
+
add_error(
|
|
358
|
+
code: Errors::ValidationErrorCode::PATTERN_MISMATCH,
|
|
359
|
+
path: path,
|
|
360
|
+
message: "Pattern validation timed out at '#{path}'"
|
|
361
|
+
)
|
|
362
|
+
elsif !result[:matched]
|
|
363
|
+
add_error(
|
|
364
|
+
code: Errors::ValidationErrorCode::PATTERN_MISMATCH,
|
|
365
|
+
path: path,
|
|
366
|
+
message: "Value '#{value.value}' does not match pattern /#{constraint.pattern}/ at '#{path}'",
|
|
367
|
+
expected: constraint.pattern,
|
|
368
|
+
actual: value.value
|
|
369
|
+
)
|
|
370
|
+
end
|
|
371
|
+
rescue RegexpError => e
|
|
372
|
+
add_error(
|
|
373
|
+
code: Errors::ValidationErrorCode::PATTERN_MISMATCH,
|
|
374
|
+
path: path,
|
|
375
|
+
message: "Invalid regex pattern: #{e.message} at '#{path}'"
|
|
376
|
+
)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# ── V005: Invalid enum value ──
|
|
382
|
+
|
|
383
|
+
def check_enum_constraints
|
|
384
|
+
each_schema_field_with_constraints(:enum) do |path, field, value, constraint|
|
|
385
|
+
next if value.nil? || value.null?
|
|
386
|
+
|
|
387
|
+
val_str = extract_value_for_comparison(value)
|
|
388
|
+
unless constraint.values.include?(val_str)
|
|
389
|
+
add_error(
|
|
390
|
+
code: Errors::ValidationErrorCode::INVALID_ENUM_VALUE,
|
|
391
|
+
path: path,
|
|
392
|
+
message: "Value '#{val_str}' is not one of allowed values [#{constraint.values.join(', ')}] at '#{path}'",
|
|
393
|
+
expected: constraint.values.join(", "),
|
|
394
|
+
actual: val_str
|
|
395
|
+
)
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# ── V006: Array length violation ──
|
|
401
|
+
|
|
402
|
+
def check_array_lengths
|
|
403
|
+
@schema.arrays.each do |array_path, schema_array|
|
|
404
|
+
count = count_array_items(array_path)
|
|
405
|
+
# For max_items, only validate if array exists
|
|
406
|
+
# For min_items, always validate (0 items < min is a violation)
|
|
407
|
+
|
|
408
|
+
if schema_array.min_items && count < schema_array.min_items
|
|
409
|
+
add_error(
|
|
410
|
+
code: Errors::ValidationErrorCode::ARRAY_LENGTH_VIOLATION,
|
|
411
|
+
path: array_path,
|
|
412
|
+
message: "Array '#{array_path}' has #{count} items, minimum is #{schema_array.min_items}",
|
|
413
|
+
expected: ">= #{schema_array.min_items}",
|
|
414
|
+
actual: count.to_s
|
|
415
|
+
)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
if schema_array.max_items && count > schema_array.max_items
|
|
419
|
+
add_error(
|
|
420
|
+
code: Errors::ValidationErrorCode::ARRAY_LENGTH_VIOLATION,
|
|
421
|
+
path: array_path,
|
|
422
|
+
message: "Array '#{array_path}' has #{count} items, maximum is #{schema_array.max_items}",
|
|
423
|
+
expected: "<= #{schema_array.max_items}",
|
|
424
|
+
actual: count.to_s
|
|
425
|
+
)
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# ── V007: Uniqueness constraint violation ──
|
|
431
|
+
|
|
432
|
+
def check_uniqueness
|
|
433
|
+
@schema.arrays.each do |array_path, schema_array|
|
|
434
|
+
next unless schema_array.unique
|
|
435
|
+
|
|
436
|
+
count = count_array_items(array_path)
|
|
437
|
+
next if count <= 1
|
|
438
|
+
|
|
439
|
+
# Collect values for uniqueness check
|
|
440
|
+
seen = {}
|
|
441
|
+
count.times do |i|
|
|
442
|
+
# Get all fields for this item
|
|
443
|
+
item_key = collect_item_values(array_path, i)
|
|
444
|
+
if seen.key?(item_key)
|
|
445
|
+
add_error(
|
|
446
|
+
code: Errors::ValidationErrorCode::UNIQUE_CONSTRAINT_VIOLATION,
|
|
447
|
+
path: array_path,
|
|
448
|
+
message: "Duplicate item at index #{i} in array '#{array_path}'",
|
|
449
|
+
expected: "unique items",
|
|
450
|
+
actual: "duplicate of index #{seen[item_key]}"
|
|
451
|
+
)
|
|
452
|
+
else
|
|
453
|
+
seen[item_key] = i
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Check unique constraints on individual fields
|
|
459
|
+
each_schema_field_with_constraints(:unique) do |path, field, value, constraint|
|
|
460
|
+
# Unique constraint on a field within an array — check uniqueness across items
|
|
461
|
+
check_field_uniqueness_in_array(path, field, constraint)
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def check_field_uniqueness_in_array(path, field, constraint)
|
|
466
|
+
# Determine if this field is inside an array
|
|
467
|
+
parts = path.split(".")
|
|
468
|
+
return unless parts.length >= 2
|
|
469
|
+
|
|
470
|
+
array_path = parts[0...-1].join(".")
|
|
471
|
+
field_name = parts.last
|
|
472
|
+
count = count_array_items(array_path)
|
|
473
|
+
return if count <= 1
|
|
474
|
+
|
|
475
|
+
seen = {}
|
|
476
|
+
count.times do |i|
|
|
477
|
+
item_path = "#{array_path}[#{i}].#{field_name}"
|
|
478
|
+
value = @doc.get(item_path)
|
|
479
|
+
next unless value
|
|
480
|
+
|
|
481
|
+
val_str = extract_value_for_comparison(value)
|
|
482
|
+
if seen.key?(val_str)
|
|
483
|
+
add_error(
|
|
484
|
+
code: Errors::ValidationErrorCode::UNIQUE_CONSTRAINT_VIOLATION,
|
|
485
|
+
path: item_path,
|
|
486
|
+
message: "Duplicate value '#{val_str}' for unique field '#{field_name}' at index #{i}",
|
|
487
|
+
expected: "unique",
|
|
488
|
+
actual: val_str
|
|
489
|
+
)
|
|
490
|
+
else
|
|
491
|
+
seen[val_str] = i
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# ── V008: Invariant violation ──
|
|
497
|
+
|
|
498
|
+
def check_invariants
|
|
499
|
+
@schema.object_constraints.each do |scope, constraints|
|
|
500
|
+
constraints.each do |constraint|
|
|
501
|
+
next unless constraint.is_a?(Types::SchemaInvariant)
|
|
502
|
+
evaluate_invariant(scope, constraint)
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def evaluate_invariant(scope, invariant)
|
|
508
|
+
expr = invariant.expression
|
|
509
|
+
# Parse simple binary expressions: field OPERATOR value_or_field
|
|
510
|
+
match = expr.match(/\A(\S+)\s*(>=|<=|!=|==|>|<|=)\s*(.+)\z/)
|
|
511
|
+
return unless match
|
|
512
|
+
|
|
513
|
+
left_field = match[1]
|
|
514
|
+
operator = match[2]
|
|
515
|
+
right_expr = match[3].strip
|
|
516
|
+
|
|
517
|
+
left_path = scope.empty? ? left_field : "#{scope}.#{left_field}"
|
|
518
|
+
left_value = @doc.get(left_path)
|
|
519
|
+
return unless left_value # Can't evaluate if field missing
|
|
520
|
+
|
|
521
|
+
# Right side might be a field reference or a literal
|
|
522
|
+
right_path = scope.empty? ? right_expr : "#{scope}.#{right_expr}"
|
|
523
|
+
right_value = @doc.get(right_path)
|
|
524
|
+
|
|
525
|
+
if right_value
|
|
526
|
+
# Compare two field values
|
|
527
|
+
result = compare_values(left_value, operator, right_value)
|
|
528
|
+
else
|
|
529
|
+
# Compare field to literal
|
|
530
|
+
result = compare_value_to_literal(left_value, operator, right_expr)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
unless result
|
|
534
|
+
add_error(
|
|
535
|
+
code: Errors::ValidationErrorCode::INVARIANT_VIOLATION,
|
|
536
|
+
path: scope,
|
|
537
|
+
message: "Invariant '#{expr}' violated at '#{scope}'",
|
|
538
|
+
expected: expr
|
|
539
|
+
)
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def compare_values(left, operator, right)
|
|
544
|
+
lv = extract_numeric_value(left)
|
|
545
|
+
rv = extract_numeric_value(right)
|
|
546
|
+
|
|
547
|
+
if lv && rv
|
|
548
|
+
case operator
|
|
549
|
+
when ">", ">" then lv > rv
|
|
550
|
+
when "<" then lv < rv
|
|
551
|
+
when ">=", ">=" then lv >= rv
|
|
552
|
+
when "<=", "<=" then lv <= rv
|
|
553
|
+
when "=", "==" then lv == rv
|
|
554
|
+
when "!=" then lv != rv
|
|
555
|
+
else false
|
|
556
|
+
end
|
|
557
|
+
else
|
|
558
|
+
ls = extract_value_for_comparison(left)
|
|
559
|
+
rs = extract_value_for_comparison(right)
|
|
560
|
+
case operator
|
|
561
|
+
when "=", "==" then ls == rs
|
|
562
|
+
when "!=" then ls != rs
|
|
563
|
+
else false
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def compare_value_to_literal(value, operator, literal)
|
|
569
|
+
nv = extract_numeric_value(value)
|
|
570
|
+
nl = Float(literal) rescue nil
|
|
571
|
+
|
|
572
|
+
if nv && nl
|
|
573
|
+
case operator
|
|
574
|
+
when ">" then nv > nl
|
|
575
|
+
when "<" then nv < nl
|
|
576
|
+
when ">=", ">=" then nv >= nl
|
|
577
|
+
when "<=", "<=" then nv <= nl
|
|
578
|
+
when "=", "==" then nv == nl
|
|
579
|
+
when "!=" then nv != nl
|
|
580
|
+
else false
|
|
581
|
+
end
|
|
582
|
+
else
|
|
583
|
+
vs = extract_value_for_comparison(value)
|
|
584
|
+
case operator
|
|
585
|
+
when "=", "==" then vs == literal
|
|
586
|
+
when "!=" then vs != literal
|
|
587
|
+
else false
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# ── V009: Cardinality constraint violation ──
|
|
593
|
+
|
|
594
|
+
def check_cardinality
|
|
595
|
+
@schema.object_constraints.each do |scope, constraints|
|
|
596
|
+
constraints.each do |constraint|
|
|
597
|
+
next unless constraint.is_a?(Types::SchemaCardinality)
|
|
598
|
+
evaluate_cardinality(scope, constraint)
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def evaluate_cardinality(scope, constraint)
|
|
604
|
+
# Count how many of the listed fields are present and non-null
|
|
605
|
+
count = 0
|
|
606
|
+
constraint.fields.each do |field_name|
|
|
607
|
+
path = scope.empty? ? field_name : "#{scope}.#{field_name}"
|
|
608
|
+
value = @doc.get(path)
|
|
609
|
+
count += 1 if value && !value.null?
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
case constraint.cardinality_type
|
|
613
|
+
when "of"
|
|
614
|
+
if constraint.min && count < constraint.min
|
|
615
|
+
add_error(
|
|
616
|
+
code: Errors::ValidationErrorCode::CARDINALITY_CONSTRAINT_VIOLATION,
|
|
617
|
+
path: scope,
|
|
618
|
+
message: "At least #{constraint.min} of [#{constraint.fields.join(', ')}] required at '#{scope}', found #{count}",
|
|
619
|
+
expected: ">= #{constraint.min}",
|
|
620
|
+
actual: count.to_s
|
|
621
|
+
)
|
|
622
|
+
end
|
|
623
|
+
if constraint.max && count > constraint.max
|
|
624
|
+
add_error(
|
|
625
|
+
code: Errors::ValidationErrorCode::CARDINALITY_CONSTRAINT_VIOLATION,
|
|
626
|
+
path: scope,
|
|
627
|
+
message: "At most #{constraint.max} of [#{constraint.fields.join(', ')}] allowed at '#{scope}', found #{count}",
|
|
628
|
+
expected: "<= #{constraint.max}",
|
|
629
|
+
actual: count.to_s
|
|
630
|
+
)
|
|
631
|
+
end
|
|
632
|
+
when "one_of"
|
|
633
|
+
unless count >= 1
|
|
634
|
+
add_error(
|
|
635
|
+
code: Errors::ValidationErrorCode::CARDINALITY_CONSTRAINT_VIOLATION,
|
|
636
|
+
path: scope,
|
|
637
|
+
message: "At least one of [#{constraint.fields.join(', ')}] required at '#{scope}'",
|
|
638
|
+
expected: ">= 1",
|
|
639
|
+
actual: count.to_s
|
|
640
|
+
)
|
|
641
|
+
end
|
|
642
|
+
when "exactly_one"
|
|
643
|
+
unless count == 1
|
|
644
|
+
add_error(
|
|
645
|
+
code: Errors::ValidationErrorCode::CARDINALITY_CONSTRAINT_VIOLATION,
|
|
646
|
+
path: scope,
|
|
647
|
+
message: "Exactly one of [#{constraint.fields.join(', ')}] required at '#{scope}', found #{count}",
|
|
648
|
+
expected: "1",
|
|
649
|
+
actual: count.to_s
|
|
650
|
+
)
|
|
651
|
+
end
|
|
652
|
+
when "at_most_one"
|
|
653
|
+
unless count <= 1
|
|
654
|
+
add_error(
|
|
655
|
+
code: Errors::ValidationErrorCode::CARDINALITY_CONSTRAINT_VIOLATION,
|
|
656
|
+
path: scope,
|
|
657
|
+
message: "At most one of [#{constraint.fields.join(', ')}] allowed at '#{scope}', found #{count}",
|
|
658
|
+
expected: "<= 1",
|
|
659
|
+
actual: count.to_s
|
|
660
|
+
)
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# ── V010: Conditional requirement not met ──
|
|
666
|
+
|
|
667
|
+
def check_conditionals
|
|
668
|
+
each_schema_field_with_conditionals do |path, field, conditionals|
|
|
669
|
+
conditionals.each do |cond|
|
|
670
|
+
# Resolve the condition field value from the document
|
|
671
|
+
cond_field_path = resolve_conditional_field(path, cond.field)
|
|
672
|
+
cond_value = @doc.get(cond_field_path)
|
|
673
|
+
|
|
674
|
+
# If condition field doesn't exist, skip
|
|
675
|
+
next unless cond_value
|
|
676
|
+
|
|
677
|
+
# Evaluate the condition
|
|
678
|
+
is_met = cond.evaluate(extract_value_for_comparison(cond_value))
|
|
679
|
+
|
|
680
|
+
if is_met && field.required && !doc_has_value?(path)
|
|
681
|
+
add_error(
|
|
682
|
+
code: Errors::ValidationErrorCode::CONDITIONAL_REQUIREMENT_NOT_MET,
|
|
683
|
+
path: path,
|
|
684
|
+
message: "Field '#{path}' is required when #{cond.field} #{cond.operator} #{cond.value}",
|
|
685
|
+
expected: "present",
|
|
686
|
+
actual: "missing"
|
|
687
|
+
)
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def resolve_conditional_field(field_path, cond_field)
|
|
694
|
+
# If the field is in a section, resolve relative to same section
|
|
695
|
+
parts = field_path.split(".")
|
|
696
|
+
if parts.length > 1
|
|
697
|
+
section = parts[0...-1].join(".")
|
|
698
|
+
"#{section}.#{cond_field}"
|
|
699
|
+
else
|
|
700
|
+
cond_field
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
# ── V011: Unknown field (strict mode) ──
|
|
705
|
+
|
|
706
|
+
def check_unknown_fields
|
|
707
|
+
known_paths = collect_known_paths
|
|
708
|
+
@doc.each_assignment do |path, _value|
|
|
709
|
+
unless known_paths.include?(path) || path_in_known_array?(path, known_paths)
|
|
710
|
+
add_error(
|
|
711
|
+
code: Errors::ValidationErrorCode::UNKNOWN_FIELD,
|
|
712
|
+
path: path,
|
|
713
|
+
message: "Unknown field '#{path}' (strict mode)",
|
|
714
|
+
expected: "known field"
|
|
715
|
+
)
|
|
716
|
+
end
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
def collect_known_paths
|
|
721
|
+
paths = Set.new
|
|
722
|
+
@schema.fields.each_key { |p| paths.add(p) }
|
|
723
|
+
@schema.types.each do |type_name, schema_type|
|
|
724
|
+
schema_type.fields.each_key { |f| paths.add("#{type_name}.#{f}") }
|
|
725
|
+
end
|
|
726
|
+
@schema.arrays.each do |array_path, schema_array|
|
|
727
|
+
schema_array.item_fields.each_key do |f|
|
|
728
|
+
# Array items match pattern: path[N].field
|
|
729
|
+
paths.add("#{array_path}[].#{f}")
|
|
730
|
+
end
|
|
731
|
+
end
|
|
732
|
+
paths
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def path_in_known_array?(path, known_paths)
|
|
736
|
+
# Check if path matches an array item pattern
|
|
737
|
+
@schema.arrays.each do |array_path, schema_array|
|
|
738
|
+
escaped = Regexp.escape(array_path)
|
|
739
|
+
# Support both formats: items[0].field and items[].[0].field
|
|
740
|
+
if path.start_with?("#{array_path}[")
|
|
741
|
+
# Format: items[0].field
|
|
742
|
+
match = path.match(/\A#{escaped}\[\d+\]\.(.+)\z/)
|
|
743
|
+
if match
|
|
744
|
+
field_name = match[1]
|
|
745
|
+
return true if schema_array.item_fields.key?(field_name)
|
|
746
|
+
end
|
|
747
|
+
return true if path.match?(/\A#{escaped}\[\d+\]\z/)
|
|
748
|
+
|
|
749
|
+
# Format: items[].[0].field
|
|
750
|
+
match = path.match(/\A#{escaped}\[\]\.\[(\d+)\]\.(.+)\z/)
|
|
751
|
+
if match
|
|
752
|
+
field_name = match[2]
|
|
753
|
+
return true if schema_array.item_fields.key?(field_name)
|
|
754
|
+
end
|
|
755
|
+
return true if path.match?(/\A#{escaped}\[\]\.\[\d+\]\z/)
|
|
756
|
+
end
|
|
757
|
+
end
|
|
758
|
+
false
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
# ── V012: Circular reference ──
|
|
762
|
+
|
|
763
|
+
def check_circular_references
|
|
764
|
+
# Check if any reference values in the document create cycles
|
|
765
|
+
@doc.each_assignment do |path, value|
|
|
766
|
+
next unless value.is_a?(Types::OdinReference)
|
|
767
|
+
visited = Set.new([path])
|
|
768
|
+
check_ref_cycle(value.path, visited, path)
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
# Check schema-level type reference cycles
|
|
772
|
+
check_schema_type_cycles
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
def check_ref_cycle(ref_path, visited, origin_path)
|
|
776
|
+
return if visited.size > 100 # safety limit
|
|
777
|
+
|
|
778
|
+
if visited.include?(ref_path)
|
|
779
|
+
add_error(
|
|
780
|
+
code: Errors::ValidationErrorCode::CIRCULAR_REFERENCE,
|
|
781
|
+
path: origin_path,
|
|
782
|
+
message: "Circular reference detected: #{origin_path} -> #{ref_path}"
|
|
783
|
+
)
|
|
784
|
+
return
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
target = @doc.get(ref_path)
|
|
788
|
+
return unless target.is_a?(Types::OdinReference)
|
|
789
|
+
|
|
790
|
+
visited.add(ref_path)
|
|
791
|
+
check_ref_cycle(target.path, visited, origin_path)
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def check_schema_type_cycles
|
|
795
|
+
# Build a graph of type references from schema types
|
|
796
|
+
type_refs = {}
|
|
797
|
+
@schema.types.each do |type_name, schema_type|
|
|
798
|
+
refs = []
|
|
799
|
+
schema_type.fields.each do |_field_name, field|
|
|
800
|
+
if field.type_ref
|
|
801
|
+
clean_ref = field.type_ref.sub(/\A@+/, "")
|
|
802
|
+
refs << clean_ref if @schema.types.key?(clean_ref)
|
|
803
|
+
end
|
|
804
|
+
end
|
|
805
|
+
type_refs[type_name] = refs unless refs.empty?
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
# Detect cycles using DFS
|
|
809
|
+
type_refs.each_key do |start_type|
|
|
810
|
+
visited = Set.new
|
|
811
|
+
check_type_cycle(start_type, start_type, visited, type_refs)
|
|
812
|
+
end
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
def check_type_cycle(current, start_type, visited, type_refs)
|
|
816
|
+
return if visited.include?(current)
|
|
817
|
+
visited.add(current)
|
|
818
|
+
|
|
819
|
+
(type_refs[current] || []).each do |ref|
|
|
820
|
+
if ref == start_type
|
|
821
|
+
add_error(
|
|
822
|
+
code: Errors::ValidationErrorCode::CIRCULAR_REFERENCE,
|
|
823
|
+
path: "@#{start_type}",
|
|
824
|
+
message: "Circular reference detected in schema types: @#{start_type} -> @#{current} -> @#{ref}"
|
|
825
|
+
)
|
|
826
|
+
return
|
|
827
|
+
end
|
|
828
|
+
check_type_cycle(ref, start_type, visited, type_refs)
|
|
829
|
+
end
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# ── V013: Unresolved reference ──
|
|
833
|
+
|
|
834
|
+
def check_unresolved_references
|
|
835
|
+
@doc.each_assignment do |path, value|
|
|
836
|
+
next unless value.is_a?(Types::OdinReference)
|
|
837
|
+
|
|
838
|
+
ref_path = value.path
|
|
839
|
+
unless @doc.include?(ref_path) || ref_path_matches_any?(ref_path)
|
|
840
|
+
add_error(
|
|
841
|
+
code: Errors::ValidationErrorCode::UNRESOLVED_REFERENCE,
|
|
842
|
+
path: path,
|
|
843
|
+
message: "Reference '@#{ref_path}' at '#{path}' does not resolve to any path",
|
|
844
|
+
expected: "valid path",
|
|
845
|
+
actual: ref_path
|
|
846
|
+
)
|
|
847
|
+
end
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
# Also check type references in schema
|
|
851
|
+
@schema.fields.each do |path, field|
|
|
852
|
+
next unless field.type_ref
|
|
853
|
+
check_type_reference(path, field.type_ref)
|
|
854
|
+
end
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def check_type_reference(path, type_ref)
|
|
858
|
+
# Strip leading @ or @@ from type reference for lookup
|
|
859
|
+
clean_ref = type_ref.sub(/\A@+/, "")
|
|
860
|
+
return if @schema.types.key?(type_ref)
|
|
861
|
+
return if @schema.types.key?(clean_ref)
|
|
862
|
+
# Check with namespace prefixes
|
|
863
|
+
return if @schema.types.any? { |name, _| name.end_with?(clean_ref) }
|
|
864
|
+
|
|
865
|
+
add_error(
|
|
866
|
+
code: Errors::ValidationErrorCode::UNRESOLVED_REFERENCE,
|
|
867
|
+
path: path,
|
|
868
|
+
message: "Type reference '@@#{clean_ref}' at '#{path}' does not resolve to any type",
|
|
869
|
+
expected: "valid type name",
|
|
870
|
+
actual: type_ref
|
|
871
|
+
)
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
def ref_path_matches_any?(ref_path)
|
|
875
|
+
# Check if ref_path with wildcard matches any document path
|
|
876
|
+
return false unless ref_path.include?("*")
|
|
877
|
+
pattern = Regexp.new("\\A#{ref_path.gsub('*', '.*')}\\z")
|
|
878
|
+
@doc.paths.any? { |p| pattern.match?(p) }
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
# ── Helpers ──
|
|
882
|
+
|
|
883
|
+
def doc_has_value?(path)
|
|
884
|
+
value = @doc.get(path)
|
|
885
|
+
!value.nil? && !value.null?
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
def doc_section_exists?(section)
|
|
889
|
+
@doc.paths.any? { |p| p == section || p.start_with?("#{section}.") || p.start_with?("#{section}[") }
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
# Find document paths where a type is used via type references
|
|
893
|
+
def find_type_usage_paths(type_name)
|
|
894
|
+
paths = []
|
|
895
|
+
@schema.fields.each do |field_path, field|
|
|
896
|
+
next unless field.type_ref
|
|
897
|
+
clean_ref = field.type_ref.sub(/\A@+/, "")
|
|
898
|
+
paths << field_path if clean_ref == type_name
|
|
899
|
+
end
|
|
900
|
+
paths
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
def array_exists?(array_path)
|
|
904
|
+
@doc.paths.any? { |p| p.start_with?("#{array_path}[") || p.start_with?("#{array_path}[].") }
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
def count_array_items(array_path)
|
|
908
|
+
max_index = -1
|
|
909
|
+
# Support both path formats: "items[0].field" and "items[].[0].field"
|
|
910
|
+
prefixes = ["#{array_path}[", "#{array_path}[].["]
|
|
911
|
+
@doc.paths.each do |p|
|
|
912
|
+
prefixes.each do |prefix|
|
|
913
|
+
next unless p.start_with?(prefix)
|
|
914
|
+
match = p[prefix.length..].match(/\A(\d+)/)
|
|
915
|
+
if match
|
|
916
|
+
idx = match[1].to_i
|
|
917
|
+
max_index = idx if idx > max_index
|
|
918
|
+
end
|
|
919
|
+
end
|
|
920
|
+
end
|
|
921
|
+
max_index + 1
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
def collect_item_values(array_path, index)
|
|
925
|
+
# Support both path formats
|
|
926
|
+
prefixes = ["#{array_path}[#{index}]", "#{array_path}[].[#{index}]"]
|
|
927
|
+
values = []
|
|
928
|
+
@doc.each_assignment do |path, value|
|
|
929
|
+
if prefixes.any? { |pfx| path.start_with?(pfx) }
|
|
930
|
+
values << [path, extract_value_for_comparison(value)]
|
|
931
|
+
end
|
|
932
|
+
end
|
|
933
|
+
values.sort_by(&:first).map { |_, v| v }.join("|")
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
def extract_value_for_comparison(value)
|
|
937
|
+
case value
|
|
938
|
+
when Types::OdinString then value.value
|
|
939
|
+
when Types::OdinInteger, Types::OdinNumber, Types::OdinCurrency,
|
|
940
|
+
Types::OdinPercent then value.value.to_s
|
|
941
|
+
when Types::OdinBoolean then value.value.to_s
|
|
942
|
+
when Types::OdinNull then ""
|
|
943
|
+
when Types::OdinDate, Types::OdinTimestamp, Types::OdinTime,
|
|
944
|
+
Types::OdinDuration then value.to_s
|
|
945
|
+
when Types::OdinReference then value.path
|
|
946
|
+
else value.to_s
|
|
947
|
+
end
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
def extract_numeric_value(value)
|
|
951
|
+
case value
|
|
952
|
+
when Types::OdinInteger, Types::OdinNumber, Types::OdinCurrency, Types::OdinPercent
|
|
953
|
+
value.value.to_f
|
|
954
|
+
else
|
|
955
|
+
nil
|
|
956
|
+
end
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
def has_active_conditional?(field)
|
|
960
|
+
!field.conditionals.empty?
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
# Iterate over all schema fields paired with their document values
|
|
964
|
+
def each_schema_field
|
|
965
|
+
@schema.fields.each do |path, field|
|
|
966
|
+
value = @doc.get(path)
|
|
967
|
+
yield path, field, value if value
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
@schema.types.each do |type_name, schema_type|
|
|
971
|
+
schema_type.fields.each do |field_name, field|
|
|
972
|
+
full_path = "#{type_name}.#{field_name}"
|
|
973
|
+
value = @doc.get(full_path)
|
|
974
|
+
yield full_path, field, value if value
|
|
975
|
+
end
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
@schema.arrays.each do |array_path, schema_array|
|
|
979
|
+
count = count_array_items(array_path)
|
|
980
|
+
count.times do |i|
|
|
981
|
+
schema_array.item_fields.each do |field_name, field|
|
|
982
|
+
# Try both path formats
|
|
983
|
+
full_path = "#{array_path}[#{i}].#{field_name}"
|
|
984
|
+
value = @doc.get(full_path)
|
|
985
|
+
unless value
|
|
986
|
+
alt_path = "#{array_path}[].[#{i}].#{field_name}"
|
|
987
|
+
value = @doc.get(alt_path)
|
|
988
|
+
full_path = alt_path if value
|
|
989
|
+
end
|
|
990
|
+
yield full_path, field, value if value
|
|
991
|
+
end
|
|
992
|
+
end
|
|
993
|
+
end
|
|
994
|
+
end
|
|
995
|
+
|
|
996
|
+
# Iterate over fields with a specific constraint kind
|
|
997
|
+
def each_schema_field_with_constraints(kind)
|
|
998
|
+
each_schema_field do |path, field, value|
|
|
999
|
+
field.constraints.each do |constraint|
|
|
1000
|
+
yield path, field, value, constraint if constraint.kind == kind
|
|
1001
|
+
end
|
|
1002
|
+
end
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
# Iterate over fields with conditionals
|
|
1006
|
+
def each_schema_field_with_conditionals
|
|
1007
|
+
@schema.fields.each do |path, field|
|
|
1008
|
+
yield path, field, field.conditionals unless field.conditionals.empty?
|
|
1009
|
+
end
|
|
1010
|
+
|
|
1011
|
+
@schema.types.each do |type_name, schema_type|
|
|
1012
|
+
schema_type.fields.each do |field_name, field|
|
|
1013
|
+
unless field.conditionals.empty?
|
|
1014
|
+
yield "#{type_name}.#{field_name}", field, field.conditionals
|
|
1015
|
+
end
|
|
1016
|
+
end
|
|
1017
|
+
end
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
# Format validation (part of V004)
|
|
1021
|
+
def check_format_constraints
|
|
1022
|
+
each_schema_field_with_constraints(:format) do |path, field, value, constraint|
|
|
1023
|
+
next if value.nil? || value.null?
|
|
1024
|
+
|
|
1025
|
+
# For non-string values, extract string representation for format check
|
|
1026
|
+
if value.string?
|
|
1027
|
+
val_str = value.value
|
|
1028
|
+
elsif value.date? || value.timestamp? || value.time?
|
|
1029
|
+
val_str = value.to_s
|
|
1030
|
+
else
|
|
1031
|
+
next # Non-string, non-temporal values skip format checks
|
|
1032
|
+
end
|
|
1033
|
+
|
|
1034
|
+
# date-iso: validate against YYYY-MM-DD pattern (matches TypeScript)
|
|
1035
|
+
if constraint.format_name == "date-iso"
|
|
1036
|
+
unless val_str.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
1037
|
+
add_error(
|
|
1038
|
+
code: Errors::ValidationErrorCode::PATTERN_MISMATCH,
|
|
1039
|
+
path: path,
|
|
1040
|
+
message: "Value does not match format 'date-iso' at '#{path}'",
|
|
1041
|
+
expected: "date-iso",
|
|
1042
|
+
actual: val_str
|
|
1043
|
+
)
|
|
1044
|
+
end
|
|
1045
|
+
next
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
unless FormatValidators.validate(constraint.format_name, val_str)
|
|
1049
|
+
add_error(
|
|
1050
|
+
code: Errors::ValidationErrorCode::PATTERN_MISMATCH,
|
|
1051
|
+
path: path,
|
|
1052
|
+
message: "Value does not match format '#{constraint.format_name}' at '#{path}'",
|
|
1053
|
+
expected: constraint.format_name,
|
|
1054
|
+
actual: val_str
|
|
1055
|
+
)
|
|
1056
|
+
end
|
|
1057
|
+
end
|
|
1058
|
+
end
|
|
1059
|
+
end
|
|
1060
|
+
end
|
|
1061
|
+
end
|