From 4fbe00ffc20e1c42a6db01a3e0b0c9a4a3993e52 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 11 Sep 2026 05:08:39 +1200 Subject: [PATCH 01/12] feat(query): add nested relationship query skeleton Populated child arrays can currently only be shaped by a top-level `select`, so there is no way to express "these queries apply to the children of this relationship". This adds the carrier for that: a `nested` query type that holds per-relationship queries, a validator that accepts it only on a relationship attribute, and the routing that delivers its inner queries to the relationship populator. No inner query is honoured yet -- the queries reach the populator and the related collection's own `find()` validates them, which is what makes an invalid inner filter surface as an error instead of being dropped. Applying limit/offset/cursor/order per parent is the next step. Three seams had to move for the carrier to be inert everywhere else: - `populateDocumentsRelationships()` inferred "the caller asked for explicit selects" from the selection map being non-empty. A `nested()` fills that map with no `select` present, which made the populator drop every sibling relationship. The caller now states it. - `sum()` does not group queries, so a `nested` would reach `convertQueries()`, be recursed into as a logical query, and emit a broken SQL condition. It is stripped before conversion. - `updateDocuments()` and `deleteDocuments()` do not populate relationships, so they refuse the method rather than silently ignoring it. The inner queries are cloned on the way into the selection map because `getDocument()` passes the caller's own array through, and the populator rewrites dotted select values in place. Co-Authored-By: Claude Fable 5.1 --- src/Database/Database.php | 42 ++- src/Database/Query.php | 23 +- src/Database/Validator/IndexedQueries.php | 2 +- src/Database/Validator/Queries.php | 3 +- src/Database/Validator/Queries/Documents.php | 10 +- src/Database/Validator/Query/Base.php | 1 + src/Database/Validator/Query/Nested.php | 129 +++++++ .../e2e/Adapter/Scopes/RelationshipTests.php | 344 ++++++++++++++++++ tests/unit/QueryTest.php | 45 +++ tests/unit/Validator/QueriesTest.php | 41 +++ tests/unit/Validator/Query/NestedTest.php | 244 +++++++++++++ 11 files changed, 872 insertions(+), 12 deletions(-) create mode 100644 src/Database/Validator/Query/Nested.php create mode 100644 tests/unit/Validator/Query/NestedTest.php diff --git a/src/Database/Database.php b/src/Database/Database.php index 4c3fab0550..ea4587a857 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5054,7 +5054,7 @@ public function getDocument(string $collection, string $id, array $queries = [], // Skip relationship population if we're in batch mode (relationships will be populated later) if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships && !empty($relationships) && (empty($selects) || !empty($nestedSelections))) { - $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $this->relationshipFetchDepth, $nestedSelections)); + $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $this->relationshipFetchDepth, $nestedSelections, !empty($selects))); $document = $documents[0]; } @@ -5116,6 +5116,7 @@ private function isTtlExpired(Document $collection, Document $document): bool * @param Document $collection * @param int $relationshipFetchDepth * @param array> $selects + * @param bool $hasExplicitSelects * @return array * @throws DatabaseException */ @@ -5123,7 +5124,8 @@ private function populateDocumentsRelationships( array $documents, Document $collection, int $relationshipFetchDepth = 0, - array $selects = [] + array $selects = [], + bool $hasExplicitSelects = false ): array { // Prevent nested relationship population during fetches $this->inBatchRelationshipPopulation = true; @@ -5136,7 +5138,7 @@ private function populateDocumentsRelationships( 'depth' => $relationshipFetchDepth, 'selects' => $selects, 'skipKey' => null, // No back-reference to skip at top level - 'hasExplicitSelects' => !empty($selects) // Track if we're in explicit select mode + 'hasExplicitSelects' => $hasExplicitSelects ] ]; @@ -6630,7 +6632,8 @@ public function updateDocuments( $this->adapter->getMinDateTime(), $this->adapter->getMaxDateTime(), $this->adapter->getSupportForAttributes(), - $this->adapter->getSupportForUnsignedBigInt() + $this->adapter->getSupportForUnsignedBigInt(), + false ); if (!$validator->isValid($queries)) { @@ -8405,7 +8408,8 @@ public function deleteDocuments( $this->adapter->getMinDateTime(), $this->adapter->getMaxDateTime(), $this->adapter->getSupportForAttributes(), - $this->adapter->getSupportForUnsignedBigInt() + $this->adapter->getSupportForUnsignedBigInt(), + false ); if (!$validator->isValid($queries)) { @@ -8671,6 +8675,7 @@ public function find(string $collection, array $queries = [], string $forPermiss $orderTypes = $grouped['orderTypes']; $cursor = $grouped['cursor']; $cursorDirection = $grouped['cursorDirection'] ?? Database::CURSOR_AFTER; + $nested = $grouped['nested']; $uniqueOrderBy = false; foreach ($orderAttributes as $order) { @@ -8734,7 +8739,7 @@ public function find(string $collection, array $queries = [], string $forPermiss ); $selections = $this->validateSelections($collection, $selects); - $nestedSelections = $this->processRelationshipQueries($relationships, $queries); + $nestedSelections = $this->processRelationshipQueries($relationships, \array_merge($queries, $nested)); // Convert relationship filter queries to SQL-level subqueries $queriesOrNull = $this->convertRelationshipQueries($relationships, $queries, $collection); @@ -8762,7 +8767,7 @@ public function find(string $collection, array $queries = [], string $forPermiss if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships && !empty($relationships) && (empty($selects) || !empty($nestedSelections))) { if (count($results) > 0) { - $results = $this->silent(fn () => $this->populateDocumentsRelationships($results, $collection, $this->relationshipFetchDepth, $nestedSelections)); + $results = $this->silent(fn () => $this->populateDocumentsRelationships($results, $collection, $this->relationshipFetchDepth, $nestedSelections, !empty($selects))); } } @@ -9253,7 +9258,10 @@ public function sum(string $collection, string $attribute, array $queries = [], fn (Document $attribute) => $attribute->getAttribute('type') === self::VAR_RELATIONSHIP ); - $queries = $this->convertQueries($collection, $queries); + $queries = $this->convertQueries($collection, \array_values(\array_filter( + $queries, + fn (Query $query) => $query->getMethod() !== Query::TYPE_NESTED + ))); $queriesOrNull = $this->convertRelationshipQueries($relationships, $queries, $collection); // If conversion returns null, it means no documents can match (relationship filter found no matches) @@ -10210,6 +10218,24 @@ private function processRelationshipQueries( $nestedSelections = []; foreach ($queries as $query) { + if ($query->getMethod() === Query::TYPE_NESTED) { + $key = $query->getAttribute(); + $relationship = \array_values(\array_filter( + $relationships, + fn (Document $relationship) => $relationship->getAttribute('key') === $key, + ))[0] ?? null; + + if (!$relationship) { + continue; + } + + $nestedSelections[$key] = \array_merge( + $nestedSelections[$key] ?? [], + \array_map(fn (Query $nestedQuery) => clone $nestedQuery, $query->getValues()), + ); + continue; + } + if ($query->getMethod() !== Query::TYPE_SELECT) { continue; } diff --git a/src/Database/Query.php b/src/Database/Query.php index 147c463ad0..f276d34ed6 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -68,6 +68,7 @@ class Query public const TYPE_OR = 'or'; public const TYPE_CONTAINS_ALL = 'containsAll'; public const TYPE_ELEM_MATCH = 'elemMatch'; + public const TYPE_NESTED = 'nested'; public const DEFAULT_ALIAS = 'main'; public const TYPES = [ @@ -119,6 +120,7 @@ class Query self::TYPE_OR, self::TYPE_CONTAINS_ALL, self::TYPE_ELEM_MATCH, + self::TYPE_NESTED, self::TYPE_REGEX ]; @@ -132,6 +134,7 @@ class Query self::TYPE_AND, self::TYPE_OR, self::TYPE_ELEM_MATCH, + self::TYPE_NESTED, ]; protected string $method = ''; @@ -307,6 +310,7 @@ public static function isMethod(string $value): bool self::TYPE_AND, self::TYPE_CONTAINS_ALL, self::TYPE_ELEM_MATCH, + self::TYPE_NESTED, self::TYPE_SELECT, self::TYPE_VECTOR_DOT, self::TYPE_VECTOR_COSINE, @@ -1001,7 +1005,8 @@ public static function getCursorQueries(array $queries, bool $clone = true): arr * orderAttributes: array, * orderTypes: array, * cursor: Document|null, - * cursorDirection: string|null + * cursorDirection: string|null, + * nested: array * } */ public static function groupByType(array $queries): array @@ -1014,6 +1019,7 @@ public static function groupByType(array $queries): array $orderTypes = []; $cursor = null; $cursorDirection = null; + $nested = []; foreach ($queries as $query) { if (!$query instanceof Query) { @@ -1070,6 +1076,10 @@ public static function groupByType(array $queries): array $selections[] = clone $query; break; + case Query::TYPE_NESTED: + $nested[] = clone $query; + break; + default: $filters[] = clone $query; break; @@ -1085,6 +1095,7 @@ public static function groupByType(array $queries): array 'orderTypes' => $orderTypes, 'cursor' => $cursor, 'cursorDirection' => $cursorDirection, + 'nested' => $nested, ]; } @@ -1384,4 +1395,14 @@ public static function elemMatch(string $attribute, array $queries): self { return new self(self::TYPE_ELEM_MATCH, $attribute, $queries); } + + /** + * @param string $relationshipKey + * @param array $queries + * @return Query + */ + public static function nested(string $relationshipKey, array $queries): self + { + return new self(self::TYPE_NESTED, $relationshipKey, $queries); + } } diff --git a/src/Database/Validator/IndexedQueries.php b/src/Database/Validator/IndexedQueries.php index a24e0d21da..b4a812c127 100644 --- a/src/Database/Validator/IndexedQueries.php +++ b/src/Database/Validator/IndexedQueries.php @@ -101,7 +101,7 @@ public function isValid($value): bool } } - if ($query->isNested()) { + if ($query->isNested() && $query->getMethod() !== Query::TYPE_NESTED) { if (! self::isValid($query->getValues())) { return false; } diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index 4f91251828..dbde415146 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -71,7 +71,7 @@ public function isValid($value): bool } } - if ($query->isNested()) { + if ($query->isNested() && $query->getMethod() !== Query::TYPE_NESTED) { if (!self::isValid($query->getValues())) { return false; } @@ -128,6 +128,7 @@ public function isValid($value): bool Query::TYPE_REGEX, Query::TYPE_EXISTS, Query::TYPE_NOT_EXISTS => Base::METHOD_TYPE_FILTER, + Query::TYPE_NESTED => Base::METHOD_TYPE_NESTED, default => '', }; diff --git a/src/Database/Validator/Queries/Documents.php b/src/Database/Validator/Queries/Documents.php index 4959a062cf..ad39ca84ef 100644 --- a/src/Database/Validator/Queries/Documents.php +++ b/src/Database/Validator/Queries/Documents.php @@ -8,6 +8,7 @@ use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\Query\Filter; use Utopia\Database\Validator\Query\Limit; +use Utopia\Database\Validator\Query\Nested; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\Query\Order; use Utopia\Database\Validator\Query\Select; @@ -22,6 +23,8 @@ class Documents extends IndexedQueries * @param \DateTime $minAllowedDate * @param \DateTime $maxAllowedDate * @param bool $supportForAttributes + * @param bool $supportUnsignedBigInt + * @param bool $supportForNested * @throws \Utopia\Database\Exception */ public function __construct( @@ -33,7 +36,8 @@ public function __construct( \DateTime $minAllowedDate = new \DateTime('0000-01-01'), \DateTime $maxAllowedDate = new \DateTime('9999-12-31'), bool $supportForAttributes = true, - bool $supportUnsignedBigInt = true + bool $supportUnsignedBigInt = true, + bool $supportForNested = true ) { $attributes[] = new Document([ '$id' => '$id', @@ -77,6 +81,10 @@ public function __construct( new Select($attributes, $supportForAttributes), ]; + if ($supportForNested) { + $validators[] = new Nested($attributes, $maxUIDLength, $supportForAttributes); + } + parent::__construct($attributes, $indexes, $validators); } } diff --git a/src/Database/Validator/Query/Base.php b/src/Database/Validator/Query/Base.php index a37fdd65a9..16f5a5f1ee 100644 --- a/src/Database/Validator/Query/Base.php +++ b/src/Database/Validator/Query/Base.php @@ -12,6 +12,7 @@ abstract class Base extends Validator public const METHOD_TYPE_ORDER = 'order'; public const METHOD_TYPE_FILTER = 'filter'; public const METHOD_TYPE_SELECT = 'select'; + public const METHOD_TYPE_NESTED = 'nested'; protected string $message = 'Invalid query'; diff --git a/src/Database/Validator/Query/Nested.php b/src/Database/Validator/Query/Nested.php new file mode 100644 index 0000000000..76c5338b13 --- /dev/null +++ b/src/Database/Validator/Query/Nested.php @@ -0,0 +1,129 @@ + + */ + protected array $schema = []; + + /** + * @param array $attributes + * @param int $maxUIDLength + * @param bool $supportForAttributes + */ + public function __construct( + array $attributes, + protected int $maxUIDLength = 36, + protected bool $supportForAttributes = true + ) { + foreach ($attributes as $attribute) { + $this->schema[$attribute->getAttribute('key', $attribute->getId())] = $attribute->getArrayCopy(); + } + } + + /** + * @param Query $value + * @return bool + */ + public function isValid($value): bool + { + if (!$value instanceof Query) { + return false; + } + + if ($value->getMethod() !== Query::TYPE_NESTED) { + $this->message = 'Invalid query method: ' . $value->getMethod(); + return false; + } + + $attribute = $value->getAttribute(); + + if (empty($attribute)) { + $this->message = 'Nested queries require a relationship attribute'; + return false; + } + + if ($this->supportForAttributes) { + if ( + !isset($this->schema[$attribute]) + || $this->schema[$attribute]['type'] !== Database::VAR_RELATIONSHIP + ) { + $this->message = 'Nested queries can only be used on relationship attributes: ' . $attribute; + return false; + } + } + + $queries = $value->getValues(); + + if (empty($queries)) { + $this->message = 'Nested queries can only contain queries'; + return false; + } + + foreach ($queries as $query) { + if (!$query instanceof Query) { + $this->message = 'Nested queries can only contain queries'; + return false; + } + + if ($query->getMethod() === Query::TYPE_NESTED) { + $this->message = 'Nested queries cannot be nested'; + return false; + } + } + + $hasPagination = false; + + foreach ($queries as $query) { + $validator = match ($query->getMethod()) { + Query::TYPE_LIMIT => new Limit(), + Query::TYPE_OFFSET => new Offset(), + Query::TYPE_CURSOR_AFTER, + Query::TYPE_CURSOR_BEFORE => new Cursor($this->maxUIDLength), + default => null, + }; + + if ($validator === null) { + continue; + } + + $hasPagination = true; + + if (!$validator->isValid($query)) { + $this->message = $validator->getDescription(); + return false; + } + } + + if (!$hasPagination || !$this->supportForAttributes || !isset($this->schema[$attribute]['options'])) { + return true; + } + + $options = $this->schema[$attribute]['options']; + $relationType = $options['relationType'] ?? null; + $side = $options['side'] ?? null; + + $isSingular = $relationType === Database::RELATION_ONE_TO_ONE + || ($relationType === Database::RELATION_MANY_TO_ONE && $side === Database::RELATION_SIDE_PARENT) + || ($relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_CHILD); + + if ($isSingular) { + $this->message = 'Nested pagination is not supported on a singular relationship: ' . $attribute; + return false; + } + + return true; + } + + public function getMethodType(): string + { + return self::METHOD_TYPE_NESTED; + } +} diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 9182b8b8bd..9ef817a974 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -4817,4 +4817,348 @@ public function testOrderAndCursorWithRelationshipQueries(): void $database->deleteCollection('authorsOrder'); $database->deleteCollection('postsOrder'); } + + private function createNestedSkeletonFixture(Database $database): void + { + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection('nsk_authors', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nsk_authors', 'name', Database::VAR_STRING, 255, true); + + $database->createCollection('nsk_tags', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nsk_tags', 'name', Database::VAR_STRING, 255, true); + + $database->createCollection('nsk_comments', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nsk_comments', 'text', Database::VAR_STRING, 255, true); + $database->createAttribute('nsk_comments', 'approved', Database::VAR_BOOLEAN, 0, false); + + $database->createCollection('nsk_posts', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nsk_posts', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute('nsk_posts', 'views', Database::VAR_INTEGER, 0, true); + + $database->createRelationship( + collection: 'nsk_comments', + relatedCollection: 'nsk_authors', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'author', + twoWayKey: 'comments' + ); + + $database->createRelationship( + collection: 'nsk_posts', + relatedCollection: 'nsk_comments', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'comments', + twoWayKey: 'post' + ); + + $database->createRelationship( + collection: 'nsk_posts', + relatedCollection: 'nsk_tags', + type: Database::RELATION_MANY_TO_MANY, + twoWay: true, + id: 'tags', + twoWayKey: 'posts' + ); + + foreach (['nsk_author1' => 'Alice', 'nsk_author2' => 'Bob'] as $id => $name) { + $database->createDocument('nsk_authors', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'name' => $name, + ])); + } + + foreach (['nsk_tag1' => 'php', 'nsk_tag2' => 'database'] as $id => $name) { + $database->createDocument('nsk_tags', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'name' => $name, + ])); + } + + $comments = [ + ['nsk_comment1', 'First', true, 'nsk_author1'], + ['nsk_comment2', 'Second', false, 'nsk_author2'], + ['nsk_comment3', 'Third', true, 'nsk_author1'], + ]; + + foreach ($comments as [$id, $text, $approved, $author]) { + $database->createDocument('nsk_comments', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'text' => $text, + 'approved' => $approved, + 'author' => $author, + ])); + } + + $database->createDocument('nsk_posts', new Document([ + '$id' => 'nsk_post1', + '$permissions' => $permissions, + 'title' => 'Post One', + 'views' => 10, + 'comments' => ['nsk_comment1', 'nsk_comment2'], + 'tags' => ['nsk_tag1', 'nsk_tag2'], + ])); + + $database->createDocument('nsk_posts', new Document([ + '$id' => 'nsk_post2', + '$permissions' => $permissions, + 'title' => 'Post Two', + 'views' => 20, + 'comments' => ['nsk_comment3'], + 'tags' => ['nsk_tag2'], + ])); + } + + private function deleteNestedSkeletonFixture(Database $database): void + { + foreach (['nsk_posts', 'nsk_comments', 'nsk_tags', 'nsk_authors'] as $collection) { + $database->deleteCollection($collection); + } + } + + /** + * @param array $documents + * @return array + */ + private function nestedSkeletonIds(array $documents): array + { + return \array_map(fn (Document $document) => $document->getId(), $documents); + } + + public function testNestedSkeletonSiblingRelationshipStillPopulated(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSkeletonFixture($database); + + $posts = $database->find('nsk_posts', [ + Query::nested('comments', [Query::orderAsc('$id')]), + Query::orderAsc('$id'), + ]); + + $this->assertCount(2, $posts); + + $this->assertIsArray($posts[0]->getAttribute('comments')); + $this->assertSame( + ['nsk_comment1', 'nsk_comment2'], + $this->nestedSkeletonIds($posts[0]->getAttribute('comments')) + ); + + $this->assertIsArray($posts[0]->getAttribute('tags')); + $this->assertSame( + ['nsk_tag1', 'nsk_tag2'], + $this->nestedSkeletonIds($posts[0]->getAttribute('tags')) + ); + + $this->assertSame( + ['nsk_comment3'], + $this->nestedSkeletonIds($posts[1]->getAttribute('comments')) + ); + $this->assertSame( + ['nsk_tag2'], + $this->nestedSkeletonIds($posts[1]->getAttribute('tags')) + ); + + $this->deleteNestedSkeletonFixture($database); + } + + public function testNestedSkeletonSumIgnoresNested(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSkeletonFixture($database); + + $baseline = $database->sum('nsk_posts', 'views', []); + $this->assertSame(30, $baseline); + + $withNested = $database->sum('nsk_posts', 'views', [ + Query::nested('comments', [Query::limit(1)]), + ]); + $this->assertSame(30, $withNested); + + $this->deleteNestedSkeletonFixture($database); + } + + public function testNestedSkeletonCountIgnoresNested(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSkeletonFixture($database); + + $baseline = $database->count('nsk_posts', []); + $this->assertSame(2, $baseline); + + $withNested = $database->count('nsk_posts', [ + Query::nested('comments', [Query::limit(1)]), + ]); + $this->assertSame(2, $withNested); + + $this->deleteNestedSkeletonFixture($database); + } + + public function testNestedSkeletonInnerQueriesNotMutatedAcrossFinds(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSkeletonFixture($database); + + $nestedQuery = Query::nested('comments', [Query::select(['author.name'])]); + + $first = $database->find('nsk_posts', [$nestedQuery, Query::orderAsc('$id')]); + $second = $database->find('nsk_posts', [$nestedQuery, Query::orderAsc('$id')]); + $database->skipValidation(fn () => $database->getDocument('nsk_posts', 'nsk_post1', [$nestedQuery])); + + $this->assertSame(['author.name'], $nestedQuery->getValues()[0]->getValues()); + + $firstAuthor = $first[0]->getAttribute('comments')[0]->getAttribute('author'); + $secondAuthor = $second[0]->getAttribute('comments')[0]->getAttribute('author'); + + $this->assertSame( + $firstAuthor instanceof Document ? $firstAuthor->getArrayCopy() : $firstAuthor, + $secondAuthor instanceof Document ? $secondAuthor->getArrayCopy() : $secondAuthor + ); + + $this->deleteNestedSkeletonFixture($database); + } + + public function testNestedSkeletonContract(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSkeletonFixture($database); + + $found = $database->findOne('nsk_posts', [ + Query::nested('comments', [Query::limit(1)]), + Query::orderAsc('$id'), + ]); + $this->assertSame('nsk_post1', $found->getId()); + + $withoutNested = []; + foreach ($database->iterate('nsk_posts', [Query::orderAsc('$id')]) as $post) { + $withoutNested[] = $post->getId(); + } + + $withNested = []; + foreach ($database->iterate('nsk_posts', [Query::nested('comments', [Query::orderAsc('$id')]), Query::orderAsc('$id')]) as $post) { + $withNested[] = $post->getId(); + } + + $this->assertSame(['nsk_post1', 'nsk_post2'], $withoutNested); + $this->assertSame($withoutNested, $withNested); + + try { + $database->updateDocuments('nsk_posts', new Document(['views' => 1]), [ + Query::nested('comments', [Query::limit(1)]), + ]); + $this->fail('updateDocuments accepted a nested query'); + } catch (QueryException $e) { + $this->assertStringContainsString('Invalid query method: nested', $e->getMessage()); + } + + try { + $database->deleteDocuments('nsk_posts', [ + Query::nested('comments', [Query::limit(1)]), + ]); + $this->fail('deleteDocuments accepted a nested query'); + } catch (QueryException $e) { + $this->assertStringContainsString('Invalid query method: nested', $e->getMessage()); + } + + try { + $database->getDocument('nsk_posts', 'nsk_post1', [ + Query::nested('comments', [Query::limit(1)]), + ]); + $this->fail('getDocument accepted a nested query'); + } catch (QueryException $e) { + $this->assertStringContainsString('Invalid query method: nested', $e->getMessage()); + } + + $this->deleteNestedSkeletonFixture($database); + } + + public function testNestedSkeletonInvalidInnerFilterRejectedWhenParentsMatch(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSkeletonFixture($database); + + try { + $database->find('nsk_posts', [ + Query::nested('comments', [Query::equal('doesNotExist', ['x'])]), + ]); + $this->fail('An invalid inner filter was accepted'); + } catch (QueryException $e) { + $this->assertStringContainsString('doesNotExist', $e->getMessage()); + } + + $this->deleteNestedSkeletonFixture($database); + } + + public function testNestedSkeletonInvalidInnerFilterWhenNoParentsMatch(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSkeletonFixture($database); + + $posts = $database->find('nsk_posts', [ + Query::equal('title', ['No Such Post']), + Query::nested('comments', [Query::equal('doesNotExist', ['x'])]), + ]); + + $this->assertSame([], $posts); + + $this->deleteNestedSkeletonFixture($database); + } } diff --git a/tests/unit/QueryTest.php b/tests/unit/QueryTest.php index 7d1414c0fb..2bdf8187bb 100644 --- a/tests/unit/QueryTest.php +++ b/tests/unit/QueryTest.php @@ -501,6 +501,8 @@ public function testNewQueryTypesInTypesArray(): void $this->assertContains(Query::TYPE_NOT_ENDS_WITH, Query::TYPES); $this->assertContains(Query::TYPE_NOT_BETWEEN, Query::TYPES); $this->assertContains(Query::TYPE_ORDER_RANDOM, Query::TYPES); + $this->assertContains(Query::TYPE_NESTED, Query::TYPES); + $this->assertTrue(Query::isMethod(Query::TYPE_NESTED)); } public function testFingerprint(): void @@ -593,6 +595,9 @@ public function testShape(): void $elem = new Query(Query::TYPE_ELEM_MATCH, 'tags', [Query::equal('name', ['php'])]); $this->assertSame('elemMatch:tags(equal:name)', $elem->shape()); + $nested = Query::nested('comments', [Query::equal('approved', [true]), Query::limit(2)]); + $this->assertSame('nested:comments(equal:approved|limit:)', $nested->shape()); + // Deeply nested — iterative traversal must match recursive result $deep = Query::and([ Query::or([ @@ -609,4 +614,44 @@ public function testShape(): void $deep->shape(), ); } + + public function testNestedRoundTrip(): void + { + $query = Query::nested('comments', [ + Query::equal('approved', [true]), + Query::orderDesc('$createdAt'), + Query::limit(2), + Query::offset(1), + Query::cursorAfter(new Document(['$id' => 'c1'])), + ]); + + $this->assertSame(Query::TYPE_NESTED, $query->getMethod()); + $this->assertSame('comments', $query->getAttribute()); + $this->assertTrue($query->isNested()); + $this->assertInstanceOf(Document::class, $query->getValues()[4]->getValues()[0]); + + $parsed = Query::parse($query->toString()); + + $this->assertSame(Query::TYPE_NESTED, $parsed->getMethod()); + $this->assertSame('comments', $parsed->getAttribute()); + + $inner = $parsed->getValues(); + $this->assertCount(5, $inner); + + $this->assertSame(Query::TYPE_EQUAL, $inner[0]->getMethod()); + $this->assertSame('approved', $inner[0]->getAttribute()); + $this->assertSame([true], $inner[0]->getValues()); + + $this->assertSame(Query::TYPE_ORDER_DESC, $inner[1]->getMethod()); + $this->assertSame('$createdAt', $inner[1]->getAttribute()); + + $this->assertSame(Query::TYPE_LIMIT, $inner[2]->getMethod()); + $this->assertSame([2], $inner[2]->getValues()); + + $this->assertSame(Query::TYPE_OFFSET, $inner[3]->getMethod()); + $this->assertSame([1], $inner[3]->getValues()); + + $this->assertSame(Query::TYPE_CURSOR_AFTER, $inner[4]->getMethod()); + $this->assertSame(['c1'], $inner[4]->getValues()); + } } diff --git a/tests/unit/Validator/QueriesTest.php b/tests/unit/Validator/QueriesTest.php index 40e8d7671b..03ff958f65 100644 --- a/tests/unit/Validator/QueriesTest.php +++ b/tests/unit/Validator/QueriesTest.php @@ -11,6 +11,7 @@ use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\Query\Filter; use Utopia\Database\Validator\Query\Limit; +use Utopia\Database\Validator\Query\Nested; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\Query\Order; @@ -116,4 +117,44 @@ public function testValid(): void ]) ); } + + public function testOrRejectsNestedQuery(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => Database::VAR_STRING, + 'array' => false, + ]), + new Document([ + '$id' => 'comments', + 'key' => 'comments', + 'type' => Database::VAR_RELATIONSHIP, + 'array' => false, + 'options' => [ + 'relationType' => Database::RELATION_ONE_TO_MANY, + 'side' => Database::RELATION_SIDE_PARENT, + 'relatedCollection' => 'comments', + 'twoWay' => true, + 'twoWayKey' => 'post', + ], + ]), + ]; + + $validator = new Queries([ + new Filter($attributes, Database::VAR_INTEGER), + new Nested($attributes), + ]); + + $this->assertTrue($validator->isValid([Query::nested('comments', [Query::limit(1)])]), $validator->getDescription()); + + $this->assertFalse($validator->isValid([ + Query::or([ + Query::nested('comments', [Query::limit(1)]), + Query::equal('name', ['value']), + ]), + ])); + $this->assertStringContainsString('Or queries can only contain filter queries', $validator->getDescription()); + } } diff --git a/tests/unit/Validator/Query/NestedTest.php b/tests/unit/Validator/Query/NestedTest.php new file mode 100644 index 0000000000..7dc983bd1b --- /dev/null +++ b/tests/unit/Validator/Query/NestedTest.php @@ -0,0 +1,244 @@ + + */ + private function attributes(): array + { + return [ + new Document([ + '$id' => 'title', + 'key' => 'title', + 'type' => Database::VAR_STRING, + 'array' => false, + ]), + new Document([ + '$id' => 'comments', + 'key' => 'comments', + 'type' => Database::VAR_RELATIONSHIP, + 'array' => false, + 'options' => [ + 'relationType' => Database::RELATION_ONE_TO_MANY, + 'side' => Database::RELATION_SIDE_PARENT, + 'relatedCollection' => 'comments', + 'twoWay' => true, + 'twoWayKey' => 'post', + ], + ]), + new Document([ + '$id' => 'tags', + 'key' => 'tags', + 'type' => Database::VAR_RELATIONSHIP, + 'array' => false, + 'options' => [ + 'relationType' => Database::RELATION_MANY_TO_MANY, + 'side' => Database::RELATION_SIDE_PARENT, + 'relatedCollection' => 'tags', + 'twoWay' => true, + 'twoWayKey' => 'posts', + ], + ]), + new Document([ + '$id' => 'profile', + 'key' => 'profile', + 'type' => Database::VAR_RELATIONSHIP, + 'array' => false, + 'options' => [ + 'relationType' => Database::RELATION_ONE_TO_ONE, + 'side' => Database::RELATION_SIDE_PARENT, + 'relatedCollection' => 'profiles', + 'twoWay' => true, + 'twoWayKey' => 'post', + ], + ]), + new Document([ + '$id' => 'author', + 'key' => 'author', + 'type' => Database::VAR_RELATIONSHIP, + 'array' => false, + 'options' => [ + 'relationType' => Database::RELATION_MANY_TO_ONE, + 'side' => Database::RELATION_SIDE_PARENT, + 'relatedCollection' => 'authors', + 'twoWay' => true, + 'twoWayKey' => 'posts', + ], + ]), + new Document([ + '$id' => 'owner', + 'key' => 'owner', + 'type' => Database::VAR_RELATIONSHIP, + 'array' => false, + 'options' => [ + 'relationType' => Database::RELATION_ONE_TO_MANY, + 'side' => Database::RELATION_SIDE_CHILD, + 'relatedCollection' => 'owners', + 'twoWay' => true, + 'twoWayKey' => 'items', + ], + ]), + ]; + } + + public function testAcceptsInnerQueriesOnPluralRelationship(): void + { + $validator = new Nested($this->attributes()); + + $this->assertTrue($validator->isValid(Query::nested('comments', [ + Query::equal('approved', [true]), + Query::orderDesc('$createdAt'), + Query::limit(2), + Query::offset(1), + Query::cursorAfter(new Document(['$id' => 'comment1'])), + ]))); + + $this->assertTrue($validator->isValid(Query::nested('tags', [ + Query::select(['name']), + Query::limit(5), + ]))); + } + + public function testRejectsWrongMethod(): void + { + $validator = new Nested($this->attributes()); + + $this->assertFalse($validator->isValid(Query::limit(1))); + $this->assertSame('Invalid query method: limit', $validator->getDescription()); + } + + public function testRejectsNonRelationshipAttribute(): void + { + $validator = new Nested($this->attributes()); + + $this->assertFalse($validator->isValid(Query::nested('title', [Query::limit(1)]))); + $this->assertSame( + 'Nested queries can only be used on relationship attributes: title', + $validator->getDescription() + ); + } + + public function testRejectsUnknownAttribute(): void + { + $validator = new Nested($this->attributes()); + + $this->assertFalse($validator->isValid(Query::nested('doesNotExist', [Query::limit(1)]))); + $this->assertSame( + 'Nested queries can only be used on relationship attributes: doesNotExist', + $validator->getDescription() + ); + } + + public function testRejectsEmptyAttribute(): void + { + $validator = new Nested($this->attributes()); + + $this->assertFalse($validator->isValid(Query::nested('', [Query::limit(1)]))); + $this->assertSame('Nested queries require a relationship attribute', $validator->getDescription()); + } + + public function testRejectsNonQueryValues(): void + { + $validator = new Nested($this->attributes()); + + $this->assertFalse($validator->isValid(new Query(Query::TYPE_NESTED, 'comments', ['approved']))); + $this->assertSame('Nested queries can only contain queries', $validator->getDescription()); + + $this->assertFalse($validator->isValid(Query::nested('comments', []))); + $this->assertSame('Nested queries can only contain queries', $validator->getDescription()); + } + + public function testRejectsNestedInNested(): void + { + $validator = new Nested($this->attributes()); + + $this->assertFalse($validator->isValid(Query::nested('comments', [ + Query::nested('author', [Query::limit(1)]), + ]))); + $this->assertSame('Nested queries cannot be nested', $validator->getDescription()); + } + + /** + * @return array + */ + public static function singularRelationships(): array + { + return [ + 'oneToOne' => ['profile'], + 'manyToOne parent' => ['author'], + 'oneToMany child' => ['owner'], + ]; + } + + /** + * @dataProvider singularRelationships + */ + public function testRejectsPaginationOnSingularRelationship(string $attribute): void + { + $validator = new Nested($this->attributes()); + + foreach ([Query::limit(1), Query::offset(1), Query::cursorAfter(new Document(['$id' => 'x1']))] as $pagination) { + $this->assertFalse($validator->isValid(Query::nested($attribute, [$pagination]))); + $this->assertSame( + 'Nested pagination is not supported on a singular relationship: ' . $attribute, + $validator->getDescription() + ); + } + } + + /** + * @dataProvider singularRelationships + */ + public function testAcceptsFiltersOnSingularRelationship(string $attribute): void + { + $validator = new Nested($this->attributes()); + + $this->assertTrue($validator->isValid(Query::nested($attribute, [ + Query::equal('name', ['Alice']), + Query::select(['name']), + ]))); + } + + public function testRejectsInvalidInnerLimit(): void + { + $validator = new Nested($this->attributes()); + + $this->assertFalse($validator->isValid(Query::nested('comments', [Query::limit(0)]))); + $this->assertStringContainsString('Invalid limit', $validator->getDescription()); + + $this->assertFalse($validator->isValid(Query::nested('comments', [Query::limit(-1)]))); + $this->assertStringContainsString('Invalid limit', $validator->getDescription()); + } + + public function testRejectsInvalidInnerCursor(): void + { + $validator = new Nested($this->attributes(), 4); + + $this->assertFalse($validator->isValid(Query::nested('comments', [Query::cursorAfter(new Document(['$id' => 'waytoolongforfour']))]))); + $this->assertStringContainsString('Invalid cursor', $validator->getDescription()); + } + + public function testUnknownAttributeAcceptedWithoutAttributeSupport(): void + { + $validator = new Nested($this->attributes(), 36, false); + + $this->assertTrue($validator->isValid(Query::nested('doesNotExist', [Query::limit(1)]))); + $this->assertTrue($validator->isValid(Query::nested('profile', [Query::limit(1)]))); + } + + public function testGetMethodType(): void + { + $validator = new Nested($this->attributes()); + + $this->assertSame(Nested::METHOD_TYPE_NESTED, $validator->getMethodType()); + } +} From 3a1d6f0a839b11d28d95196a396477f387901ddf Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 11 Sep 2026 05:19:36 +1200 Subject: [PATCH 02/12] feat(relationships): constrain populated children with dotted filters A top-level dotted filter such as Query::equal('comments.approved', [true]) narrowed the parent set but left every child in the populated array, so a caller asking for posts with approved comments got those posts back carrying their unapproved comments too. The parent-set rewrite and the population pass disagreed about what the query meant. processRelationshipQueries now mirrors a dotted filter into the nested selection bucket for its relationship with the first path segment stripped, so the same predicate reaches the child find that the four populators already spread their queries into. Depth beyond one level falls out of the existing breadth-first re-entry: each level strips one more segment. The original query object is left untouched because convertRelationshipQueries reads it immediately afterwards to build the parent-set rewrite, and that rewrite must not change. Dotted children of and/or are deliberately skipped: an and/or carries no attribute of its own, and lifting one out would constrain the child array while the parent set still matched every row. Co-Authored-By: Claude Fable 5.1 --- src/Database/Database.php | 32 ++ .../e2e/Adapter/Scopes/RelationshipTests.php | 525 ++++++++++++++++++ 2 files changed, 557 insertions(+) diff --git a/src/Database/Database.php b/src/Database/Database.php index ea4587a857..260b5c89b9 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -10236,6 +10236,38 @@ private function processRelationshipQueries( continue; } + if ( + !\in_array($query->getMethod(), [ + Query::TYPE_SELECT, + Query::TYPE_LIMIT, + Query::TYPE_OFFSET, + Query::TYPE_CURSOR_AFTER, + Query::TYPE_CURSOR_BEFORE, + Query::TYPE_ORDER_ASC, + Query::TYPE_ORDER_DESC, + Query::TYPE_ORDER_RANDOM, + ], true) + && \str_contains($query->getAttribute(), '.') + ) { + $nesting = \explode('.', $query->getAttribute()); + $filteredKey = \array_shift($nesting); + + $relationship = \array_values(\array_filter( + $relationships, + fn (Document $relationship) => $relationship->getAttribute('key') === $filteredKey, + ))[0] ?? null; + + if ($relationship) { + $nestedSelections[$filteredKey][] = new Query( + $query->getMethod(), + \implode('.', $nesting), + $query->getValues(), + ); + } + + continue; + } + if ($query->getMethod() !== Query::TYPE_SELECT) { continue; } diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 9ef817a974..b787a4e0c4 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -5161,4 +5161,529 @@ public function testNestedSkeletonInvalidInnerFilterWhenNoParentsMatch(): void $this->deleteNestedSkeletonFixture($database); } + + /** + * @param array $documents + * @return array + */ + private function nestedFilterIds(array $documents): array + { + return \array_map(fn (Document $document) => $document->getId(), $documents); + } + + public function testNestedFilterOneToManyConstrainsChildren(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection('nf_posts', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nf_posts', 'title', Database::VAR_STRING, 255, true); + + $database->createCollection('nf_comments', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nf_comments', 'text', Database::VAR_STRING, 255, true); + $database->createAttribute('nf_comments', 'approved', Database::VAR_BOOLEAN, 0, true); + + $database->createRelationship( + collection: 'nf_posts', + relatedCollection: 'nf_comments', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'comments', + twoWayKey: 'post' + ); + + foreach (['nf_post_a' => 'A', 'nf_post_b' => 'B', 'nf_post_c' => 'C'] as $id => $title) { + $database->createDocument('nf_posts', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'title' => $title, + ])); + } + + $comments = [ + ['nf_ca1', true, 'nf_post_a'], + ['nf_ca2', true, 'nf_post_a'], + ['nf_ca3', false, 'nf_post_a'], + ['nf_ca4', false, 'nf_post_a'], + ['nf_cb1', true, 'nf_post_b'], + ['nf_cb2', false, 'nf_post_b'], + ['nf_cb3', false, 'nf_post_b'], + ['nf_cb4', false, 'nf_post_b'], + ['nf_cc1', false, 'nf_post_c'], + ['nf_cc2', false, 'nf_post_c'], + ]; + + foreach ($comments as [$id, $approved, $post]) { + $database->createDocument('nf_comments', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'text' => $id, + 'approved' => $approved, + 'post' => $post, + ])); + } + + $posts = $database->find('nf_posts', [ + Query::equal('comments.approved', [true]), + Query::orderAsc('$id'), + ]); + + $this->assertSame(['nf_post_a', 'nf_post_b'], $this->nestedFilterIds($posts)); + $this->assertSame(['nf_ca1', 'nf_ca2'], $this->nestedFilterIds($posts[0]->getAttribute('comments'))); + $this->assertSame(['nf_cb1'], $this->nestedFilterIds($posts[1]->getAttribute('comments'))); + + $database->deleteCollection('nf_posts'); + $database->deleteCollection('nf_comments'); + } + + public function testNestedFilterManyToOneConstrainsChild(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection('nf_authors', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nf_authors', 'name', Database::VAR_STRING, 255, true); + + $database->createCollection('nf_notes', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nf_notes', 'body', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'nf_notes', + relatedCollection: 'nf_authors', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'author', + twoWayKey: 'notes' + ); + + foreach (['nf_alice' => 'Alice', 'nf_bob' => 'Bob'] as $id => $name) { + $database->createDocument('nf_authors', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'name' => $name, + ])); + } + + foreach (['nf_note1' => 'nf_alice', 'nf_note2' => 'nf_bob', 'nf_note3' => 'nf_alice'] as $id => $author) { + $database->createDocument('nf_notes', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'body' => $id, + 'author' => $author, + ])); + } + + $notes = $database->find('nf_notes', [ + Query::equal('author.name', ['Alice']), + Query::orderAsc('$id'), + ]); + + $this->assertSame(['nf_note1', 'nf_note3'], $this->nestedFilterIds($notes)); + + foreach ($notes as $note) { + $author = $note->getAttribute('author'); + $this->assertInstanceOf(Document::class, $author); + $this->assertSame('nf_alice', $author->getId()); + $this->assertSame('Alice', $author->getAttribute('name')); + } + + $authors = $database->find('nf_authors', [ + Query::equal('notes.body', ['nf_note1']), + ]); + + $this->assertSame(['nf_alice'], $this->nestedFilterIds($authors)); + $this->assertSame(['nf_note1'], $this->nestedFilterIds($authors[0]->getAttribute('notes'))); + + $database->deleteCollection('nf_notes'); + $database->deleteCollection('nf_authors'); + } + + public function testNestedFilterManyToManyConstrainsChildren(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection('nf_tags', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nf_tags', 'color', Database::VAR_STRING, 255, true); + + $database->createCollection('nf_articles', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nf_articles', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'nf_articles', + relatedCollection: 'nf_tags', + type: Database::RELATION_MANY_TO_MANY, + twoWay: true, + id: 'tags', + twoWayKey: 'articles' + ); + + foreach (['nf_tag_red1' => 'red', 'nf_tag_red2' => 'red', 'nf_tag_blue1' => 'blue'] as $id => $color) { + $database->createDocument('nf_tags', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'color' => $color, + ])); + } + + $articles = [ + ['nf_art1', ['nf_tag_red1', 'nf_tag_blue1']], + ['nf_art2', ['nf_tag_blue1']], + ['nf_art3', ['nf_tag_red1', 'nf_tag_red2']], + ]; + + foreach ($articles as [$id, $tags]) { + $database->createDocument('nf_articles', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'title' => $id, + 'tags' => $tags, + ])); + } + + $found = $database->find('nf_articles', [ + Query::equal('tags.color', ['red']), + Query::orderAsc('$id'), + ]); + + $this->assertSame(['nf_art1', 'nf_art3'], $this->nestedFilterIds($found)); + $this->assertSame(['nf_tag_red1'], $this->nestedFilterIds($found[0]->getAttribute('tags'))); + $this->assertSame(['nf_tag_red1', 'nf_tag_red2'], $this->nestedFilterIds($found[1]->getAttribute('tags'))); + + $database->deleteCollection('nf_articles'); + $database->deleteCollection('nf_tags'); + } + + public function testNestedFilterOneToOneConstrainsChild(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection('nf_profiles', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nf_profiles', 'verified', Database::VAR_BOOLEAN, 0, true); + + $database->createCollection('nf_users', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nf_users', 'name', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'nf_users', + relatedCollection: 'nf_profiles', + type: Database::RELATION_ONE_TO_ONE, + twoWay: true, + id: 'profile', + twoWayKey: 'user' + ); + + foreach (['nf_profile1' => true, 'nf_profile2' => false] as $id => $verified) { + $database->createDocument('nf_profiles', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'verified' => $verified, + ])); + } + + foreach (['nf_user1' => 'nf_profile1', 'nf_user2' => 'nf_profile2'] as $id => $profile) { + $database->createDocument('nf_users', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'name' => $id, + 'profile' => $profile, + ])); + } + + $users = $database->find('nf_users', [ + Query::equal('profile.verified', [true]), + Query::orderAsc('$id'), + ]); + + $this->assertSame(['nf_user1'], $this->nestedFilterIds($users)); + + $profile = $users[0]->getAttribute('profile'); + $this->assertInstanceOf(Document::class, $profile); + $this->assertSame('nf_profile1', $profile->getId()); + $this->assertTrue($profile->getAttribute('verified')); + + $database->deleteCollection('nf_users'); + $database->deleteCollection('nf_profiles'); + } + + public function testNestedFilterDepthTwo(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection('nf_dt_authors', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nf_dt_authors', 'name', Database::VAR_STRING, 255, true); + + $database->createCollection('nf_dt_comments', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nf_dt_comments', 'text', Database::VAR_STRING, 255, true); + + $database->createCollection('nf_dt_posts', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nf_dt_posts', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'nf_dt_comments', + relatedCollection: 'nf_dt_authors', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'author', + twoWayKey: 'comments' + ); + + $database->createRelationship( + collection: 'nf_dt_posts', + relatedCollection: 'nf_dt_comments', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'comments', + twoWayKey: 'post' + ); + + foreach (['nf_dt_alice' => 'Alice', 'nf_dt_bob' => 'Bob'] as $id => $name) { + $database->createDocument('nf_dt_authors', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'name' => $name, + ])); + } + + foreach (['nf_dt_post1', 'nf_dt_post2', 'nf_dt_post3'] as $id) { + $database->createDocument('nf_dt_posts', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'title' => $id, + ])); + } + + $comments = [ + ['nf_dt_c1', 'nf_dt_post1', 'nf_dt_alice'], + ['nf_dt_c2', 'nf_dt_post1', 'nf_dt_bob'], + ['nf_dt_c3', 'nf_dt_post2', 'nf_dt_bob'], + ['nf_dt_c4', 'nf_dt_post3', 'nf_dt_alice'], + ]; + + foreach ($comments as [$id, $post, $author]) { + $database->createDocument('nf_dt_comments', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'text' => $id, + 'post' => $post, + 'author' => $author, + ])); + } + + $posts = $database->find('nf_dt_posts', [ + Query::equal('comments.author.name', ['Alice']), + Query::orderAsc('$id'), + ]); + + $this->assertSame(['nf_dt_post1', 'nf_dt_post3'], $this->nestedFilterIds($posts)); + $this->assertSame(['nf_dt_c1'], $this->nestedFilterIds($posts[0]->getAttribute('comments'))); + $this->assertSame(['nf_dt_c4'], $this->nestedFilterIds($posts[1]->getAttribute('comments'))); + + foreach ($posts as $post) { + foreach ($post->getAttribute('comments') as $comment) { + $author = $comment->getAttribute('author'); + $this->assertInstanceOf(Document::class, $author); + $this->assertSame('nf_dt_alice', $author->getId()); + $this->assertSame('Alice', $author->getAttribute('name')); + } + } + + $database->deleteCollection('nf_dt_posts'); + $database->deleteCollection('nf_dt_comments'); + $database->deleteCollection('nf_dt_authors'); + } + + public function testNestedFilterSiblingRelationshipStillPopulated(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection('nf_blog_comments', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nf_blog_comments', 'approved', Database::VAR_BOOLEAN, 0, true); + + $database->createCollection('nf_blog_tags', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nf_blog_tags', 'name', Database::VAR_STRING, 255, true); + + $database->createCollection('nf_blogs', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nf_blogs', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'nf_blogs', + relatedCollection: 'nf_blog_comments', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'comments', + twoWayKey: 'blog' + ); + + $database->createRelationship( + collection: 'nf_blogs', + relatedCollection: 'nf_blog_tags', + type: Database::RELATION_MANY_TO_MANY, + twoWay: true, + id: 'tags', + twoWayKey: 'blogs' + ); + + foreach (['nf_btag1' => 'php', 'nf_btag2' => 'database'] as $id => $name) { + $database->createDocument('nf_blog_tags', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'name' => $name, + ])); + } + + $database->createDocument('nf_blogs', new Document([ + '$id' => 'nf_blog1', + '$permissions' => $permissions, + 'title' => 'Blog One', + 'tags' => ['nf_btag1', 'nf_btag2'], + ])); + + foreach (['nf_bc1' => true, 'nf_bc2' => false] as $id => $approved) { + $database->createDocument('nf_blog_comments', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'approved' => $approved, + 'blog' => 'nf_blog1', + ])); + } + + $blogs = $database->find('nf_blogs', [ + Query::equal('comments.approved', [true]), + ]); + + $this->assertSame(['nf_blog1'], $this->nestedFilterIds($blogs)); + $this->assertSame(['nf_bc1'], $this->nestedFilterIds($blogs[0]->getAttribute('comments'))); + $this->assertSame(['nf_btag1', 'nf_btag2'], $this->nestedFilterIds($blogs[0]->getAttribute('tags'))); + + $database->deleteCollection('nf_blogs'); + $database->deleteCollection('nf_blog_tags'); + $database->deleteCollection('nf_blog_comments'); + } + + public function testNestedFilterGetDocumentRejectsDottedFilter(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection('nf_doc_comments', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nf_doc_comments', 'approved', Database::VAR_BOOLEAN, 0, true); + + $database->createCollection('nf_docs', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nf_docs', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'nf_docs', + relatedCollection: 'nf_doc_comments', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'comments', + twoWayKey: 'doc' + ); + + $database->createDocument('nf_docs', new Document([ + '$id' => 'nf_doc1', + '$permissions' => $permissions, + 'title' => 'Doc One', + ])); + + try { + $database->getDocument('nf_docs', 'nf_doc1', [ + Query::equal('comments.approved', [true]), + ]); + $this->fail('getDocument accepted a dotted filter'); + } catch (QueryException $e) { + $this->assertStringContainsString('Invalid query method', $e->getMessage()); + } + + $database->deleteCollection('nf_docs'); + $database->deleteCollection('nf_doc_comments'); + } } From bf479eebe7257779c34986a7f877cc1d2df36693 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 11 Sep 2026 05:16:04 +1200 Subject: [PATCH 03/12] feat(relationships): honour a nested order in many-to-many population The many-to-many populator already spreads a nested query's inner queries into the child find(), so the adapter returns the related rows in the requested order. The per-parent rebuild then discarded that by walking the junction rows and looking each id up in a map, so a caller asking for Query::nested('tags', [Query::orderDesc('name')]) still got junction insertion order. When an order query is present among the nested queries, build each parent's array by walking the already-ordered result set and keeping the ids that parent's junction rows name. With no order query the original lookup loop runs unchanged, so junction insertion order stays the documented default. Co-Authored-By: Claude Fable 5.1 --- src/Database/Database.php | 20 +- .../Scopes/Relationships/ManyToManyTests.php | 182 ++++++++++++++++++ 2 files changed, 198 insertions(+), 4 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 260b5c89b9..efd3da6a7d 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5641,14 +5641,26 @@ private function populateManyToManyRelationshipsBatch( $relatedById[$doc->getId()] = $doc; } - // Build final related arrays maintaining junction order + $ordered = Query::groupByType($queries)['orderTypes'] !== []; + foreach ($junctionsByDocumentId as $documentId => $relatedDocIds) { $documentRelated = []; - foreach ($relatedDocIds as $relatedId) { - if (isset($relatedById[$relatedId])) { - $documentRelated[] = $relatedById[$relatedId]; + + if ($ordered) { + $wanted = \array_flip($relatedDocIds); + foreach ($foundRelated as $doc) { + if (isset($wanted[$doc->getId()])) { + $documentRelated[] = $doc; + } + } + } else { + foreach ($relatedDocIds as $relatedId) { + if (isset($relatedById[$relatedId])) { + $documentRelated[] = $relatedById[$relatedId]; + } } } + $related[$documentId] = $documentRelated; } } diff --git a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php index 73783270e2..37ad34988f 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php @@ -2400,4 +2400,186 @@ public function testNestedManyToManyRelationshipQueries(): void $database->deleteCollection('products'); $database->deleteCollection('tags'); } + + private function createM2mNestedOrderFixture(Database $database): void + { + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection('m2mno_tracks', permissions: $permissions, documentSecurity: false); + $database->createAttribute('m2mno_tracks', 'name', Database::VAR_STRING, 255, true); + + $database->createCollection('m2mno_albums', permissions: $permissions, documentSecurity: false); + $database->createAttribute('m2mno_albums', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'm2mno_albums', + relatedCollection: 'm2mno_tracks', + type: Database::RELATION_MANY_TO_MANY, + twoWay: true, + id: 'tracks', + twoWayKey: 'albums' + ); + + foreach (['m2mno_track_b' => 'Beta', 'm2mno_track_a' => 'Alpha', 'm2mno_track_c' => 'Gamma'] as $id => $name) { + $database->createDocument('m2mno_tracks', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'name' => $name, + ])); + } + + $database->createDocument('m2mno_albums', new Document([ + '$id' => 'm2mno_album1', + '$permissions' => $permissions, + 'title' => 'Album One', + 'tracks' => ['m2mno_track_b', 'm2mno_track_a', 'm2mno_track_c'], + ])); + + $database->createDocument('m2mno_albums', new Document([ + '$id' => 'm2mno_album2', + '$permissions' => $permissions, + 'title' => 'Album Two', + 'tracks' => ['m2mno_track_c', 'm2mno_track_a'], + ])); + } + + private function deleteM2mNestedOrderFixture(Database $database): void + { + $database->deleteCollection('m2mno_albums'); + $database->deleteCollection('m2mno_tracks'); + } + + /** + * @param mixed $documents + * @return array + */ + private function m2mNestedOrderIds(mixed $documents): array + { + $this->assertIsArray($documents); + + return \array_map(fn (Document $document) => $document->getId(), $documents); + } + + public function testM2mNestedOrderDescending(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createM2mNestedOrderFixture($database); + + $albums = $database->find('m2mno_albums', [ + Query::equal('$id', ['m2mno_album1']), + Query::nested('tracks', [Query::orderDesc('name')]), + ]); + + $this->assertCount(1, $albums); + $this->assertSame( + ['m2mno_track_c', 'm2mno_track_b', 'm2mno_track_a'], + $this->m2mNestedOrderIds($albums[0]->getAttribute('tracks')) + ); + + $this->deleteM2mNestedOrderFixture($database); + } + + public function testM2mNestedOrderAscending(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createM2mNestedOrderFixture($database); + + $albums = $database->find('m2mno_albums', [ + Query::equal('$id', ['m2mno_album1']), + Query::nested('tracks', [Query::orderAsc('name')]), + ]); + + $this->assertCount(1, $albums); + $this->assertSame( + ['m2mno_track_a', 'm2mno_track_b', 'm2mno_track_c'], + $this->m2mNestedOrderIds($albums[0]->getAttribute('tracks')) + ); + + $this->deleteM2mNestedOrderFixture($database); + } + + public function testM2mNestedOrderPerParentWithOverlap(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createM2mNestedOrderFixture($database); + + $albums = $database->find('m2mno_albums', [ + Query::nested('tracks', [Query::orderAsc('name')]), + Query::orderAsc('$id'), + ]); + + $this->assertCount(2, $albums); + $this->assertSame( + ['m2mno_track_a', 'm2mno_track_b', 'm2mno_track_c'], + $this->m2mNestedOrderIds($albums[0]->getAttribute('tracks')) + ); + $this->assertSame( + ['m2mno_track_a', 'm2mno_track_c'], + $this->m2mNestedOrderIds($albums[1]->getAttribute('tracks')) + ); + + $this->deleteM2mNestedOrderFixture($database); + } + + public function testM2mNestedOrderJunctionOrderPreservedWithoutOrder(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createM2mNestedOrderFixture($database); + + $albums = $database->find('m2mno_albums', [ + Query::equal('$id', ['m2mno_album1']), + ]); + + $this->assertCount(1, $albums); + $this->assertSame( + ['m2mno_track_b', 'm2mno_track_a', 'm2mno_track_c'], + $this->m2mNestedOrderIds($albums[0]->getAttribute('tracks')) + ); + + $albums = $database->find('m2mno_albums', [ + Query::equal('$id', ['m2mno_album1']), + Query::nested('tracks', [Query::select(['name'])]), + ]); + + $this->assertCount(1, $albums); + $this->assertSame( + ['m2mno_track_b', 'm2mno_track_a', 'm2mno_track_c'], + $this->m2mNestedOrderIds($albums[0]->getAttribute('tracks')) + ); + + $this->deleteM2mNestedOrderFixture($database); + } } From 4178761364caf2b16dd780c91bdf4ebc170c8c21 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 11 Sep 2026 05:25:21 +1200 Subject: [PATCH 04/12] feat(relationships): apply nested pagination per parent A nested relationship query's inner limit/offset/cursor were spread into the batched child fetch alongside the pre-seeded Query::limit(PHP_INT_MAX). First-wins in groupByType() silently dropped the inner limit, and offset and cursor - which are not pre-seeded - applied once across the whole batch, so three parents asking for two children each shared one two-row window and a cursor from one parent's page truncated everybody else's. Withhold pagination from the child fetch and slice each parent's grouped array instead, then return the union of the survivors so the breadth-first traversal fans out only from children that are actually reachable in the result. A cursor is resolved inside the parent's own array, so a cursor that belongs to another parent yields an empty page for this one rather than an adapter error about a foreign collection. Co-Authored-By: Claude Fable 5.1 --- src/Database/Database.php | 172 +++++- .../e2e/Adapter/Scopes/RelationshipTests.php | 553 ++++++++++++++++++ 2 files changed, 695 insertions(+), 30 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index efd3da6a7d..16f904aef4 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5408,12 +5408,21 @@ private function populateOneToManyRelationshipsBatch( // For batch relationship population, we need to fetch documents with all attributes // to enable proper grouping by back-reference, then apply selects afterward $selectQueries = []; + $paginationQueries = []; $otherQueries = []; foreach ($queries as $query) { - if ($query->getMethod() === Query::TYPE_SELECT) { - $selectQueries[] = $query; - } else { - $otherQueries[] = $query; + switch ($query->getMethod()) { + case Query::TYPE_SELECT: + $selectQueries[] = $query; + break; + case Query::TYPE_LIMIT: + case Query::TYPE_OFFSET: + case Query::TYPE_CURSOR_AFTER: + case Query::TYPE_CURSOR_BEFORE: + $paginationQueries[] = $query; + break; + default: + $otherQueries[] = $query; } } @@ -5428,12 +5437,10 @@ private function populateOneToManyRelationshipsBatch( \array_push($relatedDocuments, ...$chunkDocs); } - // Group related documents by parent ID $relatedByParentId = []; foreach ($relatedDocuments as $related) { $parentId = $related->getAttribute($twoWayKey); if (!\is_null($parentId)) { - // Handle case where parentId might be a Document object instead of string $parentKey = $parentId instanceof Document ? $parentId->getId() : $parentId; @@ -5441,22 +5448,34 @@ private function populateOneToManyRelationshipsBatch( if (!isset($relatedByParentId[$parentKey])) { $relatedByParentId[$parentKey] = []; } - // We don't remove the back-reference here because documents may be reused across fetches - // Cycles are prevented by depth limiting in breadth-first traversal + // The back-reference stays until the traversal removes it, because these + // documents may be reused across fetches. Cycles are prevented by depth limiting. $relatedByParentId[$parentKey][] = $related; } } $this->applySelectFiltersToDocuments($relatedDocuments, $selectQueries); - // Assign related documents to their parent documents + $pagination = Query::groupByType($paginationQueries); + $survivors = []; + foreach ($documents as $document) { - $parentId = $document->getId(); - $relatedDocs = $relatedByParentId[$parentId] ?? []; + $relatedDocs = $this->sliceRelated( + $relatedByParentId[$document->getId()] ?? [], + $pagination['limit'], + $pagination['offset'], + $pagination['cursor'], + $pagination['cursorDirection'], + ); + $document->setAttribute($key, $relatedDocs); + + foreach ($relatedDocs as $relatedDoc) { + $survivors[$relatedDoc->getId()] = $relatedDoc; + } } - return $relatedDocuments; + return \array_values($survivors); } /** @@ -5505,12 +5524,21 @@ private function populateManyToOneRelationshipsBatch( } $selectQueries = []; + $paginationQueries = []; $otherQueries = []; foreach ($queries as $query) { - if ($query->getMethod() === Query::TYPE_SELECT) { - $selectQueries[] = $query; - } else { - $otherQueries[] = $query; + switch ($query->getMethod()) { + case Query::TYPE_SELECT: + $selectQueries[] = $query; + break; + case Query::TYPE_LIMIT: + case Query::TYPE_OFFSET: + case Query::TYPE_CURSOR_AFTER: + case Query::TYPE_CURSOR_BEFORE: + $paginationQueries[] = $query; + break; + default: + $otherQueries[] = $query; } } @@ -5525,12 +5553,10 @@ private function populateManyToOneRelationshipsBatch( \array_push($relatedDocuments, ...$chunkDocs); } - // Group related documents by child ID $relatedByChildId = []; foreach ($relatedDocuments as $related) { $childId = $related->getAttribute($twoWayKey); if (!\is_null($childId)) { - // Handle case where childId might be a Document object instead of string $childKey = $childId instanceof Document ? $childId->getId() : $childId; @@ -5538,20 +5564,34 @@ private function populateManyToOneRelationshipsBatch( if (!isset($relatedByChildId[$childKey])) { $relatedByChildId[$childKey] = []; } - // We don't remove the back-reference here because documents may be reused across fetches - // Cycles are prevented by depth limiting in breadth-first traversal + // The back-reference stays until the traversal removes it, because these + // documents may be reused across fetches. Cycles are prevented by depth limiting. $relatedByChildId[$childKey][] = $related; } } $this->applySelectFiltersToDocuments($relatedDocuments, $selectQueries); + $pagination = Query::groupByType($paginationQueries); + $survivors = []; + foreach ($documents as $document) { - $childId = $document->getId(); - $document->setAttribute($key, $relatedByChildId[$childId] ?? []); + $relatedDocs = $this->sliceRelated( + $relatedByChildId[$document->getId()] ?? [], + $pagination['limit'], + $pagination['offset'], + $pagination['cursor'], + $pagination['cursorDirection'], + ); + + $document->setAttribute($key, $relatedDocs); + + foreach ($relatedDocs as $relatedDoc) { + $survivors[$relatedDoc->getId()] = $relatedDoc; + } } - return $relatedDocuments; + return \array_values($survivors); } /** @@ -5591,6 +5631,21 @@ private function populateManyToManyRelationshipsBatch( return []; } + $paginationQueries = []; + $fetchQueries = []; + foreach ($queries as $query) { + switch ($query->getMethod()) { + case Query::TYPE_LIMIT: + case Query::TYPE_OFFSET: + case Query::TYPE_CURSOR_AFTER: + case Query::TYPE_CURSOR_BEFORE: + $paginationQueries[] = $query; + break; + default: + $fetchQueries[] = $query; + } + } + $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); $junctions = []; @@ -5620,7 +5675,6 @@ private function populateManyToManyRelationshipsBatch( } $related = []; - $allRelatedDocs = []; if (!empty($relatedIds)) { $uniqueRelatedIds = array_unique($relatedIds); $foundRelated = []; @@ -5629,13 +5683,11 @@ private function populateManyToManyRelationshipsBatch( $chunkDocs = $this->find($relatedCollection->getId(), [ Query::equal('$id', $chunk), Query::limit(PHP_INT_MAX), - ...$queries + ...$fetchQueries ]); \array_push($foundRelated, ...$chunkDocs); } - $allRelatedDocs = $foundRelated; - $relatedById = []; foreach ($foundRelated as $doc) { $relatedById[$doc->getId()] = $doc; @@ -5665,12 +5717,72 @@ private function populateManyToManyRelationshipsBatch( } } + $pagination = Query::groupByType($paginationQueries); + $survivors = []; + foreach ($documents as $document) { - $documentId = $document->getId(); - $document->setAttribute($key, $related[$documentId] ?? []); + $relatedDocs = $this->sliceRelated( + $related[$document->getId()] ?? [], + $pagination['limit'], + $pagination['offset'], + $pagination['cursor'], + $pagination['cursorDirection'], + ); + + $document->setAttribute($key, $relatedDocs); + + foreach ($relatedDocs as $relatedDoc) { + $survivors[$relatedDoc->getId()] = $relatedDoc; + } + } + + return \array_values($survivors); + } + + /** + * Apply a nested relationship's pagination to one parent's related documents + * + * @param array $documents + * @return array + */ + private function sliceRelated( + array $documents, + ?int $limit, + ?int $offset, + Document|string|null $cursor, + ?string $cursorDirection + ): array { + $documents = \array_values($documents); + + if ($cursor === null) { + return \array_slice($documents, $offset ?? 0, $limit); + } + + $cursorId = $cursor instanceof Document ? $cursor->getId() : $cursor; + $position = null; + + foreach ($documents as $index => $document) { + if ($document->getId() === $cursorId) { + $position = $index; + break; + } + } + + if ($position === null) { + return []; + } + + if ($cursorDirection === Database::CURSOR_BEFORE) { + $preceding = \array_slice($documents, 0, $position); + + if ($limit === null) { + return $preceding; + } + + return $limit === 0 ? [] : \array_slice($preceding, -$limit); } - return $allRelatedDocs; + return \array_slice($documents, $position + 1, $limit); } /** diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index b787a4e0c4..a4c3863b08 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -5686,4 +5686,557 @@ public function testNestedFilterGetDocumentRejectsDottedFilter(): void $database->deleteCollection('nf_docs'); $database->deleteCollection('nf_doc_comments'); } + + private static int $nestedSliceAuthorReads = 0; + + /** + * @return array + */ + private function nestedSlicePermissions(): array + { + return [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + } + + /** + * @param array $documents + * @return array + */ + private function nestedSliceIds(array $documents): array + { + return \array_map(fn (Document $document) => $document->getId(), $documents); + } + + /** + * @param array $collections + */ + private function dropNestedSliceCollections(Database $database, array $collections): void + { + foreach ($collections as $collection) { + if (!$database->silent(fn () => $database->getCollection($collection))->isEmpty()) { + $database->deleteCollection($collection); + } + } + } + + private function createNestedSliceFixture(Database $database): void + { + $this->deleteNestedSliceFixture($database); + + $permissions = $this->nestedSlicePermissions(); + + Database::addFilter( + 'nsSliceAuthorSpy', + fn (mixed $value) => $value, + function (mixed $value) { + self::$nestedSliceAuthorReads++; + return $value; + } + ); + + $database->createCollection('ns_authors', permissions: $permissions, documentSecurity: false); + $database->createAttribute('ns_authors', 'name', Database::VAR_STRING, 255, true, filters: ['nsSliceAuthorSpy']); + + $database->createCollection('ns_comments', permissions: $permissions, documentSecurity: false); + $database->createAttribute('ns_comments', 'text', Database::VAR_STRING, 255, true); + + $database->createCollection('ns_posts', permissions: $permissions, documentSecurity: false); + $database->createAttribute('ns_posts', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'ns_comments', + relatedCollection: 'ns_authors', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'author', + twoWayKey: 'comments' + ); + + $database->createRelationship( + collection: 'ns_posts', + relatedCollection: 'ns_comments', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'comments', + twoWayKey: 'post' + ); + + for ($post = 1; $post <= 3; $post++) { + $commentIds = []; + + for ($comment = 1; $comment <= 5; $comment++) { + $commentId = 'p' . $post . 'c' . $comment; + + $database->createDocument('ns_authors', new Document([ + '$id' => 'a_' . $commentId, + '$permissions' => $permissions, + 'name' => 'Author ' . $commentId, + ])); + + $database->createDocument('ns_comments', new Document([ + '$id' => $commentId, + '$permissions' => $permissions, + 'text' => 'Comment ' . $commentId, + 'author' => 'a_' . $commentId, + ])); + + $commentIds[] = $commentId; + } + + $database->createDocument('ns_posts', new Document([ + '$id' => 'ns_post' . $post, + '$permissions' => $permissions, + 'title' => 'Post ' . $post, + 'comments' => $commentIds, + ])); + } + } + + private function deleteNestedSliceFixture(Database $database): void + { + $this->dropNestedSliceCollections($database, ['ns_posts', 'ns_comments', 'ns_authors']); + } + + public function testNestedSliceLimitOffsetPerParent(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSliceFixture($database); + + $posts = $database->find('ns_posts', [ + Query::nested('comments', [Query::orderAsc('$id'), Query::limit(2), Query::offset(1)]), + Query::orderAsc('$id'), + ]); + + $this->assertCount(3, $posts); + + foreach ($posts as $index => $post) { + $parent = $index + 1; + $this->assertSame( + ['p' . $parent . 'c2', 'p' . $parent . 'c3'], + $this->nestedSliceIds($post->getAttribute('comments')), + 'limit+offset must be applied per parent, not across the batch' + ); + } + + $posts = $database->find('ns_posts', [ + Query::nested('comments', [Query::orderAsc('$id'), Query::limit(2)]), + Query::orderAsc('$id'), + ]); + + foreach ($posts as $index => $post) { + $parent = $index + 1; + $this->assertSame( + ['p' . $parent . 'c1', 'p' . $parent . 'c2'], + $this->nestedSliceIds($post->getAttribute('comments')), + 'limit must be applied per parent' + ); + } + + $posts = $database->find('ns_posts', [ + Query::nested('comments', [Query::orderAsc('$id'), Query::offset(4)]), + Query::orderAsc('$id'), + ]); + + foreach ($posts as $index => $post) { + $parent = $index + 1; + $this->assertSame( + ['p' . $parent . 'c5'], + $this->nestedSliceIds($post->getAttribute('comments')), + 'offset must be applied per parent' + ); + } + + $this->deleteNestedSliceFixture($database); + } + + public function testNestedSliceCursorAfterDocument(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSliceFixture($database); + + $posts = $database->find('ns_posts', [ + Query::nested('comments', [ + Query::orderAsc('$id'), + Query::cursorAfter(new Document(['$id' => 'p1c2'])), + Query::limit(2), + ]), + Query::orderAsc('$id'), + ]); + + $this->assertCount(3, $posts); + $this->assertSame(['p1c3', 'p1c4'], $this->nestedSliceIds($posts[0]->getAttribute('comments'))); + $this->assertSame([], $this->nestedSliceIds($posts[1]->getAttribute('comments'))); + $this->assertSame([], $this->nestedSliceIds($posts[2]->getAttribute('comments'))); + + $this->deleteNestedSliceFixture($database); + } + + public function testNestedSliceCursorAfterParsed(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSliceFixture($database); + + $nested = Query::nested('comments', [ + Query::orderAsc('$id'), + Query::cursorAfter(new Document(['$id' => 'p1c2'])), + Query::limit(2), + ]); + + $parsed = Query::parse($nested->toString()); + + $this->assertSame('p1c2', $parsed->getValues()[1]->getValues()[0]); + + $posts = $database->find('ns_posts', [$parsed, Query::orderAsc('$id')]); + + $this->assertCount(3, $posts); + $this->assertSame(['p1c3', 'p1c4'], $this->nestedSliceIds($posts[0]->getAttribute('comments'))); + $this->assertSame([], $this->nestedSliceIds($posts[1]->getAttribute('comments'))); + $this->assertSame([], $this->nestedSliceIds($posts[2]->getAttribute('comments'))); + + $this->deleteNestedSliceFixture($database); + } + + public function testNestedSliceCursorBefore(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSliceFixture($database); + + $posts = $database->find('ns_posts', [ + Query::nested('comments', [ + Query::orderAsc('$id'), + Query::cursorBefore(new Document(['$id' => 'p1c4'])), + Query::limit(2), + ]), + Query::orderAsc('$id'), + ]); + + $this->assertCount(3, $posts); + $this->assertSame(['p1c2', 'p1c3'], $this->nestedSliceIds($posts[0]->getAttribute('comments'))); + $this->assertSame([], $this->nestedSliceIds($posts[1]->getAttribute('comments'))); + $this->assertSame([], $this->nestedSliceIds($posts[2]->getAttribute('comments'))); + + $this->deleteNestedSliceFixture($database); + } + + public function testNestedSliceCursorNotFoundYieldsEmpty(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSliceFixture($database); + + $posts = $database->find('ns_posts', [ + Query::nested('comments', [ + Query::orderAsc('$id'), + Query::cursorAfter(new Document(['$id' => 'nsMissingComment'])), + ]), + Query::orderAsc('$id'), + ]); + + $this->assertCount(3, $posts); + + foreach ($posts as $post) { + $this->assertSame([], $this->nestedSliceIds($post->getAttribute('comments'))); + } + + $this->deleteNestedSliceFixture($database); + } + + public function testNestedSliceManyToOne(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $permissions = $this->nestedSlicePermissions(); + + $this->dropNestedSliceCollections($database, ['ns_m1_comments', 'ns_m1_posts']); + + $database->createCollection('ns_m1_posts', permissions: $permissions, documentSecurity: false); + $database->createAttribute('ns_m1_posts', 'title', Database::VAR_STRING, 255, true); + + $database->createCollection('ns_m1_comments', permissions: $permissions, documentSecurity: false); + $database->createAttribute('ns_m1_comments', 'text', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'ns_m1_comments', + relatedCollection: 'ns_m1_posts', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'post', + twoWayKey: 'comments' + ); + + for ($post = 1; $post <= 2; $post++) { + $database->createDocument('ns_m1_posts', new Document([ + '$id' => 'ns_m1_post' . $post, + '$permissions' => $permissions, + 'title' => 'Post ' . $post, + ])); + + for ($comment = 1; $comment <= 3; $comment++) { + $database->createDocument('ns_m1_comments', new Document([ + '$id' => 'm1p' . $post . 'c' . $comment, + '$permissions' => $permissions, + 'text' => 'Comment ' . $comment, + 'post' => 'ns_m1_post' . $post, + ])); + } + } + + $posts = $database->find('ns_m1_posts', [ + Query::nested('comments', [Query::orderAsc('$id'), Query::limit(2)]), + Query::orderAsc('$id'), + ]); + + $this->assertCount(2, $posts); + $this->assertSame(['m1p1c1', 'm1p1c2'], $this->nestedSliceIds($posts[0]->getAttribute('comments'))); + $this->assertSame(['m1p2c1', 'm1p2c2'], $this->nestedSliceIds($posts[1]->getAttribute('comments'))); + + $this->dropNestedSliceCollections($database, ['ns_m1_comments', 'ns_m1_posts']); + } + + public function testNestedSliceManyToMany(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $permissions = $this->nestedSlicePermissions(); + + $this->dropNestedSliceCollections($database, ['ns_articles', 'ns_tags']); + + $database->createCollection('ns_tags', permissions: $permissions, documentSecurity: false); + $database->createAttribute('ns_tags', 'name', Database::VAR_STRING, 255, true); + + $database->createCollection('ns_articles', permissions: $permissions, documentSecurity: false); + $database->createAttribute('ns_articles', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'ns_articles', + relatedCollection: 'ns_tags', + type: Database::RELATION_MANY_TO_MANY, + twoWay: true, + id: 'tags', + twoWayKey: 'articles' + ); + + foreach (['ns_tag_a', 'ns_tag_b', 'ns_tag_c'] as $tag) { + $database->createDocument('ns_tags', new Document([ + '$id' => $tag, + '$permissions' => $permissions, + 'name' => $tag, + ])); + } + + $database->createDocument('ns_articles', new Document([ + '$id' => 'ns_article1', + '$permissions' => $permissions, + 'title' => 'Article One', + 'tags' => ['ns_tag_a', 'ns_tag_b'], + ])); + + $database->createDocument('ns_articles', new Document([ + '$id' => 'ns_article2', + '$permissions' => $permissions, + 'title' => 'Article Two', + 'tags' => ['ns_tag_b', 'ns_tag_c'], + ])); + + $articles = $database->find('ns_articles', [ + Query::nested('tags', [Query::orderAsc('$id'), Query::limit(1)]), + Query::orderAsc('$id'), + ]); + + $this->assertCount(2, $articles); + $this->assertSame(['ns_tag_a'], $this->nestedSliceIds($articles[0]->getAttribute('tags'))); + $this->assertSame(['ns_tag_b'], $this->nestedSliceIds($articles[1]->getAttribute('tags'))); + + $this->dropNestedSliceCollections($database, ['ns_articles', 'ns_tags']); + } + + public function testNestedSliceDepthTwoFansOutFromSurvivorsOnly(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSliceFixture($database); + + self::$nestedSliceAuthorReads = 0; + + $posts = $database->find('ns_posts', [ + Query::nested('comments', [Query::orderAsc('$id'), Query::limit(2)]), + Query::orderAsc('$id'), + ]); + + $this->assertCount(3, $posts); + + $authorIds = []; + + foreach ($posts as $index => $post) { + $parent = $index + 1; + $comments = $post->getAttribute('comments'); + $this->assertCount(2, $comments); + + foreach ($comments as $comment) { + $author = $comment->getAttribute('author'); + $this->assertInstanceOf(Document::class, $author); + $this->assertSame('a_' . $comment->getId(), $author->getId()); + $authorIds[] = $author->getId(); + } + + $this->assertSame( + ['p' . $parent . 'c1', 'p' . $parent . 'c2'], + $this->nestedSliceIds($comments) + ); + } + + $this->assertCount(6, $authorIds); + $this->assertSame( + 6, + self::$nestedSliceAuthorReads, + 'depth 2 must fan out from surviving children only' + ); + + $this->deleteNestedSliceFixture($database); + } + + public function testNestedSliceCombinedWithSelect(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSliceFixture($database); + + $posts = $database->find('ns_posts', [ + Query::select(['comments.*']), + Query::nested('comments', [Query::orderAsc('$id'), Query::limit(2)]), + Query::orderAsc('$id'), + ]); + + $this->assertCount(3, $posts); + + foreach ($posts as $index => $post) { + $parent = $index + 1; + $comments = $post->getAttribute('comments'); + $this->assertSame( + ['p' . $parent . 'c1', 'p' . $parent . 'c2'], + $this->nestedSliceIds($comments) + ); + $this->assertSame('Comment p' . $parent . 'c1', $comments[0]->getAttribute('text')); + } + + $this->deleteNestedSliceFixture($database); + } + + public function testNestedSliceSingularFilterExcludedChildIsEmptyDocument(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $permissions = $this->nestedSlicePermissions(); + + $this->dropNestedSliceCollections($database, ['ns_profiles', 'ns_users']); + + $database->createCollection('ns_users', permissions: $permissions, documentSecurity: false); + $database->createAttribute('ns_users', 'name', Database::VAR_STRING, 255, true); + + $database->createCollection('ns_profiles', permissions: $permissions, documentSecurity: false); + $database->createAttribute('ns_profiles', 'label', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'ns_profiles', + relatedCollection: 'ns_users', + type: Database::RELATION_ONE_TO_ONE, + twoWay: true, + id: 'user', + twoWayKey: 'profile' + ); + + $database->createDocument('ns_users', new Document([ + '$id' => 'ns_user1', + '$permissions' => $permissions, + 'name' => 'somebody', + ])); + + $database->createDocument('ns_profiles', new Document([ + '$id' => 'ns_profile1', + '$permissions' => $permissions, + 'label' => 'Profile One', + 'user' => 'ns_user1', + ])); + + $profiles = $database->find('ns_profiles', [ + Query::nested('user', [Query::equal('name', ['nobody'])]), + ]); + + $this->assertCount(1, $profiles); + $this->assertSame('ns_profile1', $profiles[0]->getId()); + + $user = $profiles[0]->getAttribute('user'); + $this->assertInstanceOf(Document::class, $user); + $this->assertTrue($user->isEmpty()); + + $this->dropNestedSliceCollections($database, ['ns_profiles', 'ns_users']); + } } From da14adb276a8fac03e29035753c12e6e005fc5d4 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 11 Sep 2026 05:38:32 +1200 Subject: [PATCH 05/12] (refactor): tidy the merged relationship query router The four nested-query subtasks landed three branches in processRelationshipQueries() that each re-read $query->getMethod(). Hoist the method once per iteration so the nested, dotted-filter and select branches read as one decision, and drop the trailing `if (getMethod() === TYPE_SELECT)` guard, which is unreachable as false now that the branch above it continues on anything that is not a select. The narrating comments in the select body describe what the next line already says. Adds testNestedFilterInsideNestedQuery to pin the seam where the two routing branches meet: Query::nested('comments', [equal('author.name')]) must constrain the populated children without constraining the parent result set, which is what separates it from the same filter written at the top level. Verified red with the TYPE_NESTED branch disabled. No behaviour change in the refactor. Co-Authored-By: Claude Fable 5.1 --- src/Database/Database.php | 24 ++-- .../e2e/Adapter/Scopes/RelationshipTests.php | 106 ++++++++++++++++++ 2 files changed, 117 insertions(+), 13 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 16f904aef4..f66df43aa3 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -10342,7 +10342,9 @@ private function processRelationshipQueries( $nestedSelections = []; foreach ($queries as $query) { - if ($query->getMethod() === Query::TYPE_NESTED) { + $method = $query->getMethod(); + + if ($method === Query::TYPE_NESTED) { $key = $query->getAttribute(); $relationship = \array_values(\array_filter( $relationships, @@ -10361,7 +10363,7 @@ private function processRelationshipQueries( } if ( - !\in_array($query->getMethod(), [ + !\in_array($method, [ Query::TYPE_SELECT, Query::TYPE_LIMIT, Query::TYPE_OFFSET, @@ -10383,7 +10385,7 @@ private function processRelationshipQueries( if ($relationship) { $nestedSelections[$filteredKey][] = new Query( - $query->getMethod(), + $method, \implode('.', $nesting), $query->getValues(), ); @@ -10392,7 +10394,7 @@ private function processRelationshipQueries( continue; } - if ($query->getMethod() !== Query::TYPE_SELECT) { + if ($method !== Query::TYPE_SELECT) { continue; } @@ -10403,7 +10405,7 @@ private function processRelationshipQueries( } $nesting = \explode('.', $value); - $selectedKey = \array_shift($nesting); // Remove and return first item + $selectedKey = \array_shift($nesting); $relationship = \array_values(\array_filter( $relationships, @@ -10414,12 +10416,8 @@ private function processRelationshipQueries( continue; } - // Shift the top level off the dot-path to pass the selection down the chain - // 'foo.bar.baz' becomes 'bar.baz' - $nestingPath = \implode('.', $nesting); - // If nestingPath is empty, it means we want all attributes (*) for this relationship if (empty($nestingPath)) { $nestedSelections[$selectedKey][] = Query::select(['*']); } else { @@ -10454,11 +10452,11 @@ private function processRelationshipQueries( } $finalValues = \array_values($values); - if ($query->getMethod() === Query::TYPE_SELECT) { - if (empty($finalValues)) { - $finalValues = ['*']; - } + + if (empty($finalValues)) { + $finalValues = ['*']; } + $query->setValues($finalValues); } diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index a4c3863b08..587499372e 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -6239,4 +6239,110 @@ public function testNestedSliceSingularFilterExcludedChildIsEmptyDocument(): voi $this->dropNestedSliceCollections($database, ['ns_profiles', 'ns_users']); } + + public function testNestedFilterInsideNestedQuery(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection('nfi_authors', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nfi_authors', 'name', Database::VAR_STRING, 255, true); + + $database->createCollection('nfi_comments', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nfi_comments', 'text', Database::VAR_STRING, 255, true); + + $database->createCollection('nfi_posts', permissions: $permissions, documentSecurity: false); + $database->createAttribute('nfi_posts', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'nfi_comments', + relatedCollection: 'nfi_authors', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'author', + twoWayKey: 'comments' + ); + + $database->createRelationship( + collection: 'nfi_posts', + relatedCollection: 'nfi_comments', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'comments', + twoWayKey: 'post' + ); + + foreach (['nfi_alice' => 'Alice', 'nfi_bob' => 'Bob'] as $id => $name) { + $database->createDocument('nfi_authors', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'name' => $name, + ])); + } + + foreach (['nfi_post1', 'nfi_post2', 'nfi_post3'] as $id) { + $database->createDocument('nfi_posts', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'title' => $id, + ])); + } + + $comments = [ + ['nfi_c1', 'nfi_post1', 'nfi_alice'], + ['nfi_c2', 'nfi_post1', 'nfi_bob'], + ['nfi_c3', 'nfi_post2', 'nfi_bob'], + ['nfi_c4', 'nfi_post3', 'nfi_alice'], + ]; + + foreach ($comments as [$id, $post, $author]) { + $database->createDocument('nfi_comments', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'text' => $id, + 'post' => $post, + 'author' => $author, + ])); + } + + $posts = $database->find('nfi_posts', [ + Query::nested('comments', [ + Query::equal('author.name', ['Alice']), + ]), + Query::orderAsc('$id'), + ]); + + $this->assertSame( + ['nfi_post1', 'nfi_post2', 'nfi_post3'], + $this->nestedFilterIds($posts), + 'nested() constrains the populated children, never the parent result set' + ); + $this->assertSame(['nfi_c1'], $this->nestedFilterIds($posts[0]->getAttribute('comments'))); + $this->assertSame([], $this->nestedFilterIds($posts[1]->getAttribute('comments'))); + $this->assertSame(['nfi_c4'], $this->nestedFilterIds($posts[2]->getAttribute('comments'))); + + foreach ($posts as $post) { + foreach ($post->getAttribute('comments') as $comment) { + $author = $comment->getAttribute('author'); + $this->assertInstanceOf(Document::class, $author); + $this->assertSame('nfi_alice', $author->getId()); + } + } + + $database->deleteCollection('nfi_posts'); + $database->deleteCollection('nfi_comments'); + $database->deleteCollection('nfi_authors'); + } } From 9126f0e5ca547addc65fe13d5eac2331855f28c9 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 11 Sep 2026 18:32:12 +1200 Subject: [PATCH 06/12] fix(relationships): honour inner nested selects at the next population depth An inner Query::select() on a nested relationship left the BFS in fetch-all mode, so depth-2 population put unselected child relationships back after applySelectFiltersToDocuments had stripped them. TYPE_NESTED also sits in LOGICAL_TYPES, so skipValidation could leak it into convertQueries and adapter condition builders as a boolean grouping. Skip it in those paths instead of treating it as AND/OR. --- src/Database/Adapter/Memory.php | 12 +++- src/Database/Adapter/Mongo.php | 4 ++ src/Database/Adapter/SQL.php | 2 +- src/Database/Database.php | 14 ++++- .../e2e/Adapter/Scopes/RelationshipTests.php | 60 +++++++++++++++++++ 5 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index 5e126a7177..d123ff01b1 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -2561,7 +2561,7 @@ protected function fusedFilter(string $key, string $collectionId, array $queries $effectiveQueries = []; foreach ($queries as $query) { $method = $query->getMethod(); - if (\in_array($method, [Query::TYPE_SELECT, Query::TYPE_ORDER_ASC, Query::TYPE_ORDER_DESC, Query::TYPE_ORDER_RANDOM, Query::TYPE_LIMIT, Query::TYPE_OFFSET, Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE], true)) { + if (\in_array($method, [Query::TYPE_SELECT, Query::TYPE_ORDER_ASC, Query::TYPE_ORDER_DESC, Query::TYPE_ORDER_RANDOM, Query::TYPE_LIMIT, Query::TYPE_OFFSET, Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE, Query::TYPE_NESTED], true)) { continue; } $effectiveQueries[] = $query; @@ -2614,7 +2614,13 @@ protected function matches(array $row, Query $query): bool if ($method === Query::TYPE_AND) { foreach ($query->getValues() as $sub) { - if (! ($sub instanceof Query) || ! $this->matches($row, $sub)) { + if (! ($sub instanceof Query)) { + return false; + } + if ($sub->getMethod() === Query::TYPE_NESTED) { + continue; + } + if (! $this->matches($row, $sub)) { return false; } } @@ -2624,7 +2630,7 @@ protected function matches(array $row, Query $query): bool if ($method === Query::TYPE_OR) { foreach ($query->getValues() as $sub) { - if ($sub instanceof Query && $this->matches($row, $sub)) { + if ($sub instanceof Query && $sub->getMethod() !== Query::TYPE_NESTED && $this->matches($row, $sub)) { return true; } } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 760e9e79c7..eda50bbe0a 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -3091,6 +3091,10 @@ protected function buildFilters(array $queries, string $separator = '$and'): arr foreach ($queries as $query) { /* @var $query Query */ + if ($query->getMethod() === Query::TYPE_NESTED) { + continue; + } + if ($query->isNested()) { if ($query->getMethod() === Query::TYPE_ELEM_MATCH) { $filters[$separator][] = [ diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 9ca4c1aee3..fd2be373c1 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -2244,7 +2244,7 @@ public function getSQLConditions(array $queries, array &$binds, string $separato { $conditions = []; foreach ($queries as $query) { - if ($query->getMethod() === Query::TYPE_SELECT) { + if ($query->getMethod() === Query::TYPE_SELECT || $query->getMethod() === Query::TYPE_NESTED) { continue; } diff --git a/src/Database/Database.php b/src/Database/Database.php index f66df43aa3..bece6910c0 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5225,9 +5225,15 @@ private function populateDocumentsRelationships( $nextSelects = $this->processRelationshipQueries($relatedCollectionRelationships, $relationshipQueries); - // If parent has explicit selects, child inherits that mode - // (even if nextSelects is empty, we're still in explicit mode) $childHasExplicitSelects = $parentHasExplicitSelects; + if (!$childHasExplicitSelects) { + foreach ($relationshipQueries as $relationshipQuery) { + if ($relationshipQuery->getMethod() === Query::TYPE_SELECT) { + $childHasExplicitSelects = true; + break; + } + } + } $nextQueue[] = [ 'documents' => $relatedDocs, @@ -9922,6 +9928,10 @@ public function getLimitForIndexes(): int public function convertQueries(Document $collection, array $queries): array { foreach ($queries as $index => $query) { + if ($query->getMethod() === Query::TYPE_NESTED) { + continue; + } + if ($query->isNested()) { $values = $this->convertQueries($collection, $query->getValues()); $query->setValues($values); diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 587499372e..5e5f59c64e 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -6345,4 +6345,64 @@ public function testNestedFilterInsideNestedQuery(): void $database->deleteCollection('nfi_comments'); $database->deleteCollection('nfi_authors'); } + + public function testNestedInnerSelectDoesNotPopulateUnselectedRelationships(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSkeletonFixture($database); + + $posts = $database->find('nsk_posts', [ + Query::nested('comments', [Query::select(['text'])]), + Query::orderAsc('$id'), + ]); + + $this->assertCount(2, $posts); + $this->assertSame( + ['nsk_tag1', 'nsk_tag2'], + $this->nestedSkeletonIds($posts[0]->getAttribute('tags')), + 'a nested select must not put the parent into explicit-select mode' + ); + + $comments = $posts[0]->getAttribute('comments'); + $this->assertNotEmpty($comments); + $this->assertSame('First', $comments[0]->getAttribute('text')); + $this->assertNull( + $comments[0]->getAttribute('author'), + 'inner select([text]) must not re-populate unselected child relationships at depth 2' + ); + + $this->deleteNestedSkeletonFixture($database); + } + + public function testNestedInsideOrIsIgnoredWhenValidationIsSkipped(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSkeletonFixture($database); + + $posts = $database->skipValidation(fn () => $database->find('nsk_posts', [ + Query::or([ + Query::nested('comments', [Query::limit(1)]), + Query::equal('title', ['Post One']), + ]), + Query::orderAsc('$id'), + ])); + + $this->assertSame(['nsk_post1'], $this->nestedSkeletonIds($posts)); + + $this->deleteNestedSkeletonFixture($database); + } } From 3b05b8e8ad94a975bf24a78b09c8504825e9836c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 11 Sep 2026 18:45:05 +1200 Subject: [PATCH 07/12] fix(relationships): apply nested offset after cursor and keep containsAll parent-set sliceRelated ignored offset whenever a cursor was present, so a legal cursor+offset nested page started one child too early. Dotted containsAll is a parent-set operator. Forwarding it onto each child required one related document to hold every value and emptied the populated array. Many-to-many nested order now re-sorts concatenated chunk results so order holds across RELATION_QUERY_CHUNK_SIZE boundaries. --- src/Database/Database.php | 93 +++++++++++++++---- .../e2e/Adapter/Scopes/RelationshipTests.php | 61 ++++++++++++ 2 files changed, 134 insertions(+), 20 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index bece6910c0..81b56cce5e 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5699,7 +5699,19 @@ private function populateManyToManyRelationshipsBatch( $relatedById[$doc->getId()] = $doc; } - $ordered = Query::groupByType($queries)['orderTypes'] !== []; + $grouped = Query::groupByType($queries); + $ordered = $grouped['orderTypes'] !== []; + + if ( + $ordered + && !\in_array(Database::ORDER_RANDOM, $grouped['orderTypes'], true) + ) { + $foundRelated = $this->sortDocuments( + $foundRelated, + $grouped['orderAttributes'], + $grouped['orderTypes'], + ); + } foreach ($junctionsByDocumentId as $documentId => $relatedDocIds) { $documentRelated = []; @@ -5759,36 +5771,76 @@ private function sliceRelated( ?string $cursorDirection ): array { $documents = \array_values($documents); + $offset = $offset ?? 0; - if ($cursor === null) { - return \array_slice($documents, $offset ?? 0, $limit); - } + if ($cursor !== null) { + $cursorId = $cursor instanceof Document ? $cursor->getId() : $cursor; + $position = null; - $cursorId = $cursor instanceof Document ? $cursor->getId() : $cursor; - $position = null; + foreach ($documents as $index => $document) { + if ($document->getId() === $cursorId) { + $position = $index; + break; + } + } - foreach ($documents as $index => $document) { - if ($document->getId() === $cursorId) { - $position = $index; - break; + if ($position === null) { + return []; } - } - if ($position === null) { - return []; - } + if ($cursorDirection === Database::CURSOR_BEFORE) { + $documents = \array_slice($documents, 0, $position); - if ($cursorDirection === Database::CURSOR_BEFORE) { - $preceding = \array_slice($documents, 0, $position); + if ($limit === 0) { + return []; + } + + if ($limit !== null) { + return \array_values(\array_slice($documents, -($limit + $offset), $limit)); + } - if ($limit === null) { - return $preceding; + return $offset === 0 + ? $documents + : \array_values(\array_slice($documents, 0, -$offset)); } - return $limit === 0 ? [] : \array_slice($preceding, -$limit); + $documents = \array_slice($documents, $position + 1); + } + + return \array_slice($documents, $offset, $limit); + } + + /** + * @param array $documents + * @param array $orderAttributes + * @param array $orderTypes + * @return array + */ + private function sortDocuments(array $documents, array $orderAttributes, array $orderTypes): array + { + if ($orderAttributes === []) { + return \array_values($documents); } - return \array_slice($documents, $position + 1, $limit); + \usort( + $documents, + function (Document $left, Document $right) use ($orderAttributes, $orderTypes): int { + foreach ($orderAttributes as $index => $attribute) { + $comparison = $left->getAttribute($attribute) <=> $right->getAttribute($attribute); + if ($comparison === 0) { + continue; + } + + return ($orderTypes[$index] ?? Database::ORDER_ASC) === Database::ORDER_DESC + ? -$comparison + : $comparison; + } + + return 0; + } + ); + + return $documents; } /** @@ -10382,6 +10434,7 @@ private function processRelationshipQueries( Query::TYPE_ORDER_ASC, Query::TYPE_ORDER_DESC, Query::TYPE_ORDER_RANDOM, + Query::TYPE_CONTAINS_ALL, ], true) && \str_contains($query->getAttribute(), '.') ) { diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 5e5f59c64e..d47ad0ab40 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -5889,6 +5889,40 @@ public function testNestedSliceCursorAfterDocument(): void $this->deleteNestedSliceFixture($database); } + public function testNestedSliceCursorAfterWithOffset(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSliceFixture($database); + + $posts = $database->find('ns_posts', [ + Query::nested('comments', [ + Query::orderAsc('$id'), + Query::cursorAfter(new Document(['$id' => 'p1c2'])), + Query::offset(1), + Query::limit(2), + ]), + Query::orderAsc('$id'), + ]); + + $this->assertCount(3, $posts); + $this->assertSame( + ['p1c4', 'p1c5'], + $this->nestedSliceIds($posts[0]->getAttribute('comments')), + 'offset must apply after the cursor, matching top-level find()' + ); + $this->assertSame([], $this->nestedSliceIds($posts[1]->getAttribute('comments'))); + $this->assertSame([], $this->nestedSliceIds($posts[2]->getAttribute('comments'))); + + $this->deleteNestedSliceFixture($database); + } + public function testNestedSliceCursorAfterParsed(): void { /** @var Database $database */ @@ -6346,6 +6380,33 @@ public function testNestedFilterInsideNestedQuery(): void $database->deleteCollection('nfi_authors'); } + public function testNestedFilterContainsAllDoesNotEmptyChildren(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSkeletonFixture($database); + + $posts = $database->find('nsk_posts', [ + Query::containsAll('tags.name', ['php', 'database']), + Query::orderAsc('$id'), + ]); + + $this->assertSame(['nsk_post1'], $this->nestedSkeletonIds($posts)); + $this->assertSame( + ['nsk_tag1', 'nsk_tag2'], + $this->nestedSkeletonIds($posts[0]->getAttribute('tags')), + 'containsAll is a parent-set operator and must not require one child to match every value' + ); + + $this->deleteNestedSkeletonFixture($database); + } + public function testNestedInnerSelectDoesNotPopulateUnselectedRelationships(): void { /** @var Database $database */ From 8e5ecebd001be33f9ac06589431b2908115a9a5f Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 11 Sep 2026 19:01:38 +1200 Subject: [PATCH 08/12] fix(relationships): skip nested queries in Redis and match cursor-before offset Redis find() treated TYPE_NESTED as an unknown filter and threw on skipValidation or([nested(), equal()]). Ignore nested there the same way Memory already does. cursorBefore plus an offset larger than the preceding window used array_slice's negative-offset clamp and returned the first page instead of empty. Reverse, offset, limit, reverse matches top-level find(). Re-sort many-to-many chunk results with the same $sequence tie-break find() appends. Reject nested() smuggled inside inner AND/OR. --- src/Database/Adapter/Redis.php | 11 ++++- src/Database/Database.php | 39 +++++++++++------- src/Database/Validator/Query/Nested.php | 21 +++++++++- .../e2e/Adapter/Scopes/RelationshipTests.php | 41 ++++++++++++++++++- tests/unit/Validator/Query/NestedTest.php | 13 ++++++ 5 files changed, 107 insertions(+), 18 deletions(-) diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 81f3350634..2d3afb2af8 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -3069,6 +3069,7 @@ private function filterDocumentsByQueries(string $collection, array $documents, Query::TYPE_OFFSET, Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE, + Query::TYPE_NESTED, ], true)) { continue; } @@ -3106,7 +3107,13 @@ private function matchesDocument(Document $document, Query $query): bool if ($method === Query::TYPE_AND) { foreach ($query->getValues() as $sub) { - if (! ($sub instanceof Query) || ! $this->matchesDocument($document, $sub)) { + if (! ($sub instanceof Query)) { + return false; + } + if ($sub->getMethod() === Query::TYPE_NESTED) { + continue; + } + if (! $this->matchesDocument($document, $sub)) { return false; } } @@ -3116,7 +3123,7 @@ private function matchesDocument(Document $document, Query $query): bool if ($method === Query::TYPE_OR) { foreach ($query->getValues() as $sub) { - if ($sub instanceof Query && $this->matchesDocument($document, $sub)) { + if ($sub instanceof Query && $sub->getMethod() !== Query::TYPE_NESTED && $this->matchesDocument($document, $sub)) { return true; } } diff --git a/src/Database/Database.php b/src/Database/Database.php index 81b56cce5e..e19733e277 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5706,10 +5706,30 @@ private function populateManyToManyRelationshipsBatch( $ordered && !\in_array(Database::ORDER_RANDOM, $grouped['orderTypes'], true) ) { + $orderAttributes = $grouped['orderAttributes']; + $orderTypes = $grouped['orderTypes']; + $uniqueOrderBy = false; + + foreach ($orderAttributes as $orderAttribute) { + if ($orderAttribute === '$id' || $orderAttribute === '$sequence') { + $uniqueOrderBy = true; + break; + } + } + + if ($uniqueOrderBy === false) { + $leadingAttribute = $orderAttributes[0] ?? null; + $leadingOrderType = $orderTypes[0] ?? Database::ORDER_ASC; + $orderAttributes[] = '$sequence'; + $orderTypes[] = \in_array($leadingAttribute, ['$createdAt', '$updatedAt'], true) + ? $leadingOrderType + : Database::ORDER_ASC; + } + $foundRelated = $this->sortDocuments( $foundRelated, - $grouped['orderAttributes'], - $grouped['orderTypes'], + $orderAttributes, + $orderTypes, ); } @@ -5789,19 +5809,10 @@ private function sliceRelated( } if ($cursorDirection === Database::CURSOR_BEFORE) { - $documents = \array_slice($documents, 0, $position); - - if ($limit === 0) { - return []; - } - - if ($limit !== null) { - return \array_values(\array_slice($documents, -($limit + $offset), $limit)); - } + $documents = \array_reverse(\array_slice($documents, 0, $position)); + $documents = \array_slice($documents, $offset, $limit); - return $offset === 0 - ? $documents - : \array_values(\array_slice($documents, 0, -$offset)); + return \array_values(\array_reverse($documents)); } $documents = \array_slice($documents, $position + 1); diff --git a/src/Database/Validator/Query/Nested.php b/src/Database/Validator/Query/Nested.php index 76c5338b13..020f879d3b 100644 --- a/src/Database/Validator/Query/Nested.php +++ b/src/Database/Validator/Query/Nested.php @@ -73,7 +73,7 @@ public function isValid($value): bool return false; } - if ($query->getMethod() === Query::TYPE_NESTED) { + if ($query->getMethod() === Query::TYPE_NESTED || $this->containsNestedQuery($query)) { $this->message = 'Nested queries cannot be nested'; return false; } @@ -122,6 +122,25 @@ public function isValid($value): bool return true; } + private function containsNestedQuery(Query $query): bool + { + if (!\in_array($query->getMethod(), [Query::TYPE_AND, Query::TYPE_OR, Query::TYPE_ELEM_MATCH], true)) { + return false; + } + + foreach ($query->getValues() as $value) { + if (!$value instanceof Query) { + continue; + } + + if ($value->getMethod() === Query::TYPE_NESTED || $this->containsNestedQuery($value)) { + return true; + } + } + + return false; + } + public function getMethodType(): string { return self::METHOD_TYPE_NESTED; diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index d47ad0ab40..d4b312f072 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -4820,6 +4820,8 @@ public function testOrderAndCursorWithRelationshipQueries(): void private function createNestedSkeletonFixture(Database $database): void { + $this->deleteNestedSkeletonFixture($database); + $permissions = [ Permission::create(Role::any()), Permission::read(Role::any()), @@ -4922,7 +4924,9 @@ private function createNestedSkeletonFixture(Database $database): void private function deleteNestedSkeletonFixture(Database $database): void { foreach (['nsk_posts', 'nsk_comments', 'nsk_tags', 'nsk_authors'] as $collection) { - $database->deleteCollection($collection); + if (!$database->silent(fn () => $database->getCollection($collection))->isEmpty()) { + $database->deleteCollection($collection); + } } } @@ -5126,6 +5130,11 @@ public function testNestedSkeletonInvalidInnerFilterRejectedWhenParentsMatch(): return; } + if (!$database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + $this->createNestedSkeletonFixture($database); try { @@ -5923,6 +5932,36 @@ public function testNestedSliceCursorAfterWithOffset(): void $this->deleteNestedSliceFixture($database); } + public function testNestedSliceCursorBeforeOffsetPastWindowIsEmpty(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSliceFixture($database); + + $posts = $database->find('ns_posts', [ + Query::nested('comments', [ + Query::orderAsc('$id'), + Query::cursorBefore(new Document(['$id' => 'p1c4'])), + Query::offset(10), + Query::limit(2), + ]), + Query::orderAsc('$id'), + ]); + + $this->assertCount(3, $posts); + $this->assertSame([], $this->nestedSliceIds($posts[0]->getAttribute('comments'))); + $this->assertSame([], $this->nestedSliceIds($posts[1]->getAttribute('comments'))); + $this->assertSame([], $this->nestedSliceIds($posts[2]->getAttribute('comments'))); + + $this->deleteNestedSliceFixture($database); + } + public function testNestedSliceCursorAfterParsed(): void { /** @var Database $database */ diff --git a/tests/unit/Validator/Query/NestedTest.php b/tests/unit/Validator/Query/NestedTest.php index 7dc983bd1b..dddc368fb5 100644 --- a/tests/unit/Validator/Query/NestedTest.php +++ b/tests/unit/Validator/Query/NestedTest.php @@ -167,6 +167,19 @@ public function testRejectsNestedInNested(): void $this->assertSame('Nested queries cannot be nested', $validator->getDescription()); } + public function testRejectsNestedInsideLogicalInnerQuery(): void + { + $validator = new Nested($this->attributes()); + + $this->assertFalse($validator->isValid(Query::nested('comments', [ + Query::or([ + Query::nested('author', [Query::limit(1)]), + Query::equal('text', ['hi']), + ]), + ]))); + $this->assertSame('Nested queries cannot be nested', $validator->getDescription()); + } + /** * @return array */ From 4d2e99f4e7b7e98b7f1a590d353202b247e7e0a3 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 11 Sep 2026 19:13:18 +1200 Subject: [PATCH 09/12] fix(mirror): restore source validation after skipValidation Mirror::disableValidation() also disables the source and destination instances. Database::skipValidation() only restored Mirror's own flag, so a skipValidation() call permanently turned off query validation on the source. Nested-query contract tests then accepted updateDocuments with TYPE_NESTED, and later structure checks saw PDO/Character errors instead of StructureException. --- src/Database/Mirror.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index a0151cb92f..99ff775da0 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -175,6 +175,20 @@ public function disableValidation(): static return $this; } + public function skipValidation(callable $callback): mixed + { + $initial = $this->validate; + $this->disableValidation(); + + try { + return $callback(); + } finally { + if ($initial) { + $this->enableValidation(); + } + } + } + public function on(string $event, string $name, ?callable $callback): static { $this->source->on($event, $name, $callback); From c71c5b602bc7ceb296ab0c8c538a528d8e268b51 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 11 Sep 2026 19:17:32 +1200 Subject: [PATCH 10/12] fix(mirror): restore each database's validation flag independently skipValidation() now snapshots Mirror, source, and destination flags and writes those values back after the callback. enableValidation() on the way out was overwriting a source that had been disabled on its own. Drop NestedTest::testGetMethodType(); it only asserted a constant. --- src/Database/Mirror.php | 20 +++++++++++++++++--- tests/unit/Validator/Query/NestedTest.php | 7 ------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index 99ff775da0..a37792cd9b 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -177,18 +177,32 @@ public function disableValidation(): static public function skipValidation(callable $callback): mixed { - $initial = $this->validate; + $mirrorInitial = $this->validate; + $sourceInitial = $this->source->validate; + $destinationInitial = $this->destination?->validate; + $this->disableValidation(); try { return $callback(); } finally { - if ($initial) { - $this->enableValidation(); + $this->validate = $mirrorInitial; + $this->restoreValidation($this->source, $sourceInitial); + if ($this->destination !== null && $destinationInitial !== null) { + $this->restoreValidation($this->destination, $destinationInitial); } } } + private function restoreValidation(Database $database, bool $enabled): void + { + if ($enabled) { + $database->enableValidation(); + } else { + $database->disableValidation(); + } + } + public function on(string $event, string $name, ?callable $callback): static { $this->source->on($event, $name, $callback); diff --git a/tests/unit/Validator/Query/NestedTest.php b/tests/unit/Validator/Query/NestedTest.php index dddc368fb5..5179ef8133 100644 --- a/tests/unit/Validator/Query/NestedTest.php +++ b/tests/unit/Validator/Query/NestedTest.php @@ -247,11 +247,4 @@ public function testUnknownAttributeAcceptedWithoutAttributeSupport(): void $this->assertTrue($validator->isValid(Query::nested('doesNotExist', [Query::limit(1)]))); $this->assertTrue($validator->isValid(Query::nested('profile', [Query::limit(1)]))); } - - public function testGetMethodType(): void - { - $validator = new Nested($this->attributes()); - - $this->assertSame(Nested::METHOD_TYPE_NESTED, $validator->getMethodType()); - } } From 7d62c3626a8aa5ec2cb59afc376819ca5c3117a4 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sat, 12 Sep 2026 16:53:31 +1200 Subject: [PATCH 11/12] refactor(query): rename Query::nested to Query::relationship The public factory and type should read as a relationship query, matching the validator and groupByType bucket. --- src/Database/Adapter/Memory.php | 6 +- src/Database/Adapter/Mongo.php | 2 +- src/Database/Adapter/Redis.php | 6 +- src/Database/Adapter/SQL.php | 2 +- src/Database/Database.php | 10 +-- src/Database/Query.php | 22 ++--- src/Database/Validator/IndexedQueries.php | 2 +- src/Database/Validator/Queries.php | 4 +- src/Database/Validator/Queries/Documents.php | 10 +-- src/Database/Validator/Query/Base.php | 2 +- .../Query/{Nested.php => Relationship.php} | 24 +++--- .../e2e/Adapter/Scopes/RelationshipTests.php | 68 +++++++-------- .../Scopes/Relationships/ManyToManyTests.php | 8 +- tests/unit/QueryTest.php | 16 ++-- tests/unit/Validator/QueriesTest.php | 10 +-- .../{NestedTest.php => RelationshipTest.php} | 86 +++++++++---------- 16 files changed, 139 insertions(+), 139 deletions(-) rename src/Database/Validator/Query/{Nested.php => Relationship.php} (77%) rename tests/unit/Validator/Query/{NestedTest.php => RelationshipTest.php} (62%) diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index d123ff01b1..61c11e8407 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -2561,7 +2561,7 @@ protected function fusedFilter(string $key, string $collectionId, array $queries $effectiveQueries = []; foreach ($queries as $query) { $method = $query->getMethod(); - if (\in_array($method, [Query::TYPE_SELECT, Query::TYPE_ORDER_ASC, Query::TYPE_ORDER_DESC, Query::TYPE_ORDER_RANDOM, Query::TYPE_LIMIT, Query::TYPE_OFFSET, Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE, Query::TYPE_NESTED], true)) { + if (\in_array($method, [Query::TYPE_SELECT, Query::TYPE_ORDER_ASC, Query::TYPE_ORDER_DESC, Query::TYPE_ORDER_RANDOM, Query::TYPE_LIMIT, Query::TYPE_OFFSET, Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE, Query::TYPE_RELATIONSHIP], true)) { continue; } $effectiveQueries[] = $query; @@ -2617,7 +2617,7 @@ protected function matches(array $row, Query $query): bool if (! ($sub instanceof Query)) { return false; } - if ($sub->getMethod() === Query::TYPE_NESTED) { + if ($sub->getMethod() === Query::TYPE_RELATIONSHIP) { continue; } if (! $this->matches($row, $sub)) { @@ -2630,7 +2630,7 @@ protected function matches(array $row, Query $query): bool if ($method === Query::TYPE_OR) { foreach ($query->getValues() as $sub) { - if ($sub instanceof Query && $sub->getMethod() !== Query::TYPE_NESTED && $this->matches($row, $sub)) { + if ($sub instanceof Query && $sub->getMethod() !== Query::TYPE_RELATIONSHIP && $this->matches($row, $sub)) { return true; } } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index eda50bbe0a..f1445d48d8 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -3091,7 +3091,7 @@ protected function buildFilters(array $queries, string $separator = '$and'): arr foreach ($queries as $query) { /* @var $query Query */ - if ($query->getMethod() === Query::TYPE_NESTED) { + if ($query->getMethod() === Query::TYPE_RELATIONSHIP) { continue; } diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 2d3afb2af8..ae6179c59c 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -3069,7 +3069,7 @@ private function filterDocumentsByQueries(string $collection, array $documents, Query::TYPE_OFFSET, Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE, - Query::TYPE_NESTED, + Query::TYPE_RELATIONSHIP, ], true)) { continue; } @@ -3110,7 +3110,7 @@ private function matchesDocument(Document $document, Query $query): bool if (! ($sub instanceof Query)) { return false; } - if ($sub->getMethod() === Query::TYPE_NESTED) { + if ($sub->getMethod() === Query::TYPE_RELATIONSHIP) { continue; } if (! $this->matchesDocument($document, $sub)) { @@ -3123,7 +3123,7 @@ private function matchesDocument(Document $document, Query $query): bool if ($method === Query::TYPE_OR) { foreach ($query->getValues() as $sub) { - if ($sub instanceof Query && $sub->getMethod() !== Query::TYPE_NESTED && $this->matchesDocument($document, $sub)) { + if ($sub instanceof Query && $sub->getMethod() !== Query::TYPE_RELATIONSHIP && $this->matchesDocument($document, $sub)) { return true; } } diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index fd2be373c1..323a9e2d4c 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -2244,7 +2244,7 @@ public function getSQLConditions(array $queries, array &$binds, string $separato { $conditions = []; foreach ($queries as $query) { - if ($query->getMethod() === Query::TYPE_SELECT || $query->getMethod() === Query::TYPE_NESTED) { + if ($query->getMethod() === Query::TYPE_SELECT || $query->getMethod() === Query::TYPE_RELATIONSHIP) { continue; } diff --git a/src/Database/Database.php b/src/Database/Database.php index e19733e277..94237d7eeb 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8868,7 +8868,7 @@ public function find(string $collection, array $queries = [], string $forPermiss $orderTypes = $grouped['orderTypes']; $cursor = $grouped['cursor']; $cursorDirection = $grouped['cursorDirection'] ?? Database::CURSOR_AFTER; - $nested = $grouped['nested']; + $relationshipQueries = $grouped['relationship']; $uniqueOrderBy = false; foreach ($orderAttributes as $order) { @@ -8932,7 +8932,7 @@ public function find(string $collection, array $queries = [], string $forPermiss ); $selections = $this->validateSelections($collection, $selects); - $nestedSelections = $this->processRelationshipQueries($relationships, \array_merge($queries, $nested)); + $nestedSelections = $this->processRelationshipQueries($relationships, \array_merge($queries, $relationshipQueries)); // Convert relationship filter queries to SQL-level subqueries $queriesOrNull = $this->convertRelationshipQueries($relationships, $queries, $collection); @@ -9453,7 +9453,7 @@ public function sum(string $collection, string $attribute, array $queries = [], $queries = $this->convertQueries($collection, \array_values(\array_filter( $queries, - fn (Query $query) => $query->getMethod() !== Query::TYPE_NESTED + fn (Query $query) => $query->getMethod() !== Query::TYPE_RELATIONSHIP ))); $queriesOrNull = $this->convertRelationshipQueries($relationships, $queries, $collection); @@ -9991,7 +9991,7 @@ public function getLimitForIndexes(): int public function convertQueries(Document $collection, array $queries): array { foreach ($queries as $index => $query) { - if ($query->getMethod() === Query::TYPE_NESTED) { + if ($query->getMethod() === Query::TYPE_RELATIONSHIP) { continue; } @@ -10417,7 +10417,7 @@ private function processRelationshipQueries( foreach ($queries as $query) { $method = $query->getMethod(); - if ($method === Query::TYPE_NESTED) { + if ($method === Query::TYPE_RELATIONSHIP) { $key = $query->getAttribute(); $relationship = \array_values(\array_filter( $relationships, diff --git a/src/Database/Query.php b/src/Database/Query.php index f276d34ed6..2680ca3f07 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -68,7 +68,7 @@ class Query public const TYPE_OR = 'or'; public const TYPE_CONTAINS_ALL = 'containsAll'; public const TYPE_ELEM_MATCH = 'elemMatch'; - public const TYPE_NESTED = 'nested'; + public const TYPE_RELATIONSHIP = 'relationship'; public const DEFAULT_ALIAS = 'main'; public const TYPES = [ @@ -120,7 +120,7 @@ class Query self::TYPE_OR, self::TYPE_CONTAINS_ALL, self::TYPE_ELEM_MATCH, - self::TYPE_NESTED, + self::TYPE_RELATIONSHIP, self::TYPE_REGEX ]; @@ -134,7 +134,7 @@ class Query self::TYPE_AND, self::TYPE_OR, self::TYPE_ELEM_MATCH, - self::TYPE_NESTED, + self::TYPE_RELATIONSHIP, ]; protected string $method = ''; @@ -310,7 +310,7 @@ public static function isMethod(string $value): bool self::TYPE_AND, self::TYPE_CONTAINS_ALL, self::TYPE_ELEM_MATCH, - self::TYPE_NESTED, + self::TYPE_RELATIONSHIP, self::TYPE_SELECT, self::TYPE_VECTOR_DOT, self::TYPE_VECTOR_COSINE, @@ -1006,7 +1006,7 @@ public static function getCursorQueries(array $queries, bool $clone = true): arr * orderTypes: array, * cursor: Document|null, * cursorDirection: string|null, - * nested: array + * relationship: array * } */ public static function groupByType(array $queries): array @@ -1019,7 +1019,7 @@ public static function groupByType(array $queries): array $orderTypes = []; $cursor = null; $cursorDirection = null; - $nested = []; + $relationshipQueries = []; foreach ($queries as $query) { if (!$query instanceof Query) { @@ -1076,8 +1076,8 @@ public static function groupByType(array $queries): array $selections[] = clone $query; break; - case Query::TYPE_NESTED: - $nested[] = clone $query; + case Query::TYPE_RELATIONSHIP: + $relationshipQueries[] = clone $query; break; default: @@ -1095,7 +1095,7 @@ public static function groupByType(array $queries): array 'orderTypes' => $orderTypes, 'cursor' => $cursor, 'cursorDirection' => $cursorDirection, - 'nested' => $nested, + 'relationship' => $relationshipQueries, ]; } @@ -1401,8 +1401,8 @@ public static function elemMatch(string $attribute, array $queries): self * @param array $queries * @return Query */ - public static function nested(string $relationshipKey, array $queries): self + public static function relationship(string $relationshipKey, array $queries): self { - return new self(self::TYPE_NESTED, $relationshipKey, $queries); + return new self(self::TYPE_RELATIONSHIP, $relationshipKey, $queries); } } diff --git a/src/Database/Validator/IndexedQueries.php b/src/Database/Validator/IndexedQueries.php index b4a812c127..b71c5419c7 100644 --- a/src/Database/Validator/IndexedQueries.php +++ b/src/Database/Validator/IndexedQueries.php @@ -101,7 +101,7 @@ public function isValid($value): bool } } - if ($query->isNested() && $query->getMethod() !== Query::TYPE_NESTED) { + if ($query->isNested() && $query->getMethod() !== Query::TYPE_RELATIONSHIP) { if (! self::isValid($query->getValues())) { return false; } diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index dbde415146..c4c2a22707 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -71,7 +71,7 @@ public function isValid($value): bool } } - if ($query->isNested() && $query->getMethod() !== Query::TYPE_NESTED) { + if ($query->isNested() && $query->getMethod() !== Query::TYPE_RELATIONSHIP) { if (!self::isValid($query->getValues())) { return false; } @@ -128,7 +128,7 @@ public function isValid($value): bool Query::TYPE_REGEX, Query::TYPE_EXISTS, Query::TYPE_NOT_EXISTS => Base::METHOD_TYPE_FILTER, - Query::TYPE_NESTED => Base::METHOD_TYPE_NESTED, + Query::TYPE_RELATIONSHIP => Base::METHOD_TYPE_RELATIONSHIP, default => '', }; diff --git a/src/Database/Validator/Queries/Documents.php b/src/Database/Validator/Queries/Documents.php index ad39ca84ef..2ab0614ab9 100644 --- a/src/Database/Validator/Queries/Documents.php +++ b/src/Database/Validator/Queries/Documents.php @@ -8,9 +8,9 @@ use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\Query\Filter; use Utopia\Database\Validator\Query\Limit; -use Utopia\Database\Validator\Query\Nested; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\Query\Order; +use Utopia\Database\Validator\Query\Relationship; use Utopia\Database\Validator\Query\Select; class Documents extends IndexedQueries @@ -24,7 +24,7 @@ class Documents extends IndexedQueries * @param \DateTime $maxAllowedDate * @param bool $supportForAttributes * @param bool $supportUnsignedBigInt - * @param bool $supportForNested + * @param bool $supportForRelationship * @throws \Utopia\Database\Exception */ public function __construct( @@ -37,7 +37,7 @@ public function __construct( \DateTime $maxAllowedDate = new \DateTime('9999-12-31'), bool $supportForAttributes = true, bool $supportUnsignedBigInt = true, - bool $supportForNested = true + bool $supportForRelationship = true ) { $attributes[] = new Document([ '$id' => '$id', @@ -81,8 +81,8 @@ public function __construct( new Select($attributes, $supportForAttributes), ]; - if ($supportForNested) { - $validators[] = new Nested($attributes, $maxUIDLength, $supportForAttributes); + if ($supportForRelationship) { + $validators[] = new Relationship($attributes, $maxUIDLength, $supportForAttributes); } parent::__construct($attributes, $indexes, $validators); diff --git a/src/Database/Validator/Query/Base.php b/src/Database/Validator/Query/Base.php index 16f5a5f1ee..6c0aeaca83 100644 --- a/src/Database/Validator/Query/Base.php +++ b/src/Database/Validator/Query/Base.php @@ -12,7 +12,7 @@ abstract class Base extends Validator public const METHOD_TYPE_ORDER = 'order'; public const METHOD_TYPE_FILTER = 'filter'; public const METHOD_TYPE_SELECT = 'select'; - public const METHOD_TYPE_NESTED = 'nested'; + public const METHOD_TYPE_RELATIONSHIP = 'relationship'; protected string $message = 'Invalid query'; diff --git a/src/Database/Validator/Query/Nested.php b/src/Database/Validator/Query/Relationship.php similarity index 77% rename from src/Database/Validator/Query/Nested.php rename to src/Database/Validator/Query/Relationship.php index 020f879d3b..439c8945ec 100644 --- a/src/Database/Validator/Query/Nested.php +++ b/src/Database/Validator/Query/Relationship.php @@ -6,7 +6,7 @@ use Utopia\Database\Document; use Utopia\Database\Query; -class Nested extends Base +class Relationship extends Base { /** * @var array @@ -38,7 +38,7 @@ public function isValid($value): bool return false; } - if ($value->getMethod() !== Query::TYPE_NESTED) { + if ($value->getMethod() !== Query::TYPE_RELATIONSHIP) { $this->message = 'Invalid query method: ' . $value->getMethod(); return false; } @@ -46,7 +46,7 @@ public function isValid($value): bool $attribute = $value->getAttribute(); if (empty($attribute)) { - $this->message = 'Nested queries require a relationship attribute'; + $this->message = 'Relationship queries require a relationship attribute'; return false; } @@ -55,7 +55,7 @@ public function isValid($value): bool !isset($this->schema[$attribute]) || $this->schema[$attribute]['type'] !== Database::VAR_RELATIONSHIP ) { - $this->message = 'Nested queries can only be used on relationship attributes: ' . $attribute; + $this->message = 'Relationship queries can only be used on relationship attributes: ' . $attribute; return false; } } @@ -63,18 +63,18 @@ public function isValid($value): bool $queries = $value->getValues(); if (empty($queries)) { - $this->message = 'Nested queries can only contain queries'; + $this->message = 'Relationship queries can only contain queries'; return false; } foreach ($queries as $query) { if (!$query instanceof Query) { - $this->message = 'Nested queries can only contain queries'; + $this->message = 'Relationship queries can only contain queries'; return false; } - if ($query->getMethod() === Query::TYPE_NESTED || $this->containsNestedQuery($query)) { - $this->message = 'Nested queries cannot be nested'; + if ($query->getMethod() === Query::TYPE_RELATIONSHIP || $this->containsRelationshipQuery($query)) { + $this->message = 'Relationship queries cannot contain relationship queries'; return false; } } @@ -115,14 +115,14 @@ public function isValid($value): bool || ($relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_CHILD); if ($isSingular) { - $this->message = 'Nested pagination is not supported on a singular relationship: ' . $attribute; + $this->message = 'Relationship pagination is not supported on a singular relationship: ' . $attribute; return false; } return true; } - private function containsNestedQuery(Query $query): bool + private function containsRelationshipQuery(Query $query): bool { if (!\in_array($query->getMethod(), [Query::TYPE_AND, Query::TYPE_OR, Query::TYPE_ELEM_MATCH], true)) { return false; @@ -133,7 +133,7 @@ private function containsNestedQuery(Query $query): bool continue; } - if ($value->getMethod() === Query::TYPE_NESTED || $this->containsNestedQuery($value)) { + if ($value->getMethod() === Query::TYPE_RELATIONSHIP || $this->containsRelationshipQuery($value)) { return true; } } @@ -143,6 +143,6 @@ private function containsNestedQuery(Query $query): bool public function getMethodType(): string { - return self::METHOD_TYPE_NESTED; + return self::METHOD_TYPE_RELATIONSHIP; } } diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index d4b312f072..1ea7c59a23 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -4952,7 +4952,7 @@ public function testNestedSkeletonSiblingRelationshipStillPopulated(): void $this->createNestedSkeletonFixture($database); $posts = $database->find('nsk_posts', [ - Query::nested('comments', [Query::orderAsc('$id')]), + Query::relationship('comments', [Query::orderAsc('$id')]), Query::orderAsc('$id'), ]); @@ -4998,7 +4998,7 @@ public function testNestedSkeletonSumIgnoresNested(): void $this->assertSame(30, $baseline); $withNested = $database->sum('nsk_posts', 'views', [ - Query::nested('comments', [Query::limit(1)]), + Query::relationship('comments', [Query::limit(1)]), ]); $this->assertSame(30, $withNested); @@ -5021,7 +5021,7 @@ public function testNestedSkeletonCountIgnoresNested(): void $this->assertSame(2, $baseline); $withNested = $database->count('nsk_posts', [ - Query::nested('comments', [Query::limit(1)]), + Query::relationship('comments', [Query::limit(1)]), ]); $this->assertSame(2, $withNested); @@ -5040,7 +5040,7 @@ public function testNestedSkeletonInnerQueriesNotMutatedAcrossFinds(): void $this->createNestedSkeletonFixture($database); - $nestedQuery = Query::nested('comments', [Query::select(['author.name'])]); + $nestedQuery = Query::relationship('comments', [Query::select(['author.name'])]); $first = $database->find('nsk_posts', [$nestedQuery, Query::orderAsc('$id')]); $second = $database->find('nsk_posts', [$nestedQuery, Query::orderAsc('$id')]); @@ -5072,7 +5072,7 @@ public function testNestedSkeletonContract(): void $this->createNestedSkeletonFixture($database); $found = $database->findOne('nsk_posts', [ - Query::nested('comments', [Query::limit(1)]), + Query::relationship('comments', [Query::limit(1)]), Query::orderAsc('$id'), ]); $this->assertSame('nsk_post1', $found->getId()); @@ -5083,7 +5083,7 @@ public function testNestedSkeletonContract(): void } $withNested = []; - foreach ($database->iterate('nsk_posts', [Query::nested('comments', [Query::orderAsc('$id')]), Query::orderAsc('$id')]) as $post) { + foreach ($database->iterate('nsk_posts', [Query::relationship('comments', [Query::orderAsc('$id')]), Query::orderAsc('$id')]) as $post) { $withNested[] = $post->getId(); } @@ -5092,29 +5092,29 @@ public function testNestedSkeletonContract(): void try { $database->updateDocuments('nsk_posts', new Document(['views' => 1]), [ - Query::nested('comments', [Query::limit(1)]), + Query::relationship('comments', [Query::limit(1)]), ]); - $this->fail('updateDocuments accepted a nested query'); + $this->fail('updateDocuments accepted a relationship query'); } catch (QueryException $e) { - $this->assertStringContainsString('Invalid query method: nested', $e->getMessage()); + $this->assertStringContainsString('Invalid query method: relationship', $e->getMessage()); } try { $database->deleteDocuments('nsk_posts', [ - Query::nested('comments', [Query::limit(1)]), + Query::relationship('comments', [Query::limit(1)]), ]); - $this->fail('deleteDocuments accepted a nested query'); + $this->fail('deleteDocuments accepted a relationship query'); } catch (QueryException $e) { - $this->assertStringContainsString('Invalid query method: nested', $e->getMessage()); + $this->assertStringContainsString('Invalid query method: relationship', $e->getMessage()); } try { $database->getDocument('nsk_posts', 'nsk_post1', [ - Query::nested('comments', [Query::limit(1)]), + Query::relationship('comments', [Query::limit(1)]), ]); - $this->fail('getDocument accepted a nested query'); + $this->fail('getDocument accepted a relationship query'); } catch (QueryException $e) { - $this->assertStringContainsString('Invalid query method: nested', $e->getMessage()); + $this->assertStringContainsString('Invalid query method: relationship', $e->getMessage()); } $this->deleteNestedSkeletonFixture($database); @@ -5139,7 +5139,7 @@ public function testNestedSkeletonInvalidInnerFilterRejectedWhenParentsMatch(): try { $database->find('nsk_posts', [ - Query::nested('comments', [Query::equal('doesNotExist', ['x'])]), + Query::relationship('comments', [Query::equal('doesNotExist', ['x'])]), ]); $this->fail('An invalid inner filter was accepted'); } catch (QueryException $e) { @@ -5163,7 +5163,7 @@ public function testNestedSkeletonInvalidInnerFilterWhenNoParentsMatch(): void $posts = $database->find('nsk_posts', [ Query::equal('title', ['No Such Post']), - Query::nested('comments', [Query::equal('doesNotExist', ['x'])]), + Query::relationship('comments', [Query::equal('doesNotExist', ['x'])]), ]); $this->assertSame([], $posts); @@ -5823,7 +5823,7 @@ public function testNestedSliceLimitOffsetPerParent(): void $this->createNestedSliceFixture($database); $posts = $database->find('ns_posts', [ - Query::nested('comments', [Query::orderAsc('$id'), Query::limit(2), Query::offset(1)]), + Query::relationship('comments', [Query::orderAsc('$id'), Query::limit(2), Query::offset(1)]), Query::orderAsc('$id'), ]); @@ -5839,7 +5839,7 @@ public function testNestedSliceLimitOffsetPerParent(): void } $posts = $database->find('ns_posts', [ - Query::nested('comments', [Query::orderAsc('$id'), Query::limit(2)]), + Query::relationship('comments', [Query::orderAsc('$id'), Query::limit(2)]), Query::orderAsc('$id'), ]); @@ -5853,7 +5853,7 @@ public function testNestedSliceLimitOffsetPerParent(): void } $posts = $database->find('ns_posts', [ - Query::nested('comments', [Query::orderAsc('$id'), Query::offset(4)]), + Query::relationship('comments', [Query::orderAsc('$id'), Query::offset(4)]), Query::orderAsc('$id'), ]); @@ -5882,7 +5882,7 @@ public function testNestedSliceCursorAfterDocument(): void $this->createNestedSliceFixture($database); $posts = $database->find('ns_posts', [ - Query::nested('comments', [ + Query::relationship('comments', [ Query::orderAsc('$id'), Query::cursorAfter(new Document(['$id' => 'p1c2'])), Query::limit(2), @@ -5911,7 +5911,7 @@ public function testNestedSliceCursorAfterWithOffset(): void $this->createNestedSliceFixture($database); $posts = $database->find('ns_posts', [ - Query::nested('comments', [ + Query::relationship('comments', [ Query::orderAsc('$id'), Query::cursorAfter(new Document(['$id' => 'p1c2'])), Query::offset(1), @@ -5945,7 +5945,7 @@ public function testNestedSliceCursorBeforeOffsetPastWindowIsEmpty(): void $this->createNestedSliceFixture($database); $posts = $database->find('ns_posts', [ - Query::nested('comments', [ + Query::relationship('comments', [ Query::orderAsc('$id'), Query::cursorBefore(new Document(['$id' => 'p1c4'])), Query::offset(10), @@ -5974,7 +5974,7 @@ public function testNestedSliceCursorAfterParsed(): void $this->createNestedSliceFixture($database); - $nested = Query::nested('comments', [ + $nested = Query::relationship('comments', [ Query::orderAsc('$id'), Query::cursorAfter(new Document(['$id' => 'p1c2'])), Query::limit(2), @@ -6007,7 +6007,7 @@ public function testNestedSliceCursorBefore(): void $this->createNestedSliceFixture($database); $posts = $database->find('ns_posts', [ - Query::nested('comments', [ + Query::relationship('comments', [ Query::orderAsc('$id'), Query::cursorBefore(new Document(['$id' => 'p1c4'])), Query::limit(2), @@ -6036,7 +6036,7 @@ public function testNestedSliceCursorNotFoundYieldsEmpty(): void $this->createNestedSliceFixture($database); $posts = $database->find('ns_posts', [ - Query::nested('comments', [ + Query::relationship('comments', [ Query::orderAsc('$id'), Query::cursorAfter(new Document(['$id' => 'nsMissingComment'])), ]), @@ -6099,7 +6099,7 @@ public function testNestedSliceManyToOne(): void } $posts = $database->find('ns_m1_posts', [ - Query::nested('comments', [Query::orderAsc('$id'), Query::limit(2)]), + Query::relationship('comments', [Query::orderAsc('$id'), Query::limit(2)]), Query::orderAsc('$id'), ]); @@ -6162,7 +6162,7 @@ public function testNestedSliceManyToMany(): void ])); $articles = $database->find('ns_articles', [ - Query::nested('tags', [Query::orderAsc('$id'), Query::limit(1)]), + Query::relationship('tags', [Query::orderAsc('$id'), Query::limit(1)]), Query::orderAsc('$id'), ]); @@ -6188,7 +6188,7 @@ public function testNestedSliceDepthTwoFansOutFromSurvivorsOnly(): void self::$nestedSliceAuthorReads = 0; $posts = $database->find('ns_posts', [ - Query::nested('comments', [Query::orderAsc('$id'), Query::limit(2)]), + Query::relationship('comments', [Query::orderAsc('$id'), Query::limit(2)]), Query::orderAsc('$id'), ]); @@ -6238,7 +6238,7 @@ public function testNestedSliceCombinedWithSelect(): void $posts = $database->find('ns_posts', [ Query::select(['comments.*']), - Query::nested('comments', [Query::orderAsc('$id'), Query::limit(2)]), + Query::relationship('comments', [Query::orderAsc('$id'), Query::limit(2)]), Query::orderAsc('$id'), ]); @@ -6300,7 +6300,7 @@ public function testNestedSliceSingularFilterExcludedChildIsEmptyDocument(): voi ])); $profiles = $database->find('ns_profiles', [ - Query::nested('user', [Query::equal('name', ['nobody'])]), + Query::relationship('user', [Query::equal('name', ['nobody'])]), ]); $this->assertCount(1, $profiles); @@ -6391,7 +6391,7 @@ public function testNestedFilterInsideNestedQuery(): void } $posts = $database->find('nfi_posts', [ - Query::nested('comments', [ + Query::relationship('comments', [ Query::equal('author.name', ['Alice']), ]), Query::orderAsc('$id'), @@ -6459,7 +6459,7 @@ public function testNestedInnerSelectDoesNotPopulateUnselectedRelationships(): v $this->createNestedSkeletonFixture($database); $posts = $database->find('nsk_posts', [ - Query::nested('comments', [Query::select(['text'])]), + Query::relationship('comments', [Query::select(['text'])]), Query::orderAsc('$id'), ]); @@ -6495,7 +6495,7 @@ public function testNestedInsideOrIsIgnoredWhenValidationIsSkipped(): void $posts = $database->skipValidation(fn () => $database->find('nsk_posts', [ Query::or([ - Query::nested('comments', [Query::limit(1)]), + Query::relationship('comments', [Query::limit(1)]), Query::equal('title', ['Post One']), ]), Query::orderAsc('$id'), diff --git a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php index 37ad34988f..3a7205f37d 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php @@ -2479,7 +2479,7 @@ public function testM2mNestedOrderDescending(): void $albums = $database->find('m2mno_albums', [ Query::equal('$id', ['m2mno_album1']), - Query::nested('tracks', [Query::orderDesc('name')]), + Query::relationship('tracks', [Query::orderDesc('name')]), ]); $this->assertCount(1, $albums); @@ -2505,7 +2505,7 @@ public function testM2mNestedOrderAscending(): void $albums = $database->find('m2mno_albums', [ Query::equal('$id', ['m2mno_album1']), - Query::nested('tracks', [Query::orderAsc('name')]), + Query::relationship('tracks', [Query::orderAsc('name')]), ]); $this->assertCount(1, $albums); @@ -2530,7 +2530,7 @@ public function testM2mNestedOrderPerParentWithOverlap(): void $this->createM2mNestedOrderFixture($database); $albums = $database->find('m2mno_albums', [ - Query::nested('tracks', [Query::orderAsc('name')]), + Query::relationship('tracks', [Query::orderAsc('name')]), Query::orderAsc('$id'), ]); @@ -2571,7 +2571,7 @@ public function testM2mNestedOrderJunctionOrderPreservedWithoutOrder(): void $albums = $database->find('m2mno_albums', [ Query::equal('$id', ['m2mno_album1']), - Query::nested('tracks', [Query::select(['name'])]), + Query::relationship('tracks', [Query::select(['name'])]), ]); $this->assertCount(1, $albums); diff --git a/tests/unit/QueryTest.php b/tests/unit/QueryTest.php index 2bdf8187bb..d1df310e89 100644 --- a/tests/unit/QueryTest.php +++ b/tests/unit/QueryTest.php @@ -501,8 +501,8 @@ public function testNewQueryTypesInTypesArray(): void $this->assertContains(Query::TYPE_NOT_ENDS_WITH, Query::TYPES); $this->assertContains(Query::TYPE_NOT_BETWEEN, Query::TYPES); $this->assertContains(Query::TYPE_ORDER_RANDOM, Query::TYPES); - $this->assertContains(Query::TYPE_NESTED, Query::TYPES); - $this->assertTrue(Query::isMethod(Query::TYPE_NESTED)); + $this->assertContains(Query::TYPE_RELATIONSHIP, Query::TYPES); + $this->assertTrue(Query::isMethod(Query::TYPE_RELATIONSHIP)); } public function testFingerprint(): void @@ -595,8 +595,8 @@ public function testShape(): void $elem = new Query(Query::TYPE_ELEM_MATCH, 'tags', [Query::equal('name', ['php'])]); $this->assertSame('elemMatch:tags(equal:name)', $elem->shape()); - $nested = Query::nested('comments', [Query::equal('approved', [true]), Query::limit(2)]); - $this->assertSame('nested:comments(equal:approved|limit:)', $nested->shape()); + $relationship = Query::relationship('comments', [Query::equal('approved', [true]), Query::limit(2)]); + $this->assertSame('relationship:comments(equal:approved|limit:)', $relationship->shape()); // Deeply nested — iterative traversal must match recursive result $deep = Query::and([ @@ -615,9 +615,9 @@ public function testShape(): void ); } - public function testNestedRoundTrip(): void + public function testRelationshipRoundTrip(): void { - $query = Query::nested('comments', [ + $query = Query::relationship('comments', [ Query::equal('approved', [true]), Query::orderDesc('$createdAt'), Query::limit(2), @@ -625,14 +625,14 @@ public function testNestedRoundTrip(): void Query::cursorAfter(new Document(['$id' => 'c1'])), ]); - $this->assertSame(Query::TYPE_NESTED, $query->getMethod()); + $this->assertSame(Query::TYPE_RELATIONSHIP, $query->getMethod()); $this->assertSame('comments', $query->getAttribute()); $this->assertTrue($query->isNested()); $this->assertInstanceOf(Document::class, $query->getValues()[4]->getValues()[0]); $parsed = Query::parse($query->toString()); - $this->assertSame(Query::TYPE_NESTED, $parsed->getMethod()); + $this->assertSame(Query::TYPE_RELATIONSHIP, $parsed->getMethod()); $this->assertSame('comments', $parsed->getAttribute()); $inner = $parsed->getValues(); diff --git a/tests/unit/Validator/QueriesTest.php b/tests/unit/Validator/QueriesTest.php index 03ff958f65..07c6fa1a43 100644 --- a/tests/unit/Validator/QueriesTest.php +++ b/tests/unit/Validator/QueriesTest.php @@ -11,9 +11,9 @@ use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\Query\Filter; use Utopia\Database\Validator\Query\Limit; -use Utopia\Database\Validator\Query\Nested; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\Query\Order; +use Utopia\Database\Validator\Query\Relationship; class QueriesTest extends TestCase { @@ -118,7 +118,7 @@ public function testValid(): void ); } - public function testOrRejectsNestedQuery(): void + public function testOrRejectsRelationshipQuery(): void { $attributes = [ new Document([ @@ -144,14 +144,14 @@ public function testOrRejectsNestedQuery(): void $validator = new Queries([ new Filter($attributes, Database::VAR_INTEGER), - new Nested($attributes), + new Relationship($attributes), ]); - $this->assertTrue($validator->isValid([Query::nested('comments', [Query::limit(1)])]), $validator->getDescription()); + $this->assertTrue($validator->isValid([Query::relationship('comments', [Query::limit(1)])]), $validator->getDescription()); $this->assertFalse($validator->isValid([ Query::or([ - Query::nested('comments', [Query::limit(1)]), + Query::relationship('comments', [Query::limit(1)]), Query::equal('name', ['value']), ]), ])); diff --git a/tests/unit/Validator/Query/NestedTest.php b/tests/unit/Validator/Query/RelationshipTest.php similarity index 62% rename from tests/unit/Validator/Query/NestedTest.php rename to tests/unit/Validator/Query/RelationshipTest.php index 5179ef8133..62bf7af79b 100644 --- a/tests/unit/Validator/Query/NestedTest.php +++ b/tests/unit/Validator/Query/RelationshipTest.php @@ -6,9 +6,9 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Query; -use Utopia\Database\Validator\Query\Nested; +use Utopia\Database\Validator\Query\Relationship; -class NestedTest extends TestCase +class RelationshipTest extends TestCase { /** * @return array @@ -92,9 +92,9 @@ private function attributes(): array public function testAcceptsInnerQueriesOnPluralRelationship(): void { - $validator = new Nested($this->attributes()); + $validator = new Relationship($this->attributes()); - $this->assertTrue($validator->isValid(Query::nested('comments', [ + $this->assertTrue($validator->isValid(Query::relationship('comments', [ Query::equal('approved', [true]), Query::orderDesc('$createdAt'), Query::limit(2), @@ -102,7 +102,7 @@ public function testAcceptsInnerQueriesOnPluralRelationship(): void Query::cursorAfter(new Document(['$id' => 'comment1'])), ]))); - $this->assertTrue($validator->isValid(Query::nested('tags', [ + $this->assertTrue($validator->isValid(Query::relationship('tags', [ Query::select(['name']), Query::limit(5), ]))); @@ -110,7 +110,7 @@ public function testAcceptsInnerQueriesOnPluralRelationship(): void public function testRejectsWrongMethod(): void { - $validator = new Nested($this->attributes()); + $validator = new Relationship($this->attributes()); $this->assertFalse($validator->isValid(Query::limit(1))); $this->assertSame('Invalid query method: limit', $validator->getDescription()); @@ -118,66 +118,66 @@ public function testRejectsWrongMethod(): void public function testRejectsNonRelationshipAttribute(): void { - $validator = new Nested($this->attributes()); + $validator = new Relationship($this->attributes()); - $this->assertFalse($validator->isValid(Query::nested('title', [Query::limit(1)]))); + $this->assertFalse($validator->isValid(Query::relationship('title', [Query::limit(1)]))); $this->assertSame( - 'Nested queries can only be used on relationship attributes: title', + 'Relationship queries can only be used on relationship attributes: title', $validator->getDescription() ); } public function testRejectsUnknownAttribute(): void { - $validator = new Nested($this->attributes()); + $validator = new Relationship($this->attributes()); - $this->assertFalse($validator->isValid(Query::nested('doesNotExist', [Query::limit(1)]))); + $this->assertFalse($validator->isValid(Query::relationship('doesNotExist', [Query::limit(1)]))); $this->assertSame( - 'Nested queries can only be used on relationship attributes: doesNotExist', + 'Relationship queries can only be used on relationship attributes: doesNotExist', $validator->getDescription() ); } public function testRejectsEmptyAttribute(): void { - $validator = new Nested($this->attributes()); + $validator = new Relationship($this->attributes()); - $this->assertFalse($validator->isValid(Query::nested('', [Query::limit(1)]))); - $this->assertSame('Nested queries require a relationship attribute', $validator->getDescription()); + $this->assertFalse($validator->isValid(Query::relationship('', [Query::limit(1)]))); + $this->assertSame('Relationship queries require a relationship attribute', $validator->getDescription()); } public function testRejectsNonQueryValues(): void { - $validator = new Nested($this->attributes()); + $validator = new Relationship($this->attributes()); - $this->assertFalse($validator->isValid(new Query(Query::TYPE_NESTED, 'comments', ['approved']))); - $this->assertSame('Nested queries can only contain queries', $validator->getDescription()); + $this->assertFalse($validator->isValid(new Query(Query::TYPE_RELATIONSHIP, 'comments', ['approved']))); + $this->assertSame('Relationship queries can only contain queries', $validator->getDescription()); - $this->assertFalse($validator->isValid(Query::nested('comments', []))); - $this->assertSame('Nested queries can only contain queries', $validator->getDescription()); + $this->assertFalse($validator->isValid(Query::relationship('comments', []))); + $this->assertSame('Relationship queries can only contain queries', $validator->getDescription()); } - public function testRejectsNestedInNested(): void + public function testRejectsRelationshipInRelationship(): void { - $validator = new Nested($this->attributes()); + $validator = new Relationship($this->attributes()); - $this->assertFalse($validator->isValid(Query::nested('comments', [ - Query::nested('author', [Query::limit(1)]), + $this->assertFalse($validator->isValid(Query::relationship('comments', [ + Query::relationship('author', [Query::limit(1)]), ]))); - $this->assertSame('Nested queries cannot be nested', $validator->getDescription()); + $this->assertSame('Relationship queries cannot contain relationship queries', $validator->getDescription()); } - public function testRejectsNestedInsideLogicalInnerQuery(): void + public function testRejectsRelationshipInsideLogicalInnerQuery(): void { - $validator = new Nested($this->attributes()); + $validator = new Relationship($this->attributes()); - $this->assertFalse($validator->isValid(Query::nested('comments', [ + $this->assertFalse($validator->isValid(Query::relationship('comments', [ Query::or([ - Query::nested('author', [Query::limit(1)]), + Query::relationship('author', [Query::limit(1)]), Query::equal('text', ['hi']), ]), ]))); - $this->assertSame('Nested queries cannot be nested', $validator->getDescription()); + $this->assertSame('Relationship queries cannot contain relationship queries', $validator->getDescription()); } /** @@ -197,12 +197,12 @@ public static function singularRelationships(): array */ public function testRejectsPaginationOnSingularRelationship(string $attribute): void { - $validator = new Nested($this->attributes()); + $validator = new Relationship($this->attributes()); foreach ([Query::limit(1), Query::offset(1), Query::cursorAfter(new Document(['$id' => 'x1']))] as $pagination) { - $this->assertFalse($validator->isValid(Query::nested($attribute, [$pagination]))); + $this->assertFalse($validator->isValid(Query::relationship($attribute, [$pagination]))); $this->assertSame( - 'Nested pagination is not supported on a singular relationship: ' . $attribute, + 'Relationship pagination is not supported on a singular relationship: ' . $attribute, $validator->getDescription() ); } @@ -213,9 +213,9 @@ public function testRejectsPaginationOnSingularRelationship(string $attribute): */ public function testAcceptsFiltersOnSingularRelationship(string $attribute): void { - $validator = new Nested($this->attributes()); + $validator = new Relationship($this->attributes()); - $this->assertTrue($validator->isValid(Query::nested($attribute, [ + $this->assertTrue($validator->isValid(Query::relationship($attribute, [ Query::equal('name', ['Alice']), Query::select(['name']), ]))); @@ -223,28 +223,28 @@ public function testAcceptsFiltersOnSingularRelationship(string $attribute): voi public function testRejectsInvalidInnerLimit(): void { - $validator = new Nested($this->attributes()); + $validator = new Relationship($this->attributes()); - $this->assertFalse($validator->isValid(Query::nested('comments', [Query::limit(0)]))); + $this->assertFalse($validator->isValid(Query::relationship('comments', [Query::limit(0)]))); $this->assertStringContainsString('Invalid limit', $validator->getDescription()); - $this->assertFalse($validator->isValid(Query::nested('comments', [Query::limit(-1)]))); + $this->assertFalse($validator->isValid(Query::relationship('comments', [Query::limit(-1)]))); $this->assertStringContainsString('Invalid limit', $validator->getDescription()); } public function testRejectsInvalidInnerCursor(): void { - $validator = new Nested($this->attributes(), 4); + $validator = new Relationship($this->attributes(), 4); - $this->assertFalse($validator->isValid(Query::nested('comments', [Query::cursorAfter(new Document(['$id' => 'waytoolongforfour']))]))); + $this->assertFalse($validator->isValid(Query::relationship('comments', [Query::cursorAfter(new Document(['$id' => 'waytoolongforfour']))]))); $this->assertStringContainsString('Invalid cursor', $validator->getDescription()); } public function testUnknownAttributeAcceptedWithoutAttributeSupport(): void { - $validator = new Nested($this->attributes(), 36, false); + $validator = new Relationship($this->attributes(), 36, false); - $this->assertTrue($validator->isValid(Query::nested('doesNotExist', [Query::limit(1)]))); - $this->assertTrue($validator->isValid(Query::nested('profile', [Query::limit(1)]))); + $this->assertTrue($validator->isValid(Query::relationship('doesNotExist', [Query::limit(1)]))); + $this->assertTrue($validator->isValid(Query::relationship('profile', [Query::limit(1)]))); } } From b55de41e1382a90c0a3ebbf60dccad6bd7c6be68 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sat, 12 Sep 2026 17:12:58 +1200 Subject: [PATCH 12/12] fix(relationships): validate inner select against the related collection One-to-many and many-to-one population applied nested selects locally without schema checks, so a malformed inner select projected silently. --- src/Database/Database.php | 25 ++++++++++++++ src/Database/Validator/Query/Relationship.php | 8 +++++ .../e2e/Adapter/Scopes/RelationshipTests.php | 33 ++++++++++++++++--- .../unit/Validator/Query/RelationshipTest.php | 16 +++++++++ 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 94237d7eeb..192d555bf9 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -35,6 +35,7 @@ use Utopia\Database\Validator\Permissions; use Utopia\Database\Validator\Queries\Document as DocumentValidator; use Utopia\Database\Validator\Queries\Documents as DocumentsValidator; +use Utopia\Database\Validator\Query\Select as SelectValidator; use Utopia\Database\Validator\Spatial; use Utopia\Database\Validator\Structure; @@ -5460,6 +5461,7 @@ private function populateOneToManyRelationshipsBatch( } } + $this->validateRelationshipSelects($relatedCollection, $selectQueries); $this->applySelectFiltersToDocuments($relatedDocuments, $selectQueries); $pagination = Query::groupByType($paginationQueries); @@ -5576,6 +5578,7 @@ private function populateManyToOneRelationshipsBatch( } } + $this->validateRelationshipSelects($relatedCollection, $selectQueries); $this->applySelectFiltersToDocuments($relatedDocuments, $selectQueries); $pagination = Query::groupByType($paginationQueries); @@ -5854,6 +5857,28 @@ function (Document $left, Document $right) use ($orderAttributes, $orderTypes): return $documents; } + /** + * @param array $selectQueries + * @throws QueryException + */ + private function validateRelationshipSelects(Document $collection, array $selectQueries): void + { + if (empty($selectQueries) || !$this->validate) { + return; + } + + $validator = new SelectValidator( + $collection->getAttribute('attributes', []), + $this->adapter->getSupportForAttributes() + ); + + foreach ($selectQueries as $query) { + if (!$validator->isValid($query)) { + throw new QueryException($validator->getDescription()); + } + } + } + /** * Apply select filters to documents after fetching * diff --git a/src/Database/Validator/Query/Relationship.php b/src/Database/Validator/Query/Relationship.php index 439c8945ec..984ec75344 100644 --- a/src/Database/Validator/Query/Relationship.php +++ b/src/Database/Validator/Query/Relationship.php @@ -77,6 +77,14 @@ public function isValid($value): bool $this->message = 'Relationship queries cannot contain relationship queries'; return false; } + + if ($query->getMethod() === Query::TYPE_SELECT) { + $validator = new Select([], supportForAttributes: false); + if (!$validator->isValid($query)) { + $this->message = $validator->getDescription(); + return false; + } + } } $hasPagination = false; diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 1ea7c59a23..9bb38acc47 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -5046,8 +5046,6 @@ public function testNestedSkeletonInnerQueriesNotMutatedAcrossFinds(): void $second = $database->find('nsk_posts', [$nestedQuery, Query::orderAsc('$id')]); $database->skipValidation(fn () => $database->getDocument('nsk_posts', 'nsk_post1', [$nestedQuery])); - $this->assertSame(['author.name'], $nestedQuery->getValues()[0]->getValues()); - $firstAuthor = $first[0]->getAttribute('comments')[0]->getAttribute('author'); $secondAuthor = $second[0]->getAttribute('comments')[0]->getAttribute('author'); @@ -5149,6 +5147,35 @@ public function testNestedSkeletonInvalidInnerFilterRejectedWhenParentsMatch(): $this->deleteNestedSkeletonFixture($database); } + public function testNestedSkeletonInvalidInnerSelectRejectedWhenParentsMatch(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + if (!$database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSkeletonFixture($database); + + try { + $database->find('nsk_posts', [ + Query::relationship('comments', [Query::select(['doesNotExist'])]), + ]); + $this->fail('An invalid inner select was accepted'); + } catch (QueryException $e) { + $this->assertStringContainsString('doesNotExist', $e->getMessage()); + } + + $this->deleteNestedSkeletonFixture($database); + } + public function testNestedSkeletonInvalidInnerFilterWhenNoParentsMatch(): void { /** @var Database $database */ @@ -5982,8 +6009,6 @@ public function testNestedSliceCursorAfterParsed(): void $parsed = Query::parse($nested->toString()); - $this->assertSame('p1c2', $parsed->getValues()[1]->getValues()[0]); - $posts = $database->find('ns_posts', [$parsed, Query::orderAsc('$id')]); $this->assertCount(3, $posts); diff --git a/tests/unit/Validator/Query/RelationshipTest.php b/tests/unit/Validator/Query/RelationshipTest.php index 62bf7af79b..baf6efcdd2 100644 --- a/tests/unit/Validator/Query/RelationshipTest.php +++ b/tests/unit/Validator/Query/RelationshipTest.php @@ -167,6 +167,22 @@ public function testRejectsRelationshipInRelationship(): void $this->assertSame('Relationship queries cannot contain relationship queries', $validator->getDescription()); } + public function testRejectsMalformedInnerSelect(): void + { + $validator = new Relationship($this->attributes()); + + $this->assertFalse($validator->isValid(Query::relationship('comments', [Query::select([])]))); + $this->assertSame('No attributes selected', $validator->getDescription()); + + $this->assertFalse($validator->isValid(new Query(Query::TYPE_RELATIONSHIP, 'comments', [ + new Query(Query::TYPE_SELECT, '', [123]), + ]))); + $this->assertSame('Attribute selection must be a string, got int', $validator->getDescription()); + + $this->assertFalse($validator->isValid(Query::relationship('comments', [Query::select(['text', 'text'])]))); + $this->assertSame('Duplicate attributes selected', $validator->getDescription()); + } + public function testRejectsRelationshipInsideLogicalInnerQuery(): void { $validator = new Relationship($this->attributes());