lutaml-lml 0.2.0 → 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 +84 -18
- data/lib/lutaml/lml/data_processor/value_processing.rb +6 -1
- data/lib/lutaml/lml/document_builder.rb +72 -3
- data/lib/lutaml/lml/format/adapter/mapping.rb +12 -0
- data/lib/lutaml/lml/format/adapter/standard_adapter.rb +41 -12
- data/lib/lutaml/lml/formatter/elk.rb +45 -0
- data/lib/lutaml/lml/formatter/graphviz/node_formatter.rb +2 -1
- 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 +2 -0
- data/lib/lutaml/lml/grammar/lml.artifact.json +7265 -4550
- data/lib/lutaml/lml/grammar/lml.parg +230 -115
- data/lib/lutaml/lml/layout/elk_engine.rb +15 -0
- data/lib/lutaml/lml/layout/elk_graph_builder.rb +129 -0
- data/lib/lutaml/lml/layout/elk_svg_markers.rb +45 -0
- data/lib/lutaml/lml/layout/elk_svg_renderer.rb +119 -0
- data/lib/lutaml/lml/layout/graph_viz_engine.rb +1 -1
- data/lib/lutaml/lml/layout.rb +4 -0
- 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 -453
- data/lib/lutaml/lml/models/document.rb +29 -0
- data/lib/lutaml/lml/models/operation_parameter.rb +1 -0
- data/lib/lutaml/lml/models/reference.rb +23 -0
- data/lib/lutaml/lml/models/top_element_attribute.rb +12 -0
- data/lib/lutaml/lml/models/uml_class.rb +1 -1
- data/lib/lutaml/lml/preprocessor.rb +44 -79
- data/lib/lutaml/lml/transform.rb +54 -6
- data/lib/lutaml/lml/validator.rb +88 -19
- data/lib/lutaml/lml/version.rb +1 -1
- data/lib/lutaml/lml.rb +19 -3
- metadata +17 -4
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Layout
|
|
5
|
+
# SVG arrow-marker vocabulary and the <defs> block for ElkSvgRenderer.
|
|
6
|
+
# Marker ids are keyed by association member_end_type.
|
|
7
|
+
module ElkSvgMarkers
|
|
8
|
+
EDGE_STROKE = '#666666'
|
|
9
|
+
|
|
10
|
+
ARROW_MARKERS = {
|
|
11
|
+
'association' => 'arrow',
|
|
12
|
+
'aggregation' => 'diamond',
|
|
13
|
+
'composition' => 'diamond_filled',
|
|
14
|
+
'generalization' => 'triangle',
|
|
15
|
+
'uses' => 'arrow',
|
|
16
|
+
'direct' => 'arrow'
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
MARKER_SHAPES = {
|
|
20
|
+
'arrow' => %(<path d="M 0 0 L 10 5 L 0 10 z" fill="#{EDGE_STROKE}"/>),
|
|
21
|
+
'triangle' => %(<path d="M 0 0 L 12 5 L 0 10 z" fill="white" stroke="#{EDGE_STROKE}"/>),
|
|
22
|
+
'diamond' => %(<path d="M 0 4 L 7 0 L 14 4 L 7 8 z" fill="white" stroke="#{EDGE_STROKE}"/>),
|
|
23
|
+
'diamond_filled' => %(<path d="M 0 4 L 7 0 L 14 4 L 7 8 z" fill="#{EDGE_STROKE}"/>)
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
def arrow_marker(edge)
|
|
27
|
+
edge_type = edge.properties&.fetch('member_end_type', nil)
|
|
28
|
+
ARROW_MARKERS.fetch(edge_type.to_s, 'arrow')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def defs
|
|
32
|
+
MARKER_SHAPES.filter_map { |id, path| marker_def(id, path) }.join("\n")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def marker_def(id, path)
|
|
38
|
+
w, h = id.start_with?('diamond') ? %w[14 8] : %w[10 10]
|
|
39
|
+
%(<marker id="#{id}" viewBox="0 0 #{w} #{h}" refX="9" refY="4" ) +
|
|
40
|
+
%(markerWidth="#{w}" markerHeight="#{h}" ) +
|
|
41
|
+
%(orient="auto-start-reverse">#{path}</marker>)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Layout
|
|
5
|
+
# Renders a laid-out Elkrb::Graph (nodes positioned by the layout) as
|
|
6
|
+
# standalone SVG: rounded-rect nodes with text-line labels and
|
|
7
|
+
# polyline edges with per-type arrow markers.
|
|
8
|
+
class ElkSvgRenderer
|
|
9
|
+
include ElkSvgMarkers
|
|
10
|
+
|
|
11
|
+
EDGE_STROKE = ElkSvgMarkers::EDGE_STROKE
|
|
12
|
+
|
|
13
|
+
HEADER = <<~XML
|
|
14
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
15
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="%<width>s" height="%<height>s" viewBox="0 0 %<width>s %<height>s">
|
|
16
|
+
XML
|
|
17
|
+
NODE_FILL = '#f8f8f8'
|
|
18
|
+
NODE_STROKE = '#333333'
|
|
19
|
+
|
|
20
|
+
def render(graph)
|
|
21
|
+
@graph = graph
|
|
22
|
+
@out = +''
|
|
23
|
+
@out << format(HEADER, width: bounds_width, height: bounds_height)
|
|
24
|
+
@out << "<defs>\n#{defs}\n</defs>\n"
|
|
25
|
+
nodes
|
|
26
|
+
edges
|
|
27
|
+
@out << "</svg>\n"
|
|
28
|
+
@out
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def nodes
|
|
34
|
+
@graph.children.each { |node| render_node(node) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def render_node(node)
|
|
38
|
+
@out << %(<g transform="translate(#{node.x.to_i},#{node.y.to_i})">)
|
|
39
|
+
@out << node_rect(node)
|
|
40
|
+
node.labels.each_with_index { |label, i| @out << node_label(label, i) }
|
|
41
|
+
@out << '</g>'
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def node_rect(node)
|
|
45
|
+
%(<rect width="#{node.width.to_i}" height="#{node.height.to_i}" rx="6" ) +
|
|
46
|
+
%(fill="#{NODE_FILL}" stroke="#{NODE_STROKE}"/>)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def node_label(label, index)
|
|
50
|
+
y = ((index + 0.7) * ElkGraphBuilder::LINE_HEIGHT).round(1)
|
|
51
|
+
weight = index.zero? ? ' font-weight="bold"' : ''
|
|
52
|
+
%(<text x="7" y="#{y}" font-family="Helvetica" font-size="12"#{weight}>#{escape(label.text)}</text>)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def edges
|
|
56
|
+
@graph.edges.each { |edge| render_edge(edge) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def render_edge(edge)
|
|
60
|
+
points = edge_points(edge)
|
|
61
|
+
return if points.empty?
|
|
62
|
+
|
|
63
|
+
@out << edge_polyline(edge, points)
|
|
64
|
+
edge_label(edge, points)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def edge_polyline(edge, points)
|
|
68
|
+
pts = points.map { |p| "#{p.x.round(1)},#{p.y.round(1)}" }.join(' ')
|
|
69
|
+
%(<polyline fill="none" stroke="#{EDGE_STROKE}" marker-end="url(##{arrow_marker(edge)})" points="#{pts}"/>)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def edge_label(edge, points)
|
|
73
|
+
label = Array(edge.labels).first
|
|
74
|
+
return unless label&.text
|
|
75
|
+
|
|
76
|
+
@out << edge_label_text(label, edge_anchor(points))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def edge_anchor(points)
|
|
80
|
+
mid = points[points.size / 2]
|
|
81
|
+
{ x: (mid.x + 4).round(1), y: (mid.y - 4).round(1) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def edge_label_text(label, anchor)
|
|
85
|
+
%(<text x="#{anchor[:x]}" y="#{anchor[:y]}" font-family="Helvetica" ) +
|
|
86
|
+
%(font-size="10" fill="#{EDGE_STROKE}">#{escape(label.text)}</text>)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def edge_points(edge)
|
|
90
|
+
sections = Array(edge.sections)
|
|
91
|
+
return [] if sections.empty?
|
|
92
|
+
|
|
93
|
+
points = [sections.first.start_point]
|
|
94
|
+
sections.each do |section|
|
|
95
|
+
points.concat(Array(section.bend_points))
|
|
96
|
+
points << section.end_point
|
|
97
|
+
end
|
|
98
|
+
points.compact
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def bounds_width
|
|
102
|
+
extent(:x, :width) + 20
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def bounds_height
|
|
106
|
+
extent(:y, :height) + 20
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def extent(pos, size)
|
|
110
|
+
values = @graph.children.map { |n| n.public_send(pos).to_f + n.public_send(size).to_f }
|
|
111
|
+
[values.max || 0, 0].max.round
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def escape(text)
|
|
115
|
+
text.to_s.gsub('&', '&').gsub('<', '<').gsub('>', '>')
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -7,7 +7,7 @@ module Lutaml
|
|
|
7
7
|
module Layout
|
|
8
8
|
class GraphVizEngine < Engine
|
|
9
9
|
def render(type)
|
|
10
|
-
Open3.popen3("dot -T#{type}") do |stdin, stdout, _stderr, _wait|
|
|
10
|
+
Open3.popen3("dot", "-T#{type}") do |stdin, stdout, _stderr, _wait|
|
|
11
11
|
stdout.binmode
|
|
12
12
|
stdin.puts(input)
|
|
13
13
|
stdin.close
|
data/lib/lutaml/lml/layout.rb
CHANGED
|
@@ -4,5 +4,9 @@ module Lutaml
|
|
|
4
4
|
module Layout
|
|
5
5
|
autoload :Engine, "lutaml/lml/layout/engine"
|
|
6
6
|
autoload :GraphVizEngine, "lutaml/lml/layout/graph_viz_engine"
|
|
7
|
+
autoload :ElkEngine, "lutaml/lml/layout/elk_engine"
|
|
8
|
+
autoload :ElkGraphBuilder, "lutaml/lml/layout/elk_graph_builder"
|
|
9
|
+
autoload :ElkSvgRenderer, "lutaml/lml/layout/elk_svg_renderer"
|
|
10
|
+
autoload :ElkSvgMarkers, "lutaml/lml/layout/elk_svg_markers"
|
|
7
11
|
end
|
|
8
12
|
end
|
|
@@ -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
|