Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Detect circular references more accurately #969

Merged
merged 1 commit into from
Mar 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading