diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 055d25cf3..1bd8797c9 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1618,7 +1618,14 @@ protected function getSQLCondition(Query $query, array &$binds): string case Query::TYPE_IS_NOT_NULL: return "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())}"; + case Query::TYPE_CONTAINS_ALL: + if ($query->onArray()) { + $binds[":{$placeholder}_0"] = json_encode($query->getValues()); + return "JSON_CONTAINS({$alias}.{$attribute}, :{$placeholder}_0)"; + } + // no break case Query::TYPE_CONTAINS: + case Query::TYPE_CONTAINS_ANY: case Query::TYPE_NOT_CONTAINS: if ($this->getSupportForJSONOverlaps() && $query->onArray()) { $binds[":{$placeholder}_0"] = json_encode($query->getValues()); @@ -1642,7 +1649,7 @@ protected function getSQLCondition(Query $query, array &$binds): string Query::TYPE_NOT_STARTS_WITH => $this->escapeWildcards($value) . '%', Query::TYPE_ENDS_WITH => '%' . $this->escapeWildcards($value), Query::TYPE_NOT_ENDS_WITH => '%' . $this->escapeWildcards($value), - Query::TYPE_CONTAINS => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', + Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', Query::TYPE_NOT_CONTAINS => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', default => $value }; diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index f820786c0..79ca994e8 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2673,7 +2673,7 @@ protected function buildFilter(Query $query): array }; $filter = []; - if ($query->isObjectAttribute() && !\str_contains($attribute, '.') && in_array($query->getMethod(), [Query::TYPE_EQUAL, Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS, Query::TYPE_NOT_EQUAL])) { + if ($query->isObjectAttribute() && !\str_contains($attribute, '.') && in_array($query->getMethod(), [Query::TYPE_EQUAL, Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY, Query::TYPE_CONTAINS_ALL, Query::TYPE_NOT_CONTAINS, Query::TYPE_NOT_EQUAL])) { $this->handleObjectFilters($query, $filter); return $filter; } @@ -2682,8 +2682,10 @@ protected function buildFilter(Query $query): array $filter[$attribute]['$in'] = $value; } elseif ($operator == '$ne' && \is_array($value)) { $filter[$attribute]['$nin'] = $value; + } elseif ($operator == '$all') { + $filter[$attribute]['$all'] = $query->getValues(); } elseif ($operator == '$in') { - if ($query->getMethod() === Query::TYPE_CONTAINS && !$query->onArray()) { + if (in_array($query->getMethod(), [Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY]) && !$query->onArray()) { // contains support array values if (is_array($value)) { $filter['$or'] = array_map(function ($val) use ($attribute) { @@ -2760,6 +2762,8 @@ private function handleObjectFilters(Query $query, array &$filter): void switch ($query->getMethod()) { case Query::TYPE_CONTAINS: + case Query::TYPE_CONTAINS_ANY: + case Query::TYPE_CONTAINS_ALL: case Query::TYPE_NOT_CONTAINS: { $arrayValue = \is_array($queryValue) ? $queryValue : [$queryValue]; $operator = $isNot ? '$nin' : '$in'; @@ -2844,6 +2848,8 @@ protected function getQueryOperator(string $operator): string Query::TYPE_GREATER => '$gt', Query::TYPE_GREATER_EQUAL => '$gte', Query::TYPE_CONTAINS => '$in', + Query::TYPE_CONTAINS_ANY => '$in', + Query::TYPE_CONTAINS_ALL => '$all', Query::TYPE_NOT_CONTAINS => 'notContains', Query::TYPE_SEARCH => '$search', Query::TYPE_NOT_SEARCH => '$search', diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index a4d304a59..5ab3e76ba 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1712,6 +1712,8 @@ protected function handleObjectQueries(Query $query, array &$binds, string $attr } case Query::TYPE_CONTAINS: + case Query::TYPE_CONTAINS_ANY: + case Query::TYPE_CONTAINS_ALL: case Query::TYPE_NOT_CONTAINS: { $isNot = $query->getMethod() === Query::TYPE_NOT_CONTAINS; $conditions = []; @@ -1812,7 +1814,15 @@ protected function getSQLCondition(Query $query, array &$binds): string case Query::TYPE_IS_NOT_NULL: return "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())}"; + case Query::TYPE_CONTAINS_ALL: + if ($query->onArray()) { + // @> checks the array contains ALL specified values + $binds[":{$placeholder}_0"] = \json_encode($query->getValues()); + return "{$alias}.{$attribute} @> :{$placeholder}_0::jsonb"; + } + // no break case Query::TYPE_CONTAINS: + case Query::TYPE_CONTAINS_ANY: case Query::TYPE_NOT_CONTAINS: if ($query->onArray()) { $operator = '@>'; @@ -1834,7 +1844,7 @@ protected function getSQLCondition(Query $query, array &$binds): string Query::TYPE_NOT_STARTS_WITH => $this->escapeWildcards($value) . '%', Query::TYPE_ENDS_WITH => '%' . $this->escapeWildcards($value), Query::TYPE_NOT_ENDS_WITH => '%' . $this->escapeWildcards($value), - Query::TYPE_CONTAINS => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', + Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', Query::TYPE_NOT_CONTAINS => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', default => $value }; diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 86dd5cd18..9b63bc865 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -1801,6 +1801,8 @@ protected function getSQLOperator(string $method): string case Query::TYPE_STARTS_WITH: case Query::TYPE_ENDS_WITH: case Query::TYPE_CONTAINS: + case Query::TYPE_CONTAINS_ANY: + case Query::TYPE_CONTAINS_ALL: case Query::TYPE_NOT_STARTS_WITH: case Query::TYPE_NOT_ENDS_WITH: case Query::TYPE_NOT_CONTAINS: diff --git a/src/Database/Database.php b/src/Database/Database.php index b842053de..5b16e4547 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8982,34 +8982,55 @@ private function processNestedRelationshipPath(string $startCollection, array $q ); if ($needsReverseLookup) { - // Need to find parents by querying children and extracting parent IDs - $childDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find( - $link['toCollection'], - [ - Query::equal('$id', $matchingIds), - Query::select(['$id', $link['twoWayKey']]), + if ($relationType === self::RELATION_MANY_TO_MANY) { + // For many-to-many, query the junction table directly instead + // of resolving full relationships on the child documents. + $fromCollectionDoc = $this->silent(fn () => $this->getCollection($link['fromCollection'])); + $toCollectionDoc = $this->silent(fn () => $this->getCollection($link['toCollection'])); + $junction = $this->getJunctionCollection($fromCollectionDoc, $toCollectionDoc, $link['side']); + + $junctionDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find($junction, [ + Query::equal($link['key'], $matchingIds), Query::limit(PHP_INT_MAX), - ] - ))); + ]))); - $parentIds = []; - foreach ($childDocs as $doc) { - $parentValue = $doc->getAttribute($link['twoWayKey']); - if (\is_array($parentValue)) { - foreach ($parentValue as $pId) { - if ($pId instanceof Document) { - $pId = $pId->getId(); + $parentIds = []; + foreach ($junctionDocs as $jDoc) { + $pId = $jDoc->getAttribute($link['twoWayKey']); + if ($pId && !\in_array($pId, $parentIds)) { + $parentIds[] = $pId; + } + } + } else { + // Need to find parents by querying children and extracting parent IDs + $childDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find( + $link['toCollection'], + [ + Query::equal('$id', $matchingIds), + Query::select(['$id', $link['twoWayKey']]), + Query::limit(PHP_INT_MAX), + ] + ))); + + $parentIds = []; + foreach ($childDocs as $doc) { + $parentValue = $doc->getAttribute($link['twoWayKey']); + if (\is_array($parentValue)) { + foreach ($parentValue as $pId) { + if ($pId instanceof Document) { + $pId = $pId->getId(); + } + if ($pId && !\in_array($pId, $parentIds)) { + $parentIds[] = $pId; + } } - if ($pId && !\in_array($pId, $parentIds)) { - $parentIds[] = $pId; + } else { + if ($parentValue instanceof Document) { + $parentValue = $parentValue->getId(); + } + if ($parentValue && !\in_array($parentValue, $parentIds)) { + $parentIds[] = $parentValue; } - } - } else { - if ($parentValue instanceof Document) { - $parentValue = $parentValue->getId(); - } - if ($parentValue && !\in_array($parentValue, $parentIds)) { - $parentIds[] = $parentValue; } } } @@ -9064,17 +9085,17 @@ private function convertRelationshipQueries( array $relationships, array $queries, ): ?array { - // Early return if no dot-path queries exist - $hasDotPath = false; + // Early return if no relationship queries exist + $hasRelationshipQuery = false; foreach ($queries as $query) { $attr = $query->getAttribute(); - if (\str_contains($attr, '.')) { - $hasDotPath = true; + if (\str_contains($attr, '.') || $query->getMethod() === Query::TYPE_CONTAINS_ALL) { + $hasRelationshipQuery = true; break; } } - if (!$hasDotPath) { + if (!$hasRelationshipQuery) { return $queries; } @@ -9087,19 +9108,66 @@ private function convertRelationshipQueries( $groupedQueries = []; $indicesToRemove = []; - // Group queries by relationship key + // Handle containsAll queries first foreach ($queries as $index => $query) { - if ($query->getMethod() === Query::TYPE_SELECT) { + if ($query->getMethod() !== Query::TYPE_CONTAINS_ALL) { + continue; + } + + $attribute = $query->getAttribute(); + + if (!\str_contains($attribute, '.')) { + continue; // Non-relationship containsAll handled by adapter + } + + $parts = \explode('.', $attribute); + $relationshipKey = \array_shift($parts); + $nestedAttribute = \implode('.', $parts); + $relationship = $relationshipsByKey[$relationshipKey] ?? null; + + if (!$relationship) { + continue; + } + + // Resolve each value independently, then intersect parent IDs + $parentIdSets = []; + $resolvedAttribute = '$id'; + foreach ($query->getValues() as $value) { + $relatedQuery = Query::equal($nestedAttribute, [$value]); + $result = $this->resolveRelationshipGroupToIds($relationship, [$relatedQuery]); + + if ($result === null) { + return null; + } + + $resolvedAttribute = $result['attribute']; + $parentIdSets[] = $result['ids']; + } + + $ids = \count($parentIdSets) > 1 + ? \array_values(\array_intersect(...$parentIdSets)) + : ($parentIdSets[0] ?? []); + + if (empty($ids)) { + return null; + } + + $additionalQueries[] = Query::equal($resolvedAttribute, $ids); + $indicesToRemove[] = $index; + } + + // Group regular dot-path queries by relationship key + foreach ($queries as $index => $query) { + if ($query->getMethod() === Query::TYPE_SELECT || $query->getMethod() === Query::TYPE_CONTAINS_ALL) { continue; } - $method = $query->getMethod(); + $attribute = $query->getAttribute(); if (!\str_contains($attribute, '.')) { continue; } - // Parse the relationship path $parts = \explode('.', $attribute); $relationshipKey = \array_shift($parts); $nestedAttribute = \implode('.', $parts); @@ -9109,7 +9177,6 @@ private function convertRelationshipQueries( continue; } - // Group queries by relationship key if (!isset($groupedQueries[$relationshipKey])) { $groupedQueries[$relationshipKey] = [ 'relationship' => $relationship, @@ -9119,7 +9186,7 @@ private function convertRelationshipQueries( } $groupedQueries[$relationshipKey]['queries'][] = [ - 'method' => $method, + 'method' => $query->getMethod(), 'attribute' => $nestedAttribute, 'values' => $query->getValues() ]; @@ -9130,11 +9197,19 @@ private function convertRelationshipQueries( // Process each relationship group foreach ($groupedQueries as $relationshipKey => $group) { $relationship = $group['relationship']; - $relatedCollection = $relationship->getAttribute('options')['relatedCollection']; - $relationType = $relationship->getAttribute('options')['relationType']; - $side = $relationship->getAttribute('options')['side']; - // Build combined queries for the related collection + // Detect impossible conditions: multiple equal on same attribute + $equalAttrs = []; + foreach ($group['queries'] as $queryData) { + if ($queryData['method'] === Query::TYPE_EQUAL) { + $attr = $queryData['attribute']; + if (isset($equalAttrs[$attr])) { + throw new QueryException("Multiple equal queries on '{$relationshipKey}.{$attr}' will never match a single document. Use Query::containsAll() to match across different related documents."); + } + $equalAttrs[$attr] = true; + } + } + $relatedQueries = []; foreach ($group['queries'] as $queryData) { $relatedQueries[] = new Query( @@ -9145,116 +9220,19 @@ private function convertRelationshipQueries( } try { - // Process multi-level queries by walking the relationship chain from deepest to shallowest - // For example: project.employee.company.name - // 1. Find companies matching name -> company IDs - // 2. Find employees with those company IDs -> employee IDs - // 3. Find projects with those employee IDs -> project IDs - - // Check if we have nested relationships (depth 2+) - $hasNestedPaths = false; - $deepestQuery = null; - foreach ($relatedQueries as $relatedQuery) { - if (\str_contains($relatedQuery->getAttribute(), '.')) { - $hasNestedPaths = true; - $deepestQuery = $relatedQuery; - break; - } - } - - if ($hasNestedPaths) { - // Process the nested path iteratively from deepest to shallowest - $matchingIds = $this->processNestedRelationshipPath( - $relatedCollection, - $relatedQueries - ); - - if ($matchingIds === null || empty($matchingIds)) { - return null; - } - - // Convert to simple ID filter for the current level - $relatedQueries = [Query::equal('$id', $matchingIds)]; - } - - // For virtual parent relationships (where parent doesn't store child IDs), - // we need to find which parents have matching children - // - ONE_TO_MANY from parent side: parent doesn't store children - // - MANY_TO_ONE from child side: the "one" side doesn't store "many" IDs - // - MANY_TO_MANY: both sides are virtual, stored in junction table - $needsParentResolution = ( - ($relationType === self::RELATION_ONE_TO_MANY && $side === self::RELATION_SIDE_PARENT) || - ($relationType === self::RELATION_MANY_TO_ONE && $side === self::RELATION_SIDE_CHILD) || - ($relationType === self::RELATION_MANY_TO_MANY) - ); + $result = $this->resolveRelationshipGroupToIds($relationship, $relatedQueries); - if ($needsParentResolution) { - $matchingDocs = $this->silent(fn () => $this->find( - $relatedCollection, - \array_merge($relatedQueries, [ - Query::limit(PHP_INT_MAX), - ]) - )); - } else { - $matchingDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find( - $relatedCollection, - \array_merge($relatedQueries, [ - Query::select(['$id']), - Query::limit(PHP_INT_MAX), - ]) - ))); + if ($result === null) { + return null; } - $matchingIds = \array_map(fn ($doc) => $doc->getId(), $matchingDocs); - - if ($needsParentResolution) { - // Need to find which parents have these children - $twoWayKey = $relationship->getAttribute('options')['twoWayKey']; - - $parentIds = []; - foreach ($matchingDocs as $doc) { - $parentId = $doc->getAttribute($twoWayKey); - - // Handle MANY_TO_MANY: twoWayKey returns an array - if (\is_array($parentId)) { - foreach ($parentId as $id) { - if ($id instanceof Document) { - $id = $id->getId(); - } - if ($id && !\in_array($id, $parentIds)) { - $parentIds[] = $id; - } - } - } else { - // Handle ONE_TO_MANY/MANY_TO_ONE: single value - if ($parentId instanceof Document) { - $parentId = $parentId->getId(); - } - if ($parentId && !\in_array($parentId, $parentIds)) { - $parentIds[] = $parentId; - } - } - } - - // Add filter on current collection's $id - if (!empty($parentIds)) { - $additionalQueries[] = Query::equal('$id', $parentIds); - } else { - return null; - } - } else { - // For other types, filter by the relationship attribute - if (!empty($matchingIds)) { - $additionalQueries[] = Query::equal($relationshipKey, $matchingIds); - } else { - return null; - } - } + $additionalQueries[] = Query::equal($result['attribute'], $result['ids']); - // Remove all original relationship queries for this group foreach ($group['indices'] as $originalIndex) { $indicesToRemove[] = $originalIndex; } + } catch (QueryException $e) { + throw $e; } catch (\Exception $e) { return null; } @@ -9269,6 +9247,103 @@ private function convertRelationshipQueries( return \array_merge(\array_values($queries), $additionalQueries); } + /** + * Resolve a group of relationship queries to matching document IDs. + * + * @param Document $relationship + * @param array $relatedQueries Queries on the related collection + * @return array{attribute: string, ids: string[]}|null + */ + private function resolveRelationshipGroupToIds( + Document $relationship, + array $relatedQueries, + ): ?array { + $relatedCollection = $relationship->getAttribute('options')['relatedCollection']; + $relationType = $relationship->getAttribute('options')['relationType']; + $side = $relationship->getAttribute('options')['side']; + $relationshipKey = $relationship->getAttribute('key'); + + // Process multi-level queries by walking the relationship chain + $hasNestedPaths = false; + foreach ($relatedQueries as $relatedQuery) { + if (\str_contains($relatedQuery->getAttribute(), '.')) { + $hasNestedPaths = true; + break; + } + } + + if ($hasNestedPaths) { + $matchingIds = $this->processNestedRelationshipPath( + $relatedCollection, + $relatedQueries + ); + + if ($matchingIds === null || empty($matchingIds)) { + return null; + } + + $relatedQueries = \array_values(\array_merge( + \array_filter($relatedQueries, fn (Query $q) => !\str_contains($q->getAttribute(), '.')), + [Query::equal('$id', $matchingIds)] + )); + } + + $needsParentResolution = ( + ($relationType === self::RELATION_ONE_TO_MANY && $side === self::RELATION_SIDE_PARENT) || + ($relationType === self::RELATION_MANY_TO_ONE && $side === self::RELATION_SIDE_CHILD) || + ($relationType === self::RELATION_MANY_TO_MANY) + ); + + if ($needsParentResolution) { + $matchingDocs = $this->silent(fn () => $this->find( + $relatedCollection, + \array_merge($relatedQueries, [ + Query::limit(PHP_INT_MAX), + ]) + )); + } else { + $matchingDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find( + $relatedCollection, + \array_merge($relatedQueries, [ + Query::select(['$id']), + Query::limit(PHP_INT_MAX), + ]) + ))); + } + + if ($needsParentResolution) { + $twoWayKey = $relationship->getAttribute('options')['twoWayKey']; + $parentIds = []; + + foreach ($matchingDocs as $doc) { + $parentId = $doc->getAttribute($twoWayKey); + + if (\is_array($parentId)) { + foreach ($parentId as $id) { + if ($id instanceof Document) { + $id = $id->getId(); + } + if ($id && !\in_array($id, $parentIds)) { + $parentIds[] = $id; + } + } + } else { + if ($parentId instanceof Document) { + $parentId = $parentId->getId(); + } + if ($parentId && !\in_array($parentId, $parentIds)) { + $parentIds[] = $parentId; + } + } + } + + return empty($parentIds) ? null : ['attribute' => '$id', 'ids' => $parentIds]; + } else { + $matchingIds = \array_map(fn ($doc) => $doc->getId(), $matchingDocs); + return empty($matchingIds) ? null : ['attribute' => $relationshipKey, 'ids' => $matchingIds]; + } + } + /** * Encode spatial data from array format to WKT (Well-Known Text) format * diff --git a/src/Database/Query.php b/src/Database/Query.php index 825f4f7ec..686a6ab37 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -15,6 +15,7 @@ class Query public const TYPE_GREATER = 'greaterThan'; public const TYPE_GREATER_EQUAL = 'greaterThanEqual'; public const TYPE_CONTAINS = 'contains'; + public const TYPE_CONTAINS_ANY = 'containsAny'; public const TYPE_NOT_CONTAINS = 'notContains'; public const TYPE_SEARCH = 'search'; public const TYPE_NOT_SEARCH = 'notSearch'; @@ -65,6 +66,7 @@ class Query // Logical methods public const TYPE_AND = 'and'; public const TYPE_OR = 'or'; + public const TYPE_CONTAINS_ALL = 'containsAll'; public const TYPE_ELEM_MATCH = 'elemMatch'; public const DEFAULT_ALIAS = 'main'; @@ -76,6 +78,7 @@ class Query self::TYPE_GREATER, self::TYPE_GREATER_EQUAL, self::TYPE_CONTAINS, + self::TYPE_CONTAINS_ANY, self::TYPE_NOT_CONTAINS, self::TYPE_SEARCH, self::TYPE_NOT_SEARCH, @@ -114,6 +117,7 @@ class Query self::TYPE_CURSOR_BEFORE, self::TYPE_AND, self::TYPE_OR, + self::TYPE_CONTAINS_ALL, self::TYPE_ELEM_MATCH, self::TYPE_REGEX ]; @@ -268,6 +272,7 @@ public static function isMethod(string $value): bool self::TYPE_GREATER, self::TYPE_GREATER_EQUAL, self::TYPE_CONTAINS, + self::TYPE_CONTAINS_ANY, self::TYPE_NOT_CONTAINS, self::TYPE_SEARCH, self::TYPE_NOT_SEARCH, @@ -300,6 +305,7 @@ public static function isMethod(string $value): bool self::TYPE_NOT_TOUCHES, self::TYPE_OR, self::TYPE_AND, + self::TYPE_CONTAINS_ALL, self::TYPE_ELEM_MATCH, self::TYPE_SELECT, self::TYPE_VECTOR_DOT, @@ -533,6 +539,7 @@ public static function greaterThanEqual(string $attribute, string|int|float|bool /** * Helper method to create Query with contains method * + * @deprecated Use containsAny() for array attributes, or keep using contains() for string substring matching. * @param string $attribute * @param array $values * @return Query @@ -542,6 +549,19 @@ public static function contains(string $attribute, array $values): self return new self(self::TYPE_CONTAINS, $attribute, $values); } + /** + * Helper method to create Query with containsAny method. + * For array and relationship attributes, matches documents where the attribute contains ANY of the given values. + * + * @param string $attribute + * @param array $values + * @return Query + */ + public static function containsAny(string $attribute, array $values): self + { + return new self(self::TYPE_CONTAINS_ANY, $attribute, $values); + } + /** * Helper method to create Query with notContains method * @@ -819,6 +839,16 @@ public static function and(array $queries): self return new self(self::TYPE_AND, '', $queries); } + /** + * @param string $attribute + * @param array $values + * @return Query + */ + public static function containsAll(string $attribute, array $values): self + { + return new self(self::TYPE_CONTAINS_ALL, $attribute, $values); + } + /** * Filters $queries for $types * diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index 26f2b09d9..4f9125182 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -104,9 +104,11 @@ public function isValid($value): bool Query::TYPE_ENDS_WITH, Query::TYPE_NOT_ENDS_WITH, Query::TYPE_CONTAINS, + Query::TYPE_CONTAINS_ANY, Query::TYPE_NOT_CONTAINS, Query::TYPE_AND, Query::TYPE_OR, + Query::TYPE_CONTAINS_ALL, Query::TYPE_ELEM_MATCH, Query::TYPE_CROSSES, Query::TYPE_NOT_CROSSES, diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index abe2d6c95..71b6b74f2 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -188,7 +188,7 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s } // object containment queries on the base object attribute - elseif (\in_array($method, [Query::TYPE_EQUAL, Query::TYPE_NOT_EQUAL, Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS], true) + elseif (\in_array($method, [Query::TYPE_EQUAL, Query::TYPE_NOT_EQUAL, Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY, Query::TYPE_CONTAINS_ALL, Query::TYPE_NOT_CONTAINS], true) && !$this->isValidObjectQueryValues($value)) { $this->message = 'Invalid object query structure for attribute "' . $attribute . '"'; return false; @@ -266,7 +266,7 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s if ( !$array && - in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS]) && + in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY, Query::TYPE_CONTAINS_ALL, Query::TYPE_NOT_CONTAINS]) && $attributeSchema['type'] !== Database::VAR_STRING && $attributeSchema['type'] !== Database::VAR_OBJECT && !in_array($attributeSchema['type'], Database::SPATIAL_TYPES) @@ -278,7 +278,7 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s if ( $array && - !in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS, Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL, Query::TYPE_EXISTS, Query::TYPE_NOT_EXISTS]) + !in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY, Query::TYPE_CONTAINS_ALL, Query::TYPE_NOT_CONTAINS, Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL, Query::TYPE_EXISTS, Query::TYPE_NOT_EXISTS]) ) { $this->message = 'Cannot query '. $method .' on attribute "' . $attribute . '" because it is an array.'; return false; @@ -377,7 +377,9 @@ public function isValid($value): bool switch ($method) { case Query::TYPE_EQUAL: case Query::TYPE_CONTAINS: + case Query::TYPE_CONTAINS_ANY: case Query::TYPE_NOT_CONTAINS: + case Query::TYPE_CONTAINS_ALL: case Query::TYPE_EXISTS: case Query::TYPE_NOT_EXISTS: if ($this->isEmpty($value->getValues())) { diff --git a/tests/e2e/Adapter/Scopes/AttributeTests.php b/tests/e2e/Adapter/Scopes/AttributeTests.php index ce6c0f30b..bf376d101 100644 --- a/tests/e2e/Adapter/Scopes/AttributeTests.php +++ b/tests/e2e/Adapter/Scopes/AttributeTests.php @@ -1650,6 +1650,77 @@ public function testArrayAttribute(): void Query::contains('pref', ['Joe']) ]); $this->assertCount(1, $documents); + + // containsAny tests — should behave identically to contains + + $documents = $database->find($collection, [ + Query::containsAny('tv_show', ['love']) + ]); + $this->assertCount(1, $documents); + + $documents = $database->find($collection, [ + Query::containsAny('names', ['Jake', 'Joe']) + ]); + $this->assertCount(1, $documents); + + $documents = $database->find($collection, [ + Query::containsAny('numbers', [-1, 0, 999]) + ]); + $this->assertCount(1, $documents); + + $documents = $database->find($collection, [ + Query::containsAny('booleans', [false, true]) + ]); + $this->assertCount(1, $documents); + + $documents = $database->find($collection, [ + Query::containsAny('pref', ['Joe']) + ]); + $this->assertCount(1, $documents); + + // containsAny with no matching values + $documents = $database->find($collection, [ + Query::containsAny('names', ['Jake', 'Unknown']) + ]); + $this->assertCount(0, $documents); + + // containsAll tests on array attributes + + // All values present in names array + $documents = $database->find($collection, [ + Query::containsAll('names', ['Joe', 'Antony']) + ]); + $this->assertCount(1, $documents); + + // One value missing from names array + $documents = $database->find($collection, [ + Query::containsAll('names', ['Joe', 'Jake']) + ]); + $this->assertCount(0, $documents); + + // All values present in numbers array + $documents = $database->find($collection, [ + Query::containsAll('numbers', [0, 100, -1]) + ]); + $this->assertCount(1, $documents); + + // One value missing from numbers array + $documents = $database->find($collection, [ + Query::containsAll('numbers', [0, 999]) + ]); + $this->assertCount(0, $documents); + + // Single value containsAll — should match + $documents = $database->find($collection, [ + Query::containsAll('booleans', [false]) + ]); + $this->assertCount(1, $documents); + + // Boolean value not present + $documents = $database->find($collection, [ + Query::containsAll('booleans', [true]) + ]); + $this->assertCount(0, $documents); } } diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 1d23e3f2c..e0a39c049 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -3498,6 +3498,41 @@ public function testQueryByRelationshipId(): void $this->assertCount(1, $developers); $this->assertEquals('dev1', $developers[0]->getId()); + // Query projects by BOTH developers using Query::containsAll + // This simulates: "find conversations where both user1 AND user2 are participants" + $projects = $database->find('projectsMtmId', [ + Query::containsAll('developers.$id', ['dev1', 'dev2']), + ]); + $this->assertCount(1, $projects); + $this->assertEquals('project1', $projects[0]->getId()); + + // Inverse: find developers who are on BOTH projects + // dev1 is on project1 and project2, dev2 is only on project1 + $developers = $database->find('developersMtmId', [ + Query::containsAll('projects.$id', ['project1', 'project2']), + ]); + $this->assertCount(1, $developers); + $this->assertEquals('dev1', $developers[0]->getId()); + + // Query projects by BOTH developer names (non-$id attribute) + // project1 has developers Alice and Bob, project2 has only Alice + $projects = $database->find('projectsMtmId', [ + Query::containsAll('developers.devName', ['Alice', 'Bob']), + ]); + $this->assertCount(1, $projects); + $this->assertEquals('project1', $projects[0]->getId()); + + // Two separate equal queries on same relationship attribute should throw + try { + $database->find('projectsMtmId', [ + Query::equal('developers.$id', ['dev1']), + Query::equal('developers.$id', ['dev2']), + ]); + $this->fail('Expected QueryException for impossible equal queries'); + } catch (\Utopia\Database\Exception\Query $e) { + $this->assertStringContainsString('Query::containsAll()', $e->getMessage()); + } + // Clean up MANY_TO_MANY test $database->deleteCollection('developersMtmId'); $database->deleteCollection('projectsMtmId'); diff --git a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php index ad7bbe5ed..73783270e 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php @@ -2250,4 +2250,154 @@ public function testManyToManyRelationshipWithArrayOperators(): void $database->deleteCollection('library'); $database->deleteCollection('book'); } + + /** + * Regression: processNestedRelationshipPath used skipRelationships() + * for many-to-many reverse lookups, which prevented junction-table data + * (twoWayKey) from being populated, yielding empty matchingIds. + */ + public function testNestedManyToManyRelationshipQueries(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // 3-level many-to-many chain: brands <-> products <-> tags + $database->createCollection('brands'); + $database->createCollection('products'); + $database->createCollection('tags'); + + $database->createAttribute('brands', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('products', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute('tags', 'label', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'brands', + relatedCollection: 'products', + type: Database::RELATION_MANY_TO_MANY, + twoWay: true, + id: 'products', + twoWayKey: 'brands', + ); + + $database->createRelationship( + collection: 'products', + relatedCollection: 'tags', + type: Database::RELATION_MANY_TO_MANY, + twoWay: true, + id: 'tags', + twoWayKey: 'products', + ); + + // Seed data + $database->createDocument('tags', new Document([ + '$id' => 'tag_eco', + '$permissions' => [Permission::read(Role::any())], + 'label' => 'Eco-Friendly', + ])); + $database->createDocument('tags', new Document([ + '$id' => 'tag_premium', + '$permissions' => [Permission::read(Role::any())], + 'label' => 'Premium', + ])); + $database->createDocument('tags', new Document([ + '$id' => 'tag_sale', + '$permissions' => [Permission::read(Role::any())], + 'label' => 'Sale', + ])); + + $database->createDocument('products', new Document([ + '$id' => 'prod_a', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Product A', + 'tags' => ['tag_eco', 'tag_premium'], + ])); + $database->createDocument('products', new Document([ + '$id' => 'prod_b', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Product B', + 'tags' => ['tag_sale'], + ])); + $database->createDocument('products', new Document([ + '$id' => 'prod_c', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Product C', + 'tags' => ['tag_eco'], + ])); + + $database->createDocument('brands', new Document([ + '$id' => 'brand_x', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'BrandX', + 'products' => ['prod_a', 'prod_b'], + ])); + $database->createDocument('brands', new Document([ + '$id' => 'brand_y', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'BrandY', + 'products' => ['prod_c'], + ])); + + // --- 1-level deep: query brands by product title (many-to-many) --- + $brands = $database->find('brands', [ + Query::equal('products.title', ['Product A']), + ]); + $this->assertCount(1, $brands); + $this->assertEquals('brand_x', $brands[0]->getId()); + + // --- 2-level deep: query brands by product→tag label (many-to-many→many-to-many) --- + // "Eco-Friendly" tag is on prod_a (BrandX) and prod_c (BrandY) + $brands = $database->find('brands', [ + Query::equal('products.tags.label', ['Eco-Friendly']), + ]); + $this->assertCount(2, $brands); + $brandIds = \array_map(fn ($d) => $d->getId(), $brands); + $this->assertContains('brand_x', $brandIds); + $this->assertContains('brand_y', $brandIds); + + // "Sale" tag is only on prod_b (BrandX) + $brands = $database->find('brands', [ + Query::equal('products.tags.label', ['Sale']), + ]); + $this->assertCount(1, $brands); + $this->assertEquals('brand_x', $brands[0]->getId()); + + // "Premium" tag is only on prod_a (BrandX) + $brands = $database->find('brands', [ + Query::equal('products.tags.label', ['Premium']), + ]); + $this->assertCount(1, $brands); + $this->assertEquals('brand_x', $brands[0]->getId()); + + // --- 2-level deep from the child side: query tags by product→brand name --- + $tags = $database->find('tags', [ + Query::equal('products.brands.name', ['BrandY']), + ]); + $this->assertCount(1, $tags); + $this->assertEquals('tag_eco', $tags[0]->getId()); + + $tags = $database->find('tags', [ + Query::equal('products.brands.name', ['BrandX']), + ]); + $this->assertCount(3, $tags); + $tagIds = \array_map(fn ($d) => $d->getId(), $tags); + $this->assertContains('tag_eco', $tagIds); + $this->assertContains('tag_premium', $tagIds); + $this->assertContains('tag_sale', $tagIds); + + // --- No match returns empty --- + $brands = $database->find('brands', [ + Query::equal('products.tags.label', ['NonExistent']), + ]); + $this->assertCount(0, $brands); + + // Cleanup + $database->deleteCollection('brands'); + $database->deleteCollection('products'); + $database->deleteCollection('tags'); + } }