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,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Odin
6
+ module Validation
7
+ class SchemaSerializer
8
+ # Serialize an OdinSchema back to ODIN text
9
+ def serialize(schema)
10
+ lines = []
11
+
12
+ # 1. Metadata header
13
+ unless schema.metadata.empty?
14
+ lines << "{$}"
15
+ schema.metadata.each do |key, value|
16
+ lines << "#{key} = \"#{escape_string(value.to_s)}\""
17
+ end
18
+ lines << ""
19
+ end
20
+
21
+ # 2. Import directives
22
+ schema.imports.each do |imp|
23
+ if imp.alias_name
24
+ lines << "@import \"#{imp.path}\" as #{imp.alias_name}"
25
+ else
26
+ lines << "@import \"#{imp.path}\""
27
+ end
28
+ end
29
+ lines << "" unless schema.imports.empty?
30
+
31
+ # 3. Type definitions
32
+ schema.types.each do |type_name, schema_type|
33
+ lines << "{@#{type_name}}"
34
+
35
+ # Composition
36
+ if schema_type.composition
37
+ lines << "= #{schema_type.composition}"
38
+ end
39
+
40
+ schema_type.fields.each do |field_name, field|
41
+ lines << serialize_field(field_name, field)
42
+ end
43
+
44
+ # Object constraints for this type
45
+ if schema.object_constraints[type_name]
46
+ schema.object_constraints[type_name].each do |constraint|
47
+ lines << serialize_object_constraint(constraint)
48
+ end
49
+ end
50
+
51
+ lines << ""
52
+ end
53
+
54
+ # 4. Root fields
55
+ unless schema.fields.empty?
56
+ # Group by section
57
+ root_fields = {}
58
+ sectioned_fields = Hash.new { |h, k| h[k] = {} }
59
+
60
+ schema.fields.each do |path, field|
61
+ parts = path.split(".", 2)
62
+ if parts.length > 1
63
+ sectioned_fields[parts[0]][parts[1]] = field
64
+ else
65
+ root_fields[path] = field
66
+ end
67
+ end
68
+
69
+ root_fields.each do |name, field|
70
+ lines << serialize_field(name, field)
71
+ end
72
+ lines << "" unless root_fields.empty?
73
+
74
+ sectioned_fields.each do |section, fields|
75
+ lines << "{#{section}}"
76
+ fields.each do |name, field|
77
+ lines << serialize_field(name, field)
78
+ end
79
+
80
+ if schema.object_constraints[section]
81
+ schema.object_constraints[section].each do |constraint|
82
+ lines << serialize_object_constraint(constraint)
83
+ end
84
+ end
85
+
86
+ lines << ""
87
+ end
88
+ end
89
+
90
+ # 5. Array definitions
91
+ schema.arrays.each do |array_path, schema_array|
92
+ header = "{#{array_path}[]"
93
+ if schema_array.columns && !schema_array.columns.empty?
94
+ header += " : #{schema_array.columns.join(', ')}"
95
+ end
96
+ header += "}"
97
+ lines << header
98
+
99
+ # Array-level constraints
100
+ bounds_parts = []
101
+ if schema_array.min_items || schema_array.max_items
102
+ min_str = schema_array.min_items&.to_s || ""
103
+ max_str = schema_array.max_items&.to_s || ""
104
+ bounds_parts << ":(#{min_str}..#{max_str})"
105
+ end
106
+ bounds_parts << ":unique" if schema_array.unique
107
+ lines << bounds_parts.join("") unless bounds_parts.empty?
108
+
109
+ schema_array.item_fields.each do |field_name, field|
110
+ lines << serialize_field(field_name, field)
111
+ end
112
+
113
+ if schema.object_constraints[array_path]
114
+ schema.object_constraints[array_path].each do |constraint|
115
+ lines << serialize_object_constraint(constraint)
116
+ end
117
+ end
118
+
119
+ lines << ""
120
+ end
121
+
122
+ # 6. Orphan object constraints (not already output with types/fields/arrays)
123
+ outputted_scopes = Set.new
124
+ schema.types.each_key { |k| outputted_scopes.add(k) }
125
+ schema.arrays.each_key { |k| outputted_scopes.add(k) }
126
+ # Sectioned fields
127
+ schema.fields.each_key do |path|
128
+ parts = path.split(".", 2)
129
+ outputted_scopes.add(parts[0]) if parts.length > 1
130
+ end
131
+ outputted_scopes.add("") # root constraints handled inline
132
+
133
+ schema.object_constraints.each do |scope, constraints|
134
+ next if outputted_scopes.include?(scope)
135
+ lines << "{#{scope}}" unless scope.empty?
136
+ constraints.each do |constraint|
137
+ lines << serialize_object_constraint(constraint)
138
+ end
139
+ lines << ""
140
+ end
141
+
142
+ lines.join("\n").rstrip + "\n"
143
+ end
144
+
145
+ private
146
+
147
+ def serialize_field(name, field)
148
+ parts = [name, "="]
149
+ spec = []
150
+
151
+ # Modifiers
152
+ spec << "!" if field.required
153
+ spec << "~" if field.nullable
154
+ spec << "*" if field.redacted
155
+ spec << "-" if field.deprecated
156
+
157
+ # Type
158
+ type_str = serialize_type(field.field_type, field.type_ref)
159
+ spec << type_str unless type_str.empty?
160
+
161
+ # Constraints
162
+ field.constraints.each do |constraint|
163
+ spec << serialize_constraint(constraint)
164
+ end
165
+
166
+ # Directives
167
+ spec << ":computed" if field.computed
168
+ spec << ":immutable" if field.immutable
169
+
170
+ # Conditionals
171
+ field.conditionals.each do |cond|
172
+ prefix = cond.unless ? ":unless" : ":if"
173
+ if cond.value == "true" && cond.operator == "="
174
+ spec << "#{prefix} #{cond.field}"
175
+ else
176
+ spec << "#{prefix} #{cond.field} #{cond.operator} #{cond.value}"
177
+ end
178
+ end
179
+
180
+ # Default value
181
+ spec << field.default_value.to_s if field.default_value
182
+
183
+ spec_str = spec.join("")
184
+ # Schema field values must be quoted strings for ODIN parsing
185
+ "#{name} = \"#{escape_string(spec_str)}\"".rstrip
186
+ end
187
+
188
+ def serialize_type(field_type, type_ref)
189
+ case field_type
190
+ when Types::SchemaFieldType::STRING then ""
191
+ when Types::SchemaFieldType::INTEGER then "##"
192
+ when Types::SchemaFieldType::NUMBER then "#"
193
+ when Types::SchemaFieldType::BOOLEAN then "?"
194
+ when Types::SchemaFieldType::CURRENCY then "#$"
195
+ when Types::SchemaFieldType::PERCENT then "#%"
196
+ when Types::SchemaFieldType::DATE then "date"
197
+ when Types::SchemaFieldType::TIMESTAMP then "timestamp"
198
+ when Types::SchemaFieldType::TIME then "time"
199
+ when Types::SchemaFieldType::DURATION then "duration"
200
+ when Types::SchemaFieldType::REFERENCE then type_ref ? "@#{type_ref}" : "@"
201
+ when Types::SchemaFieldType::BINARY then "^"
202
+ when Types::SchemaFieldType::NULL then "~"
203
+ when Types::SchemaFieldType::ANY then ""
204
+ else ""
205
+ end
206
+ end
207
+
208
+ def serialize_constraint(constraint)
209
+ case constraint
210
+ when Types::BoundsConstraint
211
+ min_str = constraint.min&.to_s || ""
212
+ max_str = constraint.max&.to_s || ""
213
+ if constraint.min == constraint.max && constraint.min
214
+ ":(#{min_str})"
215
+ else
216
+ ":(#{min_str}..#{max_str})"
217
+ end
218
+ when Types::PatternConstraint
219
+ ":/#{constraint.pattern}/"
220
+ when Types::EnumConstraint
221
+ "(#{constraint.values.join(', ')})"
222
+ when Types::SizeConstraint
223
+ min_str = constraint.min_length&.to_s || ""
224
+ max_str = constraint.max_length&.to_s || ""
225
+ ":(#{min_str}..#{max_str})"
226
+ when Types::FormatConstraint
227
+ ":format #{constraint.format_name}"
228
+ when Types::UniqueConstraint
229
+ ":unique"
230
+ else
231
+ ""
232
+ end
233
+ end
234
+
235
+ def serialize_object_constraint(constraint)
236
+ case constraint
237
+ when Types::SchemaInvariant
238
+ ":invariant #{constraint.expression}"
239
+ when Types::SchemaCardinality
240
+ case constraint.cardinality_type
241
+ when "of"
242
+ min_str = constraint.min&.to_s || ""
243
+ max_str = constraint.max&.to_s || ""
244
+ ":of (#{min_str}..#{max_str}) #{constraint.fields.join(', ')}"
245
+ when "one_of"
246
+ ":one_of #{constraint.fields.join(', ')}"
247
+ when "exactly_one"
248
+ ":exactly_one #{constraint.fields.join(', ')}"
249
+ when "at_most_one"
250
+ ":at_most_one #{constraint.fields.join(', ')}"
251
+ end
252
+ else
253
+ ""
254
+ end
255
+ end
256
+
257
+ def escape_string(str)
258
+ str.gsub("\\", "\\\\").gsub('"', '\\"').gsub("\n", "\\n").gsub("\t", "\\t")
259
+ end
260
+ end
261
+ end
262
+ end