lutaml-model 0.8.56 → 0.8.106

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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/.github/scripts/test_installed_gem.rb +58 -0
  3. data/.github/workflows/downstream-performance.yml +28 -3
  4. data/.github/workflows/js-pr-check.yml +4 -0
  5. data/.github/workflows/js-sync.yml +4 -0
  6. data/.github/workflows/opal.yml +9 -0
  7. data/.github/workflows/rake.yml +6 -41
  8. data/.github/workflows/release.yml +41 -0
  9. data/Gemfile +17 -3
  10. data/bench/bench_alloc_audit.rb +56 -0
  11. data/bench/bench_common.rb +11 -0
  12. data/bench/bench_merge_attempts.rb +49 -0
  13. data/docs/_guides/gem-integration.adoc +85 -0
  14. data/docs/_guides/grammar-backed-formats.adoc +69 -0
  15. data/docs/_guides/index.adoc +8 -1
  16. data/docs/_guides/native-engines.adoc +23 -5
  17. data/docs/_guides/schema-generation.adoc +25 -0
  18. data/docs/_guides/type-registers-contexts.adoc +126 -0
  19. data/lib/compat/opal/lutaml_model_boot.rb +7 -5
  20. data/lib/lutaml/json/adapter/yeptris_adapter.rb +24 -1
  21. data/lib/lutaml/key_value/data_model/element.rb +5 -1
  22. data/lib/lutaml/key_value/mapping_rule.rb +7 -0
  23. data/lib/lutaml/key_value/transform.rb +29 -24
  24. data/lib/lutaml/key_value/transformation/collection_serializer.rb +12 -29
  25. data/lib/lutaml/key_value/transformation/value_serializer.rb +29 -6
  26. data/lib/lutaml/key_value/transformation.rb +64 -116
  27. data/lib/lutaml/model/adapter_resolver.rb +7 -3
  28. data/lib/lutaml/model/attribute.rb +74 -15
  29. data/lib/lutaml/model/cached_type_resolver.rb +13 -6
  30. data/lib/lutaml/model/configuration.rb +13 -0
  31. data/lib/lutaml/model/mapping/mapping.rb +13 -0
  32. data/lib/lutaml/model/mapping/mapping_rule.rb +6 -1
  33. data/lib/lutaml/model/render_policy.rb +26 -8
  34. data/lib/lutaml/model/schema/base_schema.rb +15 -0
  35. data/lib/lutaml/model/schema/generator/definition.rb +52 -8
  36. data/lib/lutaml/model/schema/generator/definitions_collection.rb +35 -14
  37. data/lib/lutaml/model/schema.rb +4 -2
  38. data/lib/lutaml/model/serializable.rb +33 -0
  39. data/lib/lutaml/model/serialize/attribute_definition.rb +196 -2
  40. data/lib/lutaml/model/serialize/builder.rb +22 -0
  41. data/lib/lutaml/model/serialize/conversion_caching.rb +5 -0
  42. data/lib/lutaml/model/serialize/format_conversion.rb +49 -41
  43. data/lib/lutaml/model/serialize/initialization.rb +54 -7
  44. data/lib/lutaml/model/serialize/model_import.rb +60 -0
  45. data/lib/lutaml/model/serialize.rb +14 -6
  46. data/lib/lutaml/model/type/time.rb +6 -0
  47. data/lib/lutaml/model/type/value.rb +9 -7
  48. data/lib/lutaml/model/type_resolver.rb +7 -5
  49. data/lib/lutaml/model/utils.rb +18 -5
  50. data/lib/lutaml/model/version.rb +1 -1
  51. data/lib/lutaml/model.rb +7 -0
  52. data/lib/lutaml/xml/adapter/xml_parser.rb +5 -2
  53. data/lib/lutaml/xml/adapter_loader.rb +5 -2
  54. data/lib/lutaml/xml/format.rb +18 -16
  55. data/lib/lutaml/xml/leptris/native_emission.rb +187 -0
  56. data/lib/lutaml/xml/leptris/plan_compiler.rb +650 -0
  57. data/lib/lutaml/xml/leptris/plan_hydrator.rb +748 -0
  58. data/lib/lutaml/xml/leptris/plan_order.rb +55 -0
  59. data/lib/lutaml/xml/leptris/plan_serializer.rb +702 -0
  60. data/lib/lutaml/xml/leptris/plan_walk.rb +36 -0
  61. data/lib/lutaml/xml/leptris.rb +46 -0
  62. data/lib/lutaml/xml/mapping.rb +71 -18
  63. data/lib/lutaml/xml/model_transform.rb +231 -8
  64. data/lib/lutaml/xml/namespace.rb +18 -8
  65. data/lib/lutaml/xml/namespace_collector.rb +17 -1
  66. data/lib/lutaml/xml/namespace_needs.rb +77 -49
  67. data/lib/lutaml/xml/parse_session.rb +7 -19
  68. data/lib/lutaml/xml/schema/xsd/schema.rb +2 -1
  69. data/lib/lutaml/xml/serialization/format_conversion.rb +5 -1
  70. data/lib/lutaml/xml/transformation/ordered_applier.rb +21 -15
  71. data/lib/lutaml/xml/transformation.rb +15 -1
  72. data/lib/lutaml/xml/type/serializers.rb +8 -1
  73. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +34 -2
  74. data/lib/tasks/performance_probe_plan_walk.rb +1 -1
  75. data/lutaml-model.gemspec +7 -4
  76. data/spec/lutaml/json/yeptris_adapter_spec.rb +7 -2
  77. data/spec/lutaml/key_value/transformation/value_serializer_spec.rb +9 -5
  78. data/spec/lutaml/key_value/transformation_spec.rb +87 -21
  79. data/spec/lutaml/model/any_element_mapping_spec.rb +41 -0
  80. data/spec/lutaml/model/collection_validation_spec.rb +265 -0
  81. data/spec/lutaml/model/desc_spec.rb +50 -0
  82. data/spec/lutaml/model/late_attribute_declaration_spec.rb +35 -0
  83. data/spec/lutaml/model/model_import_reentry_spec.rb +46 -0
  84. data/spec/lutaml/model/opal_smoke_spec.rb +17 -0
  85. data/spec/lutaml/model/schema/generate_many_spec.rb +61 -0
  86. data/spec/lutaml/model/schema/generation_regressions_spec.rb +139 -0
  87. data/spec/lutaml/model/schema/json_schema_spec.rb +1 -13
  88. data/spec/lutaml/model/schema/yaml_schema_spec.rb +0 -11
  89. data/spec/lutaml/model/serialize/attribute_definition_block_form_spec.rb +244 -0
  90. data/spec/lutaml/model/store_spec.rb +6 -2
  91. data/spec/lutaml/model/unregistered_context_resolution_spec.rb +34 -0
  92. data/spec/lutaml/model/writer_assigned_attribute_spec.rb +62 -0
  93. data/spec/lutaml/model/yeptris_error_parity_spec.rb +95 -0
  94. data/spec/lutaml/xml/adapter/empty_document_diagnostics_spec.rb +50 -0
  95. data/spec/lutaml/xml/adapter/over_cap_parse_spec.rb +105 -0
  96. data/spec/lutaml/xml/adapter_loader_spec.rb +14 -0
  97. data/spec/lutaml/xml/adapter_metadata_default_spec.rb +16 -0
  98. data/spec/lutaml/xml/attribute_ambiguity_surface_spec.rb +5 -0
  99. data/spec/lutaml/xml/clear_parse_state_spec.rb +6 -1
  100. data/spec/lutaml/xml/compiled_rule_namespace_spec.rb +5 -0
  101. data/spec/lutaml/xml/cross_register_plan_spec.rb +75 -0
  102. data/spec/lutaml/xml/doubly_defined_namespace_spec.rb +16 -8
  103. data/spec/lutaml/xml/element_order_tracking_spec.rb +37 -0
  104. data/spec/lutaml/xml/leptris_string_encoding_spec.rb +65 -0
  105. data/spec/lutaml/xml/opal_xml_spec.rb +47 -0
  106. data/spec/lutaml/xml/parse_dom_retention_spec.rb +34 -0
  107. data/spec/lutaml/xml/plan_fast_path_spec.rb +122 -14
  108. data/spec/lutaml/xml/plan_namespaced_serialize_spec.rb +157 -0
  109. data/spec/lutaml/xml/plan_ns_less_document_spec.rb +86 -0
  110. data/spec/lutaml/xml/plan_partition_spec.rb +1 -1
  111. data/spec/lutaml/xml/plan_type_ns_attribute_spec.rb +131 -0
  112. data/spec/lutaml/xml/plan_uri_exact_dispatch_spec.rb +287 -0
  113. data/spec/lutaml/xml/plan_walk_spec.rb +6 -6
  114. data/spec/lutaml/xml/plan_wildcard_any_spec.rb +96 -0
  115. data/spec/lutaml/yaml/yeptris_adapter_spec.rb +29 -0
  116. data/spec/person_spec.rb +8 -0
  117. metadata +48 -17
  118. data/lib/lutaml/xml/plan_compiler.rb +0 -332
  119. data/lib/lutaml/xml/plan_hydrator.rb +0 -367
  120. data/lib/lutaml/xml/plan_order.rb +0 -53
  121. data/lib/lutaml/xml/plan_serializer.rb +0 -252
  122. data/lib/lutaml/xml/plan_walk.rb +0 -30
