lutaml-model 0.8.100 → 0.8.119

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +6 -5
  3. data/lib/compat/opal/lutaml_model_boot.rb +1 -0
  4. data/lib/lutaml/json/adapter/yeptris_adapter.rb +28 -0
  5. data/lib/lutaml/key_value/transform.rb +4 -0
  6. data/lib/lutaml/key_value/transformation.rb +10 -0
  7. data/lib/lutaml/model/adapter_resolver.rb +13 -5
  8. data/lib/lutaml/model/attribute.rb +77 -3
  9. data/lib/lutaml/model/cached_type_resolver.rb +13 -6
  10. data/lib/lutaml/model/configuration.rb +13 -0
  11. data/lib/lutaml/model/serialize/builder.rb +22 -0
  12. data/lib/lutaml/model/serialize/format_conversion.rb +21 -3
  13. data/lib/lutaml/model/serialize/initialization.rb +14 -1
  14. data/lib/lutaml/model/type_resolver.rb +7 -5
  15. data/lib/lutaml/model/version.rb +1 -1
  16. data/lib/lutaml/xml/adapter/leptris_adapter.rb +1 -0
  17. data/lib/lutaml/xml/adapter/xml_parser.rb +5 -45
  18. data/lib/lutaml/xml/leptris/native_emission.rb +187 -0
  19. data/lib/lutaml/xml/leptris/plan_compiler.rb +161 -102
  20. data/lib/lutaml/xml/leptris/plan_hydrator.rb +276 -58
  21. data/lib/lutaml/xml/leptris/plan_serializer.rb +309 -47
  22. data/lib/lutaml/xml/leptris/plan_walk.rb +6 -3
  23. data/lib/lutaml/xml/leptris.rb +25 -84
  24. data/lib/lutaml/xml/model_transform.rb +5 -0
  25. data/lib/lutaml/xml/transformation/custom_method_wrapper.rb +15 -6
  26. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +29 -0
  27. data/lutaml-model.gemspec +3 -3
  28. data/spec/lutaml/json/yeptris_adapter_spec.rb +3 -10
  29. data/spec/lutaml/model/adapter_resolver_configured_spec.rb +32 -8
  30. data/spec/lutaml/model/cast_element_form_spec.rb +54 -0
  31. data/spec/lutaml/model/collection_strict_assignment_spec.rb +55 -0
  32. data/spec/lutaml/model/from_json_scalar_cast_spec.rb +70 -0
  33. data/spec/lutaml/model/singular_tolerance_spec.rb +159 -0
  34. data/spec/lutaml/model/unregistered_context_resolution_spec.rb +34 -0
  35. data/spec/lutaml/model/yeptris_native_detection_spec.rb +83 -0
  36. data/spec/lutaml/xml/adapter/empty_document_diagnostics_spec.rb +50 -0
  37. data/spec/lutaml/xml/plan_fast_path_spec.rb +34 -6
  38. data/spec/lutaml/xml/plan_type_ns_attribute_spec.rb +0 -11
  39. data/spec/lutaml/xml/plan_uri_exact_dispatch_spec.rb +287 -0
  40. data/spec/lutaml/xml/plan_wildcard_any_spec.rb +0 -9
  41. data/spec/lutaml/yaml/yeptris_adapter_spec.rb +3 -10
  42. metadata +19 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2bd54a574eb02eec487d980543835abb2bec61c12104d6eea6b898a516fb301f
4
- data.tar.gz: ffc5d3ed5b0c6df7140839064baec52624a034b2d1c5cfdc764dc4ee78c199ce
3
+ metadata.gz: 8df481bfb5303c05153a8bda8eaa4996157a6060d34d70dd8eb835e461aff9f5
4
+ data.tar.gz: a23f93de4f89b1a2b7925833786e9d0337ec5e94ec172723f5b2b70f78347d3c
5
5
  SHA512:
