lutaml-model 0.8.102 → 0.8.104

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d307b0cf0381619d2f8a8d239a0f0ccdd3bc869b9ece087af504adfc19c1ef1c
4
- data.tar.gz: d2b28b3820c8331b45f078325d21492091cbbb15b1240d1750924599ee8ed3ce
3
+ metadata.gz: bd4d3ab0b184df92ad1d6ef04af5e1e1af9d483d34902f36d94b010477c54841
4
+ data.tar.gz: 6e5eee3bebcf1b9557b1cf635253b322a5dffc31fcbe1c7e3b07a76143a2100f
5
5
  SHA512:
6
- metadata.gz: 6d631f610cf9e057a8c23f3e27945d0ac7ca15a9f9633f5e49143a693148f496a1dfb3a99857efea37653f50241a96f6069d6d03839774368c82479c965942fb
7
- data.tar.gz: a4bc2ee7e75df18ebda941551972fb85a960503005dbeae0be453f792db53616785209068d38d43eb68e6e84ad85e2e1ac31fcf07acd41780a15adf4b571ed5b
6
+ metadata.gz: 761158fee8d42f919236179a76b90116183afde1cc7e8beb6921c958c27d03c96a6ada9a04abc0959417e34f1ca7677e99b33c00ef38326fe10cf196c0010e4f
7
+ data.tar.gz: 96f3e592216d1f0fe4bc6274ea92006e8db8fb67d77b31dbe79868175d1a42cfa17145ea9ac981d2ba6aaecf9cf9888260a1a33675f6aa7b735667d275270e23
@@ -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,7 @@ module Lutaml
31
37
  @conversion_cache = nil
32
38
  @configured = false
33
39
  @xml_plan_fast_path = true
40
+ @xml_plan_ordered_path = false
34
41
  @element_order_tracking = true
35
42
  end
36
43
 
@@ -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
 
@@ -145,7 +145,11 @@ module Lutaml
145
145
 
146
146
  Lutaml::Xml::Leptris::PlanHydrator.call(self, plan,
147
147
  plan[:descriptor].walk(root),
148
- node: root)
148
+ node: root,
149
+ declaration_plan_mode:
150
+ options.fetch(
151
+ :import_declaration_plan, :eager
152
+ ))
149
153
  rescue ::Leptris::XML::ParseError => e
150
154
  raise Lutaml::Model::InvalidFormatError.new(:xml, e.message)
151
155
  end
@@ -455,6 +455,7 @@ module Lutaml
455
455
  unknown = attrs.keys.find { |k| !known.include?(k.to_sym) }
456
456
  raise Error, "unknown attribute '#{unknown}' for #{self}"
457
457
  end
458
+ instance.clear_mutated_after_parse!
458
459
  instance
459
460
  end
460
461
 
@@ -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.102"
5
+ VERSION = "0.8.104"
6
6
  end
7
7
  end
@@ -23,7 +23,7 @@ module Lutaml
23
23
  assert_no_truncated_recovered_parse!(parsed)
24
24
  root_element = parsed.root
25
25
 
26
- raise_empty_document_error if root_element.nil?
26
+ raise_empty_document_error(parsed.parse_errors) if root_element.nil?
27
27
 
28
28
  root = self::PARSED_ELEMENT_CLASS.new(root_element)
29
29
  doc_pis = extract_document_processing_instructions(parsed)
@@ -112,8 +112,11 @@ module Lutaml
112
112
  }
113
113
  end
114
114
 
115
- def raise_empty_document_error
115
+ def raise_empty_document_error(parse_errors = nil)
116
116
  message = self::EMPTY_DOCUMENT_ERROR_MESSAGE
117
+ if parse_errors && !parse_errors.empty?
118
+ message += " (the XML engine reported: #{parse_errors.join('; ')})"
119
+ end
117
120
 
118
121
  case self::EMPTY_DOCUMENT_ERROR_TYPE
119
122
  when :parse_exception
@@ -97,8 +97,8 @@ module Lutaml
97
97
  next unless child_rel
98
98
 
99
99
  Array(value).each do |item|