@@ -67,11 +67,22 @@ When the XML adapter resolves to leptris, XML (de)serialization
67
67
  compiles the model's mapping into a leptris descriptor plan and runs
68
68
  the whole document through one native pass — measured 6.7x parse and
69
69
  4x serialize with -76% / -80% allocations on the plan-path corpus.
70
- Models the plan cannot express exactly (same-name discriminator
71
- partitions, namespace-qualified models with deferred-rule shapes) and
72
- shapes with custom semantics (custom methods, polymorphism, unions,
73
- ordered children) hydrate through the interpretive pipeline or a
74
- per-subtree fragment of it, unchanged.
70
+ Namespace-qualified models ride the path too: serialization spells
71
+ prefixed element and attribute names through the same namespace
72
+ planning the interpretive writer uses, and every `(prefix, uri)`
73
+ pair is declared once on the output root. Models the plan cannot
74
+ express exactly (same-name discriminator partitions, deferred-rule
75
+ shapes) and shapes with custom semantics (custom methods,
76
+ polymorphism, unions, ordered children) hydrate through the
77
+ interpretive pipeline or a per-subtree fragment of it, unchanged.
78
+
79
+ Version floor note: leptris 1.9.320+ retains child-row namespace
80
+ URIs engine-side and matches plain attribute rows against
81
+ namespace-qualified wire spellings. On older engines, models whose
82
+ plans carry exact-URI child rows or plain attribute rows under a
83
+ namespaced chain parse interpretively for correctness — a per-model
84
+ fallback, not an error. Bundles resolving leptris 1.9.320+ get the
85
+ full fast path.
75
86
 
