Repository: tefra/xsdata
Pull request #979 (2024-03-17 16:58:51)

fix: Validate min < max occurs
## 📒 Description

Ensure max occurs > min occurs

Resolves #xxxx

## 🔗 What I've Done

> Write a description of the steps taken to resolve the issue

## 💬 Comments

> A place to write any comments to the reviewer.

## 🛫 Checklist

- [ ] Updated docs
- [ ] Added unit-tests
- [ ] [Sample tests](https://github.com/tefra/xsdata-samples) pass
- [ ] [W3C tests](https://github.com/tefra/xsdata-w3c-tests) pass


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=tefra_xsdata&pullRequest=979) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=979&resolved=false&inNewCodePeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/component_measures?id=tefra_xsdata&pullRequest=979&metric=new_accepted_issues&view=list)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=tefra_xsdata&pullRequest=979&resolved=false&inNewCodePeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/no-data-16px.png '') No data about Coverage  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=tefra_xsdata&pullRequest=979&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=tefra_xsdata&pullRequest=979)



Patch:
diff --git a/xsdata/codegen/models.py b/xsdata/codegen/models.py
index 82fa0083..5b73cd50 100644
--- a/xsdata/codegen/models.py
+++ b/xsdata/codegen/models.py
@@ -160,7 +160,11 @@ class Restrictions(CodegenModel):
                 result["min_occurs"] = self.min_occurs
             if self.max_occurs is not None and self.max_occurs < sys.maxsize:
                 result["max_occurs"] = self.max_occurs
-        elif self.min_occurs == self.max_occurs == 1 and not self.nillable:
+        elif (
+            self.min_occurs == self.max_occurs == 1
+            and not self.nillable
+            and not self.tokens
+        ):
             result["required"] = True
 
         for key, value in asdict(self).items():
diff --git a/xsdata/codegen/parsers/schema.py b/xsdata/codegen/parsers/schema.py
index 9bf84608..6513c32d 100644
--- a/xsdata/codegen/parsers/schema.py
+++ b/xsdata/codegen/parsers/schema.py
@@ -129,7 +129,6 @@ class SchemaParser(UserXmlParser):
             self.set_schema_namespaces(obj)
             self.add_default_imports(obj)
             self.resolve_schemas_locations(obj)
-            self.reset_element_occurs(obj)
 
     def end_attribute(self, obj: T):
         """End attribute element entrypoint.
@@ -411,16 +410,3 @@ class SchemaParser(UserXmlParser):
         xsi_ns = Namespace.XSI.uri
         if xsi_ns in obj.ns_map.values() and xsi_ns not in imp_namespaces:
             obj.imports.insert(0, xsd.Import(namespace=xsi_ns))
-
-    @classmethod
-    def reset_element_occurs(cls, obj: xsd.Schema):
-        """Reset the root elements occurs restrictions.
-
-        The root elements don't get those.
-
-        Args:
-            obj: The xsd schema instance
-        """
-        for element in obj.elements:
-            element.min_occurs = None
-            element.max_occurs = None
diff --git a/xsdata/models/xsd.py b/xsdata/models/xsd.py
index aef36e86..0effca9d 100644
--- a/xsdata/models/xsd.py
+++ b/xsdata/models/xsd.py
@@ -33,6 +33,16 @@ docstring_serializer = XmlSerializer(
 )
 
 
+def validate_max_occurs(min_occurs: int, max_occurs: UnionType[str, int]) -> int:
+    """Validate max occurs."""
+    if max_occurs == "unbounded":
+        max_occurs = sys.maxsize
+
+    assert isinstance(max_occurs, int)
+
+    return max(max_occurs, min_occurs)
+
+
 @dataclass(frozen=True)
 class Docstring:
     """Docstring model representation.
@@ -121,7 +131,7 @@ class AnyAttribute(AnnotationBase):
     )
 
     def __post_init__(self):
-        """Clean the namespace value."""
+        """Post initialization validations."""
         self.namespace = " ".join(unique_sequence(self.namespace.split()))
 
     @property
@@ -351,14 +361,15 @@ class Any(AnnotationBase):
 
     namespace: str = attribute(default="##any")
     min_occurs: int = attribute(default=1, name="minOccurs")
-    max_occurs: UnionType[int, str] = attribute(default=1, name="maxOccurs")
+    max_occurs: UnionType[str, int] = attribute(default=1, name="maxOccurs")
     process_contents: ProcessType = attribute(
         default=ProcessType.STRICT, name="processContents"
     )
 
     def __post_init__(self):
