Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/ORM/Query/SelectQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -1551,6 +1551,10 @@ public function isHydrationEnabled(): bool
* When DTO projection is enabled, results will be hydrated into
* the specified DTO class instead of entity objects.
*
* @deprecated 5.4.0 Use {@see \Cake\ORM\Table::projectAs()} instead. The
* terminal repository method returns a concrete `list<T>` of DTOs, while
* this fluent toggle keeps the query typed as a collection of entities
* and so lies about the projected result shape. Removed in 6.0.
Comment on lines +1554 to +1557

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Unfortunate that we're churning a new method. Did we miss something during the design of the feature?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, we did the Query part, which now looks fine.
But we didnt tackle the even more problematic formatResults() and result formatting etc, which literally is "mixed" at this point, due to the way we inject them before we execute.
The more correct approach would be to separate those concerns, always return either entity collections or array, and then pass those to the processing that are return type safe then.

I had hoped people would help the last 7 weeks to find a way to way here forward that would minimize the fallout while keeping it clean moving forward.

* @param class-string $dtoClass The DTO class name
* @return $this
*/
Expand Down
83 changes: 83 additions & 0 deletions src/ORM/Table.php
Original file line number Diff line number Diff line change
Expand Up @@ -1568,6 +1568,89 @@ public function findThreaded(
));
}

/**
* Project query results into DTO instances and return them as a list.
*
* Terminal repository counterpart to the fluent
* {@see \Cake\ORM\Query\SelectQuery::projectAs()}. The honest return type
* (`list<T>`) lives here on the repository instead of being a generic that
* the chainable query cannot truthfully carry. Stack finders on `$query`
* first when needed, then hand it here to execute the projection.
*
* Rows are mapped from the raw selected columns (the same projection path
* as the fluent `SelectQuery::projectAs()`), so entity visibility and
* accessors do not affect the DTO data.
*
* ```
* $dtos = $articles->projectAs(ArticleDto::class);
*
* $dtos = $articles->projectAs(
* ArticleDto::class,
* $articles->find()->where(['published' => true])->contain('Authors'),
* );
* ```
*
* @template T of object
* @param class-string<T> $class The DTO class to map each row into.
* @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface>|null $query
* A pre-built query, or null to start from `find()`.
* @return list<T>
* @since 5.4.0
*/
public function projectAs(string $class, ?SelectQuery $query = null): array
{
$query ??= $this->find();

$results = [];
foreach ($query->projectAs($class)->all() as $dto) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Isn't this method deprecated?

if ($dto instanceof $class) {
$results[] = $dto;
}
}

return $results;
}

/**
* Execute a key/value list query and return the resulting array.
*
* Terminal repository counterpart to the `list` finder
* ({@see \Cake\ORM\Table::findList()}). Returns the combined array directly
* instead of a query whose static type still claims to be a collection of
* entities. Build and stack finders on `$query` first when needed.
*
* ```
* $options = $articles->list();
*
* $options = $articles->list(
* $articles->find()->where(['published' => true]),
* valueField: 'title',
* );
* ```
*
* @param \Cake\ORM\Query\SelectQuery<TEntity|array>|null $query
* A pre-built query, or null to start from `find()`.
* @param \Closure|array<string>|string|null $keyField The path to the key field, defaults to the primary key.
* @param \Closure|array<string>|string|null $valueField The path to the value field, defaults to the display field.
* @param \Closure|array<string>|string|null $groupField The path to the field to group on.
* @param string $valueSeparator The separator used to join composite value fields.
* @return array<array-key, mixed>
* @since 5.4.0
*/
public function list(
?SelectQuery $query = null,
Closure|array|string|null $keyField = null,
Closure|array|string|null $valueField = null,
Closure|array|string|null $groupField = null,
string $valueSeparator = ' ',
): array {
$query ??= $this->find();

return $this->findList($query, $keyField, $valueField, $groupField, $valueSeparator)
->all()
->toArray();
}

/**
* Out of an options array, check if the keys described in `$keys` are arrays
* and change the values for closures that will concatenate the each of the
Expand Down
30 changes: 30 additions & 0 deletions tests/TestCase/ORM/ResultSetFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,36 @@ public function testProjectAsSimpleDto(): void
$this->assertSame('First Article Body', $result->body);
}

/**
* Test Table::projectAs() returns a concrete list of DTOs from a default query.
*/
public function testTableProjectAs(): void
{
DtoMapper::clearCache();

$results = $this->table->projectAs(SimpleArticleDto::class);

$this->assertIsList($results);
$this->assertCount(3, $results);
$this->assertContainsOnlyInstancesOf(SimpleArticleDto::class, $results);
$this->assertSame(1, $results[0]->id);
}

/**
* Test Table::projectAs() with a pre-built query.
*/
public function testTableProjectAsWithQuery(): void
{
DtoMapper::clearCache();

$query = $this->table->find()->where(['id' => 1]);
$results = $this->table->projectAs(SimpleArticleDto::class, $query);

$this->assertCount(1, $results);
$this->assertInstanceOf(SimpleArticleDto::class, $results[0]);
$this->assertSame('First Article', $results[0]->title);
}

/**
* Test projectAs() with multiple results.
*/
Expand Down
52 changes: 52 additions & 0 deletions tests/TestCase/ORM/TableTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1427,6 +1427,58 @@ public function testFindListNoHydration(): void
$this->assertSame($expected, $query->toArray());
}

/**
* Tests the terminal list() repository method returns the combined array directly.
*/
public function testList(): void
{
$table = new Table([
'table' => 'users',
'connection' => $this->connection,
]);
$table->setDisplayField('username');

$expected = [
1 => 'mariano',
2 => 'nate',
3 => 'larry',
4 => 'garrett',
];
$this->assertSame($expected, $table->list($table->find()->orderBy('id')));
}

/**
* Tests list() builds a default query when none is passed and honors field options.
*/
public function testListDefaultQueryAndOptions(): void
{
$table = new Table([
'table' => 'users',
'connection' => $this->connection,
]);
$table->setDisplayField('username');

$result = $table->list();
ksort($result);
$expected = [
1 => 'mariano',
2 => 'nate',
3 => 'larry',
4 => 'garrett',
];
ksort($expected);
$this->assertSame($expected, $result);

$grouped = $table->list(
$table->find()
->select(['id', 'username', 'odd' => new QueryExpression('id % 2')])
->orderBy('id'),
groupField: 'odd',
);
$this->assertSame(['mariano', 'larry'], array_values($grouped[1]));
$this->assertSame(['nate', 'garrett'], array_values($grouped[0]));
}

/**
* Tests find('threaded')
*/
Expand Down