Skip to content

Commit

Permalink
feat: Detect circular references more accurately
Browse files Browse the repository at this point in the history
  • Loading branch information
tefra committed Mar 9, 2024
1 parent 57e6f92 commit 3190828
Show file tree
Hide file tree
Showing 9 changed files with 200 additions and 173 deletions.
9 changes: 6 additions & 3 deletions docs/codegen/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,6 @@ graph LR
B --> C[Validate class references]
```

API: [xsdata.codegen.analyzer.ClassAnalyzer][]

### Validate Classes

- Remove types with unknown references
Expand Down Expand Up @@ -135,14 +133,19 @@ pass through each step before next one starts. The order of the steps is very im

- [ValidateAttributesOverrides][xsdata.codegen.handlers.ValidateAttributesOverrides]

### Step: Finalize
### Step: Vacuum

- [VacuumInnerClasses][xsdata.codegen.handlers.VacuumInnerClasses]

### Step: Finalize

- [DetectCircularReferences][xsdata.codegen.handlers.DetectCircularReferences]
- [CreateCompoundFields][xsdata.codegen.handlers.CreateCompoundFields]
- [DisambiguateChoices][xsdata.codegen.handlers.DisambiguateChoices]
- [ResetAttributeSequenceNumbers][xsdata.codegen.handlers.ResetAttributeSequenceNumbers]

### Step: Designate

- [RenameDuplicateClasses][xsdata.codegen.handlers.RenameDuplicateClasses]
- [ValidateReferences][xsdata.codegen.handlers.ValidateReferences]
- [DesignateClassPackages][xsdata.codegen.handlers.DesignateClassPackages]
75 changes: 75 additions & 0 deletions tests/codegen/handlers/test_detect_circular_references.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from xsdata.codegen.container import ClassContainer
from xsdata.codegen.handlers.detect_circular_references import DetectCircularReferences
from xsdata.models.config import GeneratorConfig
from xsdata.models.enums import DataType
from xsdata.utils.testing import (
AttrFactory,
AttrTypeFactory,
ClassFactory,
FactoryTestCase,
)


class DetectCircularReferencesTests(FactoryTestCase):
def setUp(self):
super().setUp()
config = GeneratorConfig()
self.container = ClassContainer(config=config)
self.processor = DetectCircularReferences(self.container)

def test_process(self):
first = ClassFactory.create(qname="first")
second = ClassFactory.create(qname="second")
third = ClassFactory.create(qname="third")

first.attrs.append(AttrFactory.native(DataType.STRING))
first.attrs.append(
AttrFactory.create(
types=[
AttrTypeFactory.create(qname="second", reference=second.ref),
AttrTypeFactory.create(qname="third", reference=third.ref),
],
choices=[
AttrFactory.reference("second", reference=second.ref),
AttrFactory.reference("third", reference=third.ref),
],
)
)

second.attrs = AttrFactory.list(2)
third.attrs.append(AttrFactory.reference("first", reference=first.ref))
self.container.extend([first, second, third])

self.processor.process(first)

first_flags = [tp.circular for tp in first.types()]
self.assertEqual([False, False, True, False, True], first_flags)

second_flags = [tp.circular for tp in second.types()]
self.assertEqual([False, False], second_flags)

# First has the flags this doesn't need it :)
third_flags = [tp.circular for tp in third.types()]
self.assertEqual([False], third_flags)

def test_build_reference_types(self):
target = ClassFactory.create()
inner = ClassFactory.create()

outer_attr = AttrFactory.create()
inner_attr = AttrFactory.reference("foo", reference=target.ref)

inner.attrs.append(inner_attr)
target.inner.append(inner)
target.attrs.append(outer_attr)

self.container.add(target)

self.processor.build_reference_types()

expected = {
target.ref: [inner_attr.types[0]],
inner.ref: [inner_attr.types[0]],
}

self.assertEqual(expected, self.processor.reference_types)
81 changes: 3 additions & 78 deletions tests/codegen/handlers/test_process_attributes_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from xsdata.codegen.container import ClassContainer
from xsdata.codegen.handlers import ProcessAttributeTypes
from xsdata.codegen.models import Class, Restrictions, Status
from xsdata.codegen.models import Restrictions, Status
from xsdata.codegen.utils import ClassUtils
from xsdata.models.config import GeneratorConfig
from xsdata.models.enums import DataType, Tag
Expand Down Expand Up @@ -180,11 +180,8 @@ def test_process_dependency_type_with_enumeration_type(self, mock_find_dependenc
self.assertIsNone(attr.restrictions.min_length)
self.assertIsNone(attr.restrictions.max_length)

@mock.patch.object(ProcessAttributeTypes, "set_circular_flag")
@mock.patch.object(ProcessAttributeTypes, "find_dependency")
def test_process_dependency_type_with_complex_type(
self, mock_find_dependency, mock_set_circular_flag
):
def test_process_dependency_type_with_complex_type(self, mock_find_dependency):
complex_type = ClassFactory.elements(1)
mock_find_dependency.return_value = complex_type

Expand All @@ -193,13 +190,13 @@ def test_process_dependency_type_with_complex_type(
attr_type = attr.types[0]

self.processor.process_dependency_type(target, attr, attr_type)
mock_set_circular_flag.assert_called_once_with(complex_type, target, attr_type)

self.assertFalse(attr.restrictions.nillable)

complex_type.nillable = True
self.processor.process_dependency_type(target, attr, attr_type)
self.assertTrue(attr.restrictions.nillable)
self.assertEqual(complex_type.ref, attr_type.reference)

@mock.patch.object(ProcessAttributeTypes, "find_dependency")
def test_process_dependency_type_with_abstract_type_type(
Expand Down Expand Up @@ -328,63 +325,6 @@ def test_copy_attribute_properties_set_default_value_if_none(self):
self.assertEqual("foo", attr.default)
self.assertTrue("foo", attr.fixed)

@mock.patch.object(ProcessAttributeTypes, "is_circular_dependency")
def test_set_circular_flag(self, mock_is_circular_dependency):
source = ClassFactory.create()
target = ClassFactory.create()
attr = AttrFactory.create()
attr_type = attr.types[0]

mock_is_circular_dependency.return_value = True

self.processor.set_circular_flag(source, target, attr_type)
self.assertTrue(attr_type.circular)
self.assertEqual(id(source), attr_type.reference)

mock_is_circular_dependency.assert_called_once_with(source, target, set())

@mock.patch.object(ClassContainer, "find")
@mock.patch.object(Class, "dependencies")
def test_is_circular_dependency(self, mock_dependencies, mock_container_find):
source = ClassFactory.create()
target = ClassFactory.create()
another = ClassFactory.create()
processing = ClassFactory.create(status=Status.FLATTENING)

find_classes = {"a": another, "b": target}

mock_container_find.side_effect = lambda x: find_classes.get(x)
mock_dependencies.side_effect = [
list("ccde"),
list("abc"),
list("xy"),
]

self.assertTrue(
self.processor.is_circular_dependency(processing, target, set())
)

self.processor.dependencies.clear()
self.assertFalse(self.processor.is_circular_dependency(source, target, set()))

self.processor.dependencies.clear()
self.assertTrue(self.processor.is_circular_dependency(source, target, set()))

self.processor.dependencies.clear()
self.assertTrue(self.processor.is_circular_dependency(source, source, set()))

mock_container_find.assert_has_calls(
[
mock.call("c"),
mock.call("d"),
mock.call("e"),
mock.call("a"),
mock.call("x"),
mock.call("y"),
mock.call("b"),
]
)

def test_find_dependency(self):
attr_type = AttrTypeFactory.create(qname="a")

Expand Down Expand Up @@ -413,21 +353,6 @@ def test_find_dependency(self):
actual = self.processor.find_dependency(attr_type, Tag.EXTENSION)
self.assertEqual(simple_type, actual)

@mock.patch.object(Class, "dependencies")
def test_cached_dependencies(self, mock_class_dependencies):
mock_class_dependencies.return_value = ["a", "b"]

source = ClassFactory.create()
self.processor.dependencies[id(source)] = ("a",)

actual = self.processor.cached_dependencies(source)
self.assertEqual(("a",), actual)

self.processor.dependencies.clear()
actual = self.processor.cached_dependencies(source)
self.assertEqual(("a", "b"), actual)
mock_class_dependencies.assert_called_once_with()

def test_update_restrictions(self):
attr = AttrFactory.native(DataType.NMTOKENS)
self.processor.update_restrictions(attr, attr.types[0].datatype)
Expand Down
5 changes: 3 additions & 2 deletions tests/codegen/test_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ def test_initialize(self):
"SanitizeAttributesDefaultValue",
],
40: ["ValidateAttributesOverrides"],
50: [
"VacuumInnerClasses",
50: ["VacuumInnerClasses"],
60: [
"DetectCircularReferences",
"CreateCompoundFields",
"DisambiguateChoices",
"ResetAttributeSequenceNumbers",
Expand Down
22 changes: 9 additions & 13 deletions xsdata/codegen/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
CalculateAttributePaths,
CreateCompoundFields,
DesignateClassPackages,
DetectCircularReferences,
DisambiguateChoices,
FilterClasses,
FlattenAttributeGroups,
Expand Down Expand Up @@ -40,7 +41,8 @@ class Steps:
FLATTEN = 20
SANITIZE = 30
RESOLVE = 40
FINALIZE = 50
CLEANUP = 50
FINALIZE = 60


class ClassContainer(ContainerInterface):
Expand Down Expand Up @@ -91,8 +93,11 @@ def __init__(self, config: GeneratorConfig):
Steps.RESOLVE: [
ValidateAttributesOverrides(self),
],
Steps.FINALIZE: [
Steps.CLEANUP: [
VacuumInnerClasses(),
],
Steps.FINALIZE: [
DetectCircularReferences(self),
CreateCompoundFields(self),
DisambiguateChoices(self),
ResetAttributeSequenceNumbers(self),
Expand Down Expand Up @@ -165,24 +170,15 @@ def first(self, qname: str) -> Class:
return classes[0]

def process(self):
"""Run the processor and filter steps.
Steps:
1. Ungroup xs:groups and xs:attributeGroups
2. Remove the group classes from the container
3. Flatten extensions, attrs and attr types
4. Remove the classes that won't be generated
5. Resolve attrs overrides
5. Create compound fields, cleanup classes and atts
7. Designate final class names, packages and modules
"""
"""Run the processor and filter steps."""
self.validate_classes()
self.process_classes(Steps.UNGROUP)
self.remove_groups()
self.process_classes(Steps.FLATTEN)
self.filter_classes()
self.process_classes(Steps.SANITIZE)
self.process_classes(Steps.RESOLVE)
self.process_classes(Steps.CLEANUP)
self.process_classes(Steps.FINALIZE)
self.designate_classes()

Expand Down
4 changes: 3 additions & 1 deletion xsdata/codegen/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from .calculate_attribute_paths import CalculateAttributePaths
from .create_compound_fields import CreateCompoundFields
from .designate_class_packages import DesignateClassPackages
from .detect_circular_references import DetectCircularReferences
from .disambiguate_choices import DisambiguateChoices
from .filter_classes import FilterClasses
from .flatten_attribute_groups import FlattenAttributeGroups
Expand All @@ -26,6 +27,7 @@
"CalculateAttributePaths",
"CreateCompoundFields",
"DesignateClassPackages",
"DetectCircularReferences",
"DisambiguateChoices",
"FilterClasses",
"FlattenAttributeGroups",
Expand All @@ -35,8 +37,8 @@
"ProcessMixedContentClass",
"RenameDuplicateAttributes",
"RenameDuplicateClasses",
"ResetAttributeSequences",
"ResetAttributeSequenceNumbers",
"ResetAttributeSequences",
"SanitizeAttributesDefaultValue",
"SanitizeEnumerationClass",
"UnnestInnerClasses",
Expand Down
Loading

0 comments on commit 3190828

Please sign in to comment.