Repository: tefra/xsdata

Issue:
order of repeating xml tags
Hello, great work !
I have a little issue (sorry if had been discussed). The schema states:
```
<xs:complexType name="MSCONS">
		<xs:sequence>
			<xs:element name="UNH" type="UNH" minOccurs="1" maxOccurs="1"/>
			<xs:element name="BGM" type="BGM" minOccurs="1" maxOccurs="1"/>
			<xs:element name="DTM" type="DTM" minOccurs="1" maxOccurs="1"/>
			<xs:element name="NAD" type="NAD" minOccurs="2" maxOccurs="2"/>
			<xs:element name="UNS" type="UNS" minOccurs="1" maxOccurs="1"/>
			<xs:element name="NAD" type="NAD" minOccurs="1" maxOccurs="1"/>
			<xs:element name="UNT" type="UNT" minOccurs="1" maxOccurs="1"/>
		</xs:sequence>
</xs:complexType>
```
final created class is:
```
@dataclass
class Mscons:
    """
    Metered services consumption report message.
    """
    class Meta:
        name = "MSCONS"

    unh: Optional[Unh] = field(
        default=None,
        metadata={
            "name": "UNH",
            "type": "Element",
            "required": True,
        }
    )
    bgm: Optional[Bgm] = field(
        default=None,
        metadata={
            "name": "BGM",
            "type": "Element",
            "required": True,
        }
    )
    dtm: Optional[Dtm] = field(
        default=None,
        metadata={
            "name": "DTM",
            "type": "Element",
            "required": True,
        }
    )
    nad: List[Nad] = field(
        default_factory=list,
        metadata={
            "name": "NAD",
            "type": "Element",
            "min_occurs": 1,
            "max_occurs": 2,
        }
    )
    uns: Optional[Uns] = field(
        default=None,
        metadata={
            "name": "UNS",
            "type": "Element",
            "required": True,
        }
    )
    unt: Optional[Unt] = field(
        default=None,
        metadata={
            "name": "UNT",
            "type": "Element",
            "required": True,
        }
    )
```
The XML file  for parsing is:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<MSCONS xsi:noNamespaceSchemaLocation ="MSCONS_890_v1.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
	<UNH>
		<REFERENCENUMBER>1</REFERENCENUMBER>
		<IDENTIFIER>MSCONS</IDENTIFIER>
		<VERSIONNUMBER>D</VERSIONNUMBER>
		<RELEASENUMBER>1xx</RELEASENUMBER> 
		<CONTROLAGENCY>Udd</CONTROLAGENCY>
		<ASSOCCODE>xxxxx</ASSOCCODE>
	</UNH>
	<BGM>
		<NAME>890</NAME>
		<CODELISTAGENCY>esdf</CODELISTAGENCY>
		<DOCUMENTNUMBER>faaf173d-3e44-4e231-8c15-8e8634566789f</DOCUMENTNUMBER>
		<DOCUMENTFUNC>9</DOCUMENTFUNC>
		<RESPONSETYPE>NA</RESPONSETYPE>
	</BGM>
	<DTM>
		<DATUMQUALIFIER>137</DATUMQUALIFIER>
		<DATUM>201908121200+03</DATUM>
		<FORMAT>303</FORMAT>
	</DTM>
	<NAD>
		<ACTION>MS</ACTION>
		<PARTNER>sdewertxx</PARTNER>
		<CODELISTAGENCY>305</CODELISTAGENCY>
		<COUNTRY>GG</COUNTRY>
	</NAD>
	<NAD>
		<ACTION>MR</ACTION>
		<PARTNER>ddddddddd</PARTNER>
		<CODELISTAGENCY>305</CODELISTAGENCY>
		<COUNTRY>mm</COUNTRY>
	</NAD>
	<UNS>
		<SECTION_ID>D</SECTION_ID>
	</UNS>
	<NAD>
		<ACTION>GN</ACTION>
		<PARTNER>dfrterffff</PARTNER>
		<CODELISTAGENCY>305</CODELISTAGENCY>
		<COUNTRY>mm</COUNTRY>
		<LOC>
			<PLACE_QUALIFIER>172</PLACE_QUALIFIER>
			<PLACE_ID>wertyfghj</PLACE_ID>
			<CODE_LIST_RESPONSIBLE_AGENCY>ff</CODE_LIST_RESPONSIBLE_AGENCY>			
		</LOC>
	</NAD>
	<UNT>
		<NUMSEG>17</NUMSEG>
		<REFNUM>1</REFNUM>
	</UNT>
