feat(project-management) : add timestampable/blamable to Task and Project (additive)
Tranche 3 of LST-65. Task and Project adopt TimestampableBlamableTrait. - Additive migration on task and project: created_at/updated_at (nullable), created_by/updated_by (nullable INT, FK to "user" ON DELETE SET NULL) + indexes + COMMENT ON COLUMN. down() drops only the added objects. - Trait fields stay out of the existing API groups (trait carries its own). - Functional test (TaskTimestampableTest) confirms created_at on persist and updated_at refresh on update. 161 tests green, no destructive migration.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ProjectManagement module (LST-65 slice 3): add Timestampable/Blamable columns
|
||||
* to task and project.
|
||||
*
|
||||
* Task and Project adopt TimestampableBlamableTrait (after TimeEntry). This
|
||||
* migration is purely additive — nullable columns + nullable FK to "user" with
|
||||
* ON DELETE SET NULL. No DROP/ALTER on existing data. Columns are lowercase
|
||||
* snake_case. Hand-written to guarantee zero destructive instruction.
|
||||
*/
|
||||
final class Version20260620161500 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ProjectManagement: add timestampable/blamable columns to task and project (additive)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// task
|
||||
$this->addSql('ALTER TABLE task ADD created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE task ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE task ADD created_by INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE task ADD updated_by INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB25DE12AB56 FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB2516FE72E1 FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
$this->addSql('CREATE INDEX IDX_527EDB25DE12AB56 ON task (created_by)');
|
||||
$this->addSql('CREATE INDEX IDX_527EDB2516FE72E1 ON task (updated_by)');
|
||||
$this->addSql("COMMENT ON COLUMN task.created_at IS 'Creation timestamp (Timestampable, set on prePersist)'");
|
||||
$this->addSql("COMMENT ON COLUMN task.updated_at IS 'Last update timestamp (Timestampable, set on prePersist/preUpdate)'");
|
||||
$this->addSql("COMMENT ON COLUMN task.created_by IS 'User who created the entry (Blamable, FK user.id, SET NULL on delete)'");
|
||||
$this->addSql("COMMENT ON COLUMN task.updated_by IS 'User who last updated the entry (Blamable, FK user.id, SET NULL on delete)'");
|
||||
|
||||
// project
|
||||
$this->addSql('ALTER TABLE project ADD created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE project ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE project ADD created_by INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE project ADD updated_by INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE project ADD CONSTRAINT FK_2FB3D0EEDE12AB56 FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE project ADD CONSTRAINT FK_2FB3D0EE16FE72E1 FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
$this->addSql('CREATE INDEX IDX_2FB3D0EEDE12AB56 ON project (created_by)');
|
||||
$this->addSql('CREATE INDEX IDX_2FB3D0EE16FE72E1 ON project (updated_by)');
|
||||
$this->addSql("COMMENT ON COLUMN project.created_at IS 'Creation timestamp (Timestampable, set on prePersist)'");
|
||||
$this->addSql("COMMENT ON COLUMN project.updated_at IS 'Last update timestamp (Timestampable, set on prePersist/preUpdate)'");
|
||||
$this->addSql("COMMENT ON COLUMN project.created_by IS 'User who created the entry (Blamable, FK user.id, SET NULL on delete)'");
|
||||
$this->addSql("COMMENT ON COLUMN project.updated_by IS 'User who last updated the entry (Blamable, FK user.id, SET NULL on delete)'");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// task
|
||||
$this->addSql('ALTER TABLE task DROP CONSTRAINT FK_527EDB25DE12AB56');
|
||||
$this->addSql('ALTER TABLE task DROP CONSTRAINT FK_527EDB2516FE72E1');
|
||||
$this->addSql('DROP INDEX IDX_527EDB25DE12AB56');
|
||||
$this->addSql('DROP INDEX IDX_527EDB2516FE72E1');
|
||||
$this->addSql('ALTER TABLE task DROP created_at');
|
||||
$this->addSql('ALTER TABLE task DROP updated_at');
|
||||
$this->addSql('ALTER TABLE task DROP created_by');
|
||||
$this->addSql('ALTER TABLE task DROP updated_by');
|
||||
|
||||
// project
|
||||
$this->addSql('ALTER TABLE project DROP CONSTRAINT FK_2FB3D0EEDE12AB56');
|
||||
$this->addSql('ALTER TABLE project DROP CONSTRAINT FK_2FB3D0EE16FE72E1');
|
||||
$this->addSql('DROP INDEX IDX_2FB3D0EEDE12AB56');
|
||||
$this->addSql('DROP INDEX IDX_2FB3D0EE16FE72E1');
|
||||
$this->addSql('ALTER TABLE project DROP created_at');
|
||||
$this->addSql('ALTER TABLE project DROP updated_at');
|
||||
$this->addSql('ALTER TABLE project DROP created_by');
|
||||
$this->addSql('ALTER TABLE project DROP updated_by');
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,11 @@ use ApiPlatform\Metadata\Post;
|
||||
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\Resource\SwitchWorkflowOutput;
|
||||
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\SwitchProjectWorkflowProcessor;
|
||||
use App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineProjectRepository;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\ClientInterface;
|
||||
use App\Shared\Domain\Contract\ProjectInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -56,8 +59,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
|
||||
#[ORM\Entity(repositoryClass: DoctrineProjectRepository::class)]
|
||||
#[UniqueEntity(fields: ['code'], message: 'Ce code de projet est déjà utilisé.')]
|
||||
class Project implements ProjectInterface
|
||||
class Project implements ProjectInterface, TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
|
||||
@@ -18,8 +18,11 @@ use ApiPlatform\Metadata\Post;
|
||||
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskCalendarProcessor;
|
||||
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskNumberProcessor;
|
||||
use App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRepository;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TaskInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -47,8 +50,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ORM\Entity(repositoryClass: DoctrineTaskRepository::class)]
|
||||
#[ORM\Table(name: 'task')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_task_project_number', columns: ['project_id', 'number'])]
|
||||
class Task implements TaskInterface
|
||||
class Task implements TaskInterface, TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Module\ProjectManagement;
|
||||
|
||||
use App\Module\ProjectManagement\Domain\Entity\Project;
|
||||
use App\Module\ProjectManagement\Domain\Entity\Task;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* Task adopts TimestampableBlamableTrait (LST-65 slice 3).
|
||||
* Verifies the TimestampableBlamableSubscriber populates created_at on
|
||||
* persist and refreshes updated_at on update.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class TaskTimestampableTest extends KernelTestCase
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->em->getConnection()->executeStatement("DELETE FROM task WHERE title = 'ts-test-task'");
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->em->getConnection()->executeStatement("DELETE FROM task WHERE title = 'ts-test-task'");
|
||||
parent::tearDown();
|
||||
unset($this->em);
|
||||
}
|
||||
|
||||
public function testCreatedAtIsSetOnPersist(): void
|
||||
{
|
||||
$task = $this->makeTask();
|
||||
$this->em->persist($task);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertInstanceOf(DateTimeImmutable::class, $task->getCreatedAt(), 'createdAt must be set after flush');
|
||||
self::assertInstanceOf(DateTimeImmutable::class, $task->getUpdatedAt(), 'updatedAt must be set after flush');
|
||||
|
||||
$taskId = $task->getId();
|
||||
$this->em->clear();
|
||||
$reloaded = $this->em->getRepository(Task::class)->find($taskId);
|
||||
self::assertNotNull($reloaded);
|
||||
self::assertNotNull($reloaded->getCreatedAt(), 'created_at must be persisted in DB');
|
||||
}
|
||||
|
||||
public function testUpdatedAtIsRefreshedOnUpdate(): void
|
||||
{
|
||||
$task = $this->makeTask();
|
||||
$this->em->persist($task);
|
||||
$this->em->flush();
|
||||
|
||||
$taskId = $task->getId();
|
||||
$initialUpdatedAt = $task->getUpdatedAt();
|
||||
self::assertNotNull($initialUpdatedAt);
|
||||
|
||||
// Backdate the persisted value so the preUpdate refresh is observable
|
||||
// even within the same second.
|
||||
$this->em->getConnection()->executeStatement(
|
||||
"UPDATE task SET updated_at = '2000-01-01 00:00:00' WHERE id = :id",
|
||||
['id' => $taskId],
|
||||
);
|
||||
$this->em->clear();
|
||||
|
||||
/** @var Task $managed */
|
||||
$managed = $this->em->getRepository(Task::class)->find($taskId);
|
||||
self::assertSame('2000-01-01 00:00:00', $managed->getUpdatedAt()?->format('Y-m-d H:i:s'));
|
||||
|
||||
$managed->setDescription('changed description');
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
|
||||
/** @var Task $reloaded */
|
||||
$reloaded = $this->em->getRepository(Task::class)->find($taskId);
|
||||
self::assertNotNull($reloaded->getUpdatedAt());
|
||||
self::assertNotSame(
|
||||
'2000-01-01 00:00:00',
|
||||
$reloaded->getUpdatedAt()->format('Y-m-d H:i:s'),
|
||||
'updated_at must be refreshed and persisted on UPDATE',
|
||||
);
|
||||
}
|
||||
|
||||
private function makeTask(): Task
|
||||
{
|
||||
$project = $this->em->getRepository(Project::class)->findOneBy([]);
|
||||
self::assertNotNull($project, 'Les fixtures doivent fournir au moins un projet.');
|
||||
|
||||
$task = new Task();
|
||||
$task->setNumber(random_int(100000, 999999));
|
||||
$task->setTitle('ts-test-task');
|
||||
$task->setProject($project);
|
||||
|
||||
return $task;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user