odin-foundation 1.0.4 → 1.2.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 +4 -4
- data/lib/odin/export.rb +1 -1
- data/lib/odin/forms/accessibility.rb +95 -0
- data/lib/odin/forms/css.rb +42 -0
- data/lib/odin/forms/parser.rb +719 -0
- data/lib/odin/forms/renderer.rb +534 -0
- data/lib/odin/forms/types.rb +102 -0
- data/lib/odin/forms/units.rb +41 -0
- data/lib/odin/forms.rb +55 -0
- data/lib/odin/parsing/parser.rb +25 -1
- data/lib/odin/parsing/tokenizer.rb +38 -20
- data/lib/odin/parsing/value_parser.rb +65 -7
- data/lib/odin/resolver/import_resolver.rb +40 -12
- data/lib/odin/resolver/type_registry.rb +54 -0
- data/lib/odin/transform/format_exporters.rb +88 -48
- data/lib/odin/transform/source_parsers.rb +2 -2
- data/lib/odin/transform/transform_engine.rb +1388 -246
- data/lib/odin/transform/transform_expr.rb +222 -0
- data/lib/odin/transform/transform_parser.rb +377 -19
- data/lib/odin/transform/transform_types.rb +23 -7
- data/lib/odin/transform/verb_context.rb +19 -1
- data/lib/odin/transform/verbs/aggregation_verbs.rb +2 -1
- data/lib/odin/transform/verbs/collection_verbs.rb +164 -89
- data/lib/odin/transform/verbs/datetime_verbs.rb +86 -15
- data/lib/odin/transform/verbs/extra_verbs.rb +613 -0
- data/lib/odin/transform/verbs/financial_verbs.rb +116 -27
- data/lib/odin/transform/verbs/geo_verbs.rb +7 -0
- data/lib/odin/transform/verbs/numeric_verbs.rb +85 -64
- data/lib/odin/transform/verbs/object_verbs.rb +31 -26
- data/lib/odin/types/errors.rb +9 -1
- data/lib/odin/types/schema.rb +20 -3
- data/lib/odin/utils/format_utils.rb +31 -15
- data/lib/odin/validation/format_validators.rb +7 -9
- data/lib/odin/validation/invariant_evaluator.rb +410 -0
- data/lib/odin/validation/schema_definition_validator.rb +357 -0
- data/lib/odin/validation/schema_parser.rb +234 -21
- data/lib/odin/validation/validator.rb +281 -123
- data/lib/odin/version.rb +1 -1
- data/lib/odin.rb +100 -4
- metadata +14 -2
data/lib/odin/forms.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "forms/types"
|
|
4
|
+
require_relative "forms/units"
|
|
5
|
+
require_relative "forms/accessibility"
|
|
6
|
+
require_relative "forms/css"
|
|
7
|
+
require_relative "forms/parser"
|
|
8
|
+
require_relative "forms/renderer"
|
|
9
|
+
|
|
10
|
+
module Odin
|
|
11
|
+
# ODIN Forms — parse and render declarative form definitions.
|
|
12
|
+
module Forms
|
|
13
|
+
class << self
|
|
14
|
+
# Parse ODIN forms text into a typed OdinForm.
|
|
15
|
+
def parse_form(text)
|
|
16
|
+
text = text.encode("UTF-8") if text.is_a?(String) && text.encoding != Encoding::UTF_8
|
|
17
|
+
Parser.new.parse(text)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Render an OdinForm to a complete HTML string. +data+ is an optional
|
|
21
|
+
# OdinDocument bound to field values; +options+ accepts :className.
|
|
22
|
+
def render_form(form, data = nil, options = nil)
|
|
23
|
+
Renderer.new.render(form, data, options)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def generate_form_css
|
|
27
|
+
Css.generate_form_css
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def generate_print_css
|
|
31
|
+
Css.generate_print_css
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_pixels(value, unit)
|
|
35
|
+
Units.to_pixels(value, unit)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def from_pixels(px, unit)
|
|
39
|
+
Units.from_pixels(px, unit)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def generate_field_id(element_name, page_index)
|
|
43
|
+
Accessibility.generate_field_id(element_name, page_index)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def contrast_ratio(fg, bg)
|
|
47
|
+
Accessibility.contrast_ratio(fg, bg)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def meets_contrast_aa(fg, bg, font_size)
|
|
51
|
+
Accessibility.meets_contrast_aa(fg, bg, font_size)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/odin/parsing/parser.rb
CHANGED
|
@@ -32,6 +32,7 @@ module Odin
|
|
|
32
32
|
@tabular_columns = []
|
|
33
33
|
@tabular_array_path = ""
|
|
34
34
|
@tabular_row_index = 0
|
|
35
|
+
@pre_tabular_context = nil
|
|
35
36
|
|
|
36
37
|
# Document chaining
|
|
37
38
|
@documents = []
|
|
@@ -51,7 +52,7 @@ module Odin
|
|
|
51
52
|
break
|
|
52
53
|
when TokenType::NEWLINE
|
|
53
54
|
@pos += 1
|
|
54
|
-
# Blank line after {$} metadata exits metadata mode
|
|
55
|
+
# Blank line after {$} metadata exits metadata mode
|
|
55
56
|
if @metadata_mode && @context.empty?
|
|
56
57
|
nt = @tokens[@pos]
|
|
57
58
|
if nt && nt.type == TokenType::NEWLINE
|
|
@@ -279,6 +280,10 @@ module Odin
|
|
|
279
280
|
end
|
|
280
281
|
|
|
281
282
|
def setup_tabular(array_path, columns_str, token)
|
|
283
|
+
# Section context to restore when the tabular block ends and a top-level
|
|
284
|
+
# assignment follows.
|
|
285
|
+
@pre_tabular_context = @context
|
|
286
|
+
|
|
282
287
|
# Relative paths (starting with .) resolve relative to previous context
|
|
283
288
|
# Absolute paths are used as-is (same logic as resolve_header_path)
|
|
284
289
|
resolved_path = if array_path.start_with?(".")
|
|
@@ -334,6 +339,9 @@ module Odin
|
|
|
334
339
|
|
|
335
340
|
def exit_tabular_mode!
|
|
336
341
|
return unless @tabular_mode
|
|
342
|
+
# Restore the pre-tabular section context once data rows have been read, so
|
|
343
|
+
# a following top-level assignment is not nested under the array path.
|
|
344
|
+
@context = @pre_tabular_context if @tabular_row_index.positive? && !@pre_tabular_context.nil?
|
|
337
345
|
@tabular_mode = false
|
|
338
346
|
@tabular_primitive = false
|
|
339
347
|
@tabular_columns = []
|
|
@@ -393,6 +401,22 @@ module Odin
|
|
|
393
401
|
# Track array indices
|
|
394
402
|
track_array_index(full_path, path_token)
|
|
395
403
|
|
|
404
|
+
# Top-level metadata assignment ($.key = value), e.g. from canonical output
|
|
405
|
+
if !@metadata_mode && (full_path.start_with?("$.") || full_path == "$")
|
|
406
|
+
meta_key = full_path.start_with?("$.") ? full_path[2..] : ""
|
|
407
|
+
unless meta_key.empty?
|
|
408
|
+
if @current_metadata.key?(meta_key)
|
|
409
|
+
raise Errors::ParseError.new(
|
|
410
|
+
Errors::ParseErrorCode::DUPLICATE_PATH_ASSIGNMENT,
|
|
411
|
+
path_token.line, path_token.column,
|
|
412
|
+
"Duplicate metadata key: #{meta_key}"
|
|
413
|
+
)
|
|
414
|
+
end
|
|
415
|
+
@current_metadata[meta_key] = value
|
|
416
|
+
end
|
|
417
|
+
return
|
|
418
|
+
end
|
|
419
|
+
|
|
396
420
|
if @metadata_mode
|
|
397
421
|
# Check duplicate in metadata
|
|
398
422
|
if @current_metadata.key?(full_path)
|
|
@@ -196,6 +196,8 @@ module Odin
|
|
|
196
196
|
scan_identifier(line, col)
|
|
197
197
|
when 38 # &
|
|
198
198
|
scan_identifier(line, col)
|
|
199
|
+
when 36 # $
|
|
200
|
+
scan_meta_path(line, col)
|
|
199
201
|
when 91 # [
|
|
200
202
|
scan_array_indexed_path(line, col)
|
|
201
203
|
else
|
|
@@ -470,43 +472,33 @@ module Odin
|
|
|
470
472
|
emit(TokenType::ERROR, "Unterminated string", line, col)
|
|
471
473
|
end
|
|
472
474
|
|
|
475
|
+
# Triple-quoted string. Content is captured verbatim (no escape processing,
|
|
476
|
+
# no newline trimming) and may span newlines; closes at the next """.
|
|
473
477
|
def scan_multiline_string(line, col)
|
|
474
478
|
s = @scanner
|
|
475
|
-
# Skip initial newline after opening """
|
|
476
|
-
if !s.eos?
|
|
477
|
-
byte = s.string.getbyte(s.pos)
|
|
478
|
-
if byte == 10
|
|
479
|
-
s.pos += 1; @line += 1; @col = 1
|
|
480
|
-
elsif byte == 13
|
|
481
|
-
s.pos += 1; @line += 1; @col = 1
|
|
482
|
-
if !s.eos? && s.string.getbyte(s.pos) == 10
|
|
483
|
-
s.pos += 1
|
|
484
|
-
end
|
|
485
|
-
end
|
|
486
|
-
end
|
|
487
|
-
|
|
488
479
|
result = +""
|
|
489
480
|
until s.eos?
|
|
490
481
|
# Check for closing """
|
|
491
482
|
if s.string.getbyte(s.pos) == 34 &&
|
|
492
|
-
s.pos + 2 < s.string.bytesize &&
|
|
493
483
|
s.string.getbyte(s.pos + 1) == 34 &&
|
|
494
484
|
s.string.getbyte(s.pos + 2) == 34
|
|
495
485
|
s.pos += 3; @col += 3
|
|
496
|
-
emit(TokenType::STRING, result, line, col)
|
|
486
|
+
emit(TokenType::STRING, result, line, col, raw: "multiline")
|
|
497
487
|
return
|
|
498
488
|
end
|
|
499
489
|
|
|
500
490
|
byte = s.string.getbyte(s.pos)
|
|
501
|
-
if byte ==
|
|
491
|
+
if byte == 10 # \n
|
|
502
492
|
result << "\n"
|
|
503
493
|
s.pos += 1; @line += 1; @col = 1
|
|
494
|
+
elsif byte == 13 # \r
|
|
495
|
+
s.pos += 1; @line += 1; @col = 1
|
|
504
496
|
if !s.eos? && s.string.getbyte(s.pos) == 10
|
|
497
|
+
result << "\n"
|
|
505
498
|
s.pos += 1
|
|
499
|
+
else
|
|
500
|
+
result << "\r"
|
|
506
501
|
end
|
|
507
|
-
elsif byte == 10 # \n
|
|
508
|
-
result << "\n"
|
|
509
|
-
s.pos += 1; @line += 1; @col = 1
|
|
510
502
|
else
|
|
511
503
|
# Scan non-special chars in bulk
|
|
512
504
|
chunk = s.scan(/[^"\r\n]+/)
|
|
@@ -514,7 +506,7 @@ module Odin
|
|
|
514
506
|
result << chunk
|
|
515
507
|
@col += chunk.length
|
|
516
508
|
else
|
|
517
|
-
# Single quote that isn't part of """
|
|
509
|
+
# Single/double quote that isn't part of closing """
|
|
518
510
|
result << s.string[s.pos]
|
|
519
511
|
s.pos += 1; @col += 1
|
|
520
512
|
end
|
|
@@ -571,8 +563,15 @@ module Odin
|
|
|
571
563
|
return
|
|
572
564
|
end
|
|
573
565
|
|
|
566
|
+
prefix = +""
|
|
567
|
+
if !s.eos? && s.string.getbyte(s.pos) == 36 # @$ meta reference
|
|
568
|
+
prefix << "$"
|
|
569
|
+
s.pos += 1; @col += 1
|
|
570
|
+
end
|
|
571
|
+
|
|
574
572
|
path = s.scan(RE_REF_PATH) || ""
|
|
575
573
|
@col += path.length
|
|
574
|
+
path = prefix + path
|
|
576
575
|
# Normalize leading zeros in array indices: [007] -> [7]
|
|
577
576
|
path = path.gsub(/\[(\d+)\]/) { "[#{$1.to_i}]" }
|
|
578
577
|
emit(TokenType::REFERENCE, path, line, col)
|
|
@@ -712,6 +711,25 @@ module Odin
|
|
|
712
711
|
emit(TokenType::PATH, word, line, col)
|
|
713
712
|
end
|
|
714
713
|
|
|
714
|
+
# Top-level metadata path: $ or $.foo (from canonical output)
|
|
715
|
+
def scan_meta_path(line, col)
|
|
716
|
+
s = @scanner
|
|
717
|
+
word = +"$"
|
|
718
|
+
s.pos += 1; @col += 1 # skip $
|
|
719
|
+
loop do
|
|
720
|
+
if (chunk = s.scan(/[a-zA-Z0-9_.\-]+/))
|
|
721
|
+
word << chunk
|
|
722
|
+
@col += chunk.length
|
|
723
|
+
elsif (idx = s.scan(RE_ARRAY_INDEX))
|
|
724
|
+
word << idx
|
|
725
|
+
@col += idx.length
|
|
726
|
+
else
|
|
727
|
+
break
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
emit(TokenType::PATH, word, line, col)
|
|
731
|
+
end
|
|
732
|
+
|
|
715
733
|
def scan_identifier(line, col)
|
|
716
734
|
s = @scanner
|
|
717
735
|
word = +""
|
|
@@ -64,8 +64,16 @@ module Odin
|
|
|
64
64
|
"Invalid numeric format"
|
|
65
65
|
)
|
|
66
66
|
end
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
fval = Float(raw)
|
|
68
|
+
unless fval == fval.to_i
|
|
69
|
+
raise Errors::ParseError.new(
|
|
70
|
+
Errors::ParseErrorCode::INVALID_TYPE_PREFIX,
|
|
71
|
+
token.line, token.column,
|
|
72
|
+
"Integer (##) value cannot have a fractional part: #{raw}"
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
val = fval.to_i
|
|
76
|
+
# Beyond the 2^53-1 safe integer range, store raw
|
|
69
77
|
safe = val.abs <= 9_007_199_254_740_991
|
|
70
78
|
Types::OdinInteger.new(val, raw: safe && raw.length <= 15 ? nil : raw)
|
|
71
79
|
rescue ArgumentError
|
|
@@ -170,11 +178,8 @@ module Odin
|
|
|
170
178
|
|
|
171
179
|
def parse_timestamp(token)
|
|
172
180
|
raw = token.value
|
|
173
|
-
# Validate
|
|
174
|
-
|
|
175
|
-
if m
|
|
176
|
-
validate_date!("#{m[1]}-#{m[2]}-#{m[3]}", token)
|
|
177
|
-
end
|
|
181
|
+
# Validate date portion, time components, and timezone offset
|
|
182
|
+
validate_timestamp!(raw, token)
|
|
178
183
|
# DateTime.new is much faster than DateTime.parse
|
|
179
184
|
# Try fast path for ISO 8601 timestamps
|
|
180
185
|
dt = fast_parse_timestamp(raw) || DateTime.parse(raw)
|
|
@@ -188,6 +193,7 @@ module Odin
|
|
|
188
193
|
end
|
|
189
194
|
|
|
190
195
|
def parse_time(token)
|
|
196
|
+
validate_time!(token.value, token)
|
|
191
197
|
Types::OdinTime.new(token.value)
|
|
192
198
|
end
|
|
193
199
|
|
|
@@ -269,6 +275,58 @@ module Odin
|
|
|
269
275
|
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
|
270
276
|
end
|
|
271
277
|
|
|
278
|
+
RE_TIMESTAMP_FULL = /\A(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})?\z/.freeze
|
|
279
|
+
RE_TIME_FULL = /\AT(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?\z/.freeze
|
|
280
|
+
|
|
281
|
+
# Validate timestamp date portion, time components, and timezone offset.
|
|
282
|
+
def validate_timestamp!(ts_str, token)
|
|
283
|
+
m = RE_TIMESTAMP_FULL.match(ts_str)
|
|
284
|
+
unless m
|
|
285
|
+
raise_temporal_error(ts_str, token)
|
|
286
|
+
end
|
|
287
|
+
validate_date!(m[1], token)
|
|
288
|
+
validate_time_components!(m[2], m[3], m[4], ts_str, token)
|
|
289
|
+
offset = m[5]
|
|
290
|
+
if offset && offset != "Z"
|
|
291
|
+
off_hour = offset[1, 2].to_i
|
|
292
|
+
off_min = offset[4, 2].to_i
|
|
293
|
+
raise_temporal_error(ts_str, token) if off_hour > 23 || off_min > 59
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Validate time-only value: THH:MM[:SS[.sss]].
|
|
298
|
+
def validate_time!(time_str, token)
|
|
299
|
+
m = RE_TIME_FULL.match(time_str)
|
|
300
|
+
unless m
|
|
301
|
+
raise_temporal_error(time_str, token)
|
|
302
|
+
end
|
|
303
|
+
validate_time_components!(m[1], m[2], m[3], time_str, token)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Validate hour/minute/second bounds. Hour 24 only as end-of-day midnight;
|
|
307
|
+
# second may be 60 (leap second).
|
|
308
|
+
def validate_time_components!(hour_str, min_str, sec_str, raw_str, token)
|
|
309
|
+
hour = hour_str.to_i
|
|
310
|
+
minute = min_str.to_i
|
|
311
|
+
second = sec_str.nil? ? 0 : sec_str.to_i
|
|
312
|
+
|
|
313
|
+
if hour == 24
|
|
314
|
+
raise_temporal_error(raw_str, token) if minute != 0 || second != 0
|
|
315
|
+
elsif hour > 23
|
|
316
|
+
raise_temporal_error(raw_str, token)
|
|
317
|
+
end
|
|
318
|
+
raise_temporal_error(raw_str, token) if minute > 59
|
|
319
|
+
raise_temporal_error(raw_str, token) if second > 60
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def raise_temporal_error(raw_str, token)
|
|
323
|
+
raise Errors::ParseError.new(
|
|
324
|
+
Errors::ParseErrorCode::UNEXPECTED_CHARACTER,
|
|
325
|
+
token.line, token.column,
|
|
326
|
+
"Invalid temporal value: #{raw_str}"
|
|
327
|
+
)
|
|
328
|
+
end
|
|
329
|
+
|
|
272
330
|
def validate_base64!(data, token)
|
|
273
331
|
# Check for invalid characters
|
|
274
332
|
unless data.match?(/\A[A-Za-z0-9+\/]*=*\z/)
|
|
@@ -10,7 +10,8 @@ module Odin
|
|
|
10
10
|
|
|
11
11
|
def initialize(loader: nil)
|
|
12
12
|
@loader = loader || method(:default_loader)
|
|
13
|
-
@
|
|
13
|
+
@active_chain = Set.new # paths currently being resolved (true-cycle detection)
|
|
14
|
+
@schema_cache = {} # parsed schemas keyed by absolute path (diamond reuse)
|
|
14
15
|
@total_loaded = 0
|
|
15
16
|
end
|
|
16
17
|
|
|
@@ -26,6 +27,26 @@ module Odin
|
|
|
26
27
|
flatten(schema, imported_schemas)
|
|
27
28
|
end
|
|
28
29
|
|
|
30
|
+
# Resolve imports and return [flattened_schema, type_registry].
|
|
31
|
+
# base_path is the schema file path; imports resolve relative to its directory.
|
|
32
|
+
# The registry namespaces imported types by alias for @alias.typename lookup.
|
|
33
|
+
def resolve_with_registry(schema, base_path: ".")
|
|
34
|
+
import_dir = File.directory?(base_path) ? base_path : File.dirname(base_path)
|
|
35
|
+
imported_schemas = []
|
|
36
|
+
schema.imports.each do |imp|
|
|
37
|
+
resolve_import(imp, import_dir, imported_schemas, depth: 0)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
registry = TypeRegistry.new
|
|
41
|
+
imported_schemas.each do |entry|
|
|
42
|
+
registry.register_all(entry[:schema].types, entry[:alias_name])
|
|
43
|
+
end
|
|
44
|
+
registry.register_all(schema.types, nil)
|
|
45
|
+
|
|
46
|
+
flattened = schema.imports.empty? ? schema : flatten(schema, imported_schemas)
|
|
47
|
+
[flattened, registry]
|
|
48
|
+
end
|
|
49
|
+
|
|
29
50
|
private
|
|
30
51
|
|
|
31
52
|
def resolve_import(imp, base_path, collected, depth:)
|
|
@@ -45,25 +66,32 @@ module Odin
|
|
|
45
66
|
|
|
46
67
|
abs_path = resolve_path(base_path, imp.path)
|
|
47
68
|
|
|
48
|
-
|
|
69
|
+
# True cycle: path is on the current import chain.
|
|
70
|
+
if @active_chain.include?(abs_path)
|
|
49
71
|
raise Errors::OdinError.new(
|
|
50
72
|
Errors::ValidationErrorCode::CIRCULAR_REFERENCE,
|
|
51
73
|
"Circular import detected: #{abs_path}"
|
|
52
74
|
)
|
|
53
75
|
end
|
|
54
76
|
|
|
55
|
-
@
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
77
|
+
imported_schema = @schema_cache[abs_path]
|
|
78
|
+
unless imported_schema
|
|
79
|
+
@total_loaded += 1
|
|
80
|
+
text = @loader.call(abs_path)
|
|
81
|
+
imported_schema = Validation::SchemaParser.new.parse_schema(text)
|
|
82
|
+
@schema_cache[abs_path] = imported_schema
|
|
83
|
+
end
|
|
60
84
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
85
|
+
@active_chain.add(abs_path)
|
|
86
|
+
begin
|
|
87
|
+
unless imported_schema.imports.empty?
|
|
88
|
+
import_dir = File.dirname(abs_path)
|
|
89
|
+
imported_schema.imports.each do |nested_imp|
|
|
90
|
+
resolve_import(nested_imp, import_dir, collected, depth: depth + 1)
|
|
91
|
+
end
|
|
66
92
|
end
|
|
93
|
+
ensure
|
|
94
|
+
@active_chain.delete(abs_path)
|
|
67
95
|
end
|
|
68
96
|
|
|
69
97
|
collected << { schema: imported_schema, alias_name: imp.alias_name, path: abs_path }
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Odin
|
|
4
|
+
module Resolver
|
|
5
|
+
# Namespaced type registry for import resolution.
|
|
6
|
+
class TypeRegistry
|
|
7
|
+
def initialize
|
|
8
|
+
@local_types = {}
|
|
9
|
+
@namespaces = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Register all types from a schema under an optional namespace (import alias).
|
|
13
|
+
def register_all(types, namespace = nil)
|
|
14
|
+
if namespace
|
|
15
|
+
ns_map = (@namespaces[namespace] ||= {})
|
|
16
|
+
types.each { |name, type_def| ns_map[name] = type_def }
|
|
17
|
+
else
|
|
18
|
+
types.each { |name, type_def| @local_types[name] = type_def }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Look up a type by name: local first, then dot-namespaced "alias.name",
|
|
23
|
+
# then any namespace for an unqualified name.
|
|
24
|
+
def lookup(name)
|
|
25
|
+
return @local_types[name] if @local_types.key?(name)
|
|
26
|
+
|
|
27
|
+
if (dot = name.index("."))
|
|
28
|
+
ns = name[0...dot]
|
|
29
|
+
type_name = name[(dot + 1)..]
|
|
30
|
+
ns_map = @namespaces[ns]
|
|
31
|
+
return ns_map[type_name] if ns_map&.key?(type_name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@namespaces.each_value do |ns_map|
|
|
35
|
+
return ns_map[name] if ns_map.key?(name)
|
|
36
|
+
end
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def has?(name)
|
|
41
|
+
!lookup(name).nil?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# All type names, namespaced names rendered as "alias.name".
|
|
45
|
+
def all_type_names
|
|
46
|
+
names = @local_types.keys.dup
|
|
47
|
+
@namespaces.each do |ns, ns_map|
|
|
48
|
+
ns_map.each_key { |n| names << "#{ns}.#{n}" }
|
|
49
|
+
end
|
|
50
|
+
names
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -173,57 +173,89 @@ module Odin
|
|
|
173
173
|
# ── ODIN Export ──
|
|
174
174
|
|
|
175
175
|
def self.to_odin(value, header: true, modifiers: {})
|
|
176
|
-
|
|
176
|
+
builder = Odin::Types::OdinDocumentBuilder.new
|
|
177
|
+
builder.set_metadata("odin", Odin::Types::OdinString.new("1.0.0")) if header
|
|
177
178
|
|
|
178
|
-
if
|
|
179
|
-
|
|
180
|
-
|
|
179
|
+
if value.object?
|
|
180
|
+
flatten_odin_paths(builder, "", value, modifiers)
|
|
181
|
+
elsif value.array?
|
|
182
|
+
flatten_odin_paths(builder, "items", value, modifiers)
|
|
183
|
+
else
|
|
184
|
+
builder.set("value", dyn_to_odin_value(value))
|
|
181
185
|
end
|
|
182
186
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
has_sections = entries.any? { |_k, v| v.object? || v.array? }
|
|
186
|
-
|
|
187
|
-
if has_sections
|
|
188
|
-
# Check if there are any top-level fields (non-section entries or leaf chains)
|
|
189
|
-
has_top_level = entries.any? { |_k, v| !v.object? && !v.array? } ||
|
|
190
|
-
entries.any? { |_k, v| v.object? && pure_leaf_chain?(v) }
|
|
191
|
-
|
|
192
|
-
# Emit {} root section marker when header is present and there are top-level fields
|
|
193
|
-
sb << "{}\n" if header && has_top_level
|
|
194
|
-
|
|
195
|
-
# First pass: flat top-level fields and leaf chains
|
|
196
|
-
entries.each do |key, val|
|
|
197
|
-
if val.object?
|
|
198
|
-
collect_leaf_paths(sb, key, val, key, modifiers)
|
|
199
|
-
elsif !val.array?
|
|
200
|
-
write_assignment(sb, key, val, key, modifiers)
|
|
201
|
-
end
|
|
202
|
-
end
|
|
187
|
+
Odin.stringify(builder.build, use_headers: true)
|
|
188
|
+
end
|
|
203
189
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
elsif val.array?
|
|
211
|
-
write_array_section(sb, key, nil, val.value, modifiers)
|
|
212
|
-
last_ctx = ""
|
|
213
|
-
end
|
|
214
|
-
end
|
|
215
|
-
else
|
|
216
|
-
entries.each do |key, val|
|
|
217
|
-
write_assignment(sb, key, val, key, modifiers)
|
|
218
|
-
end
|
|
190
|
+
# Flatten a DynValue tree into path -> Odin value assignments on the builder.
|
|
191
|
+
def self.flatten_odin_paths(builder, prefix, value, modifiers)
|
|
192
|
+
if value.object?
|
|
193
|
+
value.value.each do |key, child|
|
|
194
|
+
path = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
|
|
195
|
+
flatten_odin_paths(builder, path, child, modifiers)
|
|
219
196
|
end
|
|
220
197
|
elsif value.array?
|
|
221
|
-
|
|
198
|
+
value.value.each_with_index do |child, idx|
|
|
199
|
+
path = "#{prefix}[#{idx}]"
|
|
200
|
+
flatten_odin_paths(builder, path, child, modifiers)
|
|
201
|
+
end
|
|
222
202
|
else
|
|
223
|
-
|
|
203
|
+
builder.set(prefix, dyn_to_odin_value(value), modifiers: odin_modifiers_for(prefix, modifiers))
|
|
224
204
|
end
|
|
205
|
+
end
|
|
225
206
|
|
|
226
|
-
|
|
207
|
+
# Build the document Modifiers for a path from tracked transform modifiers.
|
|
208
|
+
def self.odin_modifiers_for(path, modifiers)
|
|
209
|
+
return nil if modifiers.nil? || !modifiers.key?(path)
|
|
210
|
+
mods = modifiers[path]
|
|
211
|
+
return nil if mods.nil? || mods.empty?
|
|
212
|
+
Odin::Types::OdinModifiers.new(
|
|
213
|
+
required: mods.include?(:required) || mods.include?(Odin::Transform::FieldModifier::REQUIRED),
|
|
214
|
+
deprecated: mods.include?(:deprecated) || mods.include?(Odin::Transform::FieldModifier::DEPRECATED),
|
|
215
|
+
confidential: mods.include?(:confidential) || mods.include?(Odin::Transform::FieldModifier::CONFIDENTIAL)
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Convert a scalar DynValue into the corresponding Odin value, carrying
|
|
220
|
+
# ECMAScript-style numeric formatting through the raw field.
|
|
221
|
+
def self.dyn_to_odin_value(dv)
|
|
222
|
+
case dv.type
|
|
223
|
+
when :null then Odin::Types::OdinNull.new
|
|
224
|
+
when :bool then Odin::Types::OdinBoolean.new(dv.value)
|
|
225
|
+
when :integer then Odin::Types::OdinInteger.new(dv.value)
|
|
226
|
+
when :float
|
|
227
|
+
Odin::Types::OdinNumber.new(dv.value, raw: format_double_raw(dv.value))
|
|
228
|
+
when :float_raw
|
|
229
|
+
Odin::Types::OdinNumber.new(dv.value.to_f, raw: dv.value.to_s)
|
|
230
|
+
when :string then Odin::Types::OdinString.new(dv.value)
|
|
231
|
+
when :currency
|
|
232
|
+
Odin::Types::OdinCurrency.new(dv.value.to_f, currency_code: dv.currency_code,
|
|
233
|
+
decimal_places: dv.decimal_places || 2)
|
|
234
|
+
when :currency_raw
|
|
235
|
+
Odin::Types::OdinCurrency.new(dv.value.to_f, currency_code: dv.currency_code,
|
|
236
|
+
decimal_places: dv.decimal_places || 2, raw: dv.value.to_s)
|
|
237
|
+
when :percent
|
|
238
|
+
Odin::Types::OdinPercent.new(dv.value, raw: format_percent_raw(dv.value))
|
|
239
|
+
when :date then Odin::Types::OdinDate.new(dv.value, raw: dv.value.to_s)
|
|
240
|
+
when :timestamp then Odin::Types::OdinTimestamp.new(dv.value, raw: dv.value.to_s)
|
|
241
|
+
when :time
|
|
242
|
+
t = dv.value.to_s
|
|
243
|
+
Odin::Types::OdinTime.new(t.start_with?("T") ? t : "T#{t}")
|
|
244
|
+
when :duration then Odin::Types::OdinDuration.new(dv.value.to_s)
|
|
245
|
+
when :reference then Odin::Types::OdinReference.new(dv.value.to_s)
|
|
246
|
+
when :binary then Odin::Types::OdinBinary.new(dv.value.to_s)
|
|
247
|
+
else Odin::Types::OdinString.new(dv.value.to_s)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# ECMAScript number form: whole floats render without a fractional part.
|
|
252
|
+
def self.format_double_raw(v)
|
|
253
|
+
return v.to_i.to_s if v.finite? && v == v.to_i && v.abs < 1e15
|
|
254
|
+
format_double(v)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def self.format_percent_raw(v)
|
|
258
|
+
v == v.to_i.to_f && v.abs < 1e15 ? "#{v.to_i}.0" : v.to_s
|
|
227
259
|
end
|
|
228
260
|
|
|
229
261
|
# ── Flat KVP Export ──
|
|
@@ -314,13 +346,21 @@ module Odin
|
|
|
314
346
|
|
|
315
347
|
# ── Private Helpers ──
|
|
316
348
|
|
|
349
|
+
# ECMAScript JSON.stringify form: whole-valued finite floats render as
|
|
350
|
+
# integers; others keep their numeric value.
|
|
351
|
+
def self.json_number(v)
|
|
352
|
+
return v unless v.is_a?(Float)
|
|
353
|
+
return v unless v.finite?
|
|
354
|
+
v == v.to_i && v.abs < 1e15 ? v.to_i : v
|
|
355
|
+
end
|
|
356
|
+
|
|
317
357
|
def self.dynvalue_to_json_obj(dv)
|
|
318
358
|
case dv.type
|
|
319
359
|
when :null then nil
|
|
320
360
|
when :bool then dv.value
|
|
321
361
|
when :integer then dv.value
|
|
322
|
-
when :float then dv.value
|
|
323
|
-
when :float_raw then dv.value.to_f
|
|
362
|
+
when :float then json_number(dv.value)
|
|
363
|
+
when :float_raw then json_number(dv.value.to_f)
|
|
324
364
|
when :string then dv.value
|
|
325
365
|
when :currency
|
|
326
366
|
f = dv.value.is_a?(BigDecimal) ? dv.value.to_f : dv.value.to_f
|
|
@@ -370,7 +410,7 @@ module Odin
|
|
|
370
410
|
|
|
371
411
|
# ── ODIN formatting helpers ──
|
|
372
412
|
|
|
373
|
-
# Section writing
|
|
413
|
+
# Section writing
|
|
374
414
|
def self.write_section(sb, full_path, display_path, parent_section, val, modifiers, last_ctx, inside_relative: false)
|
|
375
415
|
entries = val.value
|
|
376
416
|
return unless entries.is_a?(Hash)
|
|
@@ -414,7 +454,7 @@ module Odin
|
|
|
414
454
|
end
|
|
415
455
|
end
|
|
416
456
|
|
|
417
|
-
# Array section writing
|
|
457
|
+
# Array section writing
|
|
418
458
|
def self.write_array_section(sb, name, parent_section, items, modifiers)
|
|
419
459
|
prefix = parent_section ? "." : ""
|
|
420
460
|
|
|
@@ -529,7 +569,7 @@ module Odin
|
|
|
529
569
|
result
|
|
530
570
|
end
|
|
531
571
|
|
|
532
|
-
# Leaf chain detection
|
|
572
|
+
# Leaf chain detection
|
|
533
573
|
def self.pure_leaf_chain?(val)
|
|
534
574
|
return false unless val.object?
|
|
535
575
|
entries = val.value
|
|
@@ -540,7 +580,7 @@ module Odin
|
|
|
540
580
|
true
|
|
541
581
|
end
|
|
542
582
|
|
|
543
|
-
# Collect leaf paths for flat nested objects
|
|
583
|
+
# Collect leaf paths for flat nested objects
|
|
544
584
|
def self.collect_leaf_paths(sb, prefix, val, mod_path, modifiers)
|
|
545
585
|
return unless pure_leaf_chain?(val)
|
|
546
586
|
collect_leaf_paths_inner(sb, prefix, val, mod_path, modifiers)
|
|
@@ -336,7 +336,7 @@ module Odin
|
|
|
336
336
|
fields["_text"] = Types::DynValue.of_string(text) unless text.empty?
|
|
337
337
|
end
|
|
338
338
|
|
|
339
|
-
# Child elements — use qualified names (with namespace prefix)
|
|
339
|
+
# Child elements — use qualified names (with namespace prefix)
|
|
340
340
|
child_counts = Hash.new(0)
|
|
341
341
|
children.each { |c| child_counts[qualified_name(c)] += 1 }
|
|
342
342
|
|
|
@@ -345,7 +345,7 @@ module Odin
|
|
|
345
345
|
name = qualified_name(child)
|
|
346
346
|
child_val = parse_xml_element(child, depth + 1)
|
|
347
347
|
|
|
348
|
-
# Elements named 'item' are always treated as arrays
|
|
348
|
+
# Elements named 'item' are always treated as arrays
|
|
349
349
|
if child_counts[name] > 1 || name == "item"
|
|
350
350
|
child_arrays[name] ||= []
|
|
351
351
|
child_arrays[name] << child_val
|