lutaml-model 0.8.22 → 0.8.24

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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/performance.yml +1 -1
  3. data/Gemfile +6 -0
  4. data/README.adoc +16 -7
  5. data/docs/_guides/index.adoc +4 -0
  6. data/docs/_guides/native-engines.adoc +61 -0
  7. data/docs/_guides/xml-mapping.adoc +31 -0
  8. data/docs/_pages/configuration.adoc +11 -7
  9. data/docs/_pages/serialization_adapters.adoc +14 -5
  10. data/lib/compat/opal/lutaml_model_boot.rb +2 -0
  11. data/lib/lutaml/json/adapter/multi_json_adapter.rb +12 -1
  12. data/lib/lutaml/json/adapter/oj_adapter.rb +7 -0
  13. data/lib/lutaml/json/adapter/standard_adapter.rb +17 -5
  14. data/lib/lutaml/json/adapter/yeptris_adapter.rb +21 -0
  15. data/lib/lutaml/json/adapter.rb +1 -0
  16. data/lib/lutaml/json/format.rb +1 -1
  17. data/lib/lutaml/json/generator_options.rb +62 -0
  18. data/lib/lutaml/jsonl/adapter/standard_adapter.rb +1 -1
  19. data/lib/lutaml/jsonld/adapter.rb +12 -4
  20. data/lib/lutaml/key_value/adapter/json/multi_json_adapter.rb +16 -1
  21. data/lib/lutaml/key_value/adapter/json/oj_adapter.rb +7 -0
  22. data/lib/lutaml/key_value/adapter/json/standard_adapter.rb +17 -5
  23. data/lib/lutaml/key_value/adapter/json/yeptris_adapter.rb +15 -0
  24. data/lib/lutaml/key_value/adapter/jsonl/standard_adapter.rb +1 -1
  25. data/lib/lutaml/key_value/adapter/toml/teptris_adapter.rb +42 -0
  26. data/lib/lutaml/key_value/adapter/toml.rb +1 -0
  27. data/lib/lutaml/key_value/adapter/yaml/yeptris_adapter.rb +15 -0
  28. data/lib/lutaml/key_value/transform.rb +82 -0
  29. data/lib/lutaml/key_value/transformation/value_serializer.rb +25 -0
  30. data/lib/lutaml/key_value/transformation.rb +25 -1
  31. data/lib/lutaml/model/adapter_resolver.rb +23 -6
  32. data/lib/lutaml/model/attribute.rb +31 -6
  33. data/lib/lutaml/model/collection.rb +5 -0
  34. data/lib/lutaml/model/configuration.rb +5 -0
  35. data/lib/lutaml/model/error/unknown_adapter_type_error.rb +4 -4
  36. data/lib/lutaml/model/serialization_adapter.rb +1 -1
  37. data/lib/lutaml/model/serialize/format_conversion.rb +75 -7
  38. data/lib/lutaml/model/serialize.rb +18 -0
  39. data/lib/lutaml/model/toml.rb +10 -6
  40. data/lib/lutaml/model/transformation.rb +5 -0
  41. data/lib/lutaml/model/type/date_time.rb +2 -2
  42. data/lib/lutaml/model/type/value.rb +24 -2
  43. data/lib/lutaml/model/utils.rb +6 -5
  44. data/lib/lutaml/model/version.rb +1 -1
  45. data/lib/lutaml/toml/adapter/teptris_adapter.rb +14 -0
  46. data/lib/lutaml/toml/adapter.rb +1 -0
  47. data/lib/lutaml/toml/format.rb +1 -1
  48. data/lib/lutaml/xml/adapter/leptris_adapter.rb +15 -0
  49. data/lib/lutaml/xml/adapter_element.rb +111 -3
  50. data/lib/lutaml/xml/builder/base.rb +3 -1
  51. data/lib/lutaml/xml/builder/leptris.rb +13 -0
  52. data/lib/lutaml/xml/builder/nokogiri.rb +3 -0
  53. data/lib/lutaml/xml/builder.rb +1 -0
  54. data/lib/lutaml/xml/format.rb +4 -1
  55. data/lib/lutaml/xml/leptris/element.rb +34 -0
  56. data/lib/lutaml/xml/mapping.rb +24 -0
  57. data/lib/lutaml/xml/model_transform.rb +52 -1
  58. data/lib/lutaml/xml/plan_compiler.rb +139 -0
  59. data/lib/lutaml/xml/plan_hydrator.rb +80 -0
  60. data/lib/lutaml/xml/transformation/custom_method_wrapper.rb +8 -0
  61. data/lib/lutaml/xml/transformation.rb +29 -0
  62. data/lib/lutaml/xml/xml_element.rb +6 -2
  63. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +40 -0
  64. data/lib/lutaml/yaml/adapter.rb +5 -0
  65. data/lib/lutaml/yaml/format.rb +1 -1
  66. data/lib/tasks/performance.rake +5 -0
  67. data/lib/tasks/performance_comparator.rb +19 -3
  68. data/lib/tasks/performance_probe.rake +33 -0
  69. data/lib/tasks/performance_probe.rb +106 -0
  70. data/lib/tasks/performance_probe_xml.rb +54 -0
  71. data/lutaml-model.gemspec +1 -1
  72. data/spec/lutaml/json/adapter/standard_adapter_spec.rb +159 -0
  73. data/spec/lutaml/json/yeptris_adapter_spec.rb +63 -0
  74. data/spec/lutaml/jsonld/adapter_spec.rb +19 -0
  75. data/spec/lutaml/key_value/adapter/toml/teptris_adapter_spec.rb +71 -0
  76. data/spec/lutaml/model/collection_spec.rb +1 -1
  77. data/spec/lutaml/model/collection_state_nesting_spec.rb +44 -0
  78. data/spec/lutaml/model/generator_defaults_spec.rb +101 -0
  79. data/spec/lutaml/model/json_adapter_spec.rb +6 -0
  80. data/spec/lutaml/model/json_state_nesting_spec.rb +82 -0
  81. data/spec/lutaml/model/toml_adapter_spec.rb +8 -0
  82. data/spec/lutaml/model/toml_spec.rb +36 -1
  83. data/spec/lutaml/xml/plan_fast_path_spec.rb +96 -0
  84. data/spec/lutaml/xml/shared_element_rules_spec.rb +64 -0
  85. data/spec/lutaml/xml/xml_spec.rb +13 -0
  86. data/spec/lutaml/yaml/yeptris_adapter_spec.rb +63 -0
  87. data/spec/spec_helper.rb +7 -1
  88. metadata +29 -4
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "../../../json/generator_options"
4
5
 
