diff --git a/composer.json b/composer.json index ca21a3a..19f1ca3 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "require": { "php": "^8.3", "doctrine/dbal": "^4.0", - "gember/dependency-contracts": "^0.1" + "gember/dependency-contracts": "^0.2.1" }, "require-dev": { "captainhook/captainhook": "^5.23", diff --git a/composer.lock b/composer.lock index 5740623..c717215 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bf2314494efbd3faf1c7dc1d8d6be1e2", + "content-hash": "e44de185f6349d8968fdd4ae8539ccf5", "packages": [ { "name": "doctrine/dbal", @@ -162,16 +162,16 @@ }, { "name": "gember/dependency-contracts", - "version": "0.1.0", + "version": "0.2.1", "source": { "type": "git", "url": "https://github.com/GemberPHP/dependency-contracts.git", - "reference": "e82562e0f46c091d4991508ebf43257fc3a9a424" + "reference": "dba36626a596e8ef49c1a12ed234a4c874d335b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GemberPHP/dependency-contracts/zipball/e82562e0f46c091d4991508ebf43257fc3a9a424", - "reference": "e82562e0f46c091d4991508ebf43257fc3a9a424", + "url": "https://api.github.com/repos/GemberPHP/dependency-contracts/zipball/dba36626a596e8ef49c1a12ed234a4c874d335b9", + "reference": "dba36626a596e8ef49c1a12ed234a4c874d335b9", "shasum": "" }, "require": { @@ -212,9 +212,9 @@ ], "support": { "issues": "https://github.com/GemberPHP/dependency-contracts/issues", - "source": "https://github.com/GemberPHP/dependency-contracts/tree/0.1.0" + "source": "https://github.com/GemberPHP/dependency-contracts/tree/0.2.1" }, - "time": "2025-09-03T17:15:32+00:00" + "time": "2025-10-10T07:33:21+00:00" }, { "name": "psr/cache", diff --git a/resources/migrations/doctrine/Version20251002195234.php b/resources/migrations/doctrine/Version20251002195234.php new file mode 100644 index 0000000..c8674ec --- /dev/null +++ b/resources/migrations/doctrine/Version20251002195234.php @@ -0,0 +1,25 @@ +addSql( + <<<'SQL' + CREATE TABLE `saga_store` ( + `saga_id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL, + `saga_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `payload` json NOT NULL, + `created_at` timestamp(6) NOT NULL, + `updated_at` timestamp(6) NULL DEFAULT NULL, + PRIMARY KEY (`saga_id`, `saga_name`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + SQL + ); + } +} diff --git a/resources/migrations/phinx/20251002194212.php b/resources/migrations/phinx/20251002194212.php new file mode 100644 index 0000000..cc79524 --- /dev/null +++ b/resources/migrations/phinx/20251002194212.php @@ -0,0 +1,19 @@ +table('saga_store', ['id' => false, 'primary_key' => ['saga_id', 'saga_name']]) + ->addColumn('saga_id', 'string', ['limit' => 50, 'null' => false]) + ->addColumn('saga_name', 'string', ['null' => false]) + ->addColumn('payload', 'json', ['null' => false]) + ->addColumn('created_at', 'timestamp', ['limit' => 6, 'null' => false]) + ->addColumn('updated_at', 'timestamp', ['limit' => 6, 'null' => true]) + ->create(); + } +} diff --git a/resources/schema.sql b/resources/schema.sql index db1f218..5cd13cb 100644 --- a/resources/schema.sql +++ b/resources/schema.sql @@ -14,3 +14,12 @@ CREATE TABLE `event_store_relation` ( PRIMARY KEY (`event_id`,`domain_tag`), CONSTRAINT `event_store_relation_ibfk_1` FOREIGN KEY (`event_id`) REFERENCES `event_store` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `saga_store` ( + `saga_id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL, + `saga_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `payload` json NOT NULL, + `created_at` timestamp(6) NOT NULL, + `updated_at` timestamp(6) NULL DEFAULT NULL, + PRIMARY KEY (`saga_id`, `saga_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; \ No newline at end of file diff --git a/src/Saga/DoctrineDbalRdbmsSagaFactory.php b/src/Saga/DoctrineDbalRdbmsSagaFactory.php new file mode 100644 index 0000000..42a0a1c --- /dev/null +++ b/src/Saga/DoctrineDbalRdbmsSagaFactory.php @@ -0,0 +1,31 @@ +sagaStoreTableSchema; + + /** @var false|SagaRow $row */ + $row = $this->connection->createQueryBuilder() + ->select( + <<sagaIdFieldName} as sagaId, + ss.{$sagaStoreSchema->sagaNameFieldName} as sagaName, + ss.{$sagaStoreSchema->payloadFieldName} as payload, + ss.{$sagaStoreSchema->createdAtFieldName} as createdAt, + ss.{$sagaStoreSchema->updatedAtFieldName} as updatedAt + DQL + ) + ->from($sagaStoreSchema->tableName, 'ss') + ->where(sprintf('ss.%s = :sagaId', $sagaStoreSchema->sagaIdFieldName)) + ->andWhere(sprintf('ss.%s = :sagaName', $sagaStoreSchema->sagaNameFieldName)) + ->setParameter('sagaId', (string) $sagaId) + ->setParameter('sagaName', $sagaName) + ->executeQuery() + ->fetchAssociative(); + + if (!$row) { + throw RdbmsSagaNotFoundException::withSagaId($sagaName, $sagaId); + } + + return $this->sagaFactory->createFromRow($row); + } + + #[Override] + public function save( + string $sagaName, + Stringable|string $sagaId, + string $payload, + DateTimeImmutable $now, + ): RdbmsSaga { + $sagaStoreSchema = $this->sagaStoreTableSchema; + + try { + $previous = $this->get($sagaName, $sagaId); + } catch (RdbmsSagaNotFoundException) { + $this->connection->createQueryBuilder() + ->insert($sagaStoreSchema->tableName) + ->setValue($sagaStoreSchema->sagaIdFieldName, ':sagaId') + ->setValue($sagaStoreSchema->sagaNameFieldName, ':sagaName') + ->setValue($sagaStoreSchema->payloadFieldName, ':payload') + ->setValue($sagaStoreSchema->createdAtFieldName, ':createdAt') + ->setParameters([ + 'sagaId' => $sagaId, + 'sagaName' => $sagaName, + 'payload' => $payload, + 'createdAt' => $now->format($sagaStoreSchema->createdAtFieldFormat), + ]) + ->executeStatement(); + + return new RdbmsSaga( + $sagaName, + $sagaId, + $payload, + $now, + null, + ); + } + + $this->connection->createQueryBuilder() + ->update($sagaStoreSchema->tableName) + ->where(sprintf('%s = :sagaId', $sagaStoreSchema->sagaIdFieldName)) + ->andWhere(sprintf('%s = :sagaName', $sagaStoreSchema->sagaNameFieldName)) + ->set($sagaStoreSchema->payloadFieldName, ':payload') + ->set($sagaStoreSchema->updatedAtFieldName, ':updatedAt') + ->setParameters([ + 'sagaId' => $sagaId, + 'sagaName' => $sagaName, + 'payload' => $payload, + 'updatedAt' => $now->format($sagaStoreSchema->updatedAtFieldFormat), + ]) + ->executeStatement(); + + return new RdbmsSaga( + $sagaName, + $sagaId, + $payload, + $previous->createdAt, + $now, + ); + } +} diff --git a/src/Saga/TableSchema/SagaStoreTableSchema.php b/src/Saga/TableSchema/SagaStoreTableSchema.php new file mode 100644 index 0000000..9f1e6ee --- /dev/null +++ b/src/Saga/TableSchema/SagaStoreTableSchema.php @@ -0,0 +1,19 @@ +createFromRow([ + 'sagaName' => 'some.saga', + 'sagaId' => '01K76G1PGKPZ047KDN25PFPEEV', + 'payload' => '{"some":"data"}', + 'createdAt' => '2018-12-01 12:05:08.234543', + 'updatedAt' => null, + ]); + + self::assertSame('some.saga', $saga->sagaName); + self::assertSame('01K76G1PGKPZ047KDN25PFPEEV', $saga->sagaId); + self::assertSame('{"some":"data"}', $saga->payload); + self::assertEquals(new DateTimeImmutable('2018-12-01 12:05:08.234543'), $saga->createdAt); + self::assertNull($saga->updatedAt); + } +} diff --git a/tests/Saga/DoctrineRdbmsSagaStoreRepositoryTest.php b/tests/Saga/DoctrineRdbmsSagaStoreRepositoryTest.php new file mode 100644 index 0000000..4ff4bbf --- /dev/null +++ b/tests/Saga/DoctrineRdbmsSagaStoreRepositoryTest.php @@ -0,0 +1,92 @@ +parse('pdo-sqlite:///:memory:')); + $connection->executeStatement((string) file_get_contents(__DIR__ . '/../schema.sql')); + + $this->repository = new DoctrineRdbmsSagaStoreRepository( + $connection, + SagaTableSchemaFactory::createDefaultSagaStore(), + new DoctrineDbalRdbmsSagaFactory(), + ); + } + + #[Test] + public function itShouldThrowExceptionWhenSagaNotFound(): void + { + self::expectException(RdbmsSagaNotFoundException::class); + + $this->repository->get('some.saga', '01K76GDQ5RT71G7HQVNR264KD4'); + } + + #[Test] + public function itShouldSaveAndGetSaga(): void + { + $this->repository->save( + 'some.saga', + '01K76GDQ5RT71G7HQVNR264KD4', + '{"some":"data"}', + new DateTimeImmutable('2025-10-10 12:00:34'), + ); + + $saga = $this->repository->get('some.saga', '01K76GDQ5RT71G7HQVNR264KD4'); + + self::assertSame('some.saga', $saga->sagaName); + self::assertSame('01K76GDQ5RT71G7HQVNR264KD4', $saga->sagaId); + self::assertSame('{"some":"data"}', $saga->payload); + self::assertEquals(new DateTimeImmutable('2025-10-10 12:00:34'), $saga->createdAt); + self::assertNull($saga->updatedAt); + } + + #[Test] + public function itShouldSaveExistingSaga(): void + { + $this->repository->save( + 'some.saga', + '01K76GDQ5RT71G7HQVNR264KD4', + '{"some":"data"}', + new DateTimeImmutable('2025-10-10 12:00:34'), + ); + + $this->repository->save( + 'some.saga', + '01K76GDQ5RT71G7HQVNR264KD4', + '{"some":"updated"}', + new DateTimeImmutable('2025-10-10 13:30:12'), + ); + + $saga = $this->repository->get('some.saga', '01K76GDQ5RT71G7HQVNR264KD4'); + + self::assertSame('some.saga', $saga->sagaName); + self::assertSame('01K76GDQ5RT71G7HQVNR264KD4', $saga->sagaId); + self::assertSame('{"some":"updated"}', $saga->payload); + self::assertEquals(new DateTimeImmutable('2025-10-10 12:00:34'), $saga->createdAt); + self::assertEquals(new DateTimeImmutable('2025-10-10 13:30:12'), $saga->updatedAt); + } +} diff --git a/tests/Saga/TableSchema/SagaTableSchemaFactoryTest.php b/tests/Saga/TableSchema/SagaTableSchemaFactoryTest.php new file mode 100644 index 0000000..9c8aacf --- /dev/null +++ b/tests/Saga/TableSchema/SagaTableSchemaFactoryTest.php @@ -0,0 +1,54 @@ +tableName); + self::assertSame('saga_id', $schema->sagaIdFieldName); + self::assertSame('saga_name', $schema->sagaNameFieldName); + self::assertSame('payload', $schema->payloadFieldName); + self::assertSame('created_at', $schema->createdAtFieldName); + self::assertSame('Y-m-d H:i:s.u', $schema->createdAtFieldFormat); + self::assertSame('updated_at', $schema->updatedAtFieldName); + self::assertSame('Y-m-d H:i:s.u', $schema->updatedAtFieldFormat); + } + + #[Test] + public function itShouldCreateCustomEventStoreTableSchema(): void + { + $schema = SagaTableSchemaFactory::createDefaultSagaStore( + 'custom_saga_store', + 'custom_saga_id', + 'custom_saga_name', + 'custom_payload', + 'custom_created_at', + 'custom_created_at_format', + 'custom_updated_at', + 'custom_updated_at_format', + ); + + self::assertSame('custom_saga_store', $schema->tableName); + self::assertSame('custom_saga_id', $schema->sagaIdFieldName); + self::assertSame('custom_saga_name', $schema->sagaNameFieldName); + self::assertSame('custom_payload', $schema->payloadFieldName); + self::assertSame('custom_created_at', $schema->createdAtFieldName); + self::assertSame('custom_created_at_format', $schema->createdAtFieldFormat); + self::assertSame('custom_updated_at', $schema->updatedAtFieldName); + self::assertSame('custom_updated_at_format', $schema->updatedAtFieldFormat); + } +} diff --git a/tests/schema.sql b/tests/schema.sql index d61f257..1ef9736 100644 --- a/tests/schema.sql +++ b/tests/schema.sql @@ -10,3 +10,11 @@ CREATE TABLE `event_store_relation` ( `event_id` varchar(50) NOT NULL, `domain_tag` varchar(50) NOT NULL ); + +CREATE TABLE `saga_store` ( + `saga_id` varchar(50), + `saga_name` varchar(255), + `payload` json NOT NULL, + `created_at` timestamp(6) NOT NULL, + `updated_at` timestamp(6) NULL DEFAULT NULL +); \ No newline at end of file