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
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Odin
|
|
4
|
+
module Validation
|
|
5
|
+
class SchemaParser
|
|
6
|
+
# Core schema metadata keys (always metadata, never field definitions)
|
|
7
|
+
SCHEMA_META_KEYS = %w[odin schema].freeze
|
|
8
|
+
|
|
9
|
+
KEYWORD_TYPES = [
|
|
10
|
+
["timestamp", Types::SchemaFieldType::TIMESTAMP],
|
|
11
|
+
["datetime", Types::SchemaFieldType::TIMESTAMP],
|
|
12
|
+
["date", Types::SchemaFieldType::DATE],
|
|
13
|
+
["time", Types::SchemaFieldType::TIME],
|
|
14
|
+
["duration", Types::SchemaFieldType::DURATION],
|
|
15
|
+
["string", Types::SchemaFieldType::STRING],
|
|
16
|
+
["integer", Types::SchemaFieldType::INTEGER],
|
|
17
|
+
["number", Types::SchemaFieldType::NUMBER],
|
|
18
|
+
["boolean", Types::SchemaFieldType::BOOLEAN],
|
|
19
|
+
["currency", Types::SchemaFieldType::CURRENCY],
|
|
20
|
+
["percent", Types::SchemaFieldType::PERCENT],
|
|
21
|
+
["binary", Types::SchemaFieldType::BINARY],
|
|
22
|
+
["null", Types::SchemaFieldType::NULL],
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
@metadata = {}
|
|
27
|
+
@types = {}
|
|
28
|
+
@fields = {}
|
|
29
|
+
@arrays = {}
|
|
30
|
+
@imports = []
|
|
31
|
+
@object_constraints = {}
|
|
32
|
+
|
|
33
|
+
@current_header = nil
|
|
34
|
+
@current_header_kind = :root # :root, :metadata, :type, :array, :object
|
|
35
|
+
@current_type_name = nil
|
|
36
|
+
@current_array_path = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Parse an ODIN schema document text into an OdinSchema
|
|
40
|
+
def parse_schema(text)
|
|
41
|
+
lines = text.split("\n")
|
|
42
|
+
lines.each do |raw_line|
|
|
43
|
+
line = raw_line.strip
|
|
44
|
+
|
|
45
|
+
if line.empty?
|
|
46
|
+
# Blank line resets metadata mode to root
|
|
47
|
+
if @current_header_kind == :metadata
|
|
48
|
+
@current_header_kind = :root
|
|
49
|
+
@current_header = nil
|
|
50
|
+
end
|
|
51
|
+
next
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
next if line.start_with?(";")
|
|
55
|
+
|
|
56
|
+
if line.start_with?("@import ")
|
|
57
|
+
parse_import(line)
|
|
58
|
+
elsif line.start_with?("{") && line.include?("}")
|
|
59
|
+
parse_header(line)
|
|
60
|
+
elsif line.start_with?("@") && !line.include?("=")
|
|
61
|
+
parse_bare_type_line(line)
|
|
62
|
+
elsif line.start_with?(":")
|
|
63
|
+
parse_object_constraint(line)
|
|
64
|
+
elsif line.include?("=")
|
|
65
|
+
parse_field_definition(line)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
Types::OdinSchema.new(
|
|
70
|
+
metadata: @metadata,
|
|
71
|
+
types: @types,
|
|
72
|
+
fields: @fields,
|
|
73
|
+
arrays: @arrays,
|
|
74
|
+
imports: @imports,
|
|
75
|
+
object_constraints: @object_constraints
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def parse_import(line)
|
|
82
|
+
parts = line[8..].strip.split
|
|
83
|
+
@imports << Types::SchemaImport.new(path: parts[0]) if parts.any?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Handle bare @TypeName lines (not inside {})
|
|
87
|
+
# Supports: @TypeName, @Extended : @Base, @TypeA & @TypeB
|
|
88
|
+
def parse_bare_type_line(line)
|
|
89
|
+
rest = line[1..].strip
|
|
90
|
+
@current_header_kind = :bare_type
|
|
91
|
+
@current_array_path = nil
|
|
92
|
+
|
|
93
|
+
# Check for inheritance: @Extended : @Base
|
|
94
|
+
if rest.include?(" : ")
|
|
95
|
+
parts = rest.split(" : ", 2)
|
|
96
|
+
type_name = parts[0].strip
|
|
97
|
+
parent_refs = parts[1].strip.split(/\s*,\s*/).map { |p| p.strip.sub(/^@/, "") }
|
|
98
|
+
@current_type_name = type_name
|
|
99
|
+
@types[type_name] ||= Types::SchemaType.new(name: type_name, parent_types: parent_refs)
|
|
100
|
+
elsif rest.include?(" & ")
|
|
101
|
+
# Intersection: @TypeA & @TypeB (this is a composed result)
|
|
102
|
+
# The type name is before any & — but actually the type definition is
|
|
103
|
+
# @SmallPositive\n= @Positive & @SmallNumber
|
|
104
|
+
# This is handled in field_definition when = is present
|
|
105
|
+
type_name = rest.strip
|
|
106
|
+
@current_type_name = type_name
|
|
107
|
+
@types[type_name] ||= Types::SchemaType.new(name: type_name)
|
|
108
|
+
else
|
|
109
|
+
type_name = rest.strip
|
|
110
|
+
# Handle namespace prefix &
|
|
111
|
+
namespace = nil
|
|
112
|
+
if type_name.start_with?("&")
|
|
113
|
+
raw = type_name[1..]
|
|
114
|
+
dot_idx = raw.rindex(".")
|
|
115
|
+
if dot_idx
|
|
116
|
+
namespace = raw[0...dot_idx]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
@current_type_name = type_name
|
|
120
|
+
@types[type_name] ||= Types::SchemaType.new(name: type_name, namespace: namespace)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
@current_header = "@#{@current_type_name}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def parse_header(line)
|
|
127
|
+
brace_end = line.index("}")
|
|
128
|
+
content = line[1...brace_end].strip
|
|
129
|
+
after_header = line[(brace_end + 1)..].to_s.strip
|
|
130
|
+
|
|
131
|
+
if content == "$" || content == "$derivation"
|
|
132
|
+
@current_header = content
|
|
133
|
+
@current_header_kind = :metadata
|
|
134
|
+
@current_type_name = nil
|
|
135
|
+
@current_array_path = nil
|
|
136
|
+
elsif content.start_with?("@")
|
|
137
|
+
# Type definition
|
|
138
|
+
type_name = content[1..]
|
|
139
|
+
@current_header = content
|
|
140
|
+
@current_header_kind = :type
|
|
141
|
+
@current_type_name = type_name
|
|
142
|
+
@current_array_path = nil
|
|
143
|
+
@types[type_name] ||= Types::SchemaType.new(name: type_name, fields: {})
|
|
144
|
+
elsif content.end_with?("[]")
|
|
145
|
+
# Array definition
|
|
146
|
+
array_path = content[0...-2]
|
|
147
|
+
@current_header = array_path
|
|
148
|
+
@current_header_kind = :array
|
|
149
|
+
@current_type_name = nil
|
|
150
|
+
@current_array_path = array_path
|
|
151
|
+
|
|
152
|
+
min_items = nil
|
|
153
|
+
max_items = nil
|
|
154
|
+
unique = false
|
|
155
|
+
|
|
156
|
+
if after_header && !after_header.empty?
|
|
157
|
+
unique, min_items, max_items = parse_array_constraint_text(after_header)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
@arrays[array_path] = Types::SchemaArray.new(
|
|
161
|
+
path: array_path,
|
|
162
|
+
item_fields: {},
|
|
163
|
+
min_items: min_items,
|
|
164
|
+
max_items: max_items,
|
|
165
|
+
unique: unique
|
|
166
|
+
)
|
|
167
|
+
else
|
|
168
|
+
# Regular object header
|
|
169
|
+
@current_header = content
|
|
170
|
+
@current_header_kind = :object
|
|
171
|
+
@current_type_name = nil
|
|
172
|
+
@current_array_path = nil
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def parse_array_constraint_text(text)
|
|
177
|
+
unique = false
|
|
178
|
+
min_items = nil
|
|
179
|
+
max_items = nil
|
|
180
|
+
|
|
181
|
+
if text.include?(":unique")
|
|
182
|
+
unique = true
|
|
183
|
+
text = text.gsub(":unique", "").strip
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
bounds_match = text.match(/:\((\d*)\.\.(\d*)\)/)
|
|
187
|
+
if bounds_match
|
|
188
|
+
min_items = bounds_match[1].to_i unless bounds_match[1].empty?
|
|
189
|
+
max_items = bounds_match[2].to_i unless bounds_match[2].empty?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
[unique, min_items, max_items]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def parse_object_constraint(line)
|
|
196
|
+
scope = @current_header || ""
|
|
197
|
+
@object_constraints[scope] ||= []
|
|
198
|
+
|
|
199
|
+
if line.start_with?(":invariant ")
|
|
200
|
+
expr = line[11..].strip
|
|
201
|
+
@object_constraints[scope] << Types::SchemaInvariant.new(expression: expr)
|
|
202
|
+
elsif line.start_with?(":one_of ")
|
|
203
|
+
fields = parse_field_list(line[8..])
|
|
204
|
+
@object_constraints[scope] << Types::SchemaCardinality.new(
|
|
205
|
+
cardinality_type: "one_of", fields: fields, min: 1
|
|
206
|
+
)
|
|
207
|
+
elsif line.start_with?(":exactly_one ")
|
|
208
|
+
fields = parse_field_list(line[13..])
|
|
209
|
+
@object_constraints[scope] << Types::SchemaCardinality.new(
|
|
210
|
+
cardinality_type: "exactly_one", fields: fields, min: 1, max: 1
|
|
211
|
+
)
|
|
212
|
+
elsif line.start_with?(":at_most_one ")
|
|
213
|
+
fields = parse_field_list(line[13..])
|
|
214
|
+
@object_constraints[scope] << Types::SchemaCardinality.new(
|
|
215
|
+
cardinality_type: "at_most_one", fields: fields, max: 1
|
|
216
|
+
)
|
|
217
|
+
elsif line.start_with?(":of ")
|
|
218
|
+
parse_of_constraint(line[4..], scope)
|
|
219
|
+
elsif line.start_with?(":(")
|
|
220
|
+
# Array bounds constraint: :(min..max) — applies to current array
|
|
221
|
+
if @current_array_path && @arrays[@current_array_path]
|
|
222
|
+
unique, min_items, max_items = parse_array_constraint_text(line)
|
|
223
|
+
old = @arrays[@current_array_path]
|
|
224
|
+
@arrays[@current_array_path] = Types::SchemaArray.new(
|
|
225
|
+
path: old.path,
|
|
226
|
+
item_fields: old.item_fields,
|
|
227
|
+
min_items: min_items || old.min_items,
|
|
228
|
+
max_items: max_items || old.max_items,
|
|
229
|
+
unique: unique || old.unique,
|
|
230
|
+
columns: old.columns
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
elsif line.start_with?(":unique")
|
|
234
|
+
if @current_array_path && @arrays[@current_array_path]
|
|
235
|
+
# Rebuild array with unique flag
|
|
236
|
+
old = @arrays[@current_array_path]
|
|
237
|
+
@arrays[@current_array_path] = Types::SchemaArray.new(
|
|
238
|
+
path: old.path,
|
|
239
|
+
item_fields: old.item_fields,
|
|
240
|
+
min_items: old.min_items,
|
|
241
|
+
max_items: old.max_items,
|
|
242
|
+
unique: true,
|
|
243
|
+
columns: old.columns
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def parse_of_constraint(text, scope)
|
|
250
|
+
bounds_match = text.strip.match(/\A\((\d*)\.\.(\d*)\)\s*(.*)\z/)
|
|
251
|
+
return unless bounds_match
|
|
252
|
+
|
|
253
|
+
min_val = bounds_match[1].empty? ? nil : bounds_match[1].to_i
|
|
254
|
+
max_val = bounds_match[2].empty? ? nil : bounds_match[2].to_i
|
|
255
|
+
fields = parse_field_list(bounds_match[3])
|
|
256
|
+
@object_constraints[scope] << Types::SchemaCardinality.new(
|
|
257
|
+
cardinality_type: "of", fields: fields, min: min_val, max: max_val
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def parse_field_list(text)
|
|
262
|
+
text.split(",").map(&:strip).reject(&:empty?)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Parse type-level constraint line for bare @TypeName definitions
|
|
266
|
+
# e.g., = :(10..15) :pattern "..." or = @TypeA & @TypeB or = ##:(1..)
|
|
267
|
+
def parse_type_constraint_line(spec)
|
|
268
|
+
return unless @current_type_name
|
|
269
|
+
|
|
270
|
+
# Check for intersection: @TypeA & @TypeB
|
|
271
|
+
if spec.include?(" & ")
|
|
272
|
+
refs = spec.split("&").map { |p| p.strip.sub(/^@/, "") }
|
|
273
|
+
old = @types[@current_type_name]
|
|
274
|
+
@types[@current_type_name] = Types::SchemaType.new(
|
|
275
|
+
name: old.name, fields: old.fields, namespace: old.namespace,
|
|
276
|
+
intersection_types: refs, parent_types: old.parent_types
|
|
277
|
+
)
|
|
278
|
+
return
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Check for :deprecated directive
|
|
282
|
+
deprecated = false
|
|
283
|
+
deprecation_msg = nil
|
|
284
|
+
if spec.include?(":deprecated")
|
|
285
|
+
deprecated = true
|
|
286
|
+
dep_match = spec.match(/:deprecated\s+"([^"]*)"/)
|
|
287
|
+
if dep_match
|
|
288
|
+
deprecation_msg = dep_match[1]
|
|
289
|
+
spec = spec.sub(/:deprecated\s+"[^"]*"/, "").strip
|
|
290
|
+
else
|
|
291
|
+
spec = spec.sub(/:deprecated/, "").strip
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Parse as a field spec to extract type and constraints
|
|
296
|
+
old = @types[@current_type_name]
|
|
297
|
+
existing_constraints = old.constraints.dup
|
|
298
|
+
|
|
299
|
+
unless spec.empty?
|
|
300
|
+
dummy_field = parse_field_spec(@current_type_name, spec)
|
|
301
|
+
base = field_type_to_string(dummy_field.field_type)
|
|
302
|
+
new_constraints = build_constraint_hash(dummy_field.constraints, dummy_field.field_type)
|
|
303
|
+
existing_constraints.merge!(new_constraints)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
@types[@current_type_name] = Types::SchemaType.new(
|
|
307
|
+
name: old.name, fields: old.fields, namespace: old.namespace,
|
|
308
|
+
base_type: old.base_type || (spec.empty? ? nil : base),
|
|
309
|
+
constraints: existing_constraints,
|
|
310
|
+
intersection_types: old.intersection_types, parent_types: old.parent_types
|
|
311
|
+
)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def field_type_to_string(ft)
|
|
315
|
+
case ft
|
|
316
|
+
when :string then "string"
|
|
317
|
+
when :integer then "integer"
|
|
318
|
+
when :number then "number"
|
|
319
|
+
when :boolean then "boolean"
|
|
320
|
+
when :currency then "currency"
|
|
321
|
+
when :percent then "percent"
|
|
322
|
+
when :date then "date"
|
|
323
|
+
when :timestamp then "timestamp"
|
|
324
|
+
when :time then "time"
|
|
325
|
+
when :duration then "duration"
|
|
326
|
+
when :reference then "reference"
|
|
327
|
+
when :binary then "binary"
|
|
328
|
+
when :null then "null"
|
|
329
|
+
else ft.to_s
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def build_constraint_hash(constraints, field_type)
|
|
334
|
+
h = {}
|
|
335
|
+
constraints.each do |c|
|
|
336
|
+
case c
|
|
337
|
+
when Types::BoundsConstraint
|
|
338
|
+
if field_type == :string || field_type == Types::SchemaFieldType::STRING
|
|
339
|
+
if c.min == c.max && c.min
|
|
340
|
+
h["length"] = c.min
|
|
341
|
+
else
|
|
342
|
+
h["minLength"] = c.min if c.min
|
|
343
|
+
h["maxLength"] = c.max if c.max
|
|
344
|
+
end
|
|
345
|
+
else
|
|
346
|
+
h["min"] = c.min if c.min
|
|
347
|
+
h["max"] = c.max if c.max
|
|
348
|
+
end
|
|
349
|
+
when Types::PatternConstraint
|
|
350
|
+
h["pattern"] = c.pattern
|
|
351
|
+
when Types::FormatConstraint
|
|
352
|
+
h["format"] = c.format_name
|
|
353
|
+
when Types::EnumConstraint
|
|
354
|
+
h["enum"] = c.values
|
|
355
|
+
when Types::UniqueConstraint
|
|
356
|
+
h["unique"] = true
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
h
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def parse_field_definition(line)
|
|
363
|
+
# Handle comment at end of line
|
|
364
|
+
comment_idx = find_comment(line)
|
|
365
|
+
line = line[0...comment_idx].rstrip if comment_idx >= 0
|
|
366
|
+
|
|
367
|
+
eq_idx = line.index("=")
|
|
368
|
+
return unless eq_idx
|
|
369
|
+
|
|
370
|
+
left = line[0...eq_idx].strip
|
|
371
|
+
right = line[(eq_idx + 1)..].strip
|
|
372
|
+
|
|
373
|
+
# Handle lines with empty left side: = constraint_spec
|
|
374
|
+
if left.empty?
|
|
375
|
+
if @current_header_kind == :bare_type && @current_type_name
|
|
376
|
+
# Type-level constraint: = :(10..15) :pattern "..." or = @TypeA & @TypeB
|
|
377
|
+
parse_type_constraint_line(right)
|
|
378
|
+
return
|
|
379
|
+
elsif right.start_with?(":")
|
|
380
|
+
if @current_array_path && @arrays[@current_array_path]
|
|
381
|
+
unique, min_items, max_items = parse_array_constraint_text(right)
|
|
382
|
+
old = @arrays[@current_array_path]
|
|
383
|
+
@arrays[@current_array_path] = Types::SchemaArray.new(
|
|
384
|
+
path: old.path,
|
|
385
|
+
item_fields: old.item_fields,
|
|
386
|
+
min_items: min_items || old.min_items,
|
|
387
|
+
max_items: max_items || old.max_items,
|
|
388
|
+
unique: unique || old.unique,
|
|
389
|
+
columns: old.columns
|
|
390
|
+
)
|
|
391
|
+
end
|
|
392
|
+
return
|
|
393
|
+
end
|
|
394
|
+
return
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
field_name = left
|
|
398
|
+
# Strip array indicator from field names
|
|
399
|
+
is_array_field = field_name.end_with?("[]")
|
|
400
|
+
field_name = field_name[0...-2] if is_array_field
|
|
401
|
+
|
|
402
|
+
# Unquote the value (Java does this before metadata check)
|
|
403
|
+
if right.length >= 2 && right[0] == '"' && right[-1] == '"'
|
|
404
|
+
right = right[1...-1]
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Store metadata
|
|
408
|
+
if @current_header_kind == :metadata
|
|
409
|
+
@metadata[field_name] = right
|
|
410
|
+
return
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Build full path based on context
|
|
414
|
+
full_path = case @current_header_kind
|
|
415
|
+
when :type then field_name
|
|
416
|
+
when :array then field_name
|
|
417
|
+
when :object
|
|
418
|
+
@current_header ? "#{@current_header}.#{field_name}" : field_name
|
|
419
|
+
else
|
|
420
|
+
field_name
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Parse the field spec from the right side
|
|
424
|
+
schema_field = parse_field_spec(full_path, right)
|
|
425
|
+
|
|
426
|
+
# Override type_ref for array fields
|
|
427
|
+
if is_array_field
|
|
428
|
+
schema_field = Types::SchemaField.new(
|
|
429
|
+
name: schema_field.name, field_type: schema_field.field_type,
|
|
430
|
+
required: schema_field.required, nullable: schema_field.nullable,
|
|
431
|
+
redacted: schema_field.redacted, deprecated: schema_field.deprecated,
|
|
432
|
+
constraints: schema_field.constraints, conditionals: schema_field.conditionals,
|
|
433
|
+
computed: schema_field.computed, immutable: schema_field.immutable,
|
|
434
|
+
type_ref: "array"
|
|
435
|
+
)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Store the field
|
|
439
|
+
case @current_header_kind
|
|
440
|
+
when :type, :bare_type
|
|
441
|
+
if @current_type_name && @types[@current_type_name]
|
|
442
|
+
old_type = @types[@current_type_name]
|
|
443
|
+
new_fields = old_type.fields.dup
|
|
444
|
+
new_fields[field_name] = schema_field
|
|
445
|
+
@types[@current_type_name] = Types::SchemaType.new(
|
|
446
|
+
name: old_type.name,
|
|
447
|
+
fields: new_fields,
|
|
448
|
+
namespace: old_type.namespace,
|
|
449
|
+
composition: old_type.composition,
|
|
450
|
+
base_type: old_type.base_type,
|
|
451
|
+
constraints: old_type.constraints,
|
|
452
|
+
intersection_types: old_type.intersection_types,
|
|
453
|
+
parent_types: old_type.parent_types
|
|
454
|
+
)
|
|
455
|
+
end
|
|
456
|
+
when :array
|
|
457
|
+
if @current_array_path && @arrays[@current_array_path]
|
|
458
|
+
old_arr = @arrays[@current_array_path]
|
|
459
|
+
new_fields = old_arr.item_fields.dup
|
|
460
|
+
new_fields[field_name] = schema_field
|
|
461
|
+
@arrays[@current_array_path] = Types::SchemaArray.new(
|
|
462
|
+
path: old_arr.path,
|
|
463
|
+
item_fields: new_fields,
|
|
464
|
+
min_items: old_arr.min_items,
|
|
465
|
+
max_items: old_arr.max_items,
|
|
466
|
+
unique: old_arr.unique,
|
|
467
|
+
columns: old_arr.columns
|
|
468
|
+
)
|
|
469
|
+
end
|
|
470
|
+
# Don't add array item fields to root @fields — they are only in item_fields
|
|
471
|
+
else
|
|
472
|
+
@fields[full_path] = schema_field
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def parse_field_spec(path, spec)
|
|
477
|
+
field_type = Types::SchemaFieldType::STRING
|
|
478
|
+
required = false
|
|
479
|
+
nullable = false
|
|
480
|
+
redacted = false
|
|
481
|
+
deprecated = false
|
|
482
|
+
computed = false
|
|
483
|
+
immutable = false
|
|
484
|
+
constraints = []
|
|
485
|
+
conditionals = []
|
|
486
|
+
type_ref = nil
|
|
487
|
+
|
|
488
|
+
return Types::SchemaField.new(
|
|
489
|
+
name: path, field_type: field_type, required: required,
|
|
490
|
+
nullable: nullable, redacted: redacted, deprecated: deprecated,
|
|
491
|
+
constraints: constraints, conditionals: conditionals,
|
|
492
|
+
computed: computed, immutable: immutable, type_ref: type_ref
|
|
493
|
+
) if spec.nil? || spec.empty?
|
|
494
|
+
|
|
495
|
+
pos = 0
|
|
496
|
+
# Parse modifiers: ! ~ * -
|
|
497
|
+
while pos < spec.length
|
|
498
|
+
case spec[pos]
|
|
499
|
+
when "!" then required = true; pos += 1
|
|
500
|
+
when "~" then nullable = true; pos += 1
|
|
501
|
+
when "*" then redacted = true; pos += 1
|
|
502
|
+
when "-" then deprecated = true; pos += 1
|
|
503
|
+
else break
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
remaining = spec[pos..].to_s.strip
|
|
508
|
+
|
|
509
|
+
# Parse type
|
|
510
|
+
type_result = parse_type_spec(remaining)
|
|
511
|
+
field_type = type_result[0]
|
|
512
|
+
remaining = type_result[1]
|
|
513
|
+
enum_values = type_result[2] # may be nil
|
|
514
|
+
type_ref = type_result[3] # may be nil
|
|
515
|
+
|
|
516
|
+
# If enum values were returned, add as constraint
|
|
517
|
+
if enum_values
|
|
518
|
+
constraints << Types::EnumConstraint.new(values: enum_values)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Parse constraints (append to existing, don't replace)
|
|
522
|
+
more_constraints, remaining = parse_constraints(remaining)
|
|
523
|
+
constraints.concat(more_constraints)
|
|
524
|
+
|
|
525
|
+
# Parse conditionals
|
|
526
|
+
conditionals, remaining = parse_conditionals(remaining)
|
|
527
|
+
|
|
528
|
+
# Parse directives
|
|
529
|
+
while remaining && !remaining.empty?
|
|
530
|
+
if remaining.start_with?(":computed")
|
|
531
|
+
computed = true
|
|
532
|
+
remaining = remaining[9..].to_s.strip
|
|
533
|
+
elsif remaining.start_with?(":immutable")
|
|
534
|
+
immutable = true
|
|
535
|
+
remaining = remaining[10..].to_s.strip
|
|
536
|
+
else
|
|
537
|
+
break
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
Types::SchemaField.new(
|
|
542
|
+
name: path, field_type: field_type, required: required,
|
|
543
|
+
nullable: nullable, redacted: redacted, deprecated: deprecated,
|
|
544
|
+
constraints: constraints, conditionals: conditionals,
|
|
545
|
+
computed: computed, immutable: immutable, type_ref: type_ref
|
|
546
|
+
)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Returns [type, remaining, enum_values_or_nil, type_ref_or_nil]
|
|
550
|
+
def parse_type_spec(text)
|
|
551
|
+
text = text.to_s.strip
|
|
552
|
+
return [Types::SchemaFieldType::STRING, "", nil, nil] if text.empty?
|
|
553
|
+
|
|
554
|
+
# Check for enum: (val1, val2, ...)
|
|
555
|
+
if text.start_with?("(")
|
|
556
|
+
return parse_enum_type(text)
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Type prefixes
|
|
560
|
+
if text.start_with?("##")
|
|
561
|
+
return [Types::SchemaFieldType::INTEGER, text[2..].to_s.strip, nil, nil]
|
|
562
|
+
elsif text.start_with?("#" + "$")
|
|
563
|
+
rest = text[2..].to_s.strip
|
|
564
|
+
if rest.start_with?(".") && rest.length > 1 && rest[1]&.match?(/\d/)
|
|
565
|
+
return [Types::SchemaFieldType::CURRENCY, rest[2..].to_s.strip, nil, nil]
|
|
566
|
+
end
|
|
567
|
+
return [Types::SchemaFieldType::CURRENCY, rest, nil, nil]
|
|
568
|
+
elsif text.start_with?("#" + "%")
|
|
569
|
+
return [Types::SchemaFieldType::PERCENT, text[2..].to_s.strip, nil, nil]
|
|
570
|
+
elsif text.start_with?("#")
|
|
571
|
+
rest = text[1..].to_s.strip
|
|
572
|
+
if rest.start_with?(".") && rest.length > 1 && rest[1]&.match?(/\d/)
|
|
573
|
+
return [Types::SchemaFieldType::NUMBER, rest[2..].to_s.strip, nil, nil]
|
|
574
|
+
end
|
|
575
|
+
return [Types::SchemaFieldType::NUMBER, rest, nil, nil]
|
|
576
|
+
elsif text.start_with?("?")
|
|
577
|
+
return [Types::SchemaFieldType::BOOLEAN, text[1..].to_s.strip, nil, nil]
|
|
578
|
+
elsif text.start_with?("@")
|
|
579
|
+
rest = text[1..]
|
|
580
|
+
name = ""
|
|
581
|
+
i = 0
|
|
582
|
+
while i < rest.length && !(" \t:,)".include?(rest[i]))
|
|
583
|
+
name += rest[i]
|
|
584
|
+
i += 1
|
|
585
|
+
end
|
|
586
|
+
remaining = rest[i..].to_s.strip
|
|
587
|
+
ref = name.empty? ? nil : "@#{name}"
|
|
588
|
+
return [Types::SchemaFieldType::REFERENCE, remaining, nil, ref]
|
|
589
|
+
elsif text.start_with?("^")
|
|
590
|
+
return [Types::SchemaFieldType::BINARY, text[1..].to_s.strip, nil, nil]
|
|
591
|
+
elsif text.start_with?("~")
|
|
592
|
+
return [Types::SchemaFieldType::NULL, text[1..].to_s.strip, nil, nil]
|
|
593
|
+
elsif text.start_with?('"')
|
|
594
|
+
return [Types::SchemaFieldType::STRING, text, nil, nil]
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# Keyword types
|
|
598
|
+
KEYWORD_TYPES.each do |keyword, type_val|
|
|
599
|
+
if text.start_with?(keyword)
|
|
600
|
+
after = text[keyword.length..]
|
|
601
|
+
if after.nil? || after.empty? || " \t:,)".include?(after[0])
|
|
602
|
+
return [type_val, after.to_s.strip, nil, nil]
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# Default: string
|
|
608
|
+
[Types::SchemaFieldType::STRING, text, nil, nil]
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def parse_enum_type(text)
|
|
612
|
+
depth = 0
|
|
613
|
+
close_idx = 0
|
|
614
|
+
text.each_char.with_index do |ch, i|
|
|
615
|
+
if ch == "("
|
|
616
|
+
depth += 1
|
|
617
|
+
elsif ch == ")"
|
|
618
|
+
depth -= 1
|
|
619
|
+
if depth == 0
|
|
620
|
+
close_idx = i
|
|
621
|
+
break
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
enum_content = text[1...close_idx]
|
|
627
|
+
values = enum_content.split(",").map { |v| v.strip.gsub(/\A["']|["']\z/, "") }
|
|
628
|
+
remaining = text[(close_idx + 1)..].to_s.strip
|
|
629
|
+
# Store as STRING type with an enum constraint - enum is handled as a constraint
|
|
630
|
+
[Types::SchemaFieldType::STRING, remaining, values, nil]
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def parse_constraints(text)
|
|
634
|
+
constraints = []
|
|
635
|
+
text = text.to_s.strip
|
|
636
|
+
|
|
637
|
+
# Handle enum values returned from parse_type_spec
|
|
638
|
+
# (This is handled via the 3-element return from parse_enum_type)
|
|
639
|
+
|
|
640
|
+
while text.start_with?(":")
|
|
641
|
+
if text.start_with?(":(")
|
|
642
|
+
# Bounds constraint
|
|
643
|
+
constraint, text = parse_bounds_constraint(text[1..])
|
|
644
|
+
constraints << constraint if constraint
|
|
645
|
+
elsif text.start_with?(":/")
|
|
646
|
+
# Pattern constraint
|
|
647
|
+
constraint, text = parse_pattern_constraint(text[1..])
|
|
648
|
+
constraints << constraint if constraint
|
|
649
|
+
elsif text.start_with?(":format ")
|
|
650
|
+
rest = text[8..].strip
|
|
651
|
+
fmt_name = ""
|
|
652
|
+
i = 0
|
|
653
|
+
while i < rest.length && !" \t:".include?(rest[i])
|
|
654
|
+
fmt_name += rest[i]
|
|
655
|
+
i += 1
|
|
656
|
+
end
|
|
657
|
+
constraints << Types::FormatConstraint.new(format_name: fmt_name) unless fmt_name.empty?
|
|
658
|
+
text = rest[i..].to_s.strip
|
|
659
|
+
elsif text.start_with?(":unique")
|
|
660
|
+
constraints << Types::UniqueConstraint.new
|
|
661
|
+
text = text[7..].to_s.strip
|
|
662
|
+
elsif text.start_with?(":pattern ")
|
|
663
|
+
rest = text[9..].strip
|
|
664
|
+
if rest.start_with?('"')
|
|
665
|
+
end_idx = rest.index('"', 1)
|
|
666
|
+
if end_idx
|
|
667
|
+
pat = rest[1...end_idx]
|
|
668
|
+
constraints << Types::PatternConstraint.new(pattern: pat)
|
|
669
|
+
text = rest[(end_idx + 1)..].to_s.strip
|
|
670
|
+
else
|
|
671
|
+
text = rest
|
|
672
|
+
end
|
|
673
|
+
else
|
|
674
|
+
text = rest
|
|
675
|
+
end
|
|
676
|
+
elsif text.start_with?(":if ") || text.start_with?(":unless ")
|
|
677
|
+
break # Conditionals handled separately
|
|
678
|
+
elsif text.start_with?(":computed") || text.start_with?(":immutable")
|
|
679
|
+
break # Directives handled separately
|
|
680
|
+
else
|
|
681
|
+
break
|
|
682
|
+
end
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
[constraints, text]
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def parse_bounds_constraint(text)
|
|
689
|
+
return [nil, text] unless text.start_with?("(")
|
|
690
|
+
|
|
691
|
+
depth = 0
|
|
692
|
+
close_idx = 0
|
|
693
|
+
text.each_char.with_index do |ch, i|
|
|
694
|
+
if ch == "("
|
|
695
|
+
depth += 1
|
|
696
|
+
elsif ch == ")"
|
|
697
|
+
depth -= 1
|
|
698
|
+
if depth == 0
|
|
699
|
+
close_idx = i
|
|
700
|
+
break
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
content = text[1...close_idx]
|
|
706
|
+
remaining = text[(close_idx + 1)..].to_s.strip
|
|
707
|
+
|
|
708
|
+
if content.include?("..")
|
|
709
|
+
parts = content.split("..", 2)
|
|
710
|
+
min_val = parts[0].strip.empty? ? nil : parse_bound_value(parts[0].strip)
|
|
711
|
+
max_val = parts[1].strip.empty? ? nil : parse_bound_value(parts[1].strip)
|
|
712
|
+
else
|
|
713
|
+
val = parse_bound_value(content.strip)
|
|
714
|
+
min_val = val
|
|
715
|
+
max_val = val
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
[Types::BoundsConstraint.new(min: min_val, max: max_val), remaining]
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
def parse_pattern_constraint(text)
|
|
722
|
+
return [nil, text] unless text.start_with?("/")
|
|
723
|
+
|
|
724
|
+
end_idx = text.index("/", 1)
|
|
725
|
+
return [nil, text] unless end_idx
|
|
726
|
+
|
|
727
|
+
pattern = text[1...end_idx]
|
|
728
|
+
remaining = text[(end_idx + 1)..].to_s.strip
|
|
729
|
+
[Types::PatternConstraint.new(pattern: pattern), remaining]
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def parse_conditionals(text)
|
|
733
|
+
conditionals = []
|
|
734
|
+
text = text.to_s.strip
|
|
735
|
+
|
|
736
|
+
while !text.empty?
|
|
737
|
+
if text.start_with?(":if ")
|
|
738
|
+
cond, text = parse_single_conditional(text[4..], false)
|
|
739
|
+
conditionals << cond if cond
|
|
740
|
+
elsif text.start_with?(":unless ")
|
|
741
|
+
cond, text = parse_single_conditional(text[8..], true)
|
|
742
|
+
conditionals << cond if cond
|
|
743
|
+
else
|
|
744
|
+
break
|
|
745
|
+
end
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
[conditionals, text]
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def parse_single_conditional(text, is_unless)
|
|
752
|
+
text = text.to_s.strip
|
|
753
|
+
|
|
754
|
+
# Try operator match first: field op value
|
|
755
|
+
match = text.match(/\A(\w[\w.]*)\s*(>=|<=|!=|>|<|=)\s*(\S+)(.*)\z/)
|
|
756
|
+
if match
|
|
757
|
+
field_name = match[1]
|
|
758
|
+
operator = match[2]
|
|
759
|
+
raw_value = match[3].gsub(/\A["']|["']\z/, "")
|
|
760
|
+
remaining = match[4].strip
|
|
761
|
+
else
|
|
762
|
+
# Shorthand boolean: :if field_name (implies field = true)
|
|
763
|
+
match = text.match(/\A(\w[\w.]*)(.*)\z/)
|
|
764
|
+
return [nil, text] unless match
|
|
765
|
+
field_name = match[1]
|
|
766
|
+
operator = "="
|
|
767
|
+
raw_value = "true"
|
|
768
|
+
remaining = match[2].strip
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
cond_field = field_name
|
|
772
|
+
|
|
773
|
+
[Types::SchemaConditional.new(
|
|
774
|
+
field: cond_field, operator: operator, value: raw_value, unless_cond: is_unless
|
|
775
|
+
), remaining]
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def parse_bound_value(s)
|
|
779
|
+
return nil if s.nil? || s.empty?
|
|
780
|
+
if s.match?(/\A-?\d+\z/)
|
|
781
|
+
s.to_i
|
|
782
|
+
elsif s.match?(/\A-?\d+\.\d+\z/)
|
|
783
|
+
s.to_f
|
|
784
|
+
else
|
|
785
|
+
s
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
def find_comment(line)
|
|
790
|
+
in_string = false
|
|
791
|
+
line.each_char.with_index do |ch, i|
|
|
792
|
+
if ch == '"' && (i == 0 || line[i - 1] != "\\")
|
|
793
|
+
in_string = !in_string
|
|
794
|
+
elsif ch == ";" && !in_string
|
|
795
|
+
return i
|
|
796
|
+
end
|
|
797
|
+
end
|
|
798
|
+
-1
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
def unquote(s)
|
|
802
|
+
s = s.strip
|
|
803
|
+
if s.length >= 2 && s[0] == '"' && s[-1] == '"'
|
|
804
|
+
s[1...-1]
|
|
805
|
+
elsif s.length >= 2 && s[0] == "'" && s[-1] == "'"
|
|
806
|
+
s[1...-1]
|
|
807
|
+
else
|
|
808
|
+
s
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
end
|
|
812
|
+
end
|
|
813
|
+
end
|