5
6
  # Backward compatibility - delegates to Lutaml::Json::Adapter
6
7
  # @deprecated Use Lutaml::Json::Adapter::StandardAdapter instead
@@ -13,12 +14,16 @@ module Lutaml
13
14
  FORMAT_SYMBOL = :json
14
15
 
15
16
  def self.parse(json, _options = {})
16
- JSON.parse(json, create_additions: false)
17
+ JSON.parse(json)
17
18
  end
18
19
 
19
- def to_json(*args)
20
- options = args.first || {}
20
+ # This adapter hands its payload to the stdlib generator, so a
21
+ # JSON::State carrying the outer formatting is meaningful here.
22
+ def accepts_generator_state?
23
+ true
24
+ end
21
25
 
26
+ def to_json(*args)
22
27
  # Handle KeyValueElement input (new symmetric architecture)
23
28
  attributes_to_serialize = if @attributes.is_a?(Lutaml::KeyValue::DataModel::Element)
24
29
  # Unwrap __root__ wrapper to get actual content
@@ -28,10 +33,17 @@ module Lutaml
28
33
  @attributes
29
34
  end
30
35
 
36
+ unless Lutaml::Json::GeneratorOptions.lutaml_options?(args.first)
37
+ return JSON.generate(attributes_to_serialize, args.first)
38
+ end
39
+
40
+ options = args.first || {}
41
+ generator_options = Lutaml::Json::GeneratorOptions.filter(options)
42
+
31
43
  if options[:pretty]
32
- JSON.pretty_generate(attributes_to_serialize, *args)
44
+ JSON.pretty_generate(attributes_to_serialize, generator_options)
33
45
  else
34
- JSON.generate(attributes_to_serialize, *args)
46
+ JSON.generate(attributes_to_serialize, generator_options)
35
47
  end
36
48
  end
37
49
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../json/adapter/yeptris_adapter"
4
+
5
+ module Lutaml
6
+ module KeyValue
7
+ module Adapter
8
+ module Json
9
+ # Backward-compatibility entry for the adapter resolver's default
10
+ # load path; the implementation lives in Lutaml::Json::Adapter.
11
+ YeptrisAdapter = Lutaml::Json::Adapter::YeptrisAdapter
12
+ end
13
+ end
14
+ end
15
+ end
@@ -15,7 +15,7 @@ module Lutaml
15
15
  next if line.strip.empty?
16
16
 
17
17
  begin
18
- results << JSON.parse(line, create_additions: false)
18
+ results << JSON.parse(line)
19
19
  rescue JSON::ParserError => e
20
20
  warn "Skipping invalid line: #{e.message}"
21
21
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # TOML over the teptris engine (libteptris via FFI). teptris ships
4
+ # prebuilt platform gems for every platform we support — including
5
+ # mingw-ucrt — which retires the "no native TOML on Windows" gap that
6
+ # keeps tomlib off Windows. Value semantics mirror tomlib: offset and
7
+ # local datetimes become Time, dates become Date, local times stay
8
+ # String. Parse failures raise Teptris::ParseError (line/column).
9
+
10
+ module Lutaml
11
+ module KeyValue
12
+ module Adapter
13
+ module Toml
14
+ class TeptrisAdapter < Document
15
+ # teptris is required lazily (in parse/to_toml), NOT at file
16
+ # load: a platform gem whose native library is broken must
17
+ # degrade to detection-fallback + skipped specs, never crash
18
+ # unrelated loads. The gem's own require raises a clear
19
+ # LoadError ("no fallback by design") for that case.
20
+ def self.parse(toml, _options = {})
21
+ require "teptris"
22
+ Teptris::TOML.load(toml)
23
+ end
24
+
25
+ def to_toml(*)
26
+ require "teptris"
27
+ # Handle KeyValueElement input (new symmetric architecture)
28
+ attributes_to_serialize = if @attributes.is_a?(Lutaml::KeyValue::DataModel::Element)
29
+ # Unwrap __root__ wrapper to get actual content
30
+ @attributes.to_hash["__root__"]
31
+ else
32
+ # Legacy Hash input (backward compatibility)
33
+ @attributes
34
+ end
35
+
36
+ Teptris::TOML.dump(attributes_to_serialize)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -12,6 +12,7 @@ module Lutaml
12
12
  autoload :Transform, "#{__dir__}/toml/transform"
13
13
  Lutaml::Model::RuntimeCompatibility.autoload_native(
14
14
  self,
15
+ TeptrisAdapter: "#{__dir__}/toml/teptris_adapter",
15
16
  TomlibAdapter: "#{__dir__}/toml/tomlib_adapter",
16
17
  TomlRbAdapter: "#{__dir__}/toml/toml_rb_adapter",
17
18
  )
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../yaml/adapter/yeptris_adapter"
4
+
5
+ module Lutaml
6
+ module KeyValue
7
+ module Adapter
8
+ module Yaml
9
+ # Backward-compatibility entry for the adapter resolver's default
10
+ # load path; the implementation lives in Lutaml::Yaml::Adapter.
11
+ YeptrisAdapter = Lutaml::Yaml::Adapter::YeptrisAdapter
12
+ end
13
+ end
14
+ end
15
+ end
@@ -244,6 +244,13 @@ format)
244
244
  rule, attr
245
245
  )
246
246
 
247
+ if (plan = kv_rule_plan(format, rule, attr)) &&
248
+ (value = kv_fast_extract(doc, plan))
249
+ rule.deserialize(instance, kv_fast_cast(value, plan, instance),
250
+ attributes, self)
251
+ return
252
+ end
253
+
247
254
  value = rule_value_extractor_class.call(rule, doc, format, attr,
248
255
  lutaml_register, options, instance)
249
256
  value = apply_value_map(value, rule.value_map(:from, options), attr)
@@ -269,6 +276,81 @@ format)
269
276
  rule.deserialize(instance, value, attributes, self)
270
277
  end
271
278
 
