metaschema 0.2.0 → 0.2.2
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/.rubocop_todo.yml +155 -28
- data/README.adoc +54 -4
- data/lib/metaschema/allowed_value_type.rb +1 -1
- data/lib/metaschema/anchor_type.rb +1 -1
- data/lib/metaschema/augment_type.rb +39 -0
- data/lib/metaschema/code_type.rb +1 -1
- data/lib/metaschema/constraint_validator.rb +483 -0
- data/lib/metaschema/inline_markup_type.rb +1 -1
- data/lib/metaschema/json_schema_generator.rb +456 -0
- data/lib/metaschema/list_item_type.rb +1 -1
- data/lib/metaschema/markdown_doc_generator.rb +354 -0
- data/lib/metaschema/markup_line_datatype.rb +1 -1
- data/lib/metaschema/markup_multiline_datatype.rb +41 -0
- data/lib/metaschema/metapath_evaluator.rb +385 -0
- data/lib/metaschema/model_generator/assembly_factory.rb +1583 -0
- data/lib/metaschema/model_generator/field_factory.rb +275 -0
- data/lib/metaschema/model_generator/services/collapsibles_collapser.rb +82 -0
- data/lib/metaschema/model_generator/services/field_deserializer.rb +92 -0
- data/lib/metaschema/model_generator/services/field_serializer.rb +111 -0
- data/lib/metaschema/model_generator/utils.rb +64 -0
- data/lib/metaschema/model_generator.rb +280 -0
- data/lib/metaschema/preformatted_type.rb +1 -1
- data/lib/metaschema/root.rb +2 -0
- data/lib/metaschema/ruby_source_emitter.rb +875 -0
- data/lib/metaschema/table_cell_type.rb +1 -1
- data/lib/metaschema/type_mapper.rb +102 -0
- data/lib/metaschema/version.rb +1 -1
- data/lib/metaschema.rb +9 -0
- metadata +17 -2
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metaschema
|
|
4
|
+
class ModelGenerator
|
|
5
|
+
# Creates field classes from Metaschema field definitions.
|
|
6
|
+
# Each field class inherits from Lutaml::Model::Serializable and
|
|
7
|
+
# includes XML + JSON mappings for the field's content and flags.
|
|
8
|
+
class FieldFactory
|
|
9
|
+
def initialize(field_def, generator)
|
|
10
|
+
@field_def = field_def
|
|
11
|
+
@g = generator
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
fd = @field_def
|
|
16
|
+
return unless fd.name
|
|
17
|
+
|
|
18
|
+
klass_name = "Field_#{fd.name.gsub('-', '_')}"
|
|
19
|
+
klass = Class.new(Lutaml::Model::Serializable)
|
|
20
|
+
@g.classes[klass_name] = klass
|
|
21
|
+
|
|
22
|
+
is_markup = TypeMapper.markup?(fd.as_type)
|
|
23
|
+
is_multiline = TypeMapper.multiline?(fd.as_type)
|
|
24
|
+
content_type = TypeMapper.map(fd.as_type)
|
|
25
|
+
|
|
26
|
+
if is_multiline
|
|
27
|
+
self.class.apply_markup_multiline_attributes(klass)
|
|
28
|
+
elsif is_markup
|
|
29
|
+
self.class.apply_markup_attributes(klass)
|
|
30
|
+
elsif fd.collapsible == "yes"
|
|
31
|
+
klass.attribute :content, content_type, collection: true
|
|
32
|
+
else
|
|
33
|
+
klass.attribute :content, content_type
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
fd.define_flag&.each { |f| @g.add_inline_flag(klass, f) }
|
|
37
|
+
fd.flag&.each { |f| @g.add_flag_reference(klass, f) }
|
|
38
|
+
|
|
39
|
+
build_field_xml(klass, fd.name, is_markup || is_multiline,
|
|
40
|
+
fd, is_multiline)
|
|
41
|
+
build_field_json(klass, fd)
|
|
42
|
+
|
|
43
|
+
has_flags = fd.define_flag&.any? || fd.flag&.any?
|
|
44
|
+
has_json_vk = fd.json_value_key || fd.json_value_key_flag
|
|
45
|
+
is_collapsible = fd.collapsible == "yes"
|
|
46
|
+
value_key = fd.json_value_key || TypeMapper.json_value_key(fd.as_type)
|
|
47
|
+
|
|
48
|
+
klass.define_singleton_method(:of_json) do |data|
|
|
49
|
+
if data.is_a?(String)
|
|
50
|
+
new(content: data)
|
|
51
|
+
else
|
|
52
|
+
super(data)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
klass.define_singleton_method(:from_json) do |data|
|
|
57
|
+
if data.is_a?(String)
|
|
58
|
+
new(content: data)
|
|
59
|
+
else
|
|
60
|
+
super(data)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if has_flags || has_json_vk || is_collapsible
|
|
65
|
+
flag_attr_names = (fd.define_flag || []).filter_map do |f|
|
|
66
|
+
Utils.safe_attr(f.name) if f.name
|
|
67
|
+
end +
|
|
68
|
+
(fd.flag || []).filter_map do |f|
|
|
69
|
+
Utils.safe_attr(f.ref) if f.ref
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
orig_as_json = klass.method(:as_json)
|
|
73
|
+
klass.define_singleton_method(:as_json) do |instance, options = {}|
|
|
74
|
+
result = orig_as_json.call(instance, options)
|
|
75
|
+
|
|
76
|
+
if is_collapsible && result.is_a?(Hash) && result[value_key].is_a?(Array) && result[value_key].length == 1
|
|
77
|
+
result[value_key] = result[value_key].first
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if (has_flags || has_json_vk) && result.is_a?(Hash) && result.key?(value_key)
|
|
81
|
+
flags_present = flag_attr_names.any? do |attr|
|
|
82
|
+
val = instance.send(attr)
|
|
83
|
+
val && !(val.respond_to?(:using_default?) && val.using_default?)
|
|
84
|
+
end
|
|
85
|
+
unless flags_present
|
|
86
|
+
return result[value_key]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
result
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
@g.apply_constraint_validation(klass, fd.constraint)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class << self
|
|
98
|
+
# Add inline markup attributes (a, code, em, etc.) for markup-line fields.
|
|
99
|
+
def apply_markup_attributes(klass)
|
|
100
|
+
klass.attribute :content, :string, collection: true
|
|
101
|
+
klass.attribute :a, AnchorType, collection: true
|
|
102
|
+
klass.attribute :insert, InsertType, collection: true
|
|
103
|
+
klass.attribute :br, :string, collection: true
|
|
104
|
+
klass.attribute :code, CodeType, collection: true
|
|
105
|
+
klass.attribute :em, InlineMarkupType, collection: true
|
|
106
|
+
klass.attribute :i, InlineMarkupType, collection: true
|
|
107
|
+
klass.attribute :b, InlineMarkupType, collection: true
|
|
108
|
+
klass.attribute :strong, InlineMarkupType, collection: true
|
|
109
|
+
klass.attribute :sub, InlineMarkupType, collection: true
|
|
110
|
+
klass.attribute :sup, InlineMarkupType, collection: true
|
|
111
|
+
klass.attribute :q, InlineMarkupType, collection: true
|
|
112
|
+
klass.attribute :img, ImageType, collection: true
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Add block-level markup attributes (p, h1-h6, ul, etc.) for markup-multiline fields.
|
|
116
|
+
def apply_markup_multiline_attributes(klass)
|
|
117
|
+
apply_markup_attributes(klass)
|
|
118
|
+
klass.attribute :p, InlineMarkupType, collection: true
|
|
119
|
+
klass.attribute :h1, InlineMarkupType, collection: true
|
|
120
|
+
klass.attribute :h2, InlineMarkupType, collection: true
|
|
121
|
+
klass.attribute :h3, InlineMarkupType, collection: true
|
|
122
|
+
klass.attribute :h4, InlineMarkupType, collection: true
|
|
123
|
+
klass.attribute :h5, InlineMarkupType, collection: true
|
|
124
|
+
klass.attribute :h6, InlineMarkupType, collection: true
|
|
125
|
+
klass.attribute :ul, ListType, collection: true
|
|
126
|
+
klass.attribute :ol, OrderedListType, collection: true
|
|
127
|
+
klass.attribute :pre, PreformattedType, collection: true
|
|
128
|
+
klass.attribute :hr, :string, collection: true
|
|
129
|
+
klass.attribute :blockquote, BlockQuoteType, collection: true
|
|
130
|
+
klass.attribute :table, TableType, collection: true
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def build_field_xml(klass, xml_element, is_markup, field_def,
|
|
137
|
+
is_multiline = false)
|
|
138
|
+
flag_defs = field_def.define_flag || []
|
|
139
|
+
flag_refs = field_def.flag || []
|
|
140
|
+
|
|
141
|
+
flag_attr_maps = flag_defs.filter_map do |f|
|
|
142
|
+
[f.name, Utils.safe_attr(f.name)] if f.name
|
|
143
|
+
end
|
|
144
|
+
flag_ref_maps = flag_refs.filter_map do |f|
|
|
145
|
+
[f.ref, Utils.safe_attr(f.ref)] if f.ref
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
klass.class_eval do
|
|
149
|
+
xml do
|
|
150
|
+
element xml_element
|
|
151
|
+
mixed_content if is_markup
|
|
152
|
+
ordered if is_markup
|
|
153
|
+
|
|
154
|
+
map_content to: :content
|
|
155
|
+
|
|
156
|
+
if is_markup
|
|
157
|
+
map_element "a", to: :a
|
|
158
|
+
map_element "insert", to: :insert
|
|
159
|
+
map_element "br", to: :br
|
|
160
|
+
map_element "code", to: :code
|
|
161
|
+
map_element "em", to: :em
|
|
162
|
+
map_element "i", to: :i
|
|
163
|
+
map_element "b", to: :b
|
|
164
|
+
map_element "strong", to: :strong
|
|
165
|
+
map_element "sub", to: :sub
|
|
166
|
+
map_element "sup", to: :sup
|
|
167
|
+
map_element "q", to: :q
|
|
168
|
+
map_element "img", to: :img
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
if is_multiline
|
|
172
|
+
map_element "p", to: :p
|
|
173
|
+
map_element "h1", to: :h1
|
|
174
|
+
map_element "h2", to: :h2
|
|
175
|
+
map_element "h3", to: :h3
|
|
176
|
+
map_element "h4", to: :h4
|
|
177
|
+
map_element "h5", to: :h5
|
|
178
|
+
map_element "h6", to: :h6
|
|
179
|
+
map_element "ul", to: :ul
|
|
180
|
+
map_element "ol", to: :ol
|
|
181
|
+
map_element "pre", to: :pre
|
|
182
|
+
map_element "hr", to: :hr
|
|
183
|
+
map_element "blockquote", to: :blockquote
|
|
184
|
+
map_element "table", to: :table
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
flag_attr_maps.each do |xml_name, attr_name|
|
|
188
|
+
map_attribute xml_name, to: attr_name
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
flag_ref_maps.each do |xml_name, attr_name|
|
|
192
|
+
map_attribute xml_name, to: attr_name
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def build_field_json(klass, field_def)
|
|
199
|
+
flag_defs = field_def.define_flag || []
|
|
200
|
+
flag_refs = field_def.flag || []
|
|
201
|
+
has_flags = flag_defs.any? || flag_refs.any?
|
|
202
|
+
json_vk = field_def.json_value_key
|
|
203
|
+
json_vk_flag = field_def.json_value_key_flag&.flag_ref
|
|
204
|
+
|
|
205
|
+
if json_vk_flag
|
|
206
|
+
build_field_json_value_key_flag(klass, field_def, json_vk_flag)
|
|
207
|
+
return
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
value_key = json_vk || TypeMapper.json_value_key(field_def.as_type)
|
|
211
|
+
|
|
212
|
+
flag_attr_maps = flag_defs.filter_map do |f|
|
|
213
|
+
[f.name, Utils.safe_attr(f.name)] if f.name
|
|
214
|
+
end
|
|
215
|
+
flag_ref_maps = flag_refs.filter_map do |f|
|
|
216
|
+
[f.ref, Utils.safe_attr(f.ref)] if f.ref
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
klass.class_eval do
|
|
220
|
+
key_value do
|
|
221
|
+
root field_def.name
|
|
222
|
+
|
|
223
|
+
if has_flags || json_vk
|
|
224
|
+
map value_key, to: :content
|
|
225
|
+
else
|
|
226
|
+
map "content", to: :content
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
flag_attr_maps.each do |xml_name, attr_name|
|
|
230
|
+
map xml_name, to: attr_name
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
flag_ref_maps.each do |xml_name, attr_name|
|
|
234
|
+
map xml_name, to: attr_name
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def build_field_json_value_key_flag(klass, field_def, key_flag_ref)
|
|
241
|
+
key_attr = Utils.safe_attr(key_flag_ref)
|
|
242
|
+
flag_defs = field_def.define_flag || []
|
|
243
|
+
flag_refs = field_def.flag || []
|
|
244
|
+
|
|
245
|
+
other_flag_maps = flag_defs.reject { |f| f.name == key_flag_ref }
|
|
246
|
+
.filter_map do |f|
|
|
247
|
+
if f.name
|
|
248
|
+
[f.name,
|
|
249
|
+
Utils.safe_attr(f.name)]
|
|
250
|
+
end
|
|
251
|
+
end +
|
|
252
|
+
flag_refs.reject { |f| f.ref == key_flag_ref }
|
|
253
|
+
.filter_map do |f|
|
|
254
|
+
if f.ref
|
|
255
|
+
[f.ref,
|
|
256
|
+
Utils.safe_attr(f.ref)]
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
klass.instance_variable_set(:@json_vk_flag_key_attr, key_attr)
|
|
261
|
+
klass.instance_variable_set(:@json_vk_flag_other_flag_maps,
|
|
262
|
+
other_flag_maps)
|
|
263
|
+
|
|
264
|
+
klass.class_eval do
|
|
265
|
+
key_value do
|
|
266
|
+
root field_def.name
|
|
267
|
+
other_flag_maps.each do |json_name, attr_name|
|
|
268
|
+
map json_name, to: attr_name
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metaschema
|
|
4
|
+
class ModelGenerator
|
|
5
|
+
module Services
|
|
6
|
+
# Collapses multiple model instances that share the same flag values
|
|
7
|
+
# into a single instance with array-valued content. Implements the
|
|
8
|
+
# Metaschema "collapsible" field semantics.
|
|
9
|
+
#
|
|
10
|
+
# The inverse operation (expanding collapsed items) is performed by
|
|
11
|
+
# FieldDeserializer#separate.
|
|
12
|
+
class CollapsiblesCollapser
|
|
13
|
+
attr_reader :collapsibles
|
|
14
|
+
|
|
15
|
+
def initialize(model_class, collapsible_attributes, format, models)
|
|
16
|
+
@model_class = model_class
|
|
17
|
+
@collapsible_attributes = collapsible_attributes
|
|
18
|
+
@format = format
|
|
19
|
+
@uncollapsible_mappings = model_class.mappings[format].mappings
|
|
20
|
+
.reject { |n| collapsible_attributes.key?(n.to) }
|
|
21
|
+
@collapsibles = []
|
|
22
|
+
@groups = {}
|
|
23
|
+
process(models)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(value)
|
|
27
|
+
@groups.map { |_, group| collapse_group(group, value) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def process(models)
|
|
33
|
+
models.each_with_index do |model, index|
|
|
34
|
+
collapsible = create_collapsible(model)
|
|
35
|
+
@collapsibles << collapsible
|
|
36
|
+
group_id = group_id_for(collapsible)
|
|
37
|
+
(@groups[group_id] ||= []) << [index, collapsible]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def create_collapsible(model)
|
|
42
|
+
@model_class.new(attributes_from(model))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def attributes_from(model)
|
|
46
|
+
model.class.attributes.each_with_object({}) do |(name, attr), attrs|
|
|
47
|
+
value = model.public_send(name)
|
|
48
|
+
next if value == attr.default && @collapsible_attributes.key?(name)
|
|
49
|
+
|
|
50
|
+
attrs[name] = value
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def group_id_for(model)
|
|
55
|
+
@collapsible_attributes.transform_values do |attr|
|
|
56
|
+
model.public_send(attr.name)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def collapse_group(group, value)
|
|
61
|
+
indices = group.map { |idx, _| idx }
|
|
62
|
+
first_collapsed = value[indices.first]
|
|
63
|
+
|
|
64
|
+
return first_collapsed if indices.one?
|
|
65
|
+
|
|
66
|
+
merge_uncollapsible(first_collapsed, indices, value)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def merge_uncollapsible(first, indices, value)
|
|
70
|
+
return first unless @uncollapsible_mappings.any?
|
|
71
|
+
|
|
72
|
+
result = first.dup
|
|
73
|
+
@uncollapsible_mappings.each do |rule|
|
|
74
|
+
values = indices.map { |idx| value[idx][rule.name] }
|
|
75
|
+
result[rule.name] = values
|
|
76
|
+
end
|
|
77
|
+
result
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metaschema
|
|
4
|
+
class ModelGenerator
|
|
5
|
+
module Services
|
|
6
|
+
# Deserializes a field value from the source format into a model instance.
|
|
7
|
+
# Handles SINGLETON_OR_ARRAY normalization and collapsible field expansion.
|
|
8
|
+
#
|
|
9
|
+
# Pipeline: normalize -> separate -> cast -> validate_collection -> transform
|
|
10
|
+
class FieldDeserializer
|
|
11
|
+
def initialize(model, attr, format, data, group_as:, collapsible:)
|
|
12
|
+
@model = model
|
|
13
|
+
@attribute = model.class.attributes[attr]
|
|
14
|
+
@format = format
|
|
15
|
+
@mapping_rule = model.class.mappings[format].mappings
|
|
16
|
+
.find { |r| r.to == attr }
|
|
17
|
+
@data = data
|
|
18
|
+
@group_as = group_as
|
|
19
|
+
@collapsible = collapsible
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.call(...)
|
|
23
|
+
new(...).call
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call
|
|
27
|
+
data = normalize(@data)
|
|
28
|
+
data = separate(data) if @collapsible
|
|
29
|
+
data = cast(data)
|
|
30
|
+
validate_collection!(data)
|
|
31
|
+
data = transform(data)
|
|
32
|
+
data = unwrap_singleton(data)
|
|
33
|
+
|
|
34
|
+
@model.public_send(:"#{@attribute.name}=", data)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Wrap non-array data in an array for SINGLETON_OR_ARRAY fields.
|
|
40
|
+
def normalize(data)
|
|
41
|
+
if @group_as == "SINGLETON_OR_ARRAY" && !data.is_a?(Array)
|
|
42
|
+
[data].compact
|
|
43
|
+
else
|
|
44
|
+
data
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Expand collapsed items back into individual instances.
|
|
49
|
+
# Collapsed data has array-valued non-collapsible attributes;
|
|
50
|
+
# we expand each array position into a separate instance.
|
|
51
|
+
def separate(data)
|
|
52
|
+
data.each_with_object([]) do |item, results|
|
|
53
|
+
size = item.each_value.find { |v| v.is_a?(Array) }&.size
|
|
54
|
+
|
|
55
|
+
if size
|
|
56
|
+
size.times do |index|
|
|
57
|
+
results << item.transform_values do |v|
|
|
58
|
+
v.is_a?(Array) ? v[index] : v
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
else
|
|
62
|
+
results << item
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def cast(data)
|
|
68
|
+
opts = { polymorphic: @mapping_rule&.polymorphic }.compact
|
|
69
|
+
@attribute.cast(data, @format, @model.lutaml_register, opts)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate_collection!(data)
|
|
73
|
+
@attribute.valid_collection!(data, @model.class)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def transform(data)
|
|
77
|
+
Lutaml::Model::ImportTransformer.call(data, @mapping_rule, @attribute)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# For SINGLETON_OR_ARRAY with non-collection attributes, unwrap
|
|
81
|
+
# a single-element array back to a scalar value.
|
|
82
|
+
def unwrap_singleton(data)
|
|
83
|
+
return data unless data.is_a?(Array)
|
|
84
|
+
return data unless @group_as == "SINGLETON_OR_ARRAY"
|
|
85
|
+
return data if @attribute.collection
|
|
86
|
+
|
|
87
|
+
data.one? ? data.first : data
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "collapsibles_collapser"
|
|
4
|
+
|
|
5
|
+
module Metaschema
|
|
6
|
+
class ModelGenerator
|
|
7
|
+
module Services
|
|
8
|
+
# Serializes a field value from a model instance to the target format.
|
|
9
|
+
# Handles SINGLETON_OR_ARRAY normalization and collapsible field merging.
|
|
10
|
+
#
|
|
11
|
+
# Pipeline: transform -> make_collapsible -> serialize -> apply_value_map
|
|
12
|
+
# -> collapse -> denormalize
|
|
13
|
+
class FieldSerializer
|
|
14
|
+
def initialize(model, attr, format, data, group_as:, collapsible:)
|
|
15
|
+
@model = model
|
|
16
|
+
@attribute = model.class.attributes[attr]
|
|
17
|
+
@format = format
|
|
18
|
+
@mapping_rule = model.class.mappings[format].mappings
|
|
19
|
+
.find { |r| r.to == attr }
|
|
20
|
+
@data = data
|
|
21
|
+
@group_as = group_as
|
|
22
|
+
@collapsible = collapsible
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.call(...)
|
|
26
|
+
new(...).call
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
value = @model.public_send(@attribute.name)
|
|
31
|
+
return if value.nil?
|
|
32
|
+
|
|
33
|
+
value = transform(value)
|
|
34
|
+
value = make_collapsible(value) if @collapsible
|
|
35
|
+
value = serialize(value)
|
|
36
|
+
return unless @mapping_rule&.render?(value, @model)
|
|
37
|
+
|
|
38
|
+
value = apply_value_map(value)
|
|
39
|
+
value = collapse(value) if @collapsible
|
|
40
|
+
value = denormalize(value)
|
|
41
|
+
|
|
42
|
+
@data[@mapping_rule.name] = value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def transform(value)
|
|
48
|
+
Lutaml::Model::ExportTransformer.call(value, @mapping_rule,
|
|
49
|
+
@attribute)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def make_collapsible(value)
|
|
53
|
+
collapsible_attrs = @attribute.type.attributes
|
|
54
|
+
.reject { |_, attr| content_attribute?(attr) }
|
|
55
|
+
@collapser = CollapsiblesCollapser.new(
|
|
56
|
+
@attribute.type, collapsible_attrs, @format, value
|
|
57
|
+
)
|
|
58
|
+
@collapser.collapsibles
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def content_attribute?(attr)
|
|
62
|
+
attr.name == :content && attr.type < Lutaml::Model::Type::Value
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def serialize(value)
|
|
66
|
+
@attribute.serialize(value, @format, @model.lutaml_register)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def apply_value_map(value)
|
|
70
|
+
if value.nil?
|
|
71
|
+
value_for_option(value_map[:nil], @attribute)
|
|
72
|
+
elsif Lutaml::Model::Utils.empty?(value)
|
|
73
|
+
value_for_option(value_map[:empty], @attribute, value)
|
|
74
|
+
elsif Lutaml::Model::Utils.uninitialized?(value)
|
|
75
|
+
value_for_option(value_map[:omitted], @attribute)
|
|
76
|
+
else
|
|
77
|
+
value
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def value_map
|
|
82
|
+
@mapping_rule.value_map(:to)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def value_for_option(option, attr, empty_value = nil)
|
|
86
|
+
return nil if option == :nil
|
|
87
|
+
return empty_value || empty_object(attr) if option == :empty
|
|
88
|
+
|
|
89
|
+
Lutaml::Model::UninitializedClass.instance
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def empty_object(attr)
|
|
93
|
+
attr.collection? ? [] : ""
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def collapse(value)
|
|
97
|
+
@collapser.call(value)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def denormalize(value)
|
|
101
|
+
soa = @group_as == "SINGLETON_OR_ARRAY"
|
|
102
|
+
if soa && value.is_a?(Array) && value.one?
|
|
103
|
+
value.first
|
|
104
|
+
else
|
|
105
|
+
value
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metaschema
|
|
4
|
+
class ModelGenerator
|
|
5
|
+
# Shared utility methods extracted from ModelGenerator.
|
|
6
|
+
# Used by FieldFactory, AssemblyFactory, and ModelGenerator itself.
|
|
7
|
+
module Utils
|
|
8
|
+
RESERVED_WORDS = %i[class module method hash object_id nil? is_a? kind_of?
|
|
9
|
+
instance_of? respond_to? send].freeze
|
|
10
|
+
|
|
11
|
+
# Convert a Metaschema name (hyphenated) to a safe Ruby attribute name.
|
|
12
|
+
def self.safe_attr(name)
|
|
13
|
+
sym = name.gsub("-", "_").to_sym
|
|
14
|
+
RESERVED_WORDS.include?(sym) ? :"#{sym}_attr" : sym
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Check if a max-occurs value represents an unbounded collection.
|
|
18
|
+
def self.unbounded?(max_occurs)
|
|
19
|
+
max_occurs.nil? || max_occurs == "unbounded" ||
|
|
20
|
+
(max_occurs.respond_to?(:to_i) && max_occurs.to_i > 1)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Generate a scoped name for inline field classes to avoid collisions.
|
|
24
|
+
def self.scoped_field_name(field_name, parent_name = nil)
|
|
25
|
+
if parent_name
|
|
26
|
+
"Field_#{parent_name}_#{field_name.gsub('-', '_')}"
|
|
27
|
+
else
|
|
28
|
+
"Field_#{field_name.gsub('-', '_')}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Create an anonymous class with a debuggable temporary name.
|
|
33
|
+
def self.create_model(name, super_class = Lutaml::Model::Serializable)
|
|
34
|
+
model = Class.new(super_class)
|
|
35
|
+
set_model_temporary_name(model, name)
|
|
36
|
+
model
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Check if an object is a lutaml-model Serializable subclass.
|
|
40
|
+
def self.model?(object)
|
|
41
|
+
object.is_a?(Class) && object.include?(Lutaml::Model::Serialize)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Assign a human-readable name to an anonymous class for debugging.
|
|
45
|
+
def self.set_model_temporary_name(model, name)
|
|
46
|
+
display_name = name.gsub(/(?:^|[-._]+)./) do |n|
|
|
47
|
+
n[-1].upcase
|
|
48
|
+
end + ":Class"
|
|
49
|
+
|
|
50
|
+
if model.respond_to?(:set_temporary_name)
|
|
51
|
+
model.set_temporary_name(display_name)
|
|
52
|
+
return
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
model.class_eval <<~RUBY, __FILE__, __LINE__ + 1
|
|
56
|
+
def self.to_s
|
|
57
|
+
name || #{display_name.inspect}
|
|
58
|
+
end
|
|
59
|
+
singleton_class.alias_method :inspect, :to_s
|
|
60
|
+
RUBY
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|