lutaml-model 0.8.33 → 0.8.35

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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +1 -0
  3. data/.rubocop.yml +5 -0
  4. data/Gemfile +4 -0
  5. data/README.adoc +39 -0
  6. data/docs/conversion_caching.adoc +143 -0
  7. data/lib/compat/opal/lutaml_model_boot.rb +15 -0
  8. data/lib/lutaml/model/attribute.rb +95 -4
  9. data/lib/lutaml/model/config.rb +13 -0
  10. data/lib/lutaml/model/configuration.rb +23 -0
  11. data/lib/lutaml/model/error/fraction_digits_error.rb +20 -0
  12. data/lib/lutaml/model/error/length_error.rb +19 -0
  13. data/lib/lutaml/model/error/max_exclusive_error.rb +19 -0
  14. data/lib/lutaml/model/error/max_inclusive_error.rb +20 -0
  15. data/lib/lutaml/model/error/max_length_error.rb +20 -0
  16. data/lib/lutaml/model/error/min_exclusive_error.rb +19 -0
  17. data/lib/lutaml/model/error/min_inclusive_error.rb +20 -0
  18. data/lib/lutaml/model/error/min_length_error.rb +20 -0
  19. data/lib/lutaml/model/error/restriction_error.rb +8 -0
  20. data/lib/lutaml/model/error/total_digits_error.rb +20 -0
  21. data/lib/lutaml/model/error/type/max_exclusive_error.rb +20 -0
  22. data/lib/lutaml/model/error/type/min_exclusive_error.rb +20 -0
  23. data/lib/lutaml/model/error/type.rb +2 -0
  24. data/lib/lutaml/model/register.rb +12 -5
  25. data/lib/lutaml/model/restriction_validation.rb +437 -0
  26. data/lib/lutaml/model/schema/definitions/restricted_type.rb +4 -2
  27. data/lib/lutaml/model/schema/renderers/restricted_type.rb +134 -4
  28. data/lib/lutaml/model/schema/rng_compiler/define_classifier.rb +3 -0
  29. data/lib/lutaml/model/schema/rng_compiler/rng_helpers.rb +9 -1
  30. data/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb +2 -0
  31. data/lib/lutaml/model/schema/templates.rb +2 -0
  32. data/lib/lutaml/model/schema/xml_compiler/spec_builder/simple_types.rb +39 -7
  33. data/lib/lutaml/model/schema/xml_compiler/supported_data_types.rb +3 -0
  34. data/lib/lutaml/model/serialize/attribute_definition.rb +18 -7
  35. data/lib/lutaml/model/serialize/builder.rb +4 -5
  36. data/lib/lutaml/model/serialize/conversion_caching.rb +124 -0
  37. data/lib/lutaml/model/serialize/enum_handling.rb +50 -11
  38. data/lib/lutaml/model/serialize/format_conversion.rb +46 -38
  39. data/lib/lutaml/model/serialize/initialization.rb +4 -2
  40. data/lib/lutaml/model/serialize.rb +61 -0
  41. data/lib/lutaml/model/services/type/validator.rb +23 -0
  42. data/lib/lutaml/model/transformation_registry.rb +13 -7
  43. data/lib/lutaml/model/type/string.rb +28 -3
  44. data/lib/lutaml/model/type/value.rb +231 -0
  45. data/lib/lutaml/model/type.rb +2 -0
  46. data/lib/lutaml/model/validation.rb +2 -1
  47. data/lib/lutaml/model/version.rb +1 -1
  48. data/lib/lutaml/model.rb +16 -0
  49. data/lib/lutaml/xml/adapter_element.rb +17 -4
  50. data/lib/lutaml/xml/model_transform.rb +4 -1
  51. data/lib/lutaml/xml/schema/xsd/max_exclusive.rb +1 -1
  52. data/lib/lutaml/xml/schema/xsd/min_exclusive.rb +1 -1
  53. data/lib/lutaml/xml/schema/xsd_schema.rb +183 -11
  54. data/lib/lutaml/xml/transformation/order_reconciler.rb +267 -0
  55. data/lib/lutaml/xml/transformation/ordered_applier.rb +80 -122
  56. data/lib/lutaml/xml/transformation/rule_compiler.rb +2 -0
  57. data/lib/lutaml/xml/transformation.rb +2 -0
  58. data/lib/lutaml/xml/transformation_support.rb +1 -0
  59. data/spec/benchmarks/xml_parsing_benchmark_spec.rb +70 -0
  60. data/spec/fixtures/xml/restriction_facets.xsd +46 -0
  61. data/spec/lutaml/model/attribute_restriction_spec.rb +472 -0
  62. data/spec/lutaml/model/attribute_spec.rb +7 -2
  63. data/spec/lutaml/model/collection_reader_liveness_spec.rb +516 -0
  64. data/spec/lutaml/model/conversion_caching_spec.rb +280 -0
  65. data/spec/lutaml/model/lazy_collection_spec.rb +28 -3
  66. data/spec/lutaml/model/parsed_model_mutation_spec.rb +486 -0
  67. data/spec/lutaml/model/phantom_value_spec.rb +799 -0
  68. data/spec/lutaml/model/schema/renderers/restricted_type_spec.rb +1 -0
  69. data/spec/lutaml/model/schema/xsd_schema_spec.rb +340 -695
  70. data/spec/lutaml/model/serialize_perf_guard_spec.rb +53 -1
  71. data/spec/lutaml/model/type_digit_facet_spec.rb +229 -0
  72. data/spec/lutaml/model/type_facet_restriction_spec.rb +667 -0
  73. data/spec/lutaml/model/type_white_space_facet_spec.rb +132 -0
  74. data/spec/lutaml/xml/schema/compiler_spec.rb +302 -0
  75. data/spec/lutaml/xml/schema/facet_round_trip_spec.rb +153 -0
  76. metadata +28 -1
@@ -64,8 +64,8 @@ module Lutaml
64
64
  name = adapter_class.name_of(node)
65
65
  super(
66
66
  node,
67
- Hash(attributes),
68
- Array(children),
67
+ attributes || NO_ATTRIBUTES,
68
+ children || NO_CHILDREN,
69
69
  text,
70
70
  name: name,
71
71
  parent_document: parent,
@@ -146,8 +146,19 @@ module Lutaml
146
146
  end
147
147
  end
148
148
 
149
+ # Shared frozen empties: the parse path hands these straight into
150
+ # XmlElement (which never mutates the containers it is given), so
151
+ # attribute-less elements — the majority in real documents — build
152
+ # nothing here.
153
+ NO_ATTRIBUTES = {}.freeze # rubocop:todo Lint/UselessConstantScoping
154
+ NO_CHILDREN = [].freeze # rubocop:todo Lint/UselessConstantScoping
155
+ EMPTY_ATTRIBUTES = [{}, nil].freeze # rubocop:todo Lint/UselessConstantScoping
156
+
149
157
  def node_attributes_with_order(node)
150
- return [{}, nil] unless node.is_a?(Moxml::Element)
158
+ return EMPTY_ATTRIBUTES unless node.is_a?(Moxml::Element)
159
+
160
+ attrs = node.attributes
161
+ return EMPTY_ATTRIBUTES if attrs.respond_to?(:empty?) && attrs.empty?
151
162
 
152
163
  order = []
153
164
  hash = node.attributes.each_with_object({}) do |attr, h|
@@ -174,7 +185,7 @@ module Lutaml
174
185
  end
175
186
 
176
187
  def parse_children(node, default_namespace: nil)
177
- return [] unless node.children
188
+ return NO_CHILDREN unless node.children
178
189
 
179
190
  # Non-element children (text/cdata/comment/PI) stay RAW moxml
180
191
  # nodes in the list — the hot scalar parse path reads text off
@@ -197,6 +208,8 @@ module Lutaml
197
208
  public
198
209
 
199
210
  def children
211
+ return @children if @children.empty?
212
+
200
213
  unless @non_element_children_wrapped
201
214
  @children.map! do |child|
202
215
  if child.is_a?(Moxml::Node) && !child.is_a?(Moxml::Element)
@@ -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
- instance.element_order = doc.root.order
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
@@ -6,7 +6,7 @@ module Lutaml
6
6
  module Xsd
7
7
  class MaxExclusive < Base
8
8
  attribute :fixed, :string
9
- attribute :value, :integer
9
+ attribute :value, :string
10
10
 
11
11
  xml do
12
12
  element "maxExclusive"
@@ -6,7 +6,7 @@ module Lutaml
6
6
  module Xsd
7
7
  class MinExclusive < Base
8
8
  attribute :fixed, :string
9
- attribute :value, :integer
9
+ attribute :value, :string
10
10
 
11
11
  xml do
12
12
  element "minExclusive"
@@ -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
- xs(xml, "element", { name: "item", type: xsd_type })
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
- xs(xml, "element", element_attrs)
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
- attr_attrs = { name: rule.name, type: xsd_type }
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, "attribute", attr_attrs) do
616
- xs(xml, "annotation") do
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("&", "&amp;").gsub("<", "&lt;").gsub('"', "&quot;")
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