279
+ # Compiled rule plans (TODO.perf/07): for plain scalar rules the
280
+ # per-value interpretation (extraction dispatch, type resolution,
281
+ # per-element probes, validation inside casts) collapses to a
282
+ # precomputed wire name, target class, and identity-guard cast.
283
+ # Guard + cached target — an inline cache per rule. Complex rules
284
+ # (custom methods, transforms, value maps, delegates, hash mappings,
285
+ # unions, polymorphism) return nil and keep the interpretive path.
286
+ KvRulePlan = ::Struct.new(:wire, :klass, :collection)
287
+
288
+ def kv_rule_plan(format, rule, attr)
289
+ @kv_rule_plans ||= {}
290
+ @kv_rule_plans[[format, rule]] ||= build_kv_rule_plan(format, rule,
291
+ attr)
292
+ end
293
+
294
+ # `rule.polymorphic` defaults to an empty Hash, which is truthy —
295
+ # presence is emptiness-based, mirroring MappingRule#polymorphic_mapping?.
296
+ def polymorphic_rule?(rule)
297
+ poly = rule.polymorphic
298
+ poly.respond_to?(:empty?) ? !poly.empty? : !!poly
299
+ end
300
+
301
+ def build_kv_rule_plan(_format, rule, attr)
302
+ return nil if rule.raw_mapping? ||
303
+ rule.has_custom_method_for_deserialization? ||
304
+ rule.hash_mappings || rule.multiple_mappings? ||
305
+ polymorphic_rule?(rule) || rule.delegate ||
306
+ (rule.transform.is_a?(Hash) && !rule.transform.empty?) ||
307
+ rule.transform.is_a?(Class) ||
308
+ attr.derived? ||
309
+ attr.union? || attr.polymorphic? ||
310
+ attr.custom_collection?
311
+
312
+ type = attr.type(lutaml_register)
313
+ return nil unless type.is_a?(Class) &&
314
+ type < ::Lutaml::Model::Type::Value &&
315
+ !attr.value_policy.whole_value?(type) &&
316
+ !Lutaml::Model::Attribute.custom_from_probe?(type)
317
+
318
+ KvRulePlan.new(rule.name.to_s, type, attr.collection?)
319
+ end
320
+
321
+ # Extraction fast path: plain hash fetch for a present, non-blank
322
+ # value. nil/blank values keep the interpretive path — their
323
+ # value-map/missing-value semantics must not be duplicated here.
324
+ def kv_fast_extract(doc, plan)
325
+ return nil unless doc.is_a?(::Hash)
326
+
327
+ v = doc[plan.wire]
328
+ return nil if v.nil?
329
+ return nil if v.is_a?(::String) && v.empty?
330
+ return nil if v.is_a?(::Array) && (v.empty? || !plan.collection)
331
+ # Structured values on scalar plans keep the interpretive path —
332
+ # its collection guidance error and cast semantics own them.
333
+ return nil if v.is_a?(::Hash)
334
+
335
+ v
336
+ end
337
+
338
+ # Cast fast path: identity when the value already has the target
339
+ # class (the overwhelming case for engine-typed scalars), the type's
340
+ # cast otherwise. Collections cast per element.
341
+ def kv_fast_cast(value, plan, _instance)
342
+ if plan.collection
343
+ unless value.is_a?(::Array)
344
+ return value.is_a?(plan.klass) ? value : plan.klass.cast(value)
345
+ end
346
+
347
+ klass = plan.klass
348
+ value.map { |e| e.is_a?(klass) ? e : klass.cast(e) }
349
+ else
350
+ value.is_a?(plan.klass) ? value : plan.klass.cast(value)
351
+ end
352
+ end
353
+
272
354
  def cast_value(value, attr, format, rule, instance)
273
355
  cast_options = rule.polymorphic ? { polymorphic: rule.polymorphic } : {}
274
356
  cast_options[:lutaml_parent] = instance if instance
@@ -57,6 +57,14 @@ model_class: nil)
57
57
  return nil if value.nil?
58
58
  return nil if Lutaml::Model::Utils.uninitialized?(value)
59
59
 
60
+ # Compiled serialize plan (TODO.perf/07): builtin scalars whose
61
+ # type registers no custom to_<format> serializer serialize as the
62
+ # value itself — Type::Value#to_<format>'s default — without the
63
+ # wrap-allocate-cast ceremony per field. Computed once per rule.
64
+ if (fast = serialize_plan(rule)) && value.instance_of?(fast)
65
+ return value
66
+ end
67
+
60
68
  # Check for Reference type first - even if value is a Serializable,
61
69
  # it should be serialized as a key, not as a nested model
62
70
  if reference_type?(rule)
@@ -151,6 +159,23 @@ model_class: nil)
151
159
  end
152
160
  end
153
161
 
162
+ # Per-rule serialize plan: the type class for builtin scalars with
163
+ # no custom to_<format> serializer (identity serialization), nil
164
+ # otherwise. Memoized per [rule].
165
+ def serialize_plan(rule)
166
+ @serialize_plans ||= {}
167
+ @serialize_plans[rule] ||= begin
168
+ type = rule.attribute_type
169
+ if type.is_a?(::Class) && type < ::Lutaml::Model::Type::Value
170
+ serializer = ::Lutaml::Model::Type::Value
171
+ .format_type_serializer_for(@format, type)
172
+ has_custom_to = serializer && serializer[:to]
173
+ has_custom_from = ::Lutaml::Model::Attribute.custom_from_probe?(type)
174
+ type unless has_custom_to || has_custom_from
175
+ end
176
+ end
177
+ end
178
+
154
179
  private