100
- child_plan = nested_plans_for(plan)[item.class] ||
101
- (nested_plans_for(plan)[item.class] =
100
+ child_plan = nested_plans_for(plan)[[item.class, rule.name.to_s]] ||
101
+ (nested_plans_for(plan)[[item.class, rule.name.to_s]] =
102
102
  PlanCompiler.compile(item.class, register))
103
103
  ops << { kind: :element, plan_index: my_index,
104
104
  row_index: row_index }
@@ -53,6 +53,10 @@ module Lutaml
53
53
  # name and wildcard rows carry their own :any.
54
54
  ROW_KINDS_NS_UNQUALIFIED = %i[scalar collection nested callback
55
55
  raw].freeze
56
+ # Row kinds whose hydration can dispatch per source node —
57
+ # the only safe claimants of a contested (multi-row) name.
58
+ PER_NODE_ROW_KINDS = %i[nested ordered_deferred
59
+ child_deferred].freeze
56
60
 
57
61
  class << self
58
62
  def compile(model_class, register)
@@ -104,9 +108,23 @@ module Lutaml
104
108
 
105
109
  mapping = model_class.mappings_for(:xml, register)
106
110
  return nil unless compilable_mapping?(mapping)
111
+ # Ordered/mixed models ride the plan path only when opted
112
+ # in: the per-node ordered hydration beats the interpretive
113
+ # lane on time but allocates above the downstream budget
114
+ # gates (niso pnas ratio 1.25) — it ships dark until the
115
+ # allocation diet lands.
116
+ if (mapping.ordered? || mapping.mixed_content?) &&
117
+ !Lutaml::Model::Config.instance.xml_plan_ordered_path
118
+ return opt_out!(:ordered_opt_in)
119
+ end
120
+ # Processing-instruction mappings (rfcxml's <?rfc toc?>)
121
+ # have no plan-row spelling — the interpretive path owns
122
+ # them (compiling here silently dropped pi_settings).
123
+ return opt_out!(:processing_instruction) unless
124
+ mapping.processing_instruction_mappings.empty?
107
125
 
108
126
  rows = []
109
- attr_rows = [] # [[rule, attr]] hydration metadata
127
+ attr_rows = [] # [[rule, attr, row_name]] hydration metadata
110
128
  plan_attrs = [] # [{name:, kind:}] rows for the engine plan
111
129
  compiled = [] # [rule, attr, kind, spelling, delegate_target]
112
130
  ns_attr_names = [] # exact-URI attribute rows (ambiguity guard)
@@ -202,28 +220,26 @@ module Lutaml
202
220
  type = attr.type(register)
203
221
  if serializable_type?(type)
204
222
  child = compile(type, register)
205
- return opt_out!(:child_uncompilable) unless child
206
-
207
- if child[:ordered]
208
- # Ordered/mixed children need element_order on their
209
- # instances; the walk hands back plan values, not
210
- # source nodes, so the subtree defers interpretively
211
- # (the fragment parse runs the full machinery,
212
- # order included).
213
- return opt_out!(:ordered_child_with_ns) if model_ns
214
-
215
- needs_nodes = true
216
- compiled << [rule, attr, :ordered_deferred, nil,
217
- delegate_target]
218
- rows << { name: rule.name.to_s, kind: :raw }
219
- else
220
- compiled << [rule, attr, :nested, nil, delegate_target]
221
- needs_nodes ||= child[:needs_nodes]
222
- nested_row = { name: rule.name.to_s, kind: :nested,
223
- plan: child[:tree] }
224
- nested_row[:ns] = child_ns(rule, model_ns) if rule.namespace_set?
225
- rows << nested_row
223
+ if child.nil? &&
224
+ !Lutaml::Model::Config.instance.xml_plan_ordered_path
225
+ child_mapping = type.mappings_for(:xml, register)
226
+ if child_mapping&.ordered? ||
227
+ child_mapping&.mixed_content?
228
+ # A child refused ONLY by the ordered opt-in gate
229
+ # cascades: the bridged-island deferral drops its
230
+ # mixed children (customxml schemaRefs), so the
231
+ # parent compiles to nil — main's whole-model
232
+ # interpretive shape.
233
+ return opt_out!(:ordered_opt_in_cascade)
234
+ end
226
235
  end
236
+ needs_nodes = true if child.nil? || child[:ordered]
237
+ compiled_entry, row = child_row_for(rule, attr, type, child,
238
+ delegate_target,
239
+ model_ns)
240
+ compiled << compiled_entry
241
+ needs_nodes ||= child && child[:needs_nodes]
242
+ rows << row
227
243
  elsif rule.multiple_mappings?
228
244
  rule.name.each do |spelling|
229
245
  compiled << [rule, attr, :spelling, spelling.to_s,
@@ -271,6 +287,19 @@ module Lutaml
271
287
 
272
288
  row_tags = partition_row_tags!(compiled, rows, tag)
273
289
 
290
+ # Same-name rows cross-echo in the engine walk: every source
291
+ # element of a name lands in every row of that name (two
292
+ # w14:/w15: docId rows each capture both elements). Names
293
+ # claimed by more than one element row are contested — they
294
+ # hydrate per source node (the ordered_deferred pattern)
295
+ # when every claimant is per-node-dispatchable, and opt the
296
+ # model out otherwise (the interpretive matcher owns the
297
+ # (URI, local) dispatch there).
298
+ contested = contested_element_names(rows, compiled)
299
+ return opt_out!(:contested_names) if contested == :unsafe
300
+
301
+ needs_nodes = true unless contested.empty?
302
+
274
303
  # The plan serializer does not emit namespace declarations —
275
304
  # namespaced models serialize through the interpretive
276
305
  # writer (lutaml-model#847: standalone to_xml under leptris
@@ -354,6 +383,7 @@ module Lutaml
354
383
  ordered: mapping.ordered? || mapping.mixed_content?,
355
384
  needs_nodes: needs_nodes,
356
385
  collection_defaults: collection_defaults,
386
+ contested_names: contested.empty? ? nil : contested,
357
387
  ns_attr_names: ns_attr_names.empty? ? nil : ns_attr_names,
358
388
  wildcard_tag: (wildcard_tag if defined?(wildcard_tag)) }
359
389
  end
@@ -375,9 +405,19 @@ module Lutaml
375
405
  attr_local_claims)
