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.
- checksums.yaml +4 -4
- data/.github/scripts/test_installed_gem.rb +58 -0
- data/.github/workflows/downstream-performance.yml +28 -3
- data/.github/workflows/js-pr-check.yml +4 -0
- data/.github/workflows/js-sync.yml +4 -0
- data/.github/workflows/opal.yml +9 -0
- data/.github/workflows/rake.yml +6 -41
- data/.github/workflows/release.yml +41 -0
- data/Gemfile +17 -3
- data/bench/bench_alloc_audit.rb +56 -0
- data/bench/bench_common.rb +11 -0
- data/bench/bench_merge_attempts.rb +49 -0
- data/docs/_guides/gem-integration.adoc +85 -0
- data/docs/_guides/grammar-backed-formats.adoc +69 -0
- data/docs/_guides/index.adoc +8 -1
- data/docs/_guides/native-engines.adoc +23 -5
- data/docs/_guides/schema-generation.adoc +25 -0
- data/docs/_guides/type-registers-contexts.adoc +126 -0
- data/lib/compat/opal/lutaml_model_boot.rb +7 -5
- data/lib/lutaml/json/adapter/yeptris_adapter.rb +24 -1
- data/lib/lutaml/key_value/data_model/element.rb +5 -1
- data/lib/lutaml/key_value/mapping_rule.rb +7 -0
- data/lib/lutaml/key_value/transform.rb +29 -24
- data/lib/lutaml/key_value/transformation/collection_serializer.rb +12 -29
- data/lib/lutaml/key_value/transformation/value_serializer.rb +29 -6
- data/lib/lutaml/key_value/transformation.rb +64 -116
- data/lib/lutaml/model/adapter_resolver.rb +7 -3
- data/lib/lutaml/model/attribute.rb +74 -15
- data/lib/lutaml/model/cached_type_resolver.rb +13 -6
- data/lib/lutaml/model/configuration.rb +13 -0
- data/lib/lutaml/model/mapping/mapping.rb +13 -0
- data/lib/lutaml/model/mapping/mapping_rule.rb +6 -1
- data/lib/lutaml/model/render_policy.rb +26 -8
- data/lib/lutaml/model/schema/base_schema.rb +15 -0
- data/lib/lutaml/model/schema/generator/definition.rb +52 -8
- data/lib/lutaml/model/schema/generator/definitions_collection.rb +35 -14
- data/lib/lutaml/model/schema.rb +4 -2
- data/lib/lutaml/model/serializable.rb +33 -0
- data/lib/lutaml/model/serialize/attribute_definition.rb +196 -2
- data/lib/lutaml/model/serialize/builder.rb +22 -0
- data/lib/lutaml/model/serialize/conversion_caching.rb +5 -0
- data/lib/lutaml/model/serialize/format_conversion.rb +49 -41
- data/lib/lutaml/model/serialize/initialization.rb +54 -7
- data/lib/lutaml/model/serialize/model_import.rb +60 -0
- data/lib/lutaml/model/serialize.rb +14 -6
- data/lib/lutaml/model/type/time.rb +6 -0
- data/lib/lutaml/model/type/value.rb +9 -7
- data/lib/lutaml/model/type_resolver.rb +7 -5
- data/lib/lutaml/model/utils.rb +18 -5
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/model.rb +7 -0
- data/lib/lutaml/xml/adapter/xml_parser.rb +5 -2
- data/lib/lutaml/xml/adapter_loader.rb +5 -2
- data/lib/lutaml/xml/format.rb +18 -16
- data/lib/lutaml/xml/leptris/native_emission.rb +187 -0
- data/lib/lutaml/xml/leptris/plan_compiler.rb +650 -0
- data/lib/lutaml/xml/leptris/plan_hydrator.rb +748 -0
- data/lib/lutaml/xml/leptris/plan_order.rb +55 -0
- data/lib/lutaml/xml/leptris/plan_serializer.rb +702 -0
- data/lib/lutaml/xml/leptris/plan_walk.rb +36 -0
- data/lib/lutaml/xml/leptris.rb +46 -0
- data/lib/lutaml/xml/mapping.rb +71 -18
- data/lib/lutaml/xml/model_transform.rb +231 -8
- data/lib/lutaml/xml/namespace.rb +18 -8
- data/lib/lutaml/xml/namespace_collector.rb +17 -1
- data/lib/lutaml/xml/namespace_needs.rb +77 -49
- data/lib/lutaml/xml/parse_session.rb +7 -19
- data/lib/lutaml/xml/schema/xsd/schema.rb +2 -1
- data/lib/lutaml/xml/serialization/format_conversion.rb +5 -1
- data/lib/lutaml/xml/transformation/ordered_applier.rb +21 -15
- data/lib/lutaml/xml/transformation.rb +15 -1
- data/lib/lutaml/xml/type/serializers.rb +8 -1
- data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +34 -2
- data/lib/tasks/performance_probe_plan_walk.rb +1 -1
- data/lutaml-model.gemspec +7 -4
- data/spec/lutaml/json/yeptris_adapter_spec.rb +7 -2
- data/spec/lutaml/key_value/transformation/value_serializer_spec.rb +9 -5
- data/spec/lutaml/key_value/transformation_spec.rb +87 -21
- data/spec/lutaml/model/any_element_mapping_spec.rb +41 -0
- data/spec/lutaml/model/collection_validation_spec.rb +265 -0
- data/spec/lutaml/model/desc_spec.rb +50 -0
- data/spec/lutaml/model/late_attribute_declaration_spec.rb +35 -0
- data/spec/lutaml/model/model_import_reentry_spec.rb +46 -0
- data/spec/lutaml/model/opal_smoke_spec.rb +17 -0
- data/spec/lutaml/model/schema/generate_many_spec.rb +61 -0
- data/spec/lutaml/model/schema/generation_regressions_spec.rb +139 -0
- data/spec/lutaml/model/schema/json_schema_spec.rb +1 -13
- data/spec/lutaml/model/schema/yaml_schema_spec.rb +0 -11
- data/spec/lutaml/model/serialize/attribute_definition_block_form_spec.rb +244 -0
- data/spec/lutaml/model/store_spec.rb +6 -2
- data/spec/lutaml/model/unregistered_context_resolution_spec.rb +34 -0
- data/spec/lutaml/model/writer_assigned_attribute_spec.rb +62 -0
- data/spec/lutaml/model/yeptris_error_parity_spec.rb +95 -0
- data/spec/lutaml/xml/adapter/empty_document_diagnostics_spec.rb +50 -0
- data/spec/lutaml/xml/adapter/over_cap_parse_spec.rb +105 -0
- data/spec/lutaml/xml/adapter_loader_spec.rb +14 -0
- data/spec/lutaml/xml/adapter_metadata_default_spec.rb +16 -0
- data/spec/lutaml/xml/attribute_ambiguity_surface_spec.rb +5 -0
- data/spec/lutaml/xml/clear_parse_state_spec.rb +6 -1
- data/spec/lutaml/xml/compiled_rule_namespace_spec.rb +5 -0
- data/spec/lutaml/xml/cross_register_plan_spec.rb +75 -0
- data/spec/lutaml/xml/doubly_defined_namespace_spec.rb +16 -8
- data/spec/lutaml/xml/element_order_tracking_spec.rb +37 -0
- data/spec/lutaml/xml/leptris_string_encoding_spec.rb +65 -0
- data/spec/lutaml/xml/opal_xml_spec.rb +47 -0
- data/spec/lutaml/xml/parse_dom_retention_spec.rb +34 -0
- data/spec/lutaml/xml/plan_fast_path_spec.rb +122 -14
- data/spec/lutaml/xml/plan_namespaced_serialize_spec.rb +157 -0
- data/spec/lutaml/xml/plan_ns_less_document_spec.rb +86 -0
- data/spec/lutaml/xml/plan_partition_spec.rb +1 -1
- data/spec/lutaml/xml/plan_type_ns_attribute_spec.rb +131 -0
- data/spec/lutaml/xml/plan_uri_exact_dispatch_spec.rb +287 -0
- data/spec/lutaml/xml/plan_walk_spec.rb +6 -6
- data/spec/lutaml/xml/plan_wildcard_any_spec.rb +96 -0
- data/spec/lutaml/yaml/yeptris_adapter_spec.rb +29 -0
- data/spec/person_spec.rb +8 -0
- metadata +48 -17
- data/lib/lutaml/xml/plan_compiler.rb +0 -332
- data/lib/lutaml/xml/plan_hydrator.rb +0 -367
- data/lib/lutaml/xml/plan_order.rb +0 -53
- data/lib/lutaml/xml/plan_serializer.rb +0 -252
- data/lib/lutaml/xml/plan_walk.rb +0 -30
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Xml
|
|
5
|
+
module Leptris
|
|
6
|
+
# Public consumer surface for the plan fast path: one engine
|
|
7
|
+
# parse + descriptor walk and direct model hydration, bypassing
|
|
8
|
+
# the wrapper-tree construction that dominates large-document
|
|
9
|
+
# consumer paths. The returned object is the mapped model root;
|
|
10
|
+
# collection attributes remain collections on that model.
|
|
11
|
+
module PlanWalk
|
|
12
|
+
class << self
|
|
13
|
+
# Build the plan, walk the document, and hydrate the mapped
|
|
14
|
+
# root. Returns nil when the model is not plan-compilable.
|
|
15
|
+
def call(model_class, xml, options = {})
|
|
16
|
+
register = Lutaml::Model::Config.default_register
|
|
17
|
+
plan = PlanCompiler.compile(model_class, register)
|
|
18
|
+
return nil unless plan
|
|
19
|
+
|
|
20
|
+
document = ::Leptris::XML.parse(xml)
|
|
21
|
+
root = document.root
|
|
22
|
+
return nil unless root && root.name == plan[:tree][:name]
|
|
23
|
+
|
|
24
|
+
PlanHydrator.call(model_class, plan,
|
|
25
|
+
plan[:descriptor].walk(root),
|
|
26
|
+
node: root,
|
|
27
|
+
declaration_plan_mode:
|
|
28
|
+
options.fetch(
|
|
29
|
+
:import_declaration_plan, :eager
|
|
30
|
+
))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Xml
|
|
5
|
+
# The leptris plan fast path: model mapping to libleptris descriptor
|
|
6
|
+
# plan, native walk, direct hydration. Everything under this
|
|
7
|
+
# namespace is bound to the leptris gem; nothing above it may
|
|
8
|
+
# reference leptris types directly (the moxml adapters and the
|
|
9
|
+
# interpretive path never enter here).
|
|
10
|
+
module Leptris
|
|
11
|
+
# Single capability floor (leptris 1.9.336.0): the child-handles
|
|
12
|
+
# snapshot (1.9.273), exact-URI attribute rows (#1486, 1.9.289),
|
|
13
|
+
# nested-attribute capture (#1563, 1.9.313), wildcard rows
|
|
14
|
+
# (#1552, 1.9.313), child-row ns_uri retention and qualified
|
|
15
|
+
# attribute leniency (#1585/#1586, 1.9.320), the :unqualified ns
|
|
16
|
+
# form (#1560, 1.9.323), and build_result with the address-based
|
|
17
|
+
# text attach (#408, 1.9.334/1.9.336). Bundles resolving an
|
|
18
|
+
# older gem keep the interpretive path — PlanCompiler.compile
|
|
19
|
+
# answers nil — the opt-out protocol.
|
|
20
|
+
MIN_LEPTRIS_VERSION = "1.9.336.0"
|
|
21
|
+
|
|
22
|
+
# Memoized: the compiler consults this on every cache hit (per
|
|
23
|
+
# child row per node), and the comparison minted a Gem::Version
|
|
24
|
+
# per call — visible in the niso ordered-lane allocation profile.
|
|
25
|
+
# The loaded gem cannot change mid-process.
|
|
26
|
+
def self.plan_path_compatible?
|
|
27
|
+
return @plan_path_compatible if defined?(@plan_path_compatible)
|
|
28
|
+
|
|
29
|
+
@plan_path_compatible =
|
|
30
|
+
if defined?(Gem)
|
|
31
|
+
spec = Gem.loaded_specs["leptris"]
|
|
32
|
+
spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_VERSION)
|
|
33
|
+
else
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
autoload :NativeEmission, "lutaml/xml/leptris/native_emission"
|
|
39
|
+
autoload :PlanCompiler, "lutaml/xml/leptris/plan_compiler"
|
|
40
|
+
autoload :PlanHydrator, "lutaml/xml/leptris/plan_hydrator"
|
|
41
|
+
autoload :PlanOrder, "lutaml/xml/leptris/plan_order"
|
|
42
|
+
autoload :PlanSerializer, "lutaml/xml/leptris/plan_serializer"
|
|
43
|
+
autoload :PlanWalk, "lutaml/xml/leptris/plan_walk"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/lutaml/xml/mapping.rb
CHANGED
|
@@ -38,27 +38,14 @@ module Lutaml
|
|
|
38
38
|
@xml_instance
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
attr_reader :root_element,
|
|
42
|
-
:namespace_uri,
|
|
43
|
-
:namespace_prefix,
|
|
44
|
-
:element_sequence,
|
|
45
|
-
:mappings_imported,
|
|
46
|
-
:namespace_class,
|
|
47
|
-
:namespace_param,
|
|
48
|
-
:element_name,
|
|
49
|
-
:documentation_text,
|
|
50
|
-
:type_name_value,
|
|
51
|
-
:namespace_scope,
|
|
52
|
-
:mapper_class,
|
|
53
|
-
:xml_space,
|
|
54
|
-
:consolidation_maps,
|
|
55
|
-
:processing_instruction_mappings
|
|
41
|
+
attr_reader :root_element, :namespace_uri, :namespace_prefix, :element_sequence, :mappings_imported, :namespace_class, :namespace_param, :element_name, :documentation_text, :type_name_value, :namespace_scope, :mapper_class, :xml_space, :consolidation_maps, :processing_instruction_mappings, :any_element_rule
|
|
56
42
|
|
|
57
43
|
def initialize
|
|
58
44
|
super
|
|
59
45
|
|
|
60
46
|
@elements = {}
|
|
61
47
|
@attributes = {}
|
|
48
|
+
@any_element_rule = nil
|
|
62
49
|
@element_sequence = []
|
|
63
50
|
@content_mapping = nil
|
|
64
51
|
@raw_mapping = nil
|
|
@@ -504,6 +491,24 @@ module Lutaml
|
|
|
504
491
|
# @return [String, nil] the type name
|
|
505
492
|
alias xsd_type type_name
|
|
506
493
|
|
|
494
|
+
# Catch-all element mapping: every child element with no
|
|
495
|
+
# explicit rule routes to one collection attribute. For
|
|
496
|
+
# parse-side consumption of dynamic vocabularies (semantic__
|
|
497
|
+
# trees); element_order preserves the original tag names.
|
|
498
|
+
# Serialization of a catch-all attribute emits the attribute's
|
|
499
|
+
# declared element name (not per-item originals).
|
|
500
|
+
def map_any_element(to:, render_nil: false, render_default: false)
|
|
501
|
+
validate!(nil, to, {}, render_nil, false, type: TYPES[:element])
|
|
502
|
+
@any_element_rule = MappingRule.new(
|
|
503
|
+
nil,
|
|
504
|
+
to: to,
|
|
505
|
+
render_nil: render_nil,
|
|
506
|
+
render_default: render_default,
|
|
507
|
+
)
|
|
508
|
+
@element_rules_index = nil if instance_variable_defined?(:@element_rules_index)
|
|
509
|
+
@any_element_rule
|
|
510
|
+
end
|
|
511
|
+
|
|
507
512
|
def map_instances(to:, polymorphic: {})
|
|
508
513
|
map_element(to, to: to, polymorphic: polymorphic)
|
|
509
514
|
end
|
|
@@ -1192,6 +1197,31 @@ module Lutaml
|
|
|
1192
1197
|
# transform applies the collection-attribute filter.
|
|
1193
1198
|
#
|
|
1194
1199
|
# @return [Hash{Symbol, String => Array<MappingRule>}]
|
|
1200
|
+
# The when_attribute siblings-vs-plain-names partition of the
|
|
1201
|
+
# element rules. A ParseSession used to rebuild this per parsed
|
|
1202
|
+
# node (sessions are per-element), paying an O(rules) pass on
|
|
1203
|
+
# every node of large documents; it is a pure function of the
|
|
1204
|
+
# rules, so it memoizes here — same lifetime as
|
|
1205
|
+
# plain_element_rules_by_attr.
|
|
1206
|
+
def when_attribute_partition
|
|
1207
|
+
@when_attribute_partition ||= begin
|
|
1208
|
+
siblings = {}
|
|
1209
|
+
plain_names = {}
|
|
1210
|
+
mappings.each do |rule|
|
|
1211
|
+
next if rule.attribute? || rule.content_mapping? ||
|
|
1212
|
+
rule.raw_mapping? || rule.cdata
|
|
1213
|
+
|
|
1214
|
+
pairs = rule.when_attribute
|
|
1215
|
+
if pairs && !pairs.empty?
|
|
1216
|
+
(siblings[rule.name.to_s] ||= []) << rule
|
|
1217
|
+
else
|
|
1218
|
+
plain_names[rule.name.to_s] = true
|
|
1219
|
+
end
|
|
1220
|
+
end
|
|
1221
|
+
[siblings, plain_names]
|
|
1222
|
+
end
|
|
1223
|
+
end
|
|
1224
|
+
|
|
1195
1225
|
def plain_element_rules_by_attr(register_id = nil)
|
|
1196
1226
|
reg_key = register_id || :default
|
|
1197
1227
|
@plain_element_rules_by_attr ||= {}
|
|
@@ -1342,9 +1372,9 @@ module Lutaml
|
|
|
1342
1372
|
elsif ["text", "#cdata-section"].include?(name.to_s) && type == "Text"
|
|
1343
1373
|
content_mapping
|
|
1344
1374
|
else
|
|
1345
|
-
candidates =
|
|
1346
|
-
|
|
1347
|
-
|
|
1375
|
+
candidates = element_rules_index[name.to_s]
|
|
1376
|
+
return @any_element_rule if candidates.nil? && @any_element_rule
|
|
1377
|
+
return nil if candidates.nil?
|
|
1348
1378
|
return candidates.first if namespace_uri.nil? || candidates.one?
|
|
1349
1379
|
|
|
1350
1380
|
candidates.find do |r|
|
|
@@ -1353,6 +1383,29 @@ module Lutaml
|
|
|
1353
1383
|
end
|
|
1354
1384
|
end
|
|
1355
1385
|
|
|
1386
|
+
# O(1) name -> rules lookup. Parsing consults this once per
|
|
1387
|
+
# child element; the linear scan over every rule made large
|
|
1388
|
+
# documents quadratic (155s on a 7MB vocabulary tree). Keyed on
|
|
1389
|
+
# the stable source hashes (@elements/@attributes mutate only at
|
|
1390
|
+
# DSL time), never on the per-call mappings array.
|
|
1391
|
+
def element_rules_index
|
|
1392
|
+
cache_key = [@elements.object_id, @attributes.object_id]
|
|
1393
|
+
cached = (@element_rules_index ||= {})
|
|
1394
|
+
idx = cached[cache_key]
|
|
1395
|
+
return idx if idx
|
|
1396
|
+
|
|
1397
|
+
idx = {}
|
|
1398
|
+
([content_mapping, raw_mapping].compact + @elements.values +
|
|
1399
|
+
@attributes.values).flatten.each do |rule|
|
|
1400
|
+
next if rule.is_a?(::Hash)
|
|
1401
|
+
|
|
1402
|
+
(idx[rule.name.to_s] ||= []) << rule
|
|
1403
|
+
end
|
|
1404
|
+
cached[cache_key] = idx
|
|
1405
|
+
idx
|
|
1406
|
+
end
|
|
1407
|
+
private :element_rules_index
|
|
1408
|
+
|
|
1356
1409
|
def find_by_to(to)
|
|
1357
1410
|
mappings.detect { |rule| rule.to.to_s == to.to_s }
|
|
1358
1411
|
end
|
|
@@ -114,7 +114,14 @@ module Lutaml
|
|
|
114
114
|
# Only needed for root elements (no lutaml_parent in options).
|
|
115
115
|
# Three modes: :lazy (default), :eager, :skip.
|
|
116
116
|
if !options.key?(:lutaml_parent)
|
|
117
|
-
|
|
117
|
+
# Default :eager — the plan data is tiny, but :lazy pins the
|
|
118
|
+
# adapter root element on every parsed instance, which keeps the
|
|
119
|
+
# entire native DOM (moxml wrappers + engine nodes) alive until
|
|
120
|
+
# first serialization. Models that are never serialized retain
|
|
121
|
+
# the DOM forever: a 10.5MB XMI held 578MB of wrappers this
|
|
122
|
+
# way. :lazy remains available for parse-serialize-immediately
|
|
123
|
+
# workflows that want to skip the namespace walk.
|
|
124
|
+
plan_mode = options.fetch(:import_declaration_plan, :eager)
|
|
118
125
|
case plan_mode
|
|
119
126
|
when :skip
|
|
120
127
|
# Skip plan building entirely (fastest)
|
|
@@ -205,7 +212,9 @@ module Lutaml
|
|
|
205
212
|
}
|
|
206
213
|
end
|
|
207
214
|
|
|
208
|
-
|
|
215
|
+
# The path array is shared and mutated during the descent;
|
|
216
|
+
# snapshot it as a result key only where a declaration lives.
|
|
217
|
+
result[path.dup] = input_namespaces unless input_namespaces.empty?
|
|
209
218
|
end
|
|
210
219
|
|
|
211
220
|
# Recurse into children with path extended by child local name
|
|
@@ -223,8 +232,9 @@ module Lutaml
|
|
|
223
232
|
else
|
|
224
233
|
full_name
|
|
225
234
|
end
|
|
226
|
-
|
|
227
|
-
collect_element_namespaces(child,
|
|
235
|
+
path.push(child_local_name)
|
|
236
|
+
collect_element_namespaces(child, path, result, visited)
|
|
237
|
+
path.pop
|
|
228
238
|
end
|
|
229
239
|
|
|
230
240
|
result
|
|
@@ -263,6 +273,9 @@ module Lutaml
|
|
|
263
273
|
# Performance: Resolve xml_mapping once — used for both rules and metadata
|
|
264
274
|
xml_mapping = mappings_for(:xml, effective_register)
|
|
265
275
|
mappings = options[:mappings] || xml_mapping.mappings(effective_register)
|
|
276
|
+
# Catch-all rule (map_any_element): iterated like any other,
|
|
277
|
+
# claiming every child no explicit rule matches.
|
|
278
|
+
mappings += [xml_mapping.any_element_rule].compact if options[:mappings].nil?
|
|
266
279
|
default_namespace = options[:default_namespace]
|
|
267
280
|
ordered_option = options[:ordered]
|
|
268
281
|
mixed_content_option = options[:mixed_content]
|
|
@@ -331,6 +344,13 @@ module Lutaml
|
|
|
331
344
|
deserialize_attributes = attributes
|
|
332
345
|
mappings.each do |rule|
|
|
333
346
|
record = records&.[](rule)
|
|
347
|
+
|
|
348
|
+
# #903/#922: replay-or-drop unmatched rules; never clobber
|
|
349
|
+
# a mid-parse custom-writer assignment.
|
|
350
|
+
next if replay_unmatched_rule(child_names_set, record, rule,
|
|
351
|
+
instance, defaults_used,
|
|
352
|
+
xml_mapping)
|
|
353
|
+
|
|
334
354
|
rule_to = rule.to
|
|
335
355
|
|
|
336
356
|
if record
|
|
@@ -361,7 +381,19 @@ module Lutaml
|
|
|
361
381
|
options
|
|
362
382
|
end
|
|
363
383
|
|
|
364
|
-
value = if rule.
|
|
384
|
+
value = if !rule.attribute? && rule.name.nil? &&
|
|
385
|
+
xml_mapping.any_element_rule.equal?(rule)
|
|
386
|
+
# map_any_element: every child element no explicit
|
|
387
|
+
# element rule claims, in document order (claimed
|
|
388
|
+
# set + scalar text extraction live in
|
|
389
|
+
# any_element_members). A name-less MappingRule is
|
|
390
|
+
# otherwise indistinguishable from a content rule;
|
|
391
|
+
# name-derived lookup re-finds by name, losing
|
|
392
|
+
# order and double-picking.
|
|
393
|
+
any_element_members(
|
|
394
|
+
doc, xml_mapping, attr, effective_register
|
|
395
|
+
)
|
|
396
|
+
elsif rule.raw_mapping?
|
|
365
397
|
scoped_raw_inner_xml(doc, xml_mapping)
|
|
366
398
|
elsif rule.content_mapping?
|
|
367
399
|
rule.cdata ? doc.cdata : doc.text
|
|
@@ -369,6 +401,8 @@ module Lutaml
|
|
|
369
401
|
group.size > 1 && attr&.collection?
|
|
370
402
|
# First rule of a #765 group: match every spelling of
|
|
371
403
|
# the group so the children merge in document order.
|
|
404
|
+
raise "GROUPED-BODY" if ENV["CATCHDBG"] && rule.name.nil?
|
|
405
|
+
|
|
372
406
|
if child_names_set &&
|
|
373
407
|
group.drop(1).none? do |s|
|
|
374
408
|
child_matches_rule?(s, child_names_set,
|
|
@@ -385,21 +419,33 @@ module Lutaml
|
|
|
385
419
|
value_for_rule(session, rule, new_opts, attr,
|
|
386
420
|
extra_names)
|
|
387
421
|
end
|
|
388
|
-
elsif child_names_set && !rule.attribute? &&
|
|
422
|
+
elsif child_names_set && !rule.attribute? && rule.name &&
|
|
389
423
|
!child_matches_rule?(rule, child_names_set,
|
|
390
424
|
default_namespace)
|
|
425
|
+
raise "FASTSKIP-BODY" if ENV["CATCHDBG"] && rule.name.nil?
|
|
426
|
+
|
|
391
427
|
# Pre-match: no child element matches this rule.
|
|
392
428
|
# Skip expensive value_for_rule, handle defaults inline.
|
|
393
429
|
# NOTE: do not "read the instance" here (to_value_for) —
|
|
394
430
|
# the compiled getters materialize lazy collections on
|
|
395
431
|
# first read, which tripled parse allocations when tried.
|
|
432
|
+
if writer_populated?(record, instance)
|
|
433
|
+
instance.value_set_for(rule_to)
|
|
434
|
+
next
|
|
435
|
+
end
|
|
396
436
|
if instance.using_default?(rule_to) || rule.render_default
|
|
397
437
|
defaults_used << rule_to
|
|
398
438
|
attr&.default(effective_register) || rule.to_value_for(instance)
|
|
399
439
|
else
|
|
400
|
-
|
|
440
|
+
# The attribute already holds an explicit value --
|
|
441
|
+
# assigned by a matched rule or by a custom writer
|
|
442
|
+
# mid-parse (lutaml-model#922). An unmatched rule
|
|
443
|
+
# must not overwrite it with nil.
|
|
444
|
+
next
|
|
401
445
|
end
|
|
402
446
|
else
|
|
447
|
+
raise "ELSE-BODY" if ENV["CATCHDBG"] && rule.name.nil?
|
|
448
|
+
|
|
403
449
|
# Performance: Pass cached attr to avoid recomputing attribute_for_rule
|
|
404
450
|
val = value_for_rule(session, rule, new_opts, attr)
|
|
405
451
|
|
|
@@ -465,6 +511,11 @@ module Lutaml
|
|
|
465
511
|
def set_instance_ordering(instance, doc, ordered_option,
|
|
466
512
|
mixed_content_option, xml_mapping = nil,
|
|
467
513
|
instance_is_serialize = nil)
|
|
514
|
+
# #909: read-only consumers skip the per-instance order summaries
|
|
515
|
+
# entirely — element_order stays nil and to_xml falls back to
|
|
516
|
+
# mapping-defined order for untracked instances.
|
|
517
|
+
return unless Lutaml::Model::Config.instance.element_order_tracking
|
|
518
|
+
|
|
468
519
|
# dup: XmlElement#order hands back a frozen cache shared with the
|
|
469
520
|
# DOM. The model's copy has to stay mutable so callers can maintain
|
|
470
521
|
# element_order themselves.
|
|
@@ -805,7 +856,9 @@ _effective_register)
|
|
|
805
856
|
Lutaml::Xml::HtmlEntities.decode(value)
|
|
806
857
|
end
|
|
807
858
|
|
|
808
|
-
Record = Struct.new(:attr, :derived, :group_skip, :valid
|
|
859
|
+
Record = Struct.new(:attr, :derived, :group_skip, :valid,
|
|
860
|
+
:unmatched_mode, :replay_writer,
|
|
861
|
+
:unmatched_names, :raw_reader)
|
|
809
862
|
|
|
810
863
|
def compiled_rule_records(model_class, register, grouped_plain_rules)
|
|
811
864
|
return {}.compare_by_identity unless model_class.include?(Lutaml::Model::Serialize)
|
|
@@ -824,12 +877,85 @@ _effective_register)
|
|
|
824
877
|
mapping&.mappings(register)&.each do |rule| # rubocop:disable Style/SafeNavigationChain,Style/SafeNavigation
|
|
825
878
|
attr = attribute_for_rule_static(model_class, rule, register)
|
|
826
879
|
group = grouped_plain_rules[rule.to]
|
|
880
|
+
# #903: for a plain rule, what an unmatched visit writes is
|
|
881
|
+
# known statically. The fast-skip branch hands the attribute's
|
|
882
|
+
# uninitialized sentinel to the pipeline; with no value-map
|
|
883
|
+
# overrides and no transformers every step is identity except
|
|
884
|
+
# apply_value_map, which maps the :omitted key: the default
|
|
885
|
+
# map (:nil) yields nil and the visit writes nil through the
|
|
886
|
+
# setter; an explicit omitted: :omitted map treats it as
|
|
887
|
+
# treat_omitted? == false and the omission gate ends the
|
|
888
|
+
# visit untouched. Both endings replay without the walk:
|
|
889
|
+
# :write_nil (setter write + flag churn) or :drop (nothing).
|
|
890
|
+
# Everything else -- defaults, aliases, custom from:,
|
|
891
|
+
# delegates, when_attribute dispatch, transformers, value
|
|
892
|
+
# maps beyond those two -- keeps its visit.
|
|
893
|
+
unmatched_mode = nil
|
|
894
|
+
replay_writer = nil
|
|
895
|
+
if attr && !attr.derived? &&
|
|
896
|
+
!rule.render_default && !rule.multiple_mappings? &&
|
|
897
|
+
!rule.has_custom_method_for_deserialization? &&
|
|
898
|
+
!rule.delegate &&
|
|
899
|
+
!(rule.when_attribute && !rule.when_attribute.empty?)
|
|
900
|
+
rule_transform = rule.transform
|
|
901
|
+
attr_transform = attr.transform
|
|
902
|
+
no_transforms =
|
|
903
|
+
(rule_transform.nil? ||
|
|
904
|
+
(rule_transform.respond_to?(:empty?) &&
|
|
905
|
+
rule_transform.empty?)) &&
|
|
906
|
+
(attr_transform.nil? ||
|
|
907
|
+
(attr_transform.respond_to?(:empty?) &&
|
|
908
|
+
attr_transform.empty?))
|
|
909
|
+
rule_default = attr.default(register)
|
|
910
|
+
if no_transforms &&
|
|
911
|
+
(rule_default.nil? ||
|
|
912
|
+
rule_default.is_a?(::Lutaml::Model::UninitializedClass)) &&
|
|
913
|
+
rule.class.transform_dispatch(rule, attr) == :assign
|
|
914
|
+
if group && group.size > 1 && attr.collection? &&
|
|
915
|
+
!group.first.equal?(rule)
|
|
916
|
+
# Non-first spelling of a #765 group: the visit is a
|
|
917
|
+
# guarded next (the first member does the merged match).
|
|
918
|
+
unmatched_mode = :drop
|
|
919
|
+
else
|
|
920
|
+
omitted_val = rule.value_map(:from)[:omitted]
|
|
921
|
+
if omitted_val == :nil
|
|
922
|
+
unmatched_mode = :write_nil
|
|
923
|
+
elsif omitted_val == :omitted
|
|
924
|
+
unmatched_mode = :drop
|
|
925
|
+
end
|
|
926
|
+
end
|
|
927
|
+
end
|
|
928
|
+
end
|
|
929
|
+
# A first group member does the merged match for every
|
|
930
|
+
# spelling, so its name-miss test spans the whole group.
|
|
931
|
+
unmatched_names = if group && group.size > 1
|
|
932
|
+
group.map { |r| r.name.to_s }
|
|
933
|
+
else
|
|
934
|
+
[rule.name.to_s]
|
|
935
|
+
end
|
|
936
|
+
if unmatched_mode == :write_nil
|
|
937
|
+
replay_writer =
|
|
938
|
+
rule.class.parsed_assign_writer(model_class, rule.to)
|
|
939
|
+
unmatched_mode = nil unless replay_writer
|
|
940
|
+
end
|
|
827
941
|
records[rule] = Record.new(
|
|
828
942
|
attr,
|
|
829
943
|
attr&.derived? || false,
|
|
830
944
|
!!(group && group.size > 1 && attr&.collection? &&
|
|
831
945
|
!group.first.equal?(rule)),
|
|
832
946
|
!!(attr || rule.custom_methods[:from]),
|
|
947
|
+
unmatched_mode,
|
|
948
|
+
replay_writer,
|
|
949
|
+
unmatched_names,
|
|
950
|
+
# lutaml-model#922: raw-state reader for the attribute,
|
|
951
|
+
# when the compiled init seeds it (plain names -- the same
|
|
952
|
+
# set compile_state_defaults! writes). Delegated rules name
|
|
953
|
+
# an attribute on the delegate object, not on this
|
|
954
|
+
# instance, so they get no raw reader.
|
|
955
|
+
if attr && rule.delegate.nil? &&
|
|
956
|
+
rule.to.to_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
|
|
957
|
+
:"__lutaml_raw_#{rule.to}"
|
|
958
|
+
end,
|
|
833
959
|
)
|
|
834
960
|
end
|
|
835
961
|
entry = Struct.new(:version, :records).new(version, records)
|
|
@@ -1458,6 +1584,36 @@ effective_register = lutaml_register)
|
|
|
1458
1584
|
end
|
|
1459
1585
|
end
|
|
1460
1586
|
|
|
1587
|
+
# map_any_element members: every child element no explicit
|
|
1588
|
+
# element rule claims, in document order; scalar targets take
|
|
1589
|
+
# the captured elements' text — the same normalize step scalar
|
|
1590
|
+
# elements get (the cast pipeline receives strings, not wrappers
|
|
1591
|
+
# whose to_s is their inspect); Serializable targets keep the
|
|
1592
|
+
# wrappers and cast into instances.
|
|
1593
|
+
def any_element_members(doc, mapping, attr, register)
|
|
1594
|
+
claimed = Set.new(
|
|
1595
|
+
mapping.mappings(register)
|
|
1596
|
+
.select { |r| !r.attribute? && r.name }
|
|
1597
|
+
.flat_map { |r| [r.name.to_s] },
|
|
1598
|
+
)
|
|
1599
|
+
unclaimed = doc.element_children.reject do |child|
|
|
1600
|
+
claimed.include?(child.unprefixed_name)
|
|
1601
|
+
end
|
|
1602
|
+
return unclaimed if attr.nil? ||
|
|
1603
|
+
attr_type_is_serializable(attr, register)
|
|
1604
|
+
|
|
1605
|
+
unclaimed.map do |child|
|
|
1606
|
+
child_text = child.nil_element? ? nil : child&.text
|
|
1607
|
+
child_cdata = child&.cdata
|
|
1608
|
+
text = if child_text.is_a?(Array) || child_cdata.is_a?(Array)
|
|
1609
|
+
nil
|
|
1610
|
+
else
|
|
1611
|
+
child_text&.+ child_cdata
|
|
1612
|
+
end
|
|
1613
|
+
text.nil? ? child : text
|
|
1614
|
+
end
|
|
1615
|
+
end
|
|
1616
|
+
|
|
1461
1617
|
def attr_type_is_serializable(attr, effective_register)
|
|
1462
1618
|
attr&.serializable_type?(effective_register) || false
|
|
1463
1619
|
end
|
|
@@ -1509,6 +1665,73 @@ effective_register = lutaml_register)
|
|
|
1509
1665
|
# @param child_names_set [Set] Set of child namespaced_name and unprefixed_name
|
|
1510
1666
|
# @param default_namespace [String, nil] the default namespace
|
|
1511
1667
|
# @return [Boolean] true if a matching child likely exists
|
|
1668
|
+
# #903: for an unmatched named element rule whose record classed
|
|
1669
|
+
# it, replay what the visit would write without the walk: :drop
|
|
1670
|
+
# (the visit ended at the omission gate or was a guarded next)
|
|
1671
|
+
# writes nothing; :write_nil replays the nil the default value
|
|
1672
|
+
# map produces, plus the flag churn. Returns true when handled.
|
|
1673
|
+
# lutaml-model#922: whether the attribute holds anything other
|
|
1674
|
+
# than its initialized marker (uninitialized sentinel / lazy
|
|
1675
|
+
# collection) — i.e. a custom writer populated it mid-parse.
|
|
1676
|
+
def writer_populated?(record, instance)
|
|
1677
|
+
return false unless record&.raw_reader
|
|
1678
|
+
|
|
1679
|
+
!raw_initialized?(record, instance)
|
|
1680
|
+
end
|
|
1681
|
+
|
|
1682
|
+
# True when the attribute still holds the value the compiled init
|
|
1683
|
+
# seeded (uninitialized sentinel / lazy collection) — i.e. nothing
|
|
1684
|
+
# has written it since.
|
|
1685
|
+
def raw_initialized?(record, instance)
|
|
1686
|
+
raw = instance.public_send(record.raw_reader)
|
|
1687
|
+
raw.equal?(::Lutaml::Model::UninitializedClass.instance) ||
|
|
1688
|
+
raw.equal?(::Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION)
|
|
1689
|
+
end
|
|
1690
|
+
|
|
1691
|
+
def replay_unmatched_rule(child_names_set, record, rule, instance,
|
|
1692
|
+
defaults_used, xml_mapping)
|
|
1693
|
+
return false unless child_names_set && record
|
|
1694
|
+
|
|
1695
|
+
mode = record.unmatched_mode
|
|
1696
|
+
return false unless mode
|
|
1697
|
+
return false if rule.attribute? || rule.content_mapping? ||
|
|
1698
|
+
rule.raw_mapping? || !rule.name ||
|
|
1699
|
+
xml_mapping.any_element_rule.equal?(rule)
|
|
1700
|
+
|
|
1701
|
+
matched = record.unmatched_names.any? do |n|
|
|
1702
|
+
child_names_set.include?(n)
|
|
1703
|
+
end
|
|
1704
|
+
return false if matched
|
|
1705
|
+
|
|
1706
|
+
return true if mode == :drop
|
|
1707
|
+
|
|
1708
|
+
# The visited path picks the default branch only while the
|
|
1709
|
+
# attribute still reads as default-valued; once another rule
|
|
1710
|
+
# or a custom writer set it mid-parse, the omission gate ends
|
|
1711
|
+
# the visit with no write. Mirror that: replay writes only
|
|
1712
|
+
# while unset, and drops otherwise (a custom writer may have
|
|
1713
|
+
# populated the attribute — relaton's ICS Isoics-fallback
|
|
1714
|
+
# pattern).
|
|
1715
|
+
rule_to = rule.to
|
|
1716
|
+
unless instance.using_default?(rule_to)
|
|
1717
|
+
return true
|
|
1718
|
+
end
|
|
1719
|
+
|
|
1720
|
+
# lutaml-model#922: a custom writer may have populated the
|
|
1721
|
+
# attribute mid-parse without going through the tracker (the
|
|
1722
|
+
# compiled setters are direct ivar writes), so the tracked
|
|
1723
|
+
# flag above can lie. The raw state is the truth: still at its
|
|
1724
|
+
# initialized marker (sentinel / lazy collection) means unset
|
|
1725
|
+
# -- the visited path wrote the default there; anything else
|
|
1726
|
+
# is a writer assignment this replay must not clobber.
|
|
1727
|
+
return true unless raw_initialized?(record, instance)
|
|
1728
|
+
|
|
1729
|
+
defaults_used << rule_to
|
|
1730
|
+
instance.public_send(record.replay_writer, nil)
|
|
1731
|
+
instance.value_set_for(rule_to)
|
|
1732
|
+
true
|
|
1733
|
+
end
|
|
1734
|
+
|
|
1512
1735
|
def child_matches_rule?(rule, child_names_set, default_namespace)
|
|
1513
1736
|
rule_names = rule.namespaced_names(default_namespace)
|
|
1514
1737
|
# Array#intersect? requires an Array argument; child_names_set may
|
data/lib/lutaml/xml/namespace.rb
CHANGED
|
@@ -58,7 +58,10 @@ module Lutaml
|
|
|
58
58
|
# @param value [String, nil] the namespace URI
|
|
59
59
|
# @return [String, nil] the namespace URI
|
|
60
60
|
def uri(value = nil)
|
|
61
|
-
|
|
61
|
+
if value
|
|
62
|
+
@uri_value = value
|
|
63
|
+
@to_key = nil
|
|
64
|
+
end
|
|
62
65
|
@uri_value
|
|
63
66
|
end
|
|
64
67
|
|
|
@@ -130,6 +133,7 @@ module Lutaml
|
|
|
130
133
|
def prefix_default(value = nil)
|
|
131
134
|
if value
|
|
132
135
|
@prefix_default_value = value.to_s
|
|
136
|
+
@to_key = nil
|
|
133
137
|
end
|
|
134
138
|
@prefix_default_value
|
|
135
139
|
end
|
|
@@ -286,13 +290,19 @@ module Lutaml
|
|
|
286
290
|
#
|
|
287
291
|
# @api private
|
|
288
292
|
def to_key
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
293
|
+
# Called per element on the serialize path (90k births per
|
|
294
|
+
# 200-paragraph to_xml); memoized since uri/prefix_default
|
|
295
|
+
# are set at class-definition time. The setters above
|
|
296
|
+
# invalidate on reconfiguration.
|
|
297
|
+
@to_key ||= begin
|
|
298
|
+
prefix = prefix_default
|
|
299
|
+
namespace_uri = uri
|
|
300
|
+
|
|
301
|
+
if prefix && !prefix.empty?
|
|
302
|
+
"#{prefix}:#{namespace_uri}"
|
|
303
|
+
else
|
|
304
|
+
":#{namespace_uri}"
|
|
305
|
+
end
|
|
296
306
|
end
|
|
297
307
|
end
|
|
298
308
|
|
|
@@ -409,9 +409,25 @@ module Lutaml
|
|
|
409
409
|
# Handle collections and arrays
|
|
410
410
|
if child_instance.is_a?(Array) || child_instance.is_a?(Lutaml::Model::Collection)
|
|
411
411
|
instances = child_instance.is_a?(Lutaml::Model::Collection) ? child_instance.collection : child_instance
|
|
412
|
-
# Collect needs from
|
|
412
|
+
# Collect needs from the FIRST instance of each distinct
|
|
413
|
+
# class and merge once — instances of one class share the
|
|
414
|
+
# mapping graph, so re-collecting every member re-walks
|
|
415
|
+
# identical structure (row-heavy models spent most of
|
|
416
|
+
# their serialize budget here). Value-dependent divergences
|
|
417
|
+
# in later members surface as unspellable forms on the
|
|
418
|
+
# serializer and fall back interpretively, never as wrong
|
|
419
|
+
# output. Merge-once is equivalent to merge-N: every read
|
|
420
|
+
# path of the merged needs is any?/set-based.
|
|
413
421
|
merged_needs = NamespaceNeeds.new
|
|
422
|
+
seen_classes = nil
|
|
414
423
|
instances.each do |item|
|
|
424
|
+
item_class = item.class
|
|
425
|
+
if seen_classes
|
|
426
|
+
next if seen_classes[item_class]
|
|
427
|
+
else
|
|
428
|
+
seen_classes = {}
|
|
429
|
+
end
|
|
430
|
+
seen_classes[item_class] = true
|
|
415
431
|
item_ns_prefix = if item.is_a?(::Lutaml::Model::Serialize)
|
|
416
432
|
item.xml_namespace_prefix
|
|
417
433
|
end
|