Skip to content

Add Mapper facade and Dto::from() shortcut for heterogeneous sources#111

Merged
dereuromark merged 5 commits into
masterfrom
feature/mapper-facade
Apr 11, 2026
Merged

Add Mapper facade and Dto::from() shortcut for heterogeneous sources#111
dereuromark merged 5 commits into
masterfrom
feature/mapper-facade

Conversation

@dereuromark
Copy link
Copy Markdown
Contributor

@dereuromark dereuromark commented Apr 11, 2026

Summary

Introduces a unified entry point for hydrating DTOs from any supported source shape, removing the need for callers to pick between createFromArray, fromUnserialized, and new + fromArray depending on whether the input is an array, a JSON string, another DTO, or a plain object.

Two complementary APIs:

1. Dto::from() — typed shortcut (common case)

use App\Dto\UserDto;

$user = UserDto::from($request->getParsedBody());
$copy = UserDto::from($existingUserDto);
$fromJson = UserDto::from('{"name":"Alice"}');

Returns static, so PHPStan and IDEs infer the concrete DTO type at every call site without any template annotations. Defaults to ignoreMissing = true so controllers can feed raw request payloads without pre-filtering. This is the recommended entry point when the target type is fixed.

2. Mapper::map() — fluent facade (edge cases)

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);

ObjectFactory::to() returns the abstract Dto type (no template annotation — see Design notes below). Callers wanting a typed return from the facade form should either use the Dto::from() shortcut or annotate the assignment locally.

Sources recognized

Both entry points extract an array payload via Mapper::toArray():

Source Extraction
array pass-through (list arrays rejected)
Dto instance touchedToArray() — only fields that were set propagate
FromArrayToArrayInterface implementor toArray()
JsonSerializable implementor jsonSerialize() coerced to array (list arrays rejected)
JSON string Json::decode(..., true) (list arrays rejected, decode errors wrapped)
plain object get_object_vars() — public props

List-shaped payloads (e.g. [1, 2, 3] or JSON "[1, 2]") and arrays with non-string keys are rejected up front with a clear InvalidArgumentException instead of producing a confusing TypeError deeper in Dto::hasField(string). Empty arrays are allowed and hydrate to a default DTO.

Design notes

  • DTO→DTO copies use touchedToArray(), not toArray(). Only fields the source actually set propagate to the copy. Untouched fields remain null on the copy and absent from its touchedFields(). This matches the semantics users expect from "copy this DTO" and avoids accidentally widening the set of fields marked dirty downstream.
  • ObjectFactory modifiers default to ergonomic values. ignoreMissing = true, keyType = null, only = null — chaining is only needed when deviating.
  • only() filters on source keys, not canonical DTO field names. When combined with withKeyType() callers must pass the inflected source form (e.g. ['first_name'] with TYPE_UNDERSCORED, not ['firstName']). Filtering happens before key-type normalization so the payload contract stays predictable. Documented in the method PHPDoc and the runtime API guide.
  • No template annotation on ObjectFactory::to(). An initial revision used @template T of Dto + class-string<T> for typed returns, but CI's PHPStan narrowed the runtime is_a() guard to always-true and failed the build. Dropping the template keeps the runtime safety check and pushes typed-return users toward Dto::from(), which returns static natively.
  • No PSR-7 detection in v1. Keeping the PR dependency-free. Callers with PSR-7 requests can do Mapper::map($request->getParsedBody()) explicitly. Adding transparent PSR-7 support later is trivial (soft instanceof check against Psr\Http\Message\ServerRequestInterface) and non-breaking.
  • No cross-target hydration in v1. to() only accepts DTO subclasses and throws on anything else. Supporting arbitrary classes / CakePHP entities would need a separate hydration layer and is a logical follow-up.

Changes

  • src/Mapper.php — new facade. map() returns ObjectFactory; toArray() is a shared static extractor reused by Dto::from(). Private assertAssociative() helper rejects list/integer-keyed payloads at the entry point.
  • src/ObjectFactory.php — new fluent builder with ignoreMissing, withKeyType, only, and to. Runtime is_a() guard ensures non-DTO targets throw cleanly.
  • src/Dto/Dto.php — new from(mixed $source): static method. One-liner delegating to static::createFromArray(Mapper::toArray($source), true).
  • tests/MapperTest.php — 26 tests covering each supported source (array, DTO, JSON string, JsonSerializable, FromArrayToArrayInterface, plain object), each modifier (ignoreMissing, withKeyType, only), each error path (unsupported source, invalid JSON, non-DTO target, scalar JsonSerializable, list arrays in every branch, integer-keyed arrays), the Dto::from() shortcut, and the only() source-key semantics with TYPE_UNDERSCORED.
  • docs/guide/runtime-api.md — new "Mapping From Arbitrary Sources" section with the shortcut, the facade, sources table, modifiers table (including the only() source-key clarification), list-rejection note, DTO-to-DTO copy semantics, and Cake/Laravel examples.

Test status

  • tests/Dto/DtoTest.php + tests/MapperTest.php: 166/166 passing, 378 assertions.
  • CS clean. PHPStan shows only the pre-existing unrelated JsonSchema optional-dependency errors.

Relationship to other PRs

Merged with master after #110 (TransformerRegistry) landed. Both features are independent but combine cleanly: Dto::from() and Mapper::map() route through createFromArraysetFromArray, which consults the TransformerRegistry, so global casters and serializers automatically apply to values passing through the new mapping API.

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.
…owing

The @template T + class-string<T> 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.
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 11, 2026

Codecov Report

❌ Patch coverage is 96.10390% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.06%. Comparing base (dc2a3eb) to head (b7f0d92).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
src/Mapper.php 94.44% 3 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master     #111      +/-   ##
============================================
+ Coverage     82.80%   83.06%   +0.26%     
- Complexity     1530     1555      +25     
============================================
  Files            43       45       +2     
  Lines          3756     3833      +77     
============================================
+ Hits           3110     3184      +74     
- Misses          646      649       +3     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a unified runtime mapping API so callers can hydrate DTOs from heterogeneous source shapes (arrays, JSON strings, DTO instances, JsonSerializable, etc.) without choosing between multiple constructors/factories.

Changes:

  • Introduces Mapper::map() + ObjectFactory fluent facade for mapping from arbitrary sources into DTO subclasses.
  • Adds Mapper::toArray() as the shared “source → array payload” extractor.
  • Adds Dto::from(mixed $source): static shortcut delegating to Mapper::toArray() with ignoreMissing=true, plus docs and tests.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/Mapper.php New facade + source-shape detection/extraction into array payloads.
src/ObjectFactory.php New fluent builder applying modifiers (ignoreMissing, withKeyType, only) then dispatching to createFromArray().
src/Dto/Dto.php Adds Dto::from() typed shortcut that routes through Mapper::toArray().
tests/MapperTest.php Adds tests covering supported source types, modifiers, and error paths.
docs/guide/runtime-api.md Documents the new mapping entry points, modifiers, and supported sources.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Mapper.php
Comment thread src/Mapper.php
Comment thread src/ObjectFactory.php
Comment thread src/ObjectFactory.php
Comment thread src/ObjectFactory.php
Comment thread tests/MapperTest.php
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.
@dereuromark dereuromark merged commit 11acfa2 into master Apr 11, 2026
12 checks passed
@dereuromark dereuromark deleted the feature/mapper-facade branch April 11, 2026 10:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants