You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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
The text was updated successfully, but these errors were encountered:
No duplicates 🥲.
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:
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:
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.
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)
Contents of Signature class looks like this:
Signature.php (Embeddable)
Parent Category Entity that has Signatures:
Category.php (Parent Entity)
Child Entity "Sizes", that also has Signatures
Size.php (Child Entity)
Rendered Schema Output:
Schema
Version
The text was updated successfully, but these errors were encountered: