From 66502f608111cfce7ddeeb836a2ecb216531f4b2 Mon Sep 17 00:00:00 2001 From: deepshekhardas Date: Sun, 15 Mar 2026 17:42:56 +0530 Subject: [PATCH 1/2] Implement cascading deletes and findAcrossCollections support --- src/Database/Adapter.php | 36 ++- src/Database/Adapter/Postgres.php | 5 +- src/Database/Adapter/SQL.php | 275 +++++++++++++++++-- src/Database/Database.php | 95 ++++++- src/Database/Query.php | 142 +++++++++- src/Database/Validator/Queries/Documents.php | 2 + src/Database/Validator/Query/Join.php | 49 ++++ src/Database/Validator/Query/Select.php | 34 ++- 8 files changed, 602 insertions(+), 36 deletions(-) create mode 100644 src/Database/Validator/Query/Join.php diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 4e25c8f81..1d0191964 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -807,6 +807,32 @@ abstract public function deleteDocument(string $collection, string $id): bool; */ abstract public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int; + /** + * Find Documents Across Collections + * + * @param array $collections + * @param array $queries + * @param int|null $limit + * @param int|null $offset + * @param array $orderAttributes + * @param array $orderTypes + * @param array $cursor + * @param string $cursorDirection + * @param string $forPermission + * @return array> + */ + abstract public function findAcrossCollections( + array $collections, + array $queries = [], + ?int $limit = 25, + ?int $offset = null, + array $orderAttributes = [], + array $orderTypes = [], + array $cursor = [], + string $cursorDirection = Database::CURSOR_AFTER, + string $forPermission = Database::PERMISSION_READ + ): array; + /** * Find Documents * @@ -1264,9 +1290,13 @@ protected function getAttributeSelections(array $queries): array foreach ($queries as $query) { switch ($query->getMethod()) { case Query::TYPE_SELECT: - foreach ($query->getValues() as $value) { - $selections[] = $value; - } + case Query::TYPE_SELECT_DISTINCT: + case Query::TYPE_COUNT: + case Query::TYPE_SUM: + case Query::TYPE_AVG: + case Query::TYPE_MIN: + case Query::TYPE_MAX: + $selections[] = $query; break; } } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 2af11aea3..09132eb78 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2067,9 +2067,8 @@ protected function encodeArray(string $value): array $string = substr($value, 1, -1); if (empty($string)) { return []; - } else { - return explode(',', $string); } + return str_getcsv($string, ',', '"', '\\'); } /** @@ -2086,7 +2085,7 @@ protected function decodeArray(array $value): string } foreach ($value as &$item) { - $item = '"' . str_replace(['"', '(', ')'], ['\"', '\(', '\)'], $item) . '"'; + $item = '"' . str_replace('"', '\\"', $item) . '"'; } return '{' . implode(",", $value) . '}'; diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index fb949dfa4..c341b186b 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -2287,7 +2287,13 @@ 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 || + in_array($query->getMethod(), [ + Query::TYPE_INNER_JOIN, + Query::TYPE_LEFT_JOIN, + Query::TYPE_RIGHT_JOIN, + Query::TYPE_FULL_JOIN, + ])) { continue; } @@ -2372,33 +2378,70 @@ public function getTenantQuery( */ protected function getAttributeProjection(array $selections, string $prefix): mixed { - if (empty($selections) || \in_array('*', $selections)) { + if (empty($selections)) { return "{$this->quote($prefix)}.*"; } - // Handle specific selections with spatial conversion where needed - $internalKeys = [ - '$id', - '$sequence', - '$permissions', - '$createdAt', - '$updatedAt', - ]; + $projections = []; + $isDistinct = false; - $selections = \array_diff($selections, [...$internalKeys, '$collection']); + foreach ($selections as $selection) { + if ($selection instanceof Query) { + switch ($selection->getMethod()) { + case Query::TYPE_SELECT_DISTINCT: + $isDistinct = true; + foreach ($selection->getValues() as $value) { + $projections[] = "{$this->quote($prefix)}.{$this->quote($this->filter($value))}"; + } + break; + case Query::TYPE_COUNT: + $attr = $selection->getAttribute(); + $target = ($attr === '*') ? '*' : "{$this->quote($prefix)}.{$this->quote($this->filter($attr))}"; + $projections[] = "COUNT({$target}) AS _count"; + break; + case Query::TYPE_SUM: + $projections[] = "SUM({$this->quote($prefix)}.{$this->quote($this->filter($selection->getAttribute()))}) AS _sum"; + break; + case Query::TYPE_AVG: + $projections[] = "AVG({$this->quote($prefix)}.{$this->quote($this->filter($selection->getAttribute()))}) AS _avg"; + break; + case Query::TYPE_MIN: + $projections[] = "MIN({$this->quote($prefix)}.{$this->quote($this->filter($selection->getAttribute()))}) AS _min"; + break; + case Query::TYPE_MAX: + $projections[] = "MAX({$this->quote($prefix)}.{$this->quote($this->filter($selection->getAttribute()))}) AS _max"; + break; + case Query::TYPE_SELECT: + foreach ($selection->getValues() as $value) { + if ($value === '*') { + $projections[] = "{$this->quote($prefix)}.*"; + continue; + } + $projections[] = "{$this->quote($prefix)}.{$this->quote($this->filter($value))}"; + } + break; + } + } else { + // Backward compatibility for string selections + if ($selection === '*') { + $projections[] = "{$this->quote($prefix)}.*"; + } else { + $projections[] = "{$this->quote($prefix)}.{$this->quote($this->filter($selection))}"; + } + } + } - foreach ($internalKeys as $internalKey) { - $selections[] = $this->getInternalKeyForAttribute($internalKey); + if (empty($projections)) { + return "{$this->quote($prefix)}.*"; } - $projections = []; - foreach ($selections as $selection) { - $filteredSelection = $this->filter($selection); - $quotedSelection = $this->quote($filteredSelection); - $projections[] = "{$this->quote($prefix)}.{$quotedSelection}"; + $sql = \implode(', ', \array_unique($projections)); + + if ($isDistinct) { + $sql = "DISTINCT " . $sql; } - return \implode(',', $projections); + return $sql; } protected function getInternalKeyForAttribute(string $attribute): string @@ -3023,6 +3066,24 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $queries = $otherQueries; + // Extract join queries + $joinQueries = []; + $otherQueries = []; + foreach ($queries as $query) { + if (in_array($query->getMethod(), [ + Query::TYPE_INNER_JOIN, + Query::TYPE_LEFT_JOIN, + Query::TYPE_RIGHT_JOIN, + Query::TYPE_FULL_JOIN, + ])) { + $joinQueries[] = $query; + } else { + $otherQueries[] = $query; + } + } + + $queries = $otherQueries; + $cursorWhere = []; foreach ($orderAttributes as $i => $originalAttribute) { @@ -3139,9 +3200,32 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $selections = $this->getAttributeSelections($queries); + $sqlJoins = ''; + foreach ($joinQueries as $query) { + $joinMethod = match ($query->getMethod()) { + Query::TYPE_INNER_JOIN => 'INNER JOIN', + Query::TYPE_LEFT_JOIN => 'LEFT JOIN', + Query::TYPE_RIGHT_JOIN => 'RIGHT JOIN', + Query::TYPE_FULL_JOIN => 'FULL JOIN', + default => 'INNER JOIN', + }; + + $joinCollection = $this->filter($query->getAttribute()); + $joinTable = $this->getSQLTable($joinCollection); + $joinOn = $query->getValues()[0] ?? ''; + $joinAlias = $query->getValues()[1] ?? ''; + + $sqlJoins .= " {$joinMethod} {$joinTable}"; + if (!empty($joinAlias)) { + $sqlJoins .= " AS {$this->quote($this->filter($joinAlias))}"; + } + $sqlJoins .= " ON {$joinOn}"; + } + $sql = " SELECT {$this->getAttributeProjection($selections, $alias)} FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} + {$sqlJoins} {$sqlWhere} {$sqlOrder} {$sqlLimit}; @@ -3204,6 +3288,159 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 return $results; } + /** + * @inheritDoc + */ + public function findAcrossCollections(array $collections, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array + { + $roles = $this->authorization->getRoles(); + $subqueries = []; + $binds = []; + $alias = Query::DEFAULT_ALIAS; + + // Parse queries to find filter attributes + $groupedQueries = Query::groupByType($queries); + $filters = $groupedQueries['filters']; + $selections = $this->getAttributeSelections($queries); + + foreach ($collections as $i => $collection) { + $name = $this->filter($collection->getId()); + $table = $this->getSQLTable($name); + $where = []; + + // Add permissions check for this specific collection + if ($this->authorization->getStatus()) { + $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias, $forPermission); + } + + // Add tenant check + if ($this->sharedTables) { + $where[] = "{$this->getTenantQuery($collection->getId(), $alias, condition: '')}"; + } + + // Add filters if they exist + $collectionFilters = []; + foreach ($filters as $filter) { + $attr = $filter->getAttribute(); + // Check if attribute exists in this collection + $hasAttr = false; + foreach ($collection->getAttribute('attributes', []) as $collectionAttr) { + if ($collectionAttr->getId() === $attr) { + $hasAttr = true; + break; + } + } + + if ($hasAttr) { + $collectionFilters[] = $filter; + } + } + + $filterConditions = $this->getSQLConditions($collectionFilters, $binds); + if (!empty($filterConditions)) { + $where[] = $filterConditions; + } + + $sqlWhere = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + + // Harmonize projection: Select common attributes + any specific selections + $projectionParts = [ + "{$this->quote($alias)}.{$this->quote('_uid')} AS {$this->quote('$id')}", + "{$this->quote($alias)}.{$this->quote('_createdAt')} AS {$this->quote('$createdAt')}", + "{$this->quote($alias)}.{$this->quote('_updatedAt')} AS {$this->quote('$updatedAt')}", + "{$this->quote($alias)}.{$this->quote('_permissions')} AS {$this->quote('$permissions')}", + "'{$name}' AS {$this->quote('$collection')}" + ]; + + foreach ($selections as $selection) { + $attr = $selection->getAttribute(); + if ($attr === '$id' || $attr === '$createdAt' || $attr === '$updatedAt' || $attr === '$permissions') { + continue; + } + + $internalAttr = $this->getInternalKeyForAttribute($attr); + // Check if attribute exists in this collection + $hasAttr = false; + foreach ($collection->getAttribute('attributes', []) as $collectionAttr) { + if ($collectionAttr->getId() === $attr) { + $hasAttr = true; + break; + } + } + + if ($hasAttr) { + $projectionParts[] = "{$this->quote($alias)}.{$this->quote($internalAttr)} AS {$this->quote($attr)}"; + } else { + $projectionParts[] = "NULL AS {$this->quote($attr)}"; + } + } + + $projection = implode(', ', $projectionParts); + + $subqueries[] = "(SELECT {$projection} FROM {$table} AS {$this->quote($alias)} {$sqlWhere})"; + } + + if ($this->sharedTables) { + $binds[':_tenant'] = $this->tenant; + } + + $sqlUnion = implode(' UNION ALL ', $subqueries); + + // Build global ORDER BY + $orders = []; + foreach ($orderAttributes as $i => $originalAttribute) { + $orderType = $orderTypes[$i] ?? Database::ORDER_ASC; + $orders[] = "{$this->quote($originalAttribute)} {$this->filter($orderType)}"; + } + $sqlOrder = !empty($orders) ? 'ORDER BY ' . implode(', ', $orders) : ''; + + $sqlLimit = ''; + if (!is_null($limit)) { + $binds[':limit'] = $limit; + $sqlLimit = 'LIMIT :limit'; + } + if (!is_null($offset)) { + $binds[':offset'] = $offset; + $sqlLimit .= ' OFFSET :offset'; + } + + $sql = " + SELECT * FROM ( + {$sqlUnion} + ) AS global_search + {$sqlOrder} + {$sqlLimit}; + "; + + try { + $stmt = $this->getPDO()->prepare($sql); + + foreach ($binds as $key => $value) { + if (gettype($value) === 'double') { + $stmt->bindValue($key, $this->getFloatPrecision($value), \PDO::PARAM_STR); + } else { + $stmt->bindValue($key, $value, $this->getPDOType($value)); + } + } + + $this->execute($stmt); + } catch (PDOException $e) { + throw $this->processException($e); + } + + $results = $stmt->fetchAll(); + $stmt->closeCursor(); + + foreach ($results as $index => $document) { + if (isset($document['$permissions'])) { + $document['$permissions'] = json_decode($document['$permissions'] ?? '[]', true); + } + $results[$index] = new Document($document); + } + + return $results; + } + /** * Count Documents * diff --git a/src/Database/Database.php b/src/Database/Database.php index 32682716a..3f336be70 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -7717,11 +7717,19 @@ private function deleteRestrict( } if ( - !empty($value) - && $relationType !== Database::RELATION_MANY_TO_ONE + $relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_PARENT ) { - throw new RestrictedException('Cannot delete document because it has at least one related document.'); + if (empty($value)) { + $value = $this->authorization->skip(fn () => $this->findOne($relatedCollection->getId(), [ + Query::select(['$id']), + Query::equal($twoWayKey, [$document->getId()]) + ])); + } + + if (!empty($value)) { + throw new RestrictedException('Cannot delete document because it has at least one related document.'); + } } if ( @@ -7820,6 +7828,15 @@ private function deleteSetNull(Document $collection, Document $relatedCollection if ($side === Database::RELATION_SIDE_CHILD) { break; } + + if (empty($value)) { + $value = $this->authorization->skip(fn () => $this->find($relatedCollection->getId(), [ + Query::select(['$id']), + Query::equal($twoWayKey, [$document->getId()]), + Query::limit(PHP_INT_MAX) + ])); + } + foreach ($value as $relation) { $this->authorization->skip(function () use ($relatedCollection, $twoWayKey, $relation) { $this->skipRelationships(fn () => $this->updateDocument( @@ -7915,6 +7932,14 @@ private function deleteCascade(Document $collection, Document $relatedCollection break; } + if (empty($value)) { + $value = $this->authorization->skip(fn () => $this->find($relatedCollection->getId(), [ + Query::select(['$id']), + Query::equal($twoWayKey, [$document->getId()]), + Query::limit(PHP_INT_MAX) + ])); + } + $this->relationshipDeleteStack[] = $relationship; foreach ($value as $relation) { @@ -8379,6 +8404,70 @@ public function find(string $collection, array $queries = [], string $forPermiss return $results; } + /** + * Find Documents Across Collections + * + * @param array $collections + * @param array $queries + * @param string $forPermission + * @return array + * @throws DatabaseException + */ + public function findAcrossCollections(array $collections, array $queries = [], string $forPermission = Database::PERMISSION_READ): array + { + $collectionDocuments = []; + foreach ($collections as $id) { + $collection = $this->silent(fn () => $this->getCollection($id)); + if ($collection->isEmpty()) { + throw new NotFoundException("Collection '{$id}' not found"); + } + $collectionDocuments[] = $collection; + } + + $this->checkQueryTypes($queries); + + $grouped = Query::groupByType($queries); + $limit = $grouped['limit']; + $offset = $grouped['offset']; + $orderAttributes = $grouped['orderAttributes']; + $orderTypes = $grouped['orderTypes']; + $cursor = $grouped['cursor']; + $cursorDirection = $grouped['cursorDirection'] ?? Database::CURSOR_AFTER; + + $results = $this->adapter->findAcrossCollections( + $collectionDocuments, + $queries, + $limit ?? 25, + $offset ?? 0, + $orderAttributes, + $orderTypes, + $cursor ?? [], + $cursorDirection, + $forPermission + ); + + foreach ($results as $index => $node) { + $collectionId = $node->getAttribute('$collection'); + $collection = $this->silent(fn () => $this->getCollection($collectionId)); + + // Apply decoding and casting based on the source collection + $node = $this->adapter->castingAfter($collection, $node); + $node = $this->casting($collection, $node); + $node = $this->decode($collection, $node); + + // Convert to custom document type if mapped + if (isset($this->documentTypes[$collectionId])) { + $node = $this->createDocumentInstance($collectionId, $node->getArrayCopy()); + } + + $results[$index] = $node; + } + + $this->trigger(self::EVENT_DOCUMENT_FIND, $results); + + return $results; + } + /** * Helper method to iterate documents in collection using callback pattern * Alterative is diff --git a/src/Database/Query.php b/src/Database/Query.php index 686a6ab37..5292521fd 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -68,6 +68,16 @@ class Query public const TYPE_OR = 'or'; public const TYPE_CONTAINS_ALL = 'containsAll'; public const TYPE_ELEM_MATCH = 'elemMatch'; + public const TYPE_INNER_JOIN = 'innerJoin'; + public const TYPE_LEFT_JOIN = 'leftJoin'; + public const TYPE_RIGHT_JOIN = 'rightJoin'; + public const TYPE_FULL_JOIN = 'fullJoin'; + public const TYPE_SELECT_DISTINCT = 'selectDistinct'; + public const TYPE_COUNT = 'count'; + public const TYPE_SUM = 'sum'; + public const TYPE_AVG = 'avg'; + public const TYPE_MIN = 'min'; + public const TYPE_MAX = 'max'; public const DEFAULT_ALIAS = 'main'; public const TYPES = [ @@ -119,7 +129,17 @@ class Query self::TYPE_OR, self::TYPE_CONTAINS_ALL, self::TYPE_ELEM_MATCH, - self::TYPE_REGEX + self::TYPE_REGEX, + self::TYPE_INNER_JOIN, + self::TYPE_LEFT_JOIN, + self::TYPE_RIGHT_JOIN, + self::TYPE_FULL_JOIN, + self::TYPE_SELECT_DISTINCT, + self::TYPE_COUNT, + self::TYPE_SUM, + self::TYPE_AVG, + self::TYPE_MIN, + self::TYPE_MAX ]; public const VECTOR_TYPES = [ @@ -312,7 +332,17 @@ public static function isMethod(string $value): bool self::TYPE_VECTOR_COSINE, self::TYPE_VECTOR_EUCLIDEAN, self::TYPE_EXISTS, - self::TYPE_NOT_EXISTS => true, + self::TYPE_NOT_EXISTS, + self::TYPE_INNER_JOIN, + self::TYPE_LEFT_JOIN, + self::TYPE_RIGHT_JOIN, + self::TYPE_FULL_JOIN, + self::TYPE_SELECT_DISTINCT, + self::TYPE_COUNT, + self::TYPE_SUM, + self::TYPE_AVG, + self::TYPE_MIN, + self::TYPE_MAX => true, default => false, }; } @@ -894,6 +924,7 @@ public static function getCursorQueries(array $queries, bool $clone = true): arr * @return array{ * filters: array, * selections: array, + * joins: array, * limit: int|null, * offset: int|null, * orderAttributes: array, @@ -906,6 +937,7 @@ public static function groupByType(array $queries): array { $filters = []; $selections = []; + $joins = []; $limit = null; $offset = null; $orderAttributes = []; @@ -968,6 +1000,13 @@ public static function groupByType(array $queries): array $selections[] = clone $query; break; + case Query::TYPE_INNER_JOIN: + case Query::TYPE_LEFT_JOIN: + case Query::TYPE_RIGHT_JOIN: + case Query::TYPE_FULL_JOIN: + $joins[] = clone $query; + break; + default: $filters[] = clone $query; break; @@ -977,6 +1016,7 @@ public static function groupByType(array $queries): array return [ 'filters' => $filters, 'selections' => $selections, + 'joins' => $joins, 'limit' => $limit, 'offset' => $offset, 'orderAttributes' => $orderAttributes, @@ -1282,4 +1322,102 @@ public static function elemMatch(string $attribute, array $queries): self { return new self(self::TYPE_ELEM_MATCH, $attribute, $queries); } + + /** + * @param string $collection + * @param string $on + * @param string $alias + * @return Query + */ + public static function innerJoin(string $collection, string $on, string $alias = ''): self + { + return new self(self::TYPE_INNER_JOIN, $collection, [$on, $alias]); + } + + /** + * @param string $collection + * @param string $on + * @param string $alias + * @return Query + */ + public static function leftJoin(string $collection, string $on, string $alias = ''): self + { + return new self(self::TYPE_LEFT_JOIN, $collection, [$on, $alias]); + } + + /** + * @param string $collection + * @param string $on + * @param string $alias + * @return Query + */ + public static function rightJoin(string $collection, string $on, string $alias = ''): self + { + return new self(self::TYPE_RIGHT_JOIN, $collection, [$on, $alias]); + } + + /** + * @param string $collection + * @param string $on + * @param string $alias + * @return Query + */ + public static function fullJoin(string $collection, string $on, string $alias = ''): self + { + return new self(self::TYPE_FULL_JOIN, $collection, [$on, $alias]); + } + + /** + * @param array $attributes + * @return Query + */ + public static function selectDistinct(array $attributes): self + { + return new self(self::TYPE_SELECT_DISTINCT, values: $attributes); + } + + /** + * @param string $attribute + * @return Query + */ + public static function count(string $attribute = '*'): self + { + return new self(self::TYPE_COUNT, $attribute); + } + + /** + * @param string $attribute + * @return Query + */ + public static function sum(string $attribute): self + { + return new self(self::TYPE_SUM, $attribute); + } + + /** + * @param string $attribute + * @return Query + */ + public static function avg(string $attribute): self + { + return new self(self::TYPE_AVG, $attribute); + } + + /** + * @param string $attribute + * @return Query + */ + public static function min(string $attribute): self + { + return new self(self::TYPE_MIN, $attribute); + } + + /** + * @param string $attribute + * @return Query + */ + public static function max(string $attribute): self + { + return new self(self::TYPE_MAX, $attribute); + } } diff --git a/src/Database/Validator/Queries/Documents.php b/src/Database/Validator/Queries/Documents.php index e55852bb8..93f594592 100644 --- a/src/Database/Validator/Queries/Documents.php +++ b/src/Database/Validator/Queries/Documents.php @@ -7,6 +7,7 @@ use Utopia\Database\Validator\IndexedQueries; use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\Query\Filter; +use Utopia\Database\Validator\Query\Join; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\Query\Order; @@ -73,6 +74,7 @@ public function __construct( ), new Order($attributes, $supportForAttributes), new Select($attributes, $supportForAttributes), + new Join(), ]; parent::__construct($attributes, $indexes, $validators); diff --git a/src/Database/Validator/Query/Join.php b/src/Database/Validator/Query/Join.php new file mode 100644 index 000000000..6954dd4c7 --- /dev/null +++ b/src/Database/Validator/Query/Join.php @@ -0,0 +1,49 @@ +getMethod(); + + if (!in_array($method, [ + Query::TYPE_INNER_JOIN, + Query::TYPE_LEFT_JOIN, + Query::TYPE_RIGHT_JOIN, + Query::TYPE_FULL_JOIN, + ])) { + return false; + } + + if (empty($value->getAttribute())) { + $this->message = 'Join query requires a collection ID'; + return false; + } + + $values = $value->getValues(); + + if (empty($values) || empty($values[0])) { + $this->message = 'Join query requires an ON clause'; + return false; + } + + return true; + } + + public function getMethodType(): string + { + return self::METHOD_TYPE_FILTER; // Joins are treated similar to filters in grouping + } +} diff --git a/src/Database/Validator/Query/Select.php b/src/Database/Validator/Query/Select.php index b0ed9e564..361cdd053 100644 --- a/src/Database/Validator/Query/Select.php +++ b/src/Database/Validator/Query/Select.php @@ -54,7 +54,18 @@ public function isValid($value): bool return false; } - if ($value->getMethod() !== Query::TYPE_SELECT) { + $method = $value->getMethod(); + $allowedMethods = [ + Query::TYPE_SELECT, + Query::TYPE_SELECT_DISTINCT, + Query::TYPE_COUNT, + Query::TYPE_SUM, + Query::TYPE_AVG, + Query::TYPE_MIN, + Query::TYPE_MAX, + ]; + + if (!\in_array($method, $allowedMethods)) { return false; } @@ -63,17 +74,28 @@ public function isValid($value): bool Database::INTERNAL_ATTRIBUTES ); - if (\count($value->getValues()) === 0) { + $attributes = $value->getValues(); + + // For aggregates, the attribute is stored in the attribute property, not values + if (\in_array($method, [Query::TYPE_COUNT, Query::TYPE_SUM, Query::TYPE_AVG, Query::TYPE_MIN, Query::TYPE_MAX])) { + $attributes = [$value->getAttribute()]; + } + + if (\count($attributes) === 0 && $method === Query::TYPE_SELECT) { $this->message = 'No attributes selected'; return false; } - if (\count($value->getValues()) !== \count(\array_unique($value->getValues()))) { + if (\count($attributes) !== \count(\array_unique($attributes))) { $this->message = 'Duplicate attributes selected'; return false; - } - foreach ($value->getValues() as $attribute) { + + foreach ($attributes as $attribute) { + if ($attribute === '*') { + continue; + } + if (\str_contains($attribute, '.')) { //special symbols with `dots` if (isset($this->schema[$attribute])) { @@ -90,7 +112,7 @@ public function isValid($value): bool continue; } - if ($this->supportForAttributes && !isset($this->schema[$attribute]) && $attribute !== '*') { + if ($this->supportForAttributes && !isset($this->schema[$attribute])) { $this->message = 'Attribute not found in schema: ' . $attribute; return false; } From 9b8ac97ba33bcd66b3e605d35b0ed8b51d76b649 Mon Sep 17 00:00:00 2001 From: deepshekhardas Date: Sun, 15 Mar 2026 19:30:05 +0530 Subject: [PATCH 2/2] feat: implement global search across collections using UNION ALL --- src/Database/Adapter.php | 36 ++++- src/Database/Adapter/SQL.php | 275 ++++++++++++++++++++++++++++++++--- src/Database/Database.php | 95 +++++++++++- 3 files changed, 381 insertions(+), 25 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 4e25c8f81..1d0191964 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -807,6 +807,32 @@ abstract public function deleteDocument(string $collection, string $id): bool; */ abstract public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int; + /** + * Find Documents Across Collections + * + * @param array $collections + * @param array $queries + * @param int|null $limit + * @param int|null $offset + * @param array $orderAttributes + * @param array $orderTypes + * @param array $cursor + * @param string $cursorDirection + * @param string $forPermission + * @return array> + */ + abstract public function findAcrossCollections( + array $collections, + array $queries = [], + ?int $limit = 25, + ?int $offset = null, + array $orderAttributes = [], + array $orderTypes = [], + array $cursor = [], + string $cursorDirection = Database::CURSOR_AFTER, + string $forPermission = Database::PERMISSION_READ + ): array; + /** * Find Documents * @@ -1264,9 +1290,13 @@ protected function getAttributeSelections(array $queries): array foreach ($queries as $query) { switch ($query->getMethod()) { case Query::TYPE_SELECT: - foreach ($query->getValues() as $value) { - $selections[] = $value; - } + case Query::TYPE_SELECT_DISTINCT: + case Query::TYPE_COUNT: + case Query::TYPE_SUM: + case Query::TYPE_AVG: + case Query::TYPE_MIN: + case Query::TYPE_MAX: + $selections[] = $query; break; } } diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index fb949dfa4..c341b186b 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -2287,7 +2287,13 @@ 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 || + in_array($query->getMethod(), [ + Query::TYPE_INNER_JOIN, + Query::TYPE_LEFT_JOIN, + Query::TYPE_RIGHT_JOIN, + Query::TYPE_FULL_JOIN, + ])) { continue; } @@ -2372,33 +2378,70 @@ public function getTenantQuery( */ protected function getAttributeProjection(array $selections, string $prefix): mixed { - if (empty($selections) || \in_array('*', $selections)) { + if (empty($selections)) { return "{$this->quote($prefix)}.*"; } - // Handle specific selections with spatial conversion where needed - $internalKeys = [ - '$id', - '$sequence', - '$permissions', - '$createdAt', - '$updatedAt', - ]; + $projections = []; + $isDistinct = false; - $selections = \array_diff($selections, [...$internalKeys, '$collection']); + foreach ($selections as $selection) { + if ($selection instanceof Query) { + switch ($selection->getMethod()) { + case Query::TYPE_SELECT_DISTINCT: + $isDistinct = true; + foreach ($selection->getValues() as $value) { + $projections[] = "{$this->quote($prefix)}.{$this->quote($this->filter($value))}"; + } + break; + case Query::TYPE_COUNT: + $attr = $selection->getAttribute(); + $target = ($attr === '*') ? '*' : "{$this->quote($prefix)}.{$this->quote($this->filter($attr))}"; + $projections[] = "COUNT({$target}) AS _count"; + break; + case Query::TYPE_SUM: + $projections[] = "SUM({$this->quote($prefix)}.{$this->quote($this->filter($selection->getAttribute()))}) AS _sum"; + break; + case Query::TYPE_AVG: + $projections[] = "AVG({$this->quote($prefix)}.{$this->quote($this->filter($selection->getAttribute()))}) AS _avg"; + break; + case Query::TYPE_MIN: + $projections[] = "MIN({$this->quote($prefix)}.{$this->quote($this->filter($selection->getAttribute()))}) AS _min"; + break; + case Query::TYPE_MAX: + $projections[] = "MAX({$this->quote($prefix)}.{$this->quote($this->filter($selection->getAttribute()))}) AS _max"; + break; + case Query::TYPE_SELECT: + foreach ($selection->getValues() as $value) { + if ($value === '*') { + $projections[] = "{$this->quote($prefix)}.*"; + continue; + } + $projections[] = "{$this->quote($prefix)}.{$this->quote($this->filter($value))}"; + } + break; + } + } else { + // Backward compatibility for string selections + if ($selection === '*') { + $projections[] = "{$this->quote($prefix)}.*"; + } else { + $projections[] = "{$this->quote($prefix)}.{$this->quote($this->filter($selection))}"; + } + } + } - foreach ($internalKeys as $internalKey) { - $selections[] = $this->getInternalKeyForAttribute($internalKey); + if (empty($projections)) { + return "{$this->quote($prefix)}.*"; } - $projections = []; - foreach ($selections as $selection) { - $filteredSelection = $this->filter($selection); - $quotedSelection = $this->quote($filteredSelection); - $projections[] = "{$this->quote($prefix)}.{$quotedSelection}"; + $sql = \implode(', ', \array_unique($projections)); + + if ($isDistinct) { + $sql = "DISTINCT " . $sql; } - return \implode(',', $projections); + return $sql; } protected function getInternalKeyForAttribute(string $attribute): string @@ -3023,6 +3066,24 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $queries = $otherQueries; + // Extract join queries + $joinQueries = []; + $otherQueries = []; + foreach ($queries as $query) { + if (in_array($query->getMethod(), [ + Query::TYPE_INNER_JOIN, + Query::TYPE_LEFT_JOIN, + Query::TYPE_RIGHT_JOIN, + Query::TYPE_FULL_JOIN, + ])) { + $joinQueries[] = $query; + } else { + $otherQueries[] = $query; + } + } + + $queries = $otherQueries; + $cursorWhere = []; foreach ($orderAttributes as $i => $originalAttribute) { @@ -3139,9 +3200,32 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $selections = $this->getAttributeSelections($queries); + $sqlJoins = ''; + foreach ($joinQueries as $query) { + $joinMethod = match ($query->getMethod()) { + Query::TYPE_INNER_JOIN => 'INNER JOIN', + Query::TYPE_LEFT_JOIN => 'LEFT JOIN', + Query::TYPE_RIGHT_JOIN => 'RIGHT JOIN', + Query::TYPE_FULL_JOIN => 'FULL JOIN', + default => 'INNER JOIN', + }; + + $joinCollection = $this->filter($query->getAttribute()); + $joinTable = $this->getSQLTable($joinCollection); + $joinOn = $query->getValues()[0] ?? ''; + $joinAlias = $query->getValues()[1] ?? ''; + + $sqlJoins .= " {$joinMethod} {$joinTable}"; + if (!empty($joinAlias)) { + $sqlJoins .= " AS {$this->quote($this->filter($joinAlias))}"; + } + $sqlJoins .= " ON {$joinOn}"; + } + $sql = " SELECT {$this->getAttributeProjection($selections, $alias)} FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} + {$sqlJoins} {$sqlWhere} {$sqlOrder} {$sqlLimit}; @@ -3204,6 +3288,159 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 return $results; } + /** + * @inheritDoc + */ + public function findAcrossCollections(array $collections, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array + { + $roles = $this->authorization->getRoles(); + $subqueries = []; + $binds = []; + $alias = Query::DEFAULT_ALIAS; + + // Parse queries to find filter attributes + $groupedQueries = Query::groupByType($queries); + $filters = $groupedQueries['filters']; + $selections = $this->getAttributeSelections($queries); + + foreach ($collections as $i => $collection) { + $name = $this->filter($collection->getId()); + $table = $this->getSQLTable($name); + $where = []; + + // Add permissions check for this specific collection + if ($this->authorization->getStatus()) { + $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias, $forPermission); + } + + // Add tenant check + if ($this->sharedTables) { + $where[] = "{$this->getTenantQuery($collection->getId(), $alias, condition: '')}"; + } + + // Add filters if they exist + $collectionFilters = []; + foreach ($filters as $filter) { + $attr = $filter->getAttribute(); + // Check if attribute exists in this collection + $hasAttr = false; + foreach ($collection->getAttribute('attributes', []) as $collectionAttr) { + if ($collectionAttr->getId() === $attr) { + $hasAttr = true; + break; + } + } + + if ($hasAttr) { + $collectionFilters[] = $filter; + } + } + + $filterConditions = $this->getSQLConditions($collectionFilters, $binds); + if (!empty($filterConditions)) { + $where[] = $filterConditions; + } + + $sqlWhere = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + + // Harmonize projection: Select common attributes + any specific selections + $projectionParts = [ + "{$this->quote($alias)}.{$this->quote('_uid')} AS {$this->quote('$id')}", + "{$this->quote($alias)}.{$this->quote('_createdAt')} AS {$this->quote('$createdAt')}", + "{$this->quote($alias)}.{$this->quote('_updatedAt')} AS {$this->quote('$updatedAt')}", + "{$this->quote($alias)}.{$this->quote('_permissions')} AS {$this->quote('$permissions')}", + "'{$name}' AS {$this->quote('$collection')}" + ]; + + foreach ($selections as $selection) { + $attr = $selection->getAttribute(); + if ($attr === '$id' || $attr === '$createdAt' || $attr === '$updatedAt' || $attr === '$permissions') { + continue; + } + + $internalAttr = $this->getInternalKeyForAttribute($attr); + // Check if attribute exists in this collection + $hasAttr = false; + foreach ($collection->getAttribute('attributes', []) as $collectionAttr) { + if ($collectionAttr->getId() === $attr) { + $hasAttr = true; + break; + } + } + + if ($hasAttr) { + $projectionParts[] = "{$this->quote($alias)}.{$this->quote($internalAttr)} AS {$this->quote($attr)}"; + } else { + $projectionParts[] = "NULL AS {$this->quote($attr)}"; + } + } + + $projection = implode(', ', $projectionParts); + + $subqueries[] = "(SELECT {$projection} FROM {$table} AS {$this->quote($alias)} {$sqlWhere})"; + } + + if ($this->sharedTables) { + $binds[':_tenant'] = $this->tenant; + } + + $sqlUnion = implode(' UNION ALL ', $subqueries); + + // Build global ORDER BY + $orders = []; + foreach ($orderAttributes as $i => $originalAttribute) { + $orderType = $orderTypes[$i] ?? Database::ORDER_ASC; + $orders[] = "{$this->quote($originalAttribute)} {$this->filter($orderType)}"; + } + $sqlOrder = !empty($orders) ? 'ORDER BY ' . implode(', ', $orders) : ''; + + $sqlLimit = ''; + if (!is_null($limit)) { + $binds[':limit'] = $limit; + $sqlLimit = 'LIMIT :limit'; + } + if (!is_null($offset)) { + $binds[':offset'] = $offset; + $sqlLimit .= ' OFFSET :offset'; + } + + $sql = " + SELECT * FROM ( + {$sqlUnion} + ) AS global_search + {$sqlOrder} + {$sqlLimit}; + "; + + try { + $stmt = $this->getPDO()->prepare($sql); + + foreach ($binds as $key => $value) { + if (gettype($value) === 'double') { + $stmt->bindValue($key, $this->getFloatPrecision($value), \PDO::PARAM_STR); + } else { + $stmt->bindValue($key, $value, $this->getPDOType($value)); + } + } + + $this->execute($stmt); + } catch (PDOException $e) { + throw $this->processException($e); + } + + $results = $stmt->fetchAll(); + $stmt->closeCursor(); + + foreach ($results as $index => $document) { + if (isset($document['$permissions'])) { + $document['$permissions'] = json_decode($document['$permissions'] ?? '[]', true); + } + $results[$index] = new Document($document); + } + + return $results; + } + /** * Count Documents * diff --git a/src/Database/Database.php b/src/Database/Database.php index 32682716a..3f336be70 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -7717,11 +7717,19 @@ private function deleteRestrict( } if ( - !empty($value) - && $relationType !== Database::RELATION_MANY_TO_ONE + $relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_PARENT ) { - throw new RestrictedException('Cannot delete document because it has at least one related document.'); + if (empty($value)) { + $value = $this->authorization->skip(fn () => $this->findOne($relatedCollection->getId(), [ + Query::select(['$id']), + Query::equal($twoWayKey, [$document->getId()]) + ])); + } + + if (!empty($value)) { + throw new RestrictedException('Cannot delete document because it has at least one related document.'); + } } if ( @@ -7820,6 +7828,15 @@ private function deleteSetNull(Document $collection, Document $relatedCollection if ($side === Database::RELATION_SIDE_CHILD) { break; } + + if (empty($value)) { + $value = $this->authorization->skip(fn () => $this->find($relatedCollection->getId(), [ + Query::select(['$id']), + Query::equal($twoWayKey, [$document->getId()]), + Query::limit(PHP_INT_MAX) + ])); + } + foreach ($value as $relation) { $this->authorization->skip(function () use ($relatedCollection, $twoWayKey, $relation) { $this->skipRelationships(fn () => $this->updateDocument( @@ -7915,6 +7932,14 @@ private function deleteCascade(Document $collection, Document $relatedCollection break; } + if (empty($value)) { + $value = $this->authorization->skip(fn () => $this->find($relatedCollection->getId(), [ + Query::select(['$id']), + Query::equal($twoWayKey, [$document->getId()]), + Query::limit(PHP_INT_MAX) + ])); + } + $this->relationshipDeleteStack[] = $relationship; foreach ($value as $relation) { @@ -8379,6 +8404,70 @@ public function find(string $collection, array $queries = [], string $forPermiss return $results; } + /** + * Find Documents Across Collections + * + * @param array $collections + * @param array $queries + * @param string $forPermission + * @return array + * @throws DatabaseException + */ + public function findAcrossCollections(array $collections, array $queries = [], string $forPermission = Database::PERMISSION_READ): array + { + $collectionDocuments = []; + foreach ($collections as $id) { + $collection = $this->silent(fn () => $this->getCollection($id)); + if ($collection->isEmpty()) { + throw new NotFoundException("Collection '{$id}' not found"); + } + $collectionDocuments[] = $collection; + } + + $this->checkQueryTypes($queries); + + $grouped = Query::groupByType($queries); + $limit = $grouped['limit']; + $offset = $grouped['offset']; + $orderAttributes = $grouped['orderAttributes']; + $orderTypes = $grouped['orderTypes']; + $cursor = $grouped['cursor']; + $cursorDirection = $grouped['cursorDirection'] ?? Database::CURSOR_AFTER; + + $results = $this->adapter->findAcrossCollections( + $collectionDocuments, + $queries, + $limit ?? 25, + $offset ?? 0, + $orderAttributes, + $orderTypes, + $cursor ?? [], + $cursorDirection, + $forPermission + ); + + foreach ($results as $index => $node) { + $collectionId = $node->getAttribute('$collection'); + $collection = $this->silent(fn () => $this->getCollection($collectionId)); + + // Apply decoding and casting based on the source collection + $node = $this->adapter->castingAfter($collection, $node); + $node = $this->casting($collection, $node); + $node = $this->decode($collection, $node); + + // Convert to custom document type if mapped + if (isset($this->documentTypes[$collectionId])) { + $node = $this->createDocumentInstance($collectionId, $node->getArrayCopy()); + } + + $results[$index] = $node; + } + + $this->trigger(self::EVENT_DOCUMENT_FIND, $results); + + return $results; + } + /** * Helper method to iterate documents in collection using callback pattern * Alterative is