Browse Source

Merge pull request #7477 from mylux/issue-7462

Implemented replace save strategy for hasmany associations
José Lorenzo Rodríguez 10 years ago
parent
commit
9dcda9b633
2 changed files with 381 additions and 1 deletions
  1. 134 1
      src/ORM/Association/HasMany.php
  2. 247 0
      tests/TestCase/ORM/TableTest.php

+ 134 - 1
src/ORM/Association/HasMany.php

@@ -15,6 +15,7 @@
  */
 namespace Cake\ORM\Association;
 
+use Cake\Collection\Collection;
 use Cake\Datasource\EntityInterface;
 use Cake\ORM\Association;
 use Cake\ORM\Table;
@@ -32,7 +33,9 @@ class HasMany extends Association
 {
 
     use DependentDeleteTrait;
-    use ExternalAssociationTrait;
+    use ExternalAssociationTrait {
+        _options as _externalOptions;
+    }
 
     /**
      * The type of join to be used when adding the association to a query
@@ -56,6 +59,27 @@ class HasMany extends Association
     protected $_validStrategies = [self::STRATEGY_SELECT, self::STRATEGY_SUBQUERY];
 
     /**
+     * Saving strategy that will only append to the links set
+     *
+     * @var string
+     */
+    const SAVE_APPEND = 'append';
+
+    /**
+     * Saving strategy that will replace the links with the provided set
+     *
+     * @var string
+     */
+    const SAVE_REPLACE = 'replace';
+
+    /**
+     * Saving strategy to be used by this association
+     *
+     * @var string
+     */
+    protected $_saveStrategy = self::SAVE_APPEND;
+
+    /**
      * Returns whether or not the passed table is the owning side for this
      * association. This means that rows in the 'target' table would miss important
      * or required information if the row in 'source' did not exist.
@@ -69,6 +93,26 @@ class HasMany extends Association
     }
 
     /**
+     * Sets the strategy that should be used for saving. If called with no
+     * arguments, it will return the currently configured strategy
+     *
+     * @param string|null $strategy the strategy name to be used
+     * @throws \InvalidArgumentException if an invalid strategy name is passed
+     * @return string the strategy to be used for saving
+     */
+    public function saveStrategy($strategy = null)
+    {
+        if ($strategy === null) {
+            return $this->_saveStrategy;
+        }
+        if (!in_array($strategy, [self::SAVE_APPEND, self::SAVE_REPLACE])) {
+            $msg = sprintf('Invalid save strategy "%s"', $strategy);
+            throw new InvalidArgumentException($msg);
+        }
+        return $this->_saveStrategy = $strategy;
+    }
+
+    /**
      * Takes an entity from the source table and looks if there is a field
      * matching the property name for this association. The found entity will be
      * saved on the target table for this association by passing supplied
@@ -104,6 +148,10 @@ class HasMany extends Association
         $original = $targetEntities;
         $options['_sourceTable'] = $this->source();
 
+        if ($this->_saveStrategy === self::SAVE_REPLACE) {
+            $this->_unlinkAssociated($properties, $entity, $target, $targetEntities);
+        }
+
         foreach ($targetEntities as $k => $targetEntity) {
             if (!($targetEntity instanceof EntityInterface)) {
                 break;
@@ -134,6 +182,77 @@ class HasMany extends Association
     }
 
     /**
+     * Deletes/sets null the related objects according to the dependency between source and targets and foreign key nullability
+     * Skips deleting records present in $remainingEntities
+     *
+     * @param array $properties array of foreignKey properties
+     * @param EntityInterface $entity the entity which should have its associated entities unassigned
+     * @param Table $target The associated table
+     * @param array $remainingEntities Entities that should not be deleted
+     * @return void
+     */
+    protected function _unlinkAssociated(array $properties, EntityInterface $entity, Table $target, array $remainingEntities = [])
+    {
+        $primaryKey = (array)$target->primaryKey();
+        $mustBeDependent = (!$this->_foreignKeyAcceptsNull($target, $properties) || $this->dependent());
+        $exclusions = new Collection($remainingEntities);
+        $exclusions = $exclusions->map(
+            function ($ent) use ($primaryKey) {
+                return $ent->extract($primaryKey);
+            }
+        )
+        ->filter(
+            function ($v) {
+                return !in_array(null, array_values($v), true);
+            }
+        )
+        ->toArray();
+
+        if (count($exclusions) > 0) {
+            $conditions = [
+                'NOT' => [
+                    'OR' => $exclusions
+                ],
+                $properties
+            ];
+
+            if ($mustBeDependent) {
+                if ($this->_cascadeCallbacks) {
+                    $query = $this->find('all')->where($conditions);
+                    foreach ($query as $assoc) {
+                        $target->delete($assoc);
+                    }
+                } else {
+                    $target->deleteAll($conditions);
+                }
+            } else {
+                $updateFields = array_fill_keys(array_keys($properties), null);
+                $target->updateAll($updateFields, $conditions);
+            }
+        }
+    }
+
+    /**
+     * Checks the nullable flag of the foreign key
+     *
+     * @param Table $table the table containing the foreign key
+     * @param array $properties the list of fields that compose the foreign key
+     * @return bool
+     */
+    protected function _foreignKeyAcceptsNull(Table $table, array $properties)
+    {
+        return !in_array(
+            false,
+            array_map(
+                function ($prop) use ($table) {
+                    return $table->schema()->isNullable($prop);
+                },
+                array_keys($properties)
+            )
+        );
+    }
+
+    /**
      * {@inheritDoc}
      */
     protected function _linkField($options)
@@ -166,4 +285,18 @@ class HasMany extends Association
     {
         return self::ONE_TO_MANY;
     }
+
+    /**
+     * Parse extra options passed in the constructor.
+     *
+     * @param array $opts original list of options passed in constructor
+     * @return void
+     */
+    protected function _options(array $opts)
+    {
+        $this->_externalOptions($opts);
+        if (!empty($opts['saveStrategy'])) {
+            $this->saveStrategy($opts['saveStrategy']);
+        }
+    }
 }