155
180
 
156
181
  # Validate that a nested model value matches the expected type.
@@ -16,6 +16,18 @@ module Lutaml
16
16
  # This is a critical step toward symmetric OOP architecture
17
17
  # across all serialization formats.
18
18
  class Transformation < Lutaml::Model::Transformation
19
+ # Builtin Value types whose cast of an already-native value is the
20
+ # identity — used by the serialize_value fast path.
21
+ NATIVE_VALUE_CLASS = {
22
+ ::Lutaml::Model::Type::String => ::String,
23
+ ::Lutaml::Model::Type::Integer => ::Integer,
24
+ ::Lutaml::Model::Type::Float => ::Float,
25
+ ::Lutaml::Model::Type::Date => ::Date,
26
+ ::Lutaml::Model::Type::Time => ::Time,
27
+ ::Lutaml::Model::Type::DateTime => ::DateTime,
28
+ ::Lutaml::Model::Type::Symbol => ::Symbol,
29
+ }.freeze
30
+
19
31
  include Lutaml::Model::RenderPolicy
20
32
 
21
33
  autoload :RuleCompiler, "#{__dir__}/transformation/rule_compiler"
@@ -1082,7 +1094,19 @@ child_mappings, options)
1082
1094
  # Wrap value in type and call to_#{format} instance method (like legacy Attribute#serialize_value)
1083
1095
  # This allows custom type subclasses to override to_json, to_yaml, etc.
1084
1096
  if rule.attribute_type.is_a?(Class) && rule.attribute_type < Lutaml::Model::Type::Value
1085
- wrapped_value = rule.attribute_type.new(value)
1097
+ type = rule.attribute_type
1098
+ # Identity fast path (TODO.perf/07): a builtin type with no custom
1099
+ # to_<format>/from_<format> behavior emits an already-native value
1100
+ # unchanged — the wrap only re-casts and re-emits it.
1101
+ native = NATIVE_VALUE_CLASS[type]
1102
+ if native && value.instance_of?(native) &&
1103
+ !::Lutaml::Model::Type::Value
1104
+ .format_type_serializer_for(format, type)&.fetch(:to, nil) &&
1105
+ !::Lutaml::Model::Attribute.custom_from_probe?(type)
1106
+ return value
1107
+ end
1108
+
1109
+ wrapped_value = type.new(value)
1086
1110
  wrapped_value.public_send(:"to_#{format}")
1087
1111
  else
1088
1112
  value
@@ -286,7 +286,7 @@ module Lutaml
286
286
  if format == :toml && type_name == :tomlib && RuntimeCompatibility.windows?
287
287
  raise ArgumentError,
288
288
  "The `:tomlib` adapter is not supported on Windows due to " \
289
- "segmentation fault issues. Please use `:toml_rb` instead."
289
+ "segmentation fault issues. Please use `:teptris` or `:toml_rb` instead."
290
290
  end
291
291
 
292
292
  available = adapter_config[:available]
@@ -363,11 +363,23 @@ module Lutaml
363
363
  detect_xml_adapter
364
364
  when :toml
365
365
  detect_toml_adapter
366
+ when :yaml, :json
367
+ detect_yeptris_kv_adapter(format)
366
368
  else
367
369
  metadata.dig(format, :default)
368
370
  end
369
371
  end
370
372
 
373
+ # Native key-value engine preference: yeptris (libyeptris C11)
374
+ # when its gem is in the bundle, else the format's standard
375
+ # adapter. Opt-in by bundle contents — nothing is required of
376
+ # consumers that do not add yeptris.
377
+ def detect_yeptris_kv_adapter(format)
378
+ return metadata.dig(format, :default) if Lutaml::Model.opal?
379
+
380
+ Utils.safe_load("yeptris", :Yeptris) ? :yeptris : metadata.dig(format, :default)
381
+ end
382
+
371
383
  # Detect available XML adapter.
372
384
  #
373
385
  # Delegates to moxml which is the authority on XML adapter
@@ -380,17 +392,22 @@ module Lutaml
380
392
 
381
393
  # Detect available TOML adapter.
382
394
  #
