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,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