-        """Clean the namespace value."""
+        """Post initialization validations."""
         self.namespace = " ".join(unique_sequence(self.namespace.split()))
+        self.max_occurs = validate_max_occurs(self.min_occurs, self.max_occurs)
 
     @property
     def is_property(self) -> bool:
@@ -397,17 +408,19 @@ class All(AnnotationBase):
     """XSD All model representation."""
 
     min_occurs: int = attribute(default=1, name="minOccurs")
-    max_occurs: UnionType[int, str] = attribute(default=1, name="maxOccurs")
+    max_occurs: UnionType[str, int] = attribute(default=1, name="maxOccurs")
     any: Array[Any] = array_element(name="any")
     elements: Array["Element"] = array_element(name="element")
     groups: Array["Group"] = array_element(name="group")
 
+    def __post_init__(self):
+        """Post initialization validations."""
+        self.max_occurs = validate_max_occurs(self.min_occurs, self.max_occurs)
+
     def get_restrictions(self) -> Dict[str, Anything]:
         """Return the restrictions dictionary of this element."""
-        max_occurs = sys.maxsize if self.max_occurs == "unbounded" else self.max_occurs
-
         return {
-            "path": [("a", id(self), self.min_occurs, max_occurs)],
+            "path": [("a", id(self), self.min_occurs, self.max_occurs)],
         }
 
 
@@ -416,19 +429,21 @@ class Sequence(AnnotationBase):
     """XSD Sequence model representation."""
 
     min_occurs: int = attribute(default=1, name="minOccurs")
-    max_occurs: UnionType[int, str] = attribute(default=1, name="maxOccurs")
+    max_occurs: UnionType[str, int] = attribute(default=1, name="maxOccurs")
     elements: Array["Element"] = array_element(name="element")
     groups: Array["Group"] = array_element(name="group")
     choices: Array["Choice"] = array_element(name="choice")
     sequences: Array["Sequence"] = array_element(name="sequence")
     any: Array["Any"] = array_element()
 
+    def __post_init__(self):
+        """Post initialization validations."""
+        self.max_occurs = validate_max_occurs(self.min_occurs, self.max_occurs)
+
     def get_restrictions(self) -> Dict[str, Anything]:
         """Return the restrictions dictionary of this element."""
-        max_occurs = sys.maxsize if self.max_occurs == "unbounded" else self.max_occurs
-
         return {
-            "path": [("s", id(self), self.min_occurs, max_occurs)],
+            "path": [("s", id(self), self.min_occurs, self.max_occurs)],
         }
 
 
@@ -437,19 +452,21 @@ class Choice(AnnotationBase):
     """XSD Choice model representation."""
 
     min_occurs: int = attribute(default=1, name="minOccurs")
-    max_occurs: UnionType[int, str] = attribute(default=1, name="maxOccurs")
+    max_occurs: UnionType[str, int] = attribute(default=1, name="maxOccurs")
     elements: Array["Element"] = array_element(name="element")
     groups: Array["Group"] = array_element(name="group")
     choices: Array["Choice"] = array_element(name="choice")
     sequences: Array[Sequence] = array_element(name="sequence")
     any: Array["Any"] = array_element()
 
+    def __post_init__(self):
+        """Post initialization validations."""
+        self.max_occurs = validate_max_occurs(self.min_occurs, self.max_occurs)
+
     def get_restrictions(self) -> Dict[str, Anything]:
         """Return the restrictions dictionary of this element."""
-        max_occurs = sys.maxsize if self.max_occurs == "unbounded" else self.max_occurs
-
         return {
-            "path": [("c", id(self), self.min_occurs, max_occurs)],
+            "path": [("c", id(self), self.min_occurs, self.max_occurs)],
         }
 
 
@@ -460,11 +477,15 @@ class Group(AnnotationBase):
     name: Optional[str] = attribute()
     ref: str = attribute(default="")
     min_occurs: int = attribute(default=1, name="minOccurs")
-    max_occurs: UnionType[int, str] = attribute(default=1, name="maxOccurs")
+    max_occurs: UnionType[str, int] = attribute(default=1, name="maxOccurs")
     all: Optional[All] = element()
     choice: Optional[Choice] = element()
     sequence: Optional[Sequence] = element()
 
+    def __post_init__(self):
+        """Post initialization validations."""
+        self.max_occurs = validate_max_occurs(self.min_occurs, self.max_occurs)
+
     @property
     def is_property(self) -> bool:
         """Specify it is qualified to be a class property."""
