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
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Odin
4
+ module Types
5
+ class OdinDirective
6
+ attr_reader :name, :value
7
+
8
+ def initialize(name, value = nil)
9
+ @name = -name.to_s
10
+ @value = value&.freeze
11
+ freeze
12
+ end
13
+
14
+ def ==(other)
15
+ other.is_a?(OdinDirective) && name == other.name && value == other.value
16
+ end
17
+ alias eql? ==
18
+
19
+ def hash
20
+ [name, value].hash
21
+ end
22
+
23
+ def to_s
24
+ value ? "#{name}(#{value})" : name
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Odin
4
+ module Types
5
+ class OdinDocument
6
+ def initialize(assignments:, metadata:, modifiers:, comments:)
7
+ @assignments = assignments.freeze
8
+ @metadata = metadata.freeze
9
+ @modifiers = modifiers.freeze
10
+ @comments = comments.freeze
11
+ freeze
12
+ end
13
+
14
+ def get(path)
15
+ @assignments[path]
16
+ end
17
+
18
+ def [](path)
19
+ get(path)
20
+ end
21
+
22
+ def paths
23
+ @assignments.keys
24
+ end
25
+
26
+ def include?(path)
27
+ @assignments.key?(path)
28
+ end
29
+ alias has_path? include?
30
+
31
+ def size
32
+ @assignments.size
33
+ end
34
+ alias length size
35
+
36
+ def assignments
37
+ @assignments
38
+ end
39
+
40
+ def metadata
41
+ @metadata
42
+ end
43
+
44
+ def metadata_value(key)
45
+ @metadata[key]
46
+ end
47
+
48
+ def modifiers_for(path)
49
+ @modifiers[path]
50
+ end
51
+
52
+ def all_modifiers
53
+ @modifiers
54
+ end
55
+
56
+ def comment_for(path)
57
+ @comments[path]
58
+ end
59
+
60
+ def all_comments
61
+ @comments
62
+ end
63
+
64
+ def empty?
65
+ @assignments.empty? && @metadata.empty?
66
+ end
67
+
68
+ def each_assignment(&block)
69
+ @assignments.each(&block)
70
+ end
71
+
72
+ def each_metadata(&block)
73
+ @metadata.each(&block)
74
+ end
75
+
76
+ def ==(other)
77
+ other.is_a?(OdinDocument) &&
78
+ assignments == other.assignments &&
79
+ metadata == other.metadata
80
+ end
81
+ alias eql? ==
82
+
83
+ def hash
84
+ [assignments, metadata].hash
85
+ end
86
+
87
+ def self.empty
88
+ new(assignments: {}, metadata: {}, modifiers: {}, comments: {})
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Odin
4
+ module Types
5
+ class OdinDocumentBuilder
6
+ def initialize
7
+ @assignments = {}
8
+ @metadata = {}
9
+ @modifiers = {}
10
+ @comments = {}
11
+ end
12
+
13
+ def set(path, value, modifiers: nil, comment: nil)
14
+ @assignments[path] = value
15
+ @modifiers[path] = modifiers if modifiers
16
+ @comments[path] = comment if comment
17
+ self
18
+ end
19
+
20
+ def set_metadata(key, value)
21
+ @metadata[key] = value
22
+ self
23
+ end
24
+
25
+ def set_string(path, value, modifiers: nil)
26
+ set(path, OdinString.new(value), modifiers: modifiers)
27
+ end
28
+
29
+ def set_integer(path, value, modifiers: nil)
30
+ set(path, OdinInteger.new(value), modifiers: modifiers)
31
+ end
32
+
33
+ def set_number(path, value, modifiers: nil)
34
+ set(path, OdinNumber.new(value), modifiers: modifiers)
35
+ end
36
+
37
+ def set_boolean(path, value, modifiers: nil)
38
+ set(path, value ? TRUE_VAL : FALSE_VAL, modifiers: modifiers)
39
+ end
40
+
41
+ def set_null(path, modifiers: nil)
42
+ set(path, NULL, modifiers: modifiers)
43
+ end
44
+
45
+ def set_currency(path, value, currency_code: nil, decimal_places: 2, modifiers: nil)
46
+ set(path, OdinCurrency.new(value, currency_code: currency_code,
47
+ decimal_places: decimal_places), modifiers: modifiers)
48
+ end
49
+
50
+ def remove(path)
51
+ @assignments.delete(path)
52
+ @modifiers.delete(path)
53
+ @comments.delete(path)
54
+ self
55
+ end
56
+
57
+ def build
58
+ OdinDocument.new(
59
+ assignments: @assignments.dup,
60
+ metadata: @metadata.dup,
61
+ modifiers: @modifiers.dup,
62
+ comments: @comments.dup
63
+ )
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "bigdecimal"
5
+
6
+ module Odin
7
+ module Types
8
+ class DynValue
9
+ TYPES = %i[
10
+ null bool integer float float_raw
11
+ currency currency_raw percent reference binary
12
+ date timestamp time duration
13
+ string array object
14
+ ].freeze
15
+
16
+ attr_reader :type, :value, :decimal_places, :currency_code
17
+
18
+ def initialize(type:, value: nil, decimal_places: 0, currency_code: nil)
19
+ @type = type
20
+ @value = value
21
+ @decimal_places = decimal_places
22
+ @currency_code = currency_code&.freeze
23
+ freeze
24
+ end
25
+
26
+ # Factory methods
27
+ def self.of_null
28
+ new(type: :null)
29
+ end
30
+
31
+ def self.of_bool(v)
32
+ new(type: :bool, value: v)
33
+ end
34
+
35
+ def self.of_integer(v)
36
+ new(type: :integer, value: v.to_i)
37
+ end
38
+
39
+ def self.of_float(v)
40
+ new(type: :float, value: v.to_f)
41
+ end
42
+
43
+ def self.of_float_raw(raw)
44
+ new(type: :float_raw, value: raw.to_s)
45
+ end
46
+
47
+ def self.of_string(v)
48
+ new(type: :string, value: v.to_s)
49
+ end
50
+
51
+ def self.of_array(items)
52
+ new(type: :array, value: items)
53
+ end
54
+
55
+ def self.of_object(entries)
56
+ new(type: :object, value: entries)
57
+ end
58
+
59
+ def self.of_currency(v, dp = 2, code = nil)
60
+ new(type: :currency, value: v.to_f, decimal_places: dp, currency_code: code)
61
+ end
62
+
63
+ def self.of_currency_raw(raw, dp = 2, code = nil)
64
+ new(type: :currency_raw, value: raw.to_s, decimal_places: dp, currency_code: code)
65
+ end
66
+
67
+ def self.of_percent(v)
68
+ new(type: :percent, value: v.to_f)
69
+ end
70
+
71
+ def self.of_reference(p)
72
+ new(type: :reference, value: p.to_s)
73
+ end
74
+
75
+ def self.of_binary(data)
76
+ new(type: :binary, value: data)
77
+ end
78
+
79
+ def self.of_date(v)
80
+ new(type: :date, value: v)
81
+ end
82
+
83
+ def self.of_timestamp(v)
84
+ new(type: :timestamp, value: v)
85
+ end
86
+
87
+ def self.of_time(v)
88
+ new(type: :time, value: v.to_s)
89
+ end
90
+
91
+ def self.of_duration(v)
92
+ new(type: :duration, value: v.to_s)
93
+ end
94
+
95
+ # Helpers: parse JSON strings into DynValue arrays/objects
96
+ def self.extract_array(json_string)
97
+ parsed = JSON.parse(json_string)
98
+ raise ArgumentError, "Not a JSON array" unless parsed.is_a?(::Array)
99
+
100
+ of_array(parsed.map { |item| from_json_value(item) })
101
+ end
102
+
103
+ def self.extract_object(json_string)
104
+ parsed = JSON.parse(json_string)
105
+ raise ArgumentError, "Not a JSON object" unless parsed.is_a?(::Hash)
106
+
107
+ of_object(parsed.transform_values { |v| from_json_value(v) })
108
+ end
109
+
110
+ def self.from_json_value(val)
111
+ case val
112
+ when nil then of_null
113
+ when true, false then of_bool(val)
114
+ when Integer then of_integer(val)
115
+ when Float then of_float(val)
116
+ when String then of_string(val)
117
+ when Array then of_array(val.map { |v| from_json_value(v) })
118
+ when Hash then of_object(val.transform_values { |v| from_json_value(v) })
119
+ else of_string(val.to_s)
120
+ end
121
+ end
122
+
123
+ # Type predicates
124
+ def null?; type == :null; end
125
+ def bool?; type == :bool; end
126
+ def integer?; type == :integer; end
127
+ def float?; type == :float || type == :float_raw; end
128
+ def currency?; type == :currency || type == :currency_raw; end
129
+ def percent?; type == :percent; end
130
+ def string?; type == :string; end
131
+ def array?; type == :array; end
132
+ def object?; type == :object; end
133
+ def reference?; type == :reference; end
134
+ def binary?; type == :binary; end
135
+ def date?; type == :date; end
136
+ def timestamp?; type == :timestamp; end
137
+ def time?; type == :time; end
138
+ def duration?; type == :duration; end
139
+ def numeric?; integer? || float? || currency? || percent?; end
140
+
141
+ def temporal?; date? || timestamp? || time? || duration?; end
142
+
143
+ # Coercion accessors
144
+ def as_bool; value; end
145
+ def as_int; value.to_i; end
146
+ def as_float; value.to_f; end
147
+ def as_string; value.to_s; end
148
+ def as_array; value; end
149
+ def as_object; value; end
150
+
151
+ # Coerce to numeric value
152
+ def to_number
153
+ case type
154
+ when :integer then value
155
+ when :float then value
156
+ when :float_raw then value.to_f
157
+ when :currency then value.to_f
158
+ when :currency_raw then value.to_f
159
+ when :percent then value
160
+ when :string
161
+ return value.to_i if value.match?(/\A-?\d+\z/)
162
+ return value.to_f if value.match?(/\A-?\d+(\.\d+)?([eE][+-]?\d+)?\z/)
163
+
164
+ 0
165
+ when :bool then value ? 1 : 0
166
+ else 0
167
+ end
168
+ end
169
+
170
+ # Coerce to string representation
171
+ def to_string
172
+ case type
173
+ when :null then ""
174
+ when :bool then value.to_s
175
+ when :integer, :float, :percent then value.to_s
176
+ when :float_raw, :currency_raw then value.to_s
177
+ when :currency then value.is_a?(BigDecimal) ? value.to_s("F") : value.to_s
178
+ when :string then value
179
+ when :array then JSON.generate(to_ruby)
180
+ when :object then JSON.generate(to_ruby)
181
+ else value.to_s
182
+ end
183
+ end
184
+
185
+ # Truthiness: null/false/0/"" are falsy
186
+ def truthy?
187
+ case type
188
+ when :null then false
189
+ when :bool then value
190
+ when :integer then value != 0
191
+ when :float, :float_raw then to_number != 0.0
192
+ when :string then !value.empty?
193
+ when :currency, :currency_raw then to_number != 0.0
194
+ when :percent then value != 0.0
195
+ when :array then true
196
+ when :object then true
197
+ else true
198
+ end
199
+ end
200
+
201
+ # Object/array access helpers
202
+ def get(key)
203
+ return nil unless object?
204
+
205
+ value[key]
206
+ end
207
+
208
+ def get_index(index)
209
+ return nil unless array?
210
+
211
+ value[index]
212
+ end
213
+
214
+ def ==(other)
215
+ other.is_a?(DynValue) && type == other.type && value == other.value &&
216
+ decimal_places == other.decimal_places && currency_code == other.currency_code
217
+ end
218
+ alias eql? ==
219
+
220
+ def hash
221
+ [type, value, decimal_places, currency_code].hash
222
+ end
223
+
224
+ # Convert Ruby native object to DynValue
225
+ def self.from_ruby(obj)
226
+ case obj
227
+ when DynValue then obj
228
+ when nil then of_null
229
+ when true, false then of_bool(obj)
230
+ when Integer then of_integer(obj)
231
+ when Float then of_float(obj)
232
+ when BigDecimal then of_float(obj.to_f)
233
+ when String then of_string(obj)
234
+ when Array then of_array(obj.map { |v| from_ruby(v) })
235
+ when Hash then of_object(obj.transform_keys(&:to_s).transform_values { |v| from_ruby(v) })
236
+ else of_string(obj.to_s)
237
+ end
238
+ end
239
+
240
+ # Convert DynValue to Ruby native object
241
+ def to_ruby
242
+ case type
243
+ when :null then nil
244
+ when :bool then value
245
+ when :integer then value
246
+ when :float then value
247
+ when :float_raw then value.to_f
248
+ when :string then value
249
+ when :currency then value.is_a?(BigDecimal) ? value.to_f : value.to_f
250
+ when :currency_raw then value.to_f
251
+ when :percent then value
252
+ when :date, :timestamp, :time, :duration then value.to_s
253
+ when :reference then value
254
+ when :binary then value
255
+ when :array then value.map(&:to_ruby)
256
+ when :object then value.transform_values(&:to_ruby)
257
+ else value
258
+ end
259
+ end
260
+
261
+ def to_s
262
+ case type
263
+ when :null then "null"
264
+ when :bool then value.to_s
265
+ else value.to_s
266
+ end
267
+ end
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Odin
4
+ module Errors
5
+ module ParseErrorCode
6
+ UNEXPECTED_CHARACTER = -"P001"
7
+ BARE_STRING_NOT_ALLOWED = -"P002"
8
+ INVALID_ARRAY_INDEX = -"P003"
9
+ UNTERMINATED_STRING = -"P004"
10
+ INVALID_ESCAPE_SEQUENCE = -"P005"
11
+ INVALID_TYPE_PREFIX = -"P006"
12
+ DUPLICATE_PATH_ASSIGNMENT = -"P007"
13
+ INVALID_HEADER_SYNTAX = -"P008"
14
+ INVALID_DIRECTIVE = -"P009"
15
+ MAXIMUM_DEPTH_EXCEEDED = -"P010"
16
+ MAXIMUM_DOCUMENT_SIZE_EXCEEDED = -"P011"
17
+ INVALID_UTF8_SEQUENCE = -"P012"
18
+ NON_CONTIGUOUS_ARRAY_INDICES = -"P013"
19
+ EMPTY_DOCUMENT = -"P014"
20
+ ARRAY_INDEX_OUT_OF_RANGE = -"P015"
21
+
22
+ ALL = {
23
+ "P001" => "Unexpected character",
24
+ "P002" => "Strings must be quoted",
25
+ "P003" => "Invalid array index",
26
+ "P004" => "Unterminated string",
27
+ "P005" => "Invalid escape sequence",
28
+ "P006" => "Invalid type prefix",
29
+ "P007" => "Duplicate path assignment",
30
+ "P008" => "Invalid header syntax",
31
+ "P009" => "Invalid directive",
32
+ "P010" => "Maximum depth exceeded",
33
+ "P011" => "Maximum document size exceeded",
34
+ "P012" => "Invalid UTF-8 sequence",
35
+ "P013" => "Non-contiguous array indices",
36
+ "P014" => "Empty document",
37
+ "P015" => "Array index out of range"
38
+ }.freeze
39
+
40
+ def self.message(code)
41
+ ALL[code] || "Unknown error"
42
+ end
43
+ end
44
+
45
+ module ValidationErrorCode
46
+ REQUIRED_FIELD_MISSING = -"V001"
47
+ TYPE_MISMATCH = -"V002"
48
+ VALUE_OUT_OF_BOUNDS = -"V003"
49
+ PATTERN_MISMATCH = -"V004"
50
+ INVALID_ENUM_VALUE = -"V005"
51
+ ARRAY_LENGTH_VIOLATION = -"V006"
52
+ UNIQUE_CONSTRAINT_VIOLATION = -"V007"
53
+ INVARIANT_VIOLATION = -"V008"
54
+ CARDINALITY_CONSTRAINT_VIOLATION = -"V009"
55
+ CONDITIONAL_REQUIREMENT_NOT_MET = -"V010"
56
+ UNKNOWN_FIELD = -"V011"
57
+ CIRCULAR_REFERENCE = -"V012"
58
+ UNRESOLVED_REFERENCE = -"V013"
59
+
60
+ ALL = {
61
+ "V001" => "Required field missing",
62
+ "V002" => "Type mismatch",
63
+ "V003" => "Value out of bounds",
64
+ "V004" => "Pattern mismatch",
65
+ "V005" => "Invalid enum value",
66
+ "V006" => "Array length violation",
67
+ "V007" => "Unique constraint violation",
68
+ "V008" => "Invariant violation",
69
+ "V009" => "Cardinality constraint violation",
70
+ "V010" => "Conditional requirement not met",
71
+ "V011" => "Unknown field",
72
+ "V012" => "Circular reference",
73
+ "V013" => "Unresolved reference"
74
+ }.freeze
75
+
76
+ def self.message(code)
77
+ ALL[code] || "Unknown error"
78
+ end
79
+ end
80
+
81
+ class OdinError < StandardError
82
+ attr_reader :code
83
+
84
+ def initialize(code, message)
85
+ @code = code
86
+ super("[#{code}] #{message}")
87
+ end
88
+ end
89
+
90
+ class ParseError < OdinError
91
+ attr_reader :line, :column
92
+
93
+ def initialize(code, line, column, detail = nil)
94
+ @line = line
95
+ @column = column
96
+ msg = ParseErrorCode.message(code)
97
+ msg = "#{msg}: #{detail}" if detail
98
+ msg = "#{msg} at line #{line}, column #{column}"
99
+ super(code, msg)
100
+ end
101
+ end
102
+
103
+ class ValidationError
104
+ attr_reader :path, :code, :message, :expected, :actual, :schema_path
105
+
106
+ def initialize(code:, path:, message:, expected: nil, actual: nil, schema_path: nil)
107
+ @code = code
108
+ @path = path
109
+ @message = message
110
+ @expected = expected
111
+ @actual = actual
112
+ @schema_path = schema_path
113
+ end
114
+
115
+ def to_s
116
+ "[#{code}] #{message} at '#{path}'"
117
+ end
118
+ end
119
+
120
+ class ValidationResult
121
+ attr_reader :errors
122
+
123
+ def initialize(errors = [])
124
+ @errors = errors.freeze
125
+ end
126
+
127
+ def valid?
128
+ errors.empty?
129
+ end
130
+
131
+ def self.valid
132
+ new([])
133
+ end
134
+
135
+ def self.with_errors(errors)
136
+ new(errors)
137
+ end
138
+ end
139
+
140
+ class PatchError < OdinError
141
+ attr_reader :path
142
+
143
+ def initialize(message, path)
144
+ @path = path
145
+ super("PATCH", "#{message} at '#{path}'")
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Odin
4
+ module Types
5
+ class OdinModifiers
6
+ attr_reader :required, :confidential, :deprecated, :attr
7
+
8
+ def initialize(required: false, confidential: false, deprecated: false, attr: nil)
9
+ @required = required
10
+ @confidential = confidential
11
+ @deprecated = deprecated
12
+ @attr = attr
13
+ freeze
14
+ end
15
+
16
+ NONE = new
17
+
18
+ def any?
19
+ required || confidential || deprecated
20
+ end
21
+
22
+ def ==(other)
23
+ other.is_a?(OdinModifiers) &&
24
+ required == other.required &&
25
+ confidential == other.confidential &&
26
+ deprecated == other.deprecated &&
27
+ self.attr == other.attr
28
+ end
29
+ alias eql? ==
30
+
31
+ def hash
32
+ [required, confidential, deprecated, self.attr].hash
33
+ end
34
+
35
+ def to_s
36
+ parts = []
37
+ parts << "required" if required
38
+ parts << "confidential" if confidential
39
+ parts << "deprecated" if deprecated
40
+ parts << "attr=#{self.attr}" if self.attr
41
+ parts.empty? ? "OdinModifiers(none)" : "OdinModifiers(#{parts.join(', ')})"
42
+ end
43
+ end
44
+ end
45
+ end