Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Extensiones de la Doctrine: Timestampable

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Me gusta mucho añadir el comportamiento timestampable a mis entidades. Es cuando tienes propiedades$createdAt y $updatedAt que se establecen automáticamente. Simplemente... ayuda a llevar la cuenta de cuándo sucedieron las cosas. Hemos añadido $createdAt y lo hemos establecido inteligentemente a mano en el constructor. ¿Pero qué pasa con $updatedAt? Doctrine tiene un impresionante sistema de eventos, y podríamos engancharnos a él para ejecutar un código en la "actualización" que establezca esa propiedad. Pero hay una biblioteca que ya hace eso. Así que vamos a instalarla.

Instalación de stof/doctrine-extensions-bundle

En tu terminal, ejecuta:

composer require stof/doctrine-extensions-bundle

Esto instala un pequeño bundle, que es una envoltura de una biblioteca llamada DoctrineExtensions. Como muchos paquetes, éste incluye una receta. Pero ésta es la primera receta que proviene del repositorio "contrib". Recuerda: Symfony tiene en realidad dos repositorios de recetas. Está el principal, que está estrechamente vigilado por el equipo principal de Symfony. Luego hay otro llamado recipes-contrib. Hay algunos controles de calidad en ese repositorio, pero está mantenido por la comunidad. La primera vez que Symfony instala una receta del repositorio "contrib", te pregunta si está bien. Voy a decir p por "sí permanentemente". Luego ejecuta:

get status

¡Impresionante! Ha habilitado un bundle y ha añadido un nuevo archivo de configuración que veremos en un segundo.

Habilitación de Timestampable

Obviamente, este bundle tiene su propia documentación. Puedes buscarstof/doctrine-extensions-bundle y encontrarla en Symfony.com. Pero la mayor parte de la documentación está en la biblioteca DoctrineExtensions subyacente... que contiene un montón de comportamientos realmente interesantes, incluyendo "sluggable" y "timestampable". Vamos a añadir primero "timestampable".

Primer paso: entra en config/packages/ y abre el archivo de configuración que acaba de añadir. Aquí, añade orm porque estamos utilizando Doctrine ORM, luego default, y por último timestampable: true.

... lines 1 - 2
stof_doctrine_extensions:
default_locale: en_US
orm:
default:
timestampable: true

Esto no hará realmente nada todavía. Sólo activa un oyente de Doctrine que buscará entidades que soporten timestampable cada vez que se inserte o actualice una entidad. ¿Cómo hacemos que nuestro VinylMix admita timestampable? La forma más sencilla (y la que a mí me gusta hacer) es mediante un rasgo.

En la parte superior de la clase, di use TimestampableEntity.

... lines 1 - 7
use Gedmo\Timestampable\Traits\TimestampableEntity;
... lines 9 - 10
class VinylMix
{
use TimestampableEntity;
... lines 14 - 124
}

Eso es todo. ¡Ya hemos terminado! ¡Hora de comer!

Para entender esta magia negra, mantén pulsado "cmd" o "ctrl" y haz clic en TimestampableEntity. Esto añade dos propiedades: createdAt yupdatedAt. Y son campos normales, como el createdAt que teníamos antes. También tiene métodos getter y setter aquí abajo, igual que tenemos en nuestra entidad.

La magia es este atributo #[Gedmo\Timestampable()]. Esto dice que

esta propiedad debe establecerse on: 'update'

y

esta propiedad debe establecerse on: 'create'.

Gracias a este rasgo, ¡obtenemos todo esto gratis! Y... ya no necesitamos nuestra propiedadcreatedAt... porque ya vive en el trait. Así que elimina la propiedad... y el constructor... y aquí abajo, elimina los métodos getter y setter ¡Limpieza!

Añadir la migración

El trait tiene una propiedad createdAt como la que teníamos antes, pero además añade un campoupdatedAt. Así que tenemos que crear una nueva migración para eso. Ya conoces el procedimiento. En tu terminal, ejecuta:

symfony console make:migration

Entonces... vamos a comprobar ese archivo... para asegurarnos de que queda como esperamos. Veamos aquí... ¡sí! Tenemos ALTER TABLE vinyl_mix ADD updated_at. Y aparentemente la columna created_at será un poco diferente a la que teníamos antes.

... lines 1 - 12
final class Version20220718170826 extends AbstractMigration
{
... lines 15 - 19
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE vinyl_mix ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL');
$this->addSql('ALTER TABLE vinyl_mix ALTER created_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('ALTER TABLE vinyl_mix ALTER created_at DROP DEFAULT');
$this->addSql('COMMENT ON COLUMN vinyl_mix.created_at IS NULL');
}
... lines 28 - 37
}

Cuando las migraciones fallan

Bien, vamos a ejecutarlo:

symfony console doctrine:migrations:migrate

Y... ¡falla!

[...] column "updated_at" of relation "vinyl_mix" contains null values.

Esto es un Not null violation... lo cual tiene sentido. Nuestra base de datos ya tiene un montón de registros... así que cuando intentamos añadir una nueva columna updated_at que no permite valores nulos... se vuelve loco.

Si el estado actual de nuestra base de datos ya estuviera en producción, tendríamos que ajustar esta migración para dar a la nueva columna un valor por defecto para esos registros existentes. Entonces podríamos volver a cambiarla para que no permita nulos. Para saber más sobre el manejo de migraciones fallidas, consulta un capítulo de nuestro tutorial de Doctrine de Symfony 5.

Pero como todavía no tenemos una base de datos de producción que contenga viny_mix filas, podemos tomar un atajo: eliminar la base de datos y empezar de nuevo con cero filas. Para ello, ejecuta

symfony console doctrine:database:drop --force

para eliminar completamente nuestra base de datos. Y vuelve a crearla con

symfony console doctrine:database:create

En este punto, tenemos una base de datos vacía sin tablas, incluso la tabla de migraciones ha desaparecido. Así que podemos volver a ejecutar todas nuestras migraciones desde el principio. Hazlo:

symfony console doctrine:migrations:migrate

¡Genial! Se han ejecutado tres migraciones: todas con éxito.

De vuelta a nuestro sitio, si vamos a "Examinar mezclas", está vacío... porque hemos vaciado nuestra base de datos. Así que vayamos a /mix/new para crear la mezcla ID 1... y luego refresquemos unas cuantas veces más. Ahora dirígete a /mix/7... y sube la nota, lo que actualizará eseVinylMix.

De acuerdo ¡Veamos si la marca de tiempo ha funcionado! Comprueba la base de datos ejecutando:

symfony console doctrine:query:sql 'SELECT * FROM vinyl_mix WHERE id = 7'

Y... ¡impresionante! El created_at está configurado y luego el updated_at está configurado justo unos segundos después de que hayamos votado la mezcla. Funciona. Ahora podemos añadir fácilmente timestampable a cualquier entidad nueva en el futuro, simplemente añadiendo ese rasgo.

A continuación: vamos a aprovechar otro comportamiento: sluggable. Esto nos permitirá crear URLs más elegantes guardando automáticamente una versión segura de la URL del título en una nueva propiedad.

Leave a comment!

30
Login or Register to join the conversation
Benoit-L Avatar
Benoit-L Avatar Benoit-L | posted hace 9 meses | edited

Hello,

For some reason, this is what I get in the version file

    public function up(Schema $schema): void
    {
        // this up() migration is auto-generated, please modify it to your needs
        $this->addSql('ALTER TABLE vinyl_mix ADD updated_at DATETIME NOT NULL, CHANGE created_at created_at DATETIME NOT NULL');
    }

    public function down(Schema $schema): void
    {
        // this down() migration is auto-generated, please modify it to your needs
        $this->addSql('ALTER TABLE vinyl_mix DROP updated_at, CHANGE created_at created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\'');
    }

And I do not get any error message as mentioned in the exercise.

Best regards

Benoit Lorant

1 Reply

Hey guys!

No error? That's even better ;) Well, most probably you have a fresher Doctrine migrations package installed, or you have a fresher MariaDB server version, or even both :) I see your app was able to generate a single query migration, i.e. it's was optimized in comparison to the migration in the video. In other words, migrations might be slightly different because of different versions. So, as long as this migration works for you - that's great, you can ignore hunting about that error ;)

Cheers!

1 Reply
remy Avatar

Same for me... My project ist connected to a local MariaDB, but this should not have any effect?!

Reply

Hey Remy,

