Skip to content

Commit

Permalink
Merge pull request #7574 from mylux/link-unlink-replace-public-hasmany
Browse files Browse the repository at this point in the history
Implemented link, unlink and replace methods for HasMany associations
  • Loading branch information
lorenzo committed Oct 23, 2015
2 parents d389d8e + c1fa2bf commit 342fe9f
Show file tree
Hide file tree
Showing 2 changed files with 551 additions and 13 deletions.
221 changes: 208 additions & 13 deletions src/ORM/Association/HasMany.php
Expand Up @@ -129,7 +129,7 @@ public function saveStrategy($strategy = null)
public function saveAssociated(EntityInterface $entity, array $options = [])
{
$targetEntities = $entity->get($this->property());
if (empty($targetEntities)) {
if (empty($targetEntities) && $this->_saveStrategy !== self::SAVE_REPLACE) {
return $entity;
}

Expand Down Expand Up @@ -181,6 +181,184 @@ public function saveAssociated(EntityInterface $entity, array $options = [])
return $entity;
}

/**
* Associates the source entity to each of the target entities provided.
* When using this method, all entities in `$targetEntities` will be appended to
* the source entity's property corresponding to this association object.
*
* This method does not check link uniqueness.
* Changes are persisted in the database and also in the source entity.
*
* ### Example:
*
* ```
* $user = $users->get(1);
* $allArticles = $articles->find('all')->execute();
* $users->Articles->link($user, $allArticles);
* ```
*
* `$user->get('articles')` will contain all articles in `$allArticles` after linking
*
* @param \Cake\Datasource\EntityInterface $sourceEntity the row belonging to the `source` side
* of this association
* @param array $targetEntities list of entities belonging to the `target` side
* of this association
* @param array $options list of options to be passed to the save method
* @return bool true on success, false otherwise
*/
public function link(EntityInterface $sourceEntity, array $targetEntities, array $options = [])
{
$saveStrategy = $this->saveStrategy();
$this->saveStrategy(self::SAVE_APPEND);
$property = $this->property();

$currentEntities = array_unique(
array_merge(
(array)$sourceEntity->get($property),
$targetEntities
)
);

$sourceEntity->set($property, $currentEntities);

$savedEntity = $this->saveAssociated($sourceEntity);

$ok = ($savedEntity instanceof EntityInterface);

$this->saveStrategy($saveStrategy);

if ($ok) {
$sourceEntity->set($property, $savedEntity->get($property));
$sourceEntity->dirty($property, false);
}

return $ok;
}

/**
* Removes all links between the passed source entity and each of the provided
* target entities. This method assumes that all passed objects are already persisted
* in the database and that each of them contain a primary key value.
*
* By default this method will also unset each of the entity objects stored inside
* the source entity.
*
* Changes are persisted in the database and also in the source entity.
*
* ### Example:
*
* ```
* $user = $users->get(1);
* $user->articles = [$article1, $article2, $article3, $article4];
* $users->save($user, ['Associated' => ['Articles']]);
* $allArticles = [$article1, $article2, $article3];
* $users->Articles->unlink($user, $allArticles);
* ```
*
* `$article->get('articles')` will contain only `[$article4]` after deleting in the database
*
* @param \Cake\Datasource\EntityInterface $sourceEntity an entity persisted in the source table for
* this association
* @param array $targetEntities list of entities persisted in the target table for
* this association
* @param bool $cleanProperty whether or not to remove all the objects in $targetEntities
* that are stored in $sourceEntity
* @throws \InvalidArgumentException if non persisted entities are passed or if
* any of them is lacking a primary key value
* @return void
*/
public function unlink(EntityInterface $sourceEntity, array $targetEntities, $cleanProperty = true)
{
$foreignKey = (array)$this->foreignKey();
$target = $this->target();
$targetPrimaryKey = array_merge((array)$target->primaryKey(), $foreignKey);
$property = $this->property();

$conditions = [
'OR' => (new Collection($targetEntities))
->map(function ($entity) use ($targetPrimaryKey) {
return $entity->extract($targetPrimaryKey);
})
->toList()
];

$this->_unlink($foreignKey, $target, $conditions);

if ($cleanProperty) {
$sourceEntity->set(
$property,
(new Collection($sourceEntity->get($property)))
->reject(
function ($assoc) use ($targetEntities) {
return in_array($assoc, $targetEntities);
}
)
->toList()
);
}

$sourceEntity->dirty($property, false);
}

/**
* Replaces existing association links between the source entity and the target
* with the ones passed. This method does a smart cleanup, links that are already
* persisted and present in `$targetEntities` will not be deleted, new links will
* be created for the passed target entities that are not already in the database
* and the rest will be removed.
*
* For example, if an author has many articles, such as 'article1','article 2' and 'article 3' and you pass
* to this method an array containing the entities for articles 'article 1' and 'article 4',
* only the link for 'article 1' will be kept in database, the links for 'article 2' and 'article 3' will be
* deleted and the link for 'article 4' will be created.
*
* Existing links are not deleted and created again, they are either left untouched
* or updated.
*
* This method does not check link uniqueness.
*
* On success, the passed `$sourceEntity` will contain `$targetEntities` as value
* in the corresponding property for this association.
*
* Additional options for new links to be saved can be passed in the third argument,
* check `Table::save()` for information on the accepted options.
*
* ### Example:
*
* ```
* $author->articles = [$article1, $article2, $article3, $article4];
* $authors->save($author);
* $articles = [$article1, $article3];
* $authors->association('articles')->replaceLinks($author, $articles);
* ```
*
* `$author->get('articles')` will contain only `[$article1, $article3]` at the end
*
* @param \Cake\Datasource\EntityInterface $sourceEntity an entity persisted in the source table for
* this association
* @param array $targetEntities list of entities from the target table to be linked
* @param array $options list of options to be passed to `save` persisting or
* updating new links
* @throws \InvalidArgumentException if non persisted entities are passed or if
* any of them is lacking a primary key value
* @return bool success
*/
public function replace(EntityInterface $sourceEntity, array $targetEntities, array $options = [])
{
$property = $this->property();
$sourceEntity->set($property, $targetEntities);
$saveStrategy = $this->saveStrategy();
$this->saveStrategy(self::SAVE_REPLACE);
$result = $this->saveAssociated($sourceEntity, $options);
$ok = ($result instanceof EntityInterface);

if ($ok) {
$sourceEntity = $result;
}
$this->saveStrategy($saveStrategy);
return $ok;
}

/**
* Deletes/sets null the related objects according to the dependency between source and targets and foreign key nullability
* Skips deleting records present in $remainingEntities
Expand All @@ -194,7 +372,6 @@ public function saveAssociated(EntityInterface $entity, array $options = [])
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) {
Expand All @@ -208,27 +385,45 @@ function ($v) {
)
->toArray();

$conditions = $properties;

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);
$this->_unlink(array_keys($properties), $target, $conditions);
}

/**
* Deletes/sets null the related objects matching $conditions.
* The action which is taken depends on the dependency between source and targets and also on foreign key nullability
*
* @param array $foreignKey array of foreign key properties
* @param Table $target The associated table
* @param array $conditions The conditions that specifies what are the objects to be unlinked
* @return void
*/
protected function _unlink(array $foreignKey, Table $target, array $conditions = [])
{
$mustBeDependent = (!$this->_foreignKeyAcceptsNull($target, $foreignKey) || $this->dependent());
if ($mustBeDependent) {
if ($this->_cascadeCallbacks) {
$query = $this->find('all')->where($conditions);
foreach ($query as $assoc) {
$target->delete($assoc);
}
} else {
$updateFields = array_fill_keys(array_keys($properties), null);
$target->updateAll($updateFields, $conditions);
$target->deleteAll($conditions);
}
} else {
$updateFields = array_fill_keys($foreignKey, null);
$target->updateAll($updateFields, $conditions);

}
}

Expand All @@ -247,7 +442,7 @@ protected function _foreignKeyAcceptsNull(Table $table, array $properties)
function ($prop) use ($table) {
return $table->schema()->isNullable($prop);
},
array_keys($properties)
$properties
)
);
}
Expand Down

0 comments on commit 342fe9f

Please sign in to comment.