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,650 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Xml
|
|
5
|
+
module Leptris
|
|
6
|
+
# Phase 5 slice: compile a model's XML mapping into a
|
|
7
|
+
# Leptris::XML::Descriptor plan and materialize whole documents in
|
|
8
|
+
# one native pass (leptris_plan_walk) — no moxml wrapper tree, no
|
|
9
|
+
# per-element Ruby dispatch. Measured on the 200-item probe:
|
|
10
|
+
# walk+hydrate 8.7x faster, 82% fewer allocations than the
|
|
11
|
+
# interpretive path, hydration-equal output.
|
|
12
|
+
#
|
|
13
|
+
# A model compiles when EVERY rule is plan-shaped; richer rows
|
|
14
|
+
# defer their subtree verbatim and interpret post-walk rather
|
|
15
|
+
# than opting the whole model out:
|
|
16
|
+
# - map_attribute / map_element / content / raw rows
|
|
17
|
+
# - Value-scalar types or nested Serializables
|
|
18
|
+
# - custom methods, polymorphism, unions, and ordered/mixed
|
|
19
|
+
# children capture :raw and interpret post-walk
|
|
20
|
+
# - ordered/mixed root mappings compile; the entry points
|
|
21
|
+
# reconstruct element_order from the node surface
|
|
22
|
+
# - attributes not derived/union/polymorphic
|
|
23
|
+
#
|
|
24
|
+
# The fast path is opt-in (Config.xml_plan_fast_path) while the
|
|
25
|
+
# full semantics audit (parent links, consolidation, ordering
|
|
26
|
+
# metadata) completes.
|
|
27
|
+
module PlanCompiler
|
|
28
|
+
# TODO.perf/15: env-gated histogram of why models opt out of the
|
|
29
|
+
# plan path. Set PLAN_COMPILE_STATS=1 and read
|
|
30
|
+
# PlanCompiler.plan_stats after a parse. Zero cost when disabled.
|
|
31
|
+
def self.plan_stats
|
|
32
|
+
@plan_stats ||= Hash.new(0)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.opt_out!(clause)
|
|
36
|
+
@plan_stats ||= Hash.new(0)
|
|
37
|
+
@plan_stats[clause] += 1 if ENV["PLAN_COMPILE_STATS"]
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Isolated holder: suites freeze model classes; a cache on a
|
|
42
|
+
# frozen constant would be immutable (TypeProbeCache precedent).
|
|
43
|
+
PLAN_CACHE = ::Class.new do
|
|
44
|
+
class << self
|
|
45
|
+
def cache
|
|
46
|
+
@cache ||= {}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end.cache
|
|
50
|
+
|
|
51
|
+
# Name-matched element row kinds eligible for the :unqualified
|
|
52
|
+
# ns form (leptris#1560). Text rows (content) match no element
|
|
53
|
+
# name and wildcard rows carry their own :any.
|
|
54
|
+
ROW_KINDS_NS_UNQUALIFIED = %i[scalar collection nested callback
|
|
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
|
|
60
|
+
|
|
61
|
+
class << self
|
|
62
|
+
def compile(model_class, register)
|
|
63
|
+
# Version gate: below MIN_LEPTRIS_VERSION (the single
|
|
64
|
+
# capability floor) gems take the interpretive path via
|
|
65
|
+
# the nil opt-out.
|
|
66
|
+
return nil unless Leptris.plan_path_compatible?
|
|
67
|
+
|
|
68
|
+
# A child model's declared lutaml_default_register takes
|
|
69
|
+
# precedence over the ambient (parent) register — the same
|
|
70
|
+
# contract the interpretive path applies through
|
|
71
|
+
# Register.resolve_for_child. Without this, plan compilation
|
|
72
|
+
# resolves the child's symbol attribute types in the parent
|
|
73
|
+
# context and raises UnknownTypeError for ids registered only
|
|
74
|
+
# in the child's own register (#876).
|
|
75
|
+
register = Lutaml::Model::Register.resolve_for_child(
|
|
76
|
+
model_class, register
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
key = [model_class, register]
|
|
80
|
+
return PLAN_CACHE[key] if PLAN_CACHE.key?(key)
|
|
81
|
+
|
|
82
|
+
# Cycle guard: a self-referential model (JATS sec-in-sec) must
|
|
83
|
+
# resolve to nil — the interpretive pipeline owns it. The guard
|
|
84
|
+
# is THREAD-LOCAL (a shared in-progress set races: a concurrent
|
|
85
|
+
# same-key compile would cache false permanently — the #828
|
|
86
|
+
# lesson); the nil at the cycle point is NOT cached — the
|
|
87
|
+
# outermost build completes and caches the real verdict. The
|
|
88
|
+
# :cycle stat makes the guard visible in plan_stats — without
|
|
89
|
+
# it a cyclic graph (uniword pict↔shape, sdt→sdt) reads as
|
|
90
|
+
# "nil with an empty histogram", indistinguishable from a
|
|
91
|
+
# silent build failure.
|
|
92
|
+
stack = (Thread.current[:plan_compiler_stack] ||= [])
|
|
93
|
+
return opt_out!(:cycle) if stack.include?(key)
|
|
94
|
+
|
|
95
|
+
stack.push(key)
|
|
96
|
+
begin
|
|
97
|
+
PLAN_CACHE[key] = build(model_class, register)
|
|
98
|
+
ensure
|
|
99
|
+
stack.pop
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def build(model_class, register)
|
|
106
|
+
return nil unless model_class.is_a?(Class) &&
|
|
107
|
+
model_class.include?(::Lutaml::Model::Serialize)
|
|
108
|
+
|
|
109
|
+
mapping = model_class.mappings_for(:xml, register)
|
|
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?
|
|
125
|
+
|
|
126
|
+
rows = []
|
|
127
|
+
attr_rows = [] # [[rule, attr, row_name]] hydration metadata
|
|
128
|
+
plan_attrs = [] # [{name:, kind:}] rows for the engine plan
|
|
129
|
+
compiled = [] # [rule, attr, kind, spelling, delegate_target]
|
|
130
|
+
ns_attr_names = [] # exact-URI attribute rows (ambiguity guard)
|
|
131
|
+
# Local-name claim tallies for attribute rows: a name claimed
|
|
132
|
+
# by both an exact-URI row and a plain (any-namespace) row
|
|
133
|
+
# cannot be dispatched by the walk — the ns form :none
|
|
134
|
+
# matches any qualification, so the plain row captures the
|
|
135
|
+
# qualified spelling too (#758). The interpretive matcher
|
|
136
|
+
# owns shared names.
|
|
137
|
+
attr_local_claims = Hash.new { |h, k| h[k] = { ns: 0, plain: 0 } }
|
|
138
|
+
cdata = false
|
|
139
|
+
mixed_content = false
|
|
140
|
+
needs_nodes = false
|
|
141
|
+
collection_defaults = [] # collection attrs with element rows
|
|
142
|
+
tag = 100 # type_tag echo space for callback-routed rows
|
|
143
|
+
model_ns = plan_namespace(model_class, mapping, register)
|
|
144
|
+
|
|
145
|
+
# Collection rows are NATIVE since leptris 1.9.178 —
|
|
146
|
+
# collection values echo name and type_tag (the #220 gap is
|
|
147
|
+
# closed), so any number routes without callbacks.
|
|
148
|
+
true
|
|
149
|
+
|
|
150
|
+
mapping.mappings(register).each do |rule|
|
|
151
|
+
delegate_target = nil
|
|
152
|
+
attr = if rule.delegate
|
|
153
|
+
delegate_target = model_class.attributes(register)[rule.delegate]
|
|
154
|
+
t = delegate_target&.type(register)
|
|
155
|
+
if t.is_a?(Class) && t.include?(::Lutaml::Model::Serialize)
|
|
156
|
+
t.attributes(register)[rule.to]
|
|
157
|
+
end
|
|
158
|
+
else
|
|
159
|
+
model_class.attributes(register)[rule.to]
|
|
160
|
+
end
|
|
161
|
+
return opt_out!(:attr_nil) if attr.nil?
|
|
162
|
+
return opt_out!(:derived) if attr.derived?
|
|
163
|
+
|
|
164
|
+
# Partition rows (TODO 34 step 2) ride native predicates
|
|
165
|
+
# only in the plain-capture shape; any other when_attribute
|
|
166
|
+
# shape stays interpretive (the suite pins nested-target
|
|
167
|
+
# partitions to the interpretive path).
|
|
168
|
+
unless rule.when_attribute.empty?
|
|
169
|
+
t = attr.type(register)
|
|
170
|
+
plain_partition = !rule.delegate &&
|
|
171
|
+
!rule.has_custom_method_for_deserialization? &&
|
|
172
|
+
!rule.polymorphic_mapping? &&
|
|
173
|
+
!attr.polymorphic? && !attr.union? &&
|
|
174
|
+
!(t.is_a?(Class) &&
|
|
175
|
+
t.include?(::Lutaml::Model::Serialize))
|
|
176
|
+
return opt_out!(:non_plain_partition) unless plain_partition
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Interpretive hydration materializes every mapped
|
|
180
|
+
# collection, present or not; the fast path mirrors with
|
|
181
|
+
# constructor-time empty arrays.
|
|
182
|
+
unless rule.attribute? || !attr.collection?
|
|
183
|
+
collection_defaults << attr.name.to_sym
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Fragment deferrals lose ancestor namespace context —
|
|
187
|
+
# ns-qualified models keep those rules interpretive.
|
|
188
|
+
fragment_needed = rule.has_custom_method_for_deserialization? ||
|
|
189
|
+
rule.polymorphic_mapping? || attr.polymorphic? || attr.union?
|
|
190
|
+
return opt_out!(:fragment_with_ns) if fragment_needed && model_ns
|
|
191
|
+
|
|
192
|
+
if rule.attribute?
|
|
193
|
+
opt = compile_attribute_row(rule, attr, register, mapping,
|
|
194
|
+
attr_rows, plan_attrs,
|
|
195
|
+
ns_attr_names, attr_local_claims)
|
|
196
|
+
return opt_out!(opt) if opt
|
|
197
|
+
elsif rule.content_mapping?
|
|
198
|
+
return opt_out!(:multi_content) if content_rows(rows) >= 1
|
|
199
|
+
|
|
200
|
+
mixed_content = true
|
|
201
|
+
compiled << [rule, attr,
|
|
202
|
+
attr.collection? ? :content : :content_deferred,
|
|
203
|
+
nil, delegate_target]
|
|
204
|
+
rows << { name: "__content__#{compiled.size}", kind: :content }
|
|
205
|
+
elsif rule.raw_mapping? || rule.raw == :element
|
|
206
|
+
compiled << [rule, attr, :raw, nil, delegate_target]
|
|
207
|
+
rows << { name: rule.name.to_s, kind: :raw }
|
|
208
|
+
elsif rule.has_custom_method_for_deserialization?
|
|
209
|
+
needs_nodes = true
|
|
210
|
+
compiled << [rule, attr, :custom_method, nil, delegate_target]
|
|
211
|
+
rows << { name: rule.name.to_s, kind: :raw }
|
|
212
|
+
elsif rule.polymorphic_mapping? || attr.polymorphic? || attr.union?
|
|
213
|
+
type = attr.type(register)
|
|
214
|
+
return opt_out!(:non_serializable_poly) unless serializable_type?(type) || attr.union?
|
|
215
|
+
|
|
216
|
+
needs_nodes = true
|
|
217
|
+
compiled << [rule, attr, :polymorphic, nil, delegate_target]
|
|
218
|
+
rows << { name: rule.name.to_s, kind: :raw }
|
|
219
|
+
else
|
|
220
|
+
type = attr.type(register)
|
|
221
|
+
if serializable_type?(type)
|
|
222
|
+
child = compile(type, register)
|
|
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
|
|
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
|
|
243
|
+
elsif rule.multiple_mappings?
|
|
244
|
+
rule.name.each do |spelling|
|
|
245
|
+
compiled << [rule, attr, :spelling, spelling.to_s,
|
|
246
|
+
delegate_target]
|
|
247
|
+
rows << { name: spelling.to_s, kind: :callback,
|
|
248
|
+
type_tag: (tag += 1) }
|
|
249
|
+
end
|
|
250
|
+
else
|
|
251
|
+
return opt_out!(:non_scalar) unless scalar_type?(attr, register)
|
|
252
|
+
|
|
253
|
+
row = { name: rule.name.to_s }
|
|
254
|
+
if rule.namespace_set?
|
|
255
|
+
row[:ns] = child_ns(rule, model_ns)
|
|
256
|
+
else
|
|
257
|
+
# The interpretive writer qualifies an element row
|
|
258
|
+
# by its attribute's type namespace when the rule
|
|
259
|
+
# declares none (resolve step 4). Rows without the
|
|
260
|
+
# type form inherit the tree namespace and land
|
|
261
|
+
# under the wrong default xmlns (uniword
|
|
262
|
+
# CoreProperties: <dc:title> spelled <title>).
|
|
263
|
+
type_ns_form = element_type_ns_form(rule, attr,
|
|
264
|
+
register, model_ns)
|
|
265
|
+
return opt_out!(:elem_type_ns_symbol) if type_ns_form == :bail_symbol
|
|
266
|
+
return opt_out!(:elem_type_ns_aliases) if type_ns_form == :bail_aliases
|
|
267
|
+
|
|
268
|
+
row[:ns] = type_ns_form if type_ns_form
|
|
269
|
+
end
|
|
270
|
+
# lutaml-model#88: same-name rows partitioned by the
|
|
271
|
+
# rule's discriminator ride the engine's exclusive
|
|
272
|
+
# predicate match (leptris 1.9.221+, #1272).
|
|
273
|
+
row[:when] = rule.when_attribute unless rule.when_attribute.empty?
|
|
274
|
+
|
|
275
|
+
if attr.collection?
|
|
276
|
+
compiled << [rule, attr, :collection_native, nil,
|
|
277
|
+
delegate_target]
|
|
278
|
+
rows << row.merge(kind: :collection)
|
|
279
|
+
else
|
|
280
|
+
cdata ||= rule.cdata
|
|
281
|
+
compiled << [rule, attr, :scalar, nil, delegate_target]
|
|
282
|
+
rows << row.merge(kind: :scalar)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
row_tags = partition_row_tags!(compiled, rows, tag)
|
|
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
|
+
|
|
303
|
+
# The plan serializer does not emit namespace declarations —
|
|
304
|
+
# namespaced models serialize through the interpretive
|
|
305
|
+
# writer (lutaml-model#847: standalone to_xml under leptris
|
|
306
|
+
# dropped the element xmlns entirely).
|
|
307
|
+
# Exact-URI attribute rows include their namespaced spelling
|
|
308
|
+
# here: the plan serializer writes attributes by local name
|
|
309
|
+
# (no prefixed wire spelling, no declaration emission —
|
|
310
|
+
# leptris#1551), so these models serialize interpretively.
|
|
311
|
+
namespaced = !model_ns.nil? || rows.any? { |r| r[:ns] } ||
|
|
312
|
+
plan_attrs.any? { |a| a[:ns] }
|
|
313
|
+
|
|
314
|
+
flags = []
|
|
315
|
+
flags << :cdata if cdata
|
|
316
|
+
flags << :mixed_content if mixed_content
|
|
317
|
+
# Unconditional: a namespace-less model's children match by
|
|
318
|
+
# local name even when the document declares a default or
|
|
319
|
+
# prefixed namespace (#932) — the interpretive matcher's
|
|
320
|
+
# unprefixed-any-URI behavior; elements (unlike attributes)
|
|
321
|
+
# inherit the default xmlns, so the strict no-URI default
|
|
322
|
+
# dropped every child of a namespaced document.
|
|
323
|
+
flags << :ns_lenient
|
|
324
|
+
|
|
325
|
+
# Namespace-less model child rows bind the strict unwritten
|
|
326
|
+
# spelling (bare, or inherited default xmlns) — the
|
|
327
|
+
# interpretive matcher's exact no-namespace semantic,
|
|
328
|
+
# refusing prefixed spellings where the ns_lenient
|
|
329
|
+
# superset bound them. Rows with an explicit form (rule ns,
|
|
330
|
+
# type ns, :none, :any) are untouched; name-matched element
|
|
331
|
+
# row kinds only.
|
|
332
|
+
if model_ns.nil?
|
|
333
|
+
rows.each do |r|
|
|
334
|
+
next if r[:ns]
|
|
335
|
+
|
|
336
|
+
r[:ns] = :unqualified if ROW_KINDS_NS_UNQUALIFIED.include?(r[:kind])
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
tree = { name: mapping.root_element.to_s,
|
|
341
|
+
attributes: plan_attrs, children: rows }
|
|
342
|
+
tree[:ns] = model_ns if model_ns
|
|
343
|
+
tree[:flags] = flags unless flags.empty?
|
|
344
|
+
begin
|
|
345
|
+
# Lazy: the Opal boot loads this file, and leptris is a
|
|
346
|
+
# native gem there — the require only belongs on the
|
|
347
|
+
# engines-enabled path.
|
|
348
|
+
require "leptris/xml/descriptor"
|
|
349
|
+
descriptor = ::Leptris::XML::Descriptor.build(**tree)
|
|
350
|
+
rescue StandardError
|
|
351
|
+
return nil
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
return opt_out!(:attr_ns_claim_conflict) if attr_local_claims
|
|
355
|
+
.any? { |_, c| c[:ns].positive? && c[:plain].positive? }
|
|
356
|
+
|
|
357
|
+
# Exact-URI attribute rows rely on the sole-claimant lenient
|
|
358
|
+
# recovery for out-of-namespace spellings (the interpretive
|
|
359
|
+
# exact-first-then-any-qualification precedence), and that
|
|
360
|
+
# recovery reads the source node — models carrying such rows
|
|
361
|
+
# must receive one through the buckets chain.
|
|
362
|
+
needs_nodes = true unless ns_attr_names.empty?
|
|
363
|
+
|
|
364
|
+
# The catch-all row: kind :wildcard with an explicit :any ns
|
|
365
|
+
# form (pad0) — the two-pass walk routes every child no
|
|
366
|
+
# named row claimed here, tagged for hydrator routing.
|
|
367
|
+
# Document order recovery and bridging happen in the
|
|
368
|
+
# hydrator against the source node.
|
|
369
|
+
if (any_rule = mapping.any_element_rule)
|
|
370
|
+
any_attr = model_class.attributes(register)[any_rule.to]
|
|
371
|
+
return opt_out!(:attr_nil) if any_attr.nil?
|
|
372
|
+
|
|
373
|
+
needs_nodes = true
|
|
374
|
+
wildcard_tag = tag += 1
|
|
375
|
+
compiled << [any_rule, any_attr, :wildcard_any, nil, nil]
|
|
376
|
+
rows << { name: "__lutaml_any__", kind: :wildcard, ns: :any,
|
|
377
|
+
type_tag: wildcard_tag }
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
{ descriptor: descriptor, tree: tree, rows: compiled,
|
|
381
|
+
attr_rows: attr_rows, mapping: mapping,
|
|
382
|
+
row_tags: row_tags, namespaced: namespaced,
|
|
383
|
+
ordered: mapping.ordered? || mapping.mixed_content?,
|
|
384
|
+
needs_nodes: needs_nodes,
|
|
385
|
+
collection_defaults: collection_defaults,
|
|
386
|
+
contested_names: contested.empty? ? nil : contested,
|
|
387
|
+
ns_attr_names: ns_attr_names.empty? ? nil : ns_attr_names,
|
|
388
|
+
wildcard_tag: (wildcard_tag if defined?(wildcard_tag)) }
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Attribute rows: identity is (URI, local) (lutaml-model#744).
|
|
392
|
+
# A type-level namespace compiles into an exact-URI AttrPlan ns
|
|
393
|
+
# row (libleptris 1.9.289): the walk captures the (URI, local)
|
|
394
|
+
# match and the sole-claimant lenient recovery in PlanHydrator
|
|
395
|
+
# keeps the interpretive exact-first-then-any-qualification
|
|
396
|
+
# precedence for out-of-namespace spellings. Returns the
|
|
397
|
+
# opt-out clause when the row must stay interpretive, nil
|
|
398
|
+
# otherwise. Types declaring alias URI families stay
|
|
399
|
+
# interpretive (a plan row holds a single exact URI and
|
|
400
|
+
# non-sole-claimant alias families have no plan-side
|
|
401
|
+
# resolution order); :blank / :inherit arrive as Symbols —
|
|
402
|
+
# no URI to express in a row.
|
|
403
|
+
def compile_attribute_row(rule, attr, register, mapping,
|
|
404
|
+
attr_rows, plan_attrs, ns_attr_names,
|
|
405
|
+
attr_local_claims)
|
|
406
|
+
return :non_scalar unless scalar_type?(attr, register)
|
|
407
|
+
|
|
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
|
|
419
|
+
type_ns = attr.type_namespace_class(register)
|
|
420
|
+
raw_name
|
|
421
|
+
if type_ns.nil?
|
|
422
|
+
# attribute_form_default :qualified on the model's
|
|
423
|
+
# namespace qualifies locally-declared attributes under
|
|
424
|
+
# the parent URI — the interpretive
|
|
425
|
+
# resolve_attribute_namespace contract (WML: updateFields
|
|
426
|
+
# w:val under xmlns="..."). The W3C default is
|
|
427
|
+
# :unqualified, so == :qualified means explicitly set.
|
|
428
|
+
# Rules with an explicit :blank stay unprefixed.
|
|
429
|
+
ns_class = mapping.namespace_class
|
|
430
|
+
if rule.namespace_param != :blank && ns_class &&
|
|
431
|
+
ns_class.attribute_form_default == :qualified
|
|
432
|
+
row_name = local_attr_name(raw_name)
|
|
433
|
+
attr_local_claims[row_name][:ns] += 1
|
|
434
|
+
plan_attrs << { name: row_name,
|
|
435
|
+
ns: { exact: ns_class.uri.to_s,
|
|
436
|
+
prefix: ns_class.prefix_default&.to_s } }
|
|
437
|
+
ns_attr_names << row_name
|
|
438
|
+
attr_rows << [rule, attr, row_name]
|
|
439
|
+
return nil
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
attr_local_claims[raw_name][:plain] += 1
|
|
443
|
+
plan_attrs << { name: raw_name }
|
|
444
|
+
attr_rows << [rule, attr, raw_name]
|
|
445
|
+
return nil
|
|
446
|
+
end
|
|
447
|
+
return :attr_type_ns_symbol if type_ns.is_a?(Symbol)
|
|
448
|
+
return :attr_type_ns_aliases unless type_ns.all_uris.size == 1
|
|
449
|
+
|
|
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,
|
|
460
|
+
ns: { exact: type_ns.uri.to_s,
|
|
461
|
+
prefix: type_ns.prefix_default&.to_s } }
|
|
462
|
+
ns_attr_names << row_name
|
|
463
|
+
attr_rows << [rule, attr, row_name]
|
|
464
|
+
nil
|
|
465
|
+
end
|
|
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
|
+
|
|
531
|
+
def compilable_mapping?(mapping)
|
|
532
|
+
# Mapping's uniform interface: root_mappings defaults to
|
|
533
|
+
# false on the base (KeyValue overrides with its own).
|
|
534
|
+
mapping.root_element && !mapping.root_mappings?
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Partition bookkeeping for when_attribute rows (#88, TODO
|
|
538
|
+
# 34 step 2): the engine matches same-name rows exclusively —
|
|
539
|
+
# first matching row wins — so predicate rows must PRECEDE any
|
|
540
|
+
# plain sibling on the same name (a plain-first order
|
|
541
|
+
# double-captures: the plain row takes everything and the
|
|
542
|
+
# predicates still claim their matches). Every row of a
|
|
543
|
+
# partitioned name gets a distinct type_tag; plan values echo
|
|
544
|
+
# it back, giving the hydrator row-exact routing with no
|
|
545
|
+
# per-occurrence re-derivation. Returns {compiled_index =>
|
|
546
|
+
# tag} (nil when the model has no partitions). compiled and
|
|
547
|
+
# rows are parallel and stay in lockstep through the reorder.
|
|
548
|
+
def partition_row_tags!(compiled, rows, tag)
|
|
549
|
+
by_name = rows.each_with_index
|
|
550
|
+
.group_by { |(row, _)| row[:name] }
|
|
551
|
+
.select { |_name, pairs| pairs.any? { |(row, _)| row[:when] } }
|
|
552
|
+
return nil if by_name.empty?
|
|
553
|
+
|
|
554
|
+
permutation = by_name.each_value.flat_map do |pairs|
|
|
555
|
+
pairs.sort_by { |(row, i)| [row[:when] ? 0 : 1, i] }
|
|
556
|
+
.map(&:last)
|
|
557
|
+
end
|
|
558
|
+
remaining = (0...rows.length).to_a - permutation
|
|
559
|
+
permutation.concat(remaining)
|
|
560
|
+
|
|
561
|
+
row_tags = {}
|
|
562
|
+
permutation.each_with_index do |old_index, new_index|
|
|
563
|
+
row = rows[old_index]
|
|
564
|
+
next unless by_name.key?(row[:name])
|
|
565
|
+
|
|
566
|
+
tag += 1
|
|
567
|
+
row[:type_tag] = tag
|
|
568
|
+
row_tags[new_index] = tag
|
|
569
|
+
end
|
|
570
|
+
rows.replace(permutation.map { |i| rows[i] })
|
|
571
|
+
compiled.replace(permutation.map { |i| compiled[i] })
|
|
572
|
+
row_tags
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Attribute for a rule — delegate rules resolve against their
|
|
576
|
+
# target model's attributes.
|
|
577
|
+
def attr_of(model_class, rule, register)
|
|
578
|
+
attr = model_class.attributes(register)[rule.to]
|
|
579
|
+
return attr if attr || !rule.delegate
|
|
580
|
+
|
|
581
|
+
target = model_class.attributes(register)[rule.delegate]
|
|
582
|
+
t = target&.type(register)
|
|
583
|
+
if t.is_a?(Class) && t.include?(::Lutaml::Model::Serialize)
|
|
584
|
+
t.attributes(register)[rule.to]
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Rule-level namespace → ChildPlan ns form (leptris 1.9.178):
|
|
589
|
+
# the child binds by local name under the rule's URI with any
|
|
590
|
+
# prefix. Blank-namespace rules (xmlns="") match :none.
|
|
591
|
+
def child_ns(rule, _model_ns)
|
|
592
|
+
uri = rule.namespace
|
|
593
|
+
return :none if uri.nil? || uri.to_s.empty?
|
|
594
|
+
|
|
595
|
+
{ exact: uri.to_s, prefix: rule.prefix&.to_s }
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
# Type-namespace ns form for a scalar element row, mirroring
|
|
599
|
+
# the interpretive resolve_element_namespace step 4: the
|
|
600
|
+
# attribute's type namespace qualifies the row when the rule
|
|
601
|
+
# declares none. Rows inheriting the tree namespace (type ns
|
|
602
|
+
# nil, :inherit, or equal to the model's own URI) get no form.
|
|
603
|
+
# :blank and alias families have no row-expressible form —
|
|
604
|
+
# the model stays interpretive.
|
|
605
|
+
def element_type_ns_form(rule, attr, register, model_ns)
|
|
606
|
+
return nil if rule.namespace_set?
|
|
607
|
+
|
|
608
|
+
type_ns = attr.type_namespace_class(register)
|
|
609
|
+
return nil if type_ns.nil? || type_ns == :inherit
|
|
610
|
+
return :bail_symbol if type_ns.is_a?(Symbol)
|
|
611
|
+
return :bail_aliases unless type_ns.all_uris.size == 1
|
|
612
|
+
|
|
613
|
+
form = { exact: type_ns.uri.to_s,
|
|
614
|
+
prefix: type_ns.prefix_default&.to_s }
|
|
615
|
+
return nil if form[:exact] == model_ns&.[](:exact)
|
|
616
|
+
|
|
617
|
+
form
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
def content_rows(rows)
|
|
621
|
+
rows.count { |r| r[:kind] == :content }
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# Model-level namespace: exact URI match with lenient prefixes
|
|
625
|
+
# — children bind by local name under any prefix the document
|
|
626
|
+
# bound to the URI (#754 adoption semantics on the engine).
|
|
627
|
+
def plan_namespace(_model_class, mapping, _register)
|
|
628
|
+
# Mapping's uniform interface: namespace_class defaults
|
|
629
|
+
# to nil on the base (Xml overrides).
|
|
630
|
+
ns_class = mapping.namespace_class
|
|
631
|
+
return nil unless ns_class&.uri
|
|
632
|
+
|
|
633
|
+
{ exact: ns_class.uri.to_s,
|
|
634
|
+
prefix: ns_class.prefix_default&.to_s }
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def scalar_type?(attr, register)
|
|
638
|
+
type = attr.type(register)
|
|
639
|
+
type.is_a?(Class) && type < ::Lutaml::Model::Type::Value &&
|
|
640
|
+
!attr.custom_collection?
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def serializable_type?(type)
|
|
644
|
+
type.is_a?(Class) && type.include?(::Lutaml::Model::Serialize)
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
end
|