76
87
  The path is on by default and is a no-op without leptris (standard
77
88
  adapters keep the classic pipeline). Opt out for the whole process:
@@ -106,6 +117,13 @@ Lutaml::Model::Config.toml_adapter_type = :teptris
106
117
  raising `Psych::DisallowedClass`.
107
118
  * **JSON**: `yeptris` parsing targets exact `JSON.parse` semantics;
108
119
  generation stays on the `json` gem.
120
+ * **Error classes**: parse failures keep the standard adapters' contract
121
+ on every engine — the yeptris adapters re-raise the engine's syntax
122
+ errors as `Psych::SyntaxError` (YAML, with the engine's line, column,
123
+ and problem text) and `JSON::ParserError` (JSON), and the model layer
124
+ wraps them as `InvalidFormatError` just as it does for Psych and the
125
+ `json` gem. Code that rescues those classes does not change when a
126
+ native engine is added to the bundle.
109
127
  * **TOML**: the teptris adapter mirrors tomlib value semantics — offset
110
128
  and local datetimes become `Time`, dates become `Date`, local times
111
129
  stay `String`. Parse failures raise `Teptris::ParseError` with line
@@ -901,3 +901,28 @@ The generated schema will include validation constraints:
901
901
  </define>
902
902
  </grammar>
903
903
  ----
