diff --git a/docs/guide/runtime-api.md b/docs/guide/runtime-api.md index 7e479a5..5d6ca48 100644 --- a/docs/guide/runtime-api.md +++ b/docs/guide/runtime-api.md @@ -245,6 +245,118 @@ 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. 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 + +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. 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 + +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); +``` + ## Transformers ### `TransformerRegistry` diff --git a/src/Dto/Dto.php b/src/Dto/Dto.php index 7e08afa..3cbefbf 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\Transformer\TransformerRegistry; use PhpCollective\Dto\Utility\Json; use ReflectionProperty; @@ -132,6 +133,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..c3b300a --- /dev/null +++ b/src/Mapper.php @@ -0,0 +1,172 @@ +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)) { + self::assertAssociative($source, 'array'); + + 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)) { + self::assertAssociative($data, 'JsonSerializable payload'); + + 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.', + ); + } + self::assertAssociative($decoded, 'JSON string'); + + 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), + )); + } + + /** + * 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 new file mode 100644 index 0000000..4d34e2f --- /dev/null +++ b/src/ObjectFactory.php @@ -0,0 +1,125 @@ +|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. + * + * 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 + */ + public function only(array $fields) + { + $this->only = $fields; + + return $this; + } + + /** + * Hydrate the target DTO class from the configured source. + * + * 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 string $target Fully-qualified DTO subclass name. + * + * @throws \InvalidArgumentException If the target is not a DTO class. + * + * @return \PhpCollective\Dto\Dto\Dto + */ + public function to(string $target): Dto + { + if (!is_a($target, Dto::class, true)) { + 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..3a41d71 --- /dev/null +++ b/tests/MapperTest.php @@ -0,0 +1,277 @@ + '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): self + { + return new self(); + } + + 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()); + } + + 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); + } +}