lutaml-model 0.8.32 → 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 +47 -6
- 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
|
@@ -399,7 +399,10 @@ module Lutaml
|
|
|
399
399
|
def set_instance_ordering(instance, doc, ordered_option,
|
|
400
400
|
mixed_content_option, xml_mapping = nil,
|
|
401
401
|
instance_is_serialize = nil)
|
|
402
|
-
|
|
402
|
+
# dup: XmlElement#order hands back a frozen cache shared with the
|
|
403
|
+
# DOM. The model's copy has to stay mutable so callers can maintain
|
|
404
|
+
# element_order themselves.
|
|
405
|
+
instance.element_order = doc.root.order.dup
|
|
403
406
|
if instance_is_serialize && doc.root.is_a?(::Lutaml::Xml::XmlElement)
|
|
404
407
|
instance.attribute_order = doc.root.attribute_order
|
|
405
408
|
end
|
|
@@ -469,7 +472,31 @@ _effective_register)
|
|
|
469
472
|
end
|
|
470
473
|
end
|
|
471
474
|
|
|
472
|
-
|
|
475
|
+
# Whether no OTHER attribute rule in the mapping claims the same
|
|
476
|
+
# local name. A sole claimant is namespace-flexible on the local-
|
|
477
|
+
# name fallback: real-world documents qualify such attributes in
|
|
478
|
+
# the element's namespace, a foreign extension namespace, or not
|
|
479
|
+
# at all (OOXML w:val on w15: elements — lutaml-model#790). When
|
|
480
|
+
# a sibling claims the name, matching stays strict so each rule
|
|
481
|
+
# keeps its own namespace (#744, #758).
|
|
482
|
+
def attribute_rule_sole_local_claimant?(instance, rule,
|
|
483
|
+
effective_register)
|
|
484
|
+
klass = instance.class
|
|
485
|
+
return true unless klass.include?(Lutaml::Model::Serialize)
|
|
486
|
+
|
|
487
|
+
mapping = klass.mappings_for(:xml, effective_register)
|
|
488
|
+
return true unless mapping
|
|
489
|
+
|
|
490
|
+
expected = Array(rule.name).map(&:to_s)
|
|
491
|
+
mapping.mappings.none? do |sib|
|
|
492
|
+
next false unless sib.attribute? && !sib.equal?(rule)
|
|
493
|
+
|
|
494
|
+
Array(sib.name).map(&:to_s) == expected
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def value_for_xml_attribute(doc, rule, rule_names,
|
|
499
|
+
flexible_local: false)
|
|
473
500
|
# For attributes, rule_names may contain namespaced names, but find_attribute_value
|
|
474
501
|
# expects prefix:name or just name, so we need to convert namespaced names to prefix format.
|
|
475
502
|
#
|
|
@@ -496,7 +523,8 @@ _effective_register)
|
|
|
496
523
|
# This handles the case where the namespace prefix is not declared in the document
|
|
497
524
|
# (e.g., v:ext without xmlns:v="...").
|
|
498
525
|
if value.nil? && rule_names.any? { |rn| rn.include?(":") }
|
|
499
|
-
value = find_attribute_by_local_name(doc, rule_names
|
|
526
|
+
value = find_attribute_by_local_name(doc, rule_names,
|
|
527
|
+
flexible_local: flexible_local)
|
|
500
528
|
end
|
|
501
529
|
|
|
502
530
|
value = value&.split(rule.delimiter) if rule.delimiter
|
|
@@ -559,7 +587,8 @@ _effective_register)
|
|
|
559
587
|
# @param doc [XmlElement] the parsed XML document root
|
|
560
588
|
# @param rule_names [Array<String>] the rule names to match
|
|
561
589
|
# @return [String, nil] the attribute value or nil
|
|
562
|
-
def find_attribute_by_local_name(doc, rule_names
|
|
590
|
+
def find_attribute_by_local_name(doc, rule_names,
|
|
591
|
+
flexible_local: false)
|
|
563
592
|
rule_names.each do |rn|
|
|
564
593
|
next unless rn.include?(":")
|
|
565
594
|
|
|
@@ -579,7 +608,12 @@ _effective_register)
|
|
|
579
608
|
# namespaced sibling attribute (lutaml-model#758). An
|
|
580
609
|
# XmlAttribute with a resolved namespace has namespaced_name
|
|
581
610
|
# "URI:local", so this comparison is allocation-free.
|
|
582
|
-
if
|
|
611
|
+
if flexible_local
|
|
612
|
+
# Sole claimant: any qualification of the local name binds
|
|
613
|
+
# (element namespace, foreign extension namespace, none).
|
|
614
|
+
attr.unprefixed_name == local_name ||
|
|
615
|
+
((colon = attr.name.rindex(":")) ? attr.name[(colon + 1)..] : attr.name) == local_name
|
|
616
|
+
elsif attr.namespace.nil?
|
|
583
617
|
next false if attr.namespace_prefix.nil? &&
|
|
584
618
|
!attr.name.include?(":")
|
|
585
619
|
|
|
@@ -630,7 +664,14 @@ _effective_register)
|
|
|
630
664
|
# return the same children again.
|
|
631
665
|
rule_names = rule_names.concat(extra_rule_names).uniq if extra_rule_names
|
|
632
666
|
|
|
633
|
-
|
|
667
|
+
if rule.attribute?
|
|
668
|
+
return value_for_xml_attribute(
|
|
669
|
+
doc, rule, rule_names,
|
|
670
|
+
flexible_local: attribute_rule_sole_local_claimant?(
|
|
671
|
+
instance, rule, effective_register
|
|
672
|
+
)
|
|
673
|
+
)
|
|
674
|
+
end
|
|
634
675
|
|
|
635
676
|
# Performance: Pre-compute type-related values used in the hot loop
|
|
636
677
|
attr_type_is_serializable = attr_type && attr_type <= ::Lutaml::Model::Serialize
|
|
@@ -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
|