metaschema 0.2.0 → 0.2.1

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,869 @@
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
+ lines << " map_content to: :#{content.to}"
369
+ end
370
+
371
+ # Attribute mappings
372
+ xml_map.instance_variable_get(:@attributes)&.each do |xml_name, rule|
373
+ lines << " map_attribute \"#{xml_name}\", to: :#{rule.to}"
374
+ end
375
+
376
+ # Element mappings
377
+ xml_map.instance_variable_get(:@elements)&.each do |xml_name, rule|
378
+ lines << " map_element \"#{xml_name}\", to: :#{rule.to}"
379
+ end
380
+
381
+ lines << " end"
382
+ lines
383
+ end
384
+
385
+ def emit_key_value_mapping(klass)
386
+ kv_map = begin
387
+ klass.mappings_for(:json)
388
+ rescue StandardError
389
+ nil
390
+ end
391
+ return nil unless kv_map
392
+
393
+ mappings = kv_map.instance_variable_get(:@mappings)
394
+ return nil unless mappings && !mappings.empty?
395
+
396
+ lines = []
397
+ lines << ""
398
+ lines << " key_value do"
399
+
400
+ root_name = kv_map.instance_variable_get(:@root_name)
401
+ lines << " root \"#{root_name}\"" if root_name && !root_name.empty?
402
+
403
+ mappings.each do |json_name, rule|
404
+ custom = rule.custom_methods
405
+ if custom && (custom[:from] || custom[:to])
406
+ opts = []
407
+ opts << "to: :#{rule.to}"
408
+ opts_parts = ["with: { "]
409
+ with_parts = []
410
+ with_parts << "to: :#{custom[:to]}" if custom[:to]
411
+ with_parts << "from: :#{custom[:from]}" if custom[:from]
412
+ opts_parts << with_parts.join(", ")
413
+ opts_parts << " }"
414
+ opts << opts_parts.join
415
+ lines << " map \"#{json_name}\", #{opts.join(', ')}"
416
+ else
417
+ render_empty = rule.instance_variable_get(:@render_empty)
418
+ lines << if render_empty
419
+ " map \"#{json_name}\", to: :#{rule.to}, render_empty: true"
420
+ else
421
+ " map \"#{json_name}\", to: :#{rule.to}"
422
+ end
423
+ end
424
+ end
425
+
426
+ lines << " end"
427
+ lines
428
+ end
429
+
430
+ def emit_custom_methods(klass)
431
+ methods = []
432
+ custom_method_names = (klass.instance_methods(false) - Lutaml::Model::Serializable.instance_methods)
433
+ .select { |m| m.to_s.start_with?("json_") }
434
+
435
+ return methods if custom_method_names.empty?
436
+
437
+ custom_method_names.each do |method_name|
438
+ ms = method_name.to_s
439
+ source = if ms.start_with?("json_assembly_soa_from_")
440
+ emit_assembly_soa_from_method(klass, method_name)
441
+ elsif ms.start_with?("json_assembly_soa_to_")
442
+ emit_assembly_soa_to_method(klass, method_name)
443
+ elsif ms.start_with?("json_soa_from_")
444
+ emit_field_soa_from_method(klass, method_name)
445
+ elsif ms.start_with?("json_soa_to_")
446
+ emit_field_soa_to_method(klass, method_name)
447
+ elsif ms.start_with?("json_from_bykey_asm_")
448
+ emit_bykey_asm_from_method(klass, method_name)
449
+ elsif ms.start_with?("json_to_bykey_asm_")
450
+ emit_bykey_asm_to_method(klass, method_name)
451
+ elsif ms.start_with?("json_from_bykey_")
452
+ emit_bykey_from_method(klass, method_name)
453
+ elsif ms.start_with?("json_to_bykey_")
454
+ emit_bykey_to_method(klass, method_name)
455
+ elsif ms.start_with?("json_from_vkf_")
456
+ emit_vkf_from_method(klass, method_name)
457
+ elsif ms.start_with?("json_to_vkf_")
458
+ emit_vkf_to_method(klass, method_name)
459
+ elsif ms.start_with?("json_from_")
460
+ emit_scalar_from_method(klass, method_name)
461
+ elsif ms.start_with?("json_to_")
462
+ emit_scalar_to_method(klass, method_name)
463
+ end
464
+ methods.concat(source) if source
465
+ end
466
+
467
+ methods
468
+ end
469
+
470
+ def emit_scalar_from_method(klass, method_name)
471
+ attr_name = find_attr_for_method(klass, method_name)
472
+ return nil unless attr_name
473
+
474
+ attr_sym = attr_name.to_sym
475
+ field_attr = klass.attributes[attr_sym]
476
+ return nil unless field_attr
477
+
478
+ has_flags = field_attr.type.is_a?(Class) && field_attr.type < Lutaml::Model::Serializable
479
+ tc = type_constant(field_attr)
480
+
481
+ lines = []
482
+ lines << ""
483
+ lines << " def #{method_name}(instance, value)"
484
+
485
+ lines << " if value.is_a?(Array)"
486
+ if has_flags && tc
487
+ lines << " parsed = value.map { |v| #{tc}.of_json(v) }"
488
+ lines << " instance.instance_variable_set(:@#{attr_name}, parsed)"
489
+ lines << " elsif value.is_a?(Hash)"
490
+ lines << " if value.empty?"
491
+ lines << " inst = #{tc}.new(content: \"\")"
492
+ lines << " instance.instance_variable_set(:@#{attr_name}, inst)"
493
+ lines << " else"
494
+ lines << " instance.instance_variable_set(:@#{attr_name}, #{tc}.of_json(value))"
495
+ lines << " end"
496
+ lines << " elsif value"
497
+ lines << " instance.instance_variable_set(:@#{attr_name}, #{tc}.of_json(value))"
498
+ else
499
+ lines << " instance.instance_variable_set(:@#{attr_name}, value.map { |v| #{tc || 'String'}.new(content: v) })"
500
+ lines << " elsif value"
501
+ lines << " instance.instance_variable_set(:@#{attr_name}, #{tc || 'String'}.new(content: value))"
502
+ end
503
+ lines << " end"
504
+
505
+ lines << " end"
506
+ lines
507
+ end
508
+
509
+ def emit_scalar_to_method(klass, method_name)
510
+ ms = method_name.to_s
511
+ ms.sub("json_to_", "")
512
+
513
+ json_name = find_json_name_for_to_method(klass, method_name)
514
+ attr_name = find_attr_for_method(klass, method_name)
515
+ return nil unless attr_name
516
+
517
+ field_attr = klass.attributes[attr_name.to_sym]
518
+ return nil unless field_attr
519
+
520
+ has_flags = field_attr.type.is_a?(Class) && field_attr.type < Lutaml::Model::Serializable
521
+ tc = type_constant(field_attr)
522
+
523
+ lines = []
524
+ lines << ""
525
+ lines << " def #{method_name}(instance, doc)"
526
+
527
+ lines << " current = instance.instance_variable_get(:@#{attr_name})"
528
+ lines << " if current.is_a?(Array)"
529
+ lines << " doc[\"#{json_name}\"] = current.map { |item| item.respond_to?(:content) ? item.content : item }"
530
+ lines << " elsif current"
531
+ if has_flags && tc
532
+ lines << " if current.is_a?(Lutaml::Model::Serializable)"
533
+ lines << " doc[\"#{json_name}\"] = #{tc}.as_json(current)"
534
+ lines << " else"
535
+ lines << " val = current.respond_to?(:content) ? current.content : current"
536
+ lines << " doc[\"#{json_name}\"] = val"
537
+ lines << " end"
538
+ else
539
+ lines << " doc[\"#{json_name}\"] = current.respond_to?(:content) ? current.content : current"
540
+ end
541
+ lines << " end"
542
+
543
+ lines << " end"
544
+ lines
545
+ end
546
+
547
+ def emit_field_soa_from_method(klass, method_name)
548
+ attr_name = find_attr_for_method(klass, method_name)
549
+ return nil unless attr_name
550
+
551
+ field_attr = klass.attributes[attr_name.to_sym]
552
+ return nil unless field_attr
553
+
554
+ tc = type_constant(field_attr)
555
+
556
+ lines = []
557
+ lines << ""
558
+ lines << " def #{method_name}(instance, value)"
559
+ lines << " items = case value"
560
+ lines << " when Hash then [value]"
561
+ lines << " when Array then value"
562
+ lines << " when String then [value]"
563
+ lines << " else return"
564
+ lines << " end"
565
+
566
+ if tc
567
+ lines << " parsed = items.map do |item|"
568
+ lines << " case item"
569
+ lines << " when Hash then #{tc}.of_json(item)"
570
+ lines << " when String then #{tc}.of_json(item)"
571
+ lines << " else item"
572
+ lines << " end"
573
+ lines << " end"
574
+ else
575
+ # Anonymous/inline type — pass through as-is
576
+ lines << " parsed = items.map { |item| item.is_a?(Hash) ? item : item }"
577
+ end
578
+
579
+ lines << " instance.instance_variable_set(:@#{attr_name}, parsed)"
580
+ lines << " end"
581
+ lines
582
+ end
583
+
584
+ def emit_field_soa_to_method(klass, method_name)
585
+ attr_name = find_attr_for_method(klass, method_name)
586
+ return nil unless attr_name
587
+
588
+ field_attr = klass.attributes[attr_name.to_sym]
589
+ return nil unless field_attr
590
+
591
+ json_name = find_json_name_for_to_method(klass, method_name)
592
+ tc = type_constant(field_attr)
593
+
594
+ lines = []
595
+ lines << ""
596
+ lines << " def #{method_name}(instance, doc)"
597
+ lines << " current = instance.instance_variable_get(:@#{attr_name})"
598
+ lines << " if current.is_a?(Array)"
599
+ lines << " result = current.map do |item|"
600
+
601
+ if tc
602
+ lines << " if item.is_a?(Lutaml::Model::Serializable)"
603
+ lines << " #{tc}.as_json(item)"
604
+ lines << " else"
605
+ lines << " item"
606
+ lines << " end"
607
+ else
608
+ lines << " item.respond_to?(:to_h) ? item.to_h : item"
609
+ end
610
+
611
+ lines << " end"
612
+ lines << " doc[\"#{json_name}\"] = result.length == 1 ? result.first : result"
613
+ lines << " end"
614
+ lines << " end"
615
+ lines
616
+ end
617
+
618
+ def emit_assembly_soa_from_method(klass, method_name)
619
+ attr_name = find_attr_for_method(klass, method_name)
620
+ return nil unless attr_name
621
+
622
+ asm_attr = klass.attributes[attr_name.to_sym]
623
+ return nil unless asm_attr
624
+
625
+ tc = type_constant(asm_attr)
626
+
627
+ lines = []
628
+ lines << ""
629
+ lines << " def #{method_name}(instance, value)"
630
+ lines << " items = case value"
631
+ lines << " when Hash then [value]"
632
+ lines << " when Array then value"
633
+ lines << " else return"
634
+ lines << " end"
635
+
636
+ if tc
637
+ lines << " parsed = items.map { |item| #{tc}.of_json(item.is_a?(Hash) ? item : {}) }"
638
+ else
639
+ lines << " parsed = items"
640
+ end
641
+
642
+ lines << " instance.instance_variable_set(:@#{attr_name}, parsed)"
643
+ lines << " end"
644
+ lines
645
+ end
646
+
647
+ def emit_assembly_soa_to_method(klass, method_name)
648
+ attr_name = find_attr_for_method(klass, method_name)
649
+ return nil unless attr_name
650
+
651
+ asm_attr = klass.attributes[attr_name.to_sym]
652
+ return nil unless asm_attr
653
+
654
+ json_name = find_json_name_for_to_method(klass, method_name)
655
+ tc = type_constant(asm_attr)
656
+
657
+ lines = []
658
+ lines << ""
659
+ lines << " def #{method_name}(instance, doc)"
660
+ lines << " current = instance.instance_variable_get(:@#{attr_name})"
661
+ lines << " if current.is_a?(Array)"
662
+ lines << " result = current.map do |item|"
663
+
664
+ if tc
665
+ lines << " if item.is_a?(Lutaml::Model::Serializable)"
666
+ lines << " #{tc}.as_json(item)"
667
+ lines << " else"
668
+ lines << " item"
669
+ lines << " end"
670
+ else
671
+ lines << " item.respond_to?(:to_h) ? item.to_h : item"
672
+ end
673
+
674
+ lines << " end"
675
+ lines << " doc[\"#{json_name}\"] = result.length == 1 ? result.first : result"
676
+ lines << " end"
677
+ lines << " end"
678
+ lines
679
+ end
680
+
681
+ def emit_bykey_from_method(klass, method_name)
682
+ # Simplified BY_KEY template
683
+ attr_name = find_attr_for_method(klass, method_name)
684
+ return nil unless attr_name
685
+
686
+ lines = []
687
+ lines << ""
688
+ lines << " def #{method_name}(instance, value)"
689
+ lines << " return unless value.is_a?(Hash)"
690
+ lines << " # BY_KEY deserialization handled by register"
691
+ lines << " instance.instance_variable_set(:@#{attr_name}, value.map { |k, v| [k, v] })"
692
+ lines << " end"
693
+ lines
694
+ end
695
+
696
+ def emit_bykey_to_method(klass, method_name)
697
+ attr_name = find_attr_for_method(klass, method_name)
698
+ return nil unless attr_name
699
+
700
+ json_name = find_json_name_for_to_method(klass, method_name)
701
+
702
+ lines = []
703
+ lines << ""
704
+ lines << " def #{method_name}(instance, doc)"
705
+ lines << " current = instance.instance_variable_get(:@#{attr_name})"
706
+ lines << " doc[\"#{json_name}\"] = current if current"
707
+ lines << " end"
708
+ lines
709
+ end
710
+
711
+ def emit_bykey_asm_from_method(klass, method_name)
712
+ emit_bykey_from_method(klass, method_name)
713
+ end
714
+
715
+ def emit_bykey_asm_to_method(klass, method_name)
716
+ emit_bykey_to_method(klass, method_name)
717
+ end
718
+
719
+ def emit_vkf_from_method(klass, method_name)
720
+ emit_bykey_from_method(klass, method_name)
721
+ end
722
+
723
+ def emit_vkf_to_method(klass, method_name)
724
+ emit_bykey_to_method(klass, method_name)
725
+ end
726
+
727
+ def emit_root_wrapping(klass)
728
+ root_name = klass.instance_variable_get(:@json_root_name)
729
+ return [] unless root_name
730
+
731
+ lines = []
732
+ lines << ""
733
+ lines << " def self.of_json(doc, options = {})"
734
+ lines << " if doc.is_a?(Hash) && doc.key?(\"#{root_name}\")"
735
+ lines << " super(doc[\"#{root_name}\"], options)"
736
+ lines << " else"
737
+ lines << " super(doc, options)"
738
+ lines << " end"
739
+ lines << " end"
740
+ lines << ""
741
+ lines << " def self.to_json(instance, options = {})"
742
+ lines << " json_str = super(instance, options)"
743
+ lines << " { \"#{root_name}\" => JSON.parse(json_str) }.to_json"
744
+ lines << " end"
745
+ lines << ""
746
+ lines << " def self.of_yaml(doc, options = {})"
747
+ lines << " if doc.is_a?(Hash) && doc.key?(\"#{root_name}\")"
748
+ lines << " super(doc[\"#{root_name}\"], options)"
749
+ lines << " else"
750
+ lines << " super(doc, options)"
751
+ lines << " end"
752
+ lines << " end"
753
+ lines << ""
754
+ lines << " def self.to_yaml(instance, options = {})"
755
+ lines << " yaml_str = super(instance, options)"
756
+ lines << " data = YAML.safe_load(yaml_str, permitted_classes: [Date, Time, Symbol])"
757
+ lines << " { \"#{root_name}\" => data }.to_yaml"
758
+ lines << " end"
759
+ lines << ""
760
+ lines << " def to_json(options = {})"
761
+ lines << " self.class.to_json(self, options)"
762
+ lines << " end"
763
+ lines << ""
764
+ lines << " def to_yaml(options = {})"
765
+ lines << " self.class.to_yaml(self, options)"
766
+ lines << " end"
767
+
768
+ lines
769
+ end
770
+
771
+ def emit_constraint_methods(klass)
772
+ constraints = klass.instance_variable_get(:@metaschema_constraints)
773
+ return [] unless constraints
774
+
775
+ lines = []
776
+ lines << ""
777
+ lines << " def self.metaschema_constraints"
778
+ lines << " @metaschema_constraints"
779
+ lines << " end"
780
+ lines << ""
781
+ lines << " def validate_constraints"
782
+ lines << " validator = Metaschema::ConstraintValidator.new"
783
+ lines << " validator.validate(self, self.class.metaschema_constraints)"
784
+ lines << " end"
785
+
786
+ lines
787
+ end
788
+
789
+ def emit_occurrence_validation(klass)
790
+ occ = klass.instance_variable_get(:@occurrence_constraints)
791
+ return nil unless occ && !occ.empty?
792
+
793
+ lines = []
794
+ lines << ""
795
+ lines << " def validate_occurrences"
796
+ lines << " Metaschema::ConstraintValidator.validate_occurrences(self, self.class.instance_variable_get(:@occurrence_constraints))"
797
+ lines << " end"
798
+
799
+ lines
800
+ end
801
+
802
+ # Helper: find the JSON name for a to: callback method
803
+ def find_json_name_for_to_method(klass, method_name)
804
+ kv_map = begin
805
+ klass.mappings_for(:json)
806
+ rescue StandardError
807
+ nil
808
+ end
809
+ return nil unless kv_map
810
+
811
+ mappings = kv_map.instance_variable_get(:@mappings)
812
+ mappings&.each do |json_name, rule|
813
+ if rule.custom_methods[:to]&.to_s == method_name.to_s
814
+ return json_name
815
+ end
816
+ end
817
+ nil
818
+ end
819
+
820
+ # Helper: find the JSON name for a from: callback method
821
+ def find_json_name_for_from_method(klass, method_name)
822
+ kv_map = begin
823
+ klass.mappings_for(:json)
824
+ rescue StandardError
825
+ nil
826
+ end
827
+ return nil unless kv_map
828
+
829
+ mappings = kv_map.instance_variable_get(:@mappings)
830
+ mappings&.each do |json_name, rule|
831
+ if rule.custom_methods[:from]&.to_s == method_name.to_s
832
+ return json_name
833
+ end
834
+ end
835
+ nil
836
+ end
837
+
838
+ # Helper: find the attribute name for a callback method
839
+ def find_attr_for_method(klass, method_name)
840
+ kv_map = begin
841
+ klass.mappings_for(:json)
842
+ rescue StandardError
843
+ nil
844
+ end
845
+ return nil unless kv_map
846
+
847
+ ms = method_name.to_s
848
+ mappings = kv_map.instance_variable_get(:@mappings)
849
+ mappings&.each_value do |rule|
850
+ custom = rule.custom_methods
851
+ if custom[:to]&.to_s == ms || custom[:from]&.to_s == ms
852
+ return rule.to.to_s
853
+ end
854
+ end
855
+ nil
856
+ end
857
+
858
+ def type_reference_short(attr)
859
+ type = attr.type
860
+ if type.is_a?(Symbol) || BUILTIN_TYPES.include?(type)
861
+ type
862
+ elsif type.is_a?(Class) && type < Lutaml::Model::Serializable
863
+ :class_ref
864
+ else
865
+ :string
866
+ end
867
+ end
868
+ end
869
+ end