Skip to content
Merged
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
11 changes: 9 additions & 2 deletions src/Database/Trait/Documents.php
Original file line number Diff line number Diff line change
Expand Up @@ -4339,7 +4339,7 @@ private function fetch(Collection $collection, array $queries, PermissionType $f

$queries = $this->readQueries($collection, $parsed, $joinedByAlias, $outerJoinIds);

$selections = $this->validateSelections($collection, $selects);
$selections = $this->validateSelections($collection, $selects, $distinct);

if ($isAggregation) {
$nestedSelections = [];
Expand Down Expand Up @@ -5958,10 +5958,13 @@ private function outerJoinIdSelections(array $selects, array $joins, array $join
}

/**
* The attributes a read returns: those its selects name and, unless it is distinct, the internal attributes every
* document carries. A distinct row is not a stored document, so it carries only the attributes it selects.
*
* @param array<Query> $queries
* @return array<string>
*/
private function validateSelections(Document $collection, array $queries): array
private function validateSelections(Document $collection, array $queries, bool $distinct = false): array
{
if (empty($queries)) {
return [];
Expand Down Expand Up @@ -6011,6 +6014,10 @@ private function validateSelections(Document $collection, array $queries): array

$selections = \array_merge($selections, $relationshipSelections);

if ($distinct) {
return \array_values(\array_unique($selections));
}

$selections[] = Document::ID;
$selections[] = Document::SEQUENCE;
$selections[] = Document::COLLECTION;
Expand Down
94 changes: 94 additions & 0 deletions tests/e2e/Adapter/Scopes/DocumentTests.php
Original file line number Diff line number Diff line change
Expand Up @@ -11285,6 +11285,100 @@ public function testIncreaseAndDecreaseRoundTheChangeValueOnAnInteger(): void
}
}

/**
* A distinct row carried `$id`, `$sequence`, `$createdAt` and `$updatedAt` as null whenever the read did not
* select them, so `new Document($row->getArrayCopy())` refused it: "$id must be of type string". A distinct row
* carries only what it selects, and every row a read returns rebuilds into an equal Document.
*/
public function testDistinctRowsCarryOnlyWhatTheySelectAndRebuildIntoADocument(): void
{
/** @var Database $database */
$database = $this->getDatabase();

$collection = 'distinct_rows_'.\substr(\uniqid(), -6);
$database->createCollection(Collection::create(
id: $collection,
attributes: [
Attribute::string(key: 'k', size: 32),
Attribute::integer(key: 'n'),
],
permissions: [Permission::create(Role::any()), Permission::read(Role::any())],
));
foreach ([['a', 1], ['a', 1], ['b', 2], [null, null]] as [$k, $n]) {
$database->createDocument($collection, new Document(['k' => $k, 'n' => $n, '$permissions' => [Permission::read(Role::any())]]));
}

$roundTrip = function (array $rows, string $case): void {
foreach ($rows as $row) {
$this->assertInstanceOf(Document::class, $row);
$this->assertEquals($row->getArrayCopy(), (new Document($row->getArrayCopy()))->getArrayCopy(), $case);
}
};

if (! $database->getAdapter()->supports(Capability::Aggregations)) {
try {
$database->find($collection, [Query::distinct(), Query::select(['k'])]);
$this->fail('A distinct() read is refused where the adapter cannot deduplicate rows');
} catch (QueryException) {
$this->addToAssertionCount(1);
}

$roundTrip($database->find($collection, [Query::select(['k'])]), 'a read of selected attributes');
$database->deleteCollection($collection);

return;
}

$values = $database->find($collection, [Query::distinct(), Query::select(['k'])]);
$roundTrip($values, 'distinct without $id');
$copies = \array_map(static fn (Document $row): array => $row->getArrayCopy(), $values);
\usort($copies, static fn (array $a, array $b): int => [$a['k'] !== null, $a['k']] <=> [$b['k'] !== null, $b['k']]);
$this->assertSame(
[['k' => null, '$collection' => $collection], ['k' => 'a', '$collection' => $collection], ['k' => 'b', '$collection' => $collection]],
$copies,
);

$pairs = $database->find($collection, [Query::distinct(), Query::select(['k', 'n']), Query::orderAsc('k'), Query::orderAsc('n')]);
$this->assertCount(3, $pairs);
foreach ($pairs as $row) {
$this->assertFalse($row->offsetExists('$id'));
$this->assertFalse($row->offsetExists('$sequence'));
}
$roundTrip($pairs, 'distinct of two attributes');

$stamped = $database->find($collection, [Query::distinct(), Query::select(['k', '$createdAt'])]);
$this->assertCount(4, $stamped);
foreach ($stamped as $row) {
$this->assertFalse($row->offsetExists('$id'));
$this->assertIsString($row->getAttribute('$createdAt'));
}
$roundTrip($stamped, 'distinct of an internal attribute but $id');

$identified = $database->find($collection, [Query::distinct(), Query::select(['k', '$id'])]);
$this->assertCount(4, $identified);
foreach ($identified as $row) {
$this->assertIsString($row->getAttribute('$id'));
$this->assertNotSame('', $row->getAttribute('$id'));
}
$roundTrip($identified, 'distinct with $id');

$paged = [Query::distinct(), Query::select(['k']), Query::isNotNull('k'), Query::orderAsc('k'), Query::limit(1)];
$first = $database->find($collection, $paged);
$next = $database->find($collection, [...$paged, Query::cursorAfter($first[0])]);
$this->assertSame(['a'], \array_map(static fn (Document $row): mixed => $row->getAttribute('k'), $first));
$this->assertSame(['b'], \array_map(static fn (Document $row): mixed => $row->getAttribute('k'), $next));
$roundTrip([...$first, ...$next], 'distinct pages');

$documents = $database->find($collection, [Query::select(['k'])]);
$this->assertCount(4, $documents);
foreach ($documents as $document) {
$this->assertIsString($document->getAttribute('$id'));
}
$roundTrip($documents, 'a read of selected attributes');

$database->deleteCollection($collection);
}

public function testDistinctIsRefusedWhereTheAdapterCannotDeduplicate(): void
{
/** @var Database $database */
Expand Down
Loading