904
+
905
+ == Documentation metadata (desc)
906
+
907
+ Models and attributes carry documentation strings through `desc` —
908
+ set on the model class or on any `attribute` declaration, read back
909
+ by documentation and schema tooling. Serialization is never affected
910
+ by it.
911
+
912
+ [source,ruby]
913
+ ----
914
+ class Title < Lutaml::Model::Serializable
915
+ desc "This is a title."
916
+
917
+ attribute :type, :string, desc: "This attribute is used for..."
918
+ end
919
+
920
+ Title.desc # => "This is a title."
921
+ Title.attributes[:type].desc # => "This attribute is used for..."
922
+ ----
923
+
924
+ Subclasses inherit the nearest ancestor's class description; an
925
+ explicit `desc` overrides it. A consumer override-free `desc` value
926
+ does not change any generated schema today — wiring `desc` into the
927
+ generated `description` fields is deliberate product scope, not a
928
+ free rider on this option.
@@ -0,0 +1,126 @@
1
+ ---
2
+ title: Type Registers and Contexts
3
+ nav_order: 20
4
+ ---
5
+
6
+ = Type registers and contexts
7
+
8
+ Lutaml::Model resolves every attribute type — Symbol or class —
9
+ through a per-application *register*: a named table of types scoped
10
+ so that different class trees can share mapping definitions without
11
+ colliding. Registers are the mechanism behind multi-version model
12
+ suites and model substitution.
13
+
14
+ == Registers
15
+
16
+ A register maps type ids to classes. Classes declare the register
17
+ they belong to:
18
+
19
+ [source,ruby]
20
+ ----
21
+ module MyModels
22
+ class Mi < Lutaml::Model::Serializable
23
+ def self.lutaml_default_register
24
+ :my_models
25
+ end
26
+ end
27
+ end
28
+ ----
29
+
30
+ Symbol attribute types resolve in the declaring class's register,
31
+ so the same symbol can mean different classes per tree — the
32
+ multi-version pattern below builds on this.
33
+
34
+ == Contexts: derived registers with substitutions
35
+
36
+ A *context* is a derived type-resolution scope: it carries a
37
+ registry, fallback registers, and — the part that powers model
38
+ substitution — a list of type substitutions. Contexts are created
39
+ through the global registry:
40
+
41
+ [source,ruby]
42
+ ----
43
+ Lutaml::Model::GlobalContext.create_context(
44
+ id: :custom_models,
45
+ fallback_to: [:my_models],
46
+ substitutions: [
47
+ { from_type: MyModels::Mover, to_type: Overset },
48
+ ],
49
+ )
50
+
51
+ instance = MyModels::Math.from_xml(xml, context: :custom_models)
52
+ instance.mover_value.first # => #<Overset>
53
+ ----
54
+
55
+ Every attribute whose type resolves to `MyModels::Mover` — at any
56
+ depth of the tree — hydrates `Overset` in that context. Substitution
57
+ is type-based, so "swap this class everywhere it is referenced" and
58
+ "swap it only inside this one path" are the same operation scoped by
59
+ context: create two contexts with different substitution lists.
60
+
61
+ Contexts are cheap and disposable:
62
+
63
+ [source,ruby]
64
+ ----
65
+ Lutaml::Model::GlobalContext.unregister_context(:custom_models)
66
+ ----
67
+
68
+ === The custom-models convenience
69
+
70
+ A whole-table convenience reads exactly like a class-swap hash and
71
+ derives the context for you:
72
+
73
+ [source,ruby]
74
+ ----
75
+ models = { MyModels::Mover => Overset, MyModels::Mi => BoldMi }
76
+ Lutaml::Model::GlobalContext.create_context(
77
+ id: :custom, fallback_to: [:my_models],
78
+ substitutions: models.map { |from, to| { from_type: from, to_type: to } },
79
+ )
80
+ MyModels::Math.from_xml(xml, context: :custom)
81
+ ----
82
+
83
+ Substitute classes should inherit from the class they replace (or
84
+ provide its full attribute surface) so mappings and serialization
85
+ keep working.
86
+
87
+ === Substituting a symbol entry instead of a class
88
+
89
+ Registers accept direct class registrations under a type id — useful
90
+ when a tree declares `attribute :row, :row` and you want the symbol
91
+ to resolve to your own class within a derived register:
92
+
93
+ [source,ruby]
94
+ ----
95
+ derived = Lutaml::Model::Register.new(:derived, fallback: [:my_models])
96
+ derived.register_model(NewRow, id: :row)
97
+ MyModels::Doc.from_xml(xml, register: derived)
98
+ ----
99
+
100
+ (`register:` is the pre-context spelling; prefer `context:` with
101
+ substitutions in new code.)
102
+
103
+ == Multi-version class trees
104
+
105
+ The flagship pattern combines registers with per-version classes
106
+ and shared mapping modules:
107
+
108
+ * a `Base::Mrow` module holds the mapping DSL once;
109
+ * each version declares `class Mrow` mixing the module in and
110
+ pointing `lutaml_default_register` at its version's register;
111
+ * attributes use symbol types, so `attribute :mrow, :mrow` means
112
+ the right class per tree;
113
+ * a substitution context swaps individual classes for one call
114
+ without re-opening or redefining anything.
115
+
116
+ Nothing re-opens generated classes, and each version's tree stays
117
+ independent — no hidden delegation.
118
+
119
+ == Rules of thumb
120
+
121
+ * Prefer contexts over global configuration: substitutions scoped
122
+ to a context cannot leak into other resolutions.
123
+ * Substitute classes should inherit the original; substitution then
124
+ preserves every mapping declared on the base.
125
+ * Clean up exploratory contexts with `unregister_context` in test
126
+ teardowns — contexts are global state.
@@ -147,6 +147,7 @@ require "lutaml/xml/format"
147
147
  require "lutaml/xml/format_chooser"
