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,875 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metaschema
|
|
4
|
+
# Emits Ruby source code from generated metaschema classes.
|
|
5
|
+
#
|
|
6
|
+
# After ModelGenerator#generate creates in-memory classes, this class
|
|
7
|
+
# introspects them and emits equivalent Ruby source code that can be
|
|
8
|
+
# saved to .rb files and loaded with `require`.
|
|
9
|
+
#
|
|
10
|
+
# Handles three kinds of type references:
|
|
11
|
+
# 1. Builtin types (:string, :integer, etc.) — emitted as symbol literals
|
|
12
|
+
# 2. Generated types (in @classes) — emitted as fully-qualified string refs
|
|
13
|
+
# 3. Framework types (named, from other gems) — emitted as bare class refs
|
|
14
|
+
# 4. Anonymous inline types — collected and emitted as separate named classes
|
|
15
|
+
#
|
|
16
|
+
# Usage:
|
|
17
|
+
# files = Metaschema::ModelGenerator.to_ruby_source(
|
|
18
|
+
# "oscal_complete_metaschema.xml",
|
|
19
|
+
# module_name: "Oscal::V1_2_1"
|
|
20
|
+
# )
|
|
21
|
+
# files.each { |name, source| File.write(name, source) }
|
|
22
|
+
#
|
|
23
|
+
class RubySourceEmitter
|
|
24
|
+
BUILTIN_TYPES = %i[string integer boolean float date time datetime
|
|
25
|
+
symbol].freeze
|
|
26
|
+
RESERVED_CLASS_NAMES = %w[Base Hash Method Object Class Module].freeze
|
|
27
|
+
|
|
28
|
+
def initialize(classes, module_name, generator)
|
|
29
|
+
@classes = classes
|
|
30
|
+
@module_name = module_name
|
|
31
|
+
@generator = generator
|
|
32
|
+
@class_name_cache = {}
|
|
33
|
+
@anon_name_map = {} # anonymous class → assigned name
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def emit
|
|
37
|
+
sorted = sort_classes
|
|
38
|
+
collect_anonymous_types(sorted)
|
|
39
|
+
files = {}
|
|
40
|
+
|
|
41
|
+
source = emit_module_header
|
|
42
|
+
|
|
43
|
+
# Emit anonymous types first (they're dependencies of named classes)
|
|
44
|
+
@anon_name_map.each_value do |anon_name|
|
|
45
|
+
anon_class = @anon_name_map.key(anon_name)
|
|
46
|
+
source += "\n#{emit_anonymous_class(anon_name, anon_class)}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
sorted.each do |key, klass|
|
|
50
|
+
next unless klass.is_a?(Class) && klass < Lutaml::Model::Serializable
|
|
51
|
+
|
|
52
|
+
source += "\n#{emit_class(key, klass)}"
|
|
53
|
+
end
|
|
54
|
+
source += emit_module_footer
|
|
55
|
+
files["all_models.rb"] = source
|
|
56
|
+
|
|
57
|
+
files
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Emit as separate files per root model type.
|
|
61
|
+
def emit_split
|
|
62
|
+
sorted = sort_classes
|
|
63
|
+
collect_anonymous_types(sorted)
|
|
64
|
+
root_classes = find_root_classes
|
|
65
|
+
emitted = Set.new
|
|
66
|
+
files = {}
|
|
67
|
+
|
|
68
|
+
root_classes.each do |root_key, root_klass|
|
|
69
|
+
deps = find_dependencies(root_key, root_klass)
|
|
70
|
+
all_keys = ([root_key] + deps).uniq
|
|
71
|
+
|
|
72
|
+
source = emit_module_header
|
|
73
|
+
|
|
74
|
+
# Emit anonymous types needed by this root's dependency tree
|
|
75
|
+
emit_anon_deps_for(all_keys, source)
|
|
76
|
+
|
|
77
|
+
all_keys.each do |key|
|
|
78
|
+
klass = @classes[key]
|
|
79
|
+
next unless klass.is_a?(Class) && klass < Lutaml::Model::Serializable
|
|
80
|
+
next if emitted.include?(key)
|
|
81
|
+
|
|
82
|
+
source += "\n#{emit_class(key, klass)}"
|
|
83
|
+
emitted.add(key)
|
|
84
|
+
end
|
|
85
|
+
source += emit_module_footer
|
|
86
|
+
|
|
87
|
+
filename = clean_class_name(root_key).gsub(/([a-z])([A-Z])/,
|
|
88
|
+
'\1_\2').downcase + ".rb"
|
|
89
|
+
files[filename] = source
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Emit any remaining classes not covered by roots
|
|
93
|
+
remaining = sorted.except(*emitted)
|
|
94
|
+
unless remaining.empty?
|
|
95
|
+
source = emit_module_header
|
|
96
|
+
remaining.each do |key, klass|
|
|
97
|
+
next unless klass.is_a?(Class) && klass < Lutaml::Model::Serializable
|
|
98
|
+
|
|
99
|
+
source += "\n#{emit_class(key, klass)}"
|
|
100
|
+
end
|
|
101
|
+
source += emit_module_footer
|
|
102
|
+
files["common.rb"] = source
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
files
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def collect_anonymous_types(sorted)
|
|
111
|
+
used_names = Set.new(sorted.map { |key, _| clean_class_name(key) })
|
|
112
|
+
|
|
113
|
+
sorted.each do |key, klass|
|
|
114
|
+
next unless klass.is_a?(Class) && klass < Lutaml::Model::Serializable
|
|
115
|
+
|
|
116
|
+
klass.attributes.each do |attr_name, attr|
|
|
117
|
+
type = attr.type
|
|
118
|
+
next unless type.is_a?(Class) && type < Lutaml::Model::Serializable
|
|
119
|
+
next if @anon_name_map.key?(type)
|
|
120
|
+
next if @classes.any? { |_, v| v == type }
|
|
121
|
+
next if type.name && !type.name.empty? # Named framework type
|
|
122
|
+
|
|
123
|
+
# Anonymous inline type — assign a name
|
|
124
|
+
parent_name = clean_class_name(key)
|
|
125
|
+
base = "#{parent_name}#{camelize(attr_name.to_s)}"
|
|
126
|
+
name = base
|
|
127
|
+
suffix = 2
|
|
128
|
+
while used_names.include?(name)
|
|
129
|
+
name = "#{base}#{suffix}"
|
|
130
|
+
suffix += 1
|
|
131
|
+
end
|
|
132
|
+
used_names.add(name)
|
|
133
|
+
@anon_name_map[type] = name
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def emit_anon_deps_for(keys, source)
|
|
139
|
+
# Find anonymous types referenced by these classes
|
|
140
|
+
keys.each do |key|
|
|
141
|
+
klass = @classes[key]
|
|
142
|
+
next unless klass
|
|
143
|
+
|
|
144
|
+
klass.attributes.each_value do |attr|
|
|
145
|
+
type = attr.type
|
|
146
|
+
next unless type.is_a?(Class) && type < Lutaml::Model::Serializable
|
|
147
|
+
|
|
148
|
+
anon_name = @anon_name_map[type]
|
|
149
|
+
next unless anon_name
|
|
150
|
+
|
|
151
|
+
source += "\n#{emit_anonymous_class(anon_name, type)}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def sort_classes
|
|
157
|
+
flags = []
|
|
158
|
+
fields = []
|
|
159
|
+
assemblies = []
|
|
160
|
+
|
|
161
|
+
@classes.each do |key, klass|
|
|
162
|
+
case key
|
|
163
|
+
when /\AFlag_/ then flags << [key, klass]
|
|
164
|
+
when /\AField_/ then fields << [key, klass]
|
|
165
|
+
when /\AAssembly_/ then assemblies << [key, klass]
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
flags + fields + assemblies
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def find_root_classes
|
|
173
|
+
@classes.select do |key, klass|
|
|
174
|
+
next unless key.start_with?("Assembly_")
|
|
175
|
+
|
|
176
|
+
klass.instance_variable_defined?(:@json_root_name) &&
|
|
177
|
+
klass.instance_variable_get(:@json_root_name)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def find_dependencies(_root_key, root_klass)
|
|
182
|
+
deps = Set.new
|
|
183
|
+
queue = [root_klass]
|
|
184
|
+
|
|
185
|
+
while (klass = queue.shift)
|
|
186
|
+
klass.attributes.each_value do |attr|
|
|
187
|
+
type = attr.type
|
|
188
|
+
next unless type.is_a?(Class) && type < Lutaml::Model::Serializable
|
|
189
|
+
next if type == klass
|
|
190
|
+
|
|
191
|
+
type_key = @classes.find { |_k, v| v == type }&.first
|
|
192
|
+
next unless type_key
|
|
193
|
+
next if deps.include?(type_key)
|
|
194
|
+
|
|
195
|
+
deps.add(type_key)
|
|
196
|
+
queue << type
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
deps.to_a
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def clean_class_name(key)
|
|
204
|
+
parts = key.sub(/\A(Assembly|Field|Flag)_/, "").split("_")
|
|
205
|
+
name = parts.map(&:capitalize).join
|
|
206
|
+
name = "#{name}Field" if RESERVED_CLASS_NAMES.include?(name)
|
|
207
|
+
name
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def camelize(str)
|
|
211
|
+
str.split("_").map(&:capitalize).join
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def type_reference(attr)
|
|
215
|
+
type = attr.type
|
|
216
|
+
if type.is_a?(Symbol) || BUILTIN_TYPES.include?(type)
|
|
217
|
+
":#{type}"
|
|
218
|
+
elsif type.is_a?(Class) && type < Lutaml::Model::Serializable
|
|
219
|
+
key = @classes.find { |_, v| v == type }&.first
|
|
220
|
+
if key
|
|
221
|
+
# Generated type — use symbol for register-swappability
|
|
222
|
+
":#{snake_case(clean_class_name(key))}"
|
|
223
|
+
elsif @anon_name_map.key?(type)
|
|
224
|
+
# Anonymous inline type — use symbol with assigned name
|
|
225
|
+
":#{snake_case(@anon_name_map[type])}"
|
|
226
|
+
elsif type.name && !type.name.empty?
|
|
227
|
+
# Framework type from another gem — use bare class reference
|
|
228
|
+
type.name.to_s
|
|
229
|
+
else
|
|
230
|
+
":string"
|
|
231
|
+
end
|
|
232
|
+
else
|
|
233
|
+
":string"
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Returns fully-qualified class name for use in method bodies (no quotes).
|
|
238
|
+
def type_constant(attr)
|
|
239
|
+
type = attr.type
|
|
240
|
+
if type.is_a?(Class) && type < Lutaml::Model::Serializable
|
|
241
|
+
key = @classes.find { |_, v| v == type }&.first
|
|
242
|
+
if key
|
|
243
|
+
"#{@module_name}::#{clean_class_name(key)}"
|
|
244
|
+
elsif @anon_name_map.key?(type)
|
|
245
|
+
"#{@module_name}::#{@anon_name_map[type]}"
|
|
246
|
+
else
|
|
247
|
+
type_name = type.name
|
|
248
|
+
type_name && !type_name.empty? ? type_name : nil
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def snake_case(str)
|
|
254
|
+
str
|
|
255
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
256
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
257
|
+
.downcase
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def emit_module_header
|
|
261
|
+
register_id = derive_register_id
|
|
262
|
+
<<~RUBY
|
|
263
|
+
# frozen_string_literal: true
|
|
264
|
+
|
|
265
|
+
module #{@module_name}
|
|
266
|
+
class Base < Lutaml::Model::Serializable
|
|
267
|
+
def self.lutaml_default_register
|
|
268
|
+
:#{register_id}
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
RUBY
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def derive_register_id
|
|
275
|
+
if @module_name.include?("::")
|
|
276
|
+
parts = @module_name.split("::")
|
|
277
|
+
ns = parts[0].downcase
|
|
278
|
+
ver = parts[1..].join("_").downcase.gsub(/^v/, "")
|
|
279
|
+
"#{ns}_#{ver}"
|
|
280
|
+
else
|
|
281
|
+
@module_name.downcase
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def emit_module_footer
|
|
286
|
+
"\nend\n"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def emit_class(key, klass)
|
|
290
|
+
name = clean_class_name(key)
|
|
291
|
+
emit_named_class(name, klass)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def emit_anonymous_class(name, klass)
|
|
295
|
+
emit_named_class(name, klass)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def emit_named_class(name, klass)
|
|
299
|
+
lines = []
|
|
300
|
+
lines << " class #{name} < Base"
|
|
301
|
+
|
|
302
|
+
# Attributes
|
|
303
|
+
klass.attributes.each do |attr_name, attr|
|
|
304
|
+
type_ref = type_reference(attr)
|
|
305
|
+
opts = []
|
|
306
|
+
opts << "collection: true" if attr.collection
|
|
307
|
+
lines << if opts.any?
|
|
308
|
+
" attribute :#{attr_name}, #{type_ref}, #{opts.join(', ')}"
|
|
309
|
+
else
|
|
310
|
+
" attribute :#{attr_name}, #{type_ref}"
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# XML mapping
|
|
315
|
+
xml_source = emit_xml_mapping(klass)
|
|
316
|
+
lines.concat(xml_source) if xml_source
|
|
317
|
+
|
|
318
|
+
# Key-value mapping
|
|
319
|
+
kv_source = emit_key_value_mapping(klass)
|
|
320
|
+
lines.concat(kv_source) if kv_source
|
|
321
|
+
|
|
322
|
+
# Custom methods for with: callbacks
|
|
323
|
+
custom_methods = emit_custom_methods(klass)
|
|
324
|
+
lines.concat(custom_methods) if custom_methods.any?
|
|
325
|
+
|
|
326
|
+
# Root wrapping methods
|
|
327
|
+
root_methods = emit_root_wrapping(klass)
|
|
328
|
+
lines.concat(root_methods) if root_methods.any?
|
|
329
|
+
|
|
330
|
+
# Constraint validation methods
|
|
331
|
+
constraint_methods = emit_constraint_methods(klass)
|
|
332
|
+
lines.concat(constraint_methods) if constraint_methods.any?
|
|
333
|
+
|
|
334
|
+
# Occurrence validation
|
|
335
|
+
occ_methods = emit_occurrence_validation(klass)
|
|
336
|
+
lines.concat(occ_methods) if occ_methods
|
|
337
|
+
|
|
338
|
+
lines << " end"
|
|
339
|
+
lines.join("\n")
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def emit_xml_mapping(klass)
|
|
343
|
+
xml_map = begin
|
|
344
|
+
klass.mappings_for(:xml)
|
|
345
|
+
rescue StandardError
|
|
346
|
+
nil
|
|
347
|
+
end
|
|
348
|
+
return nil unless xml_map
|
|
349
|
+
|
|
350
|
+
lines = []
|
|
351
|
+
lines << ""
|
|
352
|
+
lines << " xml do"
|
|
353
|
+
|
|
354
|
+
element_name = xml_map.instance_variable_get(:@element_name)
|
|
355
|
+
lines << " element \"#{element_name}\"" if element_name
|
|
356
|
+
|
|
357
|
+
if xml_map.instance_variable_get(:@mixed_content)
|
|
358
|
+
lines << " mixed_content"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
if xml_map.instance_variable_get(:@ordered)
|
|
362
|
+
lines << " ordered"
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Content mapping
|
|
366
|
+
content = xml_map.instance_variable_get(:@content_mapping)
|
|
367
|
+
if content
|
|
368
|
+
opts = ["to: :#{content.to}"]
|
|
369
|
+
opts << "delegate: :#{content.delegate}" if content.delegate
|
|
370
|
+
lines << " map_content #{opts.join(', ')}"
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Attribute mappings
|
|
374
|
+
xml_map.instance_variable_get(:@attributes)&.each do |xml_name, rule|
|
|
375
|
+
opts = ["\"#{xml_name}\"", "to: :#{rule.to}"]
|
|
376
|
+
opts << "delegate: :#{rule.delegate}" if rule.delegate
|
|
377
|
+
lines << " map_attribute #{opts.join(', ')}"
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Element mappings
|
|
381
|
+
xml_map.instance_variable_get(:@elements)&.each do |xml_name, rule|
|
|
382
|
+
opts = ["\"#{xml_name}\"", "to: :#{rule.to}"]
|
|
383
|
+
opts << "delegate: :#{rule.delegate}" if rule.delegate
|
|
384
|
+
lines << " map_element #{opts.join(', ')}"
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
lines << " end"
|
|
388
|
+
lines
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def emit_key_value_mapping(klass)
|
|
392
|
+
kv_map = begin
|
|
393
|
+
klass.mappings_for(:json)
|
|
394
|
+
rescue StandardError
|
|
395
|
+
nil
|
|
396
|
+
end
|
|
397
|
+
return nil unless kv_map
|
|
398
|
+
|
|
399
|
+
mappings = kv_map.instance_variable_get(:@mappings)
|
|
400
|
+
return nil unless mappings && !mappings.empty?
|
|
401
|
+
|
|
402
|
+
lines = []
|
|
403
|
+
lines << ""
|
|
404
|
+
lines << " key_value do"
|
|
405
|
+
|
|
406
|
+
root_name = kv_map.instance_variable_get(:@root_name)
|
|
407
|
+
lines << " root \"#{root_name}\"" if root_name && !root_name.empty?
|
|
408
|
+
|
|
409
|
+
mappings.each do |json_name, rule|
|
|
410
|
+
custom = rule.custom_methods
|
|
411
|
+
if custom && (custom[:from] || custom[:to])
|
|
412
|
+
opts = []
|
|
413
|
+
opts << "to: :#{rule.to}"
|
|
414
|
+
opts_parts = ["with: { "]
|
|
415
|
+
with_parts = []
|
|
416
|
+
with_parts << "to: :#{custom[:to]}" if custom[:to]
|
|
417
|
+
with_parts << "from: :#{custom[:from]}" if custom[:from]
|
|
418
|
+
opts_parts << with_parts.join(", ")
|
|
419
|
+
opts_parts << " }"
|
|
420
|
+
opts << opts_parts.join
|
|
421
|
+
lines << " map \"#{json_name}\", #{opts.join(', ')}"
|
|
422
|
+
else
|
|
423
|
+
render_empty = rule.instance_variable_get(:@render_empty)
|
|
424
|
+
lines << if render_empty
|
|
425
|
+
" map \"#{json_name}\", to: :#{rule.to}, render_empty: true"
|
|
426
|
+
else
|
|
427
|
+
" map \"#{json_name}\", to: :#{rule.to}"
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
lines << " end"
|
|
433
|
+
lines
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def emit_custom_methods(klass)
|
|
437
|
+
methods = []
|
|
438
|
+
custom_method_names = (klass.instance_methods(false) - Lutaml::Model::Serializable.instance_methods)
|
|
439
|
+
.select { |m| m.to_s.start_with?("json_") }
|
|
440
|
+
|
|
441
|
+
return methods if custom_method_names.empty?
|
|
442
|
+
|
|
443
|
+
custom_method_names.each do |method_name|
|
|
444
|
+
ms = method_name.to_s
|
|
445
|
+
source = if ms.start_with?("json_assembly_soa_from_")
|
|
446
|
+
emit_assembly_soa_from_method(klass, method_name)
|
|
447
|
+
elsif ms.start_with?("json_assembly_soa_to_")
|
|
448
|
+
emit_assembly_soa_to_method(klass, method_name)
|
|
449
|
+
elsif ms.start_with?("json_soa_from_")
|
|
450
|
+
emit_field_soa_from_method(klass, method_name)
|
|
451
|
+
elsif ms.start_with?("json_soa_to_")
|
|
452
|
+
emit_field_soa_to_method(klass, method_name)
|
|
453
|
+
elsif ms.start_with?("json_from_bykey_asm_")
|
|
454
|
+
emit_bykey_asm_from_method(klass, method_name)
|
|
455
|
+
elsif ms.start_with?("json_to_bykey_asm_")
|
|
456
|
+
emit_bykey_asm_to_method(klass, method_name)
|
|
457
|
+
elsif ms.start_with?("json_from_bykey_")
|
|
458
|
+
emit_bykey_from_method(klass, method_name)
|
|
459
|
+
elsif ms.start_with?("json_to_bykey_")
|
|
460
|
+
emit_bykey_to_method(klass, method_name)
|
|
461
|
+
elsif ms.start_with?("json_from_vkf_")
|
|
462
|
+
emit_vkf_from_method(klass, method_name)
|
|
463
|
+
elsif ms.start_with?("json_to_vkf_")
|
|
464
|
+
emit_vkf_to_method(klass, method_name)
|
|
465
|
+
elsif ms.start_with?("json_from_")
|
|
466
|
+
emit_scalar_from_method(klass, method_name)
|
|
467
|
+
elsif ms.start_with?("json_to_")
|
|
468
|
+
emit_scalar_to_method(klass, method_name)
|
|
469
|
+
end
|
|
470
|
+
methods.concat(source) if source
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
methods
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def emit_scalar_from_method(klass, method_name)
|
|
477
|
+
attr_name = find_attr_for_method(klass, method_name)
|
|
478
|
+
return nil unless attr_name
|
|
479
|
+
|
|
480
|
+
attr_sym = attr_name.to_sym
|
|
481
|
+
field_attr = klass.attributes[attr_sym]
|
|
482
|
+
return nil unless field_attr
|
|
483
|
+
|
|
484
|
+
has_flags = field_attr.type.is_a?(Class) && field_attr.type < Lutaml::Model::Serializable
|
|
485
|
+
tc = type_constant(field_attr)
|
|
486
|
+
|
|
487
|
+
lines = []
|
|
488
|
+
lines << ""
|
|
489
|
+
lines << " def #{method_name}(instance, value)"
|
|
490
|
+
|
|
491
|
+
lines << " if value.is_a?(Array)"
|
|
492
|
+
if has_flags && tc
|
|
493
|
+
lines << " parsed = value.map { |v| #{tc}.of_json(v) }"
|
|
494
|
+
lines << " instance.instance_variable_set(:@#{attr_name}, parsed)"
|
|
495
|
+
lines << " elsif value.is_a?(Hash)"
|
|
496
|
+
lines << " if value.empty?"
|
|
497
|
+
lines << " inst = #{tc}.new(content: \"\")"
|
|
498
|
+
lines << " instance.instance_variable_set(:@#{attr_name}, inst)"
|
|
499
|
+
lines << " else"
|
|
500
|
+
lines << " instance.instance_variable_set(:@#{attr_name}, #{tc}.of_json(value))"
|
|
501
|
+
lines << " end"
|
|
502
|
+
lines << " elsif value"
|
|
503
|
+
lines << " instance.instance_variable_set(:@#{attr_name}, #{tc}.of_json(value))"
|
|
504
|
+
else
|
|
505
|
+
lines << " instance.instance_variable_set(:@#{attr_name}, value.map { |v| #{tc || 'String'}.new(content: v) })"
|
|
506
|
+
lines << " elsif value"
|
|
507
|
+
lines << " instance.instance_variable_set(:@#{attr_name}, #{tc || 'String'}.new(content: value))"
|
|
508
|
+
end
|
|
509
|
+
lines << " end"
|
|
510
|
+
|
|
511
|
+
lines << " end"
|
|
512
|
+
lines
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def emit_scalar_to_method(klass, method_name)
|
|
516
|
+
ms = method_name.to_s
|
|
517
|
+
ms.sub("json_to_", "")
|
|
518
|
+
|
|
519
|
+
json_name = find_json_name_for_to_method(klass, method_name)
|
|
520
|
+
attr_name = find_attr_for_method(klass, method_name)
|
|
521
|
+
return nil unless attr_name
|
|
522
|
+
|
|
523
|
+
field_attr = klass.attributes[attr_name.to_sym]
|
|
524
|
+
return nil unless field_attr
|
|
525
|
+
|
|
526
|
+
has_flags = field_attr.type.is_a?(Class) && field_attr.type < Lutaml::Model::Serializable
|
|
527
|
+
tc = type_constant(field_attr)
|
|
528
|
+
|
|
529
|
+
lines = []
|
|
530
|
+
lines << ""
|
|
531
|
+
lines << " def #{method_name}(instance, doc)"
|
|
532
|
+
|
|
533
|
+
lines << " current = instance.instance_variable_get(:@#{attr_name})"
|
|
534
|
+
lines << " if current.is_a?(Array)"
|
|
535
|
+
lines << " doc[\"#{json_name}\"] = current.map { |item| item.respond_to?(:content) ? item.content : item }"
|
|
536
|
+
lines << " elsif current"
|
|
537
|
+
if has_flags && tc
|
|
538
|
+
lines << " if current.is_a?(Lutaml::Model::Serializable)"
|
|
539
|
+
lines << " doc[\"#{json_name}\"] = #{tc}.as_json(current)"
|
|
540
|
+
lines << " else"
|
|
541
|
+
lines << " val = current.respond_to?(:content) ? current.content : current"
|
|
542
|
+
lines << " doc[\"#{json_name}\"] = val"
|
|
543
|
+
lines << " end"
|
|
544
|
+
else
|
|
545
|
+
lines << " doc[\"#{json_name}\"] = current.respond_to?(:content) ? current.content : current"
|
|
546
|
+
end
|
|
547
|
+
lines << " end"
|
|
548
|
+
|
|
549
|
+
lines << " end"
|
|
550
|
+
lines
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def emit_field_soa_from_method(klass, method_name)
|
|
554
|
+
attr_name = find_attr_for_method(klass, method_name)
|
|
555
|
+
return nil unless attr_name
|
|
556
|
+
|
|
557
|
+
field_attr = klass.attributes[attr_name.to_sym]
|
|
558
|
+
return nil unless field_attr
|
|
559
|
+
|
|
560
|
+
tc = type_constant(field_attr)
|
|
561
|
+
|
|
562
|
+
lines = []
|
|
563
|
+
lines << ""
|
|
564
|
+
lines << " def #{method_name}(instance, value)"
|
|
565
|
+
lines << " items = case value"
|
|
566
|
+
lines << " when Hash then [value]"
|
|
567
|
+
lines << " when Array then value"
|
|
568
|
+
lines << " when String then [value]"
|
|
569
|
+
lines << " else return"
|
|
570
|
+
lines << " end"
|
|
571
|
+
|
|
572
|
+
if tc
|
|
573
|
+
lines << " parsed = items.map do |item|"
|
|
574
|
+
lines << " case item"
|
|
575
|
+
lines << " when Hash then #{tc}.of_json(item)"
|
|
576
|
+
lines << " when String then #{tc}.of_json(item)"
|
|
577
|
+
lines << " else item"
|
|
578
|
+
lines << " end"
|
|
579
|
+
lines << " end"
|
|
580
|
+
else
|
|
581
|
+
# Anonymous/inline type — pass through as-is
|
|
582
|
+
lines << " parsed = items.map { |item| item.is_a?(Hash) ? item : item }"
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
lines << " instance.instance_variable_set(:@#{attr_name}, parsed)"
|
|
586
|
+
lines << " end"
|
|
587
|
+
lines
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def emit_field_soa_to_method(klass, method_name)
|
|
591
|
+
attr_name = find_attr_for_method(klass, method_name)
|
|
592
|
+
return nil unless attr_name
|
|
593
|
+
|
|
594
|
+
field_attr = klass.attributes[attr_name.to_sym]
|
|
595
|
+
return nil unless field_attr
|
|
596
|
+
|
|
597
|
+
json_name = find_json_name_for_to_method(klass, method_name)
|
|
598
|
+
tc = type_constant(field_attr)
|
|
599
|
+
|
|
600
|
+
lines = []
|
|
601
|
+
lines << ""
|
|
602
|
+
lines << " def #{method_name}(instance, doc)"
|
|
603
|
+
lines << " current = instance.instance_variable_get(:@#{attr_name})"
|
|
604
|
+
lines << " if current.is_a?(Array)"
|
|
605
|
+
lines << " result = current.map do |item|"
|
|
606
|
+
|
|
607
|
+
if tc
|
|
608
|
+
lines << " if item.is_a?(Lutaml::Model::Serializable)"
|
|
609
|
+
lines << " #{tc}.as_json(item)"
|
|
610
|
+
lines << " else"
|
|
611
|
+
lines << " item"
|
|
612
|
+
lines << " end"
|
|
613
|
+
else
|
|
614
|
+
lines << " item.respond_to?(:to_h) ? item.to_h : item"
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
lines << " end"
|
|
618
|
+
lines << " doc[\"#{json_name}\"] = result.length == 1 ? result.first : result"
|
|
619
|
+
lines << " end"
|
|
620
|
+
lines << " end"
|
|
621
|
+
lines
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def emit_assembly_soa_from_method(klass, method_name)
|
|
625
|
+
attr_name = find_attr_for_method(klass, method_name)
|
|
626
|
+
return nil unless attr_name
|
|
627
|
+
|
|
628
|
+
asm_attr = klass.attributes[attr_name.to_sym]
|
|
629
|
+
return nil unless asm_attr
|
|
630
|
+
|
|
631
|
+
tc = type_constant(asm_attr)
|
|
632
|
+
|
|
633
|
+
lines = []
|
|
634
|
+
lines << ""
|
|
635
|
+
lines << " def #{method_name}(instance, value)"
|
|
636
|
+
lines << " items = case value"
|
|
637
|
+
lines << " when Hash then [value]"
|
|
638
|
+
lines << " when Array then value"
|
|
639
|
+
lines << " else return"
|
|
640
|
+
lines << " end"
|
|
641
|
+
|
|
642
|
+
if tc
|
|
643
|
+
lines << " parsed = items.map { |item| #{tc}.of_json(item.is_a?(Hash) ? item : {}) }"
|
|
644
|
+
else
|
|
645
|
+
lines << " parsed = items"
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
lines << " instance.instance_variable_set(:@#{attr_name}, parsed)"
|
|
649
|
+
lines << " end"
|
|
650
|
+
lines
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def emit_assembly_soa_to_method(klass, method_name)
|
|
654
|
+
attr_name = find_attr_for_method(klass, method_name)
|
|
655
|
+
return nil unless attr_name
|
|
656
|
+
|
|
657
|
+
asm_attr = klass.attributes[attr_name.to_sym]
|
|
658
|
+
return nil unless asm_attr
|
|
659
|
+
|
|
660
|
+
json_name = find_json_name_for_to_method(klass, method_name)
|
|
661
|
+
tc = type_constant(asm_attr)
|
|
662
|
+
|
|
663
|
+
lines = []
|
|
664
|
+
lines << ""
|
|
665
|
+
lines << " def #{method_name}(instance, doc)"
|
|
666
|
+
lines << " current = instance.instance_variable_get(:@#{attr_name})"
|
|
667
|
+
lines << " if current.is_a?(Array)"
|
|
668
|
+
lines << " result = current.map do |item|"
|
|
669
|
+
|
|
670
|
+
if tc
|
|
671
|
+
lines << " if item.is_a?(Lutaml::Model::Serializable)"
|
|
672
|
+
lines << " #{tc}.as_json(item)"
|
|
673
|
+
lines << " else"
|
|
674
|
+
lines << " item"
|
|
675
|
+
lines << " end"
|
|
676
|
+
else
|
|
677
|
+
lines << " item.respond_to?(:to_h) ? item.to_h : item"
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
lines << " end"
|
|
681
|
+
lines << " doc[\"#{json_name}\"] = result.length == 1 ? result.first : result"
|
|
682
|
+
lines << " end"
|
|
683
|
+
lines << " end"
|
|
684
|
+
lines
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
def emit_bykey_from_method(klass, method_name)
|
|
688
|
+
# Simplified BY_KEY template
|
|
689
|
+
attr_name = find_attr_for_method(klass, method_name)
|
|
690
|
+
return nil unless attr_name
|
|
691
|
+
|
|
692
|
+
lines = []
|
|
693
|
+
lines << ""
|
|
694
|
+
lines << " def #{method_name}(instance, value)"
|
|
695
|
+
lines << " return unless value.is_a?(Hash)"
|
|
696
|
+
lines << " # BY_KEY deserialization handled by register"
|
|
697
|
+
lines << " instance.instance_variable_set(:@#{attr_name}, value.map { |k, v| [k, v] })"
|
|
698
|
+
lines << " end"
|
|
699
|
+
lines
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
def emit_bykey_to_method(klass, method_name)
|
|
703
|
+
attr_name = find_attr_for_method(klass, method_name)
|
|
704
|
+
return nil unless attr_name
|
|
705
|
+
|
|
706
|
+
json_name = find_json_name_for_to_method(klass, method_name)
|
|
707
|
+
|
|
708
|
+
lines = []
|
|
709
|
+
lines << ""
|
|
710
|
+
lines << " def #{method_name}(instance, doc)"
|
|
711
|
+
lines << " current = instance.instance_variable_get(:@#{attr_name})"
|
|
712
|
+
lines << " doc[\"#{json_name}\"] = current if current"
|
|
713
|
+
lines << " end"
|
|
714
|
+
lines
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
def emit_bykey_asm_from_method(klass, method_name)
|
|
718
|
+
emit_bykey_from_method(klass, method_name)
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
def emit_bykey_asm_to_method(klass, method_name)
|
|
722
|
+
emit_bykey_to_method(klass, method_name)
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
def emit_vkf_from_method(klass, method_name)
|
|
726
|
+
emit_bykey_from_method(klass, method_name)
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def emit_vkf_to_method(klass, method_name)
|
|
730
|
+
emit_bykey_to_method(klass, method_name)
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def emit_root_wrapping(klass)
|
|
734
|
+
root_name = klass.instance_variable_get(:@json_root_name)
|
|
735
|
+
return [] unless root_name
|
|
736
|
+
|
|
737
|
+
lines = []
|
|
738
|
+
lines << ""
|
|
739
|
+
lines << " def self.of_json(doc, options = {})"
|
|
740
|
+
lines << " if doc.is_a?(Hash) && doc.key?(\"#{root_name}\")"
|
|
741
|
+
lines << " super(doc[\"#{root_name}\"], options)"
|
|
742
|
+
lines << " else"
|
|
743
|
+
lines << " super(doc, options)"
|
|
744
|
+
lines << " end"
|
|
745
|
+
lines << " end"
|
|
746
|
+
lines << ""
|
|
747
|
+
lines << " def self.to_json(instance, options = {})"
|
|
748
|
+
lines << " json_str = super(instance, options)"
|
|
749
|
+
lines << " { \"#{root_name}\" => JSON.parse(json_str) }.to_json"
|
|
750
|
+
lines << " end"
|
|
751
|
+
lines << ""
|
|
752
|
+
lines << " def self.of_yaml(doc, options = {})"
|
|
753
|
+
lines << " if doc.is_a?(Hash) && doc.key?(\"#{root_name}\")"
|
|
754
|
+
lines << " super(doc[\"#{root_name}\"], options)"
|
|
755
|
+
lines << " else"
|
|
756
|
+
lines << " super(doc, options)"
|
|
757
|
+
lines << " end"
|
|
758
|
+
lines << " end"
|
|
759
|
+
lines << ""
|
|
760
|
+
lines << " def self.to_yaml(instance, options = {})"
|
|
761
|
+
lines << " yaml_str = super(instance, options)"
|
|
762
|
+
lines << " data = YAML.safe_load(yaml_str, permitted_classes: [Date, Time, Symbol])"
|
|
763
|
+
lines << " { \"#{root_name}\" => data }.to_yaml"
|
|
764
|
+
lines << " end"
|
|
765
|
+
lines << ""
|
|
766
|
+
lines << " def to_json(options = {})"
|
|
767
|
+
lines << " self.class.to_json(self, options)"
|
|
768
|
+
lines << " end"
|
|
769
|
+
lines << ""
|
|
770
|
+
lines << " def to_yaml(options = {})"
|
|
771
|
+
lines << " self.class.to_yaml(self, options)"
|
|
772
|
+
lines << " end"
|
|
773
|
+
|
|
774
|
+
lines
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
def emit_constraint_methods(klass)
|
|
778
|
+
constraints = klass.instance_variable_get(:@metaschema_constraints)
|
|
779
|
+
return [] unless constraints
|
|
780
|
+
|
|
781
|
+
lines = []
|
|
782
|
+
lines << ""
|
|
783
|
+
lines << " def self.metaschema_constraints"
|
|
784
|
+
lines << " @metaschema_constraints"
|
|
785
|
+
lines << " end"
|
|
786
|
+
lines << ""
|
|
787
|
+
lines << " def validate_constraints"
|
|
788
|
+
lines << " validator = Metaschema::ConstraintValidator.new"
|
|
789
|
+
lines << " validator.validate(self, self.class.metaschema_constraints)"
|
|
790
|
+
lines << " end"
|
|
791
|
+
|
|
792
|
+
lines
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
def emit_occurrence_validation(klass)
|
|
796
|
+
occ = klass.instance_variable_get(:@occurrence_constraints)
|
|
797
|
+
return nil unless occ && !occ.empty?
|
|
798
|
+
|
|
799
|
+
lines = []
|
|
800
|
+
lines << ""
|
|
801
|
+
lines << " def validate_occurrences"
|
|
802
|
+
lines << " Metaschema::ConstraintValidator.validate_occurrences(self, self.class.instance_variable_get(:@occurrence_constraints))"
|
|
803
|
+
lines << " end"
|
|
804
|
+
|
|
805
|
+
lines
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
# Helper: find the JSON name for a to: callback method
|
|
809
|
+
def find_json_name_for_to_method(klass, method_name)
|
|
810
|
+
kv_map = begin
|
|
811
|
+
klass.mappings_for(:json)
|
|
812
|
+
rescue StandardError
|
|
813
|
+
nil
|
|
814
|
+
end
|
|
815
|
+
return nil unless kv_map
|
|
816
|
+
|
|
817
|
+
mappings = kv_map.instance_variable_get(:@mappings)
|
|
818
|
+
mappings&.each do |json_name, rule|
|
|
819
|
+
if rule.custom_methods[:to]&.to_s == method_name.to_s
|
|
820
|
+
return json_name
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
nil
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
# Helper: find the JSON name for a from: callback method
|
|
827
|
+
def find_json_name_for_from_method(klass, method_name)
|
|
828
|
+
kv_map = begin
|
|
829
|
+
klass.mappings_for(:json)
|
|
830
|
+
rescue StandardError
|
|
831
|
+
nil
|
|
832
|
+
end
|
|
833
|
+
return nil unless kv_map
|
|
834
|
+
|
|
835
|
+
mappings = kv_map.instance_variable_get(:@mappings)
|
|
836
|
+
mappings&.each do |json_name, rule|
|
|
837
|
+
if rule.custom_methods[:from]&.to_s == method_name.to_s
|
|
838
|
+
return json_name
|
|
839
|
+
end
|
|
840
|
+
end
|
|
841
|
+
nil
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
# Helper: find the attribute name for a callback method
|
|
845
|
+
def find_attr_for_method(klass, method_name)
|
|
846
|
+
kv_map = begin
|
|
847
|
+
klass.mappings_for(:json)
|
|
848
|
+
rescue StandardError
|
|
849
|
+
nil
|
|
850
|
+
end
|
|
851
|
+
return nil unless kv_map
|
|
852
|
+
|
|
853
|
+
ms = method_name.to_s
|
|
854
|
+
mappings = kv_map.instance_variable_get(:@mappings)
|
|
855
|
+
mappings&.each_value do |rule|
|
|
856
|
+
custom = rule.custom_methods
|
|
857
|
+
if custom[:to]&.to_s == ms || custom[:from]&.to_s == ms
|
|
858
|
+
return rule.to.to_s
|
|
859
|
+
end
|
|
860
|
+
end
|
|
861
|
+
nil
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
def type_reference_short(attr)
|
|
865
|
+
type = attr.type
|
|
866
|
+
if type.is_a?(Symbol) || BUILTIN_TYPES.include?(type)
|
|
867
|
+
type
|
|
868
|
+
elsif type.is_a?(Class) && type < Lutaml::Model::Serializable
|
|
869
|
+
:class_ref
|
|
870
|
+
else
|
|
871
|
+
:string
|
|
872
|
+
end
|
|
873
|
+
end
|
|
874
|
+
end
|
|
875
|
+
end
|