lutaml-lml 0.2.1 → 0.3.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 +4 -4
- data/lib/lutaml/lml/cli.rb +76 -17
- data/lib/lutaml/lml/data_processor/value_processing.rb +6 -1
- data/lib/lutaml/lml/format/adapter/mapping.rb +12 -0
- data/lib/lutaml/lml/format/adapter/standard_adapter.rb +24 -8
- data/lib/lutaml/lml/formatter/elk.rb +5 -2
- data/lib/lutaml/lml/formatter/svg_pdf/path_parser.rb +134 -0
- data/lib/lutaml/lml/formatter/svg_pdf.rb +281 -0
- data/lib/lutaml/lml/formatter.rb +1 -0
- data/lib/lutaml/lml/grammar/lml.artifact.json +2505 -2234
- data/lib/lutaml/lml/grammar/lml.parg +25 -9
- data/lib/lutaml/lml/model_compiler/enums.rb +172 -0
- data/lib/lutaml/lml/model_compiler/hydration.rb +123 -0
- data/lib/lutaml/lml/model_compiler/serialization_mappings.rb +162 -0
- data/lib/lutaml/lml/model_compiler/string_formats.rb +42 -0
- data/lib/lutaml/lml/model_compiler/validation.rb +65 -0
- data/lib/lutaml/lml/model_compiler.rb +46 -474
- data/lib/lutaml/lml/transform.rb +53 -5
- data/lib/lutaml/lml/validator.rb +2 -1
- data/lib/lutaml/lml/version.rb +1 -1
- data/lib/lutaml/lml.rb +18 -3
- metadata +9 -2
|
@@ -4,9 +4,24 @@ require 'json'
|
|
|
4
4
|
|
|
5
5
|
module Lutaml
|
|
6
6
|
module Lml
|
|
7
|
+
# Compiles LML model definitions into anonymous
|
|
8
|
+
# Lutaml::Model::Serializable subclasses, hydrates instance data
|
|
9
|
+
# against them, and applies declared serialization mappings. The
|
|
10
|
+
# concern modules (Hydration, Validation, Enums,
|
|
11
|
+
# SerializationMappings, StringFormats) share this class's state.
|
|
7
12
|
class ModelCompiler
|
|
13
|
+
autoload :Hydration, "lutaml/lml/model_compiler/hydration"
|
|
14
|
+
autoload :Validation, "lutaml/lml/model_compiler/validation"
|
|
15
|
+
autoload :Enums, "lutaml/lml/model_compiler/enums"
|
|
16
|
+
autoload :SerializationMappings, "lutaml/lml/model_compiler/serialization_mappings"
|
|
17
|
+
autoload :StringFormats, "lutaml/lml/model_compiler/string_formats"
|
|
18
|
+
|
|
8
19
|
class ValidationError < Error; end
|
|
9
20
|
|
|
21
|
+
# The default generated namespace nests under Lutaml::Lml so
|
|
22
|
+
# compiled constants never collide with top-level constants.
|
|
23
|
+
DEFAULT_NAMESPACE = 'Lutaml::Lml::Generated'
|
|
24
|
+
|
|
10
25
|
TYPE_MAP = {
|
|
11
26
|
'String' => :string,
|
|
12
27
|
'string' => :string,
|
|
@@ -20,13 +35,19 @@ module Lutaml
|
|
|
20
35
|
'date_time' => :date_time,
|
|
21
36
|
'DateTime' => :date_time,
|
|
22
37
|
'Time' => :time,
|
|
23
|
-
'Uri' => :string
|
|
38
|
+
'Uri' => :string,
|
|
24
39
|
}.freeze
|
|
25
40
|
|
|
26
41
|
# Tokens that denote unbounded cardinality in LML attribute
|
|
27
42
|
# declarations. Anything else must parse as an Integer.
|
|
28
43
|
UNBOUNDED_TOKENS = %w[* n N unbounded].freeze
|
|
29
44
|
|
|
45
|
+
include Hydration
|
|
46
|
+
include Validation
|
|
47
|
+
include Enums
|
|
48
|
+
include SerializationMappings
|
|
49
|
+
include StringFormats
|
|
50
|
+
|
|
30
51
|
def initialize(namespace: nil, artifact_paths: {}, default_artifact: nil)
|
|
31
52
|
@namespace = namespace
|
|
32
53
|
@artifact_paths = artifact_paths
|
|
@@ -81,138 +102,6 @@ module Lutaml
|
|
|
81
102
|
|
|
82
103
|
private
|
|
83
104
|
|
|
84
|
-
def validate_instance(instance, errors, path = 'root')
|
|
85
|
-
if instance.instance && Array(instance.attributes).empty?
|
|
86
|
-
validate_instance(instance.instance, errors, "#{path}.instance")
|
|
87
|
-
return
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
attrs = Array(instance.attributes)
|
|
91
|
-
type_attr = attrs.find { |a| a.name == 'type' }
|
|
92
|
-
type_name = type_attr ? type_attr.value.to_s : instance.type
|
|
93
|
-
klass = @compiled[demodulize(type_name)]
|
|
94
|
-
unless klass
|
|
95
|
-
errors << "#{path}: unknown type '#{type_name}'"
|
|
96
|
-
return
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
schema_attrs = klass.attributes.keys.map(&:to_s)
|
|
100
|
-
present_attrs = attrs.map(&:name)
|
|
101
|
-
schema_set = schema_attrs.to_set
|
|
102
|
-
present_set = present_attrs.to_set
|
|
103
|
-
|
|
104
|
-
unknown = present_set - schema_set - Set.new(%w[type])
|
|
105
|
-
unknown.each do |name|
|
|
106
|
-
errors << "#{path}.#{name}: attribute not defined on #{type_name}"
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
required = klass.attributes.select do |_k, v|
|
|
110
|
-
!v.collection? && !v.options.key?(:default)
|
|
111
|
-
end.keys.map(&:to_s).to_set
|
|
112
|
-
|
|
113
|
-
missing = required - present_set
|
|
114
|
-
missing.each do |name|
|
|
115
|
-
errors << "#{path}.#{name}: required attribute missing (cardinality min >= 1)"
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
attrs.each do |attr|
|
|
119
|
-
next unless schema_attrs.include?(attr.name)
|
|
120
|
-
|
|
121
|
-
attr_def = klass.attributes[attr.name.to_sym]
|
|
122
|
-
next unless attr_def
|
|
123
|
-
|
|
124
|
-
next unless attr_def.collection? && attr.instances.any?
|
|
125
|
-
|
|
126
|
-
attr.instances.each_with_index do |nested, i|
|
|
127
|
-
validate_instance(nested, errors, "#{path}.#{attr.name}[#{i}]")
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
return unless instance.instance
|
|
132
|
-
|
|
133
|
-
validate_instance(instance.instance, errors, "#{path}.instance")
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def hydrate_instance(instance)
|
|
137
|
-
inner = instance.instance
|
|
138
|
-
return hydrate_instance(inner) if inner && Array(instance.attributes).empty?
|
|
139
|
-
|
|
140
|
-
type_name = resolve_instance_type(instance)
|
|
141
|
-
klass = @compiled[type_name]
|
|
142
|
-
return hydrate_as_untyped(instance, type_name) unless klass
|
|
143
|
-
|
|
144
|
-
attrs = extract_instance_attributes(instance, klass)
|
|
145
|
-
klass.new(**attrs)
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
# An inline map's cargo is {key_value_map: [{key:, value:}, ...]} with
|
|
149
|
-
# literal-shaped keys/values ({string: ..}, {number: ..}, {boolean: ..});
|
|
150
|
-
# materialize it as a plain Hash (issue #17: maps hydrate as data).
|
|
151
|
-
def hydrate_map_cargo(cargo)
|
|
152
|
-
cargo[:key_value_map].to_h do |pair|
|
|
153
|
-
[hydrate_scalar(pair[:key]).to_s, hydrate_scalar(pair[:value])]
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
def hydrate_scalar(literal)
|
|
158
|
-
literal = literal.value if literal.respond_to?(:value)
|
|
159
|
-
literal.is_a?(Hash) ? literal.values.first : literal
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def resolve_instance_type(instance)
|
|
163
|
-
# L6: the explicit isa keyword is the type override; a `type`
|
|
164
|
-
# attribute is ordinary data (pubid's identifier-kind field)
|
|
165
|
-
return demodulize(instance.isa.to_s) if instance.isa
|
|
166
|
-
|
|
167
|
-
demodulize(instance.type.to_s)
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def demodulize(name)
|
|
171
|
-
name.split('::').last.to_s
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
def hydrate_as_untyped(instance, type_name)
|
|
175
|
-
{ _name: type_name, **extract_raw_attributes(instance) }
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
def extract_raw_attributes(instance)
|
|
179
|
-
instance.each_attribute.each_with_object({}) do |(name, value, nested), hash|
|
|
180
|
-
hash[name.to_sym] = resolve_instance_value(value, nested)
|
|
181
|
-
end
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
def extract_instance_attributes(instance, klass)
|
|
185
|
-
schema_keys = klass.attributes.keys.to_set
|
|
186
|
-
instance.each_attribute.each_with_object({}) do |(name, value, nested), hash|
|
|
187
|
-
key = name.to_sym
|
|
188
|
-
next unless schema_keys.include?(key)
|
|
189
|
-
|
|
190
|
-
hash[key] = resolve_instance_value(value, nested, klass.attributes[key])
|
|
191
|
-
end
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
def resolve_instance_value(value, nested, attr_def = nil)
|
|
195
|
-
value = value.value if value.respond_to?(:value)
|
|
196
|
-
return hydrate_map_cargo(value) if value.is_a?(Hash) && value.key?(:key_value_map)
|
|
197
|
-
|
|
198
|
-
if nested.any?
|
|
199
|
-
# A scalar attribute with a single nested instance hydrates to
|
|
200
|
-
# the object itself; the declaration is the truth (issue #17).
|
|
201
|
-
return hydrate_instance(nested.first) if !attr_def&.collection && nested.one?
|
|
202
|
-
|
|
203
|
-
nested.map { |i| hydrate_instance(i) }
|
|
204
|
-
elsif value.is_a?(Array)
|
|
205
|
-
value
|
|
206
|
-
elsif (enum_class = attr_def && enum_class_for(attr_def.type))
|
|
207
|
-
value_name = enum_value_name(value.to_s)
|
|
208
|
-
description = enum_class.values
|
|
209
|
-
.find { |v| v.name == value_name }&.description
|
|
210
|
-
enum_class.new(value: value_name, description: description)
|
|
211
|
-
elsif !value.nil?
|
|
212
|
-
value
|
|
213
|
-
end
|
|
214
|
-
end
|
|
215
|
-
|
|
216
105
|
def compile_document(doc)
|
|
217
106
|
@declared_namespaces = Array(doc.namespaces)
|
|
218
107
|
@serialization_mappings = Array(doc.serialization_mappings)
|
|
@@ -226,18 +115,6 @@ module Lutaml
|
|
|
226
115
|
register_string_formats(doc)
|
|
227
116
|
end
|
|
228
117
|
|
|
229
|
-
# In-class `mapping <format> { … }` is the shortcut for a sibling
|
|
230
|
-
# mapping targeting the enclosing class; hoist them so emission is
|
|
231
|
-
# uniform.
|
|
232
|
-
def hoist_class_serialization_mappings(doc)
|
|
233
|
-
(Array(doc.classes) + Array(doc.data_types)).each do |klass_def|
|
|
234
|
-
Array(klass_def.serialization_mappings).each do |mapping|
|
|
235
|
-
mapping.target = klass_def.name.to_s
|
|
236
|
-
@serialization_mappings << mapping
|
|
237
|
-
end
|
|
238
|
-
end
|
|
239
|
-
end
|
|
240
|
-
|
|
241
118
|
def resolve_forward_references
|
|
242
119
|
@forward_refs.each do |class_name, deferred_attrs|
|
|
243
120
|
klass = @compiled[class_name]
|
|
@@ -258,57 +135,7 @@ module Lutaml
|
|
|
258
135
|
register(name, compiled_klass)
|
|
259
136
|
end
|
|
260
137
|
|
|
261
|
-
# Emit the lutaml-model mapping blocks declared in the LML source:
|
|
262
|
-
# mapping yaml { map "full-name", to: "name" }
|
|
263
|
-
# becomes klass.yml do |m| m.map "full-name", to: :name end
|
|
264
|
-
def apply_declared_mappings(compiled_klass, def_obj)
|
|
265
|
-
return unless def_obj.is_a?(UmlClass) || def_obj.is_a?(DataType)
|
|
266
|
-
|
|
267
|
-
def_obj.wire_mappings.to_a.group_by(&:format).each do |format, entries|
|
|
268
|
-
next unless format && Lutaml::Model::FormatRegistry.registered?(format.to_sym)
|
|
269
|
-
|
|
270
|
-
compiled_klass.public_send(format) do |mapping|
|
|
271
|
-
entries.each { |entry| mapping.map(entry.wire, to: entry.to.to_sym) }
|
|
272
|
-
end
|
|
273
|
-
end
|
|
274
|
-
end
|
|
275
|
-
|
|
276
138
|
alias compile_data_type compile_class
|
|
277
|
-
|
|
278
|
-
EnumValue = Struct.new(:name, :description, :payload)
|
|
279
|
-
|
|
280
|
-
def compile_enum(enum_def)
|
|
281
|
-
name = enum_def.name.to_s
|
|
282
|
-
expand_from_table(enum_def)
|
|
283
|
-
values = extract_enum_values(enum_def)
|
|
284
|
-
|
|
285
|
-
payload_fields = Array(enum_def.member_fields).map(&:name).map(&:to_s)
|
|
286
|
-
compiled_klass = Class.new(Lutaml::Model::Serializable) do
|
|
287
|
-
attribute :value, :string, default: values.first.name
|
|
288
|
-
attribute :description, :string
|
|
289
|
-
payload_fields.each do |field|
|
|
290
|
-
attribute field.to_sym, :string
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
define_method(:to_s) { value }
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
values.each do |value|
|
|
297
|
-
accessor = value.name.to_s.gsub(/[^a-zA-Z0-9_]/, '_')
|
|
298
|
-
compiled_klass.define_singleton_method(accessor) do
|
|
299
|
-
new({ value: value.name, description: value.description }.merge(value.payload || {}))
|
|
300
|
-
end
|
|
301
|
-
end
|
|
302
|
-
compiled_klass.define_singleton_method(:values) { values }
|
|
303
|
-
|
|
304
|
-
@enum_names << name
|
|
305
|
-
@enum_classes[name] = compiled_klass
|
|
306
|
-
register(name, compiled_klass)
|
|
307
|
-
end
|
|
308
|
-
|
|
309
|
-
# Builds a compiled Serializable subclass from a class or data-type
|
|
310
|
-
# definition: declares attributes and stashes forward-referenced
|
|
311
|
-
# attributes for post-registration resolution.
|
|
312
139
|
def build_compiled_class(def_obj)
|
|
313
140
|
all_attrs = build_attributes(def_obj)
|
|
314
141
|
immediate, deferred = all_attrs.partition do |(_, raw_type, type, _)|
|
|
@@ -339,125 +166,6 @@ module Lutaml
|
|
|
339
166
|
compiled_klass
|
|
340
167
|
end
|
|
341
168
|
|
|
342
|
-
# Apply a lutaml-model XML mapping to each compiled class so they
|
|
343
|
-
# support from_xml/to_xml. Runs after forward references are resolved
|
|
344
|
-
# so all attributes (immediate + deferred) get element mappings.
|
|
345
|
-
def apply_xml_mappings
|
|
346
|
-
@compiled.each do |name, klass|
|
|
347
|
-
next if @enum_names.include?(name)
|
|
348
|
-
|
|
349
|
-
custom = serialization_mapping_for(name, 'xml')
|
|
350
|
-
next apply_custom_xml_mapping(klass, custom) if custom
|
|
351
|
-
|
|
352
|
-
apply_xml_mapping(klass, name)
|
|
353
|
-
end
|
|
354
|
-
end
|
|
355
|
-
|
|
356
|
-
def apply_xml_mapping(klass, root_name)
|
|
357
|
-
attr_names = klass.attributes.keys
|
|
358
|
-
return if attr_names.empty?
|
|
359
|
-
|
|
360
|
-
klass.xml do |mapping|
|
|
361
|
-
mapping.element(root_name)
|
|
362
|
-
attr_names.each do |attr_name|
|
|
363
|
-
mapping.map_element(attr_name.to_s, to: attr_name)
|
|
364
|
-
end
|
|
365
|
-
end
|
|
366
|
-
end
|
|
367
|
-
|
|
368
|
-
# RS 3010 extension: key-value mapping blocks for the declared
|
|
369
|
-
# formats (yaml/json/toml share lutaml-model's key_value model).
|
|
370
|
-
def apply_key_value_mappings
|
|
371
|
-
@compiled.each do |name, klass|
|
|
372
|
-
next if @enum_names.include?(name)
|
|
373
|
-
|
|
374
|
-
custom = serialization_mapping_for(name, 'key_value')
|
|
375
|
-
apply_custom_key_value_mapping(klass, custom) if custom
|
|
376
|
-
end
|
|
377
|
-
end
|
|
378
|
-
|
|
379
|
-
def serialization_mapping_for(target, format)
|
|
380
|
-
@serialization_mappings.find do |m|
|
|
381
|
-
m.target.to_s == target && serialization_format(m.format) == format
|
|
382
|
-
end
|
|
383
|
-
end
|
|
384
|
-
|
|
385
|
-
def serialization_format(format)
|
|
386
|
-
%w[yaml json toml].include?(format.to_s) ? 'key_value' : format.to_s
|
|
387
|
-
end
|
|
388
|
-
|
|
389
|
-
def apply_custom_xml_mapping(klass, mapping)
|
|
390
|
-
check_namespace_ambiguity(mapping)
|
|
391
|
-
ns_class = namespace_class_for(mapping.namespace_ref) if mapping.namespace_ref
|
|
392
|
-
rule_ns = mapping.rules.select { |r| r.kind == 'namespace' }
|
|
393
|
-
.to_h { |r| [r.field, namespace_class_for(r.field)] }
|
|
394
|
-
klass.xml do |m|
|
|
395
|
-
m.element(mapping.element_name) if mapping.element_name
|
|
396
|
-
m.namespace(ns_class) if ns_class
|
|
397
|
-
m.mixed_content if mapping.mixed_content
|
|
398
|
-
mapping.rules.each do |rule|
|
|
399
|
-
case rule.kind
|
|
400
|
-
when 'element' then m.map_element(rule.wire, to: rule.field.to_sym)
|
|
401
|
-
when 'attribute' then m.map_attribute(rule.wire, to: rule.field.to_sym)
|
|
402
|
-
when 'content' then m.map_content(to: rule.field.to_sym)
|
|
403
|
-
when 'namespace' then m.namespace(rule_ns[rule.field])
|
|
404
|
-
end
|
|
405
|
-
end
|
|
406
|
-
end
|
|
407
|
-
end
|
|
408
|
-
|
|
409
|
-
def apply_custom_key_value_mapping(klass, mapping)
|
|
410
|
-
formats = mapping.format.to_s == 'key_value' ? %w[yaml json toml] : [mapping.format.to_s]
|
|
411
|
-
klass.instance_eval do
|
|
412
|
-
formats.each do |format|
|
|
413
|
-
public_send(format) do |m|
|
|
414
|
-
mapping.rules.each do |rule|
|
|
415
|
-
case rule.kind
|
|
416
|
-
when 'map' then m.map(rule.wire, to: rule.field.to_sym)
|
|
417
|
-
when 'key' then m.map_key(to_instance: rule.field.to_sym)
|
|
418
|
-
when 'value'
|
|
419
|
-
if rule.mode == 'as_attribute'
|
|
420
|
-
m.map_value(as_attribute: rule.field.to_sym)
|
|
421
|
-
else
|
|
422
|
-
m.map_value(to_instance: rule.field.to_sym)
|
|
423
|
-
end
|
|
424
|
-
end
|
|
425
|
-
end
|
|
426
|
-
end
|
|
427
|
-
end
|
|
428
|
-
end
|
|
429
|
-
end
|
|
430
|
-
|
|
431
|
-
def check_namespace_ambiguity(mapping)
|
|
432
|
-
return unless mapping.format.to_s == 'xml'
|
|
433
|
-
return if mapping.namespace_ref || @declared_namespaces.length < 2
|
|
434
|
-
|
|
435
|
-
raise ArgumentError,
|
|
436
|
-
"mapping #{mapping.format} #{mapping.target}: ambiguous default " \
|
|
437
|
-
"namespace — #{@declared_namespaces.length} namespaces in scope " \
|
|
438
|
-
"(#{@declared_namespaces.map(&:name).join(', ')}); declare one with " \
|
|
439
|
-
"'namespace <name>'"
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
def namespace_class_for(ref)
|
|
443
|
-
return nil if ref.nil?
|
|
444
|
-
|
|
445
|
-
require 'lutaml/xml/namespace' unless defined?(Lutaml::Xml::Namespace)
|
|
446
|
-
|
|
447
|
-
ns_class = @namespace_classes[ref]
|
|
448
|
-
return ns_class if ns_class
|
|
449
|
-
|
|
450
|
-
declared = @declared_namespaces.find { |ns| ns.name.to_s == ref }
|
|
451
|
-
raise ArgumentError, "unknown namespace '#{ref}'" unless declared
|
|
452
|
-
|
|
453
|
-
ns_class = Class.new(Lutaml::Xml::Namespace)
|
|
454
|
-
uri = declared.uri
|
|
455
|
-
prefix = declared.prefix.to_s
|
|
456
|
-
ns_class.define_singleton_method(:uri) { uri }
|
|
457
|
-
ns_class.define_singleton_method(:prefix_default) { prefix }
|
|
458
|
-
@namespace_classes[ref] = ns_class
|
|
459
|
-
end
|
|
460
|
-
|
|
461
169
|
def build_attributes(klass_def)
|
|
462
170
|
Array(klass_def.attributes).map do |attr|
|
|
463
171
|
attr_name = attr.name.to_sym
|
|
@@ -468,33 +176,12 @@ module Lutaml
|
|
|
468
176
|
end
|
|
469
177
|
end
|
|
470
178
|
|
|
471
|
-
# RS 3001 §Attribute: `values { v1, v2 }` expands into an anonymous
|
|
472
|
-
# enum baked from the inline set; the attribute is typed with it.
|
|
473
|
-
def resolve_values_type(klass_def, attr)
|
|
474
|
-
return attr.type.to_s unless attr.value_set&.any?
|
|
475
|
-
|
|
476
|
-
enum_name = anonymous_enum_name(klass_def.name, attr.name)
|
|
477
|
-
members = attr.value_set.map { |v| ::Lutaml::Lml::TopElementAttribute.new(name: v.to_s) }
|
|
478
|
-
compile_enum(::Lutaml::Lml::Enum.new(name: enum_name, attributes: members))
|
|
479
|
-
enum_name
|
|
480
|
-
end
|
|
481
|
-
|
|
482
|
-
def anonymous_enum_name(class_name, attr_name)
|
|
483
|
-
base = class_name.to_s.split('::').last
|
|
484
|
-
attr_part = attr_name.to_s.split('_').map(&:capitalize).join
|
|
485
|
-
"#{base}#{attr_part}Values"
|
|
486
|
-
end
|
|
487
|
-
|
|
488
179
|
def resolve_type(type_name)
|
|
489
|
-
if TYPE_MAP.key?(type_name)
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
@compiled[type_name]
|
|
495
|
-
else
|
|
496
|
-
:string
|
|
497
|
-
end
|
|
180
|
+
return TYPE_MAP[type_name] if TYPE_MAP.key?(type_name)
|
|
181
|
+
return :string if type_name.start_with?('reference:(')
|
|
182
|
+
return @compiled[type_name] if @compiled.key?(type_name)
|
|
183
|
+
|
|
184
|
+
:string
|
|
498
185
|
end
|
|
499
186
|
|
|
500
187
|
def build_options(attr)
|
|
@@ -510,9 +197,9 @@ module Lutaml
|
|
|
510
197
|
min = parse_cardinality_value(card.min)
|
|
511
198
|
max = parse_cardinality_value(card.max)
|
|
512
199
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
200
|
+
collection = (max && (max > 1 || max == Float::INFINITY)) ||
|
|
201
|
+
(max.nil? && min && min > 1)
|
|
202
|
+
if collection
|
|
516
203
|
options[:collection] = true
|
|
517
204
|
elsif min.zero?
|
|
518
205
|
options[:default] = nil
|
|
@@ -540,9 +227,8 @@ module Lutaml
|
|
|
540
227
|
# render_nil is a lutaml-model MAPPING option, not an attribute
|
|
541
228
|
# option: collect the declaration for the mapping passes
|
|
542
229
|
def declared_render_nil(attr)
|
|
543
|
-
Array(attr.properties).find
|
|
544
|
-
|
|
545
|
-
end&.then { |prop| prop.value.to_s == 'true' }
|
|
230
|
+
prop = Array(attr.properties).find { |prop| prop.name.to_s == 'render_nil' }
|
|
231
|
+
prop ? prop.value.to_s == 'true' : nil
|
|
546
232
|
end
|
|
547
233
|
|
|
548
234
|
def parse_cardinality_value(val)
|
|
@@ -553,104 +239,6 @@ module Lutaml
|
|
|
553
239
|
raise ValidationError, "Unrecognized cardinality token: #{val.inspect}"
|
|
554
240
|
end
|
|
555
241
|
|
|
556
|
-
# L7a point 3: from_table members are baked at compile time — the
|
|
557
|
-
# artifact table's rows become the values, validated against the
|
|
558
|
-
# declared member_fields (unknown field = error; missing = nil).
|
|
559
|
-
# The member name comes from the row's "name" column.
|
|
560
|
-
def expand_from_table(enum_def)
|
|
561
|
-
return unless enum_def.from_table_name
|
|
562
|
-
|
|
563
|
-
rows = artifact_table_rows(enum_def)
|
|
564
|
-
known = Array(enum_def.member_fields).map { |f| f.name.to_s }
|
|
565
|
-
rows.each do |row|
|
|
566
|
-
row = row.transform_keys(&:to_s)
|
|
567
|
-
member_name = row.fetch('name')
|
|
568
|
-
unknown = row.keys - known - ['name']
|
|
569
|
-
if unknown.any?
|
|
570
|
-
raise ArgumentError,
|
|
571
|
-
"enum #{enum_def.name}: table row #{member_name.inspect} has " \
|
|
572
|
-
"undeclared fields #{unknown.inspect} (declared: #{known.inspect})"
|
|
573
|
-
end
|
|
574
|
-
payload = row.slice(*known).compact
|
|
575
|
-
enum_def.attributes << TopElementAttribute.new(
|
|
576
|
-
name: member_name,
|
|
577
|
-
properties: payload.map do |field, value|
|
|
578
|
-
TopElementAttribute.new(name: field, value: value)
|
|
579
|
-
end
|
|
580
|
-
)
|
|
581
|
-
end
|
|
582
|
-
end
|
|
583
|
-
|
|
584
|
-
def artifact_table_rows(enum_def)
|
|
585
|
-
reference = enum_def.from_table_artifact || @default_artifact
|
|
586
|
-
path = artifact_path_for(reference.to_s)
|
|
587
|
-
unless path
|
|
588
|
-
raise ArgumentError,
|
|
589
|
-
"enum #{enum_def.name}: from_table artifact #{reference.inspect} not found"
|
|
590
|
-
end
|
|
591
|
-
|
|
592
|
-
envelope = JSON.parse(File.read(path))
|
|
593
|
-
table = envelope['tables'].to_h[enum_def.from_table_name]
|
|
594
|
-
unless table
|
|
595
|
-
raise ArgumentError,
|
|
596
|
-
"enum #{enum_def.name}: table #{enum_def.from_table_name.inspect} " \
|
|
597
|
-
"not found in artifact #{reference.inspect}"
|
|
598
|
-
end
|
|
599
|
-
|
|
600
|
-
table['rows'].to_a
|
|
601
|
-
end
|
|
602
|
-
|
|
603
|
-
def extract_enum_values(enum_def)
|
|
604
|
-
member_fields = Array(enum_def.member_fields).map do |f|
|
|
605
|
-
[f.name.to_s, f.type.to_s]
|
|
606
|
-
end
|
|
607
|
-
enum_def.attributes.map do |attr|
|
|
608
|
-
description = member_description(attr)
|
|
609
|
-
payload = extract_member_payload(attr)
|
|
610
|
-
validate_member_payload(enum_def, attr, payload, member_fields)
|
|
611
|
-
EnumValue.new(attr.name.to_s, description, payload)
|
|
612
|
-
end
|
|
613
|
-
end
|
|
614
|
-
|
|
615
|
-
# The member description rides a nested attribute named `description`
|
|
616
|
-
# (bare-member form) or `definition` (RS 3001 `value` blocks); both
|
|
617
|
-
# spellings are accepted.
|
|
618
|
-
def member_description(attr)
|
|
619
|
-
nested = Array(attr.attributes).find do |n|
|
|
620
|
-
%w[description definition].include?(n.name.to_s)
|
|
621
|
-
end
|
|
622
|
-
text = nested&.type || nested&.definition || nested&.value
|
|
623
|
-
text.is_a?(Hash) && text.key?(:string) ? text[:string] : text
|
|
624
|
-
end
|
|
625
|
-
|
|
626
|
-
# L7a: per-member payload lines ride the keyword-attribute
|
|
627
|
-
# properties (`abbreviation = "WD"`)
|
|
628
|
-
def extract_member_payload(attr)
|
|
629
|
-
Array(attr.properties).to_h { |p| [p.name.to_s, p.value&.to_s] }
|
|
630
|
-
end
|
|
631
|
-
|
|
632
|
-
def validate_member_payload(enum_def, attr, payload, member_fields)
|
|
633
|
-
known = member_fields.map(&:first)
|
|
634
|
-
unknown = payload.keys - known
|
|
635
|
-
return unless unknown.any?
|
|
636
|
-
|
|
637
|
-
raise ArgumentError,
|
|
638
|
-
"enum #{enum_def.name}: member #{attr.name} has undeclared " \
|
|
639
|
-
"payload fields #{unknown.inspect} (declared: #{known.inspect})"
|
|
640
|
-
end
|
|
641
|
-
|
|
642
|
-
def enum_class_for(type)
|
|
643
|
-
type.is_a?(Class) && @enum_classes.value?(type) and return type
|
|
644
|
-
end
|
|
645
|
-
|
|
646
|
-
def enum_value_name(raw)
|
|
647
|
-
raw.split('::').last
|
|
648
|
-
end
|
|
649
|
-
|
|
650
|
-
# L7b: derived fields declare existence + type; computation is
|
|
651
|
-
# bound by the binder from artifact derive specs. The attribute
|
|
652
|
-
# participates in mappings like any other (relaton consumes URNs
|
|
653
|
-
# from model JSON).
|
|
654
242
|
def declare_derived_fields(compiled_klass, def_obj)
|
|
655
243
|
return unless def_obj.is_a?(UmlClass) || def_obj.is_a?(DataType)
|
|
656
244
|
|
|
@@ -668,35 +256,6 @@ module Lutaml
|
|
|
668
256
|
end
|
|
669
257
|
end
|
|
670
258
|
|
|
671
|
-
# L5: register grammar-backed string formats. Class-level
|
|
672
|
-
# declarations win; a models-block default applies to classes
|
|
673
|
-
# without their own. Artifact resolution: the compiler's
|
|
674
|
-
# artifacts map (name => path), else the string as a path.
|
|
675
|
-
def register_string_formats(doc)
|
|
676
|
-
defaults = doc.packages.flat_map(&:default_string_formats)
|
|
677
|
-
doc.classes.each do |class_def|
|
|
678
|
-
declared = Array(class_def.string_formats)
|
|
679
|
-
declared.each { |fmt| register_string_format(class_def.name.to_s, fmt) }
|
|
680
|
-
|
|
681
|
-
declared_formats = declared.map(&:format)
|
|
682
|
-
defaults.reject { |fmt| declared_formats.include?(fmt.format) }
|
|
683
|
-
.each { |fmt| register_string_format(class_def.name.to_s, fmt) }
|
|
684
|
-
end
|
|
685
|
-
end
|
|
686
|
-
|
|
687
|
-
def register_string_format(class_name, fmt)
|
|
688
|
-
artifact_path = artifact_path_for(fmt.artifact.to_s)
|
|
689
|
-
return unless artifact_path
|
|
690
|
-
|
|
691
|
-
compiled = @compiled[class_name]
|
|
692
|
-
return unless compiled
|
|
693
|
-
|
|
694
|
-
Parsanol::PARG::Lutaml.register(compiled,
|
|
695
|
-
format_name: fmt.format.to_sym,
|
|
696
|
-
artifact: artifact_path,
|
|
697
|
-
entry: fmt.root.to_s)
|
|
698
|
-
end
|
|
699
|
-
|
|
700
259
|
def artifact_path_for(reference)
|
|
701
260
|
mapped = @artifact_paths && @artifact_paths[reference]
|
|
702
261
|
return mapped if mapped && File.exist?(mapped.to_s)
|
|
@@ -721,7 +280,20 @@ module Lutaml
|
|
|
721
280
|
@compiled[name] = klass
|
|
722
281
|
return unless @namespace
|
|
723
282
|
|
|
724
|
-
|
|
283
|
+
namespace_module.const_set(name, klass)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def namespace_module
|
|
287
|
+
return @namespace if @namespace.is_a?(Module)
|
|
288
|
+
|
|
289
|
+
@namespace_module ||= begin
|
|
290
|
+
chain = @namespace.to_s.split('::')
|
|
291
|
+
chain.reduce(Object) do |parent, name|
|
|
292
|
+
parent.const_get(name)
|
|
293
|
+
rescue NameError
|
|
294
|
+
parent.const_set(name, Module.new)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
725
297
|
end
|
|
726
298
|
end
|
|
727
299
|
end
|
data/lib/lutaml/lml/transform.rb
CHANGED
|
@@ -14,11 +14,59 @@ module Lutaml
|
|
|
14
14
|
rule(visibility_modifier: simple(:visibility_value)) do
|
|
15
15
|
VISIBILITY_MAP.fetch(visibility_value.to_s, 'public')
|
|
16
16
|
end
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
|
|
17
|
+
# An empty `until` capture is an empty sequence, not an empty
|
|
18
|
+
# string: `""` parses the dq_string body as [] (parsanol
|
|
19
|
+
# repetition semantics). A present, zero-length string value is
|
|
20
|
+
# distinct from an absent one (RS 3001 §String values); only the
|
|
21
|
+
# :string capture is normalized, other empty sequences stay [].
|
|
22
|
+
rule(string: sequence(:empty_string)) { { string: '' } }
|
|
23
|
+
|
|
24
|
+
# RS 3001 §String values: the only two escapes are backslash-quote
|
|
25
|
+
# and backslash-backslash (the grammar rejects every other
|
|
26
|
+
# backslash), so unescaping is removing the backslash before
|
|
27
|
+
# those two characters.
|
|
28
|
+
rule(string: simple(:escaped)) { { string: Lutaml::Lml::Transform.unescape(escaped.to_s) } }
|
|
29
|
+
|
|
30
|
+
# Rule blocks evaluate in a Parsanol::Context, so this helper is
|
|
31
|
+
# a class method the block reaches through the class.
|
|
32
|
+
# @param body [String] raw string body as captured between quotes
|
|
33
|
+
# @return [String] the value with \" and \\ resolved
|
|
34
|
+
def self.unescape(body)
|
|
35
|
+
body.gsub(/\\(["'\\])/) { Regexp.last_match(1) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Global scalar normalizer: every leaf becomes a string (or stays
|
|
39
|
+
# nil). No stripping — name and type tokens are interior-bounded
|
|
40
|
+
# in the grammar (class_run/type_run/quoted_name_run), so quoted
|
|
41
|
+
# string values keep their padding verbatim (RS 3001 §String
|
|
42
|
+
# values).
|
|
43
|
+
rule(simple(:value)) { value.nil? ? value : value.to_s }
|
|
44
|
+
|
|
45
|
+
# Block captures (definition bodies, block comments) read their
|
|
46
|
+
# surrounding layout as content — a block's first line break and
|
|
47
|
+
# closing indentation are layout, not prose. parsanol only fires
|
|
48
|
+
# hash-keyed rules at selected tree positions, so the strip is a
|
|
49
|
+
# post-pass walk, not a rule.
|
|
50
|
+
BLOCK_KEYS = %i[definition comments].freeze
|
|
51
|
+
|
|
52
|
+
def apply(tree, context = nil)
|
|
53
|
+
strip_block_layout(super)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def strip_block_layout(node)
|
|
57
|
+
case node
|
|
58
|
+
when Hash
|
|
59
|
+
node.each_with_object({}) do |(key, value), result|
|
|
60
|
+
result[key] = if BLOCK_KEYS.include?(key) && value.is_a?(String)
|
|
61
|
+
value.strip
|
|
62
|
+
else
|
|
63
|
+
strip_block_layout(value)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
when Array then node.map { |item| strip_block_layout(item) }
|
|
67
|
+
else node
|
|
68
|
+
end
|
|
69
|
+
end
|
|
22
70
|
end
|
|
23
71
|
end
|
|
24
72
|
end
|
data/lib/lutaml/lml/validator.rb
CHANGED
|
@@ -14,7 +14,8 @@ module Lutaml
|
|
|
14
14
|
'Integer' => /\A-?\d+\z/,
|
|
15
15
|
'Number' => /\A-?\d+\z/,
|
|
16
16
|
'Float' => /\A-?\d+\.\d+\z/,
|
|
17
|
-
'Boolean' => /\A(true|false)\z
|
|
17
|
+
'Boolean' => /\A(true|false)\z/,
|
|
18
|
+
'Null' => /\Anull\z/
|
|
18
19
|
}.freeze
|
|
19
20
|
|
|
20
21
|
COLLECTION_KEYS = %i[properties attributes instances].freeze
|