Skip to content

Commit

Permalink
[DDC-1654] Add support for orphanRemoval on ManyToMany associations. …
Browse files Browse the repository at this point in the history
…This only makes sense when ManyToMany is used as uni-directional OneToMany association with join table. The join column has a unique constraint on it to enforce this on the DB level, but we dont validate that this actually happens. Foreign Key constraints help prevent issues and notify developers early if they use it wrong.
  • Loading branch information
beberlei committed Feb 20, 2012
1 parent 85d1707 commit 68436fe
Show file tree
Hide file tree
Showing 10 changed files with 147 additions and 4 deletions.
1 change: 1 addition & 0 deletions doctrine-mapping.xsd
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@
<xs:attribute name="index-by" type="xs:NMTOKEN" />
<xs:attribute name="inversed-by" type="xs:NMTOKEN" />
<xs:attribute name="fetch" type="orm:fetch-type" default="LAZY" />
<xs:attribute name="orphan-removal" type="xs:boolean" default="false" />
<xs:anyAttribute namespace="##other"/>
</xs:complexType>

Expand Down
4 changes: 3 additions & 1 deletion lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php
Original file line number Diff line number Diff line change
Expand Up @@ -1115,7 +1115,7 @@ protected function _validateAndCompleteAssociationMapping(array $mapping)
$mapping['targetEntity'] = ltrim($mapping['targetEntity'], '\\');
}

if ( ($mapping['type'] & (self::MANY_TO_ONE|self::MANY_TO_MANY)) > 0 &&
if ( ($mapping['type'] & self::MANY_TO_ONE) > 0 &&
isset($mapping['orphanRemoval']) &&
$mapping['orphanRemoval'] == true) {

Expand Down Expand Up @@ -1335,6 +1335,8 @@ protected function _validateAndCompleteManyToManyMapping(array $mapping)
}
}

$mapping['orphanRemoval'] = isset($mapping['orphanRemoval']) ? (bool) $mapping['orphanRemoval'] : false;

if (isset($mapping['orderBy'])) {
if ( ! is_array($mapping['orderBy'])) {
throw new \InvalidArgumentException("'orderBy' is expected to be an array, not ".gettype($mapping['orderBy']));
Expand Down
1 change: 1 addition & 0 deletions lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ public function loadMetadataForClass($className, ClassMetadataInfo $metadata)
$mapping['inversedBy'] = $manyToManyAnnot->inversedBy;
$mapping['cascade'] = $manyToManyAnnot->cascade;
$mapping['indexBy'] = $manyToManyAnnot->indexBy;
$mapping['orphanRemoval'] = $manyToManyAnnot->orphanRemoval;
$mapping['fetch'] = $this->getFetchMode($className, $manyToManyAnnot->fetch);

if ($orderByAnnot = $this->_reader->getPropertyAnnotation($property, 'Doctrine\ORM\Mapping\OrderBy')) {
Expand Down
4 changes: 4 additions & 0 deletions lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,10 @@ public function loadMetadataForClass($className, ClassMetadataInfo $metadata)
$mapping['fetch'] = constant('Doctrine\ORM\Mapping\ClassMetadata::FETCH_' . (string)$manyToManyElement['fetch']);
}

if (isset($manyToManyElement['orphan-removal'])) {
$mapping['orphanRemoval'] = (bool)$manyToManyElement['orphan-removal'];
}

if (isset($manyToManyElement['mapped-by'])) {
$mapping['mappedBy'] = (string)$manyToManyElement['mapped-by'];
} else if (isset($manyToManyElement->{'join-table'})) {
Expand Down
4 changes: 4 additions & 0 deletions lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,10 @@ public function loadMetadataForClass($className, ClassMetadataInfo $metadata)
$mapping['indexBy'] = $manyToManyElement['indexBy'];
}

if (isset($manyToManyElement['orphanRemoval'])) {
$mapping['orphanRemoval'] = (bool)$manyToManyElement['orphanRemoval'];
}

$metadata->mapManyToMany($mapping);
}
}
Expand Down
2 changes: 2 additions & 0 deletions lib/Doctrine/ORM/Mapping/ManyToMany.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ final class ManyToMany implements Annotation
public $cascade;
/** @var string */
public $fetch = 'LAZY';
/** @var boolean */
public $orphanRemoval = false;
/** @var string */
public $indexBy;
}
6 changes: 3 additions & 3 deletions lib/Doctrine/ORM/PersistentCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ public function remove($key)
$this->changed();

if ($this->association !== null &&
$this->association['type'] == ClassMetadata::ONE_TO_MANY &&
$this->association['type'] & ClassMetadata::TO_MANY &&
$this->association['orphanRemoval']) {
$this->em->getUnitOfWork()->scheduleOrphanRemoval($removed);
}
Expand Down Expand Up @@ -426,7 +426,7 @@ public function removeElement($element)
$this->changed();

