From 29075ba7ec29662817bd3b1a52d27dfa27a1dedb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yann=20Eugon=C3=A9?= Date: Thu, 16 Apr 2026 19:17:44 +0200 Subject: [PATCH] Make InMemoryJobExecutionStorage implement QueryableJobExecutionStorageInterface with full in-memory filtering/sorting --- .../Storage/InMemoryJobExecutionStorage.php | 112 ++++++- .../InMemoryJobExecutionStorageTest.php | 311 ++++++++++++++++++ 2 files changed, 416 insertions(+), 7 deletions(-) diff --git a/src/batch/src/Test/Storage/InMemoryJobExecutionStorage.php b/src/batch/src/Test/Storage/InMemoryJobExecutionStorage.php index bc08347b..19c72106 100644 --- a/src/batch/src/Test/Storage/InMemoryJobExecutionStorage.php +++ b/src/batch/src/Test/Storage/InMemoryJobExecutionStorage.php @@ -7,24 +7,25 @@ use Yokai\Batch\Exception\CannotRemoveJobExecutionException; use Yokai\Batch\Exception\JobExecutionNotFoundException; use Yokai\Batch\JobExecution; -use Yokai\Batch\Storage\JobExecutionStorageInterface; +use Yokai\Batch\Storage\Query; +use Yokai\Batch\Storage\QueryableJobExecutionStorageInterface; +use Yokai\Batch\Storage\SortDirection; /** - * This {@see JobExecutionStorageInterface} should be used in test. - * It will store executions in memory - * and will allow you to fetch these for assertions. + * This {@see QueryableJobExecutionStorageInterface} should be used in tests. + * It stores executions in memory and allows fetching them for assertions. */ -final class InMemoryJobExecutionStorage implements JobExecutionStorageInterface +final class InMemoryJobExecutionStorage implements QueryableJobExecutionStorageInterface { /** - * @var JobExecution[] + * @var array */ private array $executions = []; public function __construct(JobExecution ...$executions) { foreach ($executions as $execution) { - $this->executions[$execution->getJobName() . '/' . $execution->getId()] = $execution; + $this->executions[self::buildKeyFrom($execution)] = $execution; } } @@ -53,6 +54,32 @@ public function retrieve(string $jobName, string $executionId): JobExecution return $this->executions[$key]; } + public function list(string $jobName): iterable + { + foreach ($this->executions as $execution) { + if ($execution->getJobName() === $jobName) { + yield $execution; + } + } + } + + public function query(Query $query): iterable + { + return $this->applyQuery($query); + } + + public function count(Query $query): int + { + return \count($this->applyQuery($query, ignoreLimit: true)); + } + + public function purge(Query $query): void + { + foreach ($this->applyQuery($query, ignoreLimit: true) as $execution) { + unset($this->executions[self::buildKeyFrom($execution)]); + } + } + /** * @return JobExecution[] */ @@ -61,6 +88,77 @@ public function getExecutions(): array return \array_values($this->executions); } + /** + * @return JobExecution[] + */ + private function applyQuery(Query $query, bool $ignoreLimit = false): array + { + $executions = \array_values($this->executions); + + // Filter + $jobs = $query->jobs(); + $ids = $query->ids(); + $statuses = $query->statuses(); + $startDateFrom = $query->startTime()?->getFrom(); + $startDateTo = $query->startTime()?->getTo(); + $endDateFrom = $query->endTime()?->getFrom(); + $endDateTo = $query->endTime()?->getTo(); + + $executions = \array_filter($executions, function (JobExecution $execution) use ( + $jobs, + $ids, + $statuses, + $startDateFrom, + $startDateTo, + $endDateFrom, + $endDateTo, + ): bool { + if ($jobs !== [] && !\in_array($execution->getJobName(), $jobs, true)) { + return false; + } + if ($ids !== [] && !\in_array($execution->getId(), $ids, true)) { + return false; + } + if ($statuses !== [] && !\in_array($execution->getStatus(), $statuses, true)) { + return false; + } + $startTime = $execution->getStartTime(); + if ($startDateFrom !== null && ($startTime === null || $startTime < $startDateFrom)) { + return false; + } + if ($startDateTo !== null && ($startTime === null || $startTime > $startDateTo)) { + return false; + } + $endTime = $execution->getEndTime(); + if ($endDateFrom !== null && ($endTime === null || $endTime < $endDateFrom)) { + return false; + } + if ($endDateTo !== null && ($endTime === null || $endTime > $endDateTo)) { + return false; + } + + return true; + }); + + // Sort + $order = match ($query->sort()) { + SortDirection::StartAsc => static fn(JobExecution $a, JobExecution $b): int => $a->getStartTime() <=> $b->getStartTime(), + SortDirection::StartDesc => static fn(JobExecution $a, JobExecution $b): int => $b->getStartTime() <=> $a->getStartTime(), + SortDirection::EndAsc => static fn(JobExecution $a, JobExecution $b): int => $a->getEndTime() <=> $b->getEndTime(), + SortDirection::EndDesc => static fn(JobExecution $a, JobExecution $b): int => $b->getEndTime() <=> $a->getEndTime(), + default => null, + }; + if ($order !== null) { + \usort($executions, $order); + } + + if ($ignoreLimit) { + return \array_values($executions); + } + + return \array_slice(\array_values($executions), $query->offset(), $query->limit()); + } + private static function buildKeyFrom(JobExecution $execution): string { return self::buildKey($execution->getJobName(), $execution->getId()); diff --git a/src/batch/tests/Test/Storage/InMemoryJobExecutionStorageTest.php b/src/batch/tests/Test/Storage/InMemoryJobExecutionStorageTest.php index 9fa8172e..65e25a34 100644 --- a/src/batch/tests/Test/Storage/InMemoryJobExecutionStorageTest.php +++ b/src/batch/tests/Test/Storage/InMemoryJobExecutionStorageTest.php @@ -4,14 +4,88 @@ namespace Yokai\Batch\Tests\Test\Storage; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Yokai\Batch\BatchStatus; use Yokai\Batch\Exception\CannotRemoveJobExecutionException; use Yokai\Batch\Exception\JobExecutionNotFoundException; use Yokai\Batch\JobExecution; +use Yokai\Batch\Storage\QueryBuilder; +use Yokai\Batch\Storage\SortDirection; use Yokai\Batch\Test\Storage\InMemoryJobExecutionStorage; +use Yokai\Batch\Test\Storage\JobExecutionStorageTestTrait; final class InMemoryJobExecutionStorageTest extends TestCase { + use JobExecutionStorageTestTrait; + + /** + * Build a storage seeded with 5 executions for query/list/count/purge tests: + * + * export/20210920 — Completed, start=2021-09-20T10:35:48, end=2021-09-20T10:36:00 + * export/20210922 — Completed, start=2021-09-22T10:35:48, end=2021-09-22T10:36:00 + * list/20210910 — Failed, start=2021-09-10T10:35:47, end=2021-09-10T10:36:00 + * list/20210915 — Completed, start=2021-09-15T10:35:48, end=2021-09-15T10:36:00 + * list/20210920 — Running, start=2021-09-20T10:35:51, end=2021-09-20T10:36:05 + */ + private static function createSeededStorage(): InMemoryJobExecutionStorage + { + $executions = [ + self::createExecution( + 'export', + '20210920', + BatchStatus::Completed, + '2021-09-20T10:35:48+0200', + '2021-09-20T10:36:00+0200', + ), + self::createExecution( + 'export', + '20210922', + BatchStatus::Completed, + '2021-09-22T10:35:48+0200', + '2021-09-22T10:36:00+0200', + ), + self::createExecution( + 'list', + '20210910', + BatchStatus::Failed, + '2021-09-10T10:35:47+0200', + '2021-09-10T10:36:00+0200', + ), + self::createExecution( + 'list', + '20210915', + BatchStatus::Completed, + '2021-09-15T10:35:48+0200', + '2021-09-15T10:36:00+0200', + ), + self::createExecution( + 'list', + '20210920', + BatchStatus::Running, + '2021-09-20T10:35:51+0200', + '2021-09-20T10:36:05+0200', + ), + ]; + + return new InMemoryJobExecutionStorage(...$executions); + } + + private static function createExecution( + string $jobName, + string $id, + BatchStatus $status, + string $startTime, + string $endTime, + ): JobExecution { + $execution = JobExecution::createRoot($id, $jobName); + $execution->setStatus($status); + $execution->setStartTime(new \DateTimeImmutable($startTime)); + $execution->setEndTime(new \DateTimeImmutable($endTime)); + + return $execution; + } + public function testRetrieve(): void { $storage = new InMemoryJobExecutionStorage($execution = JobExecution::createRoot('123', 'testing')); @@ -62,4 +136,241 @@ public function testRemoveNotFound(): void $storage = new InMemoryJobExecutionStorage(); $storage->remove(JobExecution::createRoot('123', 'testing')); } + + #[DataProvider('list')] + public function testList(string $jobName, array $expectedCouples): void + { + self::assertExecutions($expectedCouples, self::createSeededStorage()->list($jobName)); + } + + public static function list(): \Generator + { + yield 'export' => [ + 'export', + [ + ['export', '20210920'], + ['export', '20210922'], + ], + ]; + yield 'list' => [ + 'list', + [ + ['list', '20210910'], + ['list', '20210915'], + ['list', '20210920'], + ], + ]; + yield 'unknown job' => [ + 'unknown', + [], + ]; + } + + #[DataProvider('query')] + public function testQuery(QueryBuilder $builder, array $expectedCouples): void + { + $storage = self::createSeededStorage(); + $query = $builder->getQuery(); + + self::assertExecutions($expectedCouples, $storage->query($query)); + } + + public static function query(): \Generator + { + yield 'No filter' => [ + new QueryBuilder(), + [ + ['export', '20210920'], + ['export', '20210922'], + ['list', '20210910'], + ['list', '20210915'], + ['list', '20210920'], + ], + ]; + yield 'Filter by ids' => [ + (new QueryBuilder())->ids(['20210920']), + [ + ['export', '20210920'], + ['list', '20210920'], + ], + ]; + yield 'Filter by job names' => [ + (new QueryBuilder())->jobs(['list']), + [ + ['list', '20210910'], + ['list', '20210915'], + ['list', '20210920'], + ], + ]; + yield 'Filter by statuses' => [ + (new QueryBuilder())->statuses([BatchStatus::Failed]), + [ + ['list', '20210910'], + ], + ]; + yield 'Filter by multiple statuses' => [ + (new QueryBuilder())->statuses([BatchStatus::Completed, BatchStatus::Running]), + [ + ['export', '20210920'], + ['export', '20210922'], + ['list', '20210915'], + ['list', '20210920'], + ], + ]; + yield 'Sort by start ASC' => [ + (new QueryBuilder())->sort(SortDirection::StartAsc), + [ + ['list', '20210910'], + ['list', '20210915'], + ['export', '20210920'], + ['list', '20210920'], + ['export', '20210922'], + ], + ]; + yield 'Sort by start DESC' => [ + (new QueryBuilder())->sort(SortDirection::StartDesc), + [ + ['export', '20210922'], + ['list', '20210920'], + ['export', '20210920'], + ['list', '20210915'], + ['list', '20210910'], + ], + ]; + yield 'Sort by end ASC' => [ + (new QueryBuilder())->sort(SortDirection::EndAsc), + [ + ['list', '20210910'], + ['list', '20210915'], + ['export', '20210920'], + ['list', '20210920'], + ['export', '20210922'], + ], + ]; + yield 'Sort by end DESC' => [ + (new QueryBuilder())->sort(SortDirection::EndDesc), + [ + ['export', '20210922'], + ['list', '20210920'], + ['export', '20210920'], + ['list', '20210915'], + ['list', '20210910'], + ], + ]; + yield 'Filter start time lower boundary' => [ + (new QueryBuilder())->startTime(new \DateTimeImmutable('2021-09-20T10:35:48+0200'), null), + [ + ['export', '20210920'], + ['export', '20210922'], + ['list', '20210920'], + ], + ]; + yield 'Filter start time upper boundary' => [ + (new QueryBuilder())->startTime(null, new \DateTimeImmutable('2021-09-20T10:35:50+0200')), + [ + ['export', '20210920'], + ['list', '20210910'], + ['list', '20210915'], + ], + ]; + yield 'Filter start time both boundaries' => [ + (new QueryBuilder())->startTime( + new \DateTimeImmutable('2021-09-20T10:35:48+0200'), + new \DateTimeImmutable('2021-09-20T10:35:50+0200'), + ), + [ + ['export', '20210920'], + ], + ]; + yield 'Filter end time lower boundary' => [ + (new QueryBuilder())->endTime(new \DateTimeImmutable('2021-09-20T10:36:03+0200'), null), + [ + ['export', '20210922'], + ['list', '20210920'], + ], + ]; + yield 'Filter end time upper boundary' => [ + (new QueryBuilder())->endTime(null, new \DateTimeImmutable('2021-09-20T10:36:02+0200')), + [ + ['export', '20210920'], + ['list', '20210910'], + ['list', '20210915'], + ], + ]; + yield 'Filter end time both boundaries' => [ + (new QueryBuilder())->endTime( + new \DateTimeImmutable('2021-09-20T10:35:59+0200'), + new \DateTimeImmutable('2021-09-20T10:36:02+0200'), + ), + [ + ['export', '20210920'], + ], + ]; + yield 'Limit' => [ + (new QueryBuilder())->limit(2, 0), + [ + ['export', '20210920'], + ['export', '20210922'], + ], + ]; + yield 'Offset' => [ + (new QueryBuilder())->limit(2, 2), + [ + ['list', '20210910'], + ['list', '20210915'], + ], + ]; + } + + public function testCountIgnoresLimit(): void + { + $storage = self::createSeededStorage(); + + // With limit(2, 0), query() returns 2 results but count() returns total (5). + $query = (new QueryBuilder())->limit(2, 0)->getQuery(); + + $results = \iterator_to_array($storage->query($query)); + self::assertCount(2, $results); + self::assertSame(5, $storage->count($query)); + } + + public function testCountWithFilter(): void + { + $storage = self::createSeededStorage(); + + $query = (new QueryBuilder())->jobs(['list'])->getQuery(); + + self::assertSame(3, $storage->count($query)); + } + + public function testPurge(): void + { + $storage = self::createSeededStorage(); + + // limit is ignored by purge — all 3 "list" executions must be deleted + $storage->purge((new QueryBuilder())->jobs(['list'])->limit(1, 0)->getQuery()); + + self::assertExecutions( + [ + ['export', '20210920'], + ['export', '20210922'], + ], + $storage->query((new QueryBuilder())->getQuery()), + ); + } + + public function testPurgeWithStatusFilter(): void + { + $storage = self::createSeededStorage(); + + $storage->purge((new QueryBuilder())->statuses([BatchStatus::Completed])->getQuery()); + + self::assertExecutions( + [ + ['list', '20210910'], + ['list', '20210920'], + ], + $storage->query((new QueryBuilder())->getQuery()), + ); + } }