6
- metadata.gz: 191c5ee654cc6863a6f8d865448f0fbb29c31f7d602e3ab2a356148fdca7117b254aa497bb1ebdd80da1c80f495cf46c5f1029897cf87e7423a7409747e4e97c
7
- data.tar.gz: 4b55a05f437df7e0c85c7545d2042990572c70a4d64b42d9ae3ee44950ca6875cd640c2089cd9620951264180556e2e3776e7b32028b1b1d9962cbdaad739ecb
6
+ metadata.gz: adbc819876bf696f60009c53042dba835e5e557f32057f1aba4f58da56831c15f347d35dcf0160536a877f29f2d17ba59aadb8b1ac38b7fa9572b2d12d6c6dd2
7
+ data.tar.gz: 9af887394f9b2e241f299cb8eabcc3e0202ec51dba5129477a16cb85234105f51c7198fc44b9c20c916fa89a1356918c4752bbd73afa8f83a5a49ffd3032f08d
data/Gemfile CHANGED
@@ -44,11 +44,12 @@ gem "rexml"
44
44
  # Native engines stay development-only: every adapter detects and
45
45
  # degrades gracefully at runtime (safe requires + prefer-if-available).
46
46
  # yeptris ships no Windows prebuilt platform gem.
47
- # leptris floor: the plan hydrator's one-crossing snapshot
48
- # (leptris_plan_value_children_snapshot) landed in 1.9.273.0; older
49
- # gems fall back to accessor enumeration at runtime, but CI and dev
50
- # resolve the snapshot-capable line.
51
- gem "leptris", ">= 1.9.273.0"
47
+ # leptris floor: the single capability line for the plan fast path
48
+ # (see Lutaml::Xml::Leptris::MIN_LEPTRIS_VERSION) — child snapshots,
49
+ # exact-URI attribute rows, nested-attribute capture, wildcard rows,
50
+ # walk-side ns retention and leniency, :unqualified rows, and the
51
+ # address-based text attach all ride it.
52
+ gem "leptris", ">= 1.9.351.1"
52
53
  gem "teptris"
53
54
  gem "yeptris" unless Gem.win_platform?
54
55
  # TODO: revert rng branch to main when lutaml/rng#32 is merged
@@ -382,6 +382,7 @@ require "lutaml/xml/error/namespace_mismatch_error"
382
382
  require "lutaml/xml/error/schema_validation_error"
383
383
  require "lutaml/xml/error/xml_configuration_error"
384
384
  require "lutaml/xml/error/xml_error"
385
+ require "lutaml/xml/leptris/native_emission"
385
386
  require "lutaml/xml/leptris/plan_compiler"
386
387
  require "lutaml/xml/leptris/plan_hydrator"
387
388
  require "lutaml/xml/leptris/plan_order"
@@ -26,6 +26,34 @@ module Lutaml
26
26
  end
27
27
  private_class_method :require_engine
28
28
 
29
+ # True where the yeptris engine loads (libyeptris present for
30
+ # this platform) AND answers a probe parse. A require alone
31
+ # does not prove the engine: a half-initialized Yeptris
32
+ # constant (the poison-process state the gem documents)
33
+ # passes const_defined? and require while every parse fails,
34
+ # and the native parse error then surfaces on valid input.
35
+ # Idempotent; a missing or broken install reports false
36
+ # instead of raising, so optional-engine callers (detection)
37
+ # can gate on it and fall back to the standard lane.
38
+ def self.available?
39
+ require_engine
40
+ native_ready?
41
+ rescue LoadError
42
+ false
43
+ end
44
+
45
+ def self.native_ready?
46
+ return @native_ready unless @native_ready.nil?
47
+
48
+ @native_ready = begin
49
+ ::Yeptris::JSON.load('{"native_probe": 1}')
50
+ true
51
+ rescue ::Yeptris::Error, LoadError, NoMemoryError
52
+ false
53
+ end
54
+ end
55
+ private_class_method :native_ready?
56
+
29
57
  # The parse-error contract is the standard adapter's: invalid
30
58
  # input raises JSON::ParserError regardless of the engine, so
31
59
  # consumers' rescue ladders (and this gem's own
@@ -687,6 +687,10 @@ partition = nil)
687
687
  # attribute is not a collection, so this guard defers exactly that case
688
688
  # to `.validate` while leaving declared ranges checked at parse. A mapped
689
689
  # PORO has no `.validate` to defer into, so it keeps the eager check.
690
+ # singular_tolerance: :first on the KV lane (#971): collapse an
691
+ # over-ingested collection before the writer stores it whole.
692
+ value = value.first if attr.tolerates_singular_overflow?(value)
693
+
690
694
  if attr.collection? || !instance.is_a?(Lutaml::Model::Serialize)
691
695
  attr.valid_collection!(value, context)
692
696
  end
@@ -1031,6 +1031,16 @@ child_mappings, options)
1031
1031
  rule.attribute_type < Lutaml::Model::Serialize
1032
1032
  end
1033
1033
 
1034
+ if is_nested_model && !value.is_a?(Lutaml::Model::Serialize)
1035
+ # The KV mirror of the XML lane's #971 collapse: a tolerated
1036
+ # singular attribute holding an over-assigned collection
1037
+ # serializes its leading value instead of raising.
1038
+ attr = if model_class.respond_to?(:attributes)
1039
+ model_class.attributes[rule.attribute_name.to_sym]
1040
+ end
1041
+ value = value.first if attr&.tolerates_singular_overflow?(value)
1042
+ end
1043
+
1034
1044
  if is_nested_model
1035
1045
  # Validate that value matches the expected type
1036
1046
  # There are three valid cases:
@@ -387,13 +387,21 @@ module Lutaml
387
387
  end
388
388
 
389
389
  # Native key-value engine preference: yeptris (libyeptris C11)
390
- # when its gem is in the bundle, else the format's standard
391
- # adapter. Opt-in by bundle contents — nothing is required of
392
- # consumers that do not add yeptris.
390
+ # when its gem is in the bundle AND the native engine answers a
391
+ # probe, else the format's standard adapter. Opt-in by bundle
392
+ # contents — nothing is required of consumers that do not add
393
+ # yeptris. The probe matters because a half-initialized Yeptris
394
+ # constant (the poison-process state the gem documents) passes
395
+ # const_defined? and require while every parse fails; without
396
+ # it the lane selection flips with process load order and the
397
+ # failure surfaces as a native parse error on valid input
398
+ # instead of a clean standard-lane parse (#978).
393
399
  def detect_yeptris_kv_adapter(format)
394
- return metadata.dig(format, :default) if Lutaml::Model.opal?
400
+ default = metadata.dig(format, :default)
401
+ return default if Lutaml::Model.opal?
402
+ return default unless Utils.safe_load("yeptris", :Yeptris)
395
403
 
396
- Utils.safe_load("yeptris", :Yeptris) ? :yeptris : metadata.dig(format, :default)
404
+ load_adapter(format, :yeptris).available? ? :yeptris : default
397
405
  end
398
406
 
399
407
  # Detect available XML adapter.
@@ -5,6 +5,20 @@ module Lutaml
5
5
  class Attribute
6
6
  attr_reader :name, :options
7
7
 
8
+ # Format scalars a mapped-model attribute may be handed in the
9
+ # key-value lanes (#978). Adapter element nodes and other object
10
+ # forms belong to the mapping lane and must not divert.
11
+ SCALAR_CAST_TYPES = [
12
+ ::String,
13
+ ::Numeric,
14
+ ::Symbol,
15
+ ::Time,
16
+ ::Date,
17
+ ::DateTime,
18
+ ::TrueClass,
19
+ ::FalseClass,
20
+ ].freeze
21
+
8
22
  include CollectionHandler
9
23
  include DeepDupable
10
24
  include RestrictionValidation
@@ -47,6 +61,7 @@ module Lutaml
47
61
  min_length
48
62
  max_length
49
63
  desc
64
+ singular_tolerance
50
65
  ].freeze
51
66
 
52
67
  # Per-type-class memo of the custom from_xml/from_json probe used by #cast.
@@ -389,12 +404,34 @@ module Lutaml
389
404
 