+ 247 - 0
tests/TestCase/ORM/TableTest.php

@@ -1737,6 +1737,253 @@ class TableTest extends TestCase
         $this->assertSame($entity, $table->save($entity));
     }
 
+
+    /**
+     * Test that save works with replace saveStrategy and are not deleted once they are not null
+     *
+     * @return void
+     */
+    public function testSaveReplaceSaveStrategy()
+    {
+        $authors = new Table(
+            [
+                'table' => 'authors',
+                'alias' => 'Authors',
+                'connection' => $this->connection,
+                'entityClass' => 'Cake\ORM\Entity',
+            ]
+        );
+        
+        $authors->hasMany('Articles', ['saveStrategy' => 'replace']);
+
+        $entity = $authors->newEntity([
+            'name' => 'mylux',
+            'articles' => [
+                ['title' => 'One Random Post', 'body' => 'The cake is not a lie'],
+                ['title' => 'Another Random Post', 'body' => 'The cake is nice'],
+                ['title' => 'One more random post', 'body' => 'The cake is forever']
+            ]
+        ], ['associated' => ['Articles']]);
+
+        $entity = $authors->save($entity, ['associated' => ['Articles']]);
+        $sizeArticles = count($entity->articles);
+        $this->assertEquals($sizeArticles, $authors->Articles->find('all')->where(['author_id' => $entity['id']])->count());
+
+        $articleId = $entity->articles[0]->id;
+        unset($entity->articles[0]);
+        $entity->dirty('articles', true);
+        
+        $authors->save($entity, ['associated' => ['Articles']]);
+        
+        $this->assertEquals($sizeArticles - 1, $authors->Articles->find('all')->where(['author_id' => $entity['id']])->count());
+        $this->assertTrue($authors->Articles->exists(['id' => $articleId]));
+    }
+
+    /**
+     * Test that save works with append saveStrategy not deleting or setting null anything
+     *
+     * @return void
+     */
+    public function testSaveAppendSaveStrategy()
+    {
+        $authors = new Table(
+            [
+                'table' => 'authors',
+                'alias' => 'Authors',
+                'connection' => $this->connection,
+                'entityClass' => 'Cake\ORM\Entity',
+            ]
+        );
+
+        $authors->hasMany('Articles', ['saveStrategy' => 'append']);
+
+        $entity = $authors->newEntity([
+            'name' => 'mylux',
+            'articles' => [
+                ['title' => 'One Random Post', 'body' => 'The cake is not a lie'],
+                ['title' => 'Another Random Post', 'body' => 'The cake is nice'],
+                ['title' => 'One more random post', 'body' => 'The cake is forever']
+            ]
+        ], ['associated' => ['Articles']]);
+
+        $entity = $authors->save($entity, ['associated' => ['Articles']]);
+        $sizeArticles = count($entity->articles);
+
+        $this->assertEquals($sizeArticles, $authors->Articles->find('all')->where(['author_id' => $entity['id']])->count());
+        
+        $articleId = $entity->articles[0]->id;
+        unset($entity->articles[0]);
+        $entity->dirty('articles', true);
+        
+        $authors->save($entity, ['associated' => ['Articles']]);
+        
+        $this->assertEquals($sizeArticles, $authors->Articles->find('all')->where(['author_id' => $entity['id']])->count());
+        $this->assertTrue($authors->Articles->exists(['id' => $articleId]));
+    }
+
+    /**
+     * Test that save has append as the default save strategy
+     *
+     * @return void
+     */
+    public function testSaveDefaultSaveStrategy()
+    {
+        $authors = new Table(
+            [
+                'table' => 'authors',
+                'alias' => 'Authors',
+                'connection' => $this->connection,
+                'entityClass' => 'Cake\ORM\Entity',
+            ]
+        );
+        $authors->hasMany('Articles', ['saveStrategy' => 'append']);
+        $this->assertEquals('append', $authors->association('articles')->saveStrategy());
+    }
+
+    /**
+     * Test that the associated entities are unlinked and deleted when they are dependent
+     *
+     * @return void
+     */
+    public function testSaveReplaceSaveStrategyDependent()
+    {
+        $authors = new Table(
+            [
+                'table' => 'authors',
+                'alias' => 'Authors',
+                'connection' => $this->connection,
+                'entityClass' => 'Cake\ORM\Entity',
+            ]
+        );
+
+        $authors->hasMany('Articles', ['saveStrategy' => 'replace', 'dependent' => true]);
+
+        $entity = $authors->newEntity([
+            'name' => 'mylux',
+            'articles' => [
+                ['title' => 'One Random Post', 'body' => 'The cake is not a lie'],
+                ['title' => 'Another Random Post', 'body' => 'The cake is nice'],
+                ['title' => 'One more random post', 'body' => 'The cake is forever']
+            ]
+        ], ['associated' => ['Articles']]);
+
+        $entity = $authors->save($entity, ['associated' => ['Articles']]);
+        $sizeArticles = count($entity->articles);
+        $this->assertEquals($sizeArticles, $authors->Articles->find('all')->where(['author_id' => $entity['id']])->count());
+
+        $articleId = $entity->articles[0]->id;
+        unset($entity->articles[0]);
+        $entity->dirty('articles', true);
+        
+        $authors->save($entity, ['associated' => ['Articles']]);
+        
+        $this->assertEquals($sizeArticles - 1, $authors->Articles->find('all')->where(['author_id' => $entity['id']])->count());
+        $this->assertFalse($authors->Articles->exists(['id' => $articleId]));
+    }
+
+    /**
+     * Test that the associated entities are unlinked and deleted when they have a not nullable foreign key
+     *
+     * @return void
+     */
+    public function testSaveReplaceSaveStrategyNotNullable()
+    {
+        $articles = new Table(
+            [
+                'table' => 'articles',
+                'alias' => 'Articles',
+                'connection' => $this->connection,
+                'entityClass' => 'Cake\ORM\Entity',
+            ]
+        );
+
+        $articles->hasMany('Comments', ['saveStrategy' => 'replace']);
+
+        $article = $articles->newEntity([
+            'title' => 'Bakeries are sky rocketing',
+            'body' => 'All because of cake',
+            'comments' => [
+                [
+                    'user_id' => 1,
+                    'comment' => 'That is true!'
+                ],
+                [
+                    'user_id' => 2,
+                    'comment' => 'Of course'
+                ]
+            ]
+        ], ['associated' => ['Comments']]);
+
+        $article = $articles->save($article, ['associated' => ['Comments']]);
+        $commentId = $article->comments[0]->id;
+        $sizeComments = count($article->comments);
+
+        $this->assertEquals($sizeComments, $articles->Comments->find('all')->where(['article_id' => $article->id])->count());
+        $this->assertTrue($articles->Comments->exists(['id' => $commentId]));
+        
+        unset($article->comments[0]);
+        $article->dirty('comments', true);
+        $article = $articles->save($article, ['associated' => ['Comments']]);
+
+        $this->assertEquals($sizeComments - 1, $articles->Comments->find('all')->where(['article_id' => $article->id])->count());
+        $this->assertFalse($articles->Comments->exists(['id' => $commentId]));
+    }
+
+    /**
+     * Test that the associated entities are unlinked and deleted when they have a not nullable foreign key
+     *
+     * @return void
+     */
+    public function testSaveReplaceSaveStrategyAdding()
+    {
+        $articles = new Table(
+            [
+                'table' => 'articles',
+                'alias' => 'Articles',
+                'connection' => $this->connection,
+                'entityClass' => 'Cake\ORM\Entity',
+            ]
+        );
+
+        $articles->hasMany('Comments', ['saveStrategy' => 'replace']);
+
+        $article = $articles->newEntity([
+            'title' => 'Bakeries are sky rocketing',
+            'body' => 'All because of cake',
+            'comments' => [
+                [
+                    'user_id' => 1,
+                    'comment' => 'That is true!'
+                ],
+                [
+                    'user_id' => 2,
+                    'comment' => 'Of course'
+                ]
+            ]
+        ], ['associated' => ['Comments']]);
+
+        $article = $articles->save($article, ['associated' => ['Comments']]);
+        $commentId = $article->comments[0]->id;
+        $sizeComments = count($article->comments);
+        $articleId = $article->id;
+
+        $this->assertEquals($sizeComments, $articles->Comments->find('all')->where(['article_id' => $article->id])->count());
+        $this->assertTrue($articles->Comments->exists(['id' => $commentId]));
+        
+        unset($article->comments[0]);
+        $article->comments[] = $articles->Comments->newEntity([
+            'user_id' => 1,
+            'comment' => 'new comment'
+        ]);
+
+        $article->dirty('comments', true);
+        $article = $articles->save($article, ['associated' => ['Comments']]);
+
+        $this->assertEquals($sizeComments, $articles->Comments->find('all')->where(['article_id' => $article->id])->count());
+        $this->assertFalse($articles->Comments->exists(['id' => $commentId]));
+        $this->assertTrue($articles->Comments->exists(['comment' => 'new comment', 'article_id' => $articleId]));
+    }
+
     /**
      * Test that saving a new entity with a Primary Key set does not call exists when checkExisting is false.
      *