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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 41cba3e1d6ec4a7cc481423fcb6ec0ddaba15b17dbe097fdb283e2f75ea75a26
|
|
4
|
+
data.tar.gz: c114129c45abc953e0e1f8e181d6f3c35037768bd2c3f9bc89c01798492c916b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f66842bdf339de14334c2f83e2f4c739b8ab4f82fb5040050a687a28b002695bade7ce71c90928bb620597c502ee5aee4b5e81b837787148d4c916fe23657c64
|
|
7
|
+
data.tar.gz: d4bfeeb7605d2de792b932da4d9b551e667ca53cbd965e27edc47d9b554ffc8dc060dd77e62bcab89290d93d551a98acba4adc5035c9ab37828df67911174971
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Odin
|
|
6
|
+
module Diff
|
|
7
|
+
class Differ
|
|
8
|
+
def compute_diff(doc_a, doc_b)
|
|
9
|
+
paths_a = Set.new(doc_a.paths)
|
|
10
|
+
paths_b = Set.new(doc_b.paths)
|
|
11
|
+
|
|
12
|
+
removed = []
|
|
13
|
+
changed = []
|
|
14
|
+
added = []
|
|
15
|
+
|
|
16
|
+
# 1. Find removed: in A but not in B
|
|
17
|
+
(paths_a - paths_b).sort.each do |path|
|
|
18
|
+
removed << Types::DiffEntry.new(
|
|
19
|
+
path: path,
|
|
20
|
+
value: doc_a.get(path),
|
|
21
|
+
modifiers: doc_a.modifiers_for(path)
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# 2. Find added: in B but not in A
|
|
26
|
+
(paths_b - paths_a).sort.each do |path|
|
|
27
|
+
added << Types::DiffEntry.new(
|
|
28
|
+
path: path,
|
|
29
|
+
value: doc_b.get(path),
|
|
30
|
+
modifiers: doc_b.modifiers_for(path)
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# 3. Find changed: in both but different value or modifiers
|
|
35
|
+
(paths_a & paths_b).sort.each do |path|
|
|
36
|
+
val_a = doc_a.get(path)
|
|
37
|
+
val_b = doc_b.get(path)
|
|
38
|
+
mod_a = doc_a.modifiers_for(path)
|
|
39
|
+
mod_b = doc_b.modifiers_for(path)
|
|
40
|
+
|
|
41
|
+
unless values_equal?(val_a, val_b) && modifiers_equal?(mod_a, mod_b)
|
|
42
|
+
changed << Types::DiffChange.new(
|
|
43
|
+
path: path,
|
|
44
|
+
old_value: val_a,
|
|
45
|
+
new_value: val_b,
|
|
46
|
+
old_modifiers: mod_a,
|
|
47
|
+
new_modifiers: mod_b
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# 4. Detect moves: removed value that appears as added value
|
|
53
|
+
moved = detect_moves(removed, added)
|
|
54
|
+
|
|
55
|
+
Types::OdinDiff.new(
|
|
56
|
+
added: added,
|
|
57
|
+
removed: removed,
|
|
58
|
+
changed: changed,
|
|
59
|
+
moved: moved
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def values_equal?(a, b)
|
|
66
|
+
return a.nil? && b.nil? if a.nil? || b.nil?
|
|
67
|
+
|
|
68
|
+
a == b
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def modifiers_equal?(a, b)
|
|
72
|
+
# Treat nil and NONE (all false) as equivalent
|
|
73
|
+
a_req = a&.required || false
|
|
74
|
+
b_req = b&.required || false
|
|
75
|
+
a_conf = a&.confidential || false
|
|
76
|
+
b_conf = b&.confidential || false
|
|
77
|
+
a_dep = a&.deprecated || false
|
|
78
|
+
b_dep = b&.deprecated || false
|
|
79
|
+
a_req == b_req && a_conf == b_conf && a_dep == b_dep
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def detect_moves(removed, added)
|
|
83
|
+
moves = []
|
|
84
|
+
removed_matched = Set.new
|
|
85
|
+
added_matched = Set.new
|
|
86
|
+
|
|
87
|
+
removed.each_with_index do |rem_entry, ri|
|
|
88
|
+
next if removed_matched.include?(ri)
|
|
89
|
+
|
|
90
|
+
added.each_with_index do |add_entry, ai|
|
|
91
|
+
next if added_matched.include?(ai)
|
|
92
|
+
|
|
93
|
+
if values_equal?(rem_entry.value, add_entry.value)
|
|
94
|
+
moves << Types::DiffMove.new(
|
|
95
|
+
from_path: rem_entry.path,
|
|
96
|
+
to_path: add_entry.path,
|
|
97
|
+
value: rem_entry.value,
|
|
98
|
+
modifiers: rem_entry.modifiers
|
|
99
|
+
)
|
|
100
|
+
removed_matched << ri
|
|
101
|
+
added_matched << ai
|
|
102
|
+
break
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Remove matched entries from removed/added lists (mutate in-place)
|
|
108
|
+
removed_matched.sort.reverse_each { |i| removed.delete_at(i) }
|
|
109
|
+
added_matched.sort.reverse_each { |i| added.delete_at(i) }
|
|
110
|
+
|
|
111
|
+
moves
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Odin
|
|
6
|
+
module Diff
|
|
7
|
+
class Patcher
|
|
8
|
+
def apply_patch(doc, diff)
|
|
9
|
+
return clone_document(doc) if diff.empty?
|
|
10
|
+
|
|
11
|
+
builder = Types::OdinDocumentBuilder.new
|
|
12
|
+
|
|
13
|
+
# Build set of paths to skip (removed + move sources)
|
|
14
|
+
removed_paths = Set.new(diff.removed.map(&:path))
|
|
15
|
+
moved_from_paths = Set.new(diff.moved.map(&:from_path))
|
|
16
|
+
changed_paths = Set.new(diff.changed.map(&:path))
|
|
17
|
+
skip_paths = removed_paths | moved_from_paths
|
|
18
|
+
|
|
19
|
+
# 1. Copy existing assignments (except removed, moved-from, and changed)
|
|
20
|
+
doc.each_assignment do |path, value|
|
|
21
|
+
next if skip_paths.include?(path) || changed_paths.include?(path)
|
|
22
|
+
|
|
23
|
+
builder.set(path, value, modifiers: doc.modifiers_for(path))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# 2. Apply changes (update values and/or modifiers)
|
|
27
|
+
diff.changed.each do |change|
|
|
28
|
+
builder.set(change.path, change.new_value, modifiers: change.new_modifiers)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# 3. Apply moves (add at new path)
|
|
32
|
+
diff.moved.each do |move|
|
|
33
|
+
mods = doc.modifiers_for(move.from_path)
|
|
34
|
+
builder.set(move.to_path, move.value, modifiers: mods)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# 4. Apply additions
|
|
38
|
+
diff.added.each do |entry|
|
|
39
|
+
builder.set(entry.path, entry.value, modifiers: entry.modifiers)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# 5. Copy metadata
|
|
43
|
+
doc.each_metadata do |key, value|
|
|
44
|
+
builder.set_metadata(key, value)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
builder.build
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def clone_document(doc)
|
|
53
|
+
builder = Types::OdinDocumentBuilder.new
|
|
54
|
+
doc.each_assignment do |path, value|
|
|
55
|
+
builder.set(path, value, modifiers: doc.modifiers_for(path))
|
|
56
|
+
end
|
|
57
|
+
doc.each_metadata do |key, value|
|
|
58
|
+
builder.set_metadata(key, value)
|
|
59
|
+
end
|
|
60
|
+
builder.build
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/odin/export.rb
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "bigdecimal"
|
|
5
|
+
|
|
6
|
+
module Odin
|
|
7
|
+
module Export
|
|
8
|
+
# Convert OdinDocument to JSON string
|
|
9
|
+
def self.to_json(doc, pretty: true)
|
|
10
|
+
obj = doc_to_json_obj(doc)
|
|
11
|
+
if pretty
|
|
12
|
+
JSON.pretty_generate(obj)
|
|
13
|
+
else
|
|
14
|
+
JSON.generate(obj)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Convert OdinDocument to XML string
|
|
19
|
+
def self.to_xml(doc, root: "root", preserve_types: false, preserve_modifiers: false)
|
|
20
|
+
# When preserving modifiers, always preserve types too (matches Java behavior)
|
|
21
|
+
preserve_types = true if preserve_modifiers
|
|
22
|
+
|
|
23
|
+
xml = +%{<?xml version="1.0" encoding="UTF-8"?>\n}
|
|
24
|
+
|
|
25
|
+
ns = ""
|
|
26
|
+
if preserve_types || preserve_modifiers
|
|
27
|
+
ns = ' xmlns:odin="https://odin.foundation/ns"'
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
xml << "<#{root}#{ns}>\n"
|
|
31
|
+
emit_xml_assignments(doc, xml, 1, preserve_types, preserve_modifiers)
|
|
32
|
+
xml << "</#{root}>\n"
|
|
33
|
+
xml
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Convert OdinDocument to CSV string
|
|
37
|
+
def self.to_csv(doc)
|
|
38
|
+
# Find array pattern: path[index].field
|
|
39
|
+
array_pattern = /\A(.+?)\[(\d+)\]\.(.+)\z/
|
|
40
|
+
rows = {}
|
|
41
|
+
columns = []
|
|
42
|
+
|
|
43
|
+
doc.each_assignment do |path, value|
|
|
44
|
+
m = array_pattern.match(path)
|
|
45
|
+
next unless m
|
|
46
|
+
|
|
47
|
+
idx = m[2].to_i
|
|
48
|
+
field = m[3]
|
|
49
|
+
rows[idx] ||= {}
|
|
50
|
+
rows[idx][field] = value
|
|
51
|
+
columns << field unless columns.include?(field)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
return "" if rows.empty? || columns.empty?
|
|
55
|
+
|
|
56
|
+
lines = []
|
|
57
|
+
lines << columns.map { |c| csv_escape(c) }.join(",")
|
|
58
|
+
|
|
59
|
+
rows.keys.sort.each do |idx|
|
|
60
|
+
row = rows[idx]
|
|
61
|
+
cells = columns.map do |col|
|
|
62
|
+
val = row[col]
|
|
63
|
+
val ? csv_escape(odin_value_to_string(val)) : ""
|
|
64
|
+
end
|
|
65
|
+
lines << cells.join(",")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
lines.join("\n") + "\n"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Convert OdinDocument to fixed-width string
|
|
72
|
+
def self.to_fixed_width(doc, columns:, line_width: nil)
|
|
73
|
+
total_width = line_width || columns.map { |c| (c[:pos] || 0) + (c[:len] || 0) }.max || 80
|
|
74
|
+
|
|
75
|
+
line = " " * total_width
|
|
76
|
+
columns.each do |col|
|
|
77
|
+
pos = col[:pos] || 0
|
|
78
|
+
len = col[:len] || 0
|
|
79
|
+
pad_char = col[:pad] || " "
|
|
80
|
+
align = col[:align] || :left
|
|
81
|
+
field_path = col[:path] || col[:name]
|
|
82
|
+
|
|
83
|
+
val = doc.get(field_path)
|
|
84
|
+
raw = val ? odin_value_to_string(val) : ""
|
|
85
|
+
raw = raw[0...len] if raw.length > len
|
|
86
|
+
|
|
87
|
+
padded = if align == :right
|
|
88
|
+
raw.rjust(len, pad_char)
|
|
89
|
+
else
|
|
90
|
+
raw.ljust(len, pad_char)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
padded.chars.each_with_index do |ch, i|
|
|
94
|
+
line[pos + i] = ch if pos + i < total_width
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
line.rstrip + "\n"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# ── Private Helpers ──
|
|
102
|
+
|
|
103
|
+
def self.doc_to_json_obj(doc)
|
|
104
|
+
result = {}
|
|
105
|
+
# Build nested structure from flat path assignments
|
|
106
|
+
doc.each_assignment do |path, value|
|
|
107
|
+
set_nested_json(result, path, odin_value_to_json(value))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Include metadata header as "$" key
|
|
111
|
+
meta = {}
|
|
112
|
+
doc.each_metadata do |key, value|
|
|
113
|
+
meta[key] = odin_value_to_json(value)
|
|
114
|
+
end
|
|
115
|
+
result["$"] = meta unless meta.empty?
|
|
116
|
+
|
|
117
|
+
result
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.set_nested_json(root, path, value)
|
|
121
|
+
segments = parse_path(path)
|
|
122
|
+
current = root
|
|
123
|
+
|
|
124
|
+
segments[0...-1].each_with_index do |seg, idx|
|
|
125
|
+
next_seg = segments[idx + 1]
|
|
126
|
+
if seg.is_a?(Integer)
|
|
127
|
+
current[seg] ||= next_seg.is_a?(Integer) ? [] : {}
|
|
128
|
+
current = current[seg]
|
|
129
|
+
else
|
|
130
|
+
if next_seg.is_a?(Integer)
|
|
131
|
+
current[seg] ||= []
|
|
132
|
+
else
|
|
133
|
+
current[seg] ||= {}
|
|
134
|
+
end
|
|
135
|
+
current = current[seg]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
last = segments.last
|
|
140
|
+
current[last] = value
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.parse_path(path)
|
|
144
|
+
segments = []
|
|
145
|
+
# Split on dots but handle bracket notation
|
|
146
|
+
parts = path.split(".")
|
|
147
|
+
parts.each do |part|
|
|
148
|
+
if part.include?("[")
|
|
149
|
+
# e.g., "items[0]" -> "items", 0
|
|
150
|
+
part.scan(/([^\[\]]+)|\[(\d+)\]/) do |name, index|
|
|
151
|
+
if index
|
|
152
|
+
segments << index.to_i
|
|
153
|
+
else
|
|
154
|
+
segments << name
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
else
|
|
158
|
+
segments << part
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
segments
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def self.odin_value_to_json(val)
|
|
165
|
+
case val
|
|
166
|
+
when Types::OdinNull then nil
|
|
167
|
+
when Types::OdinBoolean then val.value
|
|
168
|
+
when Types::OdinString then val.value
|
|
169
|
+
when Types::OdinInteger then val.value
|
|
170
|
+
when Types::OdinNumber then val.value
|
|
171
|
+
when Types::OdinCurrency
|
|
172
|
+
f = val.value.to_f
|
|
173
|
+
f == f.to_i && f.abs < 1e15 ? f.to_i : f
|
|
174
|
+
when Types::OdinPercent then val.value
|
|
175
|
+
when Types::OdinDate then val.raw || val.value.to_s
|
|
176
|
+
when Types::OdinTimestamp then val.raw || val.value.to_s
|
|
177
|
+
when Types::OdinTime then val.value
|
|
178
|
+
when Types::OdinDuration then val.value
|
|
179
|
+
when Types::OdinReference then "@#{val.path}"
|
|
180
|
+
when Types::OdinBinary
|
|
181
|
+
val.algorithm ? "^#{val.algorithm}:#{val.data}" : "^#{val.data}"
|
|
182
|
+
when Types::OdinArray
|
|
183
|
+
val.items.map { |item|
|
|
184
|
+
item.is_a?(Types::ArrayItem) ? odin_value_to_json(item.value) : odin_value_to_json(item)
|
|
185
|
+
}
|
|
186
|
+
when Types::OdinObject
|
|
187
|
+
val.entries.transform_values { |v| odin_value_to_json(v) }
|
|
188
|
+
else
|
|
189
|
+
val.respond_to?(:value) ? val.value : nil
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def self.odin_value_to_string(val)
|
|
194
|
+
case val
|
|
195
|
+
when Types::OdinNull then ""
|
|
196
|
+
when Types::OdinBoolean then val.value.to_s
|
|
197
|
+
when Types::OdinString then val.value
|
|
198
|
+
when Types::OdinInteger then (val.raw || val.value.to_s)
|
|
199
|
+
when Types::OdinNumber then (val.raw || format_num(val.value))
|
|
200
|
+
when Types::OdinCurrency
|
|
201
|
+
val.raw || val.value.to_s("F")
|
|
202
|
+
when Types::OdinPercent then (val.raw || format_num(val.value))
|
|
203
|
+
when Types::OdinDate then val.raw || val.value.to_s
|
|
204
|
+
when Types::OdinTimestamp then val.raw || val.value.to_s
|
|
205
|
+
when Types::OdinTime then val.value
|
|
206
|
+
when Types::OdinDuration then val.value
|
|
207
|
+
when Types::OdinReference then "@#{val.path}"
|
|
208
|
+
when Types::OdinBinary
|
|
209
|
+
val.algorithm ? "^#{val.algorithm}:#{val.data}" : "^#{val.data}"
|
|
210
|
+
else
|
|
211
|
+
val.respond_to?(:value) ? val.value.to_s : ""
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def self.format_num(v)
|
|
216
|
+
v == v.to_i && v.abs < 1e15 ? v.to_i.to_s : v.to_s
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def self.emit_xml_assignments(doc, xml, depth, preserve_types, preserve_modifiers)
|
|
220
|
+
indent = " " * depth
|
|
221
|
+
|
|
222
|
+
# Group assignments by top-level section
|
|
223
|
+
sections = {}
|
|
224
|
+
doc.each_assignment do |path, value|
|
|
225
|
+
parts = path.split(".", 2)
|
|
226
|
+
section = parts[0]
|
|
227
|
+
remainder = parts[1]
|
|
228
|
+
|
|
229
|
+
sections[section] ||= []
|
|
230
|
+
sections[section] << [remainder, value, path]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
sections.each do |section, entries|
|
|
234
|
+
if entries.size == 1 && entries[0][0].nil?
|
|
235
|
+
# Simple field
|
|
236
|
+
_, value, full_path = entries[0]
|
|
237
|
+
emit_xml_value(xml, section, value, full_path, doc, indent, depth,
|
|
238
|
+
preserve_types, preserve_modifiers)
|
|
239
|
+
else
|
|
240
|
+
# Nested section
|
|
241
|
+
xml << "#{indent}<#{sanitize_xml_name(section)}>\n"
|
|
242
|
+
entries.each do |remainder, value, full_path|
|
|
243
|
+
if remainder
|
|
244
|
+
emit_xml_value(xml, remainder.split(".").last, value, full_path, doc,
|
|
245
|
+
" " * (depth + 1), depth + 1, preserve_types, preserve_modifiers)
|
|
246
|
+
else
|
|
247
|
+
emit_xml_value(xml, section, value, full_path, doc, " " * (depth + 1),
|
|
248
|
+
depth + 1, preserve_types, preserve_modifiers)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
xml << "#{indent}</#{sanitize_xml_name(section)}>\n"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def self.emit_xml_value(xml, name, value, full_path, doc, indent, _depth,
|
|
257
|
+
preserve_types, preserve_modifiers)
|
|
258
|
+
safe_name = sanitize_xml_name(name)
|
|
259
|
+
attrs = +""
|
|
260
|
+
|
|
261
|
+
if preserve_types
|
|
262
|
+
type_name = xml_type_name(value)
|
|
263
|
+
attrs << " odin:type=\"#{type_name}\"" if type_name
|
|
264
|
+
if value.is_a?(Types::OdinCurrency) && value.currency_code
|
|
265
|
+
attrs << " odin:currencyCode=\"#{value.currency_code}\""
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
if preserve_modifiers
|
|
270
|
+
mods = value.modifiers || doc.modifiers_for(full_path)
|
|
271
|
+
if mods
|
|
272
|
+
attrs << ' odin:required="true"' if mods.required
|
|
273
|
+
attrs << ' odin:confidential="true"' if mods.confidential
|
|
274
|
+
attrs << ' odin:deprecated="true"' if mods.deprecated
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Skip null values in XML output (omit them entirely)
|
|
279
|
+
return if value.is_a?(Types::OdinNull)
|
|
280
|
+
|
|
281
|
+
text = xml_escape(odin_value_to_string(value))
|
|
282
|
+
xml << "#{indent}<#{safe_name}#{attrs}>#{text}</#{safe_name}>\n"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def self.xml_type_name(value)
|
|
286
|
+
case value
|
|
287
|
+
when Types::OdinInteger then "integer"
|
|
288
|
+
when Types::OdinNumber then "number"
|
|
289
|
+
when Types::OdinCurrency then "currency"
|
|
290
|
+
when Types::OdinPercent then "percent"
|
|
291
|
+
when Types::OdinBoolean then "boolean"
|
|
292
|
+
when Types::OdinDate then "date"
|
|
293
|
+
when Types::OdinTimestamp then "timestamp"
|
|
294
|
+
when Types::OdinTime then "time"
|
|
295
|
+
when Types::OdinDuration then "duration"
|
|
296
|
+
when Types::OdinReference then "reference"
|
|
297
|
+
when Types::OdinBinary then "binary"
|
|
298
|
+
else nil # string and null don't need type attr
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def self.sanitize_xml_name(name)
|
|
303
|
+
name = name.to_s.gsub(/[^a-zA-Z0-9._-]/, "_")
|
|
304
|
+
name = "_#{name}" if name.match?(/\A\d/)
|
|
305
|
+
name = "element" if name.empty?
|
|
306
|
+
name
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def self.xml_escape(s)
|
|
310
|
+
s.gsub("&", "&")
|
|
311
|
+
.gsub("<", "<")
|
|
312
|
+
.gsub(">", ">")
|
|
313
|
+
.gsub('"', """)
|
|
314
|
+
.gsub("'", "'")
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def self.csv_escape(s)
|
|
318
|
+
if s.include?(",") || s.include?('"') || s.include?("\n") || s.include?("\r")
|
|
319
|
+
'"' + s.gsub('"', '""') + '"'
|
|
320
|
+
else
|
|
321
|
+
s
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
private_class_method :doc_to_json_obj, :set_nested_json, :parse_path,
|
|
326
|
+
:odin_value_to_json, :odin_value_to_string, :format_num,
|
|
327
|
+
:emit_xml_assignments, :emit_xml_value, :xml_type_name,
|
|
328
|
+
:sanitize_xml_name, :xml_escape, :csv_escape
|
|
329
|
+
end
|
|
330
|
+
end
|