Add DTO projection support via projectAs() query method#19135
Conversation
Adds ability to project ORM query results directly into readonly DTOs instead of Entity objects. This provides a more memory-efficient and immutable way to fetch read-only data. New features: - projectAs() method on SelectQuery to specify a DTO class - DtoMapper using PHP 8 reflection with named constructor arguments - CollectionOf attribute for specifying array collection element types - Support for createFromArray() factory method (cakephp-dto style) - Nested association hydration into DTOs
| * public function __construct( | ||
| * public int $id, | ||
| * public string $title, | ||
| * #[CollectionOf(CommentDto::class)] |
There was a problem hiding this comment.
This will be a good test to find out how the community likes APIs using attributes.
|
How will fields like |
|
They will be mapped if present in the dto specs as property. I will check if there is a test. |
readonly class ArticleDto {
public function __construct(
public int $id,
public string $title,
public ?AuthorDto $author = null,
#[CollectionOf(CommentDto::class)]
public array $comments = [],
) {}
}This looks similar to how an entity would look for the entity implementation I have made https://github.com/ADmad/cakephp-entity/blob/master/tests/test_app/TestApp/Model/Entity/Article.php <?php
declare(strict_types=1);
namespace TestApp\Model\Entity;
use ADmad\Entity\Datasource\Entity;
use BackedEnum;
use DateTimeInterface;
class Article extends Entity
{
protected ?int $id;
protected ?int $author_id;
protected $user_id;
protected string $title;
protected ?string $body;
protected string|BackedEnum $published;
protected ?DateTimeInterface $created;
protected ?DateTimeInterface $modified;
protected ?DateTimeInterface $updated;
protected ?DateTimeInterface $date_specialed;
protected array $tags;
protected ?Author $author;
protected array $comments;
protected array $unaproved_comments;
protected $article;
protected $not_in_schema;
protected $comments_notype;
protected $user;
public function isRequired(): bool
{
return true;
}
} |
| public string $class, | ||
| ) { | ||
| } | ||
| } |
There was a problem hiding this comment.
Could we have a simple attribute with all possible configs? Here is an example with name and collectionOf, both are optional
```php
readonly class ArticleDto {
public function __construct(
public int $id,
#[FieldDto(name: "main_title")] public string $title,
#[FieldDto(collectionOf: CommentDto::class)]
public array $comments = [],
) {}
} ```
There was a problem hiding this comment.
Isnt that bit of scope creep? Renaming the fields is a bit of different concern
Usually thats handled in the DTOs using from/to kind of mapping configs.
There was a problem hiding this comment.
Doing attribute aliasing/inflection is a good scenario for createFromArray() as well.
There was a problem hiding this comment.
What do you propose here @markstory ?
Maybe you can clarify
There was a problem hiding this comment.
Renaming the fields is a bit of different concern
Being able to rename fields would be quite handy for tasks like report generation. For e.g. being able to represent author.name as author_name in xls/csv rendering.
There was a problem hiding this comment.
OK, what would be the naming/signature be then for this attribute?
If we brainstorm on full specs now first, I can finish this up in one batch.
And:
Should I implement this as a replacement for #[CollectionOf], or keep both (with #[CollectionOf] as shorthand)?
There was a problem hiding this comment.
Maybe you can clarify
My thought here is that we build provide simple constructor support, and if userland code need attribute renames, inflections or anything along those lines they can implement createFromArray(). We can add more attributes and more 'configuration' to DTO mapping later when we better understand the gaps/limitations in our simple solution.
There was a problem hiding this comment.
OK, so we address this later if needed.
DateTime and other objects type-hinted on constructor parameters were incorrectly being passed to map() as if they were arrays for nested DTO mapping. Added is_array() check to only map actual arrays. Added test cases for DateTime object handling.
Previously, DTO mapping happened in ResultSetFactory::groupResult() during hydration, BEFORE formatters ran. This caused issues with behaviors like TagBehavior that add formatters expecting arrays/entities. Now DTO projection: 1. Returns arrays from groupResult() when projectAs() is used 2. Applies DTO mapping as a FINAL formatter after all others 3. Allows behavior formatters to work with arrays/entities first This aligns with the user-space approach suggested in PR review: disableHydration()->formatResults() but built-in for convenience.
Use hydrateDto() (now public) instead of getDtoMapper()->map() directly so that DTOs with static createFromArray() factory methods (cakephp-dto style) continue to work.
|
@ADmad Option 1: ?array (simple) readonly class UserWithMatchingDto {
public function __construct(
public int $id,
public string $username,
public ?array $_matchingData = null,
) {}
}Option 2: ?MatchingDataDto (full type safety) readonly class MatchingDataDto {
public function __construct(
public ?SimpleRoleDto $Roles = null, // Property name = association alias
) {}
}
readonly class UserWithMatchingDtoTyped {
public function __construct(
public int $id,
public string $username,
public ?MatchingDataDto $_matchingData = null,
) {}
} |
|
To support a wider range of DTO libs, we might want to create a DtoRegistry or some Configure approach on how to map them. // For Valinor (external mapper)
->projectAs(UserDto::class, fn($data) => $valinorMapper->map(UserDto::class, $data))Implementation: public function projectAs(string $dtoClass, ?callable $factory = null): static {
$this->_dtoClass = $dtoClass;
$this->_dtoFactory = $factory;
return $this;
} |
Is this something that is needed now? I think we should see if we can get the design right and get some adoption before we need to make it pluggable. |
|
Providing the factory possibility wouldn't hurt IMO, since it would not be used in default cases anyway. |
|
Does this feature works with column type Enum? |
|
I would think so. Enums are also just classes. The DTO just has to define them. See test cases in cake-dto. //EDIT @rochamarcelo I added a real life test in sandbox for enums, works. |
|
Are we then able to move forward? |
markstory
left a comment
There was a problem hiding this comment.
Generally looks good to me. I think this feature will require documentation on what a DTO is, and give developers context on when/why they would use projectAs() instead of getting results as entities & arrays.
| public ?DateTime $created = null, | ||
| public ?DateTime $modified = null, |
There was a problem hiding this comment.
Do we need special casing for application enums or other custom application type mappings?
There was a problem hiding this comment.
As per my sandbox example code this all works as is.
Documents the new projectAs() method for projecting ORM results into DTOs instead of Entity objects, including the #[CollectionOf] attribute for nested associations. Refs cakephp/cakephp#19135
Summary
projectAs()method on SelectQuery to project results directly into readonly DTOs instead of Entity objectscreateFromArray()factory method#[CollectionOf]attribute for specifying collection element typesUsage
DTO classes can use either constructor parameters (mapped via reflection):
Or a
createFromArray()factory method for full control over mapping:Performance
DTOs use approximately 3x less memory than Entity objects while providing similar hydration speed. The
createFromArray()method is ~2.5x faster than Entity hydration.Refs #19131