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 @@ + @@ -269,6 +270,7 @@ + diff --git a/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php b/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php index 353f47128..5e96cc4c1 100644 --- a/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php +++ b/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php @@ -376,6 +376,11 @@ class ClassMetadataInfo * Only valid for many-to-many mappings. Note that one-to-many associations can be mapped * through a join table by simply mapping the association as many-to-many with a unique * constraint on the join table. + * + * - indexBy (string, optional, to-many only) + * Specification of a field on target-entity that is used to index the collection by. + * This field HAS to be either the primary key or a unique column. Otherwise the collection + * does not contain all the entities that are actually related. * * A join table definition has the following structure: *
@@ -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();
     }