From 2cdb396b71a4f9675ce363512ed8b75c0cd6374b Mon Sep 17 00:00:00 2001 From: Yevgeny Tomenko Date: Sat, 8 Aug 2026 19:07:53 +0300 Subject: [PATCH 1/8] Add DTO (Data Transfer Object) support to Queue plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows job payloads to be dispatched and received as typed DTO objects instead of plain arrays, while maintaining full backward compatibility with existing array-based jobs. - `QueueManager::push()` now accepts a DTO object directly, or a plain array paired with an explicit `dtoClass` option - New `Message::getDto()` / `getDtoClass()` to hydrate the payload back into the DTO on the receiving side — `getArgument()` still returns the raw array unchanged - Two hydration styles, matching CakePHP 5.4's own DTO conventions (`#[RequestToDto]`, `SelectQuery::projectAs()`): constructor reflection (with nested DTOs and `#[CollectionOf]`), and a static `createFromArray()` factory - `shouldBeUnique` dedupe hashing now factors in `dtoClass`, so two different DTO types with coincidentally identical data are never treated as duplicates of each other - Fully backward compatible — legacy array-only pushes produce byte-identical message bodies; `getDto()` gracefully returns `null` (never throws) when no DTO was dispatched or the recorded `dtoClass` can no longer be autoloaded --- composer.json | 4 +- docs/en/jobs.md | 68 ++++++- .../RemoveUniqueJobIdFromCacheExtension.php | 2 +- src/Dto/DtoManager.php | 101 ++++++++++ src/Job/Message.php | 44 +++++ src/QueueManager.php | 38 +++- ...emoveUniqueJobIdFromCacheExtensionTest.php | 29 ++- tests/TestCase/Dto/DtoManagerTest.php | 174 ++++++++++++++++++ tests/TestCase/Job/MessageTest.php | 116 ++++++++++++ tests/TestCase/Queue/ProcessorTest.php | 33 ++++ tests/TestCase/QueueManagerTest.php | 137 ++++++++++++++ .../TestCase/TestSuite/QueueTestSuiteTest.php | 2 +- tests/test_app/src/Dto/InvalidDto.php | 12 ++ .../test_app/src/Dto/JsonSerializableDto.php | 23 +++ tests/test_app/src/Dto/OrderDto.php | 20 ++ tests/test_app/src/Dto/OrderItemDto.php | 13 ++ .../src/Dto/ScalarJsonSerializableDto.php | 19 ++ tests/test_app/src/Dto/UserDto.php | 21 +++ tests/test_app/src/Job/DtoJob.php | 20 ++ 19 files changed, 863 insertions(+), 13 deletions(-) create mode 100644 src/Dto/DtoManager.php create mode 100644 tests/TestCase/Dto/DtoManagerTest.php create mode 100644 tests/test_app/src/Dto/InvalidDto.php create mode 100644 tests/test_app/src/Dto/JsonSerializableDto.php create mode 100644 tests/test_app/src/Dto/OrderDto.php create mode 100644 tests/test_app/src/Dto/OrderItemDto.php create mode 100644 tests/test_app/src/Dto/ScalarJsonSerializableDto.php create mode 100644 tests/test_app/src/Dto/UserDto.php create mode 100644 tests/test_app/src/Job/DtoJob.php diff --git a/composer.json b/composer.json index a59d9b1..18dae30 100644 --- a/composer.json +++ b/composer.json @@ -21,8 +21,8 @@ "source": "https://github.com/cakephp/queue" }, "require": { - "php": ">=8.1", - "cakephp/cakephp": "^5.1.0", + "php": ">=8.2", + "cakephp/cakephp": "^5.4.0", "enqueue/simple-client": "^0.10", "psr/log": "^3.0" }, diff --git a/docs/en/jobs.md b/docs/en/jobs.md index e61698c..eb80264 100644 --- a/docs/en/jobs.md +++ b/docs/en/jobs.md @@ -60,7 +60,7 @@ Returning any other value is treated as a failure and results in the message bei ## Job Properties - `maxAttempts` limits how many times a job can be retried after an exception or explicit `Processor::REQUEUE`. If unset, the worker's `--max-attempts` option applies. If neither is set, retries are unlimited. -- `shouldBeUnique` allows only one queued copy of the same job class, method, and payload. Duplicate pushes are ignored. This requires `uniqueCache` in the queue configuration. +- `shouldBeUnique` allows only one queued copy of the same job class, method, and payload. Duplicate pushes are ignored. This requires `uniqueCache` in the queue configuration. When the payload is a DTO, its class is also factored into the uniqueness check, so two different DTO classes with coincidentally identical data are never treated as duplicates of each other. ## Queueing Jobs @@ -89,3 +89,69 @@ Supported options: - `expires`: expire the message after a number of seconds if it has not been consumed. - `priority`: one of `\Enqueue\Client\MessagePriority::VERY_LOW`, `LOW`, `NORMAL`, `HIGH`, or `VERY_HIGH`. - `queue`: queue name to use. Defaults to the configured queue, then `default`. + +## Dispatching and Receiving DTOs + +Instead of an array, `QueueManager::push()` also accepts a DTO object as the payload: + +```php +use App\Dto\OrderDto; +use App\Job\ProcessOrderJob; +use Cake\Queue\QueueManager; + +$order = new OrderDto(id: 7, customer: 'Acme Corp'); + +QueueManager::push(ProcessOrderJob::class, $order); +``` + +The DTO is serialized into the same JSON-safe array that a plain array payload would produce (via `jsonSerialize()` when the DTO implements `JsonSerializable`, otherwise its public properties), and the DTO's class name travels alongside it so the job can hydrate it back. If you only have an array at the dispatch site but still want the job to receive a typed object, pass the target class via the `dtoClass` option instead: + +```php +QueueManager::push(ProcessOrderJob::class, $data, [ + 'dtoClass' => OrderDto::class, +]); +``` + +A plain array push with no `dtoClass` option behaves exactly as before; the message body is unchanged. + +### Receiving a DTO in a job + +Call `Message::getDto()` to hydrate the payload back into the DTO class it was dispatched with. `getArgument()` keeps returning the raw array, so existing jobs that only read array data are unaffected: + +```php +public function execute(Message $message): ?string +{ + $order = $message->getDto(); // OrderDto, or null if no DTO was dispatched + $id = $message->getArgument('id'); // the raw array is still available + + return Processor::ACK; +} +``` + +`getDto()` returns `null` when the message wasn't dispatched with a DTO, and also when the recorded `dtoClass` can no longer be autoloaded (e.g. the class was renamed or removed after the job was queued) — a job can always fall back to `getArgument()` in that case instead of crashing. + +### Supported DTO classes + +Hydration mirrors the DTO conventions used elsewhere in CakePHP (`#[RequestToDto]` for controllers, `SelectQuery::projectAs()` for the ORM), so the same DTO class can be reused across all three: + +- **Constructor reflection** — a plain class (typically `readonly`) with typed, named constructor parameters. Nested DTOs are resolved from the parameter's type hint, and arrays of DTOs via the `#[CollectionOf]` attribute: + + ```php + use Cake\ORM\Attribute\CollectionOf; + + readonly class OrderDto + { + /** + * @param array $items + */ + public function __construct( + public int $id, + public string $customer, + #[CollectionOf(OrderItemDto::class)] + public array $items = [], + ) { + } + } + ``` + +- **`createFromArray()` factory** — if the DTO class defines a static `createFromArray(array $data, bool $nested = false): static` method, it's used instead of reflection. diff --git a/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php b/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php index 3a70e17..8b9dc5e 100644 --- a/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php +++ b/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php @@ -37,7 +37,7 @@ public function onResult(MessageResult $context): void $data = $jobMessage->getArgument(); - $uniqueId = QueueManager::getUniqueId($class, $method, $data); + $uniqueId = QueueManager::getUniqueId($class, $method, $data, $jobMessage->getDtoClass()); Cache::delete($uniqueId, $this->cache); } diff --git a/src/Dto/DtoManager.php b/src/Dto/DtoManager.php new file mode 100644 index 0000000..76118b9 --- /dev/null +++ b/src/Dto/DtoManager.php @@ -0,0 +1,101 @@ +|object $data Data or DTO object to serialize. + * @return array Serialized data. + */ + public static function serialize(array|object $data): array + { + if ($data instanceof JsonSerializable) { + $data = $data->jsonSerialize(); + } + + if (is_object($data)) { + $data = get_object_vars($data); + } + + if (!is_array($data)) { + throw new InvalidArgumentException( + 'DTO data could not be serialized into an array. `jsonSerialize()` must return an array or object.', + ); + } + + return static::toScalarArray($data); + } + + /** + * Hydrate queue data back into a DTO instance. + * + * @param array $data Serialized data. + * @param class-string $dtoClass DTO class name. + * @return object Hydrated DTO instance. + * @throws \InvalidArgumentException When the DTO class does not exist. + */ + public static function deserialize(array $data, string $dtoClass): object + { + if (!class_exists($dtoClass)) { + throw new InvalidArgumentException(sprintf('DTO class `%s` does not exist.', $dtoClass)); + } + + return (new ResultSetFactory())->hydrateDto($data, $dtoClass); + } + + /** + * Recursively convert any nested objects into arrays. + * + * @param array $data The data to convert. + * @return array The converted data. + */ + protected static function toScalarArray(array $data): array + { + foreach ($data as $key => $value) { + if (is_object($value)) { + $value = $value instanceof JsonSerializable + ? $value->jsonSerialize() + : get_object_vars($value); + } + + if (is_array($value)) { + $data[$key] = static::toScalarArray($value); + } + } + + return $data; + } +} diff --git a/src/Job/Message.php b/src/Job/Message.php index f1b504c..96a4fe4 100644 --- a/src/Job/Message.php +++ b/src/Job/Message.php @@ -17,6 +17,7 @@ namespace Cake\Queue\Job; use Cake\Core\ContainerInterface; +use Cake\Queue\Dto\DtoManager; use Cake\Utility\Hash; use Closure; use Interop\Queue\Context; @@ -33,6 +34,11 @@ class Message implements JsonSerializable protected ?Closure $callable = null; + /** + * @var object|null + */ + protected ?object $dto = null; + /** * @param \Interop\Queue\Message $originalMessage Queue message. * @param \Interop\Queue\Context $context Context. @@ -145,6 +151,44 @@ public function getArgument(mixed $key = null, mixed $default = null): mixed return Hash::get($data, $key, $default); } + /** + * Get the DTO class name the message was dispatched with, if any. + * + * @return class-string|null + */ + public function getDtoClass(): ?string + { + $dtoClass = $this->parsedBody['dtoClass'] ?? null; + if (!is_string($dtoClass) || !class_exists($dtoClass)) { + return null; + } + + return $dtoClass; + } + + /** + * Get the message data hydrated back into a DTO object. + * + * Returns `null` when the message was not dispatched with a DTO. + * + * @return object|null + */ + public function getDto(): ?object + { + if ($this->dto !== null) { + return $this->dto; + } + + $dtoClass = $this->getDtoClass(); + if ($dtoClass === null) { + return null; + } + + $this->dto = DtoManager::deserialize($this->getArgument(), $dtoClass); + + return $this->dto; + } + /** * The maximum number of attempts allowed by the job. * diff --git a/src/QueueManager.php b/src/QueueManager.php index 9a8bd89..5337521 100644 --- a/src/QueueManager.php +++ b/src/QueueManager.php @@ -20,6 +20,7 @@ use Cake\Cache\Cache; use Cake\Core\App; use Cake\Log\Log; +use Cake\Queue\Dto\DtoManager; use Enqueue\Client\Message as ClientMessage; use Enqueue\SimpleClient\SimpleClient; use InvalidArgumentException; @@ -205,11 +206,16 @@ public static function engine(string $name): SimpleClient * @param array|string $className The classname of a job that implements the * \Cake\Queue\Job\JobInterface. The class will be constructed by * \Cake\Queue\Processor and have the execute method invoked. - * @param array $data An array of data that will be passed to the job. + * @param array|object $data An array of data or a DTO object that will + * be passed to the job. When a DTO object is given it is serialized and the class + * name is stored so the job can hydrate it back via `Message::getDto()`. * @param array $options An array of options for publishing the job: * - `config` - A queue config name. Defaults to 'default'. * - `delay` - Time (in integer seconds) to delay message, after which it * will be processed. Not all message brokers accept this. Default `null`. + * - `dtoClass` - The DTO class to hydrate the data into on the receiving side. + * Only needed when `$data` is an array. Ignored when `$data` is already a DTO + * object. Default `null`. * - `expires` - Time (in integer seconds) after which the message expires. * The message will be removed from the queue if this time is exceeded * and it has not been consumed. Default `null`. @@ -222,7 +228,7 @@ public static function engine(string $name): SimpleClient * - `queue` - The name of a queue to use, from queue `config` array or * string 'default' if empty. */ - public static function push(string|array $className, array $data = [], array $options = []): void + public static function push(string|array $className, array|object $data = [], array $options = []): void { [$class, $method] = is_array($className) ? $className : [$className, 'execute']; @@ -231,6 +237,15 @@ public static function push(string|array $className, array $data = [], array $op throw new InvalidArgumentException(sprintf('`%s` class does not exist.', $class)); } + $dtoClass = null; + if (is_object($data)) { + $dtoClass = $data::class; + $data = DtoManager::serialize($data); + } elseif (!empty($options['dtoClass'])) { + $dtoClass = $options['dtoClass']; + $data = DtoManager::serialize($data); + } + $name = $options['config'] ?? 'default'; $config = static::getConfig($name) + [ @@ -246,7 +261,7 @@ public static function push(string|array $className, array $data = [], array $op ); } - $uniqueId = static::getUniqueId($class, $method, $data); + $uniqueId = static::getUniqueId($class, $method, $data, $dtoClass); if (Cache::read($uniqueId, $config['uniqueCacheKey'])) { if ($logger instanceof LoggerInterface) { @@ -264,7 +279,7 @@ public static function push(string|array $className, array $data = [], array $op $queue = $options['queue'] ?? $config['queue'] ?? 'default'; - $message = new ClientMessage([ + $body = [ 'class' => [$class, $method], 'args' => [$data], 'data' => $data, @@ -273,7 +288,12 @@ public static function push(string|array $className, array $data = [], array $op 'priority' => $options['priority'] ?? null, 'queue' => $queue, ], - ]); + ]; + if ($dtoClass !== null) { + $body['dtoClass'] = $dtoClass; + } + + $message = new ClientMessage($body); if (isset($options['delay'])) { $message->setDelay($options['delay']); @@ -291,7 +311,7 @@ public static function push(string|array $className, array $data = [], array $op $client->sendEvent($queue, $message); if (!empty($class::$shouldBeUnique)) { - $uniqueId = static::getUniqueId($class, $method, $data); + $uniqueId = static::getUniqueId($class, $method, $data, $dtoClass); Cache::add($uniqueId, true, $config['uniqueCacheKey']); } @@ -301,14 +321,18 @@ public static function push(string|array $className, array $data = [], array $op * @param class-string $class Class name * @param string $method Method name * @param array $data Message data + * @param class-string|null $dtoClass The DTO class the data was dispatched with, if any. Two + * dispatches with identical `$data` but different `$dtoClass` are treated as distinct so a + * coincidental structural match between unrelated DTOs does not collapse into one dedupe entry. */ - public static function getUniqueId(string $class, string $method, array $data): string + public static function getUniqueId(string $class, string $method, array $data, ?string $dtoClass = null): string { $data = static::sortUniqueValues($data); $hashInput = implode('', [ $class, $method, + $dtoClass ?? '', json_encode($data), ]); diff --git a/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php b/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php index 05b940e..c3f5175 100644 --- a/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php +++ b/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php @@ -15,6 +15,7 @@ use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\BeforeClass; use Psr\Log\NullLogger; +use TestApp\Dto\OrderDto; use TestApp\Job\UniqueJob; class RemoveUniqueJobIdFromCacheExtensionTest extends TestCase @@ -26,9 +27,9 @@ public static function dropConfigs() { Log::drop('debug'); + $cacheKey = QueueManager::getConfig('default')['uniqueCacheKey'] ?? null; QueueManager::drop('default'); - $cacheKey = QueueManager::getConfig('default')['uniqueCacheKey'] ?? null; if ($cacheKey) { Cache::clear($cacheKey); Cache::drop($cacheKey); @@ -49,6 +50,32 @@ public function testJobIsRemovedFromCacheAfterProcessing() $this->assertNull(Cache::read($uniqueId, 'Cake/Queue.queueUnique.default')); } + /** + * Test that a unique job dispatched with a DTO is removed from the cache + * using a hash that includes the dtoClass, matching the one computed at push time. + * + * @return void + */ + public function testJobWithDtoIsRemovedFromCacheAfterProcessing() + { + $consume = $this->setupQueue(); + + $dto = new OrderDto(7, 'Acme Corp', []); + QueueManager::push(UniqueJob::class, $dto); + + $uniqueId = QueueManager::getUniqueId( + UniqueJob::class, + 'execute', + ['id' => 7, 'customer' => 'Acme Corp', 'items' => []], + OrderDto::class, + ); + $this->assertTrue(Cache::read($uniqueId, 'Cake/Queue.queueUnique.default')); + + $consume(); + + $this->assertNull(Cache::read($uniqueId, 'Cake/Queue.queueUnique.default')); + } + protected function setupQueue() { Log::setConfig('debug', [ diff --git a/tests/TestCase/Dto/DtoManagerTest.php b/tests/TestCase/Dto/DtoManagerTest.php new file mode 100644 index 0000000..733fffe --- /dev/null +++ b/tests/TestCase/Dto/DtoManagerTest.php @@ -0,0 +1,174 @@ + 1, 'nested' => ['a' => 'b']]; + + $this->assertSame($data, DtoManager::serialize($data)); + } + + /** + * Test that a plain object is serialized from its public properties. + * + * @return void + */ + public function testSerializeObject() + { + $object = new class (1, 'Acme') { + public function __construct( + public int $id, + public string $name, + ) { + } + }; + + $this->assertSame(['id' => 1, 'name' => 'Acme'], DtoManager::serialize($object)); + } + + /** + * Test that JsonSerializable DTOs use their jsonSerialize() output. + * + * @return void + */ + public function testSerializeJsonSerializable() + { + $dto = new JsonSerializableDto(1, 'acme'); + + $this->assertSame(['id' => 1, 'label' => 'ACME'], DtoManager::serialize($dto)); + } + + /** + * Test that a JsonSerializable DTO returning a non-array/non-object value + * from jsonSerialize() throws a clear exception instead of an unrelated + * TypeError from get_object_vars(). + * + * @return void + */ + public function testSerializeJsonSerializableReturningScalarThrows() + { + $dto = new ScalarJsonSerializableDto('not-an-array'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('could not be serialized into an array'); + + DtoManager::serialize($dto); + } + + /** + * Test that nested objects are recursively converted to arrays. + * + * @return void + */ + public function testSerializeNestedObjects() + { + $dto = new OrderDto(7, 'Acme', [new OrderItemDto('SKU-1', 2)]); + + $this->assertSame([ + 'id' => 7, + 'customer' => 'Acme', + 'items' => [ + ['sku' => 'SKU-1', 'quantity' => 2], + ], + ], DtoManager::serialize($dto)); + } + + /** + * Test hydration using a createFromArray() factory method. + * + * @return void + */ + public function testDeserializeWithCreateFromArray() + { + $dto = DtoManager::deserialize([ + 'id' => 3, + 'username' => 'markstory', + ], UserDto::class); + + $this->assertInstanceOf(UserDto::class, $dto); + $this->assertSame(3, $dto->id); + $this->assertSame('markstory', $dto->username); + } + + /** + * Test hydration of a plain DTO using constructor reflection. + * + * @return void + */ + public function testDeserializeWithReflection() + { + $dto = DtoManager::deserialize([ + 'id' => 7, + 'customer' => 'Acme', + 'items' => [ + ['sku' => 'SKU-1', 'quantity' => 2], + ], + ], OrderDto::class); + + $this->assertInstanceOf(OrderDto::class, $dto); + $this->assertSame(7, $dto->id); + $this->assertSame('Acme', $dto->customer); + $this->assertCount(1, $dto->items); + $this->assertInstanceOf(OrderItemDto::class, $dto->items[0]); + $this->assertSame('SKU-1', $dto->items[0]->sku); + $this->assertSame(2, $dto->items[0]->quantity); + } + + /** + * Test that a non-existent DTO class throws. + * + * @return void + */ + public function testDeserializeNonExistentClass() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('does not exist'); + + DtoManager::deserialize(['id' => 1], 'TestApp\Dto\DoesNotExist'); + } + + /** + * Test that a DTO with no factory and an incompatible constructor throws. + * + * @return void + */ + public function testDeserializeIncompatibleDto() + { + $this->expectException(ArgumentCountError::class); + + DtoManager::deserialize(['id' => 1], InvalidDto::class); + } +} diff --git a/tests/TestCase/Job/MessageTest.php b/tests/TestCase/Job/MessageTest.php index 9d09471..c10470a 100644 --- a/tests/TestCase/Job/MessageTest.php +++ b/tests/TestCase/Job/MessageTest.php @@ -23,6 +23,9 @@ use Enqueue\Null\NullMessage; use Error; use RuntimeException; +use TestApp\Dto\OrderDto; +use TestApp\Dto\OrderItemDto; +use TestApp\Dto\UserDto; use TestApp\WelcomeMailer; class MessageTest extends TestCase @@ -94,6 +97,119 @@ public function testLegacyArguments() $this->assertSame('no third argument', $message->getArgument('third', 'no third argument')); } + /** + * Test that a DTO dispatched with the message is hydrated on the receiving side. + * + * @return void + */ + public function testGetDto() + { + $parsedBody = [ + 'class' => [WelcomeMailer::class, 'welcome'], + 'data' => [ + 'id' => 7, + 'customer' => 'Acme Corp', + 'items' => [ + ['sku' => 'SKU-1', 'quantity' => 2], + ['sku' => 'SKU-2', 'quantity' => 1], + ], + ], + 'dtoClass' => OrderDto::class, + ]; + $connectionFactory = new NullConnectionFactory(); + $context = $connectionFactory->createContext(); + $originalMessage = new NullMessage((string)json_encode($parsedBody)); + $message = new Message($originalMessage, $context); + + $this->assertSame(OrderDto::class, $message->getDtoClass()); + + $dto = $message->getDto(); + $this->assertInstanceOf(OrderDto::class, $dto); + $this->assertSame(7, $dto->id); + $this->assertSame('Acme Corp', $dto->customer); + $this->assertCount(2, $dto->items); + $this->assertInstanceOf(OrderItemDto::class, $dto->items[0]); + $this->assertSame('SKU-1', $dto->items[0]->sku); + $this->assertSame(1, $dto->items[1]->quantity); + + // The DTO is only hydrated once. + $this->assertSame($dto, $message->getDto()); + + // The raw data is still accessible as an array. + $this->assertSame($parsedBody['data'], $message->getArgument()); + $this->assertSame(7, $message->getArgument('id')); + } + + /** + * Test that DTOs using a `createFromArray()` factory are supported. + * + * @return void + */ + public function testGetDtoWithCreateFromArray() + { + $parsedBody = [ + 'class' => [WelcomeMailer::class, 'welcome'], + 'data' => [ + 'id' => 3, + 'username' => 'markstory', + ], + 'dtoClass' => UserDto::class, + ]; + $connectionFactory = new NullConnectionFactory(); + $context = $connectionFactory->createContext(); + $originalMessage = new NullMessage((string)json_encode($parsedBody)); + $message = new Message($originalMessage, $context); + + $dto = $message->getDto(); + $this->assertInstanceOf(UserDto::class, $dto); + $this->assertSame(3, $dto->id); + $this->assertSame('markstory', $dto->username); + } + + /** + * Test that messages without a DTO class do not expose a DTO. + * + * @return void + */ + public function testGetDtoWithoutDtoClass() + { + $parsedBody = [ + 'class' => [WelcomeMailer::class, 'welcome'], + 'data' => ['id' => 7], + ]; + $connectionFactory = new NullConnectionFactory(); + $context = $connectionFactory->createContext(); + $originalMessage = new NullMessage((string)json_encode($parsedBody)); + $message = new Message($originalMessage, $context); + + $this->assertNull($message->getDtoClass()); + $this->assertNull($message->getDto()); + $this->assertSame(['id' => 7], $message->getArgument()); + } + + /** + * Test that a `dtoClass` referencing a class that no longer exists at + * consume time is treated the same as no DTO at all, rather than crashing. + * + * @return void + */ + public function testGetDtoWithUnresolvableDtoClass() + { + $parsedBody = [ + 'class' => [WelcomeMailer::class, 'welcome'], + 'data' => ['id' => 7], + 'dtoClass' => 'TestApp\Dto\DoesNotExist', + ]; + $connectionFactory = new NullConnectionFactory(); + $context = $connectionFactory->createContext(); + $originalMessage = new NullMessage((string)json_encode($parsedBody)); + $message = new Message($originalMessage, $context); + + $this->assertNull($message->getDtoClass()); + $this->assertNull($message->getDto()); + $this->assertSame(['id' => 7], $message->getArgument()); + } + /** * Test that invalid classes cannot be made into callables. * diff --git a/tests/TestCase/Queue/ProcessorTest.php b/tests/TestCase/Queue/ProcessorTest.php index d0161de..853aa52 100644 --- a/tests/TestCase/Queue/ProcessorTest.php +++ b/tests/TestCase/Queue/ProcessorTest.php @@ -28,6 +28,8 @@ use Enqueue\Null\NullMessage; use Interop\Queue\Processor as InteropProcessor; use PHPUnit\Framework\Attributes\DataProvider; +use TestApp\Dto\OrderDto; +use TestApp\Job\DtoJob; use TestApp\TestProcessor; use TestApp\WelcomeMailer; use Traversable; @@ -244,6 +246,37 @@ public function testProcessJobObject() $this->assertSame(InteropProcessor::ACK, $result); } + /** + * Test that a job receives its data hydrated back into a DTO. + * + * @return void + */ + public function testProcessMessageWithDto() + { + $messageBody = [ + 'class' => [DtoJob::class, 'execute'], + 'data' => [ + 'id' => 7, + 'customer' => 'Acme Corp', + 'items' => [], + ], + 'dtoClass' => OrderDto::class, + ]; + $connectionFactory = new NullConnectionFactory(); + $context = $connectionFactory->createContext(); + $queueMessage = new NullMessage((string)json_encode($messageBody)); + $processor = new Processor(); + + $result = $processor->process($queueMessage, $context); + + $this->assertSame(InteropProcessor::ACK, $result); + $this->assertInstanceOf(OrderDto::class, DtoJob::$lastDto); + $this->assertSame(7, DtoJob::$lastDto->id); + $this->assertSame('Acme Corp', DtoJob::$lastDto->customer); + + DtoJob::$lastDto = null; + } + /** * Test processMessage method. * diff --git a/tests/TestCase/QueueManagerTest.php b/tests/TestCase/QueueManagerTest.php index c584751..f2fb035 100644 --- a/tests/TestCase/QueueManagerTest.php +++ b/tests/TestCase/QueueManagerTest.php @@ -24,6 +24,8 @@ use Cake\TestSuite\TestCase; use Enqueue\SimpleClient\SimpleClient; use LogicException; +use TestApp\Dto\OrderDto; +use TestApp\Dto\OrderItemDto; use TestApp\Job\LogToDebugJob; use TestApp\Job\UniqueJob; use TypeError; @@ -95,6 +97,30 @@ public function testGetUniqueId() $this->assertEquals($first, $second, 'nested arrays are sorted too'); } + /** + * Test that the dtoClass argument is factored into the unique hash so two + * different DTO types that happen to serialize identically don't collide. + * + * @return void + */ + public function testGetUniqueIdWithDtoClass() + { + $data = ['id' => 7, 'customer' => 'Acme Corp']; + + $withoutDto = QueueManager::getUniqueId('Example', 'hello', $data); + $withNullDto = QueueManager::getUniqueId('Example', 'hello', $data, null); + $this->assertSame($withoutDto, $withNullDto, 'omitting dtoClass matches an explicit null'); + + $withOrderDto = QueueManager::getUniqueId('Example', 'hello', $data, 'App\Dto\OrderDto'); + $this->assertNotEquals($withoutDto, $withOrderDto, 'a dtoClass changes the hash'); + + $withOtherDto = QueueManager::getUniqueId('Example', 'hello', $data, 'App\Dto\OtherDto'); + $this->assertNotEquals($withOrderDto, $withOtherDto, 'different dtoClasses with identical data are distinct'); + + $withOrderDtoAgain = QueueManager::getUniqueId('Example', 'hello', $data, 'App\Dto\OrderDto'); + $this->assertSame($withOrderDto, $withOrderDtoAgain, 'same dtoClass and data are the same'); + } + public function testSetConfig() { QueueManager::setConfig('test', [ @@ -223,6 +249,61 @@ public function testMessageIsPushedToQueuePassedAsOption() $this->assertStringContainsString('non-default-queue-name', file_get_contents($fsQueueFile)); } + public function testPushWithDtoObject() + { + QueueManager::setConfig('test', [ + 'url' => $this->getFsQueueUrl(), + 'queue' => 'test', + ]); + + $dto = new OrderDto(7, 'Acme Corp', [ + new OrderItemDto('SKU-1', 2), + ]); + QueueManager::push(LogToDebugJob::class, $dto, ['config' => 'test']); + + $fsQueueFile = $this->getFsQueueUrl() . DS . 'enqueue.app.test'; + $this->assertFileExists($fsQueueFile); + $contents = file_get_contents($fsQueueFile); + $this->assertStringContainsString('dtoClass', $contents); + $this->assertStringContainsString('OrderDto', $contents); + $this->assertStringContainsString('Acme Corp', $contents); + $this->assertStringContainsString('SKU-1', $contents); + } + + public function testPushWithDtoClassOption() + { + QueueManager::setConfig('test', [ + 'url' => $this->getFsQueueUrl(), + 'queue' => 'test', + ]); + + QueueManager::push(LogToDebugJob::class, [ + 'id' => 7, + 'customer' => 'Acme Corp', + ], ['config' => 'test', 'dtoClass' => OrderDto::class]); + + $fsQueueFile = $this->getFsQueueUrl() . DS . 'enqueue.app.test'; + $this->assertFileExists($fsQueueFile); + $contents = file_get_contents($fsQueueFile); + $this->assertStringContainsString('dtoClass', $contents); + $this->assertStringContainsString('OrderDto', $contents); + } + + public function testPushWithoutDtoDoesNotAddDtoClass() + { + QueueManager::setConfig('test', [ + 'url' => $this->getFsQueueUrl(), + 'queue' => 'test', + ]); + + QueueManager::push(LogToDebugJob::class, ['id' => 7], ['config' => 'test']); + + $fsQueueFile = $this->getFsQueueUrl() . DS . 'enqueue.app.test'; + $this->assertFileExists($fsQueueFile); + $contents = file_get_contents($fsQueueFile); + $this->assertStringNotContainsString('dtoClass', $contents); + } + public function testUniqueMessageIsQueuedOnlyOnce() { QueueManager::setConfig('test', [ @@ -241,6 +322,62 @@ public function testUniqueMessageIsQueuedOnlyOnce() $this->assertSame(1, substr_count(file_get_contents($fsQueueFile), 'UniqueJob')); } + /** + * Test that pushing the same DTO twice for a unique job only queues it once. + * + * @return void + */ + public function testUniqueMessageWithDtoObjectIsQueuedOnlyOnce() + { + QueueManager::setConfig('test', [ + 'url' => $this->getFsQueueUrl(), + 'queue' => 'test', + 'uniqueCache' => [ + 'engine' => 'File', + ], + ]); + + $first = new OrderDto(7, 'Acme Corp', [ + new OrderItemDto('SKU-1', 2), + ]); + $second = new OrderDto(7, 'Acme Corp', [ + new OrderItemDto('SKU-1', 2), + ]); + + QueueManager::push(UniqueJob::class, $first, ['config' => 'test']); + QueueManager::push(UniqueJob::class, $second, ['config' => 'test']); + + $fsQueueFile = $this->getFsQueueUrl() . DS . 'enqueue.app.test'; + $this->assertFileExists($fsQueueFile); + $this->assertSame(1, substr_count(file_get_contents($fsQueueFile), 'UniqueJob')); + } + + /** + * Test that pushing DTOs with different field values for a unique job queues both. + * + * @return void + */ + public function testUniqueMessageWithDifferentDtoObjectsAreBothQueued() + { + QueueManager::setConfig('test', [ + 'url' => $this->getFsQueueUrl(), + 'queue' => 'test', + 'uniqueCache' => [ + 'engine' => 'File', + ], + ]); + + $first = new OrderDto(7, 'Acme Corp', []); + $second = new OrderDto(8, 'Other Corp', []); + + QueueManager::push(UniqueJob::class, $first, ['config' => 'test']); + QueueManager::push(UniqueJob::class, $second, ['config' => 'test']); + + $fsQueueFile = $this->getFsQueueUrl() . DS . 'enqueue.app.test'; + $this->assertFileExists($fsQueueFile); + $this->assertSame(2, substr_count(file_get_contents($fsQueueFile), 'UniqueJob')); + } + public function testDroppedJobIsLoggedForUniqueJob() { Log::setConfig('debug', [ diff --git a/tests/TestCase/TestSuite/QueueTestSuiteTest.php b/tests/TestCase/TestSuite/QueueTestSuiteTest.php index 0747165..c709f28 100644 --- a/tests/TestCase/TestSuite/QueueTestSuiteTest.php +++ b/tests/TestCase/TestSuite/QueueTestSuiteTest.php @@ -665,7 +665,7 @@ public function testCreateConsumerWithOtherDestination(): void public function testCreateConsumerWithTopicOnlyDestination(): void { $context = new TestContext(); - $topic = $this->createMock(Topic::class); + $topic = $this->createStub(Topic::class); $topic->method('getTopicName')->willReturn('test-topic'); $consumer = $context->createConsumer($topic); diff --git a/tests/test_app/src/Dto/InvalidDto.php b/tests/test_app/src/Dto/InvalidDto.php new file mode 100644 index 0000000..bdf53b2 --- /dev/null +++ b/tests/test_app/src/Dto/InvalidDto.php @@ -0,0 +1,12 @@ + $this->id, + 'label' => strtoupper($this->name), + ]; + } +} diff --git a/tests/test_app/src/Dto/OrderDto.php b/tests/test_app/src/Dto/OrderDto.php new file mode 100644 index 0000000..928a839 --- /dev/null +++ b/tests/test_app/src/Dto/OrderDto.php @@ -0,0 +1,20 @@ + $items + */ + public function __construct( + public int $id, + public string $customer, + #[CollectionOf(OrderItemDto::class)] + public array $items = [], + ) { + } +} diff --git a/tests/test_app/src/Dto/OrderItemDto.php b/tests/test_app/src/Dto/OrderItemDto.php new file mode 100644 index 0000000..a4e3ab8 --- /dev/null +++ b/tests/test_app/src/Dto/OrderItemDto.php @@ -0,0 +1,13 @@ +value; + } +} diff --git a/tests/test_app/src/Dto/UserDto.php b/tests/test_app/src/Dto/UserDto.php new file mode 100644 index 0000000..6028243 --- /dev/null +++ b/tests/test_app/src/Dto/UserDto.php @@ -0,0 +1,21 @@ + $data + */ + public static function createFromArray(array $data, bool $nested = false): static + { + return new static($data['id'], $data['username']); + } + + public function __construct( + public int $id, + public string $username, + ) { + } +} diff --git a/tests/test_app/src/Job/DtoJob.php b/tests/test_app/src/Job/DtoJob.php new file mode 100644 index 0000000..11a1ae7 --- /dev/null +++ b/tests/test_app/src/Job/DtoJob.php @@ -0,0 +1,20 @@ +getDto(); + + return Processor::ACK; + } +} From 8b8b17bcfd4f1bee2e255b94c5d806ce0bbeaa20 Mon Sep 17 00:00:00 2001 From: Yevgeny Tomenko Date: Sat, 8 Aug 2026 19:16:18 +0300 Subject: [PATCH 2/8] apply rector, fix ci lowest, bump ramsey/uuid --- composer.json | 3 ++- src/Consumption/LimitAttemptsExtension.php | 3 --- src/Dto/DtoManager.php | 4 ++-- src/Job/JobInterface.php | 1 - src/Job/MailerJob.php | 1 - src/Job/Message.php | 8 -------- src/Queue/Processor.php | 3 --- src/TestSuite/Transport/TestConsumer.php | 3 --- src/TestSuite/Transport/TestMessage.php | 10 ---------- src/TestSuite/Transport/TestProducer.php | 6 ------ tests/TestCase/QueueManagerTest.php | 2 +- tests/comparisons/JobTask.php | 1 - tests/comparisons/JobTaskWithMaxAttempts.php | 1 - tests/comparisons/JobTaskWithUnique.php | 1 - 14 files changed, 5 insertions(+), 42 deletions(-) diff --git a/composer.json b/composer.json index 18dae30..3033302 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,8 @@ "php": ">=8.2", "cakephp/cakephp": "^5.4.0", "enqueue/simple-client": "^0.10", - "psr/log": "^3.0" + "psr/log": "^3.0", + "ramsey/uuid": "^4.2.0" }, "require-dev": { "cakephp/bake": "^3.5.1", diff --git a/src/Consumption/LimitAttemptsExtension.php b/src/Consumption/LimitAttemptsExtension.php index 721431e..2733470 100644 --- a/src/Consumption/LimitAttemptsExtension.php +++ b/src/Consumption/LimitAttemptsExtension.php @@ -12,9 +12,6 @@ class LimitAttemptsExtension implements MessageResultExtensionInterface { - /** - * @use \Cake\Event\EventDispatcherTrait<\Cake\Queue\Job\Message> - */ use EventDispatcherTrait; /** diff --git a/src/Dto/DtoManager.php b/src/Dto/DtoManager.php index 76118b9..8480ff7 100644 --- a/src/Dto/DtoManager.php +++ b/src/Dto/DtoManager.php @@ -56,7 +56,7 @@ public static function serialize(array|object $data): array ); } - return static::toScalarArray($data); + return self::toScalarArray($data); } /** @@ -92,7 +92,7 @@ protected static function toScalarArray(array $data): array } if (is_array($value)) { - $data[$key] = static::toScalarArray($value); + $data[$key] = self::toScalarArray($value); } } diff --git a/src/Job/JobInterface.php b/src/Job/JobInterface.php index 65f84fc..d3dd2b8 100644 --- a/src/Job/JobInterface.php +++ b/src/Job/JobInterface.php @@ -22,7 +22,6 @@ interface JobInterface * Executes logic for Job * * @param \Cake\Queue\Job\Message $message job message - * @return string|null */ public function execute(Message $message): ?string; } diff --git a/src/Job/MailerJob.php b/src/Job/MailerJob.php index 3d6d71e..81d167d 100644 --- a/src/Job/MailerJob.php +++ b/src/Job/MailerJob.php @@ -29,7 +29,6 @@ class MailerJob implements JobInterface * Constructs and dispatches the event from a job message * * @param \Cake\Queue\Job\Message $message job message - * @return string|null */ public function execute(Message $message): ?string { diff --git a/src/Job/Message.php b/src/Job/Message.php index 96a4fe4..f32c08d 100644 --- a/src/Job/Message.php +++ b/src/Job/Message.php @@ -34,9 +34,6 @@ class Message implements JsonSerializable protected ?Closure $callable = null; - /** - * @var object|null - */ protected ?object $dto = null; /** @@ -132,7 +129,6 @@ public function getTarget(): array /** * @param mixed $key Key * @param mixed $default Default value. - * @return mixed */ public function getArgument(mixed $key = null, mixed $default = null): mixed { @@ -170,8 +166,6 @@ public function getDtoClass(): ?string * Get the message data hydrated back into a DTO object. * * Returns `null` when the message was not dispatched with a DTO. - * - * @return object|null */ public function getDto(): ?object { @@ -191,8 +185,6 @@ public function getDto(): ?object /** * The maximum number of attempts allowed by the job. - * - * @return int|null */ public function getMaxAttempts(): ?int { diff --git a/src/Queue/Processor.php b/src/Queue/Processor.php index bff6628..5a66ae5 100644 --- a/src/Queue/Processor.php +++ b/src/Queue/Processor.php @@ -31,9 +31,6 @@ class Processor implements InteropProcessor { - /** - * @use \Cake\Event\EventDispatcherTrait<\Cake\Queue\Queue\Processor> - */ use EventDispatcherTrait; /** diff --git a/src/TestSuite/Transport/TestConsumer.php b/src/TestSuite/Transport/TestConsumer.php index ca2bf70..00db46e 100644 --- a/src/TestSuite/Transport/TestConsumer.php +++ b/src/TestSuite/Transport/TestConsumer.php @@ -43,7 +43,6 @@ public function getQueue(): Queue * Receive message * * @param int|null $timeout Timeout in milliseconds - * @return \Interop\Queue\Message|null */ public function receive(?int $timeout = null): ?Message { @@ -52,8 +51,6 @@ public function receive(?int $timeout = null): ?Message /** * Receive no wait - * - * @return \Interop\Queue\Message|null */ public function receiveNoWait(): ?Message { diff --git a/src/TestSuite/Transport/TestMessage.php b/src/TestSuite/Transport/TestMessage.php index 2eaa341..d9904a4 100644 --- a/src/TestSuite/Transport/TestMessage.php +++ b/src/TestSuite/Transport/TestMessage.php @@ -83,7 +83,6 @@ public function setProperty(string $name, mixed $value): void * * @param string $name Property name * @param mixed $default Default value - * @return mixed */ public function getProperty(string $name, mixed $default = null): mixed { @@ -107,7 +106,6 @@ public function setHeader(string $name, mixed $value): void * * @param string $name Header name * @param mixed $default Default value - * @return mixed */ public function getHeader(string $name, mixed $default = null): mixed { @@ -178,8 +176,6 @@ public function setRedelivered(bool $redelivered): void /** * Get correlation ID - * - * @return string|null */ public function getCorrelationId(): ?string { @@ -199,8 +195,6 @@ public function setCorrelationId(?string $correlationId = null): void /** * Get message ID - * - * @return string|null */ public function getMessageId(): ?string { @@ -220,8 +214,6 @@ public function setMessageId(?string $messageId = null): void /** * Get timestamp - * - * @return int|null */ public function getTimestamp(): ?int { @@ -241,8 +233,6 @@ public function setTimestamp(?int $timestamp = null): void /** * Get reply to - * - * @return string|null */ public function getReplyTo(): ?string { diff --git a/src/TestSuite/Transport/TestProducer.php b/src/TestSuite/Transport/TestProducer.php index 75b433f..2100ec0 100644 --- a/src/TestSuite/Transport/TestProducer.php +++ b/src/TestSuite/Transport/TestProducer.php @@ -63,8 +63,6 @@ public function setDeliveryDelay(?int $deliveryDelay = null): Producer /** * Get delivery delay - * - * @return int|null */ public function getDeliveryDelay(): ?int { @@ -86,8 +84,6 @@ public function setPriority(?int $priority = null): Producer /** * Get priority - * - * @return int|null */ public function getPriority(): ?int { @@ -109,8 +105,6 @@ public function setTimeToLive(?int $timeToLive = null): Producer /** * Get time to live - * - * @return int|null */ public function getTimeToLive(): ?int { diff --git a/tests/TestCase/QueueManagerTest.php b/tests/TestCase/QueueManagerTest.php index f2fb035..8999eb3 100644 --- a/tests/TestCase/QueueManagerTest.php +++ b/tests/TestCase/QueueManagerTest.php @@ -108,7 +108,7 @@ public function testGetUniqueIdWithDtoClass() $data = ['id' => 7, 'customer' => 'Acme Corp']; $withoutDto = QueueManager::getUniqueId('Example', 'hello', $data); - $withNullDto = QueueManager::getUniqueId('Example', 'hello', $data, null); + $withNullDto = QueueManager::getUniqueId('Example', 'hello', $data); $this->assertSame($withoutDto, $withNullDto, 'omitting dtoClass matches an explicit null'); $withOrderDto = QueueManager::getUniqueId('Example', 'hello', $data, 'App\Dto\OrderDto'); diff --git a/tests/comparisons/JobTask.php b/tests/comparisons/JobTask.php index 822a677..f1e89ae 100644 --- a/tests/comparisons/JobTask.php +++ b/tests/comparisons/JobTask.php @@ -16,7 +16,6 @@ class UploadJob implements JobInterface * Executes logic for UploadJob * * @param \Cake\Queue\Job\Message $message job message - * @return string|null */ public function execute(Message $message): ?string { diff --git a/tests/comparisons/JobTaskWithMaxAttempts.php b/tests/comparisons/JobTaskWithMaxAttempts.php index d8f4c91..ce5f606 100644 --- a/tests/comparisons/JobTaskWithMaxAttempts.php +++ b/tests/comparisons/JobTaskWithMaxAttempts.php @@ -23,7 +23,6 @@ class UploadJob implements JobInterface * Executes logic for UploadJob * * @param \Cake\Queue\Job\Message $message job message - * @return string|null */ public function execute(Message $message): ?string { diff --git a/tests/comparisons/JobTaskWithUnique.php b/tests/comparisons/JobTaskWithUnique.php index f0faf36..2b9aa8a 100644 --- a/tests/comparisons/JobTaskWithUnique.php +++ b/tests/comparisons/JobTaskWithUnique.php @@ -23,7 +23,6 @@ class UploadJob implements JobInterface * Executes logic for UploadJob * * @param \Cake\Queue\Job\Message $message job message - * @return string|null */ public function execute(Message $message): ?string { From 9522b579a72b0a9f14c51e6de4a36754ea3172d7 Mon Sep 17 00:00:00 2001 From: Yevgeny Tomenko Date: Sat, 8 Aug 2026 19:20:57 +0300 Subject: [PATCH 3/8] fix job template to match rector rules --- templates/bake/job.twig | 1 - 1 file changed, 1 deletion(-) diff --git a/templates/bake/job.twig b/templates/bake/job.twig index 7dc6873..7c32b34 100644 --- a/templates/bake/job.twig +++ b/templates/bake/job.twig @@ -49,7 +49,6 @@ class {{ name }}Job implements JobInterface * Executes logic for {{ name }}Job * * @param \Cake\Queue\Job\Message $message job message - * @return string|null */ public function execute(Message $message): ?string { From dc4fbd8e0ac3bcfe195197e4e3738ac614d849a6 Mon Sep 17 00:00:00 2001 From: Yevgeny Tomenko Date: Sat, 8 Aug 2026 19:24:22 +0300 Subject: [PATCH 4/8] bump ramsey/uuid --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3033302..048c932 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "cakephp/cakephp": "^5.4.0", "enqueue/simple-client": "^0.10", "psr/log": "^3.0", - "ramsey/uuid": "^4.2.0" + "ramsey/uuid": "^4.7.0" }, "require-dev": { "cakephp/bake": "^3.5.1", From ba6c91dea49fb77caa913068931de6d4ab31dd99 Mon Sep 17 00:00:00 2001 From: Yevgeny Tomenko Date: Sat, 8 Aug 2026 20:43:41 +0300 Subject: [PATCH 5/8] remove final for DtoManager --- src/Dto/DtoManager.php | 4 ++-- tests/TestCase/Dto/DtoManagerTest.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Dto/DtoManager.php b/src/Dto/DtoManager.php index 8480ff7..a449921 100644 --- a/src/Dto/DtoManager.php +++ b/src/Dto/DtoManager.php @@ -11,7 +11,7 @@ * * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org/) * @link https://cakephp.org CakePHP(tm) Project - * @since 2.0.0 + * @since 3.0.0 * @license https://opensource.org/licenses/MIT MIT License */ namespace Cake\Queue\Dto; @@ -29,7 +29,7 @@ * (cakephp-dto style) and plain DTOs mapped through `Cake\ORM\DtoMapper` (constructor * parameters, nested DTO type-hints and the `#[CollectionOf]` attribute). */ -final class DtoManager +class DtoManager { /** * Serialize a DTO (or array) into an array suitable for queue transport. diff --git a/tests/TestCase/Dto/DtoManagerTest.php b/tests/TestCase/Dto/DtoManagerTest.php index 733fffe..6ddbc21 100644 --- a/tests/TestCase/Dto/DtoManagerTest.php +++ b/tests/TestCase/Dto/DtoManagerTest.php @@ -11,7 +11,7 @@ * * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org/) * @link https://cakephp.org CakePHP(tm) Project - * @since 2.0.0 + * @since 3.0.0 * @license https://opensource.org/licenses/MIT MIT License */ namespace Cake\Queue\Test\TestCase\Dto; From b01bee3fb62b3131deeb1b63887ac4f913b76b7c Mon Sep 17 00:00:00 2001 From: Yevgeny Tomenko Date: Mon, 10 Aug 2026 14:41:09 +0300 Subject: [PATCH 6/8] Require expected class in Message::getDto() Hydrate only the type the job asks for so a tampered queue body cannot choose which class is instantiated. Throw on failure instead of returning null. --- docs/en/jobs.md | 8 ++-- src/Job/Message.php | 32 ++++++++++---- src/QueueManager.php | 8 ++-- tests/TestCase/Job/MessageTest.php | 70 +++++++++++++++++++++++------- tests/test_app/src/Job/DtoJob.php | 3 +- 5 files changed, 89 insertions(+), 32 deletions(-) diff --git a/docs/en/jobs.md b/docs/en/jobs.md index eb80264..d6a3666 100644 --- a/docs/en/jobs.md +++ b/docs/en/jobs.md @@ -104,7 +104,7 @@ $order = new OrderDto(id: 7, customer: 'Acme Corp'); QueueManager::push(ProcessOrderJob::class, $order); ``` -The DTO is serialized into the same JSON-safe array that a plain array payload would produce (via `jsonSerialize()` when the DTO implements `JsonSerializable`, otherwise its public properties), and the DTO's class name travels alongside it so the job can hydrate it back. If you only have an array at the dispatch site but still want the job to receive a typed object, pass the target class via the `dtoClass` option instead: +The DTO is serialized into the same JSON-safe array that a plain array payload would produce (via `jsonSerialize()` when the DTO implements `JsonSerializable`, otherwise its public properties). The DTO's class name is also recorded on the message as metadata (used for `shouldBeUnique` hashing and debugging). If you only have an array at the dispatch site but still want that metadata recorded, pass the class via the `dtoClass` option: ```php QueueManager::push(ProcessOrderJob::class, $data, [ @@ -116,19 +116,19 @@ A plain array push with no `dtoClass` option behaves exactly as before; the mess ### Receiving a DTO in a job -Call `Message::getDto()` to hydrate the payload back into the DTO class it was dispatched with. `getArgument()` keeps returning the raw array, so existing jobs that only read array data are unaffected: +Call `Message::getDto()` with the class your job expects. The expected type comes from your code, not from the message body — that way a tampered queue message cannot choose which class gets instantiated. `getArgument()` keeps returning the raw array: ```php public function execute(Message $message): ?string { - $order = $message->getDto(); // OrderDto, or null if no DTO was dispatched + $order = $message->getDto(OrderDto::class); $id = $message->getArgument('id'); // the raw array is still available return Processor::ACK; } ``` -`getDto()` returns `null` when the message wasn't dispatched with a DTO, and also when the recorded `dtoClass` can no longer be autoloaded (e.g. the class was renamed or removed after the job was queued) — a job can always fall back to `getArgument()` in that case instead of crashing. +If the payload cannot be hydrated into the given class, `getDto()` throws. Jobs that still need to accept legacy array-only messages can catch that exception (or keep using `getArgument()` only) while they migrate. ### Supported DTO classes diff --git a/src/Job/Message.php b/src/Job/Message.php index f32c08d..064ed55 100644 --- a/src/Job/Message.php +++ b/src/Job/Message.php @@ -22,6 +22,7 @@ use Closure; use Interop\Queue\Context; use Interop\Queue\Message as QueueMessage; +use InvalidArgumentException; use JsonSerializable; use RuntimeException; @@ -36,6 +37,11 @@ class Message implements JsonSerializable protected ?object $dto = null; + /** + * @var class-string|null + */ + protected ?string $dtoHydratedAs = null; + /** * @param \Interop\Queue\Message $originalMessage Queue message. * @param \Interop\Queue\Context $context Context. @@ -148,7 +154,10 @@ public function getArgument(mixed $key = null, mixed $default = null): mixed } /** - * Get the DTO class name the message was dispatched with, if any. + * Get the DTO class name recorded on the message body at dispatch time, if any. + * + * This value is metadata for uniqueness hashing and debugging. It is never used + * as the hydration target — pass the expected class to `getDto()` instead. * * @return class-string|null */ @@ -163,22 +172,29 @@ public function getDtoClass(): ?string } /** - * Get the message data hydrated back into a DTO object. + * Hydrate the message data into the expected DTO class. + * + * The class name must come from application code, not from the message body. + * That keeps queue consumers safe if a message is tampered with: only the type + * the job asks for is ever instantiated. * - * Returns `null` when the message was not dispatched with a DTO. + * @template T of object + * @param class-string $dtoClass The DTO class the job expects. + * @return T + * @throws \InvalidArgumentException When `$dtoClass` does not exist or cannot be hydrated. */ - public function getDto(): ?object + public function getDto(string $dtoClass): object { - if ($this->dto !== null) { + if ($this->dto !== null && $this->dtoHydratedAs === $dtoClass) { return $this->dto; } - $dtoClass = $this->getDtoClass(); - if ($dtoClass === null) { - return null; + if (!class_exists($dtoClass)) { + throw new InvalidArgumentException(sprintf('DTO class `%s` does not exist.', $dtoClass)); } $this->dto = DtoManager::deserialize($this->getArgument(), $dtoClass); + $this->dtoHydratedAs = $dtoClass; return $this->dto; } diff --git a/src/QueueManager.php b/src/QueueManager.php index 5337521..1e45c4f 100644 --- a/src/QueueManager.php +++ b/src/QueueManager.php @@ -208,14 +208,16 @@ public static function engine(string $name): SimpleClient * \Cake\Queue\Processor and have the execute method invoked. * @param array|object $data An array of data or a DTO object that will * be passed to the job. When a DTO object is given it is serialized and the class - * name is stored so the job can hydrate it back via `Message::getDto()`. + * name is stored as message metadata (uniqueness / debugging). Jobs must still + * pass the expected class to `Message::getDto()`. * @param array $options An array of options for publishing the job: * - `config` - A queue config name. Defaults to 'default'. * - `delay` - Time (in integer seconds) to delay message, after which it * will be processed. Not all message brokers accept this. Default `null`. - * - `dtoClass` - The DTO class to hydrate the data into on the receiving side. + * - `dtoClass` - Optional DTO class metadata recorded on the message body. * Only needed when `$data` is an array. Ignored when `$data` is already a DTO - * object. Default `null`. + * object. Does not control hydration — the job passes the expected class to + * `Message::getDto()`. Default `null`. * - `expires` - Time (in integer seconds) after which the message expires. * The message will be removed from the queue if this time is exceeded * and it has not been consumed. Default `null`. diff --git a/tests/TestCase/Job/MessageTest.php b/tests/TestCase/Job/MessageTest.php index c10470a..d8c384e 100644 --- a/tests/TestCase/Job/MessageTest.php +++ b/tests/TestCase/Job/MessageTest.php @@ -22,6 +22,7 @@ use Enqueue\Null\NullConnectionFactory; use Enqueue\Null\NullMessage; use Error; +use InvalidArgumentException; use RuntimeException; use TestApp\Dto\OrderDto; use TestApp\Dto\OrderItemDto; @@ -98,7 +99,7 @@ public function testLegacyArguments() } /** - * Test that a DTO dispatched with the message is hydrated on the receiving side. + * Test that a DTO is hydrated into the class the job asks for. * * @return void */ @@ -123,7 +124,7 @@ public function testGetDto() $this->assertSame(OrderDto::class, $message->getDtoClass()); - $dto = $message->getDto(); + $dto = $message->getDto(OrderDto::class); $this->assertInstanceOf(OrderDto::class, $dto); $this->assertSame(7, $dto->id); $this->assertSame('Acme Corp', $dto->customer); @@ -132,8 +133,8 @@ public function testGetDto() $this->assertSame('SKU-1', $dto->items[0]->sku); $this->assertSame(1, $dto->items[1]->quantity); - // The DTO is only hydrated once. - $this->assertSame($dto, $message->getDto()); + // The DTO is only hydrated once for the same expected class. + $this->assertSame($dto, $message->getDto(OrderDto::class)); // The raw data is still accessible as an array. $this->assertSame($parsedBody['data'], $message->getArgument()); @@ -160,22 +161,29 @@ public function testGetDtoWithCreateFromArray() $originalMessage = new NullMessage((string)json_encode($parsedBody)); $message = new Message($originalMessage, $context); - $dto = $message->getDto(); + $dto = $message->getDto(UserDto::class); $this->assertInstanceOf(UserDto::class, $dto); $this->assertSame(3, $dto->id); $this->assertSame('markstory', $dto->username); } /** - * Test that messages without a DTO class do not expose a DTO. + * Test that hydration uses the caller-supplied class even when the body has + * no `dtoClass` metadata (legacy array messages / gradual adoption). * * @return void */ - public function testGetDtoWithoutDtoClass() + public function testGetDtoWithoutDtoClassMetadata() { $parsedBody = [ 'class' => [WelcomeMailer::class, 'welcome'], - 'data' => ['id' => 7], + 'data' => [ + 'id' => 7, + 'customer' => 'Acme Corp', + 'items' => [ + ['sku' => 'SKU-1', 'quantity' => 2], + ], + ], ]; $connectionFactory = new NullConnectionFactory(); $context = $connectionFactory->createContext(); @@ -183,21 +191,28 @@ public function testGetDtoWithoutDtoClass() $message = new Message($originalMessage, $context); $this->assertNull($message->getDtoClass()); - $this->assertNull($message->getDto()); - $this->assertSame(['id' => 7], $message->getArgument()); + + $dto = $message->getDto(OrderDto::class); + $this->assertInstanceOf(OrderDto::class, $dto); + $this->assertSame(7, $dto->id); + $this->assertSame('Acme Corp', $dto->customer); } /** - * Test that a `dtoClass` referencing a class that no longer exists at - * consume time is treated the same as no DTO at all, rather than crashing. + * Test that a tampered / unresolvable `dtoClass` on the body is ignored — + * only the class passed to `getDto()` is instantiated. * * @return void */ - public function testGetDtoWithUnresolvableDtoClass() + public function testGetDtoIgnoresUntrustedBodyDtoClass() { $parsedBody = [ 'class' => [WelcomeMailer::class, 'welcome'], - 'data' => ['id' => 7], + 'data' => [ + 'id' => 7, + 'customer' => 'Acme Corp', + 'items' => [], + ], 'dtoClass' => 'TestApp\Dto\DoesNotExist', ]; $connectionFactory = new NullConnectionFactory(); @@ -206,8 +221,31 @@ public function testGetDtoWithUnresolvableDtoClass() $message = new Message($originalMessage, $context); $this->assertNull($message->getDtoClass()); - $this->assertNull($message->getDto()); - $this->assertSame(['id' => 7], $message->getArgument()); + + $dto = $message->getDto(OrderDto::class); + $this->assertInstanceOf(OrderDto::class, $dto); + $this->assertSame(7, $dto->id); + } + + /** + * Test that requesting a class that cannot be autoloaded throws. + * + * @return void + */ + public function testGetDtoThrowsForMissingExpectedClass() + { + $parsedBody = [ + 'class' => [WelcomeMailer::class, 'welcome'], + 'data' => ['id' => 7], + ]; + $connectionFactory = new NullConnectionFactory(); + $context = $connectionFactory->createContext(); + $originalMessage = new NullMessage((string)json_encode($parsedBody)); + $message = new Message($originalMessage, $context); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('DTO class `TestApp\Dto\DoesNotExist` does not exist.'); + $message->getDto('TestApp\Dto\DoesNotExist'); } /** diff --git a/tests/test_app/src/Job/DtoJob.php b/tests/test_app/src/Job/DtoJob.php index 11a1ae7..7fabbcb 100644 --- a/tests/test_app/src/Job/DtoJob.php +++ b/tests/test_app/src/Job/DtoJob.php @@ -6,6 +6,7 @@ use Cake\Queue\Job\JobInterface; use Cake\Queue\Job\Message; use Interop\Queue\Processor; +use TestApp\Dto\OrderDto; class DtoJob implements JobInterface { @@ -13,7 +14,7 @@ class DtoJob implements JobInterface public function execute(Message $message): ?string { - static::$lastDto = $message->getDto(); + static::$lastDto = $message->getDto(OrderDto::class); return Processor::ACK; } From 0a72800fde9c44f766418330df825f6ca29754e8 Mon Sep 17 00:00:00 2001 From: Yevgeny Tomenko Date: Mon, 10 Aug 2026 18:17:07 +0300 Subject: [PATCH 7/8] Add typed docblocks and asserts. --- src/Dto/DtoManager.php | 10 +++++++--- src/Job/Message.php | 9 +++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Dto/DtoManager.php b/src/Dto/DtoManager.php index a449921..437d6a1 100644 --- a/src/Dto/DtoManager.php +++ b/src/Dto/DtoManager.php @@ -62,9 +62,10 @@ public static function serialize(array|object $data): array /** * Hydrate queue data back into a DTO instance. * + * @template T of object * @param array $data Serialized data. - * @param class-string $dtoClass DTO class name. - * @return object Hydrated DTO instance. + * @param class-string $dtoClass DTO class name. + * @return T Hydrated DTO instance. * @throws \InvalidArgumentException When the DTO class does not exist. */ public static function deserialize(array $data, string $dtoClass): object @@ -73,7 +74,10 @@ public static function deserialize(array $data, string $dtoClass): object throw new InvalidArgumentException(sprintf('DTO class `%s` does not exist.', $dtoClass)); } - return (new ResultSetFactory())->hydrateDto($data, $dtoClass); + $dto = (new ResultSetFactory())->hydrateDto($data, $dtoClass); + assert($dto instanceof $dtoClass); + + return $dto; } /** diff --git a/src/Job/Message.php b/src/Job/Message.php index 064ed55..521351d 100644 --- a/src/Job/Message.php +++ b/src/Job/Message.php @@ -186,6 +186,8 @@ public function getDtoClass(): ?string public function getDto(string $dtoClass): object { if ($this->dto !== null && $this->dtoHydratedAs === $dtoClass) { + assert($this->dto instanceof $dtoClass); + return $this->dto; } @@ -193,10 +195,13 @@ public function getDto(string $dtoClass): object throw new InvalidArgumentException(sprintf('DTO class `%s` does not exist.', $dtoClass)); } - $this->dto = DtoManager::deserialize($this->getArgument(), $dtoClass); + $dto = DtoManager::deserialize($this->getArgument(), $dtoClass); + assert($dto instanceof $dtoClass); + + $this->dto = $dto; $this->dtoHydratedAs = $dtoClass; - return $this->dto; + return $dto; } /** From 111ae42c7bfc4ec8efd75a39b8f968724e3955cc Mon Sep 17 00:00:00 2001 From: Evgeny Tomenko Date: Thu, 13 Aug 2026 22:44:53 +0300 Subject: [PATCH 8/8] Update src/Dto/DtoManager.php Co-authored-by: Mark Story --- src/Dto/DtoManager.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Dto/DtoManager.php b/src/Dto/DtoManager.php index 437d6a1..56bb070 100644 --- a/src/Dto/DtoManager.php +++ b/src/Dto/DtoManager.php @@ -62,6 +62,7 @@ public static function serialize(array|object $data): array /** * Hydrate queue data back into a DTO instance. * + * This method is not safe to use with user-defined `dtoClass` values. * @template T of object * @param array $data Serialized data. * @param class-string $dtoClass DTO class name.