@@ -478,10 +499,8 @@ class Group(AnnotationBase):
 
     def get_restrictions(self) -> Dict[str, Anything]:
         """Return the restrictions dictionary of this element."""
-        max_occurs = sys.maxsize if self.max_occurs == "unbounded" else self.max_occurs
-
         return {
-            "path": [("g", id(self), self.min_occurs, max_occurs)],
+            "path": [("g", id(self), self.min_occurs, self.max_occurs)],
         }
 
 
@@ -862,11 +881,15 @@ class Element(AnnotationBase):
     uniques: Array[Unique] = array_element(name="unique")
     keys: Array[Key] = array_element(name="key")
     keyrefs: Array[Keyref] = array_element(name="keyref")
-    min_occurs: Optional[int] = attribute(default=1, name="minOccurs")
-    max_occurs: UnionType[None, int, str] = attribute(default=1, name="maxOccurs")
+    min_occurs: int = attribute(default=1, name="minOccurs")
+    max_occurs: UnionType[str, int] = attribute(default=1, name="maxOccurs")
     nillable: bool = attribute(default=False)
     abstract: bool = attribute(default=False)
 
+    def __post_init__(self):
+        """Post initialization validations."""
+        self.max_occurs = validate_max_occurs(self.min_occurs, self.max_occurs)
+
     @property
     def bases(self) -> Iterator[str]:
         """Return an iterator of all the base types."""
@@ -910,11 +933,9 @@ class Element(AnnotationBase):
 
     def get_restrictions(self) -> Dict[str, Anything]:
         """Return the restrictions dictionary of this element."""
-        max_occurs = sys.maxsize if self.max_occurs == "unbounded" else self.max_occurs
-
         restrictions = {
             "min_occurs": self.min_occurs,
-            "max_occurs": max_occurs,
+            "max_occurs": self.max_occurs,
         }
 
         if self.simple_type:


Test patch:
diff --git a/tests/codegen/parsers/test_schema.py b/tests/codegen/parsers/test_schema.py
index f57907ec..09a05dc4 100644
--- a/tests/codegen/parsers/test_schema.py
+++ b/tests/codegen/parsers/test_schema.py
@@ -416,15 +416,11 @@ class SchemaParserTests(TestCase):
         schema.elements.append(Element())
         schema.elements.append(Element())
 
-        for el in schema.elements:
-            self.assertEqual(1, el.min_occurs)
-            self.assertEqual(1, el.max_occurs)
-
         self.parser.end_schema(schema)
 
         for el in schema.elements:
-            self.assertIsNone(el.min_occurs)
-            self.assertIsNone(el.max_occurs)
+            self.assertEqual(1, el.min_occurs)
+            self.assertEqual(1, el.max_occurs)
 
         self.parser.end_schema(ComplexType())
 
diff --git a/tests/models/xsd/test_all.py b/tests/models/xsd/test_all.py
index 94385686..425c294a 100644
--- a/tests/models/xsd/test_all.py
+++ b/tests/models/xsd/test_all.py
@@ -1,9 +1,19 @@
+import sys
 from unittest import TestCase
 
 from xsdata.models.xsd import All
 
 
 class AllTests(TestCase):
+    def test_normalize_max_occurs(self):
+        obj = All(min_occurs=3, max_occurs=2)
+        self.assertEqual(3, obj.max_occurs)
+        self.assertEqual(3, obj.min_occurs)
+
+        obj = All(min_occurs=3, max_occurs="unbounded")
+        self.assertEqual(sys.maxsize, obj.max_occurs)
+        self.assertEqual(3, obj.min_occurs)
+
     def test_get_restrictions(self):
         obj = All(min_occurs=1, max_occurs=2)
         self.assertEqual({"path": [("a", id(obj), 1, 2)]}, obj.get_restrictions())
diff --git a/tests/models/xsd/test_any.py b/tests/models/xsd/test_any.py
index 0d14c1ce..b419cd5e 100644
--- a/tests/models/xsd/test_any.py
+++ b/tests/models/xsd/test_any.py
@@ -1,3 +1,4 @@
+import sys
 from unittest import TestCase
 
 from xsdata.models.enums import Namespace, NamespaceType
@@ -5,6 +6,15 @@ from xsdata.models.xsd import Any
 
 
 class AnyTests(TestCase):
+    def test_normalize_max_occurs(self):
+        obj = Any(min_occurs=3, max_occurs=2)
+        self.assertEqual(3, obj.max_occurs)
+        self.assertEqual(3, obj.min_occurs)
+
+        obj = Any(min_occurs=3, max_occurs="unbounded")
+        self.assertEqual(sys.maxsize, obj.max_occurs)
+        self.assertEqual(3, obj.min_occurs)
+
     def test_property_is_property(self):
         self.assertTrue(Any().is_property)
 