I replied with my thoughts about this difference here: https://symfonycasts.com/screencast/symfony-doctrine/timestampable#comment-28049

Cheers!

Reply
Benoit-L Avatar

It’s also a Maria DB on my side.

Benoit

Reply
ssi-anik Avatar

If someone wants to migrate the newly generated migration without dropping the whole database, add DEFAULT CURRENT_TIMESTAMP(0)::TIMESTAMP WITHOUT TIME ZONE in your updated_at column's definition after NOT NULL and running the migration should work fine.

1 Reply

Hey Ssi-anik,

Thank you for the tip! I personally didn't try this but I'll believe you that it works :)

Cheers!

1 Reply
Cyril Avatar

Hi!
Is there a way to update the updateAt timestamp of a parent entity when only a collection of child entities is modified and not the parent entity itself ? The preUpdate Event seems not to be fired :-(

Thanks for your answer
Cyril

Reply
Cyril Avatar

Finally I had to create my own trait to set the updatedAt value with PreFlush LifecycleCallback in the parent entity. It fires well the preUpdate Event but even when no change was made at all… If someone has another idea, it will be appreciated. Thanks !

Reply

hey @Cyril

It's pretty hard to get some advice without seeing how listener was implemented IIRC, you can check if there were some changes on entity, there is method on event argument to check the change set

Cheers!

Reply
Cyril Avatar

Hi,
Here is my solution with an EventSubscriber. Maybe it could help someone else as it works for me.

//src/EventListener/TimestampCollectionParentEntitySubscriber.php

<?php

namespace App\EventListener;

use Doctrine\ORM\Events;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\Event\LifecycleEventArgs;
use Doctrine\Bundle\DoctrineBundle\EventSubscriber\EventSubscriberInterface;

class TimestampCollectionParentEntitySubscriber implements EventSubscriberInterface
{
    public function __construct(private EntityManagerInterface $em) {
    }

    public function getSubscribedEvents(): array
    {
        //don't use "pre" events as it creates an infinite loop...
        return [            
            Events::postPersist,
            Events::postRemove,
            Events::postUpdate,            
        ];
    }
    
    public function postPersist(LifecycleEventArgs $args): void
    {
        $this->_timestampParentEntity($args);
    }
    public function postUpdate(LifecycleEventArgs $args): void
    {
        $this->_timestampParentEntity($args);
    }
    public function postRemove(LifecycleEventArgs $args): void
    {
        $this->_timestampParentEntity($args);
    }

    private function _timestampParentEntity($args) {
    
        // During a Doctrine Event, the EntityManager gives us this array :
        // em->getUnitOfWork()->getIdentityMap() which looks like
        // [
        //    'classname1' => [id1 => entity1],
        //    'classname2' => [id2 => entity2]
        // ]
        // and its first element (entity1) is the highest entity in the form
        // => this is the entity we want to timestamp
       
        $identityMap = $this->em->getUnitOfWork()->getIdentityMap();
        if($identityMap !== []) {
            foreach(array_slice($identityMap, 0, 1, true) as $parentClassname => $parentArray);
            foreach(array_slice($parentArray, 0, 1, true) as $parentId => $parent);
            if($args->getObject() !== $parent && method_exists($parent, 'setUpdatedAt')) {
                //here, the Event does NOT concern the parent itself BUT necessarily a child collection inside the parent => let's timestamp the parent!
                $parent->setUpdatedAt(new \DateTime());
                //then flush
                $this->em->flush();
            }
        }
    }
}
Reply

Honestly I'm not sure about your way, I think it can be done with less code and without several foreach blocks, as I understand the collection is some sort of One-To-Many relation, so you can just make a doctrine listener and create some sort of ParentTimeStampableInterface and check if persisted/updated/deleted entity is instance of this interface then simple getParent() method to get parent entity and make your changes

or maybe I'm missing something =)

Cheers.

Reply
Cyril Avatar

Of course there are other ways (array_values, for example) to get the parent entity which is a bit deeply wrapped in $identityMap which is an associative array.

But anyways, I don't understand your approach as I'm not very familiar with interfaces :-(
If you ever have time to spend in it, a small example of code will be appreciated!

Thanks

Reply
Georg-L Avatar
Georg-L Avatar Georg-L | posted hace 4 meses | edited

Hi there,
I was trying to avoid droping the database and played around a bit. There seems to be no way to rollback a migration.
I ended up dropping my database and start from scratch... that is NO solution on production machines. There are functions (up and down) filled automaticaly with code but
how can I tell doctrine to rollback the last migration? i.e. to use the down method of the actual migration?

I tried things like
php app/console doctrine:migrations:execute YYYYMMDDHHMMSS --down but that did not work. I got lots of errormessages like this
`In ExceptionConverter.php line 87:

An exception occurred while executing a query: SQLSTATE[42P06]: Duplicate schema: 7 ERROR: schema "public" already exists`

Reply

Hey @Georg-L!

Yes, when things go wrong with migrations, life gets tricky :p. And part of that can't be avoided: if we get surprised by a failing migration when deploying, it means something unexpected happened. Then, to make matters worse, fixing it may not be as simple as running the "down" method. For example, suppose you have a migration that executes 3 SQL statements. When you run it, the 1st is successful, but the 2nd fails (and so the 3rd also didn't run). If you ran the down() method, it will likely also fail because usually those methods try to do up() in reverse. So, for example, if the 3rd statement in up was a CREATE TABLE foo, then the 1st statement in down() will be DROP TABLE foo... which will fail, because foo was never created.

So, as you can see - a big mess when things fail! But sometimes running down WILL help. And you DID use the correct command. My guess is that it's failing due to the reason I described above.

When this happens to me (it's rare, but bad things DO happen), I debug it by hand. There's simply no automatic way to know exactly how a migration failed (did none of the statements run successfully? Or some of them?). In this situation, doctrine:schema:update can be your friend:

php bin/console doctrine:schema:update --dump-sql

That'll show you what is "missing" from your production database. It might be enough to execute those:

php bin/console doctrine:schema:update --force

Or, more likely, I may copy those commands and "adapt" them quickly, and run them manually. For example, suppose, like in this video, I'm missing a new column that does NOT allow null... but that failed because of a not null violation. To fix this, I would copy the SQL statement from doctrine:schema:update, change it to "YES" allow NULL temporarily, then run that. At that point, your site will probably start working again, and you can figure out your next steps. Probably you'll run some SQL to give all of the existing records some value for the column, and THEN run another SQL query to change the column from "yes" allows null to "NOT" allow null. Finally, after fixing things by hand, you may need to tell Doctrine manually that the migration that failed "did run" (since you have basically completed that migration manually) so that it won't try to run it again on the next deploy. You can do that with the doctrine:migrations:version command, followed by the version.

Phew! Let me know if this helps - it is always an ugly situation when this happens!

Cheers!

1 Reply
Georg-L Avatar

Hey Rayn,
thank you so much for your answer. This is really very helpful. Yeah, failing migrations ar ugly, brrrr. I have some experience with phinx. I can tell you, that this can also be messed up, if you try hard enough ;)

So thanks to you, I have a toolset to handle these situations.

See U.

btw.
I love this course. You did a great job. Thank you.

Reply

Happy this was helpful - and thanks for the kind words ❤️

Reply
Michael-S Avatar
Michael-S Avatar Michael-S | posted hace 5 meses

By adding that trait, are you not effectively coupling your domain to the infrastructure (beyond the necessary and forgivable attributes/annotations for mapping), which should be what you'd want to avoid doing?

Reply

Hey Michael,

Hm, maybe? That trait gives you some ready-to-use functionality. You can do it yourself, with createdAt it's pretty easy, you can set it in the constructor. With the updatedAt - a bit more complex, you need to add a listener to track when the entity is actually updated. So, at least it save your time.

Cheers!

Reply

Hello,

For some reason, I get an error while installing this bundle the error:

[Semantical Error] The class "Symfony\Contracts\Service\Attribute\Required" is not annotated with @Annotation.
Are you sure this class can be used as annotation?
If so, then you need to add @Annotation to the class doc comment of "Symfony\Contracts\Service\Attribute\Required".
If it is indeed no annotation, then you need to add @IgnoreAnnotation("required") to the class doc comment of method Symfony\Bundle\FrameworkBundle\Controller\AbstractController::setContainer() in {"path":"..\/src\/Controller\/","namespace":"App\Controller"} (which is being imported from "/Users/macbookpro/Documents/symfonycasts/mixed_vinyl/config/routes.yaml"). Make sure there is a loader supporting the "attribute" type.

any help please!!

Reply

Hey JnahDev,

Did you download the course code, or are you following this tutorial on your own? It seems like your project is expecting "annotations" but in Symfony 6 those were removed and you should use PHP attributes

Reply

No I'm following the tutorial on my own, maybe I need to download the source code and continue other chapters.
Thank you MolloKhan.

Reply

yes, that's how we recommend following our tutorials, although you can do it on your own, but of course, you'll find problems like this along the way

Reply

I fixed this bug by adding annotations.yaml in config/routes, but I don't understand why this problem occurs.

controllers:
  resource: ../../src/Controller/
  type: annotation

kernel:
  resource: ../../src/Kernel.php
  type: annotation

After I installed Doctrine bundle I encountered this bug.

Reply
Markchicobaby Avatar
Markchicobaby Avatar Markchicobaby | posted hace 6 meses

Hi I really like the automatic CreatedAt and UpdatedAt feature. I am wondering how do I change the column name that is created to store these, I'm thinking I would need to edit the file below and add the Doctrine column name property to the annotation for the protected $createdAt property?
\vendor\gedmo\doctrine-extensions\src\Timestampable\Traits\TimestampableEntity.php

Is there a more "reusable" way, that doesn't modify the file from the package?

Thank you
Mark

Reply

Hey Mark,

I'm afraid that's not possible if you use the entity trait. For that purpose, you'll have to add each property to your entity like this

    #[Gedmo\Timestampable(on: 'create')]
    #[ORM\Column(name: 'table_name', type: 'datetime')]
    private ?\DateTimeInterface $createdAt = null;

I hope it helps. Cheers!

1 Reply
Camyl Avatar

Hey!

After installing stof/docrtine-extensions-bundle Symfony is throwing this error: Call to undefined method Doctrine\Common\Annotations\AnnotationRegistry::registerLoader(). My version of Symfony is 6.2.1.

Reply
fabriziofs Avatar
fabriziofs Avatar fabriziofs | Camyl | posted hace 5 meses

This error was fixed in version 6.2.3

Sorry for the late reply!

1 Reply
TS Avatar

Hi, there seems to be a little typo : Instead of "get status" you meant "git status", correct?

Reply

Nice catch! Thank you TS

By the way, in case you're interested in helping a bit further. We have a GitHub link on each chapter script where you can submit change requests

Cheers!

1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.7", // v3.7.0
        "doctrine/doctrine-bundle": "^2.7", // 2.7.0
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.12", // 2.12.3
        "knplabs/knp-time-bundle": "^1.18", // v1.19.0
        "pagerfanta/doctrine-orm-adapter": "^3.6", // v3.6.1
        "pagerfanta/twig": "^3.6", // v3.6.1
        "sensio/framework-extra-bundle": "^6.2", // v6.2.6
        "stof/doctrine-extensions-bundle": "^1.7", // v1.7.0
        "symfony/asset": "6.1.*", // v6.1.0
        "symfony/console": "6.1.*", // v6.1.2
        "symfony/dotenv": "6.1.*", // v6.1.0
        "symfony/flex": "^2", // v2.2.2
        "symfony/framework-bundle": "6.1.*", // v6.1.2
        "symfony/http-client": "6.1.*", // v6.1.2
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/proxy-manager-bridge": "6.1.*", // v6.1.0
        "symfony/runtime": "6.1.*", // v6.1.1
        "symfony/twig-bundle": "6.1.*", // v6.1.1
        "symfony/ux-turbo": "^2.0", // v2.3.0
        "symfony/webpack-encore-bundle": "^1.13", // v1.15.1
        "symfony/yaml": "6.1.*", // v6.1.2
        "twig/extra-bundle": "^2.12|^3.0", // v3.4.0
        "twig/twig": "^2.12|^3.0" // v3.4.1
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "symfony/debug-bundle": "6.1.*", // v6.1.0
        "symfony/maker-bundle": "^1.41", // v1.44.0
        "symfony/stopwatch": "6.1.*", // v6.1.0
        "symfony/web-profiler-bundle": "6.1.*", // v6.1.2
        "zenstruck/foundry": "^1.21" // v1.21.0
    }
}
userVoice