Repository: tefra/xsdata
Pull request #543 (2021-06-25 20:57:38)

Fix missing DerivedElement types
Edge case binding a dataclass to a mixed field

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=543&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=543&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=543&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=543&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=543&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=543&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=543&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=543&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=tefra_xsdata&pullRequest=543&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=543&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=543&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=543&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=543) 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=543&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=tefra_xsdata&pullRequest=543&metric=new_duplicated_lines_density&view=list)


codecov[bot]: # [Codecov](https://codecov.io/gh/tefra/xsdata/pull/543?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou) Report
> Merging [#543](https://codecov.io/gh/tefra/xsdata/pull/543?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou) (e819fde) into [master](https://codecov.io/gh/tefra/xsdata/commit/480a2d01a374a51bab25113766d025283d6fe031?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou) (480a2d0) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/tefra/xsdata/pull/543/graphs/tree.svg?width=650&height=150&src=pr&token=YzDDLtywvl&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou)](https://codecov.io/gh/tefra/xsdata/pull/543?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou)

```diff
@@            Coverage Diff            @@
##            master      #543   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           94        94           
  Lines         7752      7763   +11     
  Branches      1337      1337           
=========================================
+ Hits          7752      7763   +11     
```


| [Impacted Files](https://codecov.io/gh/tefra/xsdata/pull/543?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou) | Coverage Δ | |
|---|---|---|
| [xsdata/formats/dataclass/models/elements.py](https://codecov.io/gh/tefra/xsdata/pull/543/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou#diff-eHNkYXRhL2Zvcm1hdHMvZGF0YWNsYXNzL21vZGVscy9lbGVtZW50cy5weQ==) | `100.00% <ø> (ø)` | |
| [xsdata/formats/dataclass/models/builders.py](https://codecov.io/gh/tefra/xsdata/pull/543/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou#diff-eHNkYXRhL2Zvcm1hdHMvZGF0YWNsYXNzL21vZGVscy9idWlsZGVycy5weQ==) | `100.00% <100.00%> (ø)` | |
| [xsdata/formats/dataclass/parsers/nodes/element.py](https://codecov.io/gh/tefra/xsdata/pull/543/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou#diff-eHNkYXRhL2Zvcm1hdHMvZGF0YWNsYXNzL3BhcnNlcnMvbm9kZXMvZWxlbWVudC5weQ==) | `100.00% <100.00%> (ø)` | |
| [xsdata/formats/dataclass/serializers/xml.py](https://codecov.io/gh/tefra/xsdata/pull/543/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou#diff-eHNkYXRhL2Zvcm1hdHMvZGF0YWNsYXNzL3NlcmlhbGl6ZXJzL3htbC5weQ==) | `100.00% <100.00%> (ø)` | |
| [xsdata/utils/namespaces.py](https://codecov.io/gh/tefra/xsdata/pull/543/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou#diff-eHNkYXRhL3V0aWxzL25hbWVzcGFjZXMucHk=) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/tefra/xsdata/pull/543?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/tefra/xsdata/pull/543?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou). Last update [480a2d0...e819fde](https://codecov.io/gh/tefra/xsdata/pull/543?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou).


Patch:
diff --git a/xsdata/formats/dataclass/models/builders.py b/xsdata/formats/dataclass/models/builders.py
index ce2950d9..2f831313 100644
--- a/xsdata/formats/dataclass/models/builders.py
+++ b/xsdata/formats/dataclass/models/builders.py
@@ -62,7 +62,13 @@ class XmlMetaBuilder:
         nillable = getattr(meta, "nillable", False)
         namespace = getattr(meta, "namespace", parent_ns)
         module = sys.modules[clazz.__module__]
-        target_namespace = self.target_namespace(module, meta)
+        qname = build_qname(namespace, local_name)
+
+        if self.is_inner_class(clazz):
+            target_qname = None
+        else:
+            target_namespace = self.target_namespace(module, meta)
+            target_qname = build_qname(target_namespace, local_name)
 
         class_vars = self.build_vars(
             clazz,
@@ -94,8 +100,8 @@ class XmlMetaBuilder:
 
         return XmlMeta(
             clazz=clazz,
-            qname=build_qname(namespace, local_name),
-            target_qname=build_qname(target_namespace, local_name),
+            qname=qname,
+            target_qname=target_qname,
             nillable=nillable,
             text=text,
             attributes=attributes,
@@ -144,6 +150,11 @@ class XmlMetaBuilder:
 
         return None
 
+    @classmethod
+    def is_inner_class(cls, clazz: Type) -> bool:
+        """Return whether the given type is nested inside another type."""
+        return "." in clazz.__qualname__
+
     @classmethod
     def build_target_qname(cls, clazz: Type, element_name_generator: Callable) -> str:
         """Build the source qualified name of a model based on the module
diff --git a/xsdata/formats/dataclass/models/elements.py b/xsdata/formats/dataclass/models/elements.py
index 5b1bd7a8..1ac725a0 100644
--- a/xsdata/formats/dataclass/models/elements.py
+++ b/xsdata/formats/dataclass/models/elements.py
@@ -303,7 +303,7 @@ class XmlMeta(MetaMixin):
         self,
         clazz: Type,
         qname: str,
-        target_qname: str,
+        target_qname: Optional[str],
         nillable: bool,
         text: Optional[XmlVar],
         choices: Sequence[XmlVar],
diff --git a/xsdata/formats/dataclass/parsers/nodes/element.py b/xsdata/formats/dataclass/parsers/nodes/element.py
index 80a3d586..bb09b264 100644
--- a/xsdata/formats/dataclass/parsers/nodes/element.py
+++ b/xsdata/formats/dataclass/parsers/nodes/element.py
@@ -17,6 +17,7 @@ from xsdata.formats.dataclass.parsers.utils import ParserUtils
 from xsdata.formats.dataclass.parsers.utils import PendingCollection
 from xsdata.logger import logger
 from xsdata.models.enums import DataType
+from xsdata.utils import namespaces
 
 
 class ElementNode(XmlNode):
@@ -232,10 +233,11 @@ class ElementNode(XmlNode):
             derived_factory = self.context.class_type.derived_element
 
             if not self.context.class_type.is_model(value):
-
                 value = any_factory(qname=qname, text=converter.serialize(value))
             elif not isinstance(value, (any_factory, derived_factory)):
-                value = derived_factory(qname=qname, value=value)
+                meta = self.context.fetch(type(value))
+                xsi_type = namespaces.real_xsi_type(qname, meta.target_qname)
+                value = derived_factory(qname=qname, value=value, type=xsi_type)
 
         return value
 
diff --git a/xsdata/formats/dataclass/serializers/xml.py b/xsdata/formats/dataclass/serializers/xml.py
index c6f321b9..a44e550d 100644
--- a/xsdata/formats/dataclass/serializers/xml.py
+++ b/xsdata/formats/dataclass/serializers/xml.py
@@ -78,9 +78,9 @@ class XmlSerializer(AbstractSerializer):
         qname = xsi_type = None
         if isinstance(obj, self.context.class_type.derived_element):
             meta = self.context.build(obj.value.__class__)
-            xsi_type = QName(meta.target_qname)
             qname = obj.qname
             obj = obj.value
+            xsi_type = namespaces.real_xsi_type(qname, meta.target_qname)
 
         yield from self.write_dataclass(obj, qname=qname, xsi_type=xsi_type)
 
@@ -90,7 +90,7 @@ class XmlSerializer(AbstractSerializer):
         namespace: NoneStr = None,
         qname: NoneStr = None,
         nillable: bool = False,
-        xsi_type: Optional[QName] = None,
+        xsi_type: Optional[str] = None,
     ) -> Generator:
         """
         Produce an events stream from a dataclass.
@@ -193,13 +193,12 @@ class XmlSerializer(AbstractSerializer):
 
     def write_derived_element(self, value: Any, namespace: NoneStr) -> Generator:
         if self.context.class_type.is_model(value.value):
-            xsi_type = None
-            if value.type:
-                meta = self.context.build(value.value.__class__)
-                xsi_type = QName(meta.target_qname)
+            meta = self.context.fetch(value.value.__class__)
+            qname = value.qname
+            xsi_type = namespaces.real_xsi_type(qname, meta.target_qname)
 
             yield from self.write_dataclass(
-                value.value, namespace, qname=value.qname, xsi_type=xsi_type
+                value.value, namespace, qname=qname, xsi_type=xsi_type
             )
         else:
             datatype = DataType.from_value(value.value)
@@ -229,7 +228,7 @@ class XmlSerializer(AbstractSerializer):
         if value.tail:
             yield XmlWriterEvent.DATA, value.tail
 
-    def xsi_type(self, var: XmlVar, value: Any, namespace: NoneStr) -> Optional[QName]:
+    def xsi_type(self, var: XmlVar, value: Any, namespace: NoneStr) -> Optional[str]:
         """Get xsi:type if the given value is a derived instance."""
         if not value or value.__class__ in var.types:
             return None
@@ -237,7 +236,7 @@ class XmlSerializer(AbstractSerializer):
         clazz = var.clazz
         if clazz is None or self.context.is_derived(value, clazz):
             meta = self.context.fetch(value.__class__, namespace)
-            return QName(meta.target_qname)
+            return meta.target_qname
 
         raise SerializerError(
             f"{value.__class__.__name__} is not derived from {clazz.__name__}"
@@ -343,7 +342,7 @@ class XmlSerializer(AbstractSerializer):
 
     @classmethod
     def next_attribute(
-        cls, obj: Any, meta: XmlMeta, xsi_nil: bool, xsi_type: Optional[QName]
+        cls, obj: Any, meta: XmlMeta, xsi_nil: bool, xsi_type: Optional[str]
     ) -> Generator:
         """
         Return the attribute variables with their object values.
@@ -362,7 +361,7 @@ class XmlSerializer(AbstractSerializer):
                 yield from getattr(obj, var.name, EMPTY_MAP).items()
 
         if xsi_type:
-            yield QNames.XSI_TYPE, xsi_type
+            yield QNames.XSI_TYPE, QName(xsi_type)
 
         if xsi_nil:
             yield QNames.XSI_NIL, "true"
diff --git a/xsdata/utils/namespaces.py b/xsdata/utils/namespaces.py
index 292f7822..a797d0bb 100644
--- a/xsdata/utils/namespaces.py
+++ b/xsdata/utils/namespaces.py
@@ -77,6 +77,12 @@ def clean_uri(namespace: str) -> str:
     return "_".join(x for x in namespace.split(".") if x not in __uri_ignore__)
 
 
+def real_xsi_type(qname: str, target_qname: Optional[str]) -> Optional[str]:
+    """Determine if the given target qualified name should be used to define a
+    derived type."""
+    return target_qname if target_qname != qname else None
+
+
 @functools.lru_cache(maxsize=50)
 def build_qname(tag_or_uri: Optional[str], tag: Optional[str] = None) -> str:
     """Create namespace qualified strings."""


Test patch:
diff --git a/tests/fixtures/models.py b/tests/fixtures/models.py
index b11355ba..49f73521 100644
--- a/tests/fixtures/models.py
+++ b/tests/fixtures/models.py
@@ -147,3 +147,10 @@ class Paragraph:
         default_factory=list,
         metadata=dict(type="Wildcard", namespace="##any", mixed=True),
     )
+
+
+@dataclass
+class Parent:
+    @dataclass
+    class Inner:
+        pass
diff --git a/tests/formats/dataclass/models/test_builders.py b/tests/formats/dataclass/models/test_builders.py
index 698c6449..128e245d 100644
--- a/tests/formats/dataclass/models/test_builders.py
+++ b/tests/formats/dataclass/models/test_builders.py
@@ -16,6 +16,7 @@ from xml.etree.ElementTree import QName
 from tests.fixtures.artists import Artist
 from tests.fixtures.books import BookForm
 from tests.fixtures.models import ChoiceType
+from tests.fixtures.models import Parent
 from tests.fixtures.models import TypeA
 from tests.fixtures.models import TypeB
 from tests.fixtures.models import UnionType
@@ -113,6 +114,10 @@ class XmlMetaBuilderTests(FactoryTestCase):
         self.assertEqual(9, len(actual.choices[0].elements))
         self.assertIsNone(self.builder.find_globalns(object, "foo"))
 
+    def test_build_inner_type_has_no_target_qname(self):
+        actual = self.builder.build(Parent.Inner, None)
+        self.assertIsNone(actual.target_qname)
+
     def test_target_namespace(self):
         class Meta:
             namespace = "bar"
diff --git a/tests/formats/dataclass/parsers/nodes/test_element.py b/tests/formats/dataclass/parsers/nodes/test_element.py
index 0d284d87..e4536f2f 100644
--- a/tests/formats/dataclass/parsers/nodes/test_element.py
+++ b/tests/formats/dataclass/parsers/nodes/test_element.py
@@ -228,12 +228,16 @@ class ElementNodeTests(FactoryTestCase):
 
         fixture = make_dataclass("Fixture", [("content", str)])
         actual = self.node.prepare_generic_value("a", fixture("foo"))
-        expected = DerivedElement(qname="a", value=fixture("foo"))
+        expected = DerivedElement(qname="a", value=fixture("foo"), type="Fixture")
         self.assertEqual(expected, actual)
 
         actual = self.node.prepare_generic_value("a", expected)
         self.assertIs(expected, actual)
 
+        actual = self.node.prepare_generic_value("Fixture", fixture("foo"))
+        expected = DerivedElement(qname="Fixture", value=fixture("foo"))
+        self.assertEqual(expected, actual)
+
     def test_child(self):
         var = XmlVarFactory.create(xml_type=XmlType.ELEMENT, qname="a", types=(TypeC,))
         attrs = {"a": "b"}
diff --git a/tests/formats/dataclass/serializers/test_xml.py b/tests/formats/dataclass/serializers/test_xml.py
index d1630218..18d75d70 100644
--- a/tests/formats/dataclass/serializers/test_xml.py
+++ b/tests/formats/dataclass/serializers/test_xml.py
@@ -40,6 +40,17 @@ class XmlSerializerTests(TestCase):
         self.assertIsInstance(result, Generator)
         self.assertEqual(expected, list(result))
 
+        obj = DerivedElement(qname="{urn:books}BookForm", value=book)
+        result = self.serializer.write_object(obj)
+        expected = [
+            (XmlWriterEvent.START, "{urn:books}BookForm"),
+            (XmlWriterEvent.ATTR, "id", "123"),
+            (XmlWriterEvent.ATTR, "lang", "en"),
+            (XmlWriterEvent.END, "{urn:books}BookForm"),
+        ]
+        self.assertIsInstance(result, Generator)
+        self.assertEqual(expected, list(result))
+
     def test_write_dataclass(self):
         book = BookForm(id="123", title="Misterioso: A Crime Novel", price=19.5)
         result = self.serializer.write_object(book)
@@ -103,6 +114,7 @@ class XmlSerializerTests(TestCase):
             (XmlWriterEvent.START, "ebook"),
             (XmlWriterEvent.ATTR, "id", "123"),
             (XmlWriterEvent.ATTR, "lang", "en"),
+            ("attr", QNames.XSI_TYPE, "{urn:books}BookForm"),
             (XmlWriterEvent.END, "ebook"),
             (XmlWriterEvent.DATA, "tail"),
         ]