odin-foundation 1.2.0 → 1.2.2
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/lib/odin/transform/transform_engine.rb +201 -45
- data/lib/odin/types/schema.rb +10 -4
- data/lib/odin/utils/security_limits.rb +26 -0
- data/lib/odin/validation/schema_parser.rb +70 -3
- data/lib/odin/validation/validator.rb +64 -2
- data/lib/odin/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7941332bc111a6e015c111bdacc0112a4de7158790df8dc5795846261d63c90f
|
|
4
|
+
data.tar.gz: 2925314fb92f73c7a2f83cfde86a0975a619c9ac9f88186aeffbcfdb29f2c587
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 07ebdd4410ca462bcc14fb2095ac1302b8aecbe06a32e069bdd9c2bd3ce08afdf0fb5653f319d347fcdbcfd59a5b8338e28b718e4eb9e77dc42c145b53e586c6
|
|
7
|
+
data.tar.gz: 67dad1314b2884092d7eafa3667e4815a2469ff94f9ec4c0870a49eb0c4701d28e94d32617e3d820d59327d6eb87583bdf7b0914011ea809ce657ff4bb5e3e4a
|
|
@@ -6,6 +6,19 @@ module Odin
|
|
|
6
6
|
# Verb registry — populated in Phase 9-10. For now, core verbs only.
|
|
7
7
|
CORE_VERBS = {}.freeze
|
|
8
8
|
|
|
9
|
+
# Verbs whose cost grows with an array argument. Charged proportional to
|
|
10
|
+
# the array width so large-array work cannot escape the fuel budget.
|
|
11
|
+
SORT_VERBS = %w[sort].freeze
|
|
12
|
+
WIDTH_VERBS = %w[
|
|
13
|
+
distinct groupBy keyBy countBy reduce
|
|
14
|
+
sum avg min max count sumIf avgIf countIf
|
|
15
|
+
union intersection difference symmetricDifference
|
|
16
|
+
map filter window explode flatten reverse
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
# Read the wall clock once per this many charged units.
|
|
20
|
+
CLOCK_CHECK_INTERVAL = 1024
|
|
21
|
+
|
|
9
22
|
class TransformError < StandardError
|
|
10
23
|
attr_reader :code
|
|
11
24
|
attr_accessor :segment, :field
|
|
@@ -43,9 +56,20 @@ module Odin
|
|
|
43
56
|
end
|
|
44
57
|
end
|
|
45
58
|
|
|
59
|
+
# Execution guard abort (fuel, timeout, or depth). Not downgraded by the
|
|
60
|
+
# onError policy; the execute boundary surfaces it as a failed result.
|
|
61
|
+
class TransformAbortError < StandardError
|
|
62
|
+
attr_reader :transform_error
|
|
63
|
+
|
|
64
|
+
def initialize(transform_error)
|
|
65
|
+
@transform_error = transform_error
|
|
66
|
+
super(transform_error.message)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
46
70
|
# ── Transform Error Codes ──
|
|
47
|
-
# T001-
|
|
48
|
-
#
|
|
71
|
+
# T001-T018 are reserved for core transform errors.
|
|
72
|
+
# Later codes are for implementation-specific errors.
|
|
49
73
|
module ErrorCodes
|
|
50
74
|
T001_UNKNOWN_VERB = "T001"
|
|
51
75
|
T002_INVALID_VERB_ARGS = "T002"
|
|
@@ -60,6 +84,33 @@ module Odin
|
|
|
60
84
|
T011_INCOMPATIBLE_CONVERSION = "T011"
|
|
61
85
|
T012_DANGLING_BRANCH = "T012"
|
|
62
86
|
T014_NESTED_INTERPOLATION = "T014"
|
|
87
|
+
T016_TRANSFORM_BUDGET_EXCEEDED = "T016"
|
|
88
|
+
T017_TRANSFORM_TIMEOUT_EXCEEDED = "T017"
|
|
89
|
+
T018_EXPRESSION_DEPTH_EXCEEDED = "T018"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Create a T016 Transform Budget Exceeded error.
|
|
93
|
+
def self.budget_exceeded_error(limit)
|
|
94
|
+
TransformError.new(
|
|
95
|
+
"Transform fuel budget exceeded (limit #{limit})",
|
|
96
|
+
code: ErrorCodes::T016_TRANSFORM_BUDGET_EXCEEDED
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Create a T017 Transform Timeout Exceeded error.
|
|
101
|
+
def self.timeout_exceeded_error(limit_ms)
|
|
102
|
+
TransformError.new(
|
|
103
|
+
"Transform timeout exceeded (limit #{limit_ms}ms)",
|
|
104
|
+
code: ErrorCodes::T017_TRANSFORM_TIMEOUT_EXCEEDED
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Create a T018 Expression Depth Exceeded error.
|
|
109
|
+
def self.expression_depth_exceeded_error(limit)
|
|
110
|
+
TransformError.new(
|
|
111
|
+
"Expression evaluation depth exceeded (limit #{limit})",
|
|
112
|
+
code: ErrorCodes::T018_EXPRESSION_DEPTH_EXCEEDED
|
|
113
|
+
)
|
|
63
114
|
end
|
|
64
115
|
|
|
65
116
|
# Create a T014 Nested Interpolation error.
|
|
@@ -132,9 +183,24 @@ module Odin
|
|
|
132
183
|
|
|
133
184
|
def initialize
|
|
134
185
|
@verb_registry = build_verb_registry
|
|
186
|
+
reset_guard!
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Reset execution-guard state for a single execute call. Fuel and timeout
|
|
190
|
+
# charge only when their cap is set (> 0); depth uses its standing default.
|
|
191
|
+
def reset_guard!
|
|
192
|
+
@fuel_cap = Utils::SecurityLimits.max_transform_fuel
|
|
193
|
+
@timeout_ms = Utils::SecurityLimits.transform_timeout_ms
|
|
194
|
+
@max_expr_depth = Utils::SecurityLimits.max_expression_depth
|
|
195
|
+
@fuel_used = 0
|
|
196
|
+
@expr_depth = 0
|
|
197
|
+
@ops_since_clock = 0
|
|
198
|
+
@start_time = @timeout_ms.positive? ? monotonic_ms : 0
|
|
135
199
|
end
|
|
136
200
|
|
|
137
201
|
def execute(transform_def, source_data, import_resolver: nil)
|
|
202
|
+
reset_guard!
|
|
203
|
+
|
|
138
204
|
# Merge imported tables, constants, accumulators, and segments.
|
|
139
205
|
if import_resolver && transform_def.imports && !transform_def.imports.empty?
|
|
140
206
|
resolve_imports(transform_def, import_resolver)
|
|
@@ -159,23 +225,27 @@ module Odin
|
|
|
159
225
|
|
|
160
226
|
# 3. Process segments (multi-pass support)
|
|
161
227
|
output = {}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
228
|
+
begin
|
|
229
|
+
passes = transform_def.passes
|
|
230
|
+
if passes.empty?
|
|
231
|
+
# Single implicit pass
|
|
232
|
+
process_segment_list(transform_def.segments, source, context, output)
|
|
233
|
+
else
|
|
234
|
+
# Multi-pass: explicit passes first, then pass-0 (implicit)
|
|
235
|
+
all_passes = passes.include?(0) ? passes : passes + [0]
|
|
236
|
+
first_pass = true
|
|
237
|
+
all_passes.each do |pass_num|
|
|
238
|
+
unless first_pass
|
|
239
|
+
reset_non_persist_accumulators(context, transform_def.accumulators)
|
|
240
|
+
end
|
|
241
|
+
first_pass = false
|
|
175
242
|
|
|
176
|
-
|
|
177
|
-
|
|
243
|
+
pass_segments = transform_def.segments.select { |s| (s.pass || 0) == pass_num }
|
|
244
|
+
process_segment_list(pass_segments, source, context, output)
|
|
245
|
+
end
|
|
178
246
|
end
|
|
247
|
+
rescue TransformAbortError => e
|
|
248
|
+
return abort_result(e, output, context)
|
|
179
249
|
end
|
|
180
250
|
|
|
181
251
|
# 4. Apply confidential enforcement
|
|
@@ -229,44 +299,48 @@ module Odin
|
|
|
229
299
|
end
|
|
230
300
|
|
|
231
301
|
# Process each record/line
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
302
|
+
begin
|
|
303
|
+
lines = raw_input.split(/[\r\n]+/)
|
|
304
|
+
lines.each do |line|
|
|
305
|
+
next if line.strip.empty?
|
|
235
306
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
307
|
+
disc_value = extract_discriminator_value(line, disc, delimiter)
|
|
308
|
+
segment = segment_map[disc_value]
|
|
309
|
+
next unless segment
|
|
239
310
|
|
|
240
|
-
|
|
241
|
-
|
|
311
|
+
record_source = parse_record(line, source_format, delimiter)
|
|
312
|
+
record_output = {}
|
|
242
313
|
|
|
243
|
-
|
|
244
|
-
|
|
314
|
+
# Set the record as the current source for path resolution
|
|
315
|
+
context.source = record_source
|
|
245
316
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
317
|
+
# Process field mappings
|
|
318
|
+
segment.field_mappings.each do |mapping|
|
|
319
|
+
process_mapping(mapping, record_source, context, record_output)
|
|
320
|
+
end
|
|
250
321
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
322
|
+
# Process children
|
|
323
|
+
segment.children.each do |child|
|
|
324
|
+
process_segment(child, record_source, context, record_output)
|
|
325
|
+
end
|
|
255
326
|
|
|
256
|
-
|
|
257
|
-
|
|
327
|
+
# Merge into output
|
|
328
|
+
seg_name = segment.name
|
|
258
329
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
else
|
|
263
|
-
# Merge fields into existing segment object
|
|
264
|
-
if output[seg_name].is_a?(Hash)
|
|
265
|
-
record_output.each { |k, v| output[seg_name][k] = v }
|
|
330
|
+
if segment.is_array
|
|
331
|
+
array_accumulators[seg_name] ||= []
|
|
332
|
+
array_accumulators[seg_name] << record_output
|
|
266
333
|
else
|
|
267
|
-
|
|
334
|
+
# Merge fields into existing segment object
|
|
335
|
+
if output[seg_name].is_a?(Hash)
|
|
336
|
+
record_output.each { |k, v| output[seg_name][k] = v }
|
|
337
|
+
else
|
|
338
|
+
output[seg_name] = record_output
|
|
339
|
+
end
|
|
268
340
|
end
|
|
269
341
|
end
|
|
342
|
+
rescue TransformAbortError => e
|
|
343
|
+
return abort_result(e, output, context)
|
|
270
344
|
end
|
|
271
345
|
|
|
272
346
|
# Merge array accumulators into output in segment order
|
|
@@ -391,9 +465,84 @@ module Odin
|
|
|
391
465
|
end
|
|
392
466
|
end
|
|
393
467
|
|
|
468
|
+
# ── Execution Guard ──
|
|
469
|
+
|
|
470
|
+
# Surface a guard abort as a failed result; the abort is never raised past
|
|
471
|
+
# the execute boundary. The guard error joins context.errors so the result
|
|
472
|
+
# reports failure.
|
|
473
|
+
def abort_result(err, output, context)
|
|
474
|
+
context.errors << err.transform_error
|
|
475
|
+
plain_output = deep_to_ruby(output)
|
|
476
|
+
TransformResult.new(output: plain_output, formatted: "", errors: context.errors, warnings: context.warnings)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Monotonic clock in milliseconds for the wall-clock guard.
|
|
480
|
+
def monotonic_ms
|
|
481
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Charge fuel and, at a coarse interval, the wall clock. Both are no-ops
|
|
485
|
+
# unless their cap is set (> 0), so unbounded transforms pay nothing.
|
|
486
|
+
def charge(units)
|
|
487
|
+
if @fuel_cap.positive?
|
|
488
|
+
@fuel_used += units
|
|
489
|
+
raise TransformAbortError.new(self.class.budget_exceeded_error(@fuel_cap)) if @fuel_used > @fuel_cap
|
|
490
|
+
end
|
|
491
|
+
return unless @timeout_ms.positive?
|
|
492
|
+
|
|
493
|
+
@ops_since_clock += units
|
|
494
|
+
return if @ops_since_clock < CLOCK_CHECK_INTERVAL
|
|
495
|
+
|
|
496
|
+
@ops_since_clock = 0
|
|
497
|
+
return unless monotonic_ms - @start_time > @timeout_ms
|
|
498
|
+
|
|
499
|
+
raise TransformAbortError.new(self.class.timeout_exceeded_error(@timeout_ms))
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Charge width for a verb doing O(n)/O(n log n) work over an array argument,
|
|
503
|
+
# so large-array work cannot escape the budget at a flat unit. Runs when
|
|
504
|
+
# fuel OR timeout is set, so a timeout-only host can interrupt wide verbs.
|
|
505
|
+
def charge_verb_width(verb, args)
|
|
506
|
+
return if @fuel_cap <= 0 && @timeout_ms <= 0
|
|
507
|
+
|
|
508
|
+
n = first_array_width(args)
|
|
509
|
+
return if n <= 0
|
|
510
|
+
|
|
511
|
+
if SORT_VERBS.include?(verb)
|
|
512
|
+
charge(n * Math.log2([n, 2].max).ceil)
|
|
513
|
+
elsif WIDTH_VERBS.include?(verb)
|
|
514
|
+
charge(n)
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Width of the first array-typed argument, or 0 if none.
|
|
519
|
+
def first_array_width(args)
|
|
520
|
+
args.each do |arg|
|
|
521
|
+
return arg.value.length if arg.is_a?(Types::DynValue) && arg.array?
|
|
522
|
+
end
|
|
523
|
+
0
|
|
524
|
+
end
|
|
525
|
+
|
|
394
526
|
# ── Expression Evaluation ──
|
|
395
527
|
|
|
528
|
+
# Guard boundary: enforce depth, charge one base unit of fuel, and batch the
|
|
529
|
+
# wall-clock check before delegating to the evaluator. All evaluation paths
|
|
530
|
+
# funnel through here, so charging stays concentrated at this single point.
|
|
396
531
|
def evaluate(expr, context)
|
|
532
|
+
@expr_depth += 1
|
|
533
|
+
if @expr_depth > @max_expr_depth
|
|
534
|
+
@expr_depth -= 1
|
|
535
|
+
raise TransformAbortError.new(self.class.expression_depth_exceeded_error(@max_expr_depth))
|
|
536
|
+
end
|
|
537
|
+
charge(1)
|
|
538
|
+
begin
|
|
539
|
+
evaluate_inner(expr, context)
|
|
540
|
+
ensure
|
|
541
|
+
@expr_depth -= 1
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def evaluate_inner(expr, context)
|
|
397
546
|
case expr
|
|
398
547
|
when LiteralExpr
|
|
399
548
|
val = expr.value
|
|
@@ -1111,6 +1260,8 @@ module Odin
|
|
|
1111
1260
|
if target.start_with?("_")
|
|
1112
1261
|
begin
|
|
1113
1262
|
evaluate(mapping.expression, context)
|
|
1263
|
+
rescue TransformAbortError
|
|
1264
|
+
raise
|
|
1114
1265
|
rescue StandardError => e
|
|
1115
1266
|
context.errors << TransformError.new(e.message)
|
|
1116
1267
|
end
|
|
@@ -1227,6 +1378,9 @@ module Odin
|
|
|
1227
1378
|
# Store DynValue directly to preserve type information (date, timestamp, etc.)
|
|
1228
1379
|
dv_val = val.is_a?(Types::DynValue) ? val : Types::DynValue.from_ruby(val)
|
|
1229
1380
|
set_path(output, target, dv_val)
|
|
1381
|
+
rescue TransformAbortError
|
|
1382
|
+
# Guard aborts are not downgraded by onError.
|
|
1383
|
+
raise
|
|
1230
1384
|
rescue CodedTransformError => e
|
|
1231
1385
|
# Coded errors carry a stable T-code; preserve it under fail/warn.
|
|
1232
1386
|
coded = e.transform_error
|
|
@@ -1555,6 +1709,7 @@ module Odin
|
|
|
1555
1709
|
end
|
|
1556
1710
|
val
|
|
1557
1711
|
end
|
|
1712
|
+
charge_verb_width(verb_name, evaluated_args)
|
|
1558
1713
|
|
|
1559
1714
|
# Special handling for accumulate/set
|
|
1560
1715
|
case verb_name
|
|
@@ -1589,6 +1744,7 @@ module Odin
|
|
|
1589
1744
|
|
|
1590
1745
|
# Eager evaluation for all other verbs
|
|
1591
1746
|
evaluated_args = args.map { |arg| evaluate(arg, context) }
|
|
1747
|
+
charge_verb_width(verb_name, evaluated_args)
|
|
1592
1748
|
|
|
1593
1749
|
# Special handling for accumulate/set
|
|
1594
1750
|
case verb_name
|
data/lib/odin/types/schema.rb
CHANGED
|
@@ -154,11 +154,12 @@ module Odin
|
|
|
154
154
|
# Schema type definition (named object structure)
|
|
155
155
|
class SchemaType
|
|
156
156
|
attr_reader :name, :fields, :namespace, :composition,
|
|
157
|
-
:base_type, :constraints, :intersection_types, :parent_types
|
|
157
|
+
:base_type, :constraints, :intersection_types, :parent_types,
|
|
158
|
+
:arrays
|
|
158
159
|
|
|
159
160
|
def initialize(name:, fields: {}, namespace: nil, composition: nil,
|
|
160
161
|
base_type: nil, constraints: nil, intersection_types: nil,
|
|
161
|
-
parent_types: nil)
|
|
162
|
+
parent_types: nil, arrays: {})
|
|
162
163
|
@name = name.freeze
|
|
163
164
|
@fields = fields.freeze
|
|
164
165
|
@namespace = namespace&.freeze
|
|
@@ -167,22 +168,27 @@ module Odin
|
|
|
167
168
|
@constraints = (constraints || {}).freeze
|
|
168
169
|
@intersection_types = intersection_types&.freeze
|
|
169
170
|
@parent_types = parent_types&.freeze
|
|
171
|
+
# Array-of-object entries declared inside the type, keyed by array name.
|
|
172
|
+
@arrays = (arrays || {}).freeze
|
|
170
173
|
freeze
|
|
171
174
|
end
|
|
172
175
|
end
|
|
173
176
|
|
|
174
177
|
# Schema array definition
|
|
175
178
|
class SchemaArray
|
|
176
|
-
attr_reader :path, :item_fields, :min_items, :max_items, :unique, :columns
|
|
179
|
+
attr_reader :path, :item_fields, :min_items, :max_items, :unique, :columns,
|
|
180
|
+
:item_type_ref
|
|
177
181
|
|
|
178
182
|
def initialize(path:, item_fields: {}, min_items: nil, max_items: nil,
|
|
179
|
-
unique: false, columns: nil)
|
|
183
|
+
unique: false, columns: nil, item_type_ref: nil)
|
|
180
184
|
@path = path.freeze
|
|
181
185
|
@item_fields = item_fields.freeze
|
|
182
186
|
@min_items = min_items
|
|
183
187
|
@max_items = max_items
|
|
184
188
|
@unique = unique
|
|
185
189
|
@columns = columns&.freeze
|
|
190
|
+
# For an `arr[] = @type` declaration, the entry fields come from this type.
|
|
191
|
+
@item_type_ref = item_type_ref&.freeze
|
|
186
192
|
freeze
|
|
187
193
|
end
|
|
188
194
|
end
|
|
@@ -12,6 +12,32 @@ module Odin
|
|
|
12
12
|
MAX_RECORDS = 100_000
|
|
13
13
|
MAX_ASSIGNMENTS = 100_000
|
|
14
14
|
MAX_REGEX_PATTERN_LENGTH = 500
|
|
15
|
+
MAX_EXPRESSION_DEPTH = 32
|
|
16
|
+
|
|
17
|
+
# Read an integer limit from ODIN_<name>, falling back to default.
|
|
18
|
+
def self.env_int(name, default)
|
|
19
|
+
raw = ENV["ODIN_#{name}"]
|
|
20
|
+
return default if raw.nil? || raw.strip.empty?
|
|
21
|
+
|
|
22
|
+
Integer(raw)
|
|
23
|
+
rescue ArgumentError
|
|
24
|
+
default
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Transform fuel budget; 0 = unbounded.
|
|
28
|
+
def self.max_transform_fuel
|
|
29
|
+
env_int("MAX_TRANSFORM_FUEL", 0)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Transform wall-clock timeout in milliseconds; 0 = unbounded.
|
|
33
|
+
def self.transform_timeout_ms
|
|
34
|
+
env_int("TRANSFORM_TIMEOUT_MS", 0)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Expression evaluation depth cap; standing default when unset.
|
|
38
|
+
def self.max_expression_depth
|
|
39
|
+
env_int("MAX_EXPRESSION_DEPTH", MAX_EXPRESSION_DEPTH)
|
|
40
|
+
end
|
|
15
41
|
end
|
|
16
42
|
end
|
|
17
43
|
end
|
|
@@ -73,6 +73,8 @@ module Odin
|
|
|
73
73
|
end
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
+
extract_type_arrays
|
|
77
|
+
|
|
76
78
|
Types::OdinSchema.new(
|
|
77
79
|
metadata: @metadata,
|
|
78
80
|
types: @types,
|
|
@@ -83,6 +85,62 @@ module Odin
|
|
|
83
85
|
)
|
|
84
86
|
end
|
|
85
87
|
|
|
88
|
+
# Pull array-of-object entry fields out of each type's flat field map. A key
|
|
89
|
+
# `arr[]` (whose value is a type reference) becomes an array with an entry
|
|
90
|
+
# type ref; keys `arr[].field` become an array whose item fields are those
|
|
91
|
+
# fields. Matching keys are removed from the type's fields.
|
|
92
|
+
def extract_type_arrays
|
|
93
|
+
@types.each do |type_name, type|
|
|
94
|
+
builders = {}
|
|
95
|
+
remaining_fields = {}
|
|
96
|
+
|
|
97
|
+
type.fields.each do |key, field|
|
|
98
|
+
m = key.match(/\A([^\[\]]+)\[\](?:\.(.+))?\z/)
|
|
99
|
+
unless m
|
|
100
|
+
remaining_fields[key] = field
|
|
101
|
+
next
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
arr_name = m[1]
|
|
105
|
+
item_path = m[2]
|
|
106
|
+
b = builders[arr_name] ||= { item_fields: {}, item_type_ref: nil }
|
|
107
|
+
|
|
108
|
+
if item_path.nil?
|
|
109
|
+
# arr[] = @type -> entry fields come from the referenced type
|
|
110
|
+
ref = field.type_ref
|
|
111
|
+
b[:item_type_ref] = ref if ref && ref != "array"
|
|
112
|
+
else
|
|
113
|
+
# arr[].item_path = field
|
|
114
|
+
b[:item_fields][item_path] = field
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
next if builders.empty?
|
|
119
|
+
|
|
120
|
+
arrays = {}
|
|
121
|
+
builders.each do |arr_name, b|
|
|
122
|
+
arrays[arr_name] = Types::SchemaArray.new(
|
|
123
|
+
path: arr_name,
|
|
124
|
+
item_fields: b[:item_fields],
|
|
125
|
+
unique: false,
|
|
126
|
+
item_type_ref: b[:item_type_ref]
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
@types[type_name] = Types::SchemaType.new(
|
|
131
|
+
name: type.name,
|
|
132
|
+
fields: remaining_fields,
|
|
133
|
+
namespace: type.namespace,
|
|
134
|
+
composition: type.composition,
|
|
135
|
+
base_type: type.base_type,
|
|
136
|
+
constraints: type.constraints,
|
|
137
|
+
intersection_types: type.intersection_types,
|
|
138
|
+
parent_types: type.parent_types,
|
|
139
|
+
arrays: arrays
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
86
144
|
private
|
|
87
145
|
|
|
88
146
|
def parse_import(line)
|
|
@@ -453,6 +511,8 @@ module Odin
|
|
|
453
511
|
end
|
|
454
512
|
|
|
455
513
|
field_name = left
|
|
514
|
+
# Keep the raw left side so a type can retain its `[]` array markers.
|
|
515
|
+
raw_field_name = left
|
|
456
516
|
# Strip array indicator from field names
|
|
457
517
|
is_array_field = field_name.end_with?("[]")
|
|
458
518
|
field_name = field_name[0...-2] if is_array_field
|
|
@@ -481,15 +541,20 @@ module Odin
|
|
|
481
541
|
# Parse the field spec from the right side
|
|
482
542
|
schema_field = parse_field_spec(full_path, right)
|
|
483
543
|
|
|
484
|
-
# Override type_ref for array fields
|
|
544
|
+
# Override type_ref for array fields. Inside a type, keep an entry type
|
|
545
|
+
# ref (arr[] = @entry) so it can be extracted into the type's arrays map.
|
|
485
546
|
if is_array_field
|
|
547
|
+
array_type_ref = "array"
|
|
548
|
+
if @current_header_kind == :type && schema_field.type_ref
|
|
549
|
+
array_type_ref = schema_field.type_ref
|
|
550
|
+
end
|
|
486
551
|
schema_field = Types::SchemaField.new(
|
|
487
552
|
name: schema_field.name, field_type: schema_field.field_type,
|
|
488
553
|
required: schema_field.required, nullable: schema_field.nullable,
|
|
489
554
|
redacted: schema_field.redacted, deprecated: schema_field.deprecated,
|
|
490
555
|
constraints: schema_field.constraints, conditionals: schema_field.conditionals,
|
|
491
556
|
computed: schema_field.computed, immutable: schema_field.immutable,
|
|
492
|
-
type_ref:
|
|
557
|
+
type_ref: array_type_ref
|
|
493
558
|
)
|
|
494
559
|
end
|
|
495
560
|
|
|
@@ -499,8 +564,10 @@ module Odin
|
|
|
499
564
|
if @current_type_name && @types[@current_type_name]
|
|
500
565
|
old_type = @types[@current_type_name]
|
|
501
566
|
new_fields = old_type.fields.dup
|
|
567
|
+
# Keep `[]` markers so array-of-object entries can be extracted later.
|
|
568
|
+
key_name = raw_field_name
|
|
502
569
|
# Relative sub-section ({.term}) prefixes the field key (e.g. term.effective)
|
|
503
|
-
type_key = @current_type_sub_path.empty? ?
|
|
570
|
+
type_key = @current_type_sub_path.empty? ? key_name : "#{@current_type_sub_path}.#{key_name}"
|
|
504
571
|
new_fields[type_key] = schema_field
|
|
505
572
|
@types[@current_type_name] = Types::SchemaType.new(
|
|
506
573
|
name: old_type.name,
|
|
@@ -201,10 +201,14 @@ module Odin
|
|
|
201
201
|
# A field typed @SomeType enforces that type's required fields under the field
|
|
202
202
|
# path, but only when the sub-object is present (or the field itself required).
|
|
203
203
|
def check_field_typeref_required_fields
|
|
204
|
-
@schema.fields.each do |
|
|
205
|
-
next if
|
|
204
|
+
@schema.fields.each do |raw_path, field|
|
|
205
|
+
next if raw_path.end_with?("._composition")
|
|
206
206
|
next unless field.type_ref
|
|
207
207
|
|
|
208
|
+
# A field under the root {} header keys as ".field"; the document path
|
|
209
|
+
# has no leading dot.
|
|
210
|
+
path = raw_path.sub(/\A\./, "")
|
|
211
|
+
|
|
208
212
|
member_names(field.type_ref).each do |member|
|
|
209
213
|
type = lookup_type(member)
|
|
210
214
|
next unless type
|
|
@@ -226,8 +230,66 @@ module Odin
|
|
|
226
230
|
expected: "present"
|
|
227
231
|
)
|
|
228
232
|
end
|
|
233
|
+
|
|
234
|
+
check_type_array_required_fields(path, type) if present
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Expand a referenced type's array-of-object entries under each present
|
|
240
|
+
# index so entry-level required markers fire only when the entry is present.
|
|
241
|
+
def check_type_array_required_fields(path, type)
|
|
242
|
+
return if type.arrays.nil? || type.arrays.empty?
|
|
243
|
+
|
|
244
|
+
type.arrays.each do |arr_name, schema_array|
|
|
245
|
+
item_fields = resolve_array_item_fields(schema_array)
|
|
246
|
+
next if item_fields.empty?
|
|
247
|
+
|
|
248
|
+
array_path = "#{path}.#{arr_name}"
|
|
249
|
+
present_array_indices(array_path).each do |idx|
|
|
250
|
+
item_fields.each do |fname, tfield|
|
|
251
|
+
next if fname == "_composition"
|
|
252
|
+
next unless tfield.required
|
|
253
|
+
next if tfield.computed
|
|
254
|
+
|
|
255
|
+
full = "#{array_path}[#{idx}].#{fname}"
|
|
256
|
+
next if doc_has_value?(full)
|
|
257
|
+
|
|
258
|
+
add_error(
|
|
259
|
+
code: Errors::ValidationErrorCode::REQUIRED_FIELD_MISSING,
|
|
260
|
+
path: full,
|
|
261
|
+
message: "Required field '#{full}' is missing",
|
|
262
|
+
expected: "present"
|
|
263
|
+
)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Entry fields for an array: inline item fields, or the fields of the type
|
|
270
|
+
# named by item_type_ref (for the arr[] = @type form).
|
|
271
|
+
def resolve_array_item_fields(schema_array)
|
|
272
|
+
return schema_array.item_fields unless schema_array.item_fields.empty?
|
|
273
|
+
|
|
274
|
+
if schema_array.item_type_ref
|
|
275
|
+
member_names(schema_array.item_type_ref).each do |member|
|
|
276
|
+
type = lookup_type(member)
|
|
277
|
+
return type.fields if type
|
|
229
278
|
end
|
|
230
279
|
end
|
|
280
|
+
schema_array.item_fields
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Distinct indices present in the document under the given array path.
|
|
284
|
+
def present_array_indices(array_path)
|
|
285
|
+
seen = {}
|
|
286
|
+
prefix = "#{array_path}["
|
|
287
|
+
@doc.paths.each do |p|
|
|
288
|
+
next unless p.start_with?(prefix)
|
|
289
|
+
m = p[array_path.length..].match(/\A\[(\d+)\]/)
|
|
290
|
+
seen[m[1].to_i] = true if m
|
|
291
|
+
end
|
|
292
|
+
seen.keys.sort
|
|
231
293
|
end
|
|
232
294
|
|
|
233
295
|
# Split an &-joined type_ref (e.g. "@hasName&hasAge") into bare member names.
|
data/lib/odin/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: odin-foundation
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.2.
|
|
4
|
+
version: 1.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ODIN Foundation
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-07-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bigdecimal
|