Skip to content

Commit

Permalink
Merge pull request #432: Fix belongs to relation when parent is chang…
Browse files Browse the repository at this point in the history
…ed using parent id

Fixed expected behavior:
If we change parent ID in a BelongsTo relation and store the child entity, the parent entity will be changed in the relation with the new one.

Before it was like this: the nullable BelongsTo relation can't be finished if the parent had loaded state.
  • Loading branch information
roxblnfk authored Jul 31, 2023
2 parents b899e88 + 5e4f700 commit 04693aa
Show file tree
Hide file tree
Showing 15 changed files with 376 additions and 15 deletions.
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# CHANGELOG

v2.3.4 (31.07.2023)
--------------------
- Fix fields uncasting in the ManyToMany relation by @roxblnfk, thanks @gam6itko (#427, #428)
- Fix resolving of a not loaded parent in the relation RefersTo by @roxblnfk, thanks @msmakouz and snafets (#414)
- Fix belongs to relation when parent is changed using parent id by @roxblnfk, thanks @roquie (#346, #432)

v2.3.3 (21.07.2023)
--------------------
- Fix loading for Embedded entities when parent is null by @gam6itko and @roxblnfk (#422, #423)
Expand All @@ -11,7 +17,7 @@ v2.3.2 (20.06.2023)
--------------------
- Fix proxy-mapper hydration mechanism: public relations in a non-proxy-entity are hydrated like private ones.
There is a special logic related to `ReferenceInterface` hydrating. By @roxblnfk (#417)
- Add the method `forUpdate` in the `Select` phpdoc. By @msmakouz in (#413)
- Add the method `forUpdate` in the `Select` phpdoc. By @msmakouz (#413)

v2.3.1 (01.05.2023)
--------------------
Expand Down
12 changes: 4 additions & 8 deletions src/Heap/Node.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,11 @@ public function syncState(RelationMap $relMap, State $state): array
{
$changes = array_udiff_assoc($state->getTransactionData(), $this->data, [self::class, 'compare']);

$relations = $relMap->getRelations();
foreach ($state->getRelations() as $name => $relation) {
if ($relation instanceof ReferenceInterface
&& isset($relations[$name])
&& (isset($this->relations[$name]) xor isset($relation))
) {
$changes[$name] = $relation->hasValue() ? $relation->getValue() : $relation;
foreach ($state->getRelations() as $name => $value) {
if ($value instanceof ReferenceInterface) {
$changes[$name] = $value->hasValue() ? $value->getValue() : $value;
}
$this->setRelation($name, $relation);
$this->setRelation($name, $value);
}

// DELETE handled separately
Expand Down
17 changes: 17 additions & 0 deletions src/Mapper/Proxy/EntityProxyTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@ public function __get(string $name)
throw new RuntimeException(sprintf('Property %s.%s is not initialized.', get_parent_class(static::class), $name));
}

public function __unset(string $name): void
{
if (\array_key_exists($name, $this->__cycle_orm_rel_map->getRelations())) {
$propertyClass = $this->__cycle_orm_relation_props->getPropertyClass($name);
if ($propertyClass === PropertyMap::PUBLIC_CLASS) {
unset($this->$name);
} else {
Closure::bind(static function (object $object, string $property): void {
unset($object->{$property});
}, null, $propertyClass)($this, $name);
}
}
if (\method_exists(parent::class, '__unset')) {
parent::__unset($name);
}
}

public function __set(string $name, $value): void
{
if (!array_key_exists($name, $this->__cycle_orm_rel_map->getRelations())) {
Expand Down
7 changes: 5 additions & 2 deletions src/Mapper/Proxy/Hydrator/ClosureHydrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ public function hydrate(RelationMap $relMap, array $propertyMaps, object $object

foreach ($data as $property => $value) {
try {
// Ignore deprecations
if (isset($relMap->getRelations()[$property])) {
unset($object->{$property});
}
// Use @ to try to ignore deprecations
@$object->{$property} = $value;
} catch (\Throwable $e) {
if ($e::class === \TypeError::class) {
Expand Down Expand Up @@ -66,7 +69,7 @@ private function setEntityProperties(array $properties, object $object, array &$
}

try {
// Ignore deprecations
// Use @ to try to ignore deprecations
@$object->{$property} = $data[$property];
unset($data[$property]);
} catch (\Throwable $e) {
Expand Down
5 changes: 4 additions & 1 deletion src/Mapper/Proxy/ProxyEntityFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ public function extractRelations(RelationMap $relMap, object $entity): array
foreach ($relMap->getRelations() as $key => $relation) {
if (!array_key_exists($key, $currentData)) {
$arrayData ??= $this->entityToArray($entity);
$currentData[$key] = $arrayData[$key];
if (!array_key_exists($key, $arrayData)) {
continue;
}
$currentData[$key] = $arrayData[$key] ?? null;
}
}

Expand Down
21 changes: 18 additions & 3 deletions src/Relation/BelongsTo.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,13 @@ private function shouldPull(Tuple $tuple, Tuple $rTuple): bool
$newData = $rTuple->state->getTransactionData();
$current = $tuple->state->getData();
$noChanges = true;
$toReference = [];
foreach ($this->outerKeys as $i => $outerKey) {
$innerKey = $this->innerKeys[$i];
if (!array_key_exists($innerKey, $oldData) || $oldData[$innerKey] !== $newData[$outerKey]) {
return true;
}
$toReference[$outerKey] = $current[$innerKey];
$noChanges = $noChanges && Node::compare($current[$innerKey], $oldData[$innerKey]) === 0;
}
// If no changes
Expand All @@ -140,14 +142,27 @@ private function shouldPull(Tuple $tuple, Tuple $rTuple): bool
}
// Nullable relation and null values
if ($this->isNullable()) {
$isNull = true;
foreach ($this->innerKeys as $innerKey) {
if (!array_key_exists($innerKey, $current) || $current[$innerKey] !== null) {
return false;
$isNull = false;
break;
}
}
$tuple->state->setRelation($this->getName(), null);
$tuple->state->setRelationStatus($this->getName(), RelationInterface::STATUS_RESOLVED);
if ($isNull) {
$tuple->state->setRelation($this->getName(), null);
$tuple->state->setRelationStatus($this->getName(), RelationInterface::STATUS_RESOLVED);
return false;
}
}
$tuple->state->setRelationStatus($this->getName(), RelationInterface::STATUS_RESOLVED);

$reference = new Reference($this->target, $toReference);
$tuple->state->setRelation(
$this->getName(),
$this->resolve($reference, false) ?? $reference,
);

return false;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

declare(strict_types=1);

namespace Cycle\ORM\Tests\Functional\Driver\Common\Integration\Case346;

use Cycle\ORM\Select;
use Cycle\ORM\Tests\Functional\Driver\Common\BaseTest;
use Cycle\ORM\Tests\Functional\Driver\Common\Integration\IntegrationTestTrait;
use Cycle\ORM\Tests\Traits\TableTrait;

abstract class CaseTest extends BaseTest
{
use IntegrationTestTrait;
use TableTrait;

public function setUp(): void
{
// Init DB
parent::setUp();
$this->makeTables();
$this->fillData();

$this->loadSchema(__DIR__ . '/schema.php');
}

public function testSelect(): void
{
/** @var Entity\Post $post */
$post = (new Select($this->orm, Entity\Post::class))
->wherePK(1)
->fetchOne();

$this->assertSame(1, $post->user->id);

$post->user_id = 2;

$this->save($post);

$this->assertSame(2, $post->user_id);
$this->assertSame(2, $post->user->id);
$this->orm->getHeap()->clean();
}

private function makeTables(): void
{
// Make tables
$this->makeTable(Entity\User::ROLE, [
'id' => 'primary', // autoincrement
'login' => 'string',
'created_at' => 'datetime',
'updated_at' => 'datetime',
]);

$this->makeTable('post', [
'id' => 'primary',
'user_id' => 'int',
'slug' => 'string',
'title' => 'string',
'public' => 'bool',
'content' => 'string',
'published_at' => 'datetime,nullable',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime,nullable',
]);
$this->makeFK('post', 'user_id', 'user', 'id', 'NO ACTION', 'NO ACTION');
}

private function fillData(): void
{
$this->getDatabase()->table('user')->insertMultiple(
['login'],
[
['user-1'],
['user-2'],
['user-3'],
['user-4'],
],
);
$this->getDatabase()->table('post')->insertMultiple(
['user_id', 'slug', 'title', 'public', 'content'],
[
[1, 'slug-string-1', 'Title 1', true, 'Foo-bar-baz content 1'],
[2, 'slug-string-2', 'Title 2', true, 'Foo-bar-baz content 2'],
[2, 'slug-string-3', 'Title 3', true, 'Foo-bar-baz content 3'],
[3, 'slug-string-4', 'Title 4', true, 'Foo-bar-baz content 4'],
[3, 'slug-string-5', 'Title 5', true, 'Foo-bar-baz content 5'],
[3, 'slug-string-6', 'Title 6', true, 'Foo-bar-baz content 6'],
],
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Cycle\ORM\Tests\Functional\Driver\Common\Integration\Case346\Entity;

use DateTimeImmutable;

class Post
{
public ?int $id = null;
public string $slug;
public string $title = '';
public bool $public = false;
public string $content = '';
public DateTimeImmutable $created_at;
public DateTimeImmutable $updated_at;
public ?DateTimeImmutable $published_at = null;
public ?DateTimeImmutable $deleted_at = null;
public User $user;
public ?int $user_id = null;

public function __construct(string $title = '', string $content = '')
{
$this->title = $title;
$this->content = $content;
$this->created_at = new DateTimeImmutable();
$this->updated_at = new DateTimeImmutable();
$this->resetSlug();
}

public function resetSlug(): void
{
$this->slug = \bin2hex(\random_bytes(32));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Cycle\ORM\Tests\Functional\Driver\Common\Integration\Case346\Entity;

use DateTimeImmutable;

class User
{
public const ROLE = 'user';

public ?int $id = null;
public string $login;
public DateTimeImmutable $created_at;
public DateTimeImmutable $updated_at;
/** @var iterable<Post> */
public iterable $posts = [];

public function __construct(string $login, string $password)
{
$this->login = $login;
$this->created_at = new DateTimeImmutable();
$this->updated_at = new DateTimeImmutable();
}
}
95 changes: 95 additions & 0 deletions tests/ORM/Functional/Driver/Common/Integration/Case346/schema.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

declare(strict_types=1);

use Cycle\ORM\Mapper\Mapper;
use Cycle\ORM\Relation;
use Cycle\ORM\SchemaInterface as Schema;
use Cycle\ORM\Select\Source;
use Cycle\ORM\Tests\Functional\Driver\Common\Integration\Case346\Entity\Post;
use Cycle\ORM\Tests\Functional\Driver\Common\Integration\Case346\Entity\User;

return [
'post' => [
Schema::ENTITY => Post::class,
Schema::SOURCE => Source::class,
Schema::DATABASE => 'default',
Schema::MAPPER => Mapper::class,
Schema::TABLE => 'post',
Schema::PRIMARY_KEY => ['id'],
Schema::FIND_BY_KEYS => ['id'],
Schema::COLUMNS => [
'id' => 'id',
'slug' => 'slug',
'title' => 'title',
'public' => 'public',
'content' => 'content',
'created_at' => 'created_at',
'updated_at' => 'updated_at',
'published_at' => 'published_at',
'deleted_at' => 'deleted_at',
'user_id' => 'user_id',
],
Schema::RELATIONS => [
'user' => [
Relation::TYPE => Relation::BELONGS_TO,
Relation::TARGET => User::ROLE,
Relation::LOAD => Relation::LOAD_PROMISE,
Relation::SCHEMA => [
Relation::CASCADE => true,
Relation::NULLABLE => true,
Relation::INNER_KEY => 'user_id',
Relation::OUTER_KEY => ['id'],
],
],
],
Schema::TYPECAST => [
'id' => 'int',
'public' => 'bool',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'published_at' => 'datetime',
'deleted_at' => 'datetime',
'user_id' => 'int',
],
Schema::SCHEMA => [],
],
'user' => [
Schema::ENTITY => User::class,
Schema::MAPPER => Mapper::class,
Schema::SOURCE => Source::class,
Schema::DATABASE => 'default',
Schema::TABLE => 'user',
Schema::PRIMARY_KEY => ['id'],
Schema::FIND_BY_KEYS => ['id'],
Schema::COLUMNS => [
'id' => 'id',
'login' => 'login',
'created_at' => 'created_at',
'updated_at' => 'updated_at',
],
Schema::RELATIONS => [
// 'posts' => [
// Relation::TYPE => Relation::HAS_MANY,
// Relation::TARGET => 'post',
// Relation::COLLECTION_TYPE => 'array',
// Relation::LOAD => Relation::LOAD_PROMISE,
// Relation::SCHEMA => [
// Relation::CASCADE => true,
// Relation::NULLABLE => false,
// Relation::WHERE => [],
// Relation::ORDER_BY => [],
// Relation::INNER_KEY => ['id'],
// Relation::OUTER_KEY => 'user_id',
// ],
// ],
],
Schema::SCOPE => null,
Schema::TYPECAST => [
'id' => 'int',
'created_at' => 'datetime',
'updated_at' => 'datetime',
],
Schema::SCHEMA => [],
],
];
Loading

0 comments on commit 04693aa

Please sign in to comment.