</MSCONS>
```
After parsing the segment:
```xml
<UNS>
     <SECTION_ID>D</SECTION_ID>
</UNS>"
```

is after 3 NAD, instead to be between 2 and 3 NAD segment
Is there any option to rearrange such a case?
Thank You !

Discussion:
sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug.png' alt='Bug' width='16' height='16' />](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=436&resolved=false&types=BUG) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=436&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=436&resolved=false&types=BUG)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability.png' alt='Vulnerability' width='16' height='16' />](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=436&resolved=false&types=VULNERABILITY) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=436&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=436&resolved=false&types=VULNERABILITY)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot.png' alt='Security Hotspot' width='16' height='16' />](https://sonarcloud.io/project/security_hotspots?id=tefra_xsdata&pullRequest=436&resolved=false&types=SECURITY_HOTSPOT) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/security_hotspots?id=tefra_xsdata&pullRequest=436&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=tefra_xsdata&pullRequest=436&resolved=false&types=SECURITY_HOTSPOT)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell.png' alt='Code Smell' width='16' height='16' />](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=436&resolved=false&types=CODE_SMELL) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=436&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=436&resolved=false&types=CODE_SMELL)

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo.png' alt='No Coverage information' width='16' height='16' />](https://sonarcloud.io/component_measures?id=tefra_xsdata&pullRequest=436) No Coverage information  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3.png' alt='0.0%' width='16' height='16' />](https://sonarcloud.io/component_measures?id=tefra_xsdata&pullRequest=436&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=tefra_xsdata&pullRequest=436&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/docs/demos.rst b/docs/demos.rst
index 5b2901aa..794a15a1 100644
--- a/docs/demos.rst
+++ b/docs/demos.rst
@@ -49,7 +49,7 @@ XML and JSON Binding.
 
 XSD Results: **10** failed, **14576** passed, **89** skipped, **16** warnings
 
-XML Results: **13** failed, **14573** passed, **90** skipped, **15** warnings
+XML Results: **3** failed, **14582** passed, **90** skipped, **15** warnings
 
 JSON Results: **113** failed, **14562** passed, **1** warnings
 
diff --git a/xsdata/codegen/handlers/attribute_sanitizer.py b/xsdata/codegen/handlers/attribute_sanitizer.py
index f227e8f2..0c0fb351 100644
--- a/xsdata/codegen/handlers/attribute_sanitizer.py
+++ b/xsdata/codegen/handlers/attribute_sanitizer.py
@@ -1,5 +1,6 @@
 from dataclasses import dataclass
 from typing import Dict
+from typing import List
 
 from xsdata.codegen.mixins import ContainerInterface
 from xsdata.codegen.mixins import HandlerInterface
@@ -28,6 +29,7 @@ class AttributeSanitizerHandler(HandlerInterface):
         self.cascade_default_value(target)
         self.reset_unsupported_types(target)
         self.remove_inherited_fields(target)
+        self.set_effective_choices(target)
 
     def remove_inherited_fields(self, target: Class):
         """Compare all override fields and if they mach the parent definition
@@ -82,6 +84,24 @@ class AttributeSanitizerHandler(HandlerInterface):
 
             attr.types = collections.unique_sequence(attr.types, key="qname")
 
+    @classmethod
+    def set_effective_choices(cls, target: Class):
+        """Look for sequential lists and non list elements and set effective
+        choice group for compound fields."""
+        groups: List[List[Attr]] = [[]]
+        for attr in target.attrs:
+            # If attr is sequential and is list or the group is not empty
+            if attr.restrictions.sequential and (attr.is_list or groups[-1]):
+                groups[-1].append(attr)
+            elif groups[-1]:
+                groups.append([])
+
+        for idx, group in enumerate(groups):
+            total_lists = sum(attr.restrictions.is_list for attr in group)
+            if total_lists != len(group) and total_lists > 0:
+                for attr in group:
+                    attr.restrictions.choice = f"effective_{idx}"
+
     @classmethod
     def validate_default_value(cls, attr: Attr, ns_map: Dict) -> bool:
         """Reset attribute types when roundtrip conversion of fixed values
diff --git a/xsdata/codegen/sanitizer.py b/xsdata/codegen/sanitizer.py
index b76aa0ee..1ddda7c9 100644
--- a/xsdata/codegen/sanitizer.py
+++ b/xsdata/codegen/sanitizer.py
@@ -71,6 +71,8 @@ class ClassSanitizer:
         """Group attributes into a new compound field."""
 
         pos = target.attrs.index(attrs[0])
+        choice = attrs[0].restrictions.choice
+        sum_occurs = choice and choice.startswith("effective_")
         names = []
         choices = []
         min_occurs = []
@@ -78,11 +80,15 @@ class ClassSanitizer:
         for attr in attrs:
             target.attrs.remove(attr)
             names.append(attr.local_name)
-            min_occurs.append(attr.restrictions.min_occurs)
-            max_occurs.append(attr.restrictions.max_occurs)
+            min_occurs.append(attr.restrictions.min_occurs or 0)
+            max_occurs.append(attr.restrictions.max_occurs or 0)
             choices.append(self.build_attr_choice(attr))
 
-        name = "choice" if len(names) > 3 else "_Or_".join(names)
+        if len(names) > 3 or len(names) != len(set(names)):
+            name = "choice"
+        else:
+            name = "_Or_".join(names)
+
         target.attrs.insert(
             pos,
             Attr(
@@ -91,8 +97,8 @@ class ClassSanitizer:
                 types=[AttrType(qname=str(DataType.ANY_TYPE), native=True)],
                 tag=Tag.CHOICE,
                 restrictions=Restrictions(
-                    min_occurs=min((x for x in min_occurs if x is not None), default=0),
-                    max_occurs=max((x for x in max_occurs if x is not None), default=0),
+                    min_occurs=sum(min_occurs) if sum_occurs else min(min_occurs),
+                    max_occurs=sum(max_occurs) if sum_occurs else max(max_occurs),
                 ),
                 choices=choices,
             ),


Test patch:
diff --git a/tests/codegen/handlers/test_attribute_sanitizer.py b/tests/codegen/handlers/test_attribute_sanitizer.py
index 48c20ca5..d4f9c0ce 100644
--- a/tests/codegen/handlers/test_attribute_sanitizer.py
+++ b/tests/codegen/handlers/test_attribute_sanitizer.py
@@ -1,7 +1,9 @@
 from xsdata.codegen.container import ClassContainer
 from xsdata.codegen.handlers import AttributeSanitizerHandler
+from xsdata.codegen.models import Restrictions
 from xsdata.models.enums import DataType
 from xsdata.models.enums import Tag
+from xsdata.utils import collections
 from xsdata.utils.testing import AttrFactory
 from xsdata.utils.testing import AttrTypeFactory
 from xsdata.utils.testing import ClassFactory
@@ -184,3 +186,40 @@ class AttributeSanitizerHandlerTests(FactoryTestCase):
         source.attrs[1].restrictions.choice = "456"
         self.processor.process(target)
         self.assertEqual(0, len(target.attrs))
+
+    def test_set_effective_choices(self):
+        target = ClassFactory.create()
+        attrs = [
+            AttrFactory.any(),
+            AttrFactory.any(),
+            # first group
+            AttrFactory.any(restrictions=Restrictions(sequential=True)),
+            AttrFactory.any(restrictions=Restrictions(sequential=True, max_occurs=2)),
+            AttrFactory.any(restrictions=Restrictions(sequential=True, max_occurs=2)),
+            # break attr
+            AttrFactory.any(),
+            # second group
+            AttrFactory.any(restrictions=Restrictions(sequential=True, max_occurs=2)),
+            AttrFactory.any(restrictions=Restrictions(sequential=True, max_occurs=1)),
+            AttrFactory.any(restrictions=Restrictions(sequential=True, max_occurs=2)),
+        ]
+        target.attrs.extend(attrs)
+
+        self.processor.process(target)
+
+        self.assertIsNone(attrs[0].restrictions.choice)
+        self.assertIsNone(attrs[1].restrictions.choice)
+
+        # Part of the group but precedes list siblings
+        self.assertIsNone(attrs[2].restrictions.choice)
+        # Part of the group but both are lists
+        self.assertIsNone(attrs[3].restrictions.choice)
+        self.assertIsNone(attrs[4].restrictions.choice)
+
+        # break attr
+        self.assertIsNone(attrs[5].restrictions.choice)
+
+        # Second group, mixed list non list sequential elements
+        self.assertEqual("effective_1", attrs[6].restrictions.choice)
+        self.assertEqual("effective_1", attrs[7].restrictions.choice)
+        self.assertEqual("effective_1", attrs[8].restrictions.choice)
diff --git a/tests/codegen/test_sanitizer.py b/tests/codegen/test_sanitizer.py
index 5bb4682a..bfbb0d3e 100644
--- a/tests/codegen/test_sanitizer.py
+++ b/tests/codegen/test_sanitizer.py
@@ -491,6 +491,8 @@ class ClassSanitizerTest(FactoryTestCase):
 
     def test_group_fields(self):
         target = ClassFactory.create(attrs=AttrFactory.list(2))
+        target.attrs[0].restrictions.choice = "1"
+        target.attrs[1].restrictions.choice = "1"
         target.attrs[0].restrictions.min_occurs = 10
         target.attrs[0].restrictions.max_occurs = 15
         target.attrs[1].restrictions.min_occurs = 5
@@ -521,14 +523,42 @@ class ClassSanitizerTest(FactoryTestCase):
         self.assertEqual(expected, target.attrs[0])
         self.assertEqual(expected_res, target.attrs[0].restrictions)
 
+    def test_group_fields_with_effective_choices_sums_occurs(self):
+        target = ClassFactory.create(attrs=AttrFactory.list(2))
+        target.attrs[0].restrictions.choice = "effective_1"
+        target.attrs[1].restrictions.choice = "effective_1"
+        target.attrs[0].restrictions.min_occurs = 1
+        target.attrs[0].restrictions.max_occurs = 2
+        target.attrs[1].restrictions.min_occurs = 3
+        target.attrs[1].restrictions.max_occurs = 4
+
+        expected_res = Restrictions(min_occurs=4, max_occurs=6)
+
+        self.sanitizer.group_fields(target, list(target.attrs))
+        self.assertEqual(1, len(target.attrs))
+        self.assertEqual(expected_res, target.attrs[0].restrictions)
+
     def test_group_fields_limit_name(self):
         target = ClassFactory.create(attrs=AttrFactory.list(3))
+        for attr in target.attrs:
+            attr.restrictions.choice = "1"
+
         self.sanitizer.group_fields(target, list(target.attrs))
 
         self.assertEqual(1, len(target.attrs))
         self.assertEqual("attr_B_Or_attr_C_Or_attr_D", target.attrs[0].name)
 
         target = ClassFactory.create(attrs=AttrFactory.list(4))
+        for attr in target.attrs:
+            attr.restrictions.choice = "1"
+
+        self.sanitizer.group_fields(target, list(target.attrs))
+        self.assertEqual("choice", target.attrs[0].name)
+
+        target = ClassFactory.create()
+        attr = AttrFactory.element(restrictions=Restrictions(choice="1"))
+        target.attrs.append(attr)
+        target.attrs.append(attr.clone())
         self.sanitizer.group_fields(target, list(target.attrs))
         self.assertEqual("choice", target.attrs[0].name)