diff --git a/doctrine-mapping.xsd b/doctrine-mapping.xsd
index f1f8db814..c0dbfafcc 100644
--- a/doctrine-mapping.xsd
+++ b/doctrine-mapping.xsd
@@ -257,6 +257,7 @@
@@ -717,6 +722,11 @@ class ClassMetadataInfo } $mapping['isOwningSide'] = true; // assume owning side until we hit mappedBy + // unset optional indexBy attribute if its empty + if (!isset($mapping['indexBy']) || !$mapping['indexBy']) { + unset($mapping['indexBy']); + } + // If targetEntity is unqualified, assume it is in the same namespace as // the sourceEntity. $mapping['sourceEntity'] = $this->name; diff --git a/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php b/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php index 81dcc90da..9eb83c2b8 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php +++ b/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php @@ -304,6 +304,7 @@ class AnnotationDriver implements Driver $mapping['mappedBy'] = $oneToManyAnnot->mappedBy; $mapping['targetEntity'] = $oneToManyAnnot->targetEntity; $mapping['cascade'] = $oneToManyAnnot->cascade; + $mapping['indexBy'] = $oneToManyAnnot->indexBy; $mapping['orphanRemoval'] = $oneToManyAnnot->orphanRemoval; $mapping['fetch'] = constant('Doctrine\ORM\Mapping\ClassMetadata::FETCH_' . $oneToManyAnnot->fetch); @@ -362,6 +363,7 @@ class AnnotationDriver implements Driver $mapping['mappedBy'] = $manyToManyAnnot->mappedBy; $mapping['inversedBy'] = $manyToManyAnnot->inversedBy; $mapping['cascade'] = $manyToManyAnnot->cascade; + $mapping['indexBy'] = $manyToManyAnnot->indexBy; $mapping['fetch'] = constant('Doctrine\ORM\Mapping\ClassMetadata::FETCH_' . $manyToManyAnnot->fetch); if ($orderByAnnot = $this->_reader->getPropertyAnnotation($property, 'Doctrine\ORM\Mapping\OrderBy')) { diff --git a/lib/Doctrine/ORM/Mapping/Driver/DoctrineAnnotations.php b/lib/Doctrine/ORM/Mapping/Driver/DoctrineAnnotations.php index 435676566..ef566a083 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/DoctrineAnnotations.php +++ b/lib/Doctrine/ORM/Mapping/Driver/DoctrineAnnotations.php @@ -80,6 +80,7 @@ final class OneToMany extends Annotation { public $cascade; public $fetch = 'LAZY'; public $orphanRemoval = false; + public $indexBy; } final class ManyToOne extends Annotation { public $targetEntity; @@ -93,6 +94,7 @@ final class ManyToMany extends Annotation { public $inversedBy; public $cascade; public $fetch = 'LAZY'; + public $indexBy; } final class ElementCollection extends Annotation { public $tableName; diff --git a/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php b/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php index cd84849ae..3ec712b70 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php +++ b/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php @@ -309,6 +309,10 @@ class XmlDriver extends AbstractFileDriver $mapping['orderBy'] = $orderBy; } + if (isset($oneToManyElement->{'index-by'})) { + $mapping['indexBy'] = (string)$oneToManyElement->{'index-by'}; + } + $metadata->mapOneToMany($mapping); } } @@ -415,6 +419,10 @@ class XmlDriver extends AbstractFileDriver $mapping['orderBy'] = $orderBy; } + if (isset($manyToManyElement->{'index-by'})) { + $mapping['indexBy'] = (string)$manyToManyElement->{'index-by'}; + } + $metadata->mapManyToMany($mapping); } } diff --git a/lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php b/lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php index 0a6c6d0bd..0f88474f1 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php +++ b/lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php @@ -301,6 +301,10 @@ class YamlDriver extends AbstractFileDriver $mapping['orderBy'] = $oneToManyElement['orderBy']; } + if (isset($oneToManyElement['indexBy'])) { + $mapping['indexBy'] = $oneToManyElement['indexBy']; + } + $metadata->mapOneToMany($mapping); } } @@ -404,6 +408,10 @@ class YamlDriver extends AbstractFileDriver $mapping['orderBy'] = $manyToManyElement['orderBy']; } + if (isset($manyToManyElement['indexBy'])) { + $mapping['indexBy'] = $manyToManyElement['indexBy']; + } + $metadata->mapManyToMany($mapping); } } diff --git a/lib/Doctrine/ORM/Persisters/BasicEntityPersister.php b/lib/Doctrine/ORM/Persisters/BasicEntityPersister.php index 242a500df..56e91e49a 100644 --- a/lib/Doctrine/ORM/Persisters/BasicEntityPersister.php +++ b/lib/Doctrine/ORM/Persisters/BasicEntityPersister.php @@ -750,15 +750,55 @@ class BasicEntityPersister public function getManyToManyCollection(array $assoc, $sourceEntity, $offset = null, $limit = null) { $stmt = $this->getManyToManyStatement($assoc, $sourceEntity, $offset, $limit); + return $this->loadArrayFromStatement($assoc, $stmt); + } + /** + * Load an array of entities from a given dbal statement. + * + * @param array $assoc + * @param Doctrine\DBAL\Statement $stmt + * @return array + */ + private function loadArrayFromStatement($assoc, $stmt) + { $entities = array(); - while ($result = $stmt->fetch(PDO::FETCH_ASSOC)) { - $entities[] = $this->_createEntity($result); + if (isset($assoc['indexBy'])) { + while ($result = $stmt->fetch(PDO::FETCH_ASSOC)) { + $entity = $this->_createEntity($result); + $entities[$this->_class->reflFields[$assoc['indexBy']]->getValue($entity)] = $entity; + } + } else { + while ($result = $stmt->fetch(PDO::FETCH_ASSOC)) { + $entities[] = $this->_createEntity($result); + } } $stmt->closeCursor(); return $entities; } + /** + * Hydrate a collection from a given dbal statement. + * + * @param array $assoc + * @param Doctrine\DBAL\Statement $stmt + * @param PersistentCollection $coll + */ + private function loadCollectionFromStatement($assoc, $stmt, $coll) + { + if (isset($assoc['indexBy'])) { + while ($result = $stmt->fetch(PDO::FETCH_ASSOC)) { + $entity = $this->_createEntity($result); + $coll->hydrateSet($this->_class->reflFields[$assoc['indexBy']]->getValue($entity), $entity); + } + } else { + while ($result = $stmt->fetch(PDO::FETCH_ASSOC)) { + $coll->hydrateAdd($this->_createEntity($result)); + } + } + $stmt->closeCursor(); + } + /** * Loads a collection of entities of a many-to-many association. * @@ -772,11 +812,7 @@ class BasicEntityPersister public function loadManyToManyCollection(array $assoc, $sourceEntity, PersistentCollection $coll) { $stmt = $this->getManyToManyStatement($assoc, $sourceEntity); - - while ($result = $stmt->fetch(PDO::FETCH_ASSOC)) { - $coll->hydrateAdd($this->_createEntity($result)); - } - $stmt->closeCursor(); + return $this->loadCollectionFromStatement($assoc, $stmt, $coll); } private function getManyToManyStatement(array $assoc, $sourceEntity, $offset = null, $limit = null) @@ -1238,13 +1274,7 @@ class BasicEntityPersister public function getOneToManyCollection(array $assoc, $sourceEntity, $offset = null, $limit = null) { $stmt = $this->getOneToManyStatement($assoc, $sourceEntity, $offset, $limit); - - $entities = array(); - while ($result = $stmt->fetch(PDO::FETCH_ASSOC)) { - $entities[] = $this->_createEntity($result); - } - $stmt->closeCursor(); - return $entities; + return $this->loadArrayFromStatement($assoc, $stmt); } /** @@ -1259,11 +1289,7 @@ class BasicEntityPersister public function loadOneToManyCollection(array $assoc, $sourceEntity, PersistentCollection $coll) { $stmt = $this->getOneToManyStatement($assoc, $sourceEntity); - - while ($result = $stmt->fetch(PDO::FETCH_ASSOC)) { - $coll->hydrateAdd($this->_createEntity($result)); - } - $stmt->closeCursor(); + $this->loadCollectionFromStatement($assoc, $stmt, $coll); } /** diff --git a/lib/Doctrine/ORM/Query/Parser.php b/lib/Doctrine/ORM/Query/Parser.php index 0c22c9fbf..63351a89a 100644 --- a/lib/Doctrine/ORM/Query/Parser.php +++ b/lib/Doctrine/ORM/Query/Parser.php @@ -923,6 +923,10 @@ class Parser $token = $this->_lexer->lookahead; $identVariable = $this->IdentificationVariable(); + if (!isset($this->_queryComponents[$identVariable])) { + $this->semanticalError('Identification Variable ' . $identVariable .' used in join path expression but was not defined before.'); + } + $this->match(Lexer::T_DOT); $this->match(Lexer::T_IDENTIFIER); diff --git a/lib/Doctrine/ORM/Query/SqlWalker.php b/lib/Doctrine/ORM/Query/SqlWalker.php index ebbefbce8..7e7b832a6 100644 --- a/lib/Doctrine/ORM/Query/SqlWalker.php +++ b/lib/Doctrine/ORM/Query/SqlWalker.php @@ -750,6 +750,16 @@ class SqlWalker implements TreeWalker } } + if ($joinVarDecl->indexBy) { + // For Many-To-One or One-To-One associations this obviously makes no sense, but is ignored silently. + $this->_rsm->addIndexBy( + $joinVarDecl->indexBy->simpleStateFieldPathExpression->identificationVariable, + $joinVarDecl->indexBy->simpleStateFieldPathExpression->field + ); + } else if (isset($relation['indexBy'])) { + $this->_rsm->addIndexBy($joinedDqlAlias, $relation['indexBy']); + } + // This condition is not checking ClassMetadata::MANY_TO_ONE, because by definition it cannot // be the owning side and previously we ensured that $assoc is always the owning side of the associations. // The owning side is necessary at this point because only it contains the JoinColumn information. diff --git a/tests/Doctrine/Tests/Models/StockExchange/Bond.php b/tests/Doctrine/Tests/Models/StockExchange/Bond.php new file mode 100644 index 000000000..c8d661782 --- /dev/null +++ b/tests/Doctrine/Tests/Models/StockExchange/Bond.php @@ -0,0 +1,48 @@ +name = $name; + } + + public function getId() + { + return $this->id; + } + + public function addStock(Stock $stock) + { + $this->stocks[$stock->getSymbol()] = $stock; + } +} \ No newline at end of file diff --git a/tests/Doctrine/Tests/Models/StockExchange/Market.php b/tests/Doctrine/Tests/Models/StockExchange/Market.php new file mode 100644 index 000000000..87e12cab6 --- /dev/null +++ b/tests/Doctrine/Tests/Models/StockExchange/Market.php @@ -0,0 +1,56 @@ +name = $name; + $this->stocks = new ArrayCollection(); + } + + public function getId() + { + return $this->id; + } + + public function getName() + { + return $this->name; + } + + public function addStock(Stock $stock) + { + $this->stocks[$stock->getSymbol()] = $stock; + } + + public function getStock($symbol) + { + return $this->stocks[$symbol]; + } +} \ No newline at end of file diff --git a/tests/Doctrine/Tests/Models/StockExchange/Stock.php b/tests/Doctrine/Tests/Models/StockExchange/Stock.php new file mode 100644 index 000000000..d65675be9 --- /dev/null +++ b/tests/Doctrine/Tests/Models/StockExchange/Stock.php @@ -0,0 +1,49 @@ +symbol = $symbol; + $this->price = $initialOfferingPrice; + $this->market = $market; + $market->addStock($this); + } + + public function getSymbol() + { + return $this->symbol; + } +} \ No newline at end of file diff --git a/tests/Doctrine/Tests/ORM/Functional/AllTests.php b/tests/Doctrine/Tests/ORM/Functional/AllTests.php index 0759bcf7f..319d5bb50 100644 --- a/tests/Doctrine/Tests/ORM/Functional/AllTests.php +++ b/tests/Doctrine/Tests/ORM/Functional/AllTests.php @@ -45,6 +45,7 @@ class AllTests $suite->addTestSuite('Doctrine\Tests\ORM\Functional\ManyToManySelfReferentialAssociationTest'); $suite->addTestSuite('Doctrine\Tests\ORM\Functional\OrderedCollectionTest'); $suite->addTestSuite('Doctrine\Tests\ORM\Functional\OrderedJoinedTableInheritanceCollectionTest'); + $suite->addTestSuite('Doctrine\Tests\ORM\Functional\IndexByAssociationTest'); $suite->addTestSuite('Doctrine\Tests\ORM\Functional\CompositePrimaryKeyTest'); $suite->addTestSuite('Doctrine\Tests\ORM\Functional\ReferenceProxyTest'); $suite->addTestSuite('Doctrine\Tests\ORM\Functional\LifecycleCallbackTest'); diff --git a/tests/Doctrine/Tests/ORM/Functional/IndexByAssociationTest.php b/tests/Doctrine/Tests/ORM/Functional/IndexByAssociationTest.php new file mode 100644 index 000000000..bca4ea5df --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/IndexByAssociationTest.php @@ -0,0 +1,105 @@ +useModelSet('stockexchange'); + parent::setUp(); + $this->loadFixture(); + } + + public function loadFixture() + { + $this->market = new Market("Some Exchange"); + $stock1 = new Stock("AAPL", 10, $this->market); + $stock2 = new Stock("GOOG", 20, $this->market); + + $this->bond = new Bond("MyBond"); + $this->bond->addStock($stock1); + $this->bond->addStock($stock2); + + $this->_em->persist($this->market); + $this->_em->persist($stock1); + $this->_em->persist($stock2); + $this->_em->persist($this->bond); + $this->_em->flush(); + $this->_em->clear(); + } + + public function testManyToOneFinder() + { + /* @var $market Doctrine\Tests\Models\StockExchange\Market */ + $market = $this->_em->find('Doctrine\Tests\Models\StockExchange\Market', $this->market->getId()); + + $this->assertEquals(2, count($market->stocks)); + $this->assertTrue(isset($market->stocks['AAPL']), "AAPL symbol has to be key in indexed assocation."); + $this->assertTrue(isset($market->stocks['GOOG']), "GOOG symbol has to be key in indexed assocation."); + $this->assertEquals("AAPL", $market->stocks['AAPL']->getSymbol()); + $this->assertEquals("GOOG", $market->stocks['GOOG']->getSymbol()); + } + + public function testManyToOneDQL() + { + $dql = "SELECT m, s FROM Doctrine\Tests\Models\StockExchange\Market m JOIN m.stocks s WHERE m.id = ?1"; + $market = $this->_em->createQuery($dql)->setParameter(1, $this->market->getId())->getSingleResult(); + + $this->assertEquals(2, count($market->stocks)); + $this->assertTrue(isset($market->stocks['AAPL']), "AAPL symbol has to be key in indexed assocation."); + $this->assertTrue(isset($market->stocks['GOOG']), "GOOG symbol has to be key in indexed assocation."); + $this->assertEquals("AAPL", $market->stocks['AAPL']->getSymbol()); + $this->assertEquals("GOOG", $market->stocks['GOOG']->getSymbol()); + } + + public function testManyToMany() + { + $bond = $this->_em->find('Doctrine\Tests\Models\StockExchange\Bond', $this->bond->getId()); + + $this->assertEquals(2, count($bond->stocks)); + $this->assertTrue(isset($bond->stocks['AAPL']), "AAPL symbol has to be key in indexed assocation."); + $this->assertTrue(isset($bond->stocks['GOOG']), "GOOG symbol has to be key in indexed assocation."); + $this->assertEquals("AAPL", $bond->stocks['AAPL']->getSymbol()); + $this->assertEquals("GOOG", $bond->stocks['GOOG']->getSymbol()); + } + + public function testManytoManyDQL() + { + $dql = "SELECT b, s FROM Doctrine\Tests\Models\StockExchange\Bond b JOIN b.stocks s WHERE b.id = ?1"; + $bond = $this->_em->createQuery($dql)->setParameter(1, $this->bond->getId())->getSingleResult(); + + $this->assertEquals(2, count($bond->stocks)); + $this->assertTrue(isset($bond->stocks['AAPL']), "AAPL symbol has to be key in indexed assocation."); + $this->assertTrue(isset($bond->stocks['GOOG']), "GOOG symbol has to be key in indexed assocation."); + $this->assertEquals("AAPL", $bond->stocks['AAPL']->getSymbol()); + $this->assertEquals("GOOG", $bond->stocks['GOOG']->getSymbol()); + } + + public function testDqlOverrideIndexBy() + { + $dql = "SELECT b, s FROM Doctrine\Tests\Models\StockExchange\Bond b JOIN b.stocks s INDEX BY s.id WHERE b.id = ?1"; + $bond = $this->_em->createQuery($dql)->setParameter(1, $this->bond->getId())->getSingleResult(); + + $this->assertEquals(2, count($bond->stocks)); + $this->assertFalse(isset($bond->stocks['AAPL']), "AAPL symbol not exists in re-indexed assocation."); + $this->assertFalse(isset($bond->stocks['GOOG']), "GOOG symbol not exists in re-indexed assocation."); + } +} + diff --git a/tests/Doctrine/Tests/OrmFunctionalTestCase.php b/tests/Doctrine/Tests/OrmFunctionalTestCase.php index d9dd9bc77..166cdfb6a 100644 --- a/tests/Doctrine/Tests/OrmFunctionalTestCase.php +++ b/tests/Doctrine/Tests/OrmFunctionalTestCase.php @@ -99,6 +99,11 @@ abstract class OrmFunctionalTestCase extends OrmTestCase 'Doctrine\Tests\Models\DDC117\DDC117ApproveChanges', 'Doctrine\Tests\Models\DDC117\DDC117Editor', ), + 'stockexchange' => array( + 'Doctrine\Tests\Models\StockExchange\Bond', + 'Doctrine\Tests\Models\StockExchange\Stock', + 'Doctrine\Tests\Models\StockExchange\Market', + ), ); protected function useModelSet($setName) @@ -191,6 +196,12 @@ abstract class OrmFunctionalTestCase extends OrmTestCase $conn->executeUpdate('DELETE FROM DDC117Translation'); $conn->executeUpdate('DELETE FROM DDC117Article'); } + if (isset($this->_usedModelSets['stockexchange'])) { + $conn->executeUpdate('DELETE FROM exchange_bonds_stocks'); + $conn->executeUpdate('DELETE FROM exchange_bonds'); + $conn->executeUpdate('DELETE FROM exchange_stocks'); + $conn->executeUpdate('DELETE FROM exchange_markets'); + } $this->_em->clear(); }