148
148
  require "lutaml/xml/hoisting_algorithm"
149
149
  require "lutaml/xml/html_entities"
150
+ require "lutaml/xml/leptris"
150
151
  require "lutaml/xml/listener"
151
152
  require "lutaml/xml/mapping"
152
153
  require "lutaml/xml/mapping_rule"
@@ -170,11 +171,6 @@ require "lutaml/xml/oga"
170
171
  require "lutaml/xml/parse_session"
171
172
  require "lutaml/xml/parsed_namespace_declaration"
172
173
  require "lutaml/xml/parsed_namespace_set"
173
- require "lutaml/xml/plan_compiler"
174
- require "lutaml/xml/plan_hydrator"
175
- require "lutaml/xml/plan_order"
176
- require "lutaml/xml/plan_serializer"
177
- require "lutaml/xml/plan_walk"
178
174
  require "lutaml/xml/polymorphic_value_handler"
179
175
  require "lutaml/xml/qualified_inheritance_strategy"
180
176
  require "lutaml/xml/rexml"
@@ -386,6 +382,12 @@ require "lutaml/xml/error/namespace_mismatch_error"
386
382
  require "lutaml/xml/error/schema_validation_error"
387
383
  require "lutaml/xml/error/xml_configuration_error"
388
384
  require "lutaml/xml/error/xml_error"
385
+ require "lutaml/xml/leptris/native_emission"
386
+ require "lutaml/xml/leptris/plan_compiler"
387
+ require "lutaml/xml/leptris/plan_hydrator"
388
+ require "lutaml/xml/leptris/plan_order"
389
+ require "lutaml/xml/leptris/plan_serializer"
390
+ require "lutaml/xml/leptris/plan_walk"
389
391
  require "lutaml/xml/oga/element"
390
392
  require "lutaml/xml/ox/element"
391
393
  require "lutaml/xml/rexml/element"
@@ -12,9 +12,32 @@ module Lutaml
12
12
  # load-only), so everything else inherits from the standard adapter.
13
13
  class YeptrisAdapter < StandardAdapter
14
14
  def self.parse(json, _options = {})
15
- require "yeptris"
15
+ require_engine
16
16
  ::Yeptris::JSON.load(json)
17
+ rescue ::Yeptris::JSON::ParseError => e
18
+ raise parser_error(e)
19
+ end
20
+
21
+ # The engine require sits outside parse's rescue so a broken
22
+ # install still surfaces its LoadError (the detection fallback's
23
+ # contract) instead of a NameError from the rescue clause.
24
+ def self.require_engine
25
+ require "yeptris"
26
+ end
27
+ private_class_method :require_engine
28
+
29
+ # The parse-error contract is the standard adapter's: invalid
30
+ # input raises JSON::ParserError regardless of the engine, so
31
+ # consumers' rescue ladders (and this gem's own
32
+ # format_error_types conversion to InvalidFormatError) hold on
33
+ # every adapter. Without the json gem the engine error
34
+ # propagates unchanged.
35
+ def self.parser_error(error)
36
+ return error unless defined?(::JSON::ParserError)
37
+
38
+ ::JSON::ParserError.new(error.message)
17
39
  end
40
+ private_class_method :parser_error
18
41
  end
19
42
  end
20
43
  end
@@ -193,7 +193,11 @@ module Lutaml
193
193
  #
194
194
  # @return [Hash]
195
195
  def children_to_hash
