cycle/orm

Cascade Persist Fails without cloning object in softDelete Method

lotyp opened this issue · 0 comments

No duplicates 🥲.

  • I have searched for a similar issue in our bug tracker and didn't find any solutions.

What happened?

Summary:

As from discussion with @roxblnfk in discord, where we agreed to create issue

I'm Encountering an issue with cascade persist in Cycle ORM when a shared Embeddable (Signature) is not cloned in the softDelete method of an entity. This results in only the parent entity being persisted, and child entities are skipped in the cascade.

Steps to Reproduce:

  • Two entities (Category and Size) share an Embeddable (Signature).
  • The softDelete method is invoked on the Category entity, which also iteratively calls softDelete on associated Size entities.
  • The Signature instance is not cloned in the softDelete method.
  • Cascade persist is triggered.

Expected Result:

Both the Category and its child Size entities should be persisted.

Actual Result:

Only the Category entity is persisted, while the cascade persist for Size entities is skipped.

Technical Details:

The issue arises due to the shared Signature Embeddable having only one Node/State in the Unit of Work.
When Signature is not cloned in softDelete, it doesn't trigger cascade updates as expected.
Cloning Signature in the softDelete method ($size->softDelete(clone $deleted);) resolves the issue.

Relevant Code Snippets:

  • DeleteCategoryService (service performing soft delete actions)
  • HasSignatures (trait for handling signatures)
  • Signature (Embeddable class)
  • Category (parent entity with signatures)
  • Size (child entity with signatures)

Architecture and Schema Details:

The schema output shows the relationships and embedded fields for Category and Size.
This behavior seems to be an unintended consequence of shared Embeddables in the ORM.

<?php

declare(strict_types=1);

namespace Application\Category\Services;

use Application\Category\Commands\DeleteCategory;
use Cycle\ORM\EntityManagerInterface;
use Domain\Auth\Signature;
use Lcobucci\Clock\Clock;
use Throwable;

final readonly class DeleteCategoryService
{
    public function __construct(
        private EntityManagerInterface $em,
        private Clock $clock
    ) {
    }

    /**
     * @throws Throwable
     */
    public function handle(DeleteCategory $command): void
    {
        $signature = new Signature($this->clock->now(), $command->footprint());
        $category = $command->category();

        $category->softDelete($signature);

        $this->em->persist($category, true);
        $this->em->run();
    }
}

If, Category entity softDelete method does not use clone, then only Category entity will be persisted, and cascade will be skipped.

This happens, because there is one shared Signature embeddable and it has only one Node/State in Unit of Work

Details of architecture:

Trait that holds signatures, which consist of fields

  • _at (date)
  • _by (json, that holds info, about who performed action):
HasSignatures.php (trait)
<?php

namespace Domain\Auth;

use Cycle\Annotated\Annotation\Relation\Embedded;

trait HasSignatures
{
    #[Embedded(target: Signature::class, prefix: 'created_')]
    private Signature $created;

    #[Embedded(target: Signature::class, prefix: 'updated_')]
    private Signature $updated;

    #[Embedded(target: Signature::class, prefix: 'deleted_')]
    private ?Signature $deleted;

    public function created(): Signature
    {
        return $this->created;
    }

    public function updated(): Signature
    {
        return $this->updated;
    }

    public function deleted(): ?Signature
    {
        if (! $this->deleted?->defined()) {
            return null;
        }

        return $this->deleted;
    }

    public function softDelete(Signature $deleted): void
    {
        $this->deleted = $deleted;
    }
}

Contents of Signature class looks like this:

Signature.php (Embeddable)
<?php

namespace Domain\Auth;

use Assert\Assert;
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Embeddable;
use Cycle\ORM\Entity\Behavior;
use DateTimeImmutable;
use DateTimeInterface;
use Exception;

#[Embeddable]
#[Behavior\SoftDelete(field: 'at', column: 'deleted_at')]
class Signature
{
    #[Column(type: 'datetime', nullable: true)]
    private ?DateTimeImmutable $at = null;

    #[Column(type: 'json', nullable: true, typecast: [Footprint::class, 'castValue'])]
    private ?Footprint $by = null;

    public static function forGuest(): self
    {
        return new self(new DateTimeImmutable(), Footprint::empty());
    }

    public static function random(): self
    {
        return new self(new DateTimeImmutable(), Footprint::random());
    }

    public static function empty(): self
    {
        return new self(null, null);
    }

    /**
     * @throws Exception
     */
    public static function fromArray(array $data): self
    {
        Assert::that($data)
            ->keyExists('at')
            ->keyExists('by')
        ;

        return new self(
            new DateTimeImmutable($data['at']),
            Footprint::fromArray($data['by']),
        );
    }

    public function defined(): bool
    {
        return isset($this->at, $this->by);
    }

    public function at(): ?DateTimeImmutable
    {
        return $this->at;
    }

    public function by(): ?Footprint
    {
        return $this->by;
    }

    public function toArray(): array
    {
        return [
            'at' => $this->at?->format(DateTimeInterface::RFC3339_EXTENDED),
            'by' => $this->by?->toArray(),
        ];
    }

    public function __construct(?DateTimeImmutable $at, ?Footprint $by)
    {
        $this->at = $at;
        $this->by = $by;
    }
}

Parent Category Entity that has Signatures:

Category.php (Parent Entity)
<?php

namespace Domain\Category;

use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Relation\HasMany;
use Cycle\ORM\Entity\Behavior\Uuid\Uuid7;
use Domain\Auth\Contracts\AuditableEntity;
use Domain\Auth\HasSignatures;
use Domain\Auth\Signature;
use Domain\Category\Events\CategoryCreated;
use Domain\Category\Size\Size;
use EventSauce\EventSourcing\AggregateRoot;
use EventSauce\EventSourcing\AggregateRootId;
use Illuminate\Support\Collection;
use WayOfDev\EventSourcing\Events\Concerns\AggregatableRoot;

#[Entity(repository: CategoryRepository::class)]
#[Uuid7(field: 'id', column: 'id')]
class Category implements AggregateRoot, AuditableEntity
{
    use AggregatableRoot;
    use HasSignatures;

    #[Column(type: 'uuid', primary: true, typecast: [CategoryId::class, 'castValue'], unique: true)]
    private CategoryId $id;

    #[HasMany(target: Size::class, innerKey: 'id', outerKey: 'category_id', orderBy: ['sequence_number' => 'DESC'], load: 'eager')]
    private Collection $sizes;

    public function __construct(
        CategoryId $id,
        Signature $signature,
    ) {
        $this->id = $id;

        // ...

        $this->created = $signature;
        $this->updated = clone $signature;
        $this->deleted = Signature::empty();

        $this->sizes = new Collection();

        // ...
    }

    public function id(): CategoryId
    {
        return $this->id;
    }

    public function sizes(): Collection
    {
        return $this->sizes;
    }

    public function softDelete(Signature $deleted): void
    {
        $this->sizes->each(function (Size $size) use ($deleted): void {
            $size->softDelete($deleted); // Will not perform cascade update
			// $size->softDelete(clone $deleted); // Solution that works
        });

        $this->deleted = $deleted;
    }
}

Child Entity "Sizes", that also has Signatures

Size.php (Child Entity)
<?php

namespace Domain\Category\Size;

use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Relation\BelongsTo;
use Domain\Auth\Contracts\AuditableEntity;
use Domain\Auth\HasSignatures;
use Domain\Auth\Signature;
use Domain\Category\Category;

#[Entity(role: 'category_size', repository: SizeRepository::class, table: 'category_sizes')]
class Size implements AuditableEntity
{
    use HasSignatures;

    #[Column(type: 'uuid', primary: true, typecast: [SizeId::class, 'castValue'], unique: true)]
    private SizeId $id;

    #[BelongsTo(target: Category::class, innerKey: 'category_id', outerKey: 'id')]
    private Category $category;

    public function __construct(
        SizeId $id,
        Category $category,
        Signature $signature
    ) {
        $this->id = $id;
        $this->category = $category;

        // ...

        $this->created = $signature;
        $this->updated = clone $signature;
        $this->deleted = Signature::empty();
    }

    public function id(): SizeId
    {
        return $this->id;
    }

    public function category(): Category
    {
        return $this->category;
    }
}

Rendered Schema Output:

Schema

[category] :: default.categories
       Entity: Domain\Category\Category
       Mapper: Cycle\ORM\Mapper\Mapper
   Repository: Domain\Category\CategoryRepository
  Primary key: id
       Fields:
               (property -> db.field -> typecast)
               id -> id -> Domain\Category\CategoryId::castValue
     Typecast: Cycle\ORM\Parser\Typecast
    Listeners:
             Cycle\ORM\Entity\Behavior\Uuid\Listener\Uuid7
              - field : id
              - nullable : false
    Relations:
     category->sizes has many category_size, eager loading, cascaded
       not null category.id <==> category_size.category_id
     category->created has embedded category:signature:created, eager loading, not cascaded
       n/a category.? <==> category:signature:created.?
     category->updated has embedded category:signature:updated, eager loading, not cascaded
       n/a category.? <==> category:signature:updated.?
     category->deleted has embedded category:signature:deleted, eager loading, not cascaded
       n/a category.? <==> category:signature:deleted.?

[category_size] :: default.category_sizes
       Entity: Domain\Category\Size\Size
       Mapper: Cycle\ORM\Mapper\Mapper
   Repository: Domain\Category\Size\SizeRepository
  Primary key: id
       Fields:
               (property -> db.field -> typecast)
               id -> id -> Domain\Category\Size\SizeId::castValue
               category_id -> category_id -> Domain\Category\CategoryId::castValue
    Relations:
     category_size->category belongs to category, lazy loading, cascaded
       not null category_size.category_id <==> category.id
     category_size->created has embedded category_size:signature:created, eager loading, not cascaded
       n/a category_size.? <==> category_size:signature:created.?
     category_size->updated has embedded category_size:signature:updated, eager loading, not cascaded
       n/a category_size.? <==> category_size:signature:updated.?
     category_size->deleted has embedded category_size:signature:deleted, eager loading, not cascaded
       n/a category_size.? <==> category_size:signature:deleted.?

[category:signature:created] :: default.categories
       Entity: Domain\Auth\Signature
       Mapper: Cycle\ORM\Mapper\Mapper
   Repository: Cycle\ORM\Select\Repository
  Primary key: id
       Fields:
               (property -> db.field -> typecast)
               at -> created_at -> datetime
               by -> created_by -> Domain\Auth\Footprint::castValue
               id -> id -> Domain\Category\CategoryId::castValue
    Relations: not defined

[category:signature:updated] :: default.categories
       Entity: Domain\Auth\Signature
       Mapper: Cycle\ORM\Mapper\Mapper
   Repository: Cycle\ORM\Select\Repository
  Primary key: id
       Fields:
               (property -> db.field -> typecast)
               at -> updated_at -> datetime
               by -> updated_by -> Domain\Auth\Footprint::castValue
               id -> id -> Domain\Category\CategoryId::castValue
    Relations: not defined

[category:signature:deleted] :: default.categories
       Entity: Domain\Auth\Signature
       Mapper: Cycle\ORM\Mapper\Mapper
   Repository: Cycle\ORM\Select\Repository
  Primary key: id
       Fields:
               (property -> db.field -> typecast)
               at -> deleted_at -> datetime
               by -> deleted_by -> Domain\Auth\Footprint::castValue
               id -> id -> Domain\Category\CategoryId::castValue
    Relations: not defined

[category_size:signature:created] :: default.category_sizes
       Entity: Domain\Auth\Signature
       Mapper: Cycle\ORM\Mapper\Mapper
   Repository: Cycle\ORM\Select\Repository
  Primary key: id
       Fields:
               (property -> db.field -> typecast)
               at -> created_at -> datetime
               by -> created_by -> Domain\Auth\Footprint::castValue
               id -> id -> Domain\Category\Size\SizeId::castValue
    Relations: not defined

[category_size:signature:updated] :: default.category_sizes
       Entity: Domain\Auth\Signature
       Mapper: Cycle\ORM\Mapper\Mapper
   Repository: Cycle\ORM\Select\Repository
  Primary key: id
       Fields:
               (property -> db.field -> typecast)
               at -> updated_at -> datetime
               by -> updated_by -> Domain\Auth\Footprint::castValue
               id -> id -> Domain\Category\Size\SizeId::castValue
    Relations: not defined

[category_size:signature:deleted] :: default.category_sizes
       Entity: Domain\Auth\Signature
       Mapper: Cycle\ORM\Mapper\Mapper
   Repository: Cycle\ORM\Select\Repository
  Primary key: id
       Fields:
               (property -> db.field -> typecast)
               at -> deleted_at -> datetime
               by -> deleted_by -> Domain\Auth\Footprint::castValue
               id -> id -> Domain\Category\Size\SizeId::castValue
    Relations: not defined

Version

ORM 2.7.1
PHP 8.2