diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php index b95120b54..4decea5b2 100644 --- a/src/Database/Trait/Documents.php +++ b/src/Database/Trait/Documents.php @@ -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 = []; @@ -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 $queries * @return array */ - private function validateSelections(Document $collection, array $queries): array + private function validateSelections(Document $collection, array $queries, bool $distinct = false): array { if (empty($queries)) { return []; @@ -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; diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index bab056537..3a84c9bd8 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -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 */