lutaml-model 0.8.33 → 0.8.34
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/.rubocop.yml +5 -0
- data/Gemfile +4 -0
- data/README.adoc +39 -0
- data/docs/conversion_caching.adoc +143 -0
- data/lib/compat/opal/lutaml_model_boot.rb +15 -0
- data/lib/lutaml/model/attribute.rb +89 -0
- data/lib/lutaml/model/config.rb +13 -0
- data/lib/lutaml/model/configuration.rb +23 -0
- data/lib/lutaml/model/error/fraction_digits_error.rb +20 -0
- data/lib/lutaml/model/error/length_error.rb +19 -0
- data/lib/lutaml/model/error/max_exclusive_error.rb +19 -0
- data/lib/lutaml/model/error/max_inclusive_error.rb +20 -0
- data/lib/lutaml/model/error/max_length_error.rb +20 -0
- data/lib/lutaml/model/error/min_exclusive_error.rb +19 -0
- data/lib/lutaml/model/error/min_inclusive_error.rb +20 -0
- data/lib/lutaml/model/error/min_length_error.rb +20 -0
- data/lib/lutaml/model/error/restriction_error.rb +8 -0
- data/lib/lutaml/model/error/total_digits_error.rb +20 -0
- data/lib/lutaml/model/error/type/max_exclusive_error.rb +20 -0
- data/lib/lutaml/model/error/type/min_exclusive_error.rb +20 -0
- data/lib/lutaml/model/error/type.rb +2 -0
- data/lib/lutaml/model/restriction_validation.rb +437 -0
- data/lib/lutaml/model/schema/definitions/restricted_type.rb +4 -2
- data/lib/lutaml/model/schema/renderers/restricted_type.rb +134 -4
- data/lib/lutaml/model/schema/rng_compiler/define_classifier.rb +3 -0
- data/lib/lutaml/model/schema/rng_compiler/rng_helpers.rb +9 -1
- data/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb +2 -0
- data/lib/lutaml/model/schema/templates.rb +2 -0
- data/lib/lutaml/model/schema/xml_compiler/spec_builder/simple_types.rb +39 -7
- data/lib/lutaml/model/schema/xml_compiler/supported_data_types.rb +3 -0
- data/lib/lutaml/model/serialize/attribute_definition.rb +18 -7
- data/lib/lutaml/model/serialize/builder.rb +4 -5
- data/lib/lutaml/model/serialize/conversion_caching.rb +124 -0
- data/lib/lutaml/model/serialize/enum_handling.rb +50 -11
- data/lib/lutaml/model/serialize/format_conversion.rb +46 -38
- data/lib/lutaml/model/serialize/initialization.rb +4 -2
- data/lib/lutaml/model/serialize.rb +61 -0
- data/lib/lutaml/model/services/type/validator.rb +23 -0
- data/lib/lutaml/model/type/string.rb +28 -3
- data/lib/lutaml/model/type/value.rb +231 -0
- data/lib/lutaml/model/type.rb +2 -0
- data/lib/lutaml/model/validation.rb +2 -1
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/model.rb +16 -0
- data/lib/lutaml/xml/model_transform.rb +4 -1
- data/lib/lutaml/xml/schema/xsd/max_exclusive.rb +1 -1
- data/lib/lutaml/xml/schema/xsd/min_exclusive.rb +1 -1
- data/lib/lutaml/xml/schema/xsd_schema.rb +183 -11
- data/lib/lutaml/xml/transformation/order_reconciler.rb +267 -0
- data/lib/lutaml/xml/transformation/ordered_applier.rb +80 -122
- data/lib/lutaml/xml/transformation/rule_compiler.rb +2 -0
- data/lib/lutaml/xml/transformation.rb +2 -0
- data/lib/lutaml/xml/transformation_support.rb +1 -0
- data/spec/fixtures/xml/restriction_facets.xsd +46 -0
- data/spec/lutaml/model/attribute_restriction_spec.rb +472 -0
- data/spec/lutaml/model/attribute_spec.rb +7 -2
- data/spec/lutaml/model/collection_reader_liveness_spec.rb +516 -0
- data/spec/lutaml/model/conversion_caching_spec.rb +280 -0
- data/spec/lutaml/model/lazy_collection_spec.rb +28 -3
- data/spec/lutaml/model/parsed_model_mutation_spec.rb +486 -0
- data/spec/lutaml/model/phantom_value_spec.rb +799 -0
- data/spec/lutaml/model/schema/renderers/restricted_type_spec.rb +1 -0
- data/spec/lutaml/model/schema/xsd_schema_spec.rb +340 -695
- data/spec/lutaml/model/serialize_perf_guard_spec.rb +53 -1
- data/spec/lutaml/model/type_digit_facet_spec.rb +229 -0
- data/spec/lutaml/model/type_facet_restriction_spec.rb +667 -0
- data/spec/lutaml/model/type_white_space_facet_spec.rb +132 -0
- data/spec/lutaml/xml/schema/compiler_spec.rb +302 -0
- data/spec/lutaml/xml/schema/facet_round_trip_spec.rb +153 -0
- metadata +27 -1
|
@@ -583,7 +583,8 @@ module Lutaml
|
|
|
583
583
|
xs(xml, "element", element_attrs) do
|
|
584
584
|
xs(xml, "complexType") do
|
|
585
585
|
xs(xml, "sequence") do
|
|
586
|
-
|
|
586
|
+
emit_value_element(xml, { name: "item" }, xsd_type,
|
|
587
|
+
attr_type, attr)
|
|
587
588
|
end
|
|
588
589
|
end
|
|
589
590
|
end
|
|
@@ -591,7 +592,8 @@ module Lutaml
|
|
|
591
592
|
# Simple element
|
|
592
593
|
element_attrs = build_element_attributes(name, xsd_type, attr,
|
|
593
594
|
xml_mapping, name)
|
|
594
|
-
|
|
595
|
+
emit_value_element(xml, element_attrs, xsd_type, attr_type,
|
|
596
|
+
attr)
|
|
595
597
|
end
|
|
596
598
|
end
|
|
597
599
|
end
|
|
@@ -606,21 +608,191 @@ module Lutaml
|
|
|
606
608
|
|
|
607
609
|
attr_type = attr.type(register)
|
|
608
610
|
xsd_type = get_attribute_xsd_type(attr, attr_type, register, rule)
|
|
611
|
+
emit_xsd_attribute(xml, attr, rule, xsd_type, attr_type)
|
|
612
|
+
end
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# Emit an `xs:attribute`, inlining an `xs:restriction` when the attribute
|
|
616
|
+
# is a constrained value type (its `type=` moves into the restriction
|
|
617
|
+
# base) and/or an annotation when the mapping carries documentation.
|
|
618
|
+
def self.emit_xsd_attribute(xml, attr, rule, xsd_type, attr_type)
|
|
619
|
+
facets = effective_facets(attr, attr_type)
|
|
620
|
+
restricted = facets.any?
|
|
621
|
+
|
|
622
|
+
attr_attrs = { name: rule.name }
|
|
623
|
+
attr_attrs[:type] = xsd_type unless restricted
|
|
624
|
+
attr_attrs[:use] = "required" if attr.options[:required]
|
|
625
|
+
attr_attrs[:form] = rule.form.to_s if rule.form
|
|
609
626
|
|
|
610
|
-
|
|
611
|
-
attr_attrs[:use] = "required" if attr.options[:required]
|
|
612
|
-
attr_attrs[:form] = rule.form.to_s if rule.form
|
|
627
|
+
return xs(xml, "attribute", attr_attrs) unless restricted || rule.documentation
|
|
613
628
|
|
|
629
|
+
xs(xml, "attribute", attr_attrs) do
|
|
614
630
|
if rule.documentation
|
|
615
|
-
xs(xml, "
|
|
616
|
-
xs(xml, "
|
|
617
|
-
xs(xml, "documentation", rule.documentation)
|
|
618
|
-
end
|
|
631
|
+
xs(xml, "annotation") do
|
|
632
|
+
xs(xml, "documentation", rule.documentation)
|
|
619
633
|
end
|
|
620
|
-
else
|
|
621
|
-
xs(xml, "attribute", attr_attrs)
|
|
622
634
|
end
|
|
635
|
+
emit_restriction(xml, xsd_type, facets, attr_type) if restricted
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def self.effective_facets(attr, attr_type)
|
|
640
|
+
return {} unless attr_type.is_a?(Class) &&
|
|
641
|
+
attr_type < Lutaml::Model::Type::Value
|
|
642
|
+
|
|
643
|
+
attr.effective_restriction_facets(attr_type)
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
def self.emit_value_element(xml, attrs, xsd_type, attr_type, attr)
|
|
647
|
+
facets = effective_facets(attr, attr_type)
|
|
648
|
+
if facets.any?
|
|
649
|
+
xs(xml, "element", attrs.except(:type)) do
|
|
650
|
+
emit_restriction(xml, xsd_type, facets, attr_type)
|
|
651
|
+
end
|
|
652
|
+
else
|
|
653
|
+
xs(xml, "element", attrs.merge(type: xsd_type))
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def self.emit_restriction(xml, base, facets, type)
|
|
658
|
+
xs(xml, "simpleType") do
|
|
659
|
+
xs(xml, "restriction", base: base) do
|
|
660
|
+
emit_restriction_facets(xml, facets, type)
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def self.emit_restriction_facets(xml, facets, type)
|
|
666
|
+
emit_bound(xml, :minExclusive, facets[:min_exclusive], type)
|
|
667
|
+
emit_bound(xml, :minInclusive, facets[:min_inclusive], type)
|
|
668
|
+
emit_bound(xml, :maxExclusive, facets[:max_exclusive], type)
|
|
669
|
+
emit_bound(xml, :maxInclusive, facets[:max_inclusive], type)
|
|
670
|
+
emit_facet(xml, :totalDigits, facets[:total_digits])
|
|
671
|
+
emit_facet(xml, :fractionDigits, facets[:fraction_digits])
|
|
672
|
+
emit_facet(xml, :length, facets[:length])
|
|
673
|
+
emit_facet(xml, :minLength, facets[:min_length])
|
|
674
|
+
emit_facet(xml, :maxLength, facets[:max_length])
|
|
675
|
+
Array(facets[:enumeration]).each do |value|
|
|
676
|
+
emit_facet(xml, :enumeration, lexical_value(type, value))
|
|
623
677
|
end
|
|
678
|
+
emit_facet(xml, :whiteSpace, facets[:white_space])
|
|
679
|
+
patterns = Array(facets[:pattern])
|
|
680
|
+
if patterns.size > 1
|
|
681
|
+
raise Lutaml::Model::Error,
|
|
682
|
+
"Cannot export #{patterns.size} conjunctive patterns on " \
|
|
683
|
+
"#{type} to a single xs:restriction: sibling xs:pattern " \
|
|
684
|
+
"facets are alternatives (OR), and XSD cannot express " \
|
|
685
|
+
"conjunction (AND) without nested restriction derivation, " \
|
|
686
|
+
"which is not yet supported."
|
|
687
|
+
end
|
|
688
|
+
patterns.each { |re| emit_facet(xml, :pattern, pattern_value(re)) }
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def self.emit_bound(xml, element, value, type)
|
|
692
|
+
emit_facet(xml, element, lexical_value(type, value)) unless value.nil?
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def self.emit_facet(xml, element, value)
|
|
696
|
+
return if value.nil?
|
|
697
|
+
|
|
698
|
+
xml.public_send(qn(element), value: value.to_s)
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def self.pattern_value(regexp)
|
|
702
|
+
reject_unsupported_regex_flags!(regexp)
|
|
703
|
+
source = strip_ruby_anchors(regexp.source)
|
|
704
|
+
reject_invalid_xsd_pattern!(regexp, source)
|
|
705
|
+
source
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def self.reject_unsupported_regex_flags!(regexp)
|
|
709
|
+
return if regexp.options.nobits?(UNSUPPORTED_XSD_REGEX_FLAGS)
|
|
710
|
+
|
|
711
|
+
raise Lutaml::Model::Error,
|
|
712
|
+
"Cannot export pattern #{regexp.inspect} to xs:pattern: " \
|
|
713
|
+
"the i/m/x flags are not expressible in XSD's regexp subset."
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def self.strip_ruby_anchors(source)
|
|
717
|
+
source = strip_leading_anchor(source)
|
|
718
|
+
strip_trailing_anchor(source)
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
def self.strip_leading_anchor(source)
|
|
722
|
+
return source[1..] if source.start_with?("^")
|
|
723
|
+
|
|
724
|
+
run = leading_backslash_run(source)
|
|
725
|
+
return source unless run.odd? && source[run] == "A"
|
|
726
|
+
|
|
727
|
+
# Keep the escaped-pair backslashes; drop the anchor's `\A`.
|
|
728
|
+
source[0, run - 1] + source[(run + 1)..]
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
def self.strip_trailing_anchor(source)
|
|
732
|
+
last = source[-1]
|
|
733
|
+
before = source.length - 1
|
|
734
|
+
if ["z", "Z"].include?(last) &&
|
|
735
|
+
trailing_backslash_run(source, before).odd?
|
|
736
|
+
return source[0...-2]
|
|
737
|
+
end
|
|
738
|
+
if last == "$" && trailing_backslash_run(source, before).even?
|
|
739
|
+
return source[0...-1]
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
source
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
def self.leading_backslash_run(source)
|
|
746
|
+
run = 0
|
|
747
|
+
run += 1 while source[run] == "\\"
|
|
748
|
+
run
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def self.trailing_backslash_run(source, pos)
|
|
752
|
+
run = 0
|
|
753
|
+
i = pos - 1
|
|
754
|
+
while i >= 0 && source[i] == "\\"
|
|
755
|
+
run += 1
|
|
756
|
+
i -= 1
|
|
757
|
+
end
|
|
758
|
+
run
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def self.raise_inexpressible_pattern!(regexp, detail)
|
|
762
|
+
raise Lutaml::Model::Error,
|
|
763
|
+
"Cannot export pattern #{regexp.inspect} to xs:pattern: " \
|
|
764
|
+
"it is not expressible in XSD's regexp subset (#{detail})."
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
def self.reject_invalid_xsd_pattern!(regexp, source)
|
|
768
|
+
return unless defined?(::Nokogiri::XML::Schema)
|
|
769
|
+
|
|
770
|
+
probe = <<~XSD
|
|
771
|
+
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
|
772
|
+
<xs:simpleType name="p">
|
|
773
|
+
<xs:restriction base="xs:string">
|
|
774
|
+
<xs:pattern value="#{xsd_escape(source)}"/>
|
|
775
|
+
</xs:restriction>
|
|
776
|
+
</xs:simpleType>
|
|
777
|
+
</xs:schema>
|
|
778
|
+
XSD
|
|
779
|
+
schema = ::Nokogiri::XML::Schema(probe)
|
|
780
|
+
return if schema.errors.empty?
|
|
781
|
+
|
|
782
|
+
raise_inexpressible_pattern!(regexp, schema.errors.first.message)
|
|
783
|
+
rescue ::Nokogiri::XML::SyntaxError => e
|
|
784
|
+
raise_inexpressible_pattern!(regexp, e.message)
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
UNSUPPORTED_XSD_REGEX_FLAGS =
|
|
788
|
+
Regexp::IGNORECASE | Regexp::MULTILINE | Regexp::EXTENDED
|
|
789
|
+
|
|
790
|
+
def self.xsd_escape(text)
|
|
791
|
+
text.gsub("&", "&").gsub("<", "<").gsub('"', """)
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def self.lexical_value(type, value)
|
|
795
|
+
type.serialize(type.cast(value))
|
|
624
796
|
end
|
|
625
797
|
|
|
626
798
|
def self.attr_is_xml_attribute?(xml_mapping, attr_name)
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Xml
|
|
5
|
+
module TransformationSupport
|
|
6
|
+
# Aligns a model's element_order with the values it currently holds.
|
|
7
|
+
#
|
|
8
|
+
# element_order describes the source document, not the model. Anything
|
|
9
|
+
# assigned after parsing has no entry in it, so OrderedApplier would
|
|
10
|
+
# never emit that value. Reconciliation inserts the missing entries
|
|
11
|
+
# before serialization starts.
|
|
12
|
+
#
|
|
13
|
+
# Only insertion is needed. A collection that shrank already emits the
|
|
14
|
+
# right count, because process_collection_item stops yielding once the
|
|
15
|
+
# index passes the value length.
|
|
16
|
+
#
|
|
17
|
+
# The model's own element_order is never modified: the reconciled array
|
|
18
|
+
# is a local view, so repeated to_xml calls stay idempotent.
|
|
19
|
+
module OrderReconciler
|
|
20
|
+
# @param model_instance [Object] The model instance
|
|
21
|
+
# @param compiled_rules [Array<CompiledRule>] The compiled rules
|
|
22
|
+
# @param options [Hash] Transformation options
|
|
23
|
+
# @return [Array(Array, Array<CompiledRule>)] the element order with
|
|
24
|
+
# any missing entries inserted, and the rules it could not
|
|
25
|
+
# reconcile that still hold an unemitted value. The caller must
|
|
26
|
+
# emit those the ordinary way or their value is lost. When nothing
|
|
27
|
+
# is missing the model's own order comes back untouched.
|
|
28
|
+
def reconciled_element_order(model_instance, compiled_rules, options)
|
|
29
|
+
order = model_instance.element_order
|
|
30
|
+
element_rules = element_typed_rules(compiled_rules)
|
|
31
|
+
|
|
32
|
+
# Resolve every entry to its rule once. Coverage, insertion
|
|
33
|
+
# anchors and the final array all read from this, so no later
|
|
34
|
+
# step rescans the order or re-matches a rule.
|
|
35
|
+
resolved = order.map { |object| find_rule_for_element(object, compiled_rules) }
|
|
36
|
+
coverage = ::Hash.new(0)
|
|
37
|
+
resolved.each { |rule| coverage[rule] += 1 if rule }
|
|
38
|
+
|
|
39
|
+
deficits, fallback = classify_rules(element_rules, coverage,
|
|
40
|
+
model_instance, options)
|
|
41
|
+
return [order, fallback] if deficits.empty?
|
|
42
|
+
|
|
43
|
+
[insert_deficits(order, deficits, element_rules, resolved), fallback]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def element_typed_rules(compiled_rules)
|
|
49
|
+
compiled_rules.select do |rule|
|
|
50
|
+
rule.is_a?(::Lutaml::Model::CompiledRule) &&
|
|
51
|
+
rule.option(:mapping_type) == :element
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Custom-method element rules are compiled without a backing
|
|
56
|
+
# attribute, so they expose no value whose cardinality or
|
|
57
|
+
# explicitness could be measured. OrderedApplier invokes them
|
|
58
|
+
# directly and never reads their value either.
|
|
59
|
+
def reconcilable?(rule, options)
|
|
60
|
+
!attributeless_custom_rule?(rule) && valid_mapping?(rule, options)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def attributeless_custom_rule?(rule)
|
|
64
|
+
rule.has_custom_methods? && rule.attribute_type.nil?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Split the element rules that are short of entries into the ones
|
|
68
|
+
# reconciliation can place and the ones it cannot.
|
|
69
|
+
#
|
|
70
|
+
# A rule it cannot place still holds a value nothing has emitted, so
|
|
71
|
+
# it comes back as a fallback for the caller to serialize the
|
|
72
|
+
# ordinary way. Dropping it would be the very data loss this file
|
|
73
|
+
# exists to stop.
|
|
74
|
+
#
|
|
75
|
+
# A rule that is neither short nor unemitted costs only the
|
|
76
|
+
# `using_default?` lookup inside expected_element_count, and the
|
|
77
|
+
# ambiguity scan runs solely for the ones that are short.
|
|
78
|
+
#
|
|
79
|
+
# @return [Array(Hash<CompiledRule, Integer>, Array<CompiledRule>)]
|
|
80
|
+
def classify_rules(element_rules, coverage, model_instance, options)
|
|
81
|
+
deficits = {}
|
|
82
|
+
fallback = []
|
|
83
|
+
|
|
84
|
+
element_rules.each do |rule|
|
|
85
|
+
missing = expected_element_count(rule, model_instance) -
|
|
86
|
+
coverage[rule]
|
|
87
|
+
next unless missing.positive?
|
|
88
|
+
|
|
89
|
+
if reconcilable?(rule, options) &&
|
|
90
|
+
unambiguous?(rule, element_rules)
|
|
91
|
+
deficits[rule] = missing
|
|
92
|
+
elsif coverage[rule].zero? &&
|
|
93
|
+
emit_uncovered?(rule, element_rules, model_instance)
|
|
94
|
+
fallback << rule
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
[deficits, fallback]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Whether a rule reconciliation could not place should still be
|
|
102
|
+
# emitted the ordinary way.
|
|
103
|
+
#
|
|
104
|
+
# Yes for an unambiguous rule: nothing else will emit its value.
|
|
105
|
+
#
|
|
106
|
+
# For rules sharing a serialized name, it depends on where
|
|
107
|
+
# element_order came from, because the dispatcher cannot tell them
|
|
108
|
+
# apart. A parsed order already stands for all of them — every one
|
|
109
|
+
# parsed from the same entry — so emitting the ones it did not
|
|
110
|
+
# resolve to would duplicate the element on a plain round-trip. An
|
|
111
|
+
# order built by a builder block records one entry per mutation, so
|
|
112
|
+
# a rule with no coverage genuinely has not been emitted.
|
|
113
|
+
def emit_uncovered?(rule, element_rules, model_instance)
|
|
114
|
+
return true if unambiguous?(rule, element_rules)
|
|
115
|
+
|
|
116
|
+
model_instance.respond_to?(:order_tracking_enabled?) &&
|
|
117
|
+
model_instance.order_tracking_enabled?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Entries to insert, keyed by the position in the original order
|
|
121
|
+
# they go before. Built in declaration order so several rules
|
|
122
|
+
# landing on one position keep their mapping order.
|
|
123
|
+
def insert_deficits(order, deficits, element_rules, resolved)
|
|
124
|
+
rule_index = {}
|
|
125
|
+
element_rules.each_with_index { |rule, i| rule_index[rule] = i }
|
|
126
|
+
|
|
127
|
+
insertions = deficits.each_with_object({}) do |(rule, missing), acc|
|
|
128
|
+
at = insertion_index(resolved, rule, rule_index)
|
|
129
|
+
entries = ::Array.new(missing) { new_order_entry(rule) }
|
|
130
|
+
(acc[at] ||= []).concat(entries)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
rebuild_order(order, insertions)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def rebuild_order(order, insertions)
|
|
137
|
+
result = []
|
|
138
|
+
order.each_with_index do |object, index|
|
|
139
|
+
pending = insertions[index]
|
|
140
|
+
result.concat(pending) if pending
|
|
141
|
+
result << object
|
|
142
|
+
end
|
|
143
|
+
tail = insertions[order.length]
|
|
144
|
+
result.concat(tail) if tail
|
|
145
|
+
result
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# How many child elements the rule's current value should produce.
|
|
149
|
+
#
|
|
150
|
+
# Only values the caller explicitly set are counted. A parsed model
|
|
151
|
+
# keeps defaults and the uninitialized sentinel for elements absent
|
|
152
|
+
# from the source document, and turning those into new elements is
|
|
153
|
+
# exactly the over-emission that reverted an earlier attempt at this
|
|
154
|
+
# fix.
|
|
155
|
+
def expected_element_count(rule, model_instance)
|
|
156
|
+
owner = value_owner(rule, model_instance)
|
|
157
|
+
return 0 unless owner.respond_to?(:using_default?)
|
|
158
|
+
# A custom-method rule compiled without an attribute exposes no
|
|
159
|
+
# value to measure, and reading one would call a method that does
|
|
160
|
+
# not exist.
|
|
161
|
+
return 0 if attributeless_custom_rule?(rule)
|
|
162
|
+
|
|
163
|
+
value = extract_ordered_rule_value(rule, model_instance)
|
|
164
|
+
return 0 if unmutated_default?(owner, rule, value)
|
|
165
|
+
return 0 if should_skip_delegated_value?(value, rule, owner)
|
|
166
|
+
# A custom `to:` method is handed the whole model and emits every
|
|
167
|
+
# value itself, and OrderedApplier calls it once per matching
|
|
168
|
+
# entry. One entry means one invocation, however many values the
|
|
169
|
+
# attribute holds.
|
|
170
|
+
return 1 if rule.custom_methods[:to]
|
|
171
|
+
return 1 unless rule.collection?
|
|
172
|
+
|
|
173
|
+
collection_element_count(value, rule)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def collection_element_count(value, rule)
|
|
177
|
+
# Mirror the applier's own dispatch. A value it will not iterate
|
|
178
|
+
# — nil, or a String — never reaches process_collection_item; it
|
|
179
|
+
# goes through apply_rule, which emits one element (xsi:nil under
|
|
180
|
+
# render_nil).
|
|
181
|
+
return 1 unless value.respond_to?(:each) && !value.is_a?(String)
|
|
182
|
+
|
|
183
|
+
# Same length/size fallback process_collection_item uses, so the
|
|
184
|
+
# two cannot disagree about how many items a collection holds.
|
|
185
|
+
length = value.respond_to?(:length) ? value.length : value.size
|
|
186
|
+
return length if length.positive?
|
|
187
|
+
|
|
188
|
+
empty_collection_renders_element?(rule) ? 1 : 0
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Whether the attribute still holds its default and nothing has put
|
|
192
|
+
# real data in it.
|
|
193
|
+
#
|
|
194
|
+
# This is the guard that keeps a parsed model from turning defaults
|
|
195
|
+
# and uninitialized sentinels into elements the source document
|
|
196
|
+
# never had. It follows RenderPolicy#should_skip_default? rather
|
|
197
|
+
# than inventing a second rule: a collection mutated in place is
|
|
198
|
+
# real data, because `items << "x"` never reaches the setter and so
|
|
199
|
+
# leaves `using_default?` true.
|
|
200
|
+
def unmutated_default?(owner, rule, value)
|
|
201
|
+
return false unless owner.using_default?(rule.attribute_name)
|
|
202
|
+
return false if rule.option(:render_default)
|
|
203
|
+
return false if ::Lutaml::Model::RenderPolicy
|
|
204
|
+
.derived_attribute_for?(owner, rule.attribute_name)
|
|
205
|
+
return false if rule.collection? &&
|
|
206
|
+
!::Lutaml::Model::Utils.empty?(value)
|
|
207
|
+
|
|
208
|
+
true
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def value_owner(rule, model_instance)
|
|
212
|
+
delegate = rule.option(:delegate_from)
|
|
213
|
+
return model_instance unless delegate
|
|
214
|
+
|
|
215
|
+
model_instance.public_send(delegate)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# find_rule_for_element resolves an inserted entry back to the FIRST
|
|
219
|
+
# element rule matching its name and namespace, and that match
|
|
220
|
+
# accepts a nil rule namespace, namespace aliases and name aliases.
|
|
221
|
+
# When more than one rule would claim the entry, reconciling would
|
|
222
|
+
# feed the value to the wrong rule, so leave the rule alone.
|
|
223
|
+
def unambiguous?(rule, element_rules)
|
|
224
|
+
matches = element_rules.select do |candidate|
|
|
225
|
+
matches_element_rule?(candidate, rule.serialized_name,
|
|
226
|
+
rule.namespace_class&.uri)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
matches == [rule]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def new_order_entry(rule)
|
|
233
|
+
::Lutaml::Xml::Element.new(
|
|
234
|
+
"Element",
|
|
235
|
+
rule.serialized_name,
|
|
236
|
+
node_type: :element,
|
|
237
|
+
namespace_uri: rule.namespace_class&.uri,
|
|
238
|
+
namespace_prefix: nil,
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Where a rule's new entries go:
|
|
243
|
+
# - after the last entry that already resolves to it, so extra
|
|
244
|
+
# collection items follow the existing ones and any interleaved
|
|
245
|
+
# element keeps its place
|
|
246
|
+
# - otherwise at the first entry declared after it, so a newly-set
|
|
247
|
+
# element lands in mapping-declaration order
|
|
248
|
+
def insertion_index(resolved, rule, rule_index)
|
|
249
|
+
own = rule_index[rule]
|
|
250
|
+
last_own = nil
|
|
251
|
+
first_later = nil
|
|
252
|
+
|
|
253
|
+
resolved.each_with_index do |matched, index|
|
|
254
|
+
next unless matched
|
|
255
|
+
|
|
256
|
+
last_own = index if matched.equal?(rule)
|
|
257
|
+
first_later ||= index if rule_index[matched] > own
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
return last_own + 1 if last_own
|
|
261
|
+
|
|
262
|
+
first_later || resolved.length
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|