From 3fc53b2c434f793624630d5a8af2c57b3dde66cf Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 27 Aug 2025 23:59:40 +1200 Subject: [PATCH] Fix request filter for nested relationships --- composer.lock | 12 +-- src/Appwrite/Utopia/Request/Filters/V20.php | 86 +++++++++++++-------- 2 files changed, 60 insertions(+), 38 deletions(-) diff --git a/composer.lock b/composer.lock index 077f5204d1..3bf17bc228 100644 --- a/composer.lock +++ b/composer.lock @@ -3557,16 +3557,16 @@ }, { "name": "utopia-php/database", - "version": "1.2.1", + "version": "1.2.3", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "99beaf1dd6dc3561c8332f9893325777553644a4" + "reference": "8a536fead840d9da6ee819fe6b80e0f047997f69" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/99beaf1dd6dc3561c8332f9893325777553644a4", - "reference": "99beaf1dd6dc3561c8332f9893325777553644a4", + "url": "https://api.github.com/repos/utopia-php/database/zipball/8a536fead840d9da6ee819fe6b80e0f047997f69", + "reference": "8a536fead840d9da6ee819fe6b80e0f047997f69", "shasum": "" }, "require": { @@ -3607,9 +3607,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/1.2.1" + "source": "https://github.com/utopia-php/database/tree/1.2.3" }, - "time": "2025-08-26T16:05:26+00:00" + "time": "2025-08-27T11:47:04+00:00" }, { "name": "utopia-php/detector", diff --git a/src/Appwrite/Utopia/Request/Filters/V20.php b/src/Appwrite/Utopia/Request/Filters/V20.php index 2683d600ef..d6e95ec327 100644 --- a/src/Appwrite/Utopia/Request/Filters/V20.php +++ b/src/Appwrite/Utopia/Request/Filters/V20.php @@ -32,59 +32,67 @@ class V20 extends Filter */ protected function manageSelectQueries(array $content): array { - $hasWildcard = false; if (!isset($content['queries'])) { - $hasWildcard = true; - // only query, make it json encoded! - $content['queries'] = [Query::select(['*'])->toString()]; + $content['queries'] = []; + } + + // Handle case where queries is an array but empty + if (\is_array($content['queries'])) { + $content['queries'] = \array_filter($content['queries'], function($q) { + if (\is_object($q) && empty((array)$q)) { + return false; + } + if (\is_string($q) && \trim($q) === '') { + return false; + } + if (empty($q)) { + return false; + } + return true; + }); } try { $parsed = Query::parseQueries($content['queries']); } catch (QueryException) { - // don't crash! return $content; } $selections = Query::groupByType($parsed)['selections'] ?? []; - - // If there are no select queries at all, add wildcard - if (empty($selections)) { - $hasWildcard = true; - $parsed[] = Query::select(['*']); - } elseif (!$hasWildcard) { - // check if any select includes a wildcard as we added one above + + // Check if we need to add wildcard + relationships + // This happens when: + // 1. No select queries exist, OR + // 2. A wildcard select exists + $needsRelationships = empty($selections); + if (!$needsRelationships) { foreach ($selections as $select) { if (\in_array('*', $select->getValues(), true)) { - $hasWildcard = true; + $needsRelationships = true; break; } } } /** - * Add `keys.*` for all model types! + * Add wildcard and relationship selects for backward compatibility */ - if ($hasWildcard) { + if ($needsRelationships) { $relatedKeys = $this->getRelatedCollectionKeys(); + $selects = \array_values(\array_unique(\array_merge(['*'], $relatedKeys))); - if (! empty($relatedKeys)) { - $selects = \array_values(\array_unique(\array_merge(['*'], $relatedKeys))); + // Remove any existing select queries + $parsed = \array_filter( + $parsed, + fn ($query) => $query->getMethod() !== Query::TYPE_SELECT + ); - // remove previous select queries - $parsed = \array_filter( - $parsed, - fn ($query) => $query->getMethod() !== Query::TYPE_SELECT - ); - - // add wildcard + relationship(s) selects - $parsed[] = Query::select($selects); - } + // Add wildcard + relationship(s) selects + $parsed[] = Query::select($selects); } $resolvedQueries = []; foreach ($parsed as $query) { - // make em json encoded! $resolvedQueries[] = $query->toString(); } @@ -95,12 +103,15 @@ class V20 extends Filter /** * Returns all relationship attribute keys in `key.*` format for use with `Query::select`. + * Recursively includes nested relationships up to 3 levels deep. + * Prevents infinite loops by tracking all visited collections in the current path. */ private function getRelatedCollectionKeys( ?string $databaseId = null, ?string $collectionId = null, ?string $prefix = null, int $depth = 1, + array $visited = [] ): array { $databaseId ??= $this->getParamValue('databaseId'); $collectionId ??= $this->getParamValue('collectionId'); @@ -112,6 +123,13 @@ class V20 extends Filter ) { return []; } + + // Check if we've already visited this collection in the current path to prevent cycles + if (in_array($collectionId, $visited)) { + return []; + } + + $visited[] = $collectionId; $dbForProject = $this->getDbForProject(); if ($dbForProject === null) { @@ -144,23 +162,27 @@ class V20 extends Filter $key = $attr['key']; $fullKey = $prefix ? $prefix . '.' . $key : $key; + $relatedCollectionId = $attr['relatedCollection'] ?? null; + + // Skip this relationship entirely if it points to an already visited collection + if ($relatedCollectionId && in_array($relatedCollectionId, $visited)) { + continue; + } // Add the wildcard select for this relationship $relationshipKeys[] = $fullKey . '.*'; - // Get the related collection for nested relationships - $relatedCollectionId = $attr['relatedCollection'] ?? null; - + // Continue recursively if we have a related collection if ($relatedCollectionId) { - // Recursively get nested relationship keys $nestedKeys = $this->getRelatedCollectionKeys( $databaseId, $relatedCollectionId, $fullKey, $depth + 1, + $visited ); - $relationshipKeys = \array_merge($relationshipKeys, $nestedKeys); + \array_push($relationshipKeys, ...$nestedKeys); } }