From cda8262251c05312aa1639c21842c0e4ede1dca4 Mon Sep 17 00:00:00 2001 From: Strift Date: Fri, 17 Jul 2026 15:13:49 +0800 Subject: [PATCH 1/8] Typehint batch results including batchStrategy. Map /batches responses to a Batch contract so getBatch/getBatches return typed objects instead of raw arrays. Co-authored-by: Cursor --- src/Contracts/Batch.php | 177 +++++++++++++++++++++ src/Contracts/BatchesResults.php | 26 ++- src/Endpoints/Batches.php | 45 +++++- src/Endpoints/Delegates/HandlesBatches.php | 15 +- tests/Endpoints/BatchesTest.php | 31 ++-- 5 files changed, 271 insertions(+), 23 deletions(-) create mode 100644 src/Contracts/Batch.php diff --git a/src/Contracts/Batch.php b/src/Contracts/Batch.php new file mode 100644 index 000000000..e37fc99f2 --- /dev/null +++ b/src/Contracts/Batch.php @@ -0,0 +1,177 @@ +, + * types: array, + * indexUids: array, + * progressTrace?: array, + * writeChannelCongestion?: array|null, + * internalDatabaseSizes?: array, + * embedderRequests?: array{ + * total: non-negative-int, + * failed: non-negative-int, + * lastError?: non-empty-string|null + * } + * } + * @phpstan-type RawBatchProgressStep array{ + * currentStep: non-empty-string, + * finished: non-negative-int, + * total: non-negative-int + * } + * @phpstan-type RawBatchProgress array{ + * steps: list, + * percentage: float + * } + * @phpstan-type RawBatch array{ + * uid: non-negative-int, + * details: array, + * stats: RawBatchStats, + * duration?: non-empty-string|null, + * startedAt: non-empty-string, + * finishedAt?: non-empty-string|null, + * progress?: RawBatchProgress|null, + * batchStrategy?: non-empty-string + * } + */ +final class Batch implements \ArrayAccess +{ + /** + * @param non-negative-int $uid + * @param array $details + * @param RawBatchStats $stats + * @param non-empty-string|null $duration + * @param RawBatchProgress|null $progress + * @param non-empty-string $batchStrategy + * @param array $raw + */ + public function __construct( + private readonly int $uid, + private readonly array $details, + private readonly array $stats, + private readonly ?string $duration, + private readonly \DateTimeImmutable $startedAt, + private readonly ?\DateTimeImmutable $finishedAt, + private readonly ?array $progress, + private readonly string $batchStrategy, + private readonly array $raw = [], + ) { + } + + /** + * @return non-negative-int + */ + public function getUid(): int + { + return $this->uid; + } + + /** + * @return array + */ + public function getDetails(): array + { + return $this->details; + } + + /** + * @return RawBatchStats + */ + public function getStats(): array + { + return $this->stats; + } + + /** + * @return non-empty-string|null + */ + public function getDuration(): ?string + { + return $this->duration; + } + + public function getStartedAt(): \DateTimeImmutable + { + return $this->startedAt; + } + + public function getFinishedAt(): ?\DateTimeImmutable + { + return $this->finishedAt; + } + + /** + * Real-time progress while the batch is processing; null when finished. + * When present, `percentage` is documented by Meilisearch as 0.0–100.0. + * + * @return RawBatchProgress|null + */ + public function getProgress(): ?array + { + return $this->progress; + } + + /** + * Free-form reason why the batch stopped accepting tasks (not a closed enum). + * + * @return non-empty-string + */ + public function getBatchStrategy(): string + { + return $this->batchStrategy; + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->raw; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new LogicException('The Batch object is immutable.'); + } + + public function offsetExists(mixed $offset): bool + { + return \array_key_exists($offset, $this->raw); + } + + public function offsetUnset(mixed $offset): void + { + throw new LogicException('The Batch object is immutable.'); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->raw[$offset] ?? null; + } + + /** + * @param RawBatch $data + */ + public static function fromArray(array $data): self + { + return new self( + $data['uid'], + $data['details'], + $data['stats'], + $data['duration'] ?? null, + new \DateTimeImmutable($data['startedAt']), + \array_key_exists('finishedAt', $data) && null !== $data['finishedAt'] + ? new \DateTimeImmutable($data['finishedAt']) : null, + $data['progress'] ?? null, + $data['batchStrategy'] ?? 'unspecified', + $data, + ); + } +} diff --git a/src/Contracts/BatchesResults.php b/src/Contracts/BatchesResults.php index 7a9e7dbce..612c8140c 100644 --- a/src/Contracts/BatchesResults.php +++ b/src/Contracts/BatchesResults.php @@ -4,7 +4,7 @@ namespace Meilisearch\Contracts; -class BatchesResults extends Data +final class BatchesResults extends Data { /** * @var non-negative-int @@ -26,18 +26,27 @@ class BatchesResults extends Data */ private int $total; + /** + * @param array{ + * results: array, + * from: non-negative-int|null, + * limit: non-negative-int, + * next: non-negative-int|null, + * total: non-negative-int + * } $params + */ public function __construct(array $params) { parent::__construct($params['results']); $this->from = $params['from'] ?? 0; - $this->limit = $params['limit'] ?? 0; + $this->limit = $params['limit']; $this->next = $params['next'] ?? 0; - $this->total = $params['total'] ?? 0; + $this->total = $params['total']; } /** - * @return array + * @return array */ public function getResults(): array { @@ -76,6 +85,15 @@ public function getTotal(): int return $this->total; } + /** + * @return array{ + * results: array, + * from: non-negative-int, + * limit: non-negative-int, + * next: non-negative-int, + * total: non-negative-int + * } + */ public function toArray(): array { return [ diff --git a/src/Endpoints/Batches.php b/src/Endpoints/Batches.php index 6562b93c1..df4443c18 100644 --- a/src/Endpoints/Batches.php +++ b/src/Endpoints/Batches.php @@ -4,19 +4,58 @@ namespace Meilisearch\Endpoints; +use Meilisearch\Contracts\Batch; use Meilisearch\Contracts\Endpoint; +/** + * @phpstan-import-type RawBatch from Batch + * + * @phpstan-type RawBatches array{ + * results: array, + * from: non-negative-int|null, + * limit: non-negative-int, + * next: non-negative-int|null, + * total: non-negative-int + * } + * @phpstan-type BatchesResponse array{ + * results: array, + * from: non-negative-int|null, + * limit: non-negative-int, + * next: non-negative-int|null, + * total: non-negative-int + * } + */ class Batches extends Endpoint { protected const PATH = '/batches'; - public function get(int $batchUid): array + public function get(int $batchUid): Batch { - return $this->http->get(self::PATH.'/'.$batchUid); + /** @var RawBatch $rawBatch */ + $rawBatch = $this->http->get(self::PATH.'/'.$batchUid); + + return Batch::fromArray($rawBatch); } + /** + * @return BatchesResponse + */ public function all(array $query = []): array { - return $this->http->get(self::PATH.'/', $query); + $rawData = $this->http->get(self::PATH.'/', $query); + /** @var RawBatches $rawBatches */ + $rawBatches = $rawData; + $results = array_map( + static fn (array $batch): Batch => Batch::fromArray($batch), + $rawBatches['results'], + ); + + return [ + 'results' => $results, + 'from' => $rawBatches['from'], + 'limit' => $rawBatches['limit'], + 'next' => $rawBatches['next'], + 'total' => $rawBatches['total'], + ]; } } diff --git a/src/Endpoints/Delegates/HandlesBatches.php b/src/Endpoints/Delegates/HandlesBatches.php index 542bd2215..ba31e178b 100644 --- a/src/Endpoints/Delegates/HandlesBatches.php +++ b/src/Endpoints/Delegates/HandlesBatches.php @@ -4,6 +4,7 @@ namespace Meilisearch\Endpoints\Delegates; +use Meilisearch\Contracts\Batch; use Meilisearch\Contracts\BatchesQuery; use Meilisearch\Contracts\BatchesResults; use Meilisearch\Endpoints\Batches; @@ -12,7 +13,7 @@ trait HandlesBatches { protected Batches $batches; - public function getBatch(int $uid): array + public function getBatch(int $uid): Batch { return $this->batches->get($uid); } @@ -23,6 +24,16 @@ public function getBatches(?BatchesQuery $options = null): BatchesResults $response = $this->batches->all($query); - return new BatchesResults($response); + /** @var array{ + * results: array, + * from: non-negative-int|null, + * limit: non-negative-int, + * next: non-negative-int|null, + * total: non-negative-int + * } $rawResponse + */ + $rawResponse = $response; + + return new BatchesResults($rawResponse); } } diff --git a/tests/Endpoints/BatchesTest.php b/tests/Endpoints/BatchesTest.php index 828256d3f..2670da7bd 100644 --- a/tests/Endpoints/BatchesTest.php +++ b/tests/Endpoints/BatchesTest.php @@ -29,7 +29,7 @@ public function testGetAllBatchesWithIndexUidFilters(): void { $response = $this->client->getBatches((new BatchesQuery())->setIndexUids([$this->indexName])); foreach ($response->getResults() as $result) { - self::assertArrayHasKey($this->indexName, $result['stats']['indexUids']); + self::assertArrayHasKey($this->indexName, $result->getStats()['indexUids']); } } @@ -51,24 +51,27 @@ public function testGetAllBatchesInReverseOrder(): void ->setAfterEnqueuedAt($startDate) ->setReverse(true) ); - self::assertSame($batches->getResults(), array_reverse($reversedBatches->getResults())); + $batchUids = array_map(static fn ($b) => $b->getUid(), $batches->getResults()); + $reversedUids = array_map(static fn ($b) => $b->getUid(), $reversedBatches->getResults()); + self::assertSame($batchUids, array_reverse($reversedUids)); } public function testGetOneBatch(): void { $batches = $this->client->getBatches(); - $response = $this->client->getBatch($batches->getResults()[0]['uid']); + $first = $batches->getResults()[0]; + $response = $this->client->getBatch($first->getUid()); - self::assertSame($batches->getResults()[0]['uid'], $response['uid']); - self::assertArrayHasKey('details', $response); - self::assertArrayHasKey('totalNbTasks', $response['stats']); - self::assertArrayHasKey('status', $response['stats']); - self::assertArrayHasKey('types', $response['stats']); - self::assertArrayHasKey('indexUids', $response['stats']); - self::assertArrayHasKey('progressTrace', $response['stats']); - self::assertArrayHasKey('duration', $response); - self::assertArrayHasKey('startedAt', $response); - self::assertArrayHasKey('finishedAt', $response); - self::assertArrayHasKey('progress', $response); + self::assertSame($first->getUid(), $response->getUid()); + $stats = $response->getStats(); + self::assertSame($stats['totalNbTasks'], array_sum($stats['status'])); + self::assertNotEmpty($stats['status']); + self::assertNotEmpty($stats['types']); + self::assertArrayHasKey('progressTrace', $stats); + self::assertArrayHasKey('duration', $response->toArray()); + self::assertArrayHasKey('finishedAt', $response->toArray()); + self::assertArrayHasKey('progress', $response->toArray()); + self::assertArrayHasKey('batchStrategy', $response->toArray()); + self::assertSame($response->toArray()['batchStrategy'], $response->getBatchStrategy()); } } From 936c6f847014e43c919531aa22b897dfcde906e7 Mon Sep 17 00:00:00 2001 From: Strift Date: Fri, 17 Jul 2026 15:45:13 +0800 Subject: [PATCH 2/8] Narrow type to RawBatch --- src/Contracts/Batch.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Contracts/Batch.php b/src/Contracts/Batch.php index e37fc99f2..056c3c553 100644 --- a/src/Contracts/Batch.php +++ b/src/Contracts/Batch.php @@ -129,7 +129,7 @@ public function getBatchStrategy(): string } /** - * @return array + * @return RawBatch */ public function toArray(): array { From 59cc106d4f9e64be294f3f371d602c81bf9c8498 Mon Sep 17 00:00:00 2001 From: Strift Date: Fri, 17 Jul 2026 15:52:30 +0800 Subject: [PATCH 3/8] Fix types --- src/Contracts/Batch.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Contracts/Batch.php b/src/Contracts/Batch.php index 056c3c553..15a8ef0e6 100644 --- a/src/Contracts/Batch.php +++ b/src/Contracts/Batch.php @@ -50,7 +50,7 @@ final class Batch implements \ArrayAccess * @param non-empty-string|null $duration * @param RawBatchProgress|null $progress * @param non-empty-string $batchStrategy - * @param array $raw + * @param RawBatch $raw */ public function __construct( private readonly int $uid, @@ -61,7 +61,7 @@ public function __construct( private readonly ?\DateTimeImmutable $finishedAt, private readonly ?array $progress, private readonly string $batchStrategy, - private readonly array $raw = [], + private readonly array $raw, ) { } From 2278ce1625d6672df1c294b5aabd70121bfb6f45 Mon Sep 17 00:00:00 2001 From: Strift Date: Fri, 17 Jul 2026 16:10:14 +0800 Subject: [PATCH 4/8] Tighten types --- src/Contracts/Batch.php | 33 ++++++++++------------ src/Contracts/BatchesResults.php | 6 ++-- src/Endpoints/Batches.php | 4 +-- src/Endpoints/Delegates/HandlesBatches.php | 12 +------- tests/Endpoints/BatchesTest.php | 8 ++---- 5 files changed, 24 insertions(+), 39 deletions(-) diff --git a/src/Contracts/Batch.php b/src/Contracts/Batch.php index 15a8ef0e6..57b235618 100644 --- a/src/Contracts/Batch.php +++ b/src/Contracts/Batch.php @@ -4,6 +4,7 @@ namespace Meilisearch\Contracts; +use Meilisearch\Contracts\TaskDetails\UnknownTaskDetails; use Meilisearch\Exceptions\LogicException; /** @@ -13,7 +14,7 @@ * types: array, * indexUids: array, * progressTrace?: array, - * writeChannelCongestion?: array|null, + * writeChannelCongestion?: array, * internalDatabaseSizes?: array, * embedderRequests?: array{ * total: non-negative-int, @@ -34,33 +35,32 @@ * uid: non-negative-int, * details: array, * stats: RawBatchStats, - * duration?: non-empty-string|null, + * duration: non-empty-string|null, * startedAt: non-empty-string, - * finishedAt?: non-empty-string|null, - * progress?: RawBatchProgress|null, - * batchStrategy?: non-empty-string + * finishedAt: non-empty-string|null, + * progress: RawBatchProgress|null, + * batchStrategy?: non-empty-string|null * } */ final class Batch implements \ArrayAccess { /** * @param non-negative-int $uid - * @param array $details * @param RawBatchStats $stats * @param non-empty-string|null $duration * @param RawBatchProgress|null $progress - * @param non-empty-string $batchStrategy + * @param non-empty-string|null $batchStrategy * @param RawBatch $raw */ public function __construct( private readonly int $uid, - private readonly array $details, + private readonly TaskDetails $details, private readonly array $stats, private readonly ?string $duration, private readonly \DateTimeImmutable $startedAt, private readonly ?\DateTimeImmutable $finishedAt, private readonly ?array $progress, - private readonly string $batchStrategy, + private readonly ?string $batchStrategy, private readonly array $raw, ) { } @@ -73,10 +73,7 @@ public function getUid(): int return $this->uid; } - /** - * @return array - */ - public function getDetails(): array + public function getDetails(): TaskDetails { return $this->details; } @@ -121,9 +118,9 @@ public function getProgress(): ?array /** * Free-form reason why the batch stopped accepting tasks (not a closed enum). * - * @return non-empty-string + * @return non-empty-string|null */ - public function getBatchStrategy(): string + public function getBatchStrategy(): ?string { return $this->batchStrategy; } @@ -163,14 +160,14 @@ public static function fromArray(array $data): self { return new self( $data['uid'], - $data['details'], + UnknownTaskDetails::fromArray($data['details']), $data['stats'], $data['duration'] ?? null, new \DateTimeImmutable($data['startedAt']), - \array_key_exists('finishedAt', $data) && null !== $data['finishedAt'] + null !== $data['finishedAt'] ? new \DateTimeImmutable($data['finishedAt']) : null, $data['progress'] ?? null, - $data['batchStrategy'] ?? 'unspecified', + $data['batchStrategy'] ?? null, $data, ); } diff --git a/src/Contracts/BatchesResults.php b/src/Contracts/BatchesResults.php index 612c8140c..6a3285fc7 100644 --- a/src/Contracts/BatchesResults.php +++ b/src/Contracts/BatchesResults.php @@ -28,7 +28,7 @@ final class BatchesResults extends Data /** * @param array{ - * results: array, + * results: list, * from: non-negative-int|null, * limit: non-negative-int, * next: non-negative-int|null, @@ -46,7 +46,7 @@ public function __construct(array $params) } /** - * @return array + * @return list */ public function getResults(): array { @@ -87,7 +87,7 @@ public function getTotal(): int /** * @return array{ - * results: array, + * results: list, * from: non-negative-int, * limit: non-negative-int, * next: non-negative-int, diff --git a/src/Endpoints/Batches.php b/src/Endpoints/Batches.php index df4443c18..a8d54525a 100644 --- a/src/Endpoints/Batches.php +++ b/src/Endpoints/Batches.php @@ -11,14 +11,14 @@ * @phpstan-import-type RawBatch from Batch * * @phpstan-type RawBatches array{ - * results: array, + * results: list, * from: non-negative-int|null, * limit: non-negative-int, * next: non-negative-int|null, * total: non-negative-int * } * @phpstan-type BatchesResponse array{ - * results: array, + * results: list, * from: non-negative-int|null, * limit: non-negative-int, * next: non-negative-int|null, diff --git a/src/Endpoints/Delegates/HandlesBatches.php b/src/Endpoints/Delegates/HandlesBatches.php index ba31e178b..26f53ae81 100644 --- a/src/Endpoints/Delegates/HandlesBatches.php +++ b/src/Endpoints/Delegates/HandlesBatches.php @@ -24,16 +24,6 @@ public function getBatches(?BatchesQuery $options = null): BatchesResults $response = $this->batches->all($query); - /** @var array{ - * results: array, - * from: non-negative-int|null, - * limit: non-negative-int, - * next: non-negative-int|null, - * total: non-negative-int - * } $rawResponse - */ - $rawResponse = $response; - - return new BatchesResults($rawResponse); + return new BatchesResults($response); } } diff --git a/tests/Endpoints/BatchesTest.php b/tests/Endpoints/BatchesTest.php index 2670da7bd..1302e7396 100644 --- a/tests/Endpoints/BatchesTest.php +++ b/tests/Endpoints/BatchesTest.php @@ -5,6 +5,7 @@ namespace Tests\Endpoints; use Meilisearch\Contracts\BatchesQuery; +use Meilisearch\Contracts\TaskDetails\UnknownTaskDetails; use Meilisearch\Contracts\TasksQuery; use Tests\TestCase; @@ -63,15 +64,12 @@ public function testGetOneBatch(): void $response = $this->client->getBatch($first->getUid()); self::assertSame($first->getUid(), $response->getUid()); + self::assertInstanceOf(UnknownTaskDetails::class, $response->getDetails()); $stats = $response->getStats(); self::assertSame($stats['totalNbTasks'], array_sum($stats['status'])); self::assertNotEmpty($stats['status']); self::assertNotEmpty($stats['types']); self::assertArrayHasKey('progressTrace', $stats); - self::assertArrayHasKey('duration', $response->toArray()); - self::assertArrayHasKey('finishedAt', $response->toArray()); - self::assertArrayHasKey('progress', $response->toArray()); - self::assertArrayHasKey('batchStrategy', $response->toArray()); - self::assertSame($response->toArray()['batchStrategy'], $response->getBatchStrategy()); + self::assertSame($response->toArray()['batchStrategy'] ?? null, $response->getBatchStrategy()); } } From eab576f77ed89561e573a798fc8daf6158411072 Mon Sep 17 00:00:00 2001 From: Strift Date: Fri, 17 Jul 2026 16:34:26 +0800 Subject: [PATCH 5/8] Map Batch via toArray --- src/Contracts/BatchesResults.php | 7 ++- tests/Contracts/BatchesResultsTest.php | 71 ++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 tests/Contracts/BatchesResultsTest.php diff --git a/src/Contracts/BatchesResults.php b/src/Contracts/BatchesResults.php index 6a3285fc7..c8278ca27 100644 --- a/src/Contracts/BatchesResults.php +++ b/src/Contracts/BatchesResults.php @@ -4,6 +4,9 @@ namespace Meilisearch\Contracts; +/** + * @phpstan-import-type RawBatch from Batch + */ final class BatchesResults extends Data { /** @@ -87,7 +90,7 @@ public function getTotal(): int /** * @return array{ - * results: list, + * results: list, * from: non-negative-int, * limit: non-negative-int, * next: non-negative-int, @@ -97,7 +100,7 @@ public function getTotal(): int public function toArray(): array { return [ - 'results' => $this->data, + 'results' => array_map(static fn (Batch $batch) => $batch->toArray(), $this->data), 'next' => $this->next, 'limit' => $this->limit, 'from' => $this->from, diff --git a/tests/Contracts/BatchesResultsTest.php b/tests/Contracts/BatchesResultsTest.php new file mode 100644 index 000000000..92f3a2791 --- /dev/null +++ b/tests/Contracts/BatchesResultsTest.php @@ -0,0 +1,71 @@ + 1, + 'details' => ['receivedDocuments' => 1], + 'stats' => [ + 'totalNbTasks' => 1, + 'status' => ['succeeded' => 1], + 'types' => ['documentAdditionOrUpdate' => 1], + 'indexUids' => ['movies' => 1], + ], + 'duration' => 'PT1S', + 'startedAt' => '2025-04-09T10:28:12.236789Z', + 'finishedAt' => '2025-04-09T10:28:13.236789Z', + 'progress' => null, + ]; + $secondRaw = [ + 'uid' => 2, + 'details' => ['receivedDocuments' => 2], + 'stats' => [ + 'totalNbTasks' => 2, + 'status' => ['succeeded' => 2], + 'types' => ['documentAdditionOrUpdate' => 2], + 'indexUids' => ['books' => 2], + ], + 'duration' => null, + 'startedAt' => '2025-04-09T11:28:12.236789Z', + 'finishedAt' => null, + 'progress' => [ + 'steps' => [ + ['currentStep' => 'indexing', 'finished' => 1, 'total' => 2], + ], + 'percentage' => 50.0, + ], + ]; + + $firstBatch = Batch::fromArray($firstRaw); + $secondBatch = Batch::fromArray($secondRaw); + + $results = new BatchesResults([ + 'results' => [$firstBatch, $secondBatch], + 'from' => 1, + 'limit' => 2, + 'next' => 3, + 'total' => 5, + ]); + + $array = $results->toArray(); + + self::assertSame(1, $array['from']); + self::assertSame(2, $array['limit']); + self::assertSame(3, $array['next']); + self::assertSame(5, $array['total']); + self::assertSame( + [$firstBatch->toArray(), $secondBatch->toArray()], + $array['results'], + ); + } +} From 95a2224a9592e19f2b52bb465f40b4619ba225ba Mon Sep 17 00:00:00 2001 From: Strift Date: Sat, 18 Jul 2026 09:10:07 +0800 Subject: [PATCH 6/8] Add objects for BatchProgress and BatchStats --- src/Contracts/Batch.php | 45 ++----- src/Contracts/BatchEmbedderRequests.php | 63 ++++++++++ src/Contracts/BatchProgress.php | 52 ++++++++ src/Contracts/BatchProgressStep.php | 63 ++++++++++ src/Contracts/BatchStats.php | 123 +++++++++++++++++++ tests/Contracts/BatchProgressTest.php | 101 +++++++++++++++ tests/Contracts/BatchStatsTest.php | 157 ++++++++++++++++++++++++ tests/Endpoints/BatchesTest.php | 10 +- 8 files changed, 573 insertions(+), 41 deletions(-) create mode 100644 src/Contracts/BatchEmbedderRequests.php create mode 100644 src/Contracts/BatchProgress.php create mode 100644 src/Contracts/BatchProgressStep.php create mode 100644 src/Contracts/BatchStats.php create mode 100644 tests/Contracts/BatchProgressTest.php create mode 100644 tests/Contracts/BatchStatsTest.php diff --git a/src/Contracts/Batch.php b/src/Contracts/Batch.php index 57b235618..4ec9031e7 100644 --- a/src/Contracts/Batch.php +++ b/src/Contracts/Batch.php @@ -8,29 +8,9 @@ use Meilisearch\Exceptions\LogicException; /** - * @phpstan-type RawBatchStats array{ - * totalNbTasks: non-negative-int, - * status: array, - * types: array, - * indexUids: array, - * progressTrace?: array, - * writeChannelCongestion?: array, - * internalDatabaseSizes?: array, - * embedderRequests?: array{ - * total: non-negative-int, - * failed: non-negative-int, - * lastError?: non-empty-string|null - * } - * } - * @phpstan-type RawBatchProgressStep array{ - * currentStep: non-empty-string, - * finished: non-negative-int, - * total: non-negative-int - * } - * @phpstan-type RawBatchProgress array{ - * steps: list, - * percentage: float - * } + * @phpstan-import-type RawBatchStats from BatchStats + * @phpstan-import-type RawBatchProgress from BatchProgress + * * @phpstan-type RawBatch array{ * uid: non-negative-int, * details: array, @@ -46,20 +26,18 @@ final class Batch implements \ArrayAccess { /** * @param non-negative-int $uid - * @param RawBatchStats $stats * @param non-empty-string|null $duration - * @param RawBatchProgress|null $progress * @param non-empty-string|null $batchStrategy * @param RawBatch $raw */ public function __construct( private readonly int $uid, private readonly TaskDetails $details, - private readonly array $stats, + private readonly BatchStats $stats, private readonly ?string $duration, private readonly \DateTimeImmutable $startedAt, private readonly ?\DateTimeImmutable $finishedAt, - private readonly ?array $progress, + private readonly ?BatchProgress $progress, private readonly ?string $batchStrategy, private readonly array $raw, ) { @@ -78,10 +56,7 @@ public function getDetails(): TaskDetails return $this->details; } - /** - * @return RawBatchStats - */ - public function getStats(): array + public function getStats(): BatchStats { return $this->stats; } @@ -107,10 +82,8 @@ public function getFinishedAt(): ?\DateTimeImmutable /** * Real-time progress while the batch is processing; null when finished. * When present, `percentage` is documented by Meilisearch as 0.0–100.0. - * - * @return RawBatchProgress|null */ - public function getProgress(): ?array + public function getProgress(): ?BatchProgress { return $this->progress; } @@ -161,12 +134,12 @@ public static function fromArray(array $data): self return new self( $data['uid'], UnknownTaskDetails::fromArray($data['details']), - $data['stats'], + BatchStats::fromArray($data['stats']), $data['duration'] ?? null, new \DateTimeImmutable($data['startedAt']), null !== $data['finishedAt'] ? new \DateTimeImmutable($data['finishedAt']) : null, - $data['progress'] ?? null, + null !== ($data['progress'] ?? null) ? BatchProgress::fromArray($data['progress']) : null, $data['batchStrategy'] ?? null, $data, ); diff --git a/src/Contracts/BatchEmbedderRequests.php b/src/Contracts/BatchEmbedderRequests.php new file mode 100644 index 000000000..c8d67c9e3 --- /dev/null +++ b/src/Contracts/BatchEmbedderRequests.php @@ -0,0 +1,63 @@ +total; + } + + /** + * @return non-negative-int + */ + public function getFailed(): int + { + return $this->failed; + } + + /** + * @return non-empty-string|null + */ + public function getLastError(): ?string + { + return $this->lastError; + } + + /** + * @param RawBatchEmbedderRequests $data + */ + public static function fromArray(array $data): self + { + return new self( + $data['total'], + $data['failed'], + $data['lastError'] ?? null, + ); + } +} diff --git a/src/Contracts/BatchProgress.php b/src/Contracts/BatchProgress.php new file mode 100644 index 000000000..8c5a5babf --- /dev/null +++ b/src/Contracts/BatchProgress.php @@ -0,0 +1,52 @@ +, + * percentage: float + * } + */ +final class BatchProgress +{ + /** + * @param list $steps + */ + public function __construct( + private readonly array $steps, + private readonly float $percentage, + ) { + } + + /** + * @return list + */ + public function getSteps(): array + { + return $this->steps; + } + + public function getPercentage(): float + { + return $this->percentage; + } + + /** + * @param RawBatchProgress $data + */ + public static function fromArray(array $data): self + { + return new self( + array_map( + static fn (array $step) => BatchProgressStep::fromArray($step), + $data['steps'], + ), + $data['percentage'], + ); + } +} diff --git a/src/Contracts/BatchProgressStep.php b/src/Contracts/BatchProgressStep.php new file mode 100644 index 000000000..f17f33a31 --- /dev/null +++ b/src/Contracts/BatchProgressStep.php @@ -0,0 +1,63 @@ +currentStep; + } + + /** + * @return non-negative-int + */ + public function getFinished(): int + { + return $this->finished; + } + + /** + * @return non-negative-int + */ + public function getTotal(): int + { + return $this->total; + } + + /** + * @param RawBatchProgressStep $data + */ + public static function fromArray(array $data): self + { + return new self( + $data['currentStep'], + $data['finished'], + $data['total'], + ); + } +} diff --git a/src/Contracts/BatchStats.php b/src/Contracts/BatchStats.php new file mode 100644 index 000000000..77b591fba --- /dev/null +++ b/src/Contracts/BatchStats.php @@ -0,0 +1,123 @@ +, + * types: array, + * indexUids: array, + * progressTrace?: array, + * writeChannelCongestion?: array, + * internalDatabaseSizes?: array, + * embedderRequests?: RawBatchEmbedderRequests + * } + */ +final class BatchStats +{ + /** + * @param non-negative-int $totalNbTasks + * @param array $status + * @param array $types + * @param array $indexUids + * @param array|null $progressTrace + * @param array|null $writeChannelCongestion + * @param array|null $internalDatabaseSizes + */ + public function __construct( + private readonly int $totalNbTasks, + private readonly array $status, + private readonly array $types, + private readonly array $indexUids, + private readonly ?array $progressTrace = null, + private readonly ?array $writeChannelCongestion = null, + private readonly ?array $internalDatabaseSizes = null, + private readonly ?BatchEmbedderRequests $embedderRequests = null, + ) { + } + + /** + * @return non-negative-int + */ + public function getTotalNbTasks(): int + { + return $this->totalNbTasks; + } + + /** + * @return array + */ + public function getStatus(): array + { + return $this->status; + } + + /** + * @return array + */ + public function getTypes(): array + { + return $this->types; + } + + /** + * @return array + */ + public function getIndexUids(): array + { + return $this->indexUids; + } + + /** + * @return array|null + */ + public function getProgressTrace(): ?array + { + return $this->progressTrace; + } + + /** + * @return array|null + */ + public function getWriteChannelCongestion(): ?array + { + return $this->writeChannelCongestion; + } + + /** + * @return array|null + */ + public function getInternalDatabaseSizes(): ?array + { + return $this->internalDatabaseSizes; + } + + public function getEmbedderRequests(): ?BatchEmbedderRequests + { + return $this->embedderRequests; + } + + /** + * @param RawBatchStats $data + */ + public static function fromArray(array $data): self + { + return new self( + $data['totalNbTasks'], + $data['status'], + $data['types'], + $data['indexUids'], + $data['progressTrace'] ?? null, + $data['writeChannelCongestion'] ?? null, + $data['internalDatabaseSizes'] ?? null, + isset($data['embedderRequests']) + ? BatchEmbedderRequests::fromArray($data['embedderRequests']) + : null, + ); + } +} diff --git a/tests/Contracts/BatchProgressTest.php b/tests/Contracts/BatchProgressTest.php new file mode 100644 index 000000000..527e6df08 --- /dev/null +++ b/tests/Contracts/BatchProgressTest.php @@ -0,0 +1,101 @@ +getSteps()); + self::assertSame(50.0, $progress->getPercentage()); + } + + public function testFromArray(): void + { + $progress = BatchProgress::fromArray([ + 'steps' => [ + ['currentStep' => 'indexing', 'finished' => 1, 'total' => 2], + ], + 'percentage' => 50.0, + ]); + + self::assertEquals([ + new BatchProgressStep( + currentStep: 'indexing', + finished: 1, + total: 2, + ), + ], $progress->getSteps()); + self::assertSame(50.0, $progress->getPercentage()); + } + + public function testFromArrayMapsMultipleSteps(): void + { + $progress = BatchProgress::fromArray([ + 'steps' => [ + ['currentStep' => 'extractingWords', 'finished' => 2, 'total' => 2], + ['currentStep' => 'indexing', 'finished' => 1, 'total' => 3], + ], + 'percentage' => 33.3, + ]); + + self::assertEquals([ + new BatchProgressStep( + currentStep: 'extractingWords', + finished: 2, + total: 2, + ), + new BatchProgressStep( + currentStep: 'indexing', + finished: 1, + total: 3, + ), + ], $progress->getSteps()); + self::assertSame(33.3, $progress->getPercentage()); + } + + public function testProgressStepConstruct(): void + { + $step = new BatchProgressStep( + currentStep: 'indexing', + finished: 1, + total: 2, + ); + + self::assertSame('indexing', $step->getCurrentStep()); + self::assertSame(1, $step->getFinished()); + self::assertSame(2, $step->getTotal()); + } + + public function testProgressStepFromArray(): void + { + $step = BatchProgressStep::fromArray([ + 'currentStep' => 'indexing', + 'finished' => 1, + 'total' => 2, + ]); + + self::assertSame('indexing', $step->getCurrentStep()); + self::assertSame(1, $step->getFinished()); + self::assertSame(2, $step->getTotal()); + } +} diff --git a/tests/Contracts/BatchStatsTest.php b/tests/Contracts/BatchStatsTest.php new file mode 100644 index 000000000..0ff5c0b1f --- /dev/null +++ b/tests/Contracts/BatchStatsTest.php @@ -0,0 +1,157 @@ + 1], + types: ['documentAdditionOrUpdate' => 1], + indexUids: ['movies' => 1], + progressTrace: ['indexingDocuments' => '100%'], + writeChannelCongestion: ['attempts' => 0], + internalDatabaseSizes: ['documents' => '4.0 KiB'], + embedderRequests: $embedderRequests, + ); + + self::assertSame(1, $stats->getTotalNbTasks()); + self::assertSame(['succeeded' => 1], $stats->getStatus()); + self::assertSame(['documentAdditionOrUpdate' => 1], $stats->getTypes()); + self::assertSame(['movies' => 1], $stats->getIndexUids()); + self::assertSame(['indexingDocuments' => '100%'], $stats->getProgressTrace()); + self::assertSame(['attempts' => 0], $stats->getWriteChannelCongestion()); + self::assertSame(['documents' => '4.0 KiB'], $stats->getInternalDatabaseSizes()); + self::assertSame($embedderRequests, $stats->getEmbedderRequests()); + } + + public function testConstructWithOptionalFieldsNull(): void + { + $stats = new BatchStats( + totalNbTasks: 2, + status: ['succeeded' => 2], + types: ['documentAdditionOrUpdate' => 2], + indexUids: ['books' => 2], + ); + + self::assertSame(2, $stats->getTotalNbTasks()); + self::assertSame(['succeeded' => 2], $stats->getStatus()); + self::assertSame(['documentAdditionOrUpdate' => 2], $stats->getTypes()); + self::assertSame(['books' => 2], $stats->getIndexUids()); + self::assertNull($stats->getProgressTrace()); + self::assertNull($stats->getWriteChannelCongestion()); + self::assertNull($stats->getInternalDatabaseSizes()); + self::assertNull($stats->getEmbedderRequests()); + } + + public function testFromArray(): void + { + $stats = BatchStats::fromArray([ + 'totalNbTasks' => 1, + 'status' => ['succeeded' => 1], + 'types' => ['documentAdditionOrUpdate' => 1], + 'indexUids' => ['movies' => 1], + 'progressTrace' => ['indexingDocuments' => '100%'], + 'writeChannelCongestion' => ['attempts' => 0], + 'internalDatabaseSizes' => ['documents' => '4.0 KiB'], + 'embedderRequests' => [ + 'total' => 10, + 'failed' => 2, + 'lastError' => 'timeout', + ], + ]); + + self::assertSame(1, $stats->getTotalNbTasks()); + self::assertSame(['succeeded' => 1], $stats->getStatus()); + self::assertSame(['documentAdditionOrUpdate' => 1], $stats->getTypes()); + self::assertSame(['movies' => 1], $stats->getIndexUids()); + self::assertSame(['indexingDocuments' => '100%'], $stats->getProgressTrace()); + self::assertSame(['attempts' => 0], $stats->getWriteChannelCongestion()); + self::assertSame(['documents' => '4.0 KiB'], $stats->getInternalDatabaseSizes()); + self::assertEquals(new BatchEmbedderRequests( + total: 10, + failed: 2, + lastError: 'timeout', + ), $stats->getEmbedderRequests()); + } + + public function testFromArrayWithOptionalFieldsAbsent(): void + { + $stats = BatchStats::fromArray([ + 'totalNbTasks' => 1, + 'status' => ['succeeded' => 1], + 'types' => ['documentAdditionOrUpdate' => 1], + 'indexUids' => ['movies' => 1], + ]); + + self::assertSame(1, $stats->getTotalNbTasks()); + self::assertNull($stats->getProgressTrace()); + self::assertNull($stats->getWriteChannelCongestion()); + self::assertNull($stats->getInternalDatabaseSizes()); + self::assertNull($stats->getEmbedderRequests()); + } + + public function testEmbedderRequestsConstruct(): void + { + $requests = new BatchEmbedderRequests( + total: 5, + failed: 1, + lastError: 'rate limited', + ); + + self::assertSame(5, $requests->getTotal()); + self::assertSame(1, $requests->getFailed()); + self::assertSame('rate limited', $requests->getLastError()); + } + + public function testEmbedderRequestsConstructWithOptionalLastErrorNull(): void + { + $requests = new BatchEmbedderRequests( + total: 3, + failed: 0, + ); + + self::assertSame(3, $requests->getTotal()); + self::assertSame(0, $requests->getFailed()); + self::assertNull($requests->getLastError()); + } + + public function testEmbedderRequestsFromArray(): void + { + $requests = BatchEmbedderRequests::fromArray([ + 'total' => 10, + 'failed' => 2, + 'lastError' => 'timeout', + ]); + + self::assertSame(10, $requests->getTotal()); + self::assertSame(2, $requests->getFailed()); + self::assertSame('timeout', $requests->getLastError()); + } + + public function testEmbedderRequestsFromArrayWithOptionalLastErrorAbsent(): void + { + $requests = BatchEmbedderRequests::fromArray([ + 'total' => 4, + 'failed' => 0, + ]); + + self::assertSame(4, $requests->getTotal()); + self::assertSame(0, $requests->getFailed()); + self::assertNull($requests->getLastError()); + } +} diff --git a/tests/Endpoints/BatchesTest.php b/tests/Endpoints/BatchesTest.php index 1302e7396..5f4052fe9 100644 --- a/tests/Endpoints/BatchesTest.php +++ b/tests/Endpoints/BatchesTest.php @@ -30,7 +30,7 @@ public function testGetAllBatchesWithIndexUidFilters(): void { $response = $this->client->getBatches((new BatchesQuery())->setIndexUids([$this->indexName])); foreach ($response->getResults() as $result) { - self::assertArrayHasKey($this->indexName, $result->getStats()['indexUids']); + self::assertArrayHasKey($this->indexName, $result->getStats()->getIndexUids()); } } @@ -66,10 +66,10 @@ public function testGetOneBatch(): void self::assertSame($first->getUid(), $response->getUid()); self::assertInstanceOf(UnknownTaskDetails::class, $response->getDetails()); $stats = $response->getStats(); - self::assertSame($stats['totalNbTasks'], array_sum($stats['status'])); - self::assertNotEmpty($stats['status']); - self::assertNotEmpty($stats['types']); - self::assertArrayHasKey('progressTrace', $stats); + self::assertSame($stats->getTotalNbTasks(), array_sum($stats->getStatus())); + self::assertNotEmpty($stats->getStatus()); + self::assertNotEmpty($stats->getTypes()); + self::assertNotNull($stats->getProgressTrace()); self::assertSame($response->toArray()['batchStrategy'] ?? null, $response->getBatchStrategy()); } } From 41004c81f776d208f5a4fdf2596c0eeac196e4b3 Mon Sep 17 00:00:00 2001 From: Strift Date: Sat, 18 Jul 2026 09:23:11 +0800 Subject: [PATCH 7/8] Tighten types --- src/Contracts/BatchStats.php | 34 +++++++++++++++---------- tests/Contracts/BatchStatsTest.php | 40 +++++++++++++++++++++--------- 2 files changed, 49 insertions(+), 25 deletions(-) diff --git a/src/Contracts/BatchStats.php b/src/Contracts/BatchStats.php index 77b591fba..ba1d37b38 100644 --- a/src/Contracts/BatchStats.php +++ b/src/Contracts/BatchStats.php @@ -7,27 +7,35 @@ /** * @phpstan-import-type RawBatchEmbedderRequests from BatchEmbedderRequests * + * Keys in progressTrace and internalDatabaseSizes are intentionally open-ended + * (engine-defined step/db names that can change); values are duration/size strings. + * + * @phpstan-type RawWriteChannelCongestion array{ + * attempts: non-negative-int, + * blocking_attempts: non-negative-int, + * blocking_ratio: float + * } * @phpstan-type RawBatchStats array{ * totalNbTasks: non-negative-int, * status: array, * types: array, * indexUids: array, - * progressTrace?: array, - * writeChannelCongestion?: array, - * internalDatabaseSizes?: array, + * progressTrace?: array, + * writeChannelCongestion?: RawWriteChannelCongestion|null, + * internalDatabaseSizes?: array, * embedderRequests?: RawBatchEmbedderRequests * } */ final class BatchStats { /** - * @param non-negative-int $totalNbTasks - * @param array $status - * @param array $types - * @param array $indexUids - * @param array|null $progressTrace - * @param array|null $writeChannelCongestion - * @param array|null $internalDatabaseSizes + * @param non-negative-int $totalNbTasks + * @param array $status + * @param array $types + * @param array $indexUids + * @param array|null $progressTrace + * @param RawWriteChannelCongestion|null $writeChannelCongestion + * @param array|null $internalDatabaseSizes */ public function __construct( private readonly int $totalNbTasks, @@ -74,7 +82,7 @@ public function getIndexUids(): array } /** - * @return array|null + * @return array|null */ public function getProgressTrace(): ?array { @@ -82,7 +90,7 @@ public function getProgressTrace(): ?array } /** - * @return array|null + * @return RawWriteChannelCongestion|null */ public function getWriteChannelCongestion(): ?array { @@ -90,7 +98,7 @@ public function getWriteChannelCongestion(): ?array } /** - * @return array|null + * @return array|null */ public function getInternalDatabaseSizes(): ?array { diff --git a/tests/Contracts/BatchStatsTest.php b/tests/Contracts/BatchStatsTest.php index 0ff5c0b1f..f75a0de3c 100644 --- a/tests/Contracts/BatchStatsTest.php +++ b/tests/Contracts/BatchStatsTest.php @@ -23,9 +23,13 @@ public function testConstruct(): void status: ['succeeded' => 1], types: ['documentAdditionOrUpdate' => 1], indexUids: ['movies' => 1], - progressTrace: ['indexingDocuments' => '100%'], - writeChannelCongestion: ['attempts' => 0], - internalDatabaseSizes: ['documents' => '4.0 KiB'], + progressTrace: ['processing tasks > indexing' => '2.40s'], + writeChannelCongestion: [ + 'attempts' => 2608482, + 'blocking_attempts' => 0, + 'blocking_ratio' => 0.0, + ], + internalDatabaseSizes: ['documents' => '25.41 MiB (+25.41 MiB)'], embedderRequests: $embedderRequests, ); @@ -33,9 +37,13 @@ public function testConstruct(): void self::assertSame(['succeeded' => 1], $stats->getStatus()); self::assertSame(['documentAdditionOrUpdate' => 1], $stats->getTypes()); self::assertSame(['movies' => 1], $stats->getIndexUids()); - self::assertSame(['indexingDocuments' => '100%'], $stats->getProgressTrace()); - self::assertSame(['attempts' => 0], $stats->getWriteChannelCongestion()); - self::assertSame(['documents' => '4.0 KiB'], $stats->getInternalDatabaseSizes()); + self::assertSame(['processing tasks > indexing' => '2.40s'], $stats->getProgressTrace()); + self::assertSame([ + 'attempts' => 2608482, + 'blocking_attempts' => 0, + 'blocking_ratio' => 0.0, + ], $stats->getWriteChannelCongestion()); + self::assertSame(['documents' => '25.41 MiB (+25.41 MiB)'], $stats->getInternalDatabaseSizes()); self::assertSame($embedderRequests, $stats->getEmbedderRequests()); } @@ -65,9 +73,13 @@ public function testFromArray(): void 'status' => ['succeeded' => 1], 'types' => ['documentAdditionOrUpdate' => 1], 'indexUids' => ['movies' => 1], - 'progressTrace' => ['indexingDocuments' => '100%'], - 'writeChannelCongestion' => ['attempts' => 0], - 'internalDatabaseSizes' => ['documents' => '4.0 KiB'], + 'progressTrace' => ['processing tasks > indexing' => '2.40s'], + 'writeChannelCongestion' => [ + 'attempts' => 2608482, + 'blocking_attempts' => 0, + 'blocking_ratio' => 0.0, + ], + 'internalDatabaseSizes' => ['documents' => '25.41 MiB (+25.41 MiB)'], 'embedderRequests' => [ 'total' => 10, 'failed' => 2, @@ -79,9 +91,13 @@ public function testFromArray(): void self::assertSame(['succeeded' => 1], $stats->getStatus()); self::assertSame(['documentAdditionOrUpdate' => 1], $stats->getTypes()); self::assertSame(['movies' => 1], $stats->getIndexUids()); - self::assertSame(['indexingDocuments' => '100%'], $stats->getProgressTrace()); - self::assertSame(['attempts' => 0], $stats->getWriteChannelCongestion()); - self::assertSame(['documents' => '4.0 KiB'], $stats->getInternalDatabaseSizes()); + self::assertSame(['processing tasks > indexing' => '2.40s'], $stats->getProgressTrace()); + self::assertSame([ + 'attempts' => 2608482, + 'blocking_attempts' => 0, + 'blocking_ratio' => 0.0, + ], $stats->getWriteChannelCongestion()); + self::assertSame(['documents' => '25.41 MiB (+25.41 MiB)'], $stats->getInternalDatabaseSizes()); self::assertEquals(new BatchEmbedderRequests( total: 10, failed: 2, From f90f47493af04ff5c9d7f1e0bf3641e197207cb9 Mon Sep 17 00:00:00 2001 From: Strift Date: Tue, 4 Aug 2026 16:06:08 +0800 Subject: [PATCH 8/8] Access required Batch fields directly in fromArray. Align duration and progress reads with the RawBatch shape, keeping optional coalescing only for batchStrategy. Co-authored-by: Cursor --- src/Contracts/Batch.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Contracts/Batch.php b/src/Contracts/Batch.php index 4ec9031e7..0e55a0d75 100644 --- a/src/Contracts/Batch.php +++ b/src/Contracts/Batch.php @@ -135,11 +135,11 @@ public static function fromArray(array $data): self $data['uid'], UnknownTaskDetails::fromArray($data['details']), BatchStats::fromArray($data['stats']), - $data['duration'] ?? null, + $data['duration'], new \DateTimeImmutable($data['startedAt']), null !== $data['finishedAt'] ? new \DateTimeImmutable($data['finishedAt']) : null, - null !== ($data['progress'] ?? null) ? BatchProgress::fromArray($data['progress']) : null, + null !== $data['progress'] ? BatchProgress::fromArray($data['progress']) : null, $data['batchStrategy'] ?? null, $data, );