From de769d25afc193e2c85a492576126cbbe779e26e Mon Sep 17 00:00:00 2001 From: mscherer Date: Sat, 11 Apr 2026 10:53:39 +0200 Subject: [PATCH 1/4] Add Mapper facade and Dto::from() shortcut for heterogeneous sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces PhpCollective\Dto\Mapper and PhpCollective\Dto\ObjectFactory so callers can hydrate a DTO from any supported source through a single entry point instead of picking between createFromArray, fromUnserialized and new + fromArray per source shape. Sources recognized out of the box: - array - Dto instance (copied via touchedToArray so only set fields propagate) - FromArrayToArrayInterface implementor - JsonSerializable implementor - JSON string - plain object (public properties via get_object_vars) Fluent modifiers on ObjectFactory cover the less common cases: - ignoreMissing(bool) - withKeyType(?string) for dashed/underscored source keys - only(array) for partial hydration Dto::from() is a typed shortcut that delegates to the same extraction and returns static, so static analysers infer the concrete DTO type without template annotations — ideal for the common request -> DTO case: $user = UserDto::from($request->getParsedBody()); Includes tests covering all supported sources, each modifier, error paths (unsupported source, invalid JSON, non-DTO target, JsonSerializable scalar), the Dto::from() shortcut, and the shared Mapper::toArray() helper. Adds a "Mapping From Arbitrary Sources" section to the runtime API guide covering the facade, the shortcut, sources table, DTO-to-DTO copy semantics, and framework examples. --- docs/guide/runtime-api.md | 108 +++++++++++++++++++ src/Dto/Dto.php | 27 +++++ src/Mapper.php | 129 +++++++++++++++++++++++ src/ObjectFactory.php | 119 +++++++++++++++++++++ tests/MapperTest.php | 211 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 594 insertions(+) create mode 100644 src/Mapper.php create mode 100644 src/ObjectFactory.php create mode 100644 tests/MapperTest.php diff --git a/docs/guide/runtime-api.md b/docs/guide/runtime-api.md index ace3c49..5ef2541 100644 --- a/docs/guide/runtime-api.md +++ b/docs/guide/runtime-api.md @@ -245,6 +245,114 @@ Dto::setCollectionFactory(fn (array $items) => collect($items)); This is useful for framework-native collection classes in Laravel, CakePHP, or Symfony integrations. +## Mapping From Arbitrary Sources + +Generated DTOs expose typed factories (`createFromArray`, `fromUnserialized`) and +a constructor that takes an array. When your source could be any of several +shapes — a request payload, another DTO, a plain object, a JSON string — the +`Mapper` facade and the `Dto::from()` shortcut provide a single entry point so +the caller doesn't have to pick the right method per source. + +### `Dto::from()` — typed shortcut + +For the common DTO-in, DTO-out case, call `from()` directly on the target DTO. +It returns `static`, so the concrete DTO type is inferred without template +annotations and your IDE auto-completes methods on the result: + +```php +use App\Dto\UserDto; + +$user = UserDto::from($request->getParsedBody()); +$copy = UserDto::from($existingUserDto); +$fromJson = UserDto::from('{"name":"Alice","email":"a@example.com"}'); +``` + +`Dto::from()` defaults to `ignoreMissing = true` so request payloads with +extra keys pass through without pre-filtering. If you need strict mode or +other modifiers, use the `Mapper::map()` facade instead. + +### `Mapper::map()` — fluent facade + +```php +use PhpCollective\Dto\Dto\Dto; +use PhpCollective\Dto\Mapper; + +$dto = Mapper::map($source) + ->ignoreMissing(false) + ->withKeyType(Dto::TYPE_UNDERSCORED) + ->only(['name', 'email']) + ->to(UserDto::class); +``` + +Modifiers: + +| Method | Default | Purpose | +|--------|---------|---------| +| `ignoreMissing(bool $ignore = true)` | `true` | Silently drop unknown source keys. | +| `withKeyType(?string $type)` | `null` | Declare the inflection of the source keys (`TYPE_UNDERSCORED`, `TYPE_DASHED`, ...). | +| `only(array $fields)` | `null` | Hydrate only the listed fields. | + +### Supported sources + +Both `Dto::from()` and `Mapper::map()` accept: + +| Source | Extraction | +|--------|------------| +| `array` | pass-through | +| `Dto` instance | `touchedToArray()` — only fields that were set | +| `FromArrayToArrayInterface` implementor | `toArray()` | +| `JsonSerializable` implementor | `jsonSerialize()` (must return an array or object) | +| JSON `string` | `json_decode(..., true)` | +| plain `object` | `get_object_vars()` — public properties only | + +Unsupported sources (numbers, booleans, unparseable strings, resources) throw +`InvalidArgumentException` with a descriptive message. + +### DTO-to-DTO copies + +Because DTO sources extract via `touchedToArray()`, copying only propagates +fields that were actually set on the source. Fields left untouched remain +`null` on the copy and the copy's own `touchedFields()` reflects only what was +carried over: + +```php +$source = new UserDto(); +$source->setName('Alice'); // only 'name' is touched + +$copy = UserDto::from($source); +$copy->getName(); // 'Alice' +$copy->getEmail(); // null — never set on source +``` + +### Framework examples + +**CakePHP controller:** + +```php +public function add(): ?Response +{ + $user = UserDto::from($this->request->getData()); + // ... use $user as a typed payload +} +``` + +**Laravel form request:** + +```php +public function store(CreateUserRequest $request) +{ + $user = UserDto::from($request->validated()); +} +``` + +**Query string with dashed keys:** + +```php +$filters = Mapper::map($request->getQueryParams()) + ->withKeyType(Dto::TYPE_DASHED) + ->to(FilterDto::class); +``` + ### Resetting Global Runtime State Because collection factories and default key types are static global settings, tests should reset them explicitly: diff --git a/src/Dto/Dto.php b/src/Dto/Dto.php index d2d31e6..003a4bf 100644 --- a/src/Dto/Dto.php +++ b/src/Dto/Dto.php @@ -9,6 +9,7 @@ use Countable; use InvalidArgumentException; use JsonSerializable; +use PhpCollective\Dto\Mapper; use PhpCollective\Dto\Utility\Json; use ReflectionProperty; use RuntimeException; @@ -131,6 +132,32 @@ public static function fromUnserialized(string $data, bool $ignoreMissing = fals return new static($jsonUtil->decode($data, true), $ignoreMissing); } + /** + * Typed shortcut for `Mapper::map($source)->to(static::class)`. + * + * Accepts any source supported by `Mapper::toArray()` — array, DTO, + * `FromArrayToArrayInterface`, `JsonSerializable`, JSON string, or plain + * object — and returns an instance of the concrete DTO subclass `from` + * is called on. The return type is `static`, so static analysers infer + * the exact DTO class without template annotations: + * + * ```php + * $user = UserDto::from($request->getParsedBody()); + * $copy = UserDto::from($otherUserDto); + * ``` + * + * For fluent modifiers (`ignoreMissing(false)`, `withKeyType(...)`, + * `only([...])`) use the `Mapper::map()` facade instead. + * + * @param mixed $source + * + * @return static + */ + public static function from(mixed $source): static + { + return static::createFromArray(Mapper::toArray($source), true); + } + /** * Convenience wrapper for easier chaining. * diff --git a/src/Mapper.php b/src/Mapper.php new file mode 100644 index 0000000..7e196de --- /dev/null +++ b/src/Mapper.php @@ -0,0 +1,129 @@ +to(Target::class)` call and the facade + * figures out how to extract an array payload from the source. + * + * Supported sources out of the box: + * - `array` + * - `PhpCollective\Dto\Dto\Dto` instance (uses `touchedToArray()`) + * - `PhpCollective\Dto\Dto\FromArrayToArrayInterface` implementor + * - `JsonSerializable` implementor + * - JSON `string` + * - any plain `object` (via `get_object_vars()`) + * + * Example: + * ```php + * use PhpCollective\Dto\Mapper; + * + * $dto = Mapper::map($request->getParsedBody())->to(UserDto::class); + * $copy = Mapper::map($existingDto)->to(UserDto::class); + * $strict = Mapper::map($jsonString) + * ->ignoreMissing(false) + * ->withKeyType(Dto::TYPE_UNDERSCORED) + * ->to(UserDto::class); + * ``` + * + * For the common DTO-in, DTO-out case prefer the typed shortcut on the target + * DTO: `UserDto::from($source)`. It returns `static`, so PHPStan infers the + * concrete DTO type without template annotations. + */ +final class Mapper +{ + /** + * Begin a fluent mapping from `$source`. + * + * @param mixed $source + * + * @return \PhpCollective\Dto\ObjectFactory + */ + public static function map(mixed $source): ObjectFactory + { + return new ObjectFactory($source); + } + + /** + * Extract an array payload from an arbitrary supported source. + * + * Exposed as a static helper so `Dto::from()` and `ObjectFactory::to()` + * share the same detection logic. + * + * @param mixed $source + * + * @throws \InvalidArgumentException If the source shape is not supported. + * + * @return array + */ + public static function toArray(mixed $source): array + { + if (is_array($source)) { + return $source; + } + + if ($source instanceof Dto) { + return $source->touchedToArray(); + } + + if ($source instanceof FromArrayToArrayInterface) { + return $source->toArray(); + } + + if ($source instanceof JsonSerializable) { + $data = $source->jsonSerialize(); + if (is_array($data)) { + return $data; + } + if (is_object($data)) { + return get_object_vars($data); + } + + throw new InvalidArgumentException(sprintf( + 'JsonSerializable source returned unsupported payload of type "%s".', + get_debug_type($data), + )); + } + + if (is_string($source)) { + try { + $decoded = (new Json())->decode($source, true); + } catch (Throwable $e) { + throw new InvalidArgumentException( + 'String source could not be decoded as JSON: ' . $e->getMessage(), + 0, + $e, + ); + } + if (!is_array($decoded)) { + throw new InvalidArgumentException( + 'String source could not be decoded as a JSON object into an array.', + ); + } + + return $decoded; + } + + if (is_object($source)) { + return get_object_vars($source); + } + + throw new InvalidArgumentException(sprintf( + 'Cannot map source of type "%s" — expected array, object, or JSON string.', + get_debug_type($source), + )); + } +} diff --git a/src/ObjectFactory.php b/src/ObjectFactory.php new file mode 100644 index 0000000..3c15d2b --- /dev/null +++ b/src/ObjectFactory.php @@ -0,0 +1,119 @@ +|null + */ + private ?array $only = null; + + /** + * @param mixed $source + */ + public function __construct(private mixed $source) + { + } + + /** + * When `true` (the default), extra keys in the source that do not match + * any DTO field are silently ignored. When `false`, unknown keys cause + * the target DTO to raise an exception via its standard validation. + * + * @param bool $ignore + * + * @return $this + */ + public function ignoreMissing(bool $ignore = true) + { + $this->ignoreMissing = $ignore; + + return $this; + } + + /** + * Tell the target DTO which inflection the source keys use, so fields + * named `my_field` / `my-field` / `myField` are matched correctly. + * + * @param string|null $type One of `Dto::TYPE_DEFAULT`, `Dto::TYPE_CAMEL`, + * `Dto::TYPE_UNDERSCORED`, `Dto::TYPE_DASHED`. + * + * @return $this + */ + public function withKeyType(?string $type) + { + $this->keyType = $type; + + return $this; + } + + /** + * Restrict the hydration to only the given field names. Useful for + * partial updates where the source contains more data than you want + * to apply to the target. + * + * @param array $fields + * + * @return $this + */ + public function only(array $fields) + { + $this->only = $fields; + + return $this; + } + + /** + * Hydrate the target DTO class from the configured source. + * + * @template T of \PhpCollective\Dto\Dto\Dto + * + * @param class-string $target + * + * @throws \InvalidArgumentException If the target is not a DTO class. + * + * @return T + */ + public function to(string $target): Dto + { + // Runtime guard: the template type is only enforced statically. + // Callers passing an unrelated class name at runtime hit this branch. + /** @phpstan-ignore function.alreadyNarrowedType */ + if (!is_subclass_of($target, Dto::class)) { + throw new InvalidArgumentException(sprintf( + 'Target "%s" must be a subclass of %s.', + $target, + Dto::class, + )); + } + + $data = Mapper::toArray($this->source); + + if ($this->only !== null) { + $data = array_intersect_key($data, array_flip($this->only)); + } + + return $target::createFromArray($data, $this->ignoreMissing, $this->keyType); + } +} diff --git a/tests/MapperTest.php b/tests/MapperTest.php new file mode 100644 index 0000000..85cf5ed --- /dev/null +++ b/tests/MapperTest.php @@ -0,0 +1,211 @@ + 'Test']); + $this->assertInstanceOf(ObjectFactory::class, $factory); + } + + public function testMapArrayToDto(): void + { + $dto = Mapper::map(['name' => 'Test', 'count' => 5])->to(SimpleDto::class); + $this->assertInstanceOf(SimpleDto::class, $dto); + $this->assertSame('Test', $dto->getName()); + $this->assertSame(5, $dto->getCount()); + } + + public function testMapDtoToDtoCopiesTouchedFields(): void + { + $source = new SimpleDto(); + $source->setName('Copy'); + + $copy = Mapper::map($source)->to(SimpleDto::class); + + $this->assertNotSame($source, $copy); + $this->assertSame('Copy', $copy->getName()); + $this->assertNull($copy->getCount(), 'Untouched fields must not leak into the copy'); + } + + public function testMapJsonStringToDto(): void + { + $dto = Mapper::map('{"name":"Json","count":42}')->to(SimpleDto::class); + $this->assertSame('Json', $dto->getName()); + $this->assertSame(42, $dto->getCount()); + } + + public function testMapJsonSerializableToDto(): void + { + $source = new class implements JsonSerializable { + public function jsonSerialize(): array + { + return ['name' => 'JsonSerializable', 'count' => 7]; + } + }; + + $dto = Mapper::map($source)->to(SimpleDto::class); + $this->assertSame('JsonSerializable', $dto->getName()); + $this->assertSame(7, $dto->getCount()); + } + + public function testMapFromArrayToArrayInterfaceSource(): void + { + $source = new class implements FromArrayToArrayInterface { + public static function createFromArray(array $array): static + { + return new static(); + } + + public function toArray(): array + { + return ['name' => 'Interface', 'count' => 3]; + } + }; + + $dto = Mapper::map($source)->to(SimpleDto::class); + $this->assertSame('Interface', $dto->getName()); + $this->assertSame(3, $dto->getCount()); + } + + public function testMapPlainObjectToDto(): void + { + $source = new stdClass(); + $source->name = 'Plain'; + $source->count = 9; + + $dto = Mapper::map($source)->to(SimpleDto::class); + $this->assertSame('Plain', $dto->getName()); + $this->assertSame(9, $dto->getCount()); + } + + public function testMapWithKeyTypeUnderscored(): void + { + $dto = Mapper::map(['plain_data' => 'underscore-source']) + ->withKeyType(Dto::TYPE_UNDERSCORED) + ->to(AdvancedDto::class); + + $this->assertSame('underscore-source', $dto->getPlainData()?->value); + } + + public function testMapWithOnlyFiltersFields(): void + { + $dto = Mapper::map(['name' => 'Filtered', 'count' => 99]) + ->only(['name']) + ->to(SimpleDto::class); + + $this->assertSame('Filtered', $dto->getName()); + $this->assertNull($dto->getCount(), 'Fields outside only() must not be hydrated'); + } + + public function testMapWithIgnoreMissingFalseRejectsUnknownKeys(): void + { + $this->expectException(InvalidArgumentException::class); + + Mapper::map(['name' => 'X', 'not_a_field' => 'nope']) + ->ignoreMissing(false) + ->to(SimpleDto::class); + } + + public function testMapDefaultsToIgnoreMissingTrue(): void + { + $dto = Mapper::map(['name' => 'OK', 'extra_unknown_key' => 'ignored']) + ->to(SimpleDto::class); + + $this->assertSame('OK', $dto->getName()); + } + + public function testMapToNonDtoTargetThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must be a subclass of'); + + Mapper::map(['name' => 'X'])->to(stdClass::class); + } + + public function testMapUnsupportedSourceThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot map source of type'); + + Mapper::map(42)->to(SimpleDto::class); + } + + public function testMapInvalidJsonStringThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('could not be decoded'); + + Mapper::map('not-valid-json')->to(SimpleDto::class); + } + + public function testMapJsonSerializableReturningScalarThrows(): void + { + $source = new class implements JsonSerializable { + public function jsonSerialize(): string + { + return 'scalar-instead-of-array'; + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('unsupported payload'); + + Mapper::map($source)->to(SimpleDto::class); + } + + public function testMapperToArrayHelperIsReusable(): void + { + $array = Mapper::toArray(['a' => 1]); + $this->assertSame(['a' => 1], $array); + + $dto = new SimpleDto(); + $dto->setName('helper'); + $this->assertSame(['name' => 'helper'], Mapper::toArray($dto)); + } + + public function testDtoFromShortcutReturnsTypedInstance(): void + { + $dto = SimpleDto::from(['name' => 'Shortcut', 'count' => 1]); + $this->assertInstanceOf(SimpleDto::class, $dto); + $this->assertSame('Shortcut', $dto->getName()); + } + + public function testDtoFromShortcutAcceptsAnotherDto(): void + { + $source = new SimpleDto(); + $source->setName('From'); + + $copy = SimpleDto::from($source); + $this->assertSame('From', $copy->getName()); + } + + public function testDtoFromShortcutAcceptsJsonString(): void + { + $dto = SimpleDto::from('{"name":"json-shortcut"}'); + $this->assertSame('json-shortcut', $dto->getName()); + } + + public function testDtoFromShortcutIgnoresUnknownKeys(): void + { + // The shortcut defaults to ignoreMissing = true so callers can feed + // arbitrary request payloads without pre-filtering. + $dto = SimpleDto::from(['name' => 'X', 'bogus_field' => 'ignored']); + $this->assertSame('X', $dto->getName()); + } +} From 12ca106f4a014107f4c19505bd8f616b8dbf17d2 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sat, 11 Apr 2026 10:56:19 +0200 Subject: [PATCH 2/4] Drop template annotation on ObjectFactory::to() to avoid PHPStan narrowing The @template T + class-string annotation caused PHPStan to narrow the runtime is_a() guard to always-true on the CI version, breaking the build. The runtime check is still necessary to reject unrelated class names passed as strings. Users wanting static return-type inference should use the Dto::from() shortcut on the target DTO class, which returns static and works without any template annotation at the call site. The Mapper::map() fluent facade now returns the abstract Dto type; callers can assert or assign to a typed variable if they need the concrete class. --- src/ObjectFactory.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/ObjectFactory.php b/src/ObjectFactory.php index 3c15d2b..25e2e72 100644 --- a/src/ObjectFactory.php +++ b/src/ObjectFactory.php @@ -87,20 +87,19 @@ public function only(array $fields) /** * Hydrate the target DTO class from the configured source. * - * @template T of \PhpCollective\Dto\Dto\Dto + * For static return-type inference, prefer the `Dto::from()` shortcut on + * the target DTO class — it returns `static` and does not need template + * annotations at the call site. * - * @param class-string $target + * @param string $target Fully-qualified DTO subclass name. * * @throws \InvalidArgumentException If the target is not a DTO class. * - * @return T + * @return \PhpCollective\Dto\Dto\Dto */ public function to(string $target): Dto { - // Runtime guard: the template type is only enforced statically. - // Callers passing an unrelated class name at runtime hit this branch. - /** @phpstan-ignore function.alreadyNarrowedType */ - if (!is_subclass_of($target, Dto::class)) { + if (!is_a($target, Dto::class, true)) { throw new InvalidArgumentException(sprintf( 'Target "%s" must be a subclass of %s.', $target, From ec16565859a6e6de11226041480728edf6881083 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sat, 11 Apr 2026 10:57:27 +0200 Subject: [PATCH 3/4] Use self instead of static in anonymous class for final-construct CS rule --- tests/MapperTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/MapperTest.php b/tests/MapperTest.php index 85cf5ed..3de6fe8 100644 --- a/tests/MapperTest.php +++ b/tests/MapperTest.php @@ -67,9 +67,9 @@ public function jsonSerialize(): array public function testMapFromArrayToArrayInterfaceSource(): void { $source = new class implements FromArrayToArrayInterface { - public static function createFromArray(array $array): static + public static function createFromArray(array $array): self { - return new static(); + return new self(); } public function toArray(): array From b7f0d92a66babd9d68f97627df5ec88c16347373 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sat, 11 Apr 2026 11:50:13 +0200 Subject: [PATCH 4/4] Reject list-shaped payloads and clarify only() source-key semantics Addresses review feedback on the Mapper facade: - Mapper::toArray() now rejects list arrays (and arrays with non-string keys) at the entry point instead of letting them reach Dto::hasField() where they would produce a confusing TypeError. The new assertAssociative() helper is applied to the array branch, the JSON string branch, and the JsonSerializable-returning-array branch. Empty arrays are allowed since they are ambiguous and hydrate to a default DTO. - ObjectFactory::only() docblock and the runtime API guide now make it explicit that the filter operates on source keys as they appear in the input, not on canonical DTO field names. When combined with withKeyType() callers must pass the inflected source form. Adds tests for list arrays, JSON list strings, integer-keyed arrays, JsonSerializable payloads that return lists, empty arrays, and the only() source-key semantics with TYPE_UNDERSCORED. --- docs/guide/runtime-api.md | 8 +++-- src/Mapper.php | 43 +++++++++++++++++++++++++ src/ObjectFactory.php | 7 +++++ tests/MapperTest.php | 66 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 2 deletions(-) diff --git a/docs/guide/runtime-api.md b/docs/guide/runtime-api.md index 674f3a3..5d6ca48 100644 --- a/docs/guide/runtime-api.md +++ b/docs/guide/runtime-api.md @@ -290,7 +290,7 @@ Modifiers: |--------|---------|---------| | `ignoreMissing(bool $ignore = true)` | `true` | Silently drop unknown source keys. | | `withKeyType(?string $type)` | `null` | Declare the inflection of the source keys (`TYPE_UNDERSCORED`, `TYPE_DASHED`, ...). | -| `only(array $fields)` | `null` | Hydrate only the listed fields. | +| `only(array $fields)` | `null` | Hydrate only the listed fields. Matched against **source keys as they appear in the input** (not against canonical DTO field names), so with `withKeyType(TYPE_UNDERSCORED)` you pass `['first_name']`, not `['firstName']`. | ### Supported sources @@ -306,7 +306,11 @@ Both `Dto::from()` and `Mapper::map()` accept: | plain `object` | `get_object_vars()` — public properties only | Unsupported sources (numbers, booleans, unparseable strings, resources) throw -`InvalidArgumentException` with a descriptive message. +`InvalidArgumentException` with a descriptive message. The same applies to +**list arrays** (e.g. `[1, 2, 3]` or JSON `"[1, 2]"`): DTO hydration operates +on associative `array` payloads, so list-shaped inputs are +rejected up front rather than producing a confusing `TypeError` deeper in the +hydration stack. ### DTO-to-DTO copies diff --git a/src/Mapper.php b/src/Mapper.php index 7e196de..c3b300a 100644 --- a/src/Mapper.php +++ b/src/Mapper.php @@ -72,6 +72,8 @@ public static function map(mixed $source): ObjectFactory public static function toArray(mixed $source): array { if (is_array($source)) { + self::assertAssociative($source, 'array'); + return $source; } @@ -86,6 +88,8 @@ public static function toArray(mixed $source): array if ($source instanceof JsonSerializable) { $data = $source->jsonSerialize(); if (is_array($data)) { + self::assertAssociative($data, 'JsonSerializable payload'); + return $data; } if (is_object($data)) { @@ -113,6 +117,7 @@ public static function toArray(mixed $source): array 'String source could not be decoded as a JSON object into an array.', ); } + self::assertAssociative($decoded, 'JSON string'); return $decoded; } @@ -126,4 +131,42 @@ public static function toArray(mixed $source): array get_debug_type($source), )); } + + /** + * Reject list arrays (e.g. `[1, 2, 3]` or JSON `"[1, 2]"`). DTO hydration + * operates on associative `array` payloads and would later + * produce a confusing `TypeError` from `Dto::hasField(string)` if given an + * integer-keyed sequence. Failing here yields a clearer error at the + * actual misuse site. + * + * @param array $data + * @param string $sourceDescription Human-readable source label for the message. + * + * @throws \InvalidArgumentException If `$data` is a list or has any non-string keys. + * + * @return void + */ + private static function assertAssociative(array $data, string $sourceDescription): void + { + if ($data === []) { + return; + } + + if (array_is_list($data)) { + throw new InvalidArgumentException(sprintf( + 'Cannot map %s: expected an associative array keyed by field names, got a list.', + $sourceDescription, + )); + } + + foreach ($data as $key => $_) { + if (!is_string($key)) { + throw new InvalidArgumentException(sprintf( + 'Cannot map %s: all keys must be strings, got "%s".', + $sourceDescription, + get_debug_type($key), + )); + } + } + } } diff --git a/src/ObjectFactory.php b/src/ObjectFactory.php index 25e2e72..4d34e2f 100644 --- a/src/ObjectFactory.php +++ b/src/ObjectFactory.php @@ -73,6 +73,13 @@ public function withKeyType(?string $type) * partial updates where the source contains more data than you want * to apply to the target. * + * NOTE: `$fields` is matched against the **source keys as they appear + * in the input**, not against the canonical DTO field names. When + * combined with `withKeyType()` you must pass the inflected source + * form — e.g. with `withKeyType(Dto::TYPE_UNDERSCORED)` pass + * `['first_name']`, not `['firstName']`. Filtering happens before + * key-type normalization so the payload contract stays predictable. + * * @param array $fields * * @return $this diff --git a/tests/MapperTest.php b/tests/MapperTest.php index 3de6fe8..3a41d71 100644 --- a/tests/MapperTest.php +++ b/tests/MapperTest.php @@ -208,4 +208,70 @@ public function testDtoFromShortcutIgnoresUnknownKeys(): void $dto = SimpleDto::from(['name' => 'X', 'bogus_field' => 'ignored']); $this->assertSame('X', $dto->getName()); } + + public function testMapRejectsListArray(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('expected an associative array'); + + Mapper::map([1, 2, 3])->to(SimpleDto::class); + } + + public function testMapRejectsJsonListString(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('expected an associative array'); + + Mapper::map('[1, 2, 3]')->to(SimpleDto::class); + } + + public function testMapRejectsArrayWithIntegerKeys(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('all keys must be strings'); + + // Not a list (gap in keys) but still has int keys — reject. + Mapper::map([0 => 'a', 2 => 'b'])->to(SimpleDto::class); + } + + public function testMapRejectsJsonSerializableReturningList(): void + { + $source = new class implements JsonSerializable { + public function jsonSerialize(): array + { + return [1, 2, 3]; + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('expected an associative array'); + + Mapper::map($source)->to(SimpleDto::class); + } + + public function testMapAcceptsEmptyArray(): void + { + // Empty array is ambiguous (both list and associative) — allow it. + $dto = Mapper::map([])->to(SimpleDto::class); + $this->assertNull($dto->getName()); + } + + public function testOnlyMatchesSourceKeysBeforeKeyTypeInflection(): void + { + // With withKeyType(TYPE_UNDERSCORED) the source keys are underscored, + // and only() filters on those raw source keys — NOT on camelCase DTO + // field names. Passing 'plainData' filters everything out; passing + // 'plain_data' correctly selects the field. + $dtoWrongKey = Mapper::map(['plain_data' => 'x']) + ->withKeyType(Dto::TYPE_UNDERSCORED) + ->only(['plainData']) + ->to(AdvancedDto::class); + $this->assertNull($dtoWrongKey->getPlainData()); + + $dtoRightKey = Mapper::map(['plain_data' => 'x']) + ->withKeyType(Dto::TYPE_UNDERSCORED) + ->only(['plain_data']) + ->to(AdvancedDto::class); + $this->assertSame('x', $dtoRightKey->getPlainData()?->value); + } }