diff --git a/docs/en/cookbook/resolve-target-entity-listener.rst b/docs/en/cookbook/resolve-target-entity-listener.rst index e3c5550b102..04a50e56043 100644 --- a/docs/en/cookbook/resolve-target-entity-listener.rst +++ b/docs/en/cookbook/resolve-target-entity-listener.rst @@ -127,7 +127,7 @@ the targetEntity resolution will occur reliably: // Add the ResolveTargetEntityListener $evm->addEventListener(Doctrine\ORM\Events::loadClassMetadata, $rtel); - $connection = \Doctrine\DBAL\DriverManager::createConnection($connectionOptions, $config, $evm); + $connection = \Doctrine\DBAL\DriverManager::getConnection($connectionOptions, $config, $evm); $em = new \Doctrine\ORM\EntityManager($connection, $config, $evm); Final Thoughts diff --git a/docs/en/reference/dql-doctrine-query-language.rst b/docs/en/reference/dql-doctrine-query-language.rst index d3ad8bca845..80d41f17002 100644 --- a/docs/en/reference/dql-doctrine-query-language.rst +++ b/docs/en/reference/dql-doctrine-query-language.rst @@ -464,6 +464,11 @@ hierarchies: $query = $em->createQuery('SELECT u FROM Doctrine\Tests\Models\Company\CompanyPerson u WHERE u INSTANCE OF Doctrine\Tests\Models\Company\CompanyEmployee'); $query = $em->createQuery('SELECT u FROM Doctrine\Tests\Models\Company\CompanyPerson u WHERE u INSTANCE OF ?1'); $query = $em->createQuery('SELECT u FROM Doctrine\Tests\Models\Company\CompanyPerson u WHERE u NOT INSTANCE OF ?1'); + $query->setParameter(0, $em->getClassMetadata(CompanyEmployee::class)); + +.. note:: + To use a class as parameter, you have to bind its class metadata: + ``$query->setParameter(0, $em->getClassMetadata(CompanyEmployee::class);``. Get all users visible on a given website that have chosen certain gender: diff --git a/docs/en/reference/limitations-and-known-issues.rst b/docs/en/reference/limitations-and-known-issues.rst index 6c650a8ae8d..d261355459e 100644 --- a/docs/en/reference/limitations-and-known-issues.rst +++ b/docs/en/reference/limitations-and-known-issues.rst @@ -1,10 +1,10 @@ Limitations and Known Issues ============================ -We try to make using Doctrine2 a very pleasant experience. +We try to make using Doctrine ORM a very pleasant experience. Therefore we think it is very important to be honest about the current limitations to our users. Much like every other piece of -software Doctrine2 is not perfect and far from feature complete. +software the ORM is not perfect and far from feature complete. This section should give you an overview of current limitations of Doctrine ORM as well as critical known issues that you should know about. @@ -166,6 +166,18 @@ due to complexity. XML mapping configuration probably needs to completely re-configure or otherwise copy-and-paste configuration for fields used from traits. +Mapping multiple private fields of the same name +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When two classes, say a mapped superclass and an entity inheriting from it, +both contain a ``private`` field of the same name, this will lead to a ``MappingException``. + +Since the fields are ``private``, both are technically separate and can contain +different values at the same time. However, the ``ClassMetadata`` configuration used +internally by the ORM currently refers to fields by their name only, without taking the +class containing the field into consideration. This makes it impossible to keep separate +mapping configuration for both fields. + Known Issues ------------ diff --git a/lib/Doctrine/ORM/Internal/TopologicalSort.php b/lib/Doctrine/ORM/Internal/TopologicalSort.php index 798d9202779..808bc0f5a42 100644 --- a/lib/Doctrine/ORM/Internal/TopologicalSort.php +++ b/lib/Doctrine/ORM/Internal/TopologicalSort.php @@ -7,8 +7,6 @@ use Doctrine\ORM\Internal\TopologicalSort\CycleDetectedException; use function array_keys; -use function array_reverse; -use function array_unshift; use function spl_object_id; /** @@ -89,18 +87,14 @@ public function addEdge(object $from, object $to, bool $optional): void /** * Returns a topological sort of all nodes. When we have an edge A->B between two nodes - * A and B, then A will be listed before B in the result. + * A and B, then B will be listed before A in the result. Visually speaking, when ordering + * the nodes in the result order from left to right, all edges point to the left. * * @return list */ public function sort(): array { - /* - * When possible, keep objects in the result in the same order in which they were added as nodes. - * Since nodes are unshifted into $this->>sortResult (see the visit() method), that means we - * need to work them in array_reverse order here. - */ - foreach (array_reverse(array_keys($this->nodes)) as $oid) { + foreach (array_keys($this->nodes) as $oid) { if ($this->states[$oid] === self::NOT_VISITED) { $this->visit($oid); } @@ -143,7 +137,7 @@ private function visit(int $oid): void } // We have found a cycle and cannot break it at $edge. Best we can do - // is to retreat from the current vertex, hoping that somewhere up the + // is to backtrack from the current vertex, hoping that somewhere up the // stack this can be salvaged. $this->states[$oid] = self::NOT_VISITED; $exception->addToCycle($this->nodes[$oid]); @@ -156,6 +150,6 @@ private function visit(int $oid): void // So we're done with this vertex as well. $this->states[$oid] = self::VISITED; - array_unshift($this->sortResult, $this->nodes[$oid]); + $this->sortResult[] = $this->nodes[$oid]; } } diff --git a/lib/Doctrine/ORM/Tools/Console/Command/ValidateSchemaCommand.php b/lib/Doctrine/ORM/Tools/Console/Command/ValidateSchemaCommand.php index 0df11609bfd..cffb4ce4358 100644 --- a/lib/Doctrine/ORM/Tools/Console/Command/ValidateSchemaCommand.php +++ b/lib/Doctrine/ORM/Tools/Console/Command/ValidateSchemaCommand.php @@ -27,6 +27,7 @@ protected function configure(): void ->addOption('em', null, InputOption::VALUE_REQUIRED, 'Name of the entity manager to operate on') ->addOption('skip-mapping', null, InputOption::VALUE_NONE, 'Skip the mapping validation check') ->addOption('skip-sync', null, InputOption::VALUE_NONE, 'Skip checking if the mapping is in sync with the database') + ->addOption('skip-property-types', null, InputOption::VALUE_NONE, 'Skip checking if property types match the Doctrine types') ->setHelp('Validate that the mapping files are correct and in sync with the database.'); } @@ -35,7 +36,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $ui = (new SymfonyStyle($input, $output))->getErrorStyle(); $em = $this->getEntityManager($input); - $validator = new SchemaValidator($em); + $validator = new SchemaValidator($em, ! $input->getOption('skip-property-types')); $exit = 0; $ui->section('Mapping'); diff --git a/lib/Doctrine/ORM/Tools/SchemaValidator.php b/lib/Doctrine/ORM/Tools/SchemaValidator.php index ffadba69d36..0e77e59fff8 100644 --- a/lib/Doctrine/ORM/Tools/SchemaValidator.php +++ b/lib/Doctrine/ORM/Tools/SchemaValidator.php @@ -38,6 +38,7 @@ use function get_class; use function implode; use function in_array; +use function interface_exists; use function is_a; use function sprintf; @@ -68,8 +69,10 @@ class SchemaValidator TextType::class => 'string', ]; - public function __construct(private readonly EntityManagerInterface $em) - { + public function __construct( + private readonly EntityManagerInterface $em, + private readonly bool $validatePropertyTypes = true, + ) { } /** @@ -118,7 +121,7 @@ public function validateClass(ClassMetadata $class): array } // PHP 7.4 introduces the ability to type properties, so we can't validate them in previous versions - if (PHP_VERSION_ID >= 70400) { + if (PHP_VERSION_ID >= 70400 && $this->validatePropertyTypes) { array_push($ce, ...$this->validatePropertiesTypes($class)); } @@ -340,7 +343,7 @@ function (FieldMapping $fieldMapping) use ($class): string|null { } // If the property type is not a named type, we cannot check it - if (! ($propertyType instanceof ReflectionNamedType)) { + if (! ($propertyType instanceof ReflectionNamedType) || $propertyType->getName() === 'mixed') { return null; } @@ -358,10 +361,20 @@ function (FieldMapping $fieldMapping) use ($class): string|null { return null; } - if ( - is_a($propertyType, BackedEnum::class, true) - && $metadataFieldType === (string) (new ReflectionEnum($propertyType))->getBackingType() - ) { + if (is_a($propertyType, BackedEnum::class, true)) { + $backingType = (string) (new ReflectionEnum($propertyType))->getBackingType(); + + if ($metadataFieldType !== $backingType) { + return sprintf( + "The field '%s#%s' has the property type '%s' with a backing type of '%s' that differs from the metadata field type '%s'.", + $class->name, + $fieldName, + $propertyType, + $backingType, + $metadataFieldType, + ); + } + if (! isset($fieldMapping['enumType']) || $propertyType === $fieldMapping['enumType']) { return null; } @@ -375,6 +388,35 @@ function (FieldMapping $fieldMapping) use ($class): string|null { ); } + if ( + isset($fieldMapping['enumType']) + && $propertyType !== $fieldMapping['enumType'] + && interface_exists($propertyType) + && is_a($fieldMapping['enumType'], $propertyType, true) + ) { + $backingType = (string) (new ReflectionEnum($fieldMapping['enumType']))->getBackingType(); + + if ($metadataFieldType === $backingType) { + return null; + } + + return sprintf( + "The field '%s#%s' has the metadata enumType '%s' with a backing type of '%s' that differs from the metadata field type '%s'.", + $class->name, + $fieldName, + $fieldMapping['enumType'], + $backingType, + $metadataFieldType, + ); + } + + if ( + $fieldMapping['type'] === 'json' + && in_array($propertyType, ['string', 'int', 'float', 'bool', 'true', 'false', 'null'], true) + ) { + return null; + } + return sprintf( "The field '%s#%s' has the property type '%s' that differs from the metadata field type '%s' returned by the '%s' DBAL type.", $class->name, diff --git a/lib/Doctrine/ORM/UnitOfWork.php b/lib/Doctrine/ORM/UnitOfWork.php index 4cfcb346224..84c70944576 100644 --- a/lib/Doctrine/ORM/UnitOfWork.php +++ b/lib/Doctrine/ORM/UnitOfWork.php @@ -1229,9 +1229,10 @@ private function computeInsertExecutionOrder(): array $joinColumns = reset($assoc->joinColumns); $isNullable = ! isset($joinColumns->nullable) || $joinColumns->nullable; - // Add dependency. The dependency direction implies that "$targetEntity has to go before $entity", - // so we can work through the topo sort result from left to right (with all edges pointing right). - $sort->addEdge($targetEntity, $entity, $isNullable); + // Add dependency. The dependency direction implies that "$entity depends on $targetEntity". The + // topological sort result will output the depended-upon nodes first, which means we can insert + // entities in that order. + $sort->addEdge($entity, $targetEntity, $isNullable); } } @@ -1282,9 +1283,10 @@ private function computeDeleteExecutionOrder(): array continue; } - // Add dependency. The dependency direction implies that "$entity has to be removed before $targetEntity", - // so we can work through the topo sort result from left to right (with all edges pointing right). - $sort->addEdge($entity, $targetEntity, false); + // Add dependency. The dependency direction implies that "$targetEntity depends on $entity + // being deleted first". The topological sort will output the depended-upon nodes first, + // so we can work through the result in the returned order. + $sort->addEdge($targetEntity, $entity, false); } } diff --git a/tests/Doctrine/Tests/ORM/Functional/ParserResultSerializationTest.php b/tests/Doctrine/Tests/ORM/Functional/ParserResultSerializationTest.php index 680e0657817..e927ba5af5f 100644 --- a/tests/Doctrine/Tests/ORM/Functional/ParserResultSerializationTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/ParserResultSerializationTest.php @@ -47,7 +47,7 @@ public function testSerializeParserResult(Closure $toSerializedAndBack): void } /** @return Generator */ - public function provideToSerializedAndBack(): Generator + public static function provideToSerializedAndBack(): Generator { yield 'native serialization function' => [ static function (ParserResult $parserResult): ParserResult { @@ -57,7 +57,7 @@ static function (ParserResult $parserResult): ParserResult { $instantiatorMethod = new ReflectionMethod(Instantiator::class, 'instantiate'); if ($instantiatorMethod->getReturnType() === null) { - $this->markTestSkipped('symfony/var-exporter 5.4+ is required.'); + self::markTestSkipped('symfony/var-exporter 5.4+ is required.'); } yield 'symfony/var-exporter' => [ diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10661/GH10661Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10661/GH10661Test.php index d636f1e1689..b3274569692 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10661/GH10661Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10661/GH10661Test.php @@ -13,32 +13,36 @@ final class GH10661Test extends OrmTestCase /** @var EntityManagerInterface */ private $em; - /** @var SchemaValidator */ - private $validator; - protected function setUp(): void { - $this->em = $this->getTestEntityManager(); - $this->validator = new SchemaValidator($this->em); + $this->em = $this->getTestEntityManager(); } public function testMetadataFieldTypeNotCoherentWithEntityPropertyType(): void { $class = $this->em->getClassMetadata(InvalidEntity::class); - $ce = $this->validator->validateClass($class); + $ce = $this->bootstrapValidator()->validateClass($class); - self::assertEquals( + self::assertSame( ["The field 'Doctrine\Tests\ORM\Functional\Ticket\GH10661\InvalidEntity#property1' has the property type 'float' that differs from the metadata field type 'string' returned by the 'decimal' DBAL type."], $ce, ); } + public function testPropertyTypeErrorsCanBeSilenced(): void + { + $class = $this->em->getClassMetadata(InvalidEntity::class); + $ce = $this->bootstrapValidator(false)->validateClass($class); + + self::assertSame([], $ce); + } + public function testMetadataFieldTypeNotCoherentWithEntityPropertyTypeWithInheritance(): void { $class = $this->em->getClassMetadata(InvalidChildEntity::class); - $ce = $this->validator->validateClass($class); + $ce = $this->bootstrapValidator()->validateClass($class); - self::assertEquals( + self::assertSame( [ "The field 'Doctrine\Tests\ORM\Functional\Ticket\GH10661\InvalidChildEntity#property1' has the property type 'float' that differs from the metadata field type 'string' returned by the 'decimal' DBAL type.", "The field 'Doctrine\Tests\ORM\Functional\Ticket\GH10661\InvalidChildEntity#property2' has the property type 'int' that differs from the metadata field type 'string' returned by the 'string' DBAL type.", @@ -47,4 +51,9 @@ public function testMetadataFieldTypeNotCoherentWithEntityPropertyTypeWithInheri $ce, ); } + + private function bootstrapValidator(bool $validatePropertyTypes = true): SchemaValidator + { + return new SchemaValidator($this->em, $validatePropertyTypes); + } } diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH11037/EntityStatus.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH11037/EntityStatus.php new file mode 100644 index 00000000000..f94c44affe0 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH11037/EntityStatus.php @@ -0,0 +1,9 @@ +setUpEntitySchema([ + GH11058Parent::class, + GH11058Child::class, + ]); + } + + public function testChildrenInsertedInOrderOfPersistCalls1WhenParentPersistedLast(): void + { + [$parent, $child1, $child2] = $this->createParentWithTwoChildEntities(); + + $this->_em->persist($child1); + $this->_em->persist($child2); + $this->_em->persist($parent); + $this->_em->flush(); + + self::assertTrue($child1->id < $child2->id); + } + + public function testChildrenInsertedInOrderOfPersistCalls2WhenParentPersistedLast(): void + { + [$parent, $child1, $child2] = $this->createParentWithTwoChildEntities(); + + $this->_em->persist($child2); + $this->_em->persist($child1); + $this->_em->persist($parent); + $this->_em->flush(); + + self::assertTrue($child2->id < $child1->id); + } + + public function testChildrenInsertedInOrderOfPersistCalls1WhenParentPersistedFirst(): void + { + [$parent, $child1, $child2] = $this->createParentWithTwoChildEntities(); + + $this->_em->persist($parent); + $this->_em->persist($child1); + $this->_em->persist($child2); + $this->_em->flush(); + + self::assertTrue($child1->id < $child2->id); + } + + public function testChildrenInsertedInOrderOfPersistCalls2WhenParentPersistedFirst(): void + { + [$parent, $child1, $child2] = $this->createParentWithTwoChildEntities(); + + $this->_em->persist($parent); + $this->_em->persist($child2); + $this->_em->persist($child1); + $this->_em->flush(); + + self::assertTrue($child2->id < $child1->id); + } + + private function createParentWithTwoChildEntities(): array + { + $parent = new GH11058Parent(); + $child1 = new GH11058Child(); + $child2 = new GH11058Child(); + + $parent->addChild($child1); + $parent->addChild($child2); + + return [$parent, $child1, $child2]; + } +} + +#[ORM\Entity] +class GH11058Parent +{ + /** @var int */ + #[ORM\Id] + #[ORM\Column(type: 'integer')] + #[ORM\GeneratedValue] + public $id; + + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'parent', targetEntity: GH11058Child::class)] + public Collection $children; + + public function __construct() + { + $this->children = new ArrayCollection(); + } + + public function addChild(GH11058Child $child): void + { + if (! $this->children->contains($child)) { + $this->children->add($child); + $child->setParent($this); + } + } +} + +#[ORM\Entity] +class GH11058Child +{ + /** @var int */ + #[ORM\Id] + #[ORM\Column(type: 'integer')] + #[ORM\GeneratedValue] + public $id; + + /** @var GH11058Parent */ + #[ORM\ManyToOne(inversedBy: 'children', targetEntity: GH11058Parent::class)] + public $parent; + + public function setParent(GH11058Parent $parent): void + { + $this->parent = $parent; + $parent->addChild($this); + } +} diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH11072/GH11072EntityAdvanced.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH11072/GH11072EntityAdvanced.php new file mode 100644 index 00000000000..00c33a6e005 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH11072/GH11072EntityAdvanced.php @@ -0,0 +1,32 @@ +_em = $this->getTestEntityManager(); + $this->validator = new SchemaValidator($this->_em); + } + + public function testAcceptsSubsetOfBuiltinTypesWithoutErrors(): void + { + $class = $this->_em->getClassMetadata(GH11072EntityBasic::class); + $ce = $this->validator->validateClass($class); + + self::assertSame([], $ce); + } + + public function testAcceptsAdvancedSubsetOfBuiltinTypesWithoutErrors(): void + { + $class = $this->_em->getClassMetadata(GH11072EntityAdvanced::class); + $ce = $this->validator->validateClass($class); + + self::assertSame([], $ce); + } +} diff --git a/tests/Doctrine/Tests/ORM/Internal/TopologicalSortTest.php b/tests/Doctrine/Tests/ORM/Internal/TopologicalSortTest.php index 395aee954f9..12855a426c0 100644 --- a/tests/Doctrine/Tests/ORM/Internal/TopologicalSortTest.php +++ b/tests/Doctrine/Tests/ORM/Internal/TopologicalSortTest.php @@ -34,7 +34,7 @@ public function testSimpleOrdering(): void $this->addEdge('E', 'A'); // There is only 1 valid ordering for this constellation - self::assertSame(['E', 'A', 'B', 'C'], $this->computeResult()); + self::assertSame(['C', 'B', 'A', 'E'], $this->computeResult()); } public function testSkipOptionalEdgeToBreakCycle(): void @@ -44,7 +44,7 @@ public function testSkipOptionalEdgeToBreakCycle(): void $this->addEdge('A', 'B', true); $this->addEdge('B', 'A', false); - self::assertSame(['B', 'A'], $this->computeResult()); + self::assertSame(['A', 'B'], $this->computeResult()); } public function testBreakCycleByBacktracking(): void @@ -57,7 +57,7 @@ public function testBreakCycleByBacktracking(): void $this->addEdge('D', 'A'); // closes the cycle // We can only break B -> C, so the result must be C -> D -> A -> B - self::assertSame(['C', 'D', 'A', 'B'], $this->computeResult()); + self::assertSame(['B', 'A', 'D', 'C'], $this->computeResult()); } public function testCycleRemovedByEliminatingLastOptionalEdge(): void @@ -75,7 +75,7 @@ public function testCycleRemovedByEliminatingLastOptionalEdge(): void $this->addEdge('B', 'D', true); $this->addEdge('D', 'A'); - self::assertSame(['C', 'D', 'A', 'B'], $this->computeResult()); + self::assertSame(['B', 'A', 'C', 'D'], $this->computeResult()); } public function testGH7180Example(): void @@ -89,7 +89,7 @@ public function testGH7180Example(): void $this->addEdge('F', 'E'); $this->addEdge('E', 'D'); - self::assertSame(['F', 'E', 'D', 'G'], $this->computeResult()); + self::assertSame(['G', 'D', 'E', 'F'], $this->computeResult()); } public function testCommitOrderingFromGH7259Test(): void @@ -106,9 +106,9 @@ public function testCommitOrderingFromGH7259Test(): void // the D -> A -> B ordering is important to break the cycle // on the nullable link. $correctOrders = [ - ['D', 'A', 'B', 'C'], - ['D', 'A', 'C', 'B'], - ['D', 'C', 'A', 'B'], + ['C', 'B', 'A', 'D'], + ['B', 'C', 'A', 'D'], + ['B', 'A', 'C', 'D'], ]; self::assertContains($this->computeResult(), $correctOrders); @@ -124,12 +124,12 @@ public function testCommitOrderingFromGH8349Case1Test(): void $this->addEdge('B', 'C', true); $this->addEdge('C', 'D', true); - // Many orderings are possible here, but the bottom line is D must be before A (it's the only hard requirement). + // Many orderings are possible here, but the bottom line is A must be before D (it's the only hard requirement). $result = $this->computeResult(); $indexA = array_search('A', $result, true); $indexD = array_search('D', $result, true); - self::assertTrue($indexD < $indexA); + self::assertTrue($indexD > $indexA); } public function testCommitOrderingFromGH8349Case2Test(): void @@ -141,7 +141,7 @@ public function testCommitOrderingFromGH8349Case2Test(): void $this->addEdge('A', 'B', true); // The B -> A requirement determines the result here - self::assertSame(['B', 'A'], $this->computeResult()); + self::assertSame(['A', 'B'], $this->computeResult()); } public function testNodesMaintainOrderWhenNoDepencency(): void @@ -153,6 +153,58 @@ public function testNodesMaintainOrderWhenNoDepencency(): void self::assertSame(['A', 'B', 'C'], $this->computeResult()); } + public function testNodesReturnedInDepthFirstOrder(): void + { + $this->addNodes('A', 'B', 'C'); + $this->addEdge('A', 'B'); + $this->addEdge('A', 'C'); + + // We start on A and find that it has two dependencies on B and C, + // added (as dependencies) in that order. + // So, first we continue the DFS on B, because that edge was added first. + // This gives the result order B, C, A. + self::assertSame(['B', 'C', 'A'], $this->computeResult()); + } + + public function testNodesReturnedInDepthFirstOrderWithEdgesInDifferentOrderThanNodes(): void + { + $this->addNodes('A', 'B', 'C'); + $this->addEdge('A', 'C'); + $this->addEdge('A', 'B'); + + // This is like testNodesReturnedInDepthFirstOrder, but it shows that for the two + // nodes B and C that A depends upon, the result will follow the order in which + // the edges were added. + self::assertSame(['C', 'B', 'A'], $this->computeResult()); + } + + public function testNodesReturnedInDepthFirstOrderWithDependingNodeLast(): void + { + $this->addNodes('B', 'C', 'A'); + $this->addEdge('A', 'B'); + $this->addEdge('A', 'C'); + + // This again is like testNodesReturnedInDepthFirstOrder, but this + // time the node A that depends on B and C is added as the last node. + // That means processing can go over B and C in the order they were given. + // The order in which edges are added is not relevant (!), since at the time + // the edges are evaluated, the nodes they point to have already been finished. + self::assertSame(['B', 'C', 'A'], $this->computeResult()); + } + + public function testNodesReturnedInDepthFirstOrderWithDependingNodeLastAndEdgeOrderInversed(): void + { + $this->addNodes('B', 'C', 'A'); + $this->addEdge('A', 'C'); + $this->addEdge('A', 'B'); + + // This again is like testNodesReturnedInDepthFirstOrderWithDependingNodeLast, but adds + // the edges in the opposing order. Still, the result order is the same (!). + // This may be surprising when comparing with testNodesReturnedInDepthFirstOrderWithEdgesInDifferentOrderThanNodes, + // where the result order depends upon the _edge_ order. + self::assertSame(['B', 'C', 'A'], $this->computeResult()); + } + public function testDetectSmallCycle(): void { $this->addNodes('A', 'B'); @@ -205,7 +257,7 @@ public function testDetectLargerCycleNotIncludingStartNode(): void $this->computeResult(); } catch (CycleDetectedException $exception) { self::assertEquals( - [$this->nodes['D'], $this->nodes['B'], $this->nodes['C'], $this->nodes['D']], + [$this->nodes['B'], $this->nodes['C'], $this->nodes['D'], $this->nodes['B']], $exception->getCycle(), ); }