diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index 5e126a7177..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], 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; @@ -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_RELATIONSHIP) { + 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_RELATIONSHIP && $this->matches($row, $sub)) { return true; } } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 760e9e79c7..f1445d48d8 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_RELATIONSHIP) { + continue; + } + if ($query->isNested()) { if ($query->getMethod() === Query::TYPE_ELEM_MATCH) { $filters[$separator][] = [ diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 81f3350634..ae6179c59c 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_RELATIONSHIP, ], 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_RELATIONSHIP) { + 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_RELATIONSHIP && $this->matchesDocument($document, $sub)) { return true; } } diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 9ca4c1aee3..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) { + 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 4c3fab0550..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; @@ -5054,7 +5055,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 +5117,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 +5125,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 +5139,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 ] ]; @@ -5223,9 +5226,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, @@ -5406,12 +5415,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; } } @@ -5426,12 +5444,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; @@ -5439,22 +5455,35 @@ 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->validateRelationshipSelects($relatedCollection, $selectQueries); $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); } /** @@ -5503,12 +5532,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; } } @@ -5523,12 +5561,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; @@ -5536,20 +5572,35 @@ 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->validateRelationshipSelects($relatedCollection, $selectQueries); $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); } /** @@ -5589,6 +5640,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 = []; @@ -5618,7 +5684,6 @@ private function populateManyToManyRelationshipsBatch( } $related = []; - $allRelatedDocs = []; if (!empty($relatedIds)) { $uniqueRelatedIds = array_unique($relatedIds); $foundRelated = []; @@ -5627,36 +5692,191 @@ 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; } - // Build final related arrays maintaining junction order + $grouped = Query::groupByType($queries); + $ordered = $grouped['orderTypes'] !== []; + + if ( + $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, + $orderAttributes, + $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; } } + $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); + $offset = $offset ?? 0; + + if ($cursor !== null) { + $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) { + $documents = \array_reverse(\array_slice($documents, 0, $position)); + $documents = \array_slice($documents, $offset, $limit); + + return \array_values(\array_reverse($documents)); + } + + $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 $allRelatedDocs; + \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; + } + + /** + * @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()); + } + } } /** @@ -6630,7 +6850,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 +8626,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 +8893,7 @@ public function find(string $collection, array $queries = [], string $forPermiss $orderTypes = $grouped['orderTypes']; $cursor = $grouped['cursor']; $cursorDirection = $grouped['cursorDirection'] ?? Database::CURSOR_AFTER; + $relationshipQueries = $grouped['relationship']; $uniqueOrderBy = false; foreach ($orderAttributes as $order) { @@ -8734,7 +8957,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, $relationshipQueries)); // Convert relationship filter queries to SQL-level subqueries $queriesOrNull = $this->convertRelationshipQueries($relationships, $queries, $collection); @@ -8762,7 +8985,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 +9476,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_RELATIONSHIP + ))); $queriesOrNull = $this->convertRelationshipQueries($relationships, $queries, $collection); // If conversion returns null, it means no documents can match (relationship filter found no matches) @@ -9790,6 +10016,10 @@ public function getLimitForIndexes(): int public function convertQueries(Document $collection, array $queries): array { foreach ($queries as $index => $query) { + if ($query->getMethod() === Query::TYPE_RELATIONSHIP) { + continue; + } + if ($query->isNested()) { $values = $this->convertQueries($collection, $query->getValues()); $query->setValues($values); @@ -10210,7 +10440,60 @@ private function processRelationshipQueries( $nestedSelections = []; foreach ($queries as $query) { - if ($query->getMethod() !== Query::TYPE_SELECT) { + $method = $query->getMethod(); + + if ($method === Query::TYPE_RELATIONSHIP) { + $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 ( + !\in_array($method, [ + 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, + Query::TYPE_CONTAINS_ALL, + ], 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( + $method, + \implode('.', $nesting), + $query->getValues(), + ); + } + + continue; + } + + if ($method !== Query::TYPE_SELECT) { continue; } @@ -10221,7 +10504,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, @@ -10232,12 +10515,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 { @@ -10272,11 +10551,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/src/Database/Mirror.php b/src/Database/Mirror.php index a0151cb92f..a37792cd9b 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -175,6 +175,34 @@ public function disableValidation(): static return $this; } + public function skipValidation(callable $callback): mixed + { + $mirrorInitial = $this->validate; + $sourceInitial = $this->source->validate; + $destinationInitial = $this->destination?->validate; + + $this->disableValidation(); + + try { + return $callback(); + } finally { + $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/src/Database/Query.php b/src/Database/Query.php index 147c463ad0..2680ca3f07 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_RELATIONSHIP = 'relationship'; 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_RELATIONSHIP, self::TYPE_REGEX ]; @@ -132,6 +134,7 @@ class Query self::TYPE_AND, self::TYPE_OR, self::TYPE_ELEM_MATCH, + self::TYPE_RELATIONSHIP, ]; 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_RELATIONSHIP, 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, + * relationship: array * } */ public static function groupByType(array $queries): array @@ -1014,6 +1019,7 @@ public static function groupByType(array $queries): array $orderTypes = []; $cursor = null; $cursorDirection = null; + $relationshipQueries = []; 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_RELATIONSHIP: + $relationshipQueries[] = 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, + 'relationship' => $relationshipQueries, ]; } @@ -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 relationship(string $relationshipKey, array $queries): self + { + return new self(self::TYPE_RELATIONSHIP, $relationshipKey, $queries); + } } diff --git a/src/Database/Validator/IndexedQueries.php b/src/Database/Validator/IndexedQueries.php index a24e0d21da..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()) { + 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 4f91251828..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()) { + if ($query->isNested() && $query->getMethod() !== Query::TYPE_RELATIONSHIP) { 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_RELATIONSHIP => Base::METHOD_TYPE_RELATIONSHIP, default => '', }; diff --git a/src/Database/Validator/Queries/Documents.php b/src/Database/Validator/Queries/Documents.php index 4959a062cf..2ab0614ab9 100644 --- a/src/Database/Validator/Queries/Documents.php +++ b/src/Database/Validator/Queries/Documents.php @@ -10,6 +10,7 @@ use Utopia\Database\Validator\Query\Limit; 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 @@ -22,6 +23,8 @@ class Documents extends IndexedQueries * @param \DateTime $minAllowedDate * @param \DateTime $maxAllowedDate * @param bool $supportForAttributes + * @param bool $supportUnsignedBigInt + * @param bool $supportForRelationship * @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 $supportForRelationship = true ) { $attributes[] = new Document([ '$id' => '$id', @@ -77,6 +81,10 @@ public function __construct( new Select($attributes, $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 a37fdd65a9..6c0aeaca83 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_RELATIONSHIP = 'relationship'; protected string $message = 'Invalid query'; diff --git a/src/Database/Validator/Query/Relationship.php b/src/Database/Validator/Query/Relationship.php new file mode 100644 index 0000000000..984ec75344 --- /dev/null +++ b/src/Database/Validator/Query/Relationship.php @@ -0,0 +1,156 @@ + + */ + 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_RELATIONSHIP) { + $this->message = 'Invalid query method: ' . $value->getMethod(); + return false; + } + + $attribute = $value->getAttribute(); + + if (empty($attribute)) { + $this->message = 'Relationship queries require a relationship attribute'; + return false; + } + + if ($this->supportForAttributes) { + if ( + !isset($this->schema[$attribute]) + || $this->schema[$attribute]['type'] !== Database::VAR_RELATIONSHIP + ) { + $this->message = 'Relationship queries can only be used on relationship attributes: ' . $attribute; + return false; + } + } + + $queries = $value->getValues(); + + if (empty($queries)) { + $this->message = 'Relationship queries can only contain queries'; + return false; + } + + foreach ($queries as $query) { + if (!$query instanceof Query) { + $this->message = 'Relationship queries can only contain queries'; + return false; + } + + if ($query->getMethod() === Query::TYPE_RELATIONSHIP || $this->containsRelationshipQuery($query)) { + $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; + + 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 = 'Relationship pagination is not supported on a singular relationship: ' . $attribute; + return false; + } + + return true; + } + + private function containsRelationshipQuery(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_RELATIONSHIP || $this->containsRelationshipQuery($value)) { + return true; + } + } + + return false; + } + + public function getMethodType(): string + { + return self::METHOD_TYPE_RELATIONSHIP; + } +} diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 9182b8b8bd..9bb38acc47 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -4817,4 +4817,1717 @@ public function testOrderAndCursorWithRelationshipQueries(): void $database->deleteCollection('authorsOrder'); $database->deleteCollection('postsOrder'); } + + private function createNestedSkeletonFixture(Database $database): void + { + $this->deleteNestedSkeletonFixture($database); + + $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) { + if (!$database->silent(fn () => $database->getCollection($collection))->isEmpty()) { + $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::relationship('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::relationship('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::relationship('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::relationship('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])); + + $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::relationship('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::relationship('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::relationship('comments', [Query::limit(1)]), + ]); + $this->fail('updateDocuments accepted a relationship query'); + } catch (QueryException $e) { + $this->assertStringContainsString('Invalid query method: relationship', $e->getMessage()); + } + + try { + $database->deleteDocuments('nsk_posts', [ + Query::relationship('comments', [Query::limit(1)]), + ]); + $this->fail('deleteDocuments accepted a relationship query'); + } catch (QueryException $e) { + $this->assertStringContainsString('Invalid query method: relationship', $e->getMessage()); + } + + try { + $database->getDocument('nsk_posts', 'nsk_post1', [ + Query::relationship('comments', [Query::limit(1)]), + ]); + $this->fail('getDocument accepted a relationship query'); + } catch (QueryException $e) { + $this->assertStringContainsString('Invalid query method: relationship', $e->getMessage()); + } + + $this->deleteNestedSkeletonFixture($database); + } + + public function testNestedSkeletonInvalidInnerFilterRejectedWhenParentsMatch(): 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::equal('doesNotExist', ['x'])]), + ]); + $this->fail('An invalid inner filter was accepted'); + } catch (QueryException $e) { + $this->assertStringContainsString('doesNotExist', $e->getMessage()); + } + + $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 */ + $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::relationship('comments', [Query::equal('doesNotExist', ['x'])]), + ]); + + $this->assertSame([], $posts); + + $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'); + } + + 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::relationship('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::relationship('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::relationship('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::relationship('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 testNestedSliceCursorAfterWithOffset(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSliceFixture($database); + + $posts = $database->find('ns_posts', [ + Query::relationship('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 testNestedSliceCursorBeforeOffsetPastWindowIsEmpty(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSliceFixture($database); + + $posts = $database->find('ns_posts', [ + Query::relationship('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 */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSliceFixture($database); + + $nested = Query::relationship('comments', [ + Query::orderAsc('$id'), + Query::cursorAfter(new Document(['$id' => 'p1c2'])), + Query::limit(2), + ]); + + $parsed = Query::parse($nested->toString()); + + $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::relationship('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::relationship('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::relationship('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::relationship('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::relationship('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::relationship('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::relationship('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']); + } + + 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::relationship('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'); + } + + 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 */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createNestedSkeletonFixture($database); + + $posts = $database->find('nsk_posts', [ + Query::relationship('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::relationship('comments', [Query::limit(1)]), + Query::equal('title', ['Post One']), + ]), + Query::orderAsc('$id'), + ])); + + $this->assertSame(['nsk_post1'], $this->nestedSkeletonIds($posts)); + + $this->deleteNestedSkeletonFixture($database); + } } diff --git a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php index 73783270e2..3a7205f37d 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::relationship('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::relationship('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::relationship('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::relationship('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); + } } diff --git a/tests/unit/QueryTest.php b/tests/unit/QueryTest.php index 7d1414c0fb..d1df310e89 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_RELATIONSHIP, Query::TYPES); + $this->assertTrue(Query::isMethod(Query::TYPE_RELATIONSHIP)); } 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()); + $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([ Query::or([ @@ -609,4 +614,44 @@ public function testShape(): void $deep->shape(), ); } + + public function testRelationshipRoundTrip(): void + { + $query = Query::relationship('comments', [ + Query::equal('approved', [true]), + Query::orderDesc('$createdAt'), + Query::limit(2), + Query::offset(1), + Query::cursorAfter(new Document(['$id' => 'c1'])), + ]); + + $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_RELATIONSHIP, $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..07c6fa1a43 100644 --- a/tests/unit/Validator/QueriesTest.php +++ b/tests/unit/Validator/QueriesTest.php @@ -13,6 +13,7 @@ use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\Query\Order; +use Utopia\Database\Validator\Query\Relationship; class QueriesTest extends TestCase { @@ -116,4 +117,44 @@ public function testValid(): void ]) ); } + + public function testOrRejectsRelationshipQuery(): 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 Relationship($attributes), + ]); + + $this->assertTrue($validator->isValid([Query::relationship('comments', [Query::limit(1)])]), $validator->getDescription()); + + $this->assertFalse($validator->isValid([ + Query::or([ + Query::relationship('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/RelationshipTest.php b/tests/unit/Validator/Query/RelationshipTest.php new file mode 100644 index 0000000000..baf6efcdd2 --- /dev/null +++ b/tests/unit/Validator/Query/RelationshipTest.php @@ -0,0 +1,266 @@ + + */ + 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 Relationship($this->attributes()); + + $this->assertTrue($validator->isValid(Query::relationship('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::relationship('tags', [ + Query::select(['name']), + Query::limit(5), + ]))); + } + + public function testRejectsWrongMethod(): void + { + $validator = new Relationship($this->attributes()); + + $this->assertFalse($validator->isValid(Query::limit(1))); + $this->assertSame('Invalid query method: limit', $validator->getDescription()); + } + + public function testRejectsNonRelationshipAttribute(): void + { + $validator = new Relationship($this->attributes()); + + $this->assertFalse($validator->isValid(Query::relationship('title', [Query::limit(1)]))); + $this->assertSame( + 'Relationship queries can only be used on relationship attributes: title', + $validator->getDescription() + ); + } + + public function testRejectsUnknownAttribute(): void + { + $validator = new Relationship($this->attributes()); + + $this->assertFalse($validator->isValid(Query::relationship('doesNotExist', [Query::limit(1)]))); + $this->assertSame( + 'Relationship queries can only be used on relationship attributes: doesNotExist', + $validator->getDescription() + ); + } + + public function testRejectsEmptyAttribute(): void + { + $validator = new Relationship($this->attributes()); + + $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 Relationship($this->attributes()); + + $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::relationship('comments', []))); + $this->assertSame('Relationship queries can only contain queries', $validator->getDescription()); + } + + public function testRejectsRelationshipInRelationship(): void + { + $validator = new Relationship($this->attributes()); + + $this->assertFalse($validator->isValid(Query::relationship('comments', [ + Query::relationship('author', [Query::limit(1)]), + ]))); + $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()); + + $this->assertFalse($validator->isValid(Query::relationship('comments', [ + Query::or([ + Query::relationship('author', [Query::limit(1)]), + Query::equal('text', ['hi']), + ]), + ]))); + $this->assertSame('Relationship queries cannot contain relationship queries', $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 Relationship($this->attributes()); + + foreach ([Query::limit(1), Query::offset(1), Query::cursorAfter(new Document(['$id' => 'x1']))] as $pagination) { + $this->assertFalse($validator->isValid(Query::relationship($attribute, [$pagination]))); + $this->assertSame( + 'Relationship pagination is not supported on a singular relationship: ' . $attribute, + $validator->getDescription() + ); + } + } + + /** + * @dataProvider singularRelationships + */ + public function testAcceptsFiltersOnSingularRelationship(string $attribute): void + { + $validator = new Relationship($this->attributes()); + + $this->assertTrue($validator->isValid(Query::relationship($attribute, [ + Query::equal('name', ['Alice']), + Query::select(['name']), + ]))); + } + + public function testRejectsInvalidInnerLimit(): void + { + $validator = new Relationship($this->attributes()); + + $this->assertFalse($validator->isValid(Query::relationship('comments', [Query::limit(0)]))); + $this->assertStringContainsString('Invalid limit', $validator->getDescription()); + + $this->assertFalse($validator->isValid(Query::relationship('comments', [Query::limit(-1)]))); + $this->assertStringContainsString('Invalid limit', $validator->getDescription()); + } + + public function testRejectsInvalidInnerCursor(): void + { + $validator = new Relationship($this->attributes(), 4); + + $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 Relationship($this->attributes(), 36, false); + + $this->assertTrue($validator->isValid(Query::relationship('doesNotExist', [Query::limit(1)]))); + $this->assertTrue($validator->isValid(Query::relationship('profile', [Query::limit(1)]))); + } +}