From 2b7ea4178b559f21ffd442ab6b170aa30d036f0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 9 Jul 2024 22:23:24 +0200 Subject: [PATCH] Add versioning to stored objects This update introduces a versioning system to the stored objects in the ChillDocStoreBundle. The 'StoredObject' entity now includes several new methods, and maintains a collection of 'StoredObjectVersion' instances. Each time a 'StoredObject' is modified, a new version instance is created and added to the collection, ensuring a history of changes. Migration file for the addition of new database column included. Corresponding tests are also updated. --- .../Entity/StoredObject.php | 139 +++++++++++------- .../Entity/StoredObjectVersion.php | 127 ++++++++++++++++ .../Tests/Entity/StoredObjectTest.php | 49 +++++- .../migrations/Version20240709102730.php | 80 ++++++++++ 4 files changed, 333 insertions(+), 62 deletions(-) create mode 100644 src/Bundle/ChillDocStoreBundle/Entity/StoredObjectVersion.php create mode 100644 src/Bundle/ChillDocStoreBundle/migrations/Version20240709102730.php diff --git a/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php b/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php index 4472d2177..f37829a26 100644 --- a/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php +++ b/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php @@ -16,9 +16,12 @@ use ChampsLibres\WopiLib\Contract\Entity\Document; use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate; use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; +use Random\RandomException; use Symfony\Component\Serializer\Annotation as Serializer; /** @@ -28,9 +31,12 @@ use Symfony\Component\Serializer\Annotation as Serializer; * * The property `$deleteAt` allow a deletion of the document after the given date. But this property should * be set before the document is actually written by the StoredObjectManager. + * + * Each version is stored within a @see{StoredObjectVersion}, associated with this current's object. The creation + * of each new version should be done using the method @see{self::registerVersion}. */ #[ORM\Entity] -#[ORM\Table('chill_doc.stored_object')] +#[ORM\Table('stored_object', schema: 'chill_doc')] #[AsyncFileExists(message: 'The file is not stored properly')] class StoredObject implements Document, TrackCreationInterface { @@ -43,9 +49,11 @@ class StoredObject implements Document, TrackCreationInterface #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'datas')] private array $datas = []; - #[Serializer\Groups(['write'])] - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)] - private string $filename = ''; + /** + * the prefix of each version. + */ + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])] + private string $prefix = ''; #[Serializer\Groups(['write'])] #[ORM\Id] @@ -53,25 +61,10 @@ class StoredObject implements Document, TrackCreationInterface #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)] private ?int $id = null; - /** - * @var int[] - */ #[Serializer\Groups(['write'])] - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'iv')] - private array $iv = []; - - #[Serializer\Groups(['write'])] - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'key')] - private array $keyInfos = []; - - #[Serializer\Groups(['write'])] - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'title')] + #[ORM\Column(name: 'title', type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => ''])] private string $title = ''; - #[Serializer\Groups(['write'])] - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'type', options: ['default' => ''])] - private string $type = ''; - #[Serializer\Groups(['write'])] #[ORM\Column(type: 'uuid', unique: true)] private UuidInterface $uuid; @@ -94,6 +87,12 @@ class StoredObject implements Document, TrackCreationInterface #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])] private string $generationErrors = ''; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: StoredObjectVersion::class, cascade: ['persist'], mappedBy: 'storedObject')] + private Collection $versions; + /** * @param StoredObject::STATUS_* $status */ @@ -102,6 +101,8 @@ class StoredObject implements Document, TrackCreationInterface private string $status = 'ready' ) { $this->uuid = Uuid::uuid4(); + $this->versions = new ArrayCollection(); + $this->prefix = self::generatePrefix(); } public function addGenerationTrial(): self @@ -125,14 +126,32 @@ class StoredObject implements Document, TrackCreationInterface return \DateTime::createFromImmutable($this->createdAt); } + public function getCurrentVersion(): ?StoredObjectVersion + { + $maxVersion = null; + + foreach ($this->versions as $v) { + if ($v->getVersion() > ($maxVersion?->getVersion() ?? -1)) { + $maxVersion = $v; + } + } + + return $maxVersion; + } + public function getDatas(): array { return $this->datas; } + public function getPrefix(): string + { + return $this->prefix; + } + public function getFilename(): string { - return $this->filename; + return $this->getCurrentVersion()?->getFilename() ?? ''; } public function getGenerationTrialsCounter(): int @@ -145,14 +164,17 @@ class StoredObject implements Document, TrackCreationInterface return $this->id; } + /** + * @return list + */ public function getIv(): array { - return $this->iv; + return $this->getCurrentVersion()?->getIv() ?? []; } public function getKeyInfos(): array { - return $this->keyInfos; + return $this->getCurrentVersion()?->getKeyInfos() ?? []; } /** @@ -171,14 +193,14 @@ class StoredObject implements Document, TrackCreationInterface return $this->status; } - public function getTitle() + public function getTitle(): string { return $this->title; } - public function getType() + public function getType(): string { - return $this->type; + return $this->getCurrentVersion()?->getType() ?? ''; } public function getUuid(): UuidInterface @@ -209,27 +231,6 @@ class StoredObject implements Document, TrackCreationInterface return $this; } - public function setFilename(?string $filename): self - { - $this->filename = (string) $filename; - - return $this; - } - - public function setIv(?array $iv): self - { - $this->iv = (array) $iv; - - return $this; - } - - public function setKeyInfos(?array $keyInfos): self - { - $this->keyInfos = (array) $keyInfos; - - return $this; - } - /** * @param StoredObject::STATUS_* $status */ @@ -247,18 +248,16 @@ class StoredObject implements Document, TrackCreationInterface return $this; } - public function setType(?string $type): self - { - $this->type = (string) $type; - - return $this; - } - public function getTemplate(): ?DocGeneratorTemplate { return $this->template; } + public function getVersions(): Collection + { + return $this->versions; + } + public function hasTemplate(): bool { return null !== $this->template; @@ -314,6 +313,29 @@ class StoredObject implements Document, TrackCreationInterface return $this; } + public function registerVersion( + array $iv = [], + array $keyInfos = [], + string $type = '', + ?string $filename = null, + ): StoredObjectVersion { + $version = new StoredObjectVersion( + $this, + null === $this->getCurrentVersion() ? 0 : $this->getCurrentVersion()->getVersion() + 1, + $iv, + $keyInfos, + $type, + $filename + ); + + $this->versions->add($version); + + return $version; + } + + /** + * @deprecated + */ public function saveHistory(): void { if ('' === $this->getFilename()) { @@ -328,4 +350,13 @@ class StoredObject implements Document, TrackCreationInterface 'before' => (new \DateTimeImmutable('now'))->getTimestamp(), ]; } + + public static function generatePrefix(): string + { + try { + return base_convert(bin2hex(random_bytes(8)), 16, 36); + } catch (RandomException $e) { + return uniqid(more_entropy: true); + } + } } diff --git a/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectVersion.php b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectVersion.php new file mode 100644 index 000000000..c280b30a3 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectVersion.php @@ -0,0 +1,127 @@ + ''])] + private string $filename = ''; + + public function __construct( + /** + * The stored object associated with this version. + */ + #[ORM\ManyToOne(targetEntity: StoredObject::class, inversedBy: 'versions')] + #[ORM\JoinColumn(name: 'stored_object_id', nullable: true)] + private StoredObject $storedObject, + + /** + * The incremental version. + */ + #[ORM\Column(name: 'version', type: \Doctrine\DBAL\Types\Types::INTEGER, options: ['default' => 0])] + private int $version = 0, + + /** + * vector for encryption. + * + * @var int[] + */ + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'iv')] + private array $iv = [], + + /** + * Key infos for document encryption. + * + * @var array + */ + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'key')] + private array $keyInfos = [], + + /** + * type of the document. + */ + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'type', options: ['default' => ''])] + private string $type = '', + ?string $filename = null, + ) { + $this->filename = $filename ?? self::generateFilename($this); + } + + public static function generateFilename(StoredObjectVersion $storedObjectVersion): string + { + try { + $suffix = base_convert(bin2hex(random_bytes(16)), 16, 36); + } catch (RandomException $e) { + $suffix = uniqid(more_entropy: true); + } + + return $storedObjectVersion->getStoredObject()->getPrefix().'/'.$suffix; + } + + public function getFilename(): string + { + return $this->filename; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getIv(): array + { + return $this->iv; + } + + public function getKeyInfos(): array + { + return $this->keyInfos; + } + + public function getStoredObject(): StoredObject + { + return $this->storedObject; + } + + public function getType(): string + { + return $this->type; + } + + public function getVersion(): int + { + return $this->version; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Entity/StoredObjectTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Entity/StoredObjectTest.php index 33394114f..9351947a4 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Entity/StoredObjectTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Entity/StoredObjectTest.php @@ -25,18 +25,22 @@ class StoredObjectTest extends KernelTestCase { $storedObject = new StoredObject(); $storedObject - ->setFilename('test_0') - ->setIv([2, 4, 6, 8]) - ->setKeyInfos(['key' => ['data0' => 'data0']]) - ->setType('text/html'); + ->registerVersion( + [2, 4, 6, 8], + ['key' => ['data0' => 'data0']], + 'text/html', + 'test_0', + ); $storedObject->saveHistory(); $storedObject - ->setFilename('test_1') - ->setIv([8, 10, 12]) - ->setKeyInfos(['key' => ['data1' => 'data1']]) - ->setType('text/text'); + ->registerVersion( + [8, 10, 12], + ['key' => ['data1' => 'data1']], + 'text/text', + 'test_1', + ); $storedObject->saveHistory(); @@ -50,4 +54,33 @@ class StoredObjectTest extends KernelTestCase self::assertEquals(['key' => ['data1' => 'data1']], $storedObject->getDatas()['history'][1]['key_infos']); self::assertEquals('text/text', $storedObject->getDatas()['history'][1]['type']); } + + public function testRegisterVersion(): void + { + $object = new StoredObject(); + $firstVersion = $object->registerVersion( + [5, 6, 7, 8], + ['key' => ['some key']], + 'text/html', + ); + $version = $object->registerVersion( + [1, 2, 3, 4], + $k = ['key' => ['data0' => 'data0']], + 'text/text', + 'abcde', + ); + + self::assertCount(2, $object->getVersions()); + self::assertEquals('abcde', $object->getFilename()); + self::assertEquals([1, 2, 3, 4], $object->getIv()); + self::assertEqualsCanonicalizing($k, $object->getKeyInfos()); + self::assertEquals('text/text', $object->getType()); + + self::assertEquals('abcde', $version->getFilename()); + self::assertEquals([1, 2, 3, 4], $version->getIv()); + self::assertEqualsCanonicalizing($k, $version->getKeyInfos()); + self::assertEquals('text/text', $version->getType()); + + self::assertNotSame($firstVersion, $version); + } } diff --git a/src/Bundle/ChillDocStoreBundle/migrations/Version20240709102730.php b/src/Bundle/ChillDocStoreBundle/migrations/Version20240709102730.php new file mode 100644 index 000000000..4ac01b196 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/migrations/Version20240709102730.php @@ -0,0 +1,80 @@ +addSql('CREATE SEQUENCE chill_doc.stored_object_version_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql( + <<<'SQL' + CREATE TABLE chill_doc.stored_object_version ( + id INT NOT NULL, + stored_object_id INT DEFAULT NULL, + version INT DEFAULT 0 NOT NULL, + filename TEXT NOT NULL, + iv JSON NOT NULL, + key JSON NOT NULL, + type TEXT DEFAULT '' NOT NULL, + createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + createdBy_id INT DEFAULT NULL, + PRIMARY KEY(id)) + SQL + ); + $this->addSql('CREATE INDEX IDX_C1D55302232D562B ON chill_doc.stored_object_version (stored_object_id)'); + $this->addSql('CREATE INDEX IDX_C1D553023174800F ON chill_doc.stored_object_version (createdBy_id)'); + $this->addSql('CREATE UNIQUE INDEX chill_doc_stored_object_version_unique_by_object ON chill_doc.stored_object_version (stored_object_id, version)'); + $this->addSql('COMMENT ON COLUMN chill_doc.stored_object_version.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_doc.stored_object_version ADD CONSTRAINT FK_C1D55302232D562B FOREIGN KEY (stored_object_id) REFERENCES chill_doc.stored_object (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_doc.stored_object_version ADD CONSTRAINT FK_C1D553023174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql( + <<<'SQL' + INSERT INTO chill_doc.stored_object_version (id, stored_object_id, version, filename, iv, key, type) + SELECT nextval('chill_doc.stored_object_version_id_seq'), id, 1, filename, iv, key, type FROM chill_doc.stored_object + SQL + ); + $this->addSql('ALTER TABLE chill_doc.stored_object RENAME COLUMN filename TO prefix'); + $this->addSql('ALTER TABLE chill_doc.stored_object DROP key'); + $this->addSql('ALTER TABLE chill_doc.stored_object DROP iv'); + $this->addSql('ALTER TABLE chill_doc.stored_object DROP type'); + $this->addSql('ALTER TABLE chill_doc.stored_object ALTER title SET DEFAULT \'\''); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE chill_doc.stored_object_version_id_seq CASCADE'); + $this->addSql('ALTER TABLE chill_doc.stored_object RENAME COLUMN prefix TO filename'); + $this->addSql('ALTER TABLE chill_doc.stored_object ADD type TEXT NOT NULL DEFAULT \'\''); + $this->addSql('ALTER TABLE chill_doc.stored_object ADD key JSON NOT NULL DEFAULT \'{}\''); + $this->addSql('ALTER TABLE chill_doc.stored_object ADD iv JSON NOT NULL DEFAULT \'[]\''); + $this->addSql('ALTER TABLE chill_doc.stored_object ALTER title DROP DEFAULT'); + $this->addSql( + <<<'SQL' + UPDATE chill_doc.stored_object SET filename=sov.filename, type=sov.type, iv=sov.iv, key=sov.key + FROM chill_doc.stored_object_version sov WHERE sov.stored_object_id = stored_object.id + AND sov.version = (SELECT MAX(version) FROM chill_doc.stored_object_version AS sub_sov WHERE sub_sov.stored_object_id = stored_object.id) + SQL + ); + $this->addSql('ALTER TABLE chill_doc.stored_object_version DROP CONSTRAINT FK_C1D55302232D562B'); + $this->addSql('ALTER TABLE chill_doc.stored_object_version DROP CONSTRAINT FK_C1D553023174800F'); + $this->addSql('DROP TABLE chill_doc.stored_object_version'); + } +}