lutaml-lml 0.2.1 → 0.3.1
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/attribute_processing.rb +9 -1
- 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 +35 -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 +285 -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
|
@@ -83,15 +83,19 @@ grammar Lml version "1.0.0" {
|
|
|
83
83
|
# match['"\'']
|
|
84
84
|
quotes = %x22 / %x27
|
|
85
85
|
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
atomic
|
|
86
|
+
# RS 3001 §String values: exactly two escape sequences, \" and \\,
|
|
87
|
+
# strict in both quote kinds; a backslash before anything else is not
|
|
88
|
+
# a string. atomic keeps the scan verbatim (skip trivia must not eat
|
|
89
|
+
# interior runs).
|
|
90
|
+
atomic dq_string = %x22 ( *( ( %x5C %x22 ) / ( %x5C %x5C ) / ( !( %x22 / %x5C ) %x00-10FFFF ) ) ) as string %x22
|
|
91
|
+
atomic sq_string = %x27 ( *( ( %x5C %x27 ) / ( %x5C %x5C ) / ( !( %x27 / %x5C ) %x00-10FFFF ) ) ) as string %x27
|
|
89
92
|
quoted_string = dq_string / sq_string
|
|
90
93
|
# any_quoted(:path) for view import
|
|
91
94
|
atomic dq_path = %x22 until "\"" as path %x22
|
|
92
95
|
atomic sq_path = %x27 until "'" as path %x27
|
|
93
96
|
|
|
94
97
|
boolean = ( "true" / "false" ) as boolean
|
|
98
|
+
null_literal = "null" as null
|
|
95
99
|
number = ( 1*%x30-39 "." 1*%x30-39 ) as float / ( 1*%x30-39 ) as number
|
|
96
100
|
variable = quoted_string / 1*word
|
|
97
101
|
reference = ( "reference:(" / "ref:(" ) ( variable *( "." variable ) ) as reference ")"
|
|
@@ -100,12 +104,22 @@ grammar Lml version "1.0.0" {
|
|
|
100
104
|
comment_definition = ( "**" / "#" ) ( *( !newline ANY ) ) as comments
|
|
101
105
|
comment_multiline_definition = "*|" until "|*" as comments [ whitespace ] "|*"
|
|
102
106
|
|
|
103
|
-
|
|
107
|
+
# Spaces are interior-only in name and type tokens: a run may be
|
|
108
|
+
# followed by space-then-run, never a trailing space. The trailing
|
|
109
|
+
# separator is left for skip trivia, so captures need no post-strip
|
|
110
|
+
# and quoted string values keep their padding verbatim (RS 3001
|
|
111
|
+
# §String values).
|
|
112
|
+
# Atomic composite: the interior %x20 must match verbatim — skip
|
|
113
|
+
# trivia injects before sequence children and would otherwise eat the
|
|
114
|
+
# space before the grammar's own literal can (the type_token
|
|
115
|
+
# precedent).
|
|
116
|
+
atomic class_run = 1*( %x41-5A / %x61-7A / %x30-39 / "_" / "-" / ":" / "." )
|
|
117
|
+
atomic class_name_chars = class_run *( %x20 class_run )
|
|
104
118
|
class_name = class_name_chars [ "(" class_name_chars ")" ]
|
|
105
119
|
cardinality_body_definition = cardchar as min [ ".." [ cardchar as max ] ]
|
|
106
120
|
cardinality = "[" cardinality_body_definition as cardinality "]"
|
|
107
121
|
|
|
108
|
-
data_value = boolean / reference / range / number / quoted_string
|
|
122
|
+
data_value = boolean / null_literal / reference / range / number / quoted_string
|
|
109
123
|
key_value_pair = variable as key [ "=" ] data_value as value
|
|
110
124
|
key_value_map = "{" [ whitespace ] ( *( key_value_pair [ whitespace ] ) ) as key_value_map "}"
|
|
111
125
|
kw_visibility_modifier = "+" / "-" / "#" / "~"
|
|
@@ -116,12 +130,14 @@ grammar Lml version "1.0.0" {
|
|
|
116
130
|
attribute_line = attribute [ [ "," ] whitespace ]
|
|
117
131
|
visibility = kw_visibility_modifier as visibility_modifier
|
|
118
132
|
attribute_stereotype = "<<" ( 1*( %x41-5A / %x61-7A / %x30-39 / "_" / "-" / "/" ) ) as stereotype ">>"
|
|
119
|
-
atomic
|
|
120
|
-
atomic
|
|
133
|
+
atomic type_run = 1*( !( "//" / "/*" ) ( %x41-5A / %x61-7A / %x30-39 / "_" / "-" / ":" / "/" / "+" ) )
|
|
134
|
+
atomic type_token = type_run *( %x20 type_run )
|
|
121
135
|
atomic namechar = %x41-5A / %x61-7A / %x30-39 / "_" / "-" / "/" / "+"
|
|
122
136
|
attribute_type = [ ":" ] [ attribute_stereotype ] [ quotes as type_literal ] ( type_token ) as type [ quotes ]
|
|
123
137
|
attribute_name = ( 1*namechar ) as name
|
|
124
|
-
|
|
138
|
+
atomic quoted_name_run = 1*( %x41-5A / %x61-7A / %x30-39 / "_" / "-" / "/" / "+" )
|
|
139
|
+
atomic quoted_name_body = quoted_name_run *( %x20 quoted_name_run )
|
|
140
|
+
attribute_definition_name = ( quotes ( quoted_name_body ) as name quotes ) / attribute_name
|
|
125
141
|
attribute_definition = ( [ visibility ] as visibility attribute_definition_name [ attribute_type ] [ cardinality ] [ class_body ] ) as attributes
|
|
126
142
|
|
|
127
143
|
keyword_type_argument = "type" ( 1*nonspace ) as type [ whitespace ]
|
|
@@ -218,7 +234,7 @@ grammar Lml version "1.0.0" {
|
|
|
218
234
|
class_inner_definition = class_inner_definitions [ whitespace ]
|
|
219
235
|
class_body = "{" [ whitespace ] ( *( class_inner_definition ) ) as members "}"
|
|
220
236
|
parent_class = "<" ( class_name_chars ) as parent_class
|
|
221
|
-
class_definition = [ element_visibility ] class_modifier [ whitespace ] kw_class_sp class_name as name [ parent_class ] [ attribute_stereotype ] [ class_body ]
|
|
237
|
+
class_definition = [ whitespace ] [ element_visibility ] [ whitespace ] class_modifier [ whitespace ] kw_class_sp class_name as name [ parent_class ] [ attribute_stereotype ] [ class_body ]
|
|
222
238
|
|
|
223
239
|
# Balanced-brace definition text: nested "{...}" and "\"-escapes allowed
|
|
224
240
|
definition_content = *( ( "\\" any_char ) / ( "{" definition_content "}" ) / ( !"}" any_char ) )
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Lml
|
|
5
|
+
class ModelCompiler
|
|
6
|
+
# Compiles LML enums into Serializable subclasses with member
|
|
7
|
+
# payloads, table-driven expansion, and value accessors.
|
|
8
|
+
module Enums
|
|
9
|
+
EnumValue = Struct.new(:name, :description, :payload)
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def compile_enum(enum_def)
|
|
14
|
+
name = enum_def.name.to_s
|
|
15
|
+
expand_from_table(enum_def)
|
|
16
|
+
values = extract_enum_values(enum_def)
|
|
17
|
+
|
|
18
|
+
payload_fields = Array(enum_def.member_fields).map(&:name).map(&:to_s)
|
|
19
|
+
value_names = values.map { |v| v.name.to_s }
|
|
20
|
+
compiled_klass = Class.new(Lutaml::Model::Serializable) do
|
|
21
|
+
attribute :value, :string, values: value_names, default: values.first.name
|
|
22
|
+
attribute :description, :string
|
|
23
|
+
payload_fields.each do |field|
|
|
24
|
+
attribute field.to_sym, :string
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
define_method(:to_s) { value }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
values.each do |value|
|
|
31
|
+
accessor = value.name.to_s.gsub(/[^a-zA-Z0-9_]/, '_')
|
|
32
|
+
compiled_klass.define_singleton_method(accessor) do
|
|
33
|
+
new({ value: value.name, description: value.description }.merge(value.payload || {}))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
compiled_klass.define_singleton_method(:values) { values }
|
|
37
|
+
|
|
38
|
+
@enum_names << name
|
|
39
|
+
@enum_classes[name] = compiled_klass
|
|
40
|
+
register(name, compiled_klass)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Builds a compiled Serializable subclass from a class or data-type
|
|
44
|
+
# definition: declares attributes and stashes forward-referenced
|
|
45
|
+
# attributes for post-registration resolution.
|
|
46
|
+
|
|
47
|
+
# RS 3001 §Attribute: `values { v1, v2 }` expands into an anonymous
|
|
48
|
+
# enum baked from the inline set; the attribute is typed with it.
|
|
49
|
+
def resolve_values_type(klass_def, attr)
|
|
50
|
+
return attr.type.to_s unless attr.value_set&.any?
|
|
51
|
+
|
|
52
|
+
enum_name = anonymous_enum_name(klass_def.name, attr.name)
|
|
53
|
+
members = attr.value_set.map { |v| ::Lutaml::Lml::TopElementAttribute.new(name: v.to_s) }
|
|
54
|
+
compile_enum(::Lutaml::Lml::Enum.new(name: enum_name, attributes: members))
|
|
55
|
+
enum_name
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def anonymous_enum_name(class_name, attr_name)
|
|
59
|
+
base = class_name.to_s.split('::').last
|
|
60
|
+
attr_part = attr_name.to_s.split('_').map(&:capitalize).join
|
|
61
|
+
"#{base}#{attr_part}Values"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# L7a point 3: from_table members are baked at compile time — the
|
|
65
|
+
# artifact table's rows become the values, validated against the
|
|
66
|
+
# declared member_fields (unknown field = error; missing = nil).
|
|
67
|
+
# The member name comes from the row's "name" column.
|
|
68
|
+
def expand_from_table(enum_def)
|
|
69
|
+
return unless enum_def.from_table_name
|
|
70
|
+
|
|
71
|
+
rows = artifact_table_rows(enum_def)
|
|
72
|
+
known = Array(enum_def.member_fields).map { |f| f.name.to_s }
|
|
73
|
+
rows.each do |row|
|
|
74
|
+
row = row.transform_keys(&:to_s)
|
|
75
|
+
member_name = row.fetch('name')
|
|
76
|
+
unknown = row.keys - known - ['name']
|
|
77
|
+
if unknown.any?
|
|
78
|
+
raise ArgumentError,
|
|
79
|
+
"enum #{enum_def.name}: table row #{member_name.inspect} has " \
|
|
80
|
+
"undeclared fields #{unknown.inspect} (declared: #{known.inspect})"
|
|
81
|
+
end
|
|
82
|
+
payload = row.slice(*known).compact
|
|
83
|
+
enum_def.attributes << TopElementAttribute.new(
|
|
84
|
+
name: member_name,
|
|
85
|
+
properties: payload.map do |field, value|
|
|
86
|
+
TopElementAttribute.new(name: field, value: value)
|
|
87
|
+
end,
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def artifact_table_rows(enum_def)
|
|
93
|
+
reference = enum_def.from_table_artifact || @default_artifact
|
|
94
|
+
path = artifact_path_for(reference.to_s)
|
|
95
|
+
unless path
|
|
96
|
+
raise ArgumentError,
|
|
97
|
+
"enum #{enum_def.name}: from_table artifact #{reference.inspect} not found"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
envelope = JSON.parse(File.read(path))
|
|
101
|
+
table = envelope['tables'].to_h[enum_def.from_table_name]
|
|
102
|
+
unless table
|
|
103
|
+
raise ArgumentError,
|
|
104
|
+
"enum #{enum_def.name}: table #{enum_def.from_table_name.inspect} " \
|
|
105
|
+
"not found in artifact #{reference.inspect}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
table['rows'].to_a
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def extract_enum_values(enum_def)
|
|
112
|
+
member_fields = Array(enum_def.member_fields).map do |f|
|
|
113
|
+
[f.name.to_s, f.type.to_s]
|
|
114
|
+
end
|
|
115
|
+
enum_def.attributes.map do |attr|
|
|
116
|
+
description = member_description(attr)
|
|
117
|
+
payload = extract_member_payload(attr)
|
|
118
|
+
validate_member_payload(enum_def, attr, payload, member_fields)
|
|
119
|
+
EnumValue.new(attr.name.to_s, description, payload)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The member description rides a nested attribute named `description`
|
|
124
|
+
# (bare-member form) or `definition` (RS 3001 `value` blocks); both
|
|
125
|
+
# spellings are accepted.
|
|
126
|
+
|
|
127
|
+
# The member description rides a nested attribute named `description`
|
|
128
|
+
# (bare-member form) or `definition` (RS 3001 `value` blocks); both
|
|
129
|
+
# spellings are accepted.
|
|
130
|
+
def member_description(attr)
|
|
131
|
+
nested = Array(attr.attributes).find do |n|
|
|
132
|
+
%w[description definition].include?(n.name.to_s)
|
|
133
|
+
end
|
|
134
|
+
text = nested&.type || nested&.definition || nested&.value
|
|
135
|
+
text.is_a?(Hash) && text.key?(:string) ? text[:string] : text
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# L7a: per-member payload lines ride the keyword-attribute
|
|
139
|
+
# properties (`abbreviation = "WD"`)
|
|
140
|
+
|
|
141
|
+
# L7a: per-member payload lines ride the keyword-attribute
|
|
142
|
+
# properties (`abbreviation = "WD"`)
|
|
143
|
+
def extract_member_payload(attr)
|
|
144
|
+
Array(attr.properties).to_h { |p| [p.name.to_s, p.value&.to_s] }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def validate_member_payload(enum_def, attr, payload, member_fields)
|
|
148
|
+
known = member_fields.map(&:first)
|
|
149
|
+
unknown = payload.keys - known
|
|
150
|
+
return unless unknown.any?
|
|
151
|
+
|
|
152
|
+
raise ArgumentError,
|
|
153
|
+
"enum #{enum_def.name}: member #{attr.name} has undeclared " \
|
|
154
|
+
"payload fields #{unknown.inspect} (declared: #{known.inspect})"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def enum_class_for(type)
|
|
158
|
+
type.is_a?(Class) && @enum_classes.value?(type) and return type
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def enum_value_name(raw)
|
|
162
|
+
raw.split('::').last
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# L7b: derived fields declare existence + type; computation is
|
|
166
|
+
# bound by the binder from artifact derive specs. The attribute
|
|
167
|
+
# participates in mappings like any other (relaton consumes URNs
|
|
168
|
+
# from model JSON).
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Lml
|
|
5
|
+
class ModelCompiler
|
|
6
|
+
# Builds typed instances from parsed instance documents
|
|
7
|
+
# against the compiled class set.
|
|
8
|
+
module Hydration
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def hydrate_instance(instance)
|
|
12
|
+
inner = instance.instance
|
|
13
|
+
return hydrate_instance(inner) if inner && Array(instance.attributes).empty?
|
|
14
|
+
|
|
15
|
+
type_name = resolve_instance_type(instance)
|
|
16
|
+
klass = @compiled[type_name]
|
|
17
|
+
return hydrate_as_untyped(instance, type_name) unless klass
|
|
18
|
+
|
|
19
|
+
attrs = extract_instance_attributes(instance, klass)
|
|
20
|
+
klass.new(**attrs)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# An inline map's cargo is {key_value_map: [{key:, value:}, ...]} with
|
|
24
|
+
# literal-shaped keys/values ({string: ..}, {number: ..}, {boolean: ..});
|
|
25
|
+
# materialize it as a plain Hash (issue #17: maps hydrate as data).
|
|
26
|
+
|
|
27
|
+
# An inline map's cargo is {key_value_map: [{key:, value:}, ...]} with
|
|
28
|
+
# literal-shaped keys/values ({string: ..}, {number: ..}, {boolean: ..});
|
|
29
|
+
# materialize it as a plain Hash (issue #17: maps hydrate as data).
|
|
30
|
+
def hydrate_map_cargo(cargo)
|
|
31
|
+
cargo[:key_value_map].to_h do |pair|
|
|
32
|
+
[hydrate_scalar(pair[:key]).to_s, hydrate_scalar(pair[:value])]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def hydrate_scalar(literal)
|
|
37
|
+
literal = literal.value if literal.respond_to?(:value)
|
|
38
|
+
literal.is_a?(Hash) ? literal.values.first : literal
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def resolve_instance_type(instance)
|
|
42
|
+
# L6: the explicit isa keyword is the type override; a `type`
|
|
43
|
+
# attribute is ordinary data (pubid's identifier-kind field)
|
|
44
|
+
return demodulize(instance.isa.to_s) if instance.isa
|
|
45
|
+
|
|
46
|
+
demodulize(instance.type.to_s)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def demodulize(name)
|
|
50
|
+
name.split('::').last.to_s
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def hydrate_as_untyped(instance, type_name)
|
|
54
|
+
{ _name: type_name, **extract_raw_attributes(instance) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def extract_raw_attributes(instance)
|
|
58
|
+
instance.each_attribute.with_object({}) do |(name, value, nested), hash|
|
|
59
|
+
hash[name.to_sym] = resolve_instance_value(value, nested)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def extract_instance_attributes(instance, klass)
|
|
64
|
+
schema_keys = klass.attributes.keys.to_set
|
|
65
|
+
instance.each_attribute.with_object({}) do |(name, value, nested), hash|
|
|
66
|
+
key = name.to_sym
|
|
67
|
+
next unless schema_keys.include?(key)
|
|
68
|
+
|
|
69
|
+
hash[key] = resolve_instance_value(value, nested, klass.attributes[key])
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def resolve_instance_value(value, nested, attr_def = nil)
|
|
74
|
+
value = value.value if value.respond_to?(:value)
|
|
75
|
+
return hydrate_map_cargo(value) if value.is_a?(Hash) && value.key?(:key_value_map)
|
|
76
|
+
|
|
77
|
+
if nested.any?
|
|
78
|
+
# A scalar attribute with a single nested instance hydrates to
|
|
79
|
+
# the object itself; the declaration is the truth (issue #17).
|
|
80
|
+
return hydrate_typed(nested.first, attr_def) if !attr_def&.collection && nested.one?
|
|
81
|
+
|
|
82
|
+
nested.map { |i| hydrate_typed(i, attr_def) }
|
|
83
|
+
# The Array guard must run before the enum branch: an array
|
|
84
|
+
# value under an enum-typed attribute stays a raw array.
|
|
85
|
+
# rubocop:disable Lint/DuplicateBranch
|
|
86
|
+
elsif value.is_a?(Array)
|
|
87
|
+
value
|
|
88
|
+
elsif (enum_class = attr_def && enum_class_for(attr_def.type))
|
|
89
|
+
value_name = enum_value_name(value.to_s)
|
|
90
|
+
description = enum_class.values
|
|
91
|
+
.find { |v| v.name == value_name }&.description
|
|
92
|
+
enum_class.new(value: value_name, description: description)
|
|
93
|
+
elsif !value.nil?
|
|
94
|
+
value
|
|
95
|
+
# rubocop:enable Lint/DuplicateBranch
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# An untyped nested instance takes the enclosing attribute's type;
|
|
100
|
+
# hydrate_as_untyped would otherwise emit a raw hash polluted with
|
|
101
|
+
# a `_name` key, which also defeats union member key-coverage.
|
|
102
|
+
|
|
103
|
+
# An untyped nested instance takes the enclosing attribute's type;
|
|
104
|
+
# hydrate_as_untyped would otherwise emit a raw hash polluted with
|
|
105
|
+
# a `_name` key, which also defeats union member key-coverage.
|
|
106
|
+
def hydrate_typed(instance, attr_def)
|
|
107
|
+
return hydrate_instance(instance) if instance.isa || !instance.type.to_s.empty?
|
|
108
|
+
|
|
109
|
+
type = attr_def&.type
|
|
110
|
+
return hydrate_instance(instance) unless type.is_a?(Class)
|
|
111
|
+
|
|
112
|
+
if type.include?(Lutaml::Model::Serialize)
|
|
113
|
+
type.new(**extract_instance_attributes(instance, type))
|
|
114
|
+
elsif type == Lutaml::Model::Type::Union
|
|
115
|
+
extract_raw_attributes(instance)
|
|
116
|
+
else
|
|
117
|
+
hydrate_instance(instance)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Lml
|
|
5
|
+
class ModelCompiler
|
|
6
|
+
# Applies declared LML serialization mappings
|
|
7
|
+
# (xml / key_value wire names, namespaces) onto compiled classes.
|
|
8
|
+
module SerializationMappings
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# In-class `mapping <format> { … }` is the shortcut for a sibling
|
|
12
|
+
# mapping targeting the enclosing class; hoist them so emission is
|
|
13
|
+
# uniform.
|
|
14
|
+
def hoist_class_serialization_mappings(doc)
|
|
15
|
+
(Array(doc.classes) + Array(doc.data_types)).each do |klass_def|
|
|
16
|
+
Array(klass_def.serialization_mappings).each do |mapping|
|
|
17
|
+
mapping.target = klass_def.name.to_s
|
|
18
|
+
@serialization_mappings << mapping
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Emit the lutaml-model mapping blocks declared in the LML source:
|
|
24
|
+
# mapping yaml { map "full-name", to: "name" }
|
|
25
|
+
# becomes klass.yml do |m| m.map "full-name", to: :name end
|
|
26
|
+
def apply_declared_mappings(compiled_klass, def_obj)
|
|
27
|
+
return unless def_obj.is_a?(UmlClass) || def_obj.is_a?(DataType)
|
|
28
|
+
|
|
29
|
+
def_obj.wire_mappings.to_a.group_by(&:format).each do |format, entries|
|
|
30
|
+
next unless format && Lutaml::Model::FormatRegistry.registered?(format.to_sym)
|
|
31
|
+
|
|
32
|
+
compiled_klass.public_send(format) do |mapping|
|
|
33
|
+
entries.each { |entry| mapping.map(entry.wire, to: entry.to.to_sym) }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Apply a lutaml-model XML mapping to each compiled class so they
|
|
39
|
+
# support from_xml/to_xml. Runs after forward references are resolved
|
|
40
|
+
# so all attributes (immediate + deferred) get element mappings.
|
|
41
|
+
def apply_xml_mappings
|
|
42
|
+
@compiled.each do |name, klass|
|
|
43
|
+
next if @enum_names.include?(name)
|
|
44
|
+
|
|
45
|
+
custom = serialization_mapping_for(name, 'xml')
|
|
46
|
+
next apply_custom_xml_mapping(klass, custom) if custom
|
|
47
|
+
|
|
48
|
+
apply_xml_mapping(klass, name)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def apply_xml_mapping(klass, root_name)
|
|
53
|
+
attr_names = klass.attributes.keys
|
|
54
|
+
return if attr_names.empty?
|
|
55
|
+
|
|
56
|
+
klass.xml do |mapping|
|
|
57
|
+
mapping.element(root_name)
|
|
58
|
+
attr_names.each do |attr_name|
|
|
59
|
+
mapping.map_element(attr_name.to_s, to: attr_name)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# RS 3010 extension: key-value mapping blocks for the declared
|
|
65
|
+
# formats (yaml/json/toml share lutaml-model's key_value model).
|
|
66
|
+
|
|
67
|
+
# RS 3010 extension: key-value mapping blocks for the declared
|
|
68
|
+
# formats (yaml/json/toml share lutaml-model's key_value model).
|
|
69
|
+
def apply_key_value_mappings
|
|
70
|
+
@compiled.each do |name, klass|
|
|
71
|
+
next if @enum_names.include?(name)
|
|
72
|
+
|
|
73
|
+
custom = serialization_mapping_for(name, 'key_value')
|
|
74
|
+
apply_custom_key_value_mapping(klass, custom) if custom
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def serialization_mapping_for(target, format)
|
|
79
|
+
@serialization_mappings.find do |m|
|
|
80
|
+
m.target.to_s == target && serialization_format(m.format) == format
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def serialization_format(format)
|
|
85
|
+
%w[yaml json toml].include?(format.to_s) ? 'key_value' : format.to_s
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def apply_custom_xml_mapping(klass, mapping)
|
|
89
|
+
check_namespace_ambiguity(mapping)
|
|
90
|
+
ns_class = namespace_class_for(mapping.namespace_ref) if mapping.namespace_ref
|
|
91
|
+
rule_ns = mapping.rules.select { |r| r.kind == 'namespace' }
|
|
92
|
+
.to_h { |r| [r.field, namespace_class_for(r.field)] }
|
|
93
|
+
klass.xml do |m|
|
|
94
|
+
m.element(mapping.element_name) if mapping.element_name
|
|
95
|
+
m.namespace(ns_class) if ns_class
|
|
96
|
+
m.mixed_content if mapping.mixed_content
|
|
97
|
+
mapping.rules.each do |rule|
|
|
98
|
+
case rule.kind
|
|
99
|
+
when 'element' then m.map_element(rule.wire, to: rule.field.to_sym)
|
|
100
|
+
when 'attribute' then m.map_attribute(rule.wire, to: rule.field.to_sym)
|
|
101
|
+
when 'content' then m.map_content(to: rule.field.to_sym)
|
|
102
|
+
when 'namespace' then m.namespace(rule_ns[rule.field])
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def apply_custom_key_value_mapping(klass, mapping)
|
|
109
|
+
formats = mapping.format.to_s == 'key_value' ? %w[yaml json toml] : [mapping.format.to_s]
|
|
110
|
+
klass.instance_eval do
|
|
111
|
+
formats.each do |format|
|
|
112
|
+
public_send(format) do |m|
|
|
113
|
+
mapping.rules.each do |rule|
|
|
114
|
+
case rule.kind
|
|
115
|
+
when 'map' then m.map(rule.wire, to: rule.field.to_sym)
|
|
116
|
+
when 'key' then m.map_key(to_instance: rule.field.to_sym)
|
|
117
|
+
when 'value'
|
|
118
|
+
if rule.mode == 'as_attribute'
|
|
119
|
+
m.map_value(as_attribute: rule.field.to_sym)
|
|
120
|
+
else
|
|
121
|
+
m.map_value(to_instance: rule.field.to_sym)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def check_namespace_ambiguity(mapping)
|
|
131
|
+
return unless mapping.format.to_s == 'xml'
|
|
132
|
+
return if mapping.namespace_ref || @declared_namespaces.length < 2
|
|
133
|
+
|
|
134
|
+
raise ArgumentError,
|
|
135
|
+
"mapping #{mapping.format} #{mapping.target}: ambiguous default " \
|
|
136
|
+
"namespace — #{@declared_namespaces.length} namespaces in scope " \
|
|
137
|
+
"(#{@declared_namespaces.map(&:name).join(', ')}); declare one with " \
|
|
138
|
+
"'namespace <name>'"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def namespace_class_for(ref)
|
|
142
|
+
return nil if ref.nil?
|
|
143
|
+
|
|
144
|
+
require 'lutaml/xml/namespace' unless defined?(Lutaml::Xml::Namespace)
|
|
145
|
+
|
|
146
|
+
ns_class = @namespace_classes[ref]
|
|
147
|
+
return ns_class if ns_class
|
|
148
|
+
|
|
149
|
+
declared = @declared_namespaces.find { |ns| ns.name.to_s == ref }
|
|
150
|
+
raise ArgumentError, "unknown namespace '#{ref}'" unless declared
|
|
151
|
+
|
|
152
|
+
ns_class = Class.new(Lutaml::Xml::Namespace)
|
|
153
|
+
uri = declared.uri
|
|
154
|
+
prefix = declared.prefix.to_s
|
|
155
|
+
ns_class.define_singleton_method(:uri) { uri }
|
|
156
|
+
ns_class.define_singleton_method(:prefix_default) { prefix }
|
|
157
|
+
@namespace_classes[ref] = ns_class
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Lml
|
|
5
|
+
class ModelCompiler
|
|
6
|
+
# Registers declared string_format handlers on
|
|
7
|
+
# compiled classes.
|
|
8
|
+
module StringFormats
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# L5: register grammar-backed string formats. Class-level
|
|
12
|
+
# declarations win; a models-block default applies to classes
|
|
13
|
+
# without their own. Artifact resolution: the compiler's
|
|
14
|
+
# artifacts map (name => path), else the string as a path.
|
|
15
|
+
def register_string_formats(doc)
|
|
16
|
+
defaults = doc.packages.flat_map(&:default_string_formats)
|
|
17
|
+
doc.classes.each do |class_def|
|
|
18
|
+
declared = Array(class_def.string_formats)
|
|
19
|
+
declared.each { |fmt| register_string_format(class_def.name.to_s, fmt) }
|
|
20
|
+
|
|
21
|
+
declared_formats = declared.map(&:format)
|
|
22
|
+
defaults.reject { |fmt| declared_formats.include?(fmt.format) }
|
|
23
|
+
.each { |fmt| register_string_format(class_def.name.to_s, fmt) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def register_string_format(class_name, fmt)
|
|
28
|
+
artifact_path = artifact_path_for(fmt.artifact.to_s)
|
|
29
|
+
return unless artifact_path
|
|
30
|
+
|
|
31
|
+
compiled = @compiled[class_name]
|
|
32
|
+
return unless compiled
|
|
33
|
+
|
|
34
|
+
Parsanol::PARG::Lutaml.register(compiled,
|
|
35
|
+
format_name: fmt.format.to_sym,
|
|
36
|
+
artifact: artifact_path,
|
|
37
|
+
entry: fmt.root.to_s)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Lml
|
|
5
|
+
class ModelCompiler
|
|
6
|
+
# Evaluates hydrated instances against their compiled
|
|
7
|
+
# models (unknown attributes, enum membership, nesting).
|
|
8
|
+
module Validation
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def validate_instance(instance, errors, path = 'root')
|
|
12
|
+
if instance.instance && Array(instance.attributes).empty?
|
|
13
|
+
validate_instance(instance.instance, errors, "#{path}.instance")
|
|
14
|
+
return
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attrs = Array(instance.attributes)
|
|
18
|
+
type_attr = attrs.find { |a| a.name == 'type' }
|
|
19
|
+
type_name = type_attr ? type_attr.value.to_s : instance.type
|
|
20
|
+
klass = @compiled[demodulize(type_name)]
|
|
21
|
+
unless klass
|
|
22
|
+
errors << "#{path}: unknown type '#{type_name}'"
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
schema_attrs = klass.attributes.keys.map(&:to_s)
|
|
27
|
+
present_attrs = attrs.map(&:name)
|
|
28
|
+
schema_set = schema_attrs.to_set
|
|
29
|
+
present_set = present_attrs.to_set
|
|
30
|
+
|
|
31
|
+
unknown = present_set - schema_set - Set.new(%w[type])
|
|
32
|
+
unknown.each do |name|
|
|
33
|
+
errors << "#{path}.#{name}: attribute not defined on #{type_name}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
required = klass.attributes.values
|
|
37
|
+
.reject { |v| v.collection? || v.options.key?(:default) }
|
|
38
|
+
.to_set { |v| v.name.to_s }
|
|
39
|
+
|
|
40
|
+
missing = required - present_set
|
|
41
|
+
missing.each do |name|
|
|
42
|
+
errors << "#{path}.#{name}: required attribute missing (cardinality min >= 1)"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
attrs.each do |attr|
|
|
46
|
+
next unless schema_attrs.include?(attr.name)
|
|
47
|
+
|
|
48
|
+
attr_def = klass.attributes[attr.name.to_sym]
|
|
49
|
+
next unless attr_def
|
|
50
|
+
|
|
51
|
+
next unless attr_def.collection? && attr.instances.any?
|
|
52
|
+
|
|
53
|
+
attr.instances.each_with_index do |nested, i|
|
|
54
|
+
validate_instance(nested, errors, "#{path}.#{attr.name}[#{i}]")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
return unless instance.instance
|
|
59
|
+
|
|
60
|
+
validate_instance(instance.instance, errors, "#{path}.instance")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|