if ($this->association !== null &&
$this->association['type'] === ClassMetadata::ONE_TO_MANY &&
$this->association['type'] & ClassMetadata::TO_MANY &&
$this->association['orphanRemoval']) {
$this->em->getUnitOfWork()->scheduleOrphanRemoval($element);
}
Expand Down Expand Up @@ -631,7 +631,7 @@ public function clear()

$uow = $this->em->getUnitOfWork();

if ($this->association['type'] === ClassMetadata::ONE_TO_MANY && $this->association['orphanRemoval']) {
if ($this->association['type'] & ClassMetadata::TO_MANY && $this->association['orphanRemoval']) {
// we need to initialize here, as orphan removal acts like implicit cascadeRemove,
// hence for event listeners we need the objects in memory.
$this->initialize();
Expand Down
103 changes: 103 additions & 0 deletions tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1654Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

namespace Doctrine\Tests\ORM\Functional\Ticket;

/**
* @group DDC-1654
*/
class DDC1654Test extends \Doctrine\Tests\OrmFunctionalTestCase
{
public function setUp()
{
parent::setUp();
$this->setUpEntitySchema(array(
__NAMESPACE__ . '\\DDC1654Post',
__NAMESPACE__ . '\\DDC1654Comment',
));
}

public function testManyToManyRemoveFromCollectionOrphanRemoval()
{
$post = new DDC1654Post();
$post->comments[] = new DDC1654Comment();
$post->comments[] = new DDC1654Comment();

$this->_em->persist($post);
$this->_em->flush();

$post->comments->remove(0);
$post->comments->remove(1);

$this->_em->flush();
$this->_em->clear();

$comments = $this->_em->getRepository(__NAMESPACE__ . '\\DDC1654Comment')->findAll();
$this->assertEquals(0, count($comments));
}

public function testManyToManyRemoveElementFromCollectionOrphanRemoval()
{
$post = new DDC1654Post();
$post->comments[] = new DDC1654Comment();
$post->comments[] = new DDC1654Comment();

$this->_em->persist($post);
$this->_em->flush();

$post->comments->removeElement($post->comments[0]);
$post->comments->removeElement($post->comments[1]);

$this->_em->flush();
$this->_em->clear();

$comments = $this->_em->getRepository(__NAMESPACE__ . '\\DDC1654Comment')->findAll();
$this->assertEquals(0, count($comments));
}

public function testManyToManyClearCollectionOrphanRemoval()
{
$post = new DDC1654Post();
$post->comments[] = new DDC1654Comment();
$post->comments[] = new DDC1654Comment();

$this->_em->persist($post);
$this->_em->flush();

$post->comments->clear();

$this->_em->flush();
$this->_em->clear();

$comments = $this->_em->getRepository(__NAMESPACE__ . '\\DDC1654Comment')->findAll();
$this->assertEquals(0, count($comments));

}
}

/**
* @Entity
*/
class DDC1654Post
{
/**
* @Id @Column(type="integer") @GeneratedValue
*/
public $id;

/**
* @ManyToMany(targetEntity="DDC1654Comment", orphanRemoval=true,
* cascade={"persist"})
*/
public $comments = array();
}

/**
* @Entity
*/
class DDC1654Comment
{
/**
* @Id @Column(type="integer") @GeneratedValue
*/
public $id;
}
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ public function testCreateManyToMany()
array(
'user_id' => 'id',
),
'orphanRemoval' => false,
),
), $this->cm->associationMappings);
}
Expand Down
25 changes: 25 additions & 0 deletions tests/Doctrine/Tests/OrmFunctionalTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ abstract class OrmFunctionalTestCase extends OrmTestCase
/** Whether the database schema has already been created. */
protected static $_tablesCreated = array();

/**
* Array of entity class name to their tables that were created.
* @var array
*/
protected static $_entityTablesCreated = array();

/** List of model sets and their classes. */
protected static $_modelSets = array(
'cms' => array(
Expand Down Expand Up @@ -235,6 +241,25 @@ protected function tearDown()
$this->_em->clear();
}

protected function setUpEntitySchema(array $classNames)
{
if ($this->_em === null) {
throw new \RuntimeException("EntityManager not set, you have to call parent::setUp() before invoking this method.");
}

$classes = array();
foreach ($classNames as $className) {
if ( ! isset(static::$_entityTablesCreated[$className])) {
static::$_entityTablesCreated[$className] = true;
$classes[] = $this->_em->getClassMetadata($className);
}
}

if ($classes) {
$this->_schemaTool->createSchema($classes);
}
}

/**
* Creates a connection to the test database, if there is none yet, and
* creates the necessary tables.
Expand Down

0 comments on commit 68436fe

Please sign in to comment.