diff --git a/tests/models/xsd/test_choice.py b/tests/models/xsd/test_choice.py
index b990cda3..bf0d9284 100644
--- a/tests/models/xsd/test_choice.py
+++ b/tests/models/xsd/test_choice.py
@@ -5,11 +5,15 @@ from xsdata.models.xsd import Choice
 
 
 class ChoiceTests(TestCase):
+    def test_normalize_max_occurs(self):
+        obj = Choice(min_occurs=3, max_occurs=2)
+        self.assertEqual(3, obj.max_occurs)
+        self.assertEqual(3, obj.min_occurs)
+
+        obj = Choice(min_occurs=3, max_occurs="unbounded")
+        self.assertEqual(sys.maxsize, obj.max_occurs)
+        self.assertEqual(3, obj.min_occurs)
+
     def test_get_restrictions(self):
         obj = Choice(min_occurs=1, max_occurs=2)
         self.assertEqual({"path": [("c", id(obj), 1, 2)]}, obj.get_restrictions())
-
-        obj = Choice(max_occurs="unbounded")
-        self.assertEqual(
-            {"path": [("c", id(obj), 1, sys.maxsize)]}, obj.get_restrictions()
-        )
diff --git a/tests/models/xsd/test_element.py b/tests/models/xsd/test_element.py
index 81b37e5c..1eba24cf 100644
--- a/tests/models/xsd/test_element.py
+++ b/tests/models/xsd/test_element.py
@@ -1,3 +1,4 @@
+import sys
 from unittest import TestCase
 
 from xsdata.codegen.exceptions import CodegenError
@@ -13,6 +14,15 @@ from xsdata.models.xsd import (
 
 
 class ElementTests(TestCase):
+    def test_normalize_max_occurs(self):
+        obj = Element(min_occurs=3, max_occurs=2)
+        self.assertEqual(3, obj.max_occurs)
+        self.assertEqual(3, obj.min_occurs)
+
+        obj = Element(min_occurs=3, max_occurs="unbounded")
+        self.assertEqual(sys.maxsize, obj.max_occurs)
+        self.assertEqual(3, obj.min_occurs)
+
     def test_property_is_property(self):
         obj = Element()
         self.assertTrue(obj)
diff --git a/tests/models/xsd/test_group.py b/tests/models/xsd/test_group.py
index 6b3cd0c4..2154f663 100644
--- a/tests/models/xsd/test_group.py
+++ b/tests/models/xsd/test_group.py
@@ -1,9 +1,19 @@
+import sys
 from unittest import TestCase
 
 from xsdata.models.xsd import Group
 
 
 class GroupTests(TestCase):
+    def test_normalize_max_occurs(self):
+        obj = Group(min_occurs=3, max_occurs=2)
+        self.assertEqual(3, obj.max_occurs)
+        self.assertEqual(3, obj.min_occurs)
+
+        obj = Group(min_occurs=3, max_occurs="unbounded")
+        self.assertEqual(sys.maxsize, obj.max_occurs)
+        self.assertEqual(3, obj.min_occurs)
+
     def test_property_is_property(self):
         obj = Group()
         self.assertTrue(obj.is_property)
diff --git a/tests/models/xsd/test_sequence.py b/tests/models/xsd/test_sequence.py
index bfee3bee..22eac3b9 100644
--- a/tests/models/xsd/test_sequence.py
+++ b/tests/models/xsd/test_sequence.py
@@ -5,11 +5,15 @@ from xsdata.models.xsd import Sequence
 
 
 class SequenceTests(TestCase):
+    def test_normalize_max_occurs(self):
+        obj = Sequence(min_occurs=3, max_occurs=2)
+        self.assertEqual(3, obj.max_occurs)
+        self.assertEqual(3, obj.min_occurs)
+
+        obj = Sequence(min_occurs=3, max_occurs="unbounded")
+        self.assertEqual(sys.maxsize, obj.max_occurs)
+        self.assertEqual(3, obj.min_occurs)
+
     def test_get_restrictions(self):
         obj = Sequence(min_occurs=1, max_occurs=2)
         self.assertEqual({"path": [("s", id(obj), 1, 2)]}, obj.get_restrictions())
-
-        obj = Sequence(min_occurs=1, max_occurs="unbounded")
-        self.assertEqual(
-            {"path": [("s", id(obj), 1, sys.maxsize)]}, obj.get_restrictions()
-        )