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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a42b39245e4ab2f8821e0e12100d16418316b22758071d7acf28db0b7a754f20
4
- data.tar.gz: 99f7bc6624af35e90864f3ccc1239eb2c3c8ab1cd1a50bcd5daffdca36655e98
3
+ metadata.gz: 7941332bc111a6e015c111bdacc0112a4de7158790df8dc5795846261d63c90f
4
+ data.tar.gz: 2925314fb92f73c7a2f83cfde86a0975a619c9ac9f88186aeffbcfdb29f2c587
5
5
  SHA512:
6
- metadata.gz: 00027a7160bb7e6605bdbb3e77e2ea54232147902027cdc98d3dccc6b6ce06635563d4ca84698a12a3325d29b1310e88355911dcd0c75099ad13ba35eef84a64
7
- data.tar.gz: 4ad5366860defb9e19affa8f666eccd4d3d6bd3a9cba539131bb189987c9b878a6217cb60260bb58ff03660e71d4bb3865eb77fed4bb4ff47a3e01124f4621fb
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-T010 are reserved for core transform errors.
48
- # T011+ are for implementation-specific errors.
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
- passes = transform_def.passes
163
- if passes.empty?
164
- # Single implicit pass
165
- process_segment_list(transform_def.segments, source, context, output)
166
- else
167
- # Multi-pass: explicit passes first, then pass-0 (implicit)
168
- all_passes = passes.include?(0) ? passes : passes + [0]
169
- first_pass = true
170
- all_passes.each do |pass_num|
171
- unless first_pass
172
- reset_non_persist_accumulators(context, transform_def.accumulators)
173
- end
174
- first_pass = false
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
- pass_segments = transform_def.segments.select { |s| (s.pass || 0) == pass_num }
177
- process_segment_list(pass_segments, source, context, output)
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
- lines = raw_input.split(/[\r\n]+/)
233
- lines.each do |line|
234
- next if line.strip.empty?
302
+ begin
303
+ lines = raw_input.split(/[\r\n]+/)
304
+ lines.each do |line|
305
+ next if line.strip.empty?
235
306
 
236
- disc_value = extract_discriminator_value(line, disc, delimiter)
237
- segment = segment_map[disc_value]
238
- next unless segment
307
+ disc_value = extract_discriminator_value(line, disc, delimiter)
308
+ segment = segment_map[disc_value]
309
+ next unless segment
239
310
 
240
- record_source = parse_record(line, source_format, delimiter)
241
- record_output = {}
311
+ record_source = parse_record(line, source_format, delimiter)
312
+ record_output = {}
242
313
 
243
- # Set the record as the current source for path resolution
244
- context.source = record_source
314
+ # Set the record as the current source for path resolution
315
+ context.source = record_source
245
316
 
246
- # Process field mappings
247
- segment.field_mappings.each do |mapping|
248
- process_mapping(mapping, record_source, context, record_output)
249
- end
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
- # Process children
252
- segment.children.each do |child|
253
- process_segment(child, record_source, context, record_output)
254
- end
322
+ # Process children
323
+ segment.children.each do |child|
324
+ process_segment(child, record_source, context, record_output)
325
+ end
255
326
 
256
- # Merge into output
257
- seg_name = segment.name
327
+ # Merge into output
328
+ seg_name = segment.name
258
329
 
259
- if segment.is_array
260
- array_accumulators[seg_name] ||= []
261
- array_accumulators[seg_name] << record_output
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
- output[seg_name] = record_output
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
@@ -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: "array"
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? ? field_name : "#{@current_type_sub_path}.#{field_name}"
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 |path, field|
205
- next if path.end_with?("._composition")
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Odin
4
- VERSION = "1.2.0"
4
+ VERSION = "1.2.2"
5
5
  end
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.0
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-06-03 00:00:00.000000000 Z
11
+ date: 2026-07-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal