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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/lib/odin/diff/differ.rb +115 -0
  3. data/lib/odin/diff/patcher.rb +64 -0
  4. data/lib/odin/export.rb +330 -0
  5. data/lib/odin/parsing/parser.rb +1193 -0
  6. data/lib/odin/parsing/token.rb +26 -0
  7. data/lib/odin/parsing/token_type.rb +40 -0
  8. data/lib/odin/parsing/tokenizer.rb +825 -0
  9. data/lib/odin/parsing/value_parser.rb +322 -0
  10. data/lib/odin/resolver/import_resolver.rb +137 -0
  11. data/lib/odin/serialization/canonicalize.rb +112 -0
  12. data/lib/odin/serialization/stringify.rb +582 -0
  13. data/lib/odin/transform/format_exporters.rb +819 -0
  14. data/lib/odin/transform/source_parsers.rb +385 -0
  15. data/lib/odin/transform/transform_engine.rb +2837 -0
  16. data/lib/odin/transform/transform_parser.rb +979 -0
  17. data/lib/odin/transform/transform_types.rb +278 -0
  18. data/lib/odin/transform/verb_context.rb +87 -0
  19. data/lib/odin/transform/verbs/aggregation_verbs.rb +106 -0
  20. data/lib/odin/transform/verbs/collection_verbs.rb +640 -0
  21. data/lib/odin/transform/verbs/datetime_verbs.rb +602 -0
  22. data/lib/odin/transform/verbs/financial_verbs.rb +356 -0
  23. data/lib/odin/transform/verbs/geo_verbs.rb +125 -0
  24. data/lib/odin/transform/verbs/numeric_verbs.rb +434 -0
  25. data/lib/odin/transform/verbs/object_verbs.rb +123 -0
  26. data/lib/odin/types/array_item.rb +42 -0
  27. data/lib/odin/types/diff.rb +89 -0
  28. data/lib/odin/types/directive.rb +28 -0
  29. data/lib/odin/types/document.rb +92 -0
  30. data/lib/odin/types/document_builder.rb +67 -0
  31. data/lib/odin/types/dyn_value.rb +270 -0
  32. data/lib/odin/types/errors.rb +149 -0
  33. data/lib/odin/types/modifiers.rb +45 -0
  34. data/lib/odin/types/ordered_map.rb +79 -0
  35. data/lib/odin/types/schema.rb +262 -0
  36. data/lib/odin/types/value_type.rb +28 -0
  37. data/lib/odin/types/values.rb +618 -0
  38. data/lib/odin/types.rb +12 -0
  39. data/lib/odin/utils/format_utils.rb +186 -0
  40. data/lib/odin/utils/path_utils.rb +25 -0
  41. data/lib/odin/utils/security_limits.rb +17 -0
  42. data/lib/odin/validation/format_validators.rb +238 -0
  43. data/lib/odin/validation/redos_protection.rb +102 -0
  44. data/lib/odin/validation/schema_parser.rb +813 -0
  45. data/lib/odin/validation/schema_serializer.rb +262 -0
  46. data/lib/odin/validation/validator.rb +1061 -0
  47. data/lib/odin/version.rb +5 -0
  48. data/lib/odin.rb +90 -0
  49. 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
@@ -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("&", "&amp;")
311
+ .gsub("<", "&lt;")
312
+ .gsub(">", "&gt;")
313
+ .gsub('"', "&quot;")
314
+ .gsub("'", "&apos;")
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