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.
@@ -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