196
- @children.map(&:to_hash).reduce({}, :merge)
196
+ # Built in place: map+reduce(:merge) allocated the mapped
197
+ # array plus one merged hash per reduce step (TODO.perf 16).
198
+ h = {}
199
+ @children.each { |child| h.update(child.to_hash) }
200
+ h
197
201
  end
198
202
 
199
203
  # Convert children to Array
@@ -83,6 +83,13 @@ module Lutaml
83
83
  def root_mapping?
84
84
  name == "root_mapping"
85
85
  end
86
+
87
+ # The writer symbol for this rule's target, interned once per
88
+ # rule: the interpolation allocated a String and interned it per
89
+ # row per document in the transform's hot loop.
90
+ def kv_setter
91
+ @kv_setter ||= :"#{to}="
92
+ end
86
93
  end
87
94
  end
88
95
  end
@@ -1,5 +1,6 @@
1
1
  module Lutaml
2
2
  module KeyValue
3
+ MISSING = Object.new.freeze
3
4
  class Transform < Lutaml::Model::Transform
4
5
  def data_to_model(data, format, options = {})
5
6
  # Use child's own default register if it has one
@@ -55,13 +56,9 @@ module Lutaml
55
56
  # transformation_for returns nil for cyclic dependencies or :building sentinel
56
57
  # Fall back to legacy approach in these cases
57
58
  if transformation.is_a?(Lutaml::KeyValue::Transformation)
58
- # Use new Transformation to get KeyValueElement
59
- kv_element = transformation.transform(instance, options)
60
- # Convert KeyValueElement to hash for backward compatibility with adapters
61
- # The to_hash method returns {"__root__" => {actual_hash}}
62
- kv_hash = kv_element.to_hash
63
- # For root element, return just the content hash
64
- return kv_hash["__root__"] || kv_hash
59
+ # transform emits the output hash directly (TODO.perf 19) —
60
+ # the __root__ Element tree and its to_hash flatten are gone.
61
+ return transformation.transform(instance, options)
65
62
  end
66
63
  end
67
64
 
@@ -176,18 +173,21 @@ module Lutaml
176
173
  .substitution_for(type).any?
177
174
 
178
175
  if type.is_a?(Class) && type.include?(Lutaml::Model::Serialize)
179
- child_rows = kv_group_plan(type, format,
180
- Lutaml::Model::Register
181
- .resolve_for_child(type, register))
176
+ child_register = Lutaml::Model::Register
177
+ .resolve_for_child(type, register)
178
+ child_rows = kv_group_plan(type, format, child_register)
182
179
  return false unless child_rows
183
180
 
184
181
  rows ||= []
185
- rows << [rule, attr, :model, type, child_rows]
182
+ # child_register rides the row: the plan was BUILT under it,
183
+ # and rebuilding the resolution per model row per document
184
+ # cost a Concurrent::Map hop each time (TODO.perf 14).
185
+ rows << [rule, attr, :model, type, child_rows, child_register]
186
186
  elsif type.is_a?(Class) && type < Lutaml::Model::Type::Value &&
187
187
  !attr.value_policy.whole_value?(type) &&
188
188
  !Lutaml::Model::Attribute.custom_from_probe?(type)
189
189
  rows ||= []
190
- rows << [rule, attr, :scalar, nil, nil]
190
+ rows << [rule, attr, :scalar, nil, nil, nil]
191
191
  else
192
192
  return false
193
193
  end
@@ -206,21 +206,24 @@ module Lutaml
206
206
  def kv_group_build(rows, doc, format, register, options)
207
207
  setters = []
208
208
  children = []
209
- absent = []
210
- rows.each do |rule, attr, kind, type, child_rows|
211
- unless Lutaml::Model::Utils.string_or_symbol_key?(doc, rule.name)
212
- absent << rule
209
+ # Lazy: the present-key hot path never pushes, and this runs
210
+ # once per hydrated model instance.
211
+ absent = nil
212
+ rows.each do |rule, attr, kind, type, child_rows, child_register|
213
+ # One hash pass per row: fetch with a sentinel distinguishes
214
+ # absent keys from present-nil values (both read as nil), so
215
+ # the separate string_or_symbol_key? probe goes away.
216
+ v = Lutaml::Model::Utils.fetch_str_or_sym(doc, rule.name, MISSING)
217
+ if v.equal?(MISSING)
218
+ (absent ||= []) << rule
213
219
  next