390
405
  def cast_value(value, register)
391
406
  if list_like?(value)
392
- build_collection(cast_items(value, register), register: register)
407
+ items = cast_items(value, register)
393
408
  elsif coerce_to_collection?(value)
394
- build_collection(cast_items([value], register), register: register)
409
+ items = cast_items([value], register)
395
410
  else
396
- cast_element(value, register)
411
+ return cast_element(value, register)
397
412
  end
413
+ # cast_items already returns a fresh array, so the plain-Array
414
+ # collection needs no re-wrap — build_collection's
415
+ # collection_class.new would copy it again.
416
+ assert_flat_collection_items!(items)
417
+ return items if !custom_collection? && collection_class == ::Array
418
+
419
+ build_collection(items, register: register)
420
+ end
421
+
422
+ # Fail loudly on nested arrays. A collection's declared element
423
+ # type never admits an Array as an item; the legacy
424
+ # build_collection flatten silently absorbed mis-spliced builder
425
+ # output (expressir#506 surfaced it three frames away in a
426
+ # formatter). The error must land at the assignment boundary.
427
+ def assert_flat_collection_items!(items)
428
+ return unless items.any?(::Array)
429
+
430
+ raise TypeError,
431
+ "collection '#{name}': item " \
432
+ "#{items.index { |item| item.is_a?(::Array) }} is an Array " \
433
+ "where the declared element type is expected; nested arrays " \
434
+ "are not flattened — splice list results before assignment"
398
435
  end
399
436
 
400
437
  # Plain Arrays count as list input. On singular attributes
@@ -805,6 +842,17 @@ instance_object = nil)
805
842
  end
806
843
  end
807
844
 
845
+ # Singular-overflow tolerance (#971): ingest can hand a singular
846
+ # attribute a collection — relaton's upstream documents carry
847
+ # multiple <formattedref> elements per bibitem. With
848
+ # singular_tolerance: :first the leading value is kept instead of
849
+ # raising CollectionTrueMissingError; the default stays strict.
850
+ def tolerates_singular_overflow?(value)
851
+ !collection? && @options[:singular_tolerance] == :first &&
852
+ collection_instance?(value) &&
853
+ !value_policy.whole_value?(unresolved_type)
854
+ end
855
+
808
856
  def valid_collection!(value, caller)
809
857
  if collection_instance?(value) && !collection? &&
810
858
  !value_policy.whole_value?(unresolved_type)
@@ -943,6 +991,26 @@ instance_object = nil)
943
991
  end
944
992
 
945
993
  klass = resolve_polymorphic_class(resolved_type, value, options)
994
+ # A scalar handed to a mapped-model attribute crashed
995
+ # apply_mappings with an internal NoMethodError (key? on
996
+ # String, #978). A type declaring its own .cast owns scalar
997
+ # construction; base-cast identity types fall through to the
998
+ # legacy conversion chain, whose parse error names the token.
999
+ # Non-scalar input (adapter elements, hashes, arrays) rides
1000
+ # the mapping lane untouched.
1001
+ if serializable_type?(register) && !value.nil? &&
1002
+ SCALAR_CAST_TYPES.any? { |type| value.is_a?(type) } &&
1003
+ klass.respond_to?(:cast) &&
1004
+ klass.method(:cast).owner == klass.singleton_class
1005
+ casted = klass.cast(value)
1006
+ return casted if casted.is_a?(klass)
1007
+
1008
+ raise Lutaml::Model::IncorrectModelError,
1009
+ "attribute '#{name}': the #{format} input for type " \
1010
+ "#{klass} is a #{value.class}, and #{klass}.cast did " \
1011
+ "not build one (received #{casted.class})"
1012
+ end
1013
+
946
1014
  if can_serialize?(klass, value, format, options)
947
1015
  propagated = Serialize::DeserializationContext.propagate(options)
948
1016
  klass.apply_mappings(value, format,
@@ -1019,6 +1087,12 @@ instance_object = nil)
1019
1087
  "Use map_element \"name\", to: :#{name}, raw: :content instead."