383
- # @return [Symbol, nil] :tomlib, :toml_rb, or nil
395
+ # @return [Symbol, nil] :teptris, :tomlib, :toml_rb, or nil
384
396
  def detect_toml_adapter
385
397
  return nil if Lutaml::Model.opal?
386
398
 
387
- if RuntimeCompatibility.windows?
388
- return :toml_rb if Utils.safe_load("toml-rb", :TomlRb)
399
+ # teptris 0.2.12+ parses ~3x and dumps ~8x faster than
400
+ # tomlib and ships prebuilts for every platform (mingw-ucrt
401
+ # restored in 0.2.4+) — native TOML on Windows retires the
402
+ # tomlib segfault workaround (previously pure-Ruby toml-rb
403
+ # only).
404
+ return :teptris if Utils.safe_load("teptris", :Teptris)
389
405
 
390
- return nil
406
+ if !RuntimeCompatibility.windows? &&
407
+ Utils.safe_load("tomlib", :Tomlib)
408
+ return :tomlib
391
409
  end
392
410
 
393
- return :tomlib if Utils.safe_load("tomlib", :Tomlib)
394
411
  return :toml_rb if Utils.safe_load("toml-rb", :TomlRb)
395
412
 
396
413
  nil
@@ -31,6 +31,29 @@ module Lutaml
31
31
  union_member_types
32
32
  ].freeze
33
33
 
34
+ # Per-type-class memo of the custom from_xml/from_json probe used by #cast.
35
+ # Isolated cache holder: enclosing-class freezes (some suites freeze
36
+ # Lutaml::Model::Attribute) would freeze a constant Hash with it.
37
+ TypeProbeCache = ::Class.new do
38
+ class << self
39
+ def cache
40
+ @cache ||= {}
41
+ end
42
+ end
43
+ end
44
+
45
+ class << self
46
+ # Public reader for compiled rule plans: does this type class carry a
47
+ # custom from_xml/from_json (answer static, probed once)?
48
+ def custom_from_probe?(type)
49
+ TypeProbeCache.cache[type] ||= begin
50
+ base = Lutaml::Model::Type::Value.singleton_class
51
+ type.method(:from_xml).owner != base ||
52
+ type.method(:from_json).owner != base
53
+ end
54
+ end
55
+ end
56
+
34
57
  MODEL_STRINGS = [
35
58
  Lutaml::Model::Type::String,
36
59
  "String",
@@ -252,7 +275,9 @@ module Lutaml
252
275
  end
253
276
 
254
277
  def union?
255
- unresolved_type == Lutaml::Model::Type::Union
278
+ return @union unless @union.nil?
279
+
280
+ @union = unresolved_type == Lutaml::Model::Type::Union
256
281
  end
257
282
 
258
283
  def union_member_types
@@ -687,11 +712,11 @@ instance_object = nil)
687
712
 
688
713
  # Fast path for Type::Value subclasses (String, Integer, Boolean, etc.)
689
714
  # These are never Serializable, so skip expensive can_serialize? and needs_conversion? checks
690
- # Skip if type has custom from_xml/from_json methods (defined on the class itself, not inherited)
691
- if resolved_type.is_a?(Class) && resolved_type < Lutaml::Model::Type::Value
692
- has_custom_from_xml = resolved_type.method(:from_xml).owner != Lutaml::Model::Type::Value
693
- has_custom_from_json = resolved_type.method(:from_json).owner != Lutaml::Model::Type::Value
694
- return resolved_type.cast(value) unless has_custom_from_xml || has_custom_from_json
715
+ # Skip if type has custom from_xml/from_json methods (defined on the class itself, not inherited).
716
+ # The probe allocated two Method objects per cast call; the answer is
717
+ # static per type class, so it is resolved once.
718
+ if resolved_type.is_a?(Class) && resolved_type < Lutaml::Model::Type::Value && !self.class.custom_from_probe?(resolved_type)
719
+ return resolved_type.cast(value)
695
720
  end
696
721
 
697
722
  klass = resolve_polymorphic_class(resolved_type, value, options)
@@ -308,6 +308,10 @@ module Lutaml
308
308
  end
309
309
 
310
310
  def to(format, instance, options = {})
311
+ # Wrap before the branch, not inside it: only XML reaches the
312
+ # unwrapped path today, but a raw JSON::State must never survive
313
+ # into either arm.
314
+ options = Lutaml::Model::Serialize.wrap_generator_state(options)
311
315
  mappings = mappings_for(format)
312
316
 
313
317
  if mappings.no_root? && collection_unwrapped_to?(format)
@@ -456,6 +460,7 @@ lutaml_register: Lutaml::Model::Config.default_register)
456
460
  end
457
461
 
458
462
  def to_format(format, options = {})
463
+ options = Lutaml::Model::Serialize.wrap_generator_state(options)
459
464
  super(format, options.merge(collection: true))
460
465
  end
461
466
 
@@ -15,10 +15,15 @@ module Lutaml
15
15
  #
16
16
  class Configuration
17
17
  attr_reader :default_register
18
+ # Experimental: whole-document native materialization for plan-
19
+ # compilable XML models (Leptris::XML::Descriptor walk). Opt-in
20
+ # while the full semantics audit completes.
21
+ attr_accessor :xml_plan_fast_path
18
22
 
19
23
  def initialize
20
24
  @default_register = :default
21
25
  @configured = false
26
+ @xml_plan_fast_path = false
22
27
  end
23
28
 
24
29
  def configure
@@ -3,10 +3,10 @@ module Lutaml
3
3
  class UnknownAdapterTypeError < Error
4
4
  # Available adapters by format
5
5
  AVAILABLE_ADAPTERS = {
6
- xml: %w[nokogiri ox oga rexml],
7
- json: %w[standard multi_json oj],
8
- yaml: %w[standard],
9
- toml: %w[tomlib toml_rb],
6
+ xml: %w[nokogiri ox oga rexml leptris],
7
+ json: %w[standard multi_json oj yeptris],
8
+ yaml: %w[standard yeptris],
9
+ toml: %w[teptris tomlib toml_rb],
10
10
  hash: %w[standard],
11
11
  jsonl: %w[standard],
12
12
  yamls: %w[standard],
@@ -17,7 +17,7 @@ module Lutaml
17
17
  end
18
18
 
19
19
  def self.parse(data, _options = {})
20
- document_class.parse(data, create_additions: false)
20
+ document_class.parse(data)
21
21
  end
22
22
  end
23
23
  end
@@ -45,6 +45,11 @@ module Lutaml
45
45
  # @param options [Hash] Additional options
46
46
  # @return [Object] The deserialized model instance
47
47
  def from(format, data, options = {})
48
+ if format == :xml && Lutaml::Model::Config.instance.xml_plan_fast_path
49
+ fast = xml_plan_fast_path(data, options)
50
+ return fast if fast
51
+ end
52
+
48
53
  Instrumentation.instrument(:from, model: name, format: format) do
49
54
  adapter = resolve_adapter(format, options.delete(:adapter))
50
55
 
@@ -76,6 +81,36 @@ module Lutaml
76
81
  # No-op by default; XML overrides via prepend
77
82
  end
78
83
 
84
+ # Whole-document native materialization (Phase 5): compile the
85
+ # mapping into a Leptris descriptor plan and hydrate from one
86
+ # plan walk. Only when the resolved XML adapter is leptris-
87
+ # backed, the model fully compiles, and no path-affecting
88
+ # options are present; anything else falls back to the
89
+ # interpretive pipeline.
90
+ def xml_plan_fast_path(data, options)
91
+ return nil unless defined?(::Leptris::XML::Descriptor)
92
+ return nil if options.key?(:adapter) || options.key?(:only) ||
93
+ options.key?(:except) || options.key?(:mappings) ||
94
+ options.key?(:register)
95
+
96
+ adapter_name = Lutaml::Model::Config.adapter_for(:xml)
97
+ adapter_name = adapter_name&.name
98
+ return nil unless adapter_name.to_s.end_with?("LeptrisAdapter")
99
+
100
+ register = Lutaml::Model::Config.default_register
101
+ plan = Lutaml::Xml::PlanCompiler.compile(self, register)
102
+ return nil unless plan
103
+
104
+ root = ::Leptris::XML.parse(data.to_s).root
105
+ return nil if root.nil?
106
+ return nil unless root.name == plan[:tree][:name]
107
+
108
+ Lutaml::Xml::PlanHydrator.call(self, plan,
109
+ plan[:descriptor].walk(root))
110
+ rescue ::Leptris::XML::ParseError => e
111
+ raise Lutaml::Model::InvalidFormatError.new(:xml, e.message)
112
+ end
113
+
79
114
  # Get list of error types that can be raised during format parsing.
80
115
  # Core errors are always included; format-specific errors come from
81
116
  # FormatRegistry registrations.
@@ -119,6 +154,8 @@ module Lutaml
119
154
  toml_errors = Array(toml_errors)
120
155
  tomllib_err = compatibility.safe_constantize("Tomlib::ParseError")
121
156
  toml_errors << tomllib_err if tomllib_err
157
+ teptris_err = compatibility.safe_constantize("Teptris::ParseError")
158
+ toml_errors << teptris_err if teptris_err
122
159
 
123
160
  @format_error_types_base + toml_errors
124
161
  end
@@ -192,24 +229,55 @@ module Lutaml
192
229
  # is always preserved when available, regardless of this option.
193
230
  # @return [String] The serialized output
194
231
  def to(format, instance, options = {})
232
+ # Ruby's JSON generator hands #to_json its own JSON::State rather
233
+ # than an options hash. It carries no LutaML options, but it does
234
+ # carry the surrounding indent context, so it is forwarded to the
235
+ # adapter unchanged instead of being read like a Hash -- json 3.0
236
+ # removed JSON::State#[].
237
+ options = Serialize.wrap_generator_state(options)
238
+ generator_state = options.delete(Serialize::GENERATOR_STATE_KEY)
239
+
195
240
  Instrumentation.instrument(:to, model: name, format: format) do
196
- adapter_override = options.is_a?(Hash) && options.delete(:adapter)
197
- if adapter_override && options.is_a?(Hash)
198
- options[:_adapter_override] =
199
- true
200
- end
241
+ adapter_override = options.delete(:adapter)
242
+ options[:_adapter_override] = true if adapter_override
201
243
  value = public_send(:"as_#{format}", instance, options)
202
244
  adapter = resolve_adapter(format, adapter_override)
203
245
 
204
246
  # Hook for format-specific options preparation (e.g., XML prefix/namespace/declaration)
205
247
  options = prepare_to_options(format, instance, options)
206
248
 
207
- adapter.new(value, register: options[:register]).public_send(
208
- :"to_#{format}", options
249
+ document = adapter.new(value, register: options[:register])
250
+
251
+ document.public_send(
252
+ :"to_#{format}",
253
+ forward_options(document, generator_state, options),
209
254
  )
210
255
  end
211
256
  end
212
257
 
258
+ # Main's behaviour differs per ADAPTER, not per option, so this follows
259
+ # the adapter rather than trying to translate option names:
260
+ # stdlib honours script_safe / ascii_only / pretty -> give it the state
261
+ # Oj ignores them all and uses its own escape_mode -> give it none
262
+ # others reach the stdlib generator underneath -> give them the options
263
+ def forward_options(document, generator_state, options)
264
+ return options if generator_state.nil?
265
+
266
+ if declares?(document, :accepts_generator_state?)
267
+ generator_state
268
+ elsif declares?(document, :ignores_generator_options?)
269
+ options
270
+ elsif generator_state.respond_to?(:to_h)
271
+ options.merge(generator_state.to_h)
272
+ else
273
+ options
274
+ end
275
+ end
276
+
277
+ def declares?(document, predicate)
278
+ document.respond_to?(predicate) && document.public_send(predicate)
279
+ end
280
+
213
281
  # Hook for format-specific options preparation before serialization.
214
282
  # XML overrides to handle prefix, namespace overrides, declaration plan.
215
283
  #
@@ -225,7 +225,25 @@ module Lutaml
225
225
  self.class.as_yaml(self)
226
226
  end
227
227
 
228
+ # Ruby's JSON generator hands #to_json its own JSON::State instead of an
229
+ # options hash. It carries no LutaML options, but it does carry the
230
+ # surrounding indent context, so it travels IN BAND under a private key
231
+ # rather than replacing the options hash. Wrapping rather than returning
232
+ # early keeps every later step -- register propagation, root-mapping
233
+ # validation, Collection's `collection: true` merge -- working on a real
234
+ # Hash. json 3.0 removed JSON::State#[] and rejects unknown keys in
235
+ # State#merge, so nothing may treat it as a Hash.
236
+ GENERATOR_STATE_KEY = :_generator_state
237
+
238
+ def self.wrap_generator_state(options)
239
+ return options if options.is_a?(::Hash)
240
+
241
+ { GENERATOR_STATE_KEY => options }
242
+ end
243
+
228
244
  def to_format(format, options = {})
245
+ options = Lutaml::Model::Serialize.wrap_generator_state(options)
246
+
229
247
  # Hook for format-specific validation (e.g., XML root mapping check)
230
248
  validate_root_mapping!(format, options)
231
249