zenstruck / foundry

A model factory library for creating expressive, auto-completable, on-demand dev/test fixtures with Symfony and Doctrine.
https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html
MIT License
659 stars 75 forks source link

Create entity from another (clone) #262

Open bastien70 opened 2 years ago

bastien70 commented 2 years ago

Hello, is it possible to use the Factory to create some entity using a cloned entity ?

By example, I've the entity Foo with one entry in database, I want to create another entity Foo but with the clone of the last database entry, and just update after that some properties.

Is it possible with Foundry ?

kbond commented 2 years ago

Hey @bastien70! Not possibly currently but is an interesting idea. Maybe something like:

$post1 = PostFactory::createOne(['title' => 'original title']);
$post2 = $post1->clone(['title' => 'cloned title']);

Not sure what clone() should return though... A Factory instance? An unpersisted Proxy? A persisted Proxy?

What about relationships, should these be cloned?

bastien70 commented 2 years ago

Mmmh I was thinking about this kind of scenario :

We have an entity like this :

class Category
{
    private ?int $id = null;
    private ?string $title = null;
    private Collection $articles;
    private ?DateTimeImmutable $createdAt = null;

    public function __construct()
    {
        $this->articles = new ArrayCollection();
        $this->createdAt = new DateTimeImmutable();
    }

    public function __clone(): void
    {
        $this->id = null;
        $this->createdAt = new DateTimeImmutable();
    }
}

With a __clone() method which will copy the name and title of the current category, unset the id (to persist in database), and set a new createdAt.

Next, one can imagine that one wishes to continue the modification of this new entity but from the factory. Maybe with something like :

$currentCategory = ...;

$newCategory = clone $currentCategory; // new category but with same title and name

$newCategory = CategoryFactory::new()->from($newCategory)->createOne(['title' => 'cloned title]);

At the end, we would therefore have a new category persisted in the database, created from a previous category, but to which we have modified the title.

Obviously in reality, we can imagine that we have many more properties at the level of the entity, hence the usefulness of the clone.

We can even go further by assuming that the from method of the Factory would itself clone the entity, rather than cloning it ourselves

The ideal would also be to be able to clone the relations (in the case of the Category entity of the example, it has several articles. If we can also duplicate these articles, that would be good, but I imagine that it would be done in outside of Foundry-related features).

In the end, the only role of the bundle would be to add a from() method to the ModelFactory which could take an entity as a parameter, and apply the __clone() method to it (which we can define in each entity if we wish to control ourselves what needs to be cloned), then assign the new object in the factory, which we can then modify as we usually would.

kbond commented 2 years ago

I see. Yeah, what I was thinking might make more sense as ->duplicate().

Maybe a from() method on Factory itself as you say:

$newCategory = CategoryFactory::from($oldCategory) // clones $oldCategory, removes id field (in case you don't have a __clone method that does this), extracts existing properties as defaults
    ->createOne(['title' => 'cloned title'])
;

Still not sure how we'd handle relationships though.

bastien70 commented 2 years ago

So for the relations, in a "normal" code that we would put in the __clone method, it goes like this :

    public function __clone(): void
    {
        $this->id = null;
        $this->createdAt = new DateTimeImmutable();

        $clonedArticles = new ArrayCollection();

        /** @var Article $article */
        foreach($this->articles as $article)
        {
            $articleCloned = clone $module;
            $articleCloned->setCategory($this);
            $clonedArticles->add($articleCloned);
        }

        $this->articles = $clonedArticles;
    }

(we imagine that the Article entity also has a __clone() method in which the id is set to null) And that the $articles property of Category is cascading persist. That's it, I think it's better to let the developer implement the relationship cloning part also in the __clone() method

kbond commented 2 years ago

I think the default should be (assuming no __clone method set):

A real clone call would be made so as you demonstrate above, you can modify the behaviour via __clone().

WDYT?

bastien70 commented 2 years ago

Delete the id, that's for sure, we agree. Afterwards concerning relationships, it's hard to say, we can't imagine a default behavior because... would there really be one? To be honest, it can really depend, so I don't know.

Perhaps an integer in the method that would allow you to say the depth at which you want to clone?

Example :

We have the Category entity which has several Articles.

Article entity has multiple Tags

We can then imagine :

=> We pass 0 as depth => We clone only Category (no articles) => We pass 1 as depth => We clone Category and its articles (without id) => We pass 2 as depth => We clone Category, its articles and the articles tags etc

What do you think ?

kbond commented 2 years ago

Yeah, I think that makes sense. By default, follow the standard PHP clone logic and perform a shallow copy. This seems like a good starting point.

I believe overriding the __clone method could achieve the deep copy you are describing (but only if you have cascade: persist for the relationship). Is this correct?

Of course, after calling Factory::from(), in the create() call you can pass new relationship values.

What we don't want is for users to have to override the __clone method on their entities/documents just to satisfy foundry.

A followup iteration could add the depth option (I'd like to keep the feature as simple as possible to start).

bastien70 commented 2 years ago

(but only if you have cascade: persist for the relationship). Is this correct?

Yes ! :)

Of course, after calling Factory::from(), in the create() call you can pass new relationship values.

Yes too

Yes the goal is not to give too much work to the user, at least not superficial thing