1020
1088
  end
1021
1089
  @validations = @options[:validations]
1090
+ tolerance = @options[:singular_tolerance]
1091
+ unless tolerance.nil? || tolerance == :first
1092
+ raise ArgumentError,
1093
+ "Invalid singular_tolerance: #{tolerance.inspect} for " \
1094
+ "attribute '#{name}'. Supported: :first"
1095
+ end
1022
1096
  set_default_for_collection if collection?
1023
1097
  end
1024
1098
 
@@ -59,14 +59,14 @@ module Lutaml
59
59
  # updates so recursive type resolution can populate related keys.
60
60
  #
61
61
  # @param name [Symbol, String, Class] The type name or class to resolve
62
- # @param context [TypeContext] The resolution context
62
+ # @param context [TypeContext, nil] The resolution context
63
63
  # @return [Class] The resolved type class
64
64
  # @raise [UnknownTypeError] If type cannot be resolved
65
65
  def resolve(name, context, materialize: true)
66
66
  return @delegate.resolve(name, context, materialize: false) unless materialize
67
67
 
68
68
  cache_key = if name.is_a?(Class)
69
- [context.id, name]
69
+ [context_id_for(context), name]
70
70
  else
71
71
  build_cache_key(name, context)
72
72
  end
@@ -79,11 +79,11 @@ module Lutaml
79
79
  # Check if a type can be resolved, using cache if available.
80
80
  #
81
81
  # @param name [Symbol, String, Class] The type name or class to check
82
- # @param context [TypeContext] The resolution context
82
+ # @param context [TypeContext, nil] The resolution context
83
83
  # @return [Boolean] true if type can be resolved
84
84
  def resolvable?(name, context)
85
85
  cache_key = if name.is_a?(Class)
86
- [context.id, name]
86
+ [context_id_for(context), name]
87
87
  else
88
88
  build_cache_key(name, context)
89
89
  end
@@ -137,10 +137,17 @@ module Lutaml
137
137
  # Uses array for faster comparison than string concatenation.
138
138
  #
139
139
  # @param name [Symbol, String] The type name
140
- # @param context [TypeContext] The resolution context
140
+ # @param context [TypeContext, nil] The resolution context
141
141
  # @return [Array] The cache key (faster than string)
142
142
  def build_cache_key(name, context)
143
- [context.id, name.to_sym]
143
+ [context_id_for(context), name.to_sym]
144
+ end
145
+
146
+ # A nil context (e.g. a register ID that has no registered type
147
+ # context) is a legitimate "no namespace" resolution; the cache
148
+ # key just forgoes the namespace slot.
149
+ def context_id_for(context)
150
+ context&.id
144
151
  end
145
152
  end
146
153
  end
@@ -19,6 +19,12 @@ module Lutaml
19
19
  # compilable XML models (Leptris::XML::Descriptor walk). Opt-in
20
20
  # while the full semantics audit completes.
21
21
  attr_accessor :xml_plan_fast_path
22
+ # Ordered/mixed-content models on the plan path: opt-in. The
23
+ # per-node ordered hydration is FASTER than the interpretive
24
+ # lane on time but allocates above the downstream budget gates
25
+ # (niso pnas ratio 1.25); it ships dark until the allocation
26
+ # diet lands.
27
+ attr_accessor :xml_plan_ordered_path
22
28
  # #909: when false, XML deserialization skips the per-instance
23
29
  # element_order/attribute_order summaries — read-only consumers of
24
30
  # large documents never serialize back and should not pay for
@@ -31,6 +37,13 @@ module Lutaml
31
37
  @conversion_cache = nil
32
38
  @configured = false
33
39
  @xml_plan_fast_path = true
40
+ # On by default since the engine fix: leptris#1743 (the
41
+ # exponential flatten on diamond-shared plan graphs) shipped in
42
+ # 1.9.366.2's identity-memo flatten_plan — the ISO_13849 corpus
43
+ # that hung for 28+ CPU-min now parses in ~8s warm. The floor
44
+ # bump (MIN_LEPTRIS_VERSION = 1.9.366.2) keeps consumers on
45
+ # memo-less engines off this lane.
46
+ @xml_plan_ordered_path = true
34
47
  @element_order_tracking = true
35
48
  end
36
49
 
@@ -84,6 +84,26 @@ module Lutaml
84
84
  @__order_tracking__ ? true : false
85
85
  end
86
86
 
87
+ # Whether any writer setter ran on this instance. Parsed
88
+ # instances start pristine — every set element value is covered
89
+ # by the parse-built element_order, so ordered serialization
90
+ # can skip order reconciliation; the first writer-set marks the
91
+ # instance and reconciliation engages (writer-added values sit
92
+ # outside the parse order).
93
+ # @return [Boolean]
94
+ def mutated_after_parse?
95
+ @__mutated_after_parse__ ? true : false
96
+ end
97
+
98
+ # Construction-time reset: hydration routes every attribute
99
+ # through the generated writers (which mark mutations); a
100
+ # freshly built instance is definitionally not
101
+ # mutated-after-parse.
102
+ # @return [void]
103
+ def clear_mutated_after_parse!
104
+ @__mutated_after_parse__ = false
105
+ end
106
+
87
107
  # Record a singular attribute mutation in element_order.
88
108
  #
89
109
  # No-op unless order tracking is enabled (i.e. the instance was
@@ -102,6 +122,7 @@ module Lutaml
102
122
  # content for content-mapped attributes, ignored otherwise
103
123
  # @return [Object] the passed value
104
124
  def record_mutation(attribute_name, value = nil)
125
+ @__mutated_after_parse__ = true
105
126
  return value unless @__order_tracking__
106
127
 
107
128
  track_order(attribute_name, value, nil)
@@ -122,6 +143,7 @@ module Lutaml
122
143
  # @param value [Object, nil] The value assigned to the collection
123
144
  # @return [Object] the passed value
124
145
  def record_mutation_collection(attribute_name, value)
146
+ @__mutated_after_parse__ = true
125
147
  return value unless @__order_tracking__
126
148
  return value if value.nil? || Lutaml::Model::Utils.uninitialized?(value)
127
149
 
@@ -138,19 +138,37 @@ module Lutaml
138
138
  register = Lutaml::Model::Config.default_register
139
139
  plan = Lutaml::Xml::Leptris::PlanCompiler.compile(self, register)
140
140
  return nil unless plan
141
- return nil unless Lutaml::Xml::Leptris::PlanCompiler.engine_walk_safe?(plan)
142
141
 
143
- root = ::Leptris::XML.parse(data.to_s).root
142
+ # strict: the from_xml contract raises on malformed input —
143
+ # the FFI lane parses leniently by default and would answer a
144
+ # best-effort tree where every other adapter raises. The
145
+ # kwarg spellings vary by engine vintage (strict: landed
146
+ # after 1.9.353, recover: on 1.9.344+), so probe the resolved
147
+ # parse method and pass the strictest spelling it accepts.
148
+ root = ::Leptris::XML.parse(data.to_s, **leptris_strict_kwargs).root
144
149
  return nil if root.nil?
145
150
  return nil unless root.name == plan[:tree][:name]
146
151
 
147
152
  Lutaml::Xml::Leptris::PlanHydrator.call(self, plan,
148
153
  plan[:descriptor].walk(root),
149
- node: root)
154
+ node: root,
155
+ declaration_plan_mode:
156
+ options.fetch(
157
+ :import_declaration_plan, :eager
158
+ ))
150
159
  rescue ::Leptris::XML::ParseError => e
151
160
  raise Lutaml::Model::InvalidFormatError.new(:xml, e.message)
152
161
  end
153
162
 
163
+ def leptris_strict_kwargs
164
+ params = ::Leptris::XML.method(:parse).parameters
165
+ .map { |_, key| key }
166
+ kwargs = {}
167
+ kwargs[:recover] = false if params.include?(:recover)
168
+ kwargs[:strict] = true if params.include?(:strict)
169
+ kwargs
170
+ end
171
+
154
172
  # Get list of error types that can be raised during format parsing.
155
173
  # Core errors are always included; format-specific errors come from
156
174
  # FormatRegistry registrations.
@@ -223,6 +223,7 @@ module Lutaml
223
223
  # Clear memoized attribute merge (see .attributes)
224
224
  @merged_attributes_cache = nil
225
225
  @instantiate_writers = nil
226
+ @instantiate_attr_pairs = nil
226
227
 
227
228
  # Clear per-Attribute type caches (stale entries from GC'd TypeContext objects)
228
229
  class_attributes.each_value(&:clear_type_cache)
@@ -402,7 +403,10 @@ module Lutaml
402
403
  attrs_by = attributes(register_id)
403
404
  writers = instantiate_writers(register_id)
404
405
  given = 0
405
- attrs_by.each do |name, attr|
406
+ # Memoized pair array: Hash#each mints a [name, attr] pair
407
+ # per attribute per instance (~5 allocs/instance on
408
+ # hydration-heavy corpora); Array#each reuses its elements.
409
+ instantiate_attr_pairs(register_id).each do |name, attr|
406
410
  next if attr.derived?
407
411
 
408
412
  # Symbol keys hit directly (native-extension callers);
@@ -455,6 +459,7 @@ module Lutaml
455
459
  unknown = attrs.keys.find { |k| !known.include?(k.to_sym) }
456
460
  raise Error, "unknown attribute '#{unknown}' for #{self}"
457
461
  end
462
+ instance.clear_mutated_after_parse!
458
463
  instance
459
464
  end
460
465
 
@@ -467,6 +472,14 @@ module Lutaml
467
472
  end
468
473
  end
469
474
 
475
+ # [name, attr] pairs in attributes order — instantiate's loop
476
+ # iterates this instead of the attributes Hash so the per-call
477
+ # pair array disappears (see the instantiate comment).
478
+ def instantiate_attr_pairs(register_id = nil)
479
+ (@instantiate_attr_pairs ||= {})[register_id] ||=
480
+ attributes(register_id).to_a.freeze
481
+ end
482
+
470
483
  # Define register-specific attribute methods on the class itself.
471
484
  #
472
485
  # Called once per (class, register) combination. Replaces per-instance
@@ -44,7 +44,9 @@ module Lutaml
44
44
  # 5. Raise UnknownTypeError if not found anywhere
45
45
  #
46
46
  # @param name [Symbol, String, Class] The type name or class to resolve
47
- # @param context [TypeContext] The resolution context
47
+ # @param context [TypeContext, nil] The resolution context (nil means
48
+ # no primary registry and no fallbacks — an id registered in the
49
+ # Register namespace but not as a type context)
48
50
  # @return [Class] The resolved type class
49
51
  # @raise [UnknownTypeError] If type cannot be resolved
50
52
  #
@@ -66,11 +68,11 @@ module Lutaml
66
68
  type_name = name.to_sym
67
69
 
68
70
  # 1. Check primary registry
69
- type = context.lookup_local(type_name)
71
+ type = context&.lookup_local(type_name)
70
72
  return apply_substitutions(type, context) if type
71
73
 
72
74
  # 2. Check fallback contexts in order
73
- if context.has_fallbacks?
75
+ if context&.has_fallbacks?
74
76
  context.fallback_contexts.each do |fallback_context|
75
77
  type = resolve_from_fallback(type_name, fallback_context)
76
78
  return apply_substitutions(type, context) if type
@@ -97,8 +99,8 @@ module Lutaml
97
99
  # 5. Type not found - raise error
98
100
  raise UnknownTypeError.new(
99
101
  type_name,
100
- context_id: context.id,
101
- available_types: available_type_names(context),
102
+ context_id: context&.id,
103
+ available_types: context ? available_type_names(context) : [],
102
104
  )
103
105
  end
104
106
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.100"
5
+ VERSION = "0.8.119"
6
6
  end
7
7
  end
@@ -9,6 +9,7 @@ module Lutaml
9
9
  MOXML_ADAPTER = Moxml::Adapter::Leptris
10
10
  BUILDER_CLASS = Builder::Leptris
11
11
  PARSED_ELEMENT_CLASS = Lutaml::Xml::LeptrisElement
12
+ PARSE_ERROR_CLASS = Moxml::ParseError
12
13
  end
13
14
  end
14
15
  end
@@ -20,10 +20,9 @@ module Lutaml
20
20
  raw_xml = xml
21
21
  xml = normalize_xml_for_parse(xml)
22
22
  parsed = parse_with_moxml(xml, parse_encoding)
23
- assert_no_truncated_recovered_parse!(parsed)
24
23
  root_element = parsed.root
25
24
 
26
- raise_empty_document_error if root_element.nil?
25
+ raise_empty_document_error(parsed.parse_errors) if root_element.nil?
27
26
 
28
27
  root = self::PARSED_ELEMENT_CLASS.new(root_element)
29
28
  doc_pis = extract_document_processing_instructions(parsed)
@@ -31,31 +30,6 @@ module Lutaml
31
30
  new(root, parse_encoding, **parse_document_options(raw_xml))
32
31
  end
33
32
 
34
- # Recover-mode parsing must never masquerade input truncation as
35
- # success.
36
- #
37
- # moxml releases before 0.5.84 parsed Nokogiri documents without
38
- # XML_PARSE_HUGE, so libxml2 capped its input buffer at 10 MB;
39
- # longer documents tripped a fatal "Resource limit exceeded:
40
- # Buffer size limit exceeded, try XML_PARSE_HUGE" error mid-input.
41
- # In recover mode Nokogiri records that error on the document and
42
- # returns the partial tree, so every node past the limit silently
43
- # vanishes (lutaml-model#871). A resource-limit fatal means the
44
- # engine stopped early with input left — refuse to deserialize the
45
- # partial document instead of dropping trailing content without a
46
- # trace.
47
- #
48
- # moxml 0.5.84 and later always pass XML_PARSE_HUGE, so the
49
- # Nokogiri path no longer truncates and this guard stays silent;
50
- # it remains for older moxml releases and for any other adapter
51
- # that reports the resource-limit family.
52
- #
53
- # Other recover-mode fatals (e.g. an XML declaration after leading
54
- # whitespace) do not drop content and keep the long-standing
55
- # recover behavior; only the resource-limit family is truncating.
56
- TRUNCATION_FATAL_MARKER =
57
- /Resource limit exceeded|Buffer size limit exceeded/
58
-
59
33
  private
60
34
 
61
35
  def normalize_xml_for_parse(xml)
@@ -88,23 +62,6 @@ module Lutaml
88
62
  end
89
63
  end
90
64
 
91
- def assert_no_truncated_recovered_parse!(parsed)
92
- errors = parsed.parse_errors
93
- return if errors.nil? || errors.empty?
94
-
95
- fatal = errors.find do |message|
96
- message.match?(TRUNCATION_FATAL_MARKER)
97
- end
98
- return if fatal.nil?
99
-
100
- raise Lutaml::Model::InvalidFormatError.new(
101
- :xml,
102
- "the XML engine reported a fatal resource limit and recovered " \
103
- "with a truncated document; refusing to deserialize " \
104
- "partial input (#{fatal})",
105
- )
106
- end
107
-
108
65
  def parse_document_options(xml)
109
66
  {
110
67
  doctype: extract_doctype_from_xml(xml),
@@ -112,8 +69,11 @@ module Lutaml
112
69
  }
113
70
  end
114
71
 
115
- def raise_empty_document_error
72
+ def raise_empty_document_error(parse_errors = nil)
116
73
  message = self::EMPTY_DOCUMENT_ERROR_MESSAGE
74
+ if parse_errors && !parse_errors.empty?
75
+ message += " (the XML engine reported: #{parse_errors.join('; ')})"
76
+ end
117
77
 
118
78
  case self::EMPTY_DOCUMENT_ERROR_TYPE
119
79
  when :parse_exception