214
220
  end
215
- v = Lutaml::Model::Utils.fetch_str_or_sym(doc, rule.name)
216
221
 
217
222
  if kind == :scalar
218
- setters << [:"#{rule.to}=", v]
223
+ setters << [rule.kv_setter, v]
219
224
  next
220
225
  end
221
226
 
222
- child_register = Lutaml::Model::Register.resolve_for_child(type,
223
- register)
224
227
  if attr.collection?
225
228
  # A present-but-nil collection reaches the per-rule walk,
226
229
  # which owns the sentinel interplay for that edge
@@ -239,7 +242,7 @@ module Lutaml
239
242
 
240
243
  built << child
241
244
  end
242
- setters << [:"#{rule.to}=", built]
245
+ setters << [rule.kv_setter, built]
243
246
  children.concat(built)
244
247
  else
245
248
  return nil unless v.is_a?(::Hash)
@@ -249,13 +252,13 @@ module Lutaml
249
252
  options)
250
253
  return nil if child.nil?
251
254
 
252
- setters << [:"#{rule.to}=", child]
255
+ setters << [rule.kv_setter, child]
253
256
  children << child
254
257
  end
255
258
  end
256
259
  instance = model_class.new(lutaml_register: register)
257
260
  setters.each { |name, value| instance.public_send(name, value) }
258
- absent.each do |rule|
261
+ absent&.each do |rule|
259
262
  process_mapping_rule(doc, instance, format, rule, options, nil)
260
263
  end
261
264
  children.each do |child|
@@ -342,7 +345,9 @@ options)
342
345
  end
343
346
 
344
347
  def valid_mapping?(rule, options)
345
- return false if rule.respond_to?(:serialize?) && !rule.serialize?
348
+ # rule is always Lutaml::Model::MappingRule; serialize? is
349
+ # statically defined (the hydrate-only marker).
350
+ return false unless rule.serialize?
346
351
 
347
352
  only = options[:only]
348
353
  except = options[:except]
@@ -140,15 +140,11 @@ options = {})
140
140
  nil_mapping = to_map[:nil]
141
141
 
142
142
  if nil_mapping == :empty || converted_from_nil_to_empty
143
- element = Lutaml::KeyValue::DataModel::Element.new(
144
- rule.serialized_name, []
145
- )
146
- parent.add_child(element)
143
+ puts " HNC-EMPTY #{rule.serialized_name}" if ENV["T19"]
144
+ parent[rule.serialized_name] = []
147
145
  elsif nil_mapping == :nil || converted_from_empty_to_nil
148
- element = Lutaml::KeyValue::DataModel::Element.new(
149
- rule.serialized_name, nil
150
- )
151
- parent.add_child(element)
146
+ puts " HNC-NIL #{rule.serialized_name}" if ENV["T19"]
147
+ parent[rule.serialized_name] = nil
152
148
  end
153
149
  # else: Skip nil collection (default behavior)
154
150
  end
@@ -158,8 +154,6 @@ options = {})
158
154
  key_attribute = root_mappings.key(:key)
159
155
  value_attribute = root_mappings.key(:value)
160
156
 
161
- parent.value ||= {} if parent.value.nil?
162
-
163
157
  if key_attribute && value_attribute
164
158
  handle_root_mappings_key_value(parent, items, key_attribute,
165
159
  value_attribute, rule, options)
@@ -213,7 +207,7 @@ options = {})
213
207
  else
214
208
  attr_value
215
209
  end
216
- parent.value[key_value.to_s] = serialized_value if serialized_value
210
+ parent[key_value.to_s] = serialized_value if serialized_value
217
211
  end
218
212
  end
219
213
 
@@ -336,7 +330,7 @@ options)
336
330
  end
337
331
  end
338
332
 
339
- parent.value[key_value.to_s] = item_hash unless item_hash.empty?
333
+ parent[key_value.to_s] = item_hash unless item_hash.empty?
340
334
  end
341
335
  end
342
336
 
@@ -362,10 +356,7 @@ options)
362
356
  end
363
357
  end
364
358
 
365
- element = Lutaml::KeyValue::DataModel::Element.new(
366
- rule.serialized_name, keyed_hash
367
- )
368
- parent.add_child(element)
359
+ parent[rule.serialized_name] = keyed_hash
369
360
  end
370
361
 
371
362
  # Find value attribute from child_mappings.
@@ -490,18 +481,11 @@ options)
490
481
 
491
482
  # Create an array collection element (default).
492
483
  def create_array_collection_element(parent, rule, items, options)
493
- coll_element = Lutaml::KeyValue::DataModel::Element.new(rule.serialized_name)
494
-
495
- if items.empty?
496
- coll_element.value = []
497
- else
498
- items.each do |item|
499
- child_value = create_value_for_item(rule, item, options)
500
- coll_element.add_child(child_value) unless child_value.nil?
501
- end
484
+ built = items.each_with_object([]) do |item, memo|
485
+ child_value = create_value_for_item(rule, item, options)
486
+ memo << child_value unless child_value.nil?
502
487
  end
503
-
504
- parent.add_child(coll_element)
488
+ parent[rule.serialized_name] = built
505
489
  end
506
490
 
507
491
  # Create a value element for a collection item.
@@ -554,8 +538,7 @@ options)
554
538
  def serialize_nested_model(value, attr_type, options)
555
539
  child_transformation = create_transformation(attr_type)
556
540
  child_root = child_transformation.transform(value, options)
557
- child_hash = child_root.to_hash
558
- child_hash["__root__"]
541
+ child_root.empty? ? nil : child_root
559
542
  end
560
543
 
561
544
  # Serialize a primitive value.
@@ -151,8 +151,19 @@ model_class: nil)
151
151
  return value.public_send(:"to_#{format}")
152
152
  end
153
153
 
154
- # Wrap value in type and call to_#{format}
155
154
  if rule.attribute_type.is_a?(Class) && rule.attribute_type < Lutaml::Model::Type::Value
155
+ plan = serialize_plan(rule)
156
+ # Identity only under identity_fast_type's exact condition
157
+ # (no custom to AND no custom from): a custom from means the
158
+ # wrapper constructor normalizes the value, so the raw value
159
+ # must still pass through it.
160
+ if plan[:identity_type] && value.instance_of?(plan[:identity_type])
161
+ return value
162
+ end
163
+ if (to_fn = plan[:to_fn])
164
+ return to_fn.call(rule.attribute_type.new(value))
165
+ end
166
+
156
167
  wrapped_value = rule.attribute_type.new(value)
157
168
  wrapped_value.public_send(:"to_#{format}")
158
169
  else
@@ -187,9 +198,23 @@ model_class: nil)
187
198
  reference: reference_type?(rule),
188
199
  union: ::Lutaml::Model::Type::Union.rule?(rule),
189
200
  nested: nested_model?(rule),
201
+ # The custom to_<format> serializer, resolved once per rule:
202
+ # the default path is pure identity (to_<format> returns the
203
+ # wrapped .value), so values without a custom serializer skip
204
+ # the wrap-allocate-cast ceremony entirely, and custom ones
205
+ # skip the per-value registry lookup.
206
+ to_fn: custom_to_fn(rule),
190
207
  }
191
208
  end
192
209
 
210
+ def custom_to_fn(rule)
211
+ type = rule.attribute_type
212
+ return nil unless type.is_a?(::Class) && type < ::Lutaml::Model::Type::Value
213
+
214
+ ::Lutaml::Model::Type::Value
215
+ .format_type_serializer_for(@format, type)&.fetch(:to, nil)
216
+ end
217
+
193
218
  # Builtin scalars whose type registers no custom to_<format>
194
219
  # serializer serialize as the value itself — Type::Value#to_<format>'s
195
220
  # default — without the wrap-allocate-cast ceremony per field.
@@ -278,12 +303,10 @@ model_class: nil)
278
303
  # @param options [Hash] Serialization options
279
304
  # @return [Hash, nil] The serialized hash or nil if empty
280
305
  def transform_nested_model(value, child_transformation, options)
306
+ # transform emits the content hash directly (TODO.perf 19);
307
+ # nil for empty keeps render_nil: false working.
281
308
  child_root = child_transformation.transform(value, options)
282
- child_hash = child_root.to_hash
283
- result = child_hash["__root__"]
284
-
285
- # Return nil for empty hashes (allows render_nil: false to work)
286
- result.nil? || result.empty? ? nil : result
309
+ child_root.empty? ? nil : child_root
287
310
  end
288
311
 
289
312
  # Check if the rule defines a Reference type.