376
406
  return :non_scalar unless scalar_type?(attr, register)
377
407
 
378
- attr_rows << [rule, attr]
408
+ raw_name = rule.name.to_s
409
+ # W3C-reserved xml:* attributes bind by raw name — the xml
410
+ # prefix is implicit (never a model namespace), so form
411
+ # defaults must not qualify them under the model's URI
412
+ # (w:t's xml:space under a :qualified WML mapping).
413
+ if raw_name.start_with?("xml:")
414
+ attr_local_claims[raw_name][:plain] += 1
415
+ plan_attrs << { name: raw_name }
416
+ attr_rows << [rule, attr, raw_name]
417
+ return nil
418
+ end
379
419
  type_ns = attr.type_namespace_class(register)
380
- claim = attr_local_claims[rule.name.to_s]
420
+ raw_name
381
421
  if type_ns.nil?
382
422
  # attribute_form_default :qualified on the model's
383
423
  # namespace qualifies locally-declared attributes under
@@ -389,29 +429,105 @@ module Lutaml
389
429
  ns_class = mapping.namespace_class
390
430
  if rule.namespace_param != :blank && ns_class &&
391
431
  ns_class.attribute_form_default == :qualified
392
- claim[:ns] += 1
393
- plan_attrs << { name: rule.name.to_s,
432
+ row_name = local_attr_name(raw_name)
433
+ attr_local_claims[row_name][:ns] += 1
434
+ plan_attrs << { name: row_name,
394
435
  ns: { exact: ns_class.uri.to_s,
395
436
  prefix: ns_class.prefix_default&.to_s } }
396
- ns_attr_names << rule.name.to_s
437
+ ns_attr_names << row_name
438
+ attr_rows << [rule, attr, row_name]
397
439
  return nil
398
440
  end
399
441
 
400
- claim[:plain] += 1
401
- plan_attrs << { name: rule.name.to_s }
442
+ attr_local_claims[raw_name][:plain] += 1
443
+ plan_attrs << { name: raw_name }
444
+ attr_rows << [rule, attr, raw_name]
402
445
  return nil
403
446
  end
404
447
  return :attr_type_ns_symbol if type_ns.is_a?(Symbol)
405
448
  return :attr_type_ns_aliases unless type_ns.all_uris.size == 1
406
449
 
407
- claim[:ns] += 1
408
- plan_attrs << { name: rule.name.to_s,
450
+ # Exact-URI rows match the engine's (URI, local) capture by
451
+ # the LOCAL name — a prefixed rule spelling (xlink:href)
452
+ # binds its local part under the row's URI. Plain rows keep
453
+ # the raw name (matches-any-qualification capture). The
454
+ # claims tally keys by the effective match key so an exact
455
+ # row and a plain row claiming the same local name still
456
+ # opt the model out (#758).
457
+ row_name = local_attr_name(raw_name)
458
+ attr_local_claims[row_name][:ns] += 1
459
+ plan_attrs << { name: row_name,
409
460
  ns: { exact: type_ns.uri.to_s,
410
461
  prefix: type_ns.prefix_default&.to_s } }
411
- ns_attr_names << rule.name.to_s
462
+ ns_attr_names << row_name
463
+ attr_rows << [rule, attr, row_name]
412
464
  nil
413
465
  end
414
466
 
467
+ def local_attr_name(name)
468
+ name.include?(":") ? name.split(":", 2).last : name
469
+ end
470
+
471
+ # Names claimed by more than one element row (engine-row
472
+ # view; content and wildcard pseudo-rows are exempt). Returns
473
+ # the contested name list, or :unsafe when any claimant row
474
+ # cannot dispatch per node — the model then compiles to nil
475
+ # and the interpretive matcher owns the dispatch.
476
+ def contested_element_names(rows, compiled)
477
+ contested = []
478
+ pseudo_row_kinds = %i[content wildcard].freeze
479
+ rows.group_by { |r| r[:name] }.each do |name, group|
480
+ next if group.length < 2 || name.nil?
481
+ next if group.any? { |r| pseudo_row_kinds.include?(r[:kind]) }
482
+ # when_attribute partitions route exclusively by engine
483
+ # predicate (leptris 1.9.221+ #1272) — same-name partition
484
+ # groups are engine-managed, not cross-echoed.
485
+ next if group.any? { |r| r[:when] }
486
+
487
+ kinds = compiled.filter_map do |rule, _attr, kind, _spelling, _del|
488
+ next if rule.nil?
489
+
490
+ names = rule.name.is_a?(Array) ? rule.name.map(&:to_s) : [rule.name.to_s]
491
+ kind if names.include?(name)
492
+ end
493
+ return :unsafe unless kinds.all? do |k|
494
+ PER_NODE_ROW_KINDS.include?(k)
495
+ end
496
+
497
+ contested << name
498
+ end
499
+ contested
500
+ end
501
+
502
+ # Row entries for a serializable child. Three shapes: # - nil child (a cyclic graph, or a shape that opted out):
503
+ # :child_deferred — the ROW defers per-node instead of
504
+ # opting the whole model out; the buckets chain delivers
505
+ # source nodes and the interpretive cast runs the full
506
+ # machinery on each island. (The whole-model bail here
507
+ # cascaded child_uncompilable up entire trees — uniword's
508
+ # Run → pict cycle nilled DocumentRoot.)
509
+ # - ordered child: :ordered_deferred — the per-node plan
510
+ # walk + element_order rebuild (PlanOrder) carry the
511
+ # namespace context in the child plan's own exact-URI
512
+ # rows; nothing is lost under a namespaced parent.
513
+ # - otherwise: :nested with the child's tree.
514
+ # Returns [[compiled tuple], tree row].
515
+ def child_row_for(rule, attr, type, child, delegate_target,
516
+ model_ns)
517
+ if child.nil?
518
+ [[rule, attr, :child_deferred, nil, delegate_target, type],
519
+ { name: rule.name.to_s, kind: :raw }]
520
+ elsif child[:ordered]
521
+ [[rule, attr, :ordered_deferred, nil, delegate_target, type],
522
+ { name: rule.name.to_s, kind: :raw }]
523
+ else
524
+ row = { name: rule.name.to_s, kind: :nested,
525
+ plan: child[:tree] }
526
+ row[:ns] = child_ns(rule, model_ns) if rule.namespace_set?
527
+ [[rule, attr, :nested, nil, delegate_target, type], row]
528
+ end
529
+ end
530
+
415
531
  def compilable_mapping?(mapping)
416
532
  # Mapping's uniform interface: root_mappings defaults to
417
533
  # false on the base (KeyValue overrides with its own).