diff --git a/composer.lock b/composer.lock index 8ceef2115..488c254e3 100644 --- a/composer.lock +++ b/composer.lock @@ -1383,16 +1383,16 @@ }, { "name": "symfony/http-client", - "version": "v7.3.4", + "version": "v7.3.6", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62" + "reference": "3c0a55a2c8e21e30a37022801c11c7ab5a6cb2de" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/4b62871a01c49457cf2a8e560af7ee8a94b87a62", - "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62", + "url": "https://api.github.com/repos/symfony/http-client/zipball/3c0a55a2c8e21e30a37022801c11c7ab5a6cb2de", + "reference": "3c0a55a2c8e21e30a37022801c11c7ab5a6cb2de", "shasum": "" }, "require": { @@ -1459,7 +1459,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.3.4" + "source": "https://github.com/symfony/http-client/tree/v7.3.6" }, "funding": [ { @@ -1479,7 +1479,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:12:26+00:00" + "time": "2025-11-05T17:41:46+00:00" }, { "name": "symfony/http-client-contracts", @@ -1886,16 +1886,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { @@ -1949,7 +1949,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" }, "funding": [ { @@ -1960,12 +1960,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-25T09:37:31+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "tbachert/spi", @@ -4484,5 +4488,5 @@ "ext-mbstring": "*" }, "platform-dev": [], - "plugin-api-version": "2.2.0" + "plugin-api-version": "2.6.0" } diff --git a/phpunit.xml b/phpunit.xml index 2a0531cfd..b99bf8afd 100755 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,4 +1,4 @@ - $queries + * @param QueryContext $context * @param int|null $limit * @param int|null $offset - * @param array $orderAttributes - * @param array $orderTypes * @param array $cursor * @param string $cursorDirection * @param string $forPermission + * @param array $selects + * @param array $filters + * @param array $joins + * @param array $vectors + * @param array $orderQueries + * * @return array */ - abstract public function find(Document $collection, 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; + abstract public function find( + QueryContext $context, + ?int $limit = 25, + ?int $offset = null, + array $cursor = [], + string $cursorDirection = Database::CURSOR_AFTER, + string $forPermission = Database::PERMISSION_READ, + array $selects = [], + array $filters = [], + array $joins = [], + array $vectors = [], + array $orderQueries = [] + ): array; /** * Sum an attribute @@ -840,13 +855,19 @@ abstract public function sum(Document $collection, string $attribute, array $que /** * Count Documents * - * @param Document $collection - * @param array $queries + * @param QueryContext $context * @param int|null $max + * @param array $filters + * @param array $joins * * @return int */ - abstract public function count(Document $collection, array $queries = [], ?int $max = null): int; + abstract public function count( + QueryContext $context, + ?int $max, + array $filters, + array $joins, + ): int; /** * Get Collection Size of the raw data @@ -1229,36 +1250,10 @@ abstract public function getAttributeWidth(Document $collection): int; abstract public function getKeywords(): array; /** - * Get an attribute projection given a list of selected attributes - * - * @param array $selections - * @param string $prefix + * @param array $selects * @return mixed */ - abstract protected function getAttributeProjection(array $selections, string $prefix): mixed; - - /** - * Get all selected attributes from queries - * - * @param Query[] $queries - * @return string[] - */ - protected function getAttributeSelections(array $queries): array - { - $selections = []; - - foreach ($queries as $query) { - switch ($query->getMethod()) { - case Query::TYPE_SELECT: - foreach ($query->getValues() as $value) { - $selections[] = $value; - } - break; - } - } - - return $selections; - } + abstract protected function getAttributeProjection(array $selects): mixed; /** * Filter Keys diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 366ba8e90..d6340da92 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1546,11 +1546,14 @@ protected function handleSpatialQueries(Query $query, array &$binds, string $att protected function getSQLCondition(Query $query, array &$binds): string { $query->setAttribute($this->getInternalKeyForAttribute($query->getAttribute())); + $query->setAttributeRight($this->getInternalKeyForAttribute($query->getAttributeRight())); $attribute = $query->getAttribute(); $attribute = $this->filter($attribute); $attribute = $this->quote($attribute); - $alias = $this->quote(Query::DEFAULT_ALIAS); + $alias = $query->getAlias(); + $alias = $this->filter($alias); + $alias = $this->quote($alias); $placeholder = ID::unique(); if ($query->isSpatialAttribute()) { @@ -1592,6 +1595,12 @@ protected function getSQLCondition(Query $query, array &$binds): string return "{$alias}.{$attribute} NOT BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; + case Query::TYPE_RELATION_EQUAL: + $attributeRight = $this->quote($this->filter($query->getAttributeRight())); + $aliasRight = $this->quote($this->filter($query->getRightAlias())); + + return "{$alias}.{$attribute}={$aliasRight}.{$attributeRight}"; + case Query::TYPE_IS_NULL: case Query::TYPE_IS_NOT_NULL: diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 734312b7e..a4231a222 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -16,7 +16,7 @@ use Utopia\Database\Exception\Transaction as TransactionException; use Utopia\Database\Exception\Type as TypeException; use Utopia\Database\Query; -use Utopia\Database\Validator\Authorization; +use Utopia\Database\QueryContext; use Utopia\Mongo\Client; use Utopia\Mongo\Exception as MongoException; @@ -708,18 +708,18 @@ public function deleteAttribute(string $collection, string $id): bool * Rename Attribute. * * @param string $collection - * @param string $id - * @param string $name + * @param string $old + * @param string $new * @return bool * @throws DatabaseException * @throws MongoException */ - public function renameAttribute(string $collection, string $id, string $name): bool + public function renameAttribute(string $collection, string $old, string $new): bool { $collection = $this->getNamespace() . '_' . $this->filter($collection); - $from = $this->filter($this->getInternalKeyForAttribute($id)); - $to = $this->filter($this->getInternalKeyForAttribute($name)); + $from = $this->filter($this->getInternalKeyForAttribute($old)); + $to = $this->filter($this->getInternalKeyForAttribute($new)); $options = $this->getTransactionOptions(); $this->getClient()->update( @@ -1120,13 +1120,19 @@ public function getDocument(Document $collection, string $id, array $queries = [ $filters['_tenant'] = $this->getTenantFilters($collection->getId()); } - $options = $this->getTransactionOptions(); - $selections = $this->getAttributeSelections($queries); + $removeSequence = false; + $projections = $this->getAttributeProjection($queries); + if (!empty($projections)) { + $options['projection'] = $projections; - if (!empty($selections) && !\in_array('*', $selections)) { - $options['projection'] = $this->getAttributeProjection($selections); + /** + * Hack for _id is always returned? + */ + if (empty($options['projection']['_id'])) { + $removeSequence = true; + } } try { @@ -1144,6 +1150,10 @@ public function getDocument(Document $collection, string $id, array $queries = [ $document = new Document($result); $document = $this->castingAfter($collection, $document); + if ($removeSequence) { + $document->removeAttribute('$sequence'); + } + return $document; } @@ -1777,7 +1787,9 @@ public function deleteDocuments(string $collection, array $sequences, array $per $sequences[$index] = $sequence; } - $filters = $this->buildFilters([new Query(Query::TYPE_EQUAL, '_id', $sequences)]); + $filters = $this->buildFilters([ + Query::equal('$sequence', $sequences), + ]); if ($this->sharedTables) { $filters['_tenant'] = $this->getTenantFilters($collection); @@ -1844,33 +1856,50 @@ protected function getInternalKeyForAttribute(string $attribute): string * * Find data sets using chosen queries * - * @param Document $collection - * @param array $queries + * @param QueryContext $context * @param int|null $limit * @param int|null $offset - * @param array $orderAttributes - * @param array $orderTypes * @param array $cursor * @param string $cursorDirection * @param string $forPermission + * @param array $selects + * @param array $filters + * @param array $joins + * @param array $vectors + * @param array $orderQueries * * @return array * @throws Exception * @throws TimeoutException */ - public function find(Document $collection, 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 - { + public function find( + QueryContext $context, + ?int $limit = 25, + ?int $offset = null, + array $cursor = [], + string $cursorDirection = Database::CURSOR_AFTER, + string $forPermission = Database::PERMISSION_READ, + array $selects = [], + array $filters = [], + array $joins = [], + array $vectors = [], + array $orderQueries = [] + ): array { + $collection = $context->getMainCollection(); + $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); - $queries = array_map(fn ($query) => clone $query, $queries); - $filters = $this->buildFilters($queries); + $filters = array_map(fn ($query) => clone $query, $filters); + + $filters = $this->buildFilters($filters); if ($this->sharedTables) { $filters['_tenant'] = $this->getTenantFilters($collection->getId()); } // permissions - if ($this->authorization->getStatus()) { + $skipAuth = $context->skipAuth($this->filter($collection->getId()), $forPermission, $this->authorization); + if (! $skipAuth) { $roles = \implode('|', $this->authorization->getRoles()); $filters['_permissions']['$in'] = [new Regex("{$forPermission}\\(\".*(?:{$roles}).*\"\\)", 'i')]; } @@ -1888,9 +1917,17 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $options['maxTimeMS'] = $this->timeout; } - $selections = $this->getAttributeSelections($queries); - if (!empty($selections) && !\in_array('*', $selections)) { - $options['projection'] = $this->getAttributeProjection($selections); + $removeSequence = false; + $projections = $this->getAttributeProjection($selects); + if (!empty($projections)) { + $options['projection'] = $projections; + + /** + * Hack for _id is always returned? + */ + if (empty($options['projection']['_id'])) { + $removeSequence = true; + } } // Add transaction context to options @@ -1898,34 +1935,27 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $orFilters = []; - foreach ($orderAttributes as $i => $originalAttribute) { + foreach ($orderQueries as $i => $order) { + $attribute = $order->getAttribute(); + $originalAttribute = $attribute; $attribute = $this->getInternalKeyForAttribute($originalAttribute); $attribute = $this->filter($attribute); - $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); - $direction = $orderType; + $direction = $order->getOrderDirection(); - /** Get sort direction ASC || DESC **/ if ($cursorDirection === Database::CURSOR_BEFORE) { - $direction = ($direction === Database::ORDER_ASC) - ? Database::ORDER_DESC - : Database::ORDER_ASC; + $direction = ($direction === Database::ORDER_ASC) ? Database::ORDER_DESC : Database::ORDER_ASC; } $options['sort'][$attribute] = $this->getOrder($direction); /** Get operator sign '$lt' ? '$gt' **/ - $operator = $cursorDirection === Database::CURSOR_AFTER - ? ($orderType === Database::ORDER_DESC ? Query::TYPE_LESSER : Query::TYPE_GREATER) - : ($orderType === Database::ORDER_DESC ? Query::TYPE_GREATER : Query::TYPE_LESSER); - - $operator = $this->getQueryOperator($operator); + $operator = $this->getQueryOperator($direction === Database::ORDER_DESC ? Query::TYPE_LESSER : Query::TYPE_GREATER); if (!empty($cursor)) { - $andConditions = []; for ($j = 0; $j < $i; $j++) { - $originalPrev = $orderAttributes[$j]; + $originalPrev = $orderQueries[$j]->getAttribute(); $prevAttr = $this->filter($this->getInternalKeyForAttribute($originalPrev)); $tmp = $cursor[$originalPrev]; $andConditions[] = [ @@ -1937,7 +1967,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 if ($originalAttribute === '$sequence') { /** If there is only $sequence attribute in $orderAttributes skip Or And operators **/ - if (count($orderAttributes) === 1) { + if (count($orderQueries) === 1) { $filters[$attribute] = [ $operator => $tmp ]; @@ -1976,7 +2006,13 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 // Process first batch foreach ($results as $result) { $record = $this->replaceChars('_', '$', (array)$result); - $found[] = new Document($record); + + $doc = new Document($record); + if ($removeSequence) { + $doc->removeAttribute('$sequence'); + } + + $found[] = $doc; } // Get cursor ID for subsequent batches @@ -1993,7 +2029,13 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 foreach ($moreResults as $result) { $record = $this->replaceChars('_', '$', (array)$result); - $found[] = new Document($record); + + $doc = new Document($record); + if ($removeSequence) { + $doc->removeAttribute('$sequence'); + } + + $found[] = $doc; } $cursorId = (int)($moreResponse->cursor->id ?? 0); @@ -2086,40 +2128,37 @@ private function replaceInternalIdsKeys(array $array, string $from, string $to, /** * Count Documents * - * @param Document $collection - * @param array $queries + * @param QueryContext $context * @param int|null $max + * @param array $filters + * @param array $joins + * * @return int - * @throws Exception + * + * @throws DatabaseException */ - public function count(Document $collection, array $queries = [], ?int $max = null): int + public function count(QueryContext $context, ?int $max, array $filters = [], array $joins = []): int { + $collection = $context->getMainCollection(); $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); - $queries = array_map(fn ($query) => clone $query, $queries); - - $filters = []; - $options = []; - - if (!\is_null($max) && $max > 0) { - $options['limit'] = $max; - } - - if ($this->timeout) { - $options['maxTimeMS'] = $this->timeout; - } + $filters = array_map(fn ($query) => clone $query, $filters); // Build filters from queries - $filters = $this->buildFilters($queries); + $filters = $this->buildFilters($filters); if ($this->sharedTables) { $filters['_tenant'] = $this->getTenantFilters($collection->getId()); } - // Add permissions filter if authorization is enabled - if ($this->authorization->getStatus()) { + // Permissions + $permission = Database::PERMISSION_READ; + + // permissions + $skipAuth = $context->skipAuth($this->filter($collection->getId()), $permission, $this->authorization); + if (! $skipAuth) { $roles = \implode('|', $this->authorization->getRoles()); - $filters['_permissions']['$in'] = [new Regex("read\\(\".*(?:{$roles}).*\"\\)", 'i')]; + $filters['_permissions']['$in'] = [new Regex("{$permission}\\(\".*(?:{$roles}).*\"\\)", 'i')]; } /** @@ -2132,6 +2171,11 @@ public function count(Document $collection, array $queries = [], ?int $max = nul **/ $options = $this->getTransactionOptions(); + + if ($this->timeout) { + $options['maxTimeMS'] = $this->timeout; + } + $pipeline = []; // Add match stage if filters are provided @@ -2140,14 +2184,14 @@ public function count(Document $collection, array $queries = [], ?int $max = nul } // Add limit stage if specified - if (!\is_null($max) && $max > 0) { + if (!\is_null($max)) { $pipeline[] = ['$limit' => $max]; } // Use $group and $sum when limit is specified, $count when no limit // Note: $count stage doesn't works well with $limit in the same pipeline // When limit is specified, we need to use $group + $sum to count the limited documents - if (!\is_null($max) && $max > 0) { + if (!\is_null($max)) { // When limit is specified, use $group and $sum to count limited documents $pipeline[] = [ '$group' => [ @@ -2514,33 +2558,41 @@ protected function getOrder(string $order): int } /** - * @param array $selections - * @param string $prefix - * @return mixed + * @param array $selects + * + * @return array */ - protected function getAttributeProjection(array $selections, string $prefix = ''): mixed + protected function getAttributeProjection(array $selects): array { $projection = []; - $internalKeys = \array_map( - fn ($attr) => $attr['$id'], - Database::INTERNAL_ATTRIBUTES - ); + if (empty($selects)) { + return []; + } - foreach ($selections as $selection) { - // Skip internal attributes since all are selected by default - if (\in_array($selection, $internalKeys)) { + foreach ($selects as $select) { + if ($select->getAttribute() === '$collection') { continue; } - $projection[$selection] = 1; - } + $attribute = $select->getAttribute(); - $projection['_uid'] = 1; - $projection['_id'] = 1; - $projection['_createdAt'] = 1; - $projection['_updatedAt'] = 1; - $projection['_permissions'] = 1; + if ($attribute === '*') { + return []; + } + + $attribute = match ($attribute) { + '$id' => '_uid', + '$sequence' => '_id', + '$tenant' => '_tenant', + '$createdAt' => '_createdAt', + '$updatedAt' => '_updatedAt', + '$permissions' => '_permissions', + default => $attribute + }; + + $projection[$attribute] = 1; + } return $projection; } diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index a1c0d969f..7668ee89d 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -6,6 +6,7 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; +use Utopia\Database\QueryContext; use Utopia\Database\Validator\Authorization; use Utopia\Pools\Pool as UtopiaPool; @@ -267,8 +268,19 @@ public function deleteDocuments(string $collection, array $sequences, array $per return $this->delegate(__FUNCTION__, \func_get_args()); } - public function find(Document $collection, 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 - { + public function find( + QueryContext $context, + ?int $limit = 25, + ?int $offset = null, + array $cursor = [], + string $cursorDirection = Database::CURSOR_AFTER, + string $forPermission = Database::PERMISSION_READ, + array $selects = [], + array $filters = [], + array $joins = [], + array $vectors = [], + array $orderQueries = [] + ): array { return $this->delegate(__FUNCTION__, \func_get_args()); } @@ -277,8 +289,12 @@ public function sum(Document $collection, string $attribute, array $queries = [] return $this->delegate(__FUNCTION__, \func_get_args()); } - public function count(Document $collection, array $queries = [], ?int $max = null): int - { + public function count( + QueryContext $context, + ?int $max, + array $filters, + array $joins, + ): int { return $this->delegate(__FUNCTION__, \func_get_args()); } @@ -492,7 +508,7 @@ public function getKeywords(): array return $this->delegate(__FUNCTION__, \func_get_args()); } - protected function getAttributeProjection(array $selections, string $prefix): mixed + protected function getAttributeProjection(array $selects): mixed { return $this->delegate(__FUNCTION__, \func_get_args()); } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 44d028d99..295389834 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1725,10 +1725,13 @@ protected function handleObjectQueries(Query $query, array &$binds, string $attr protected function getSQLCondition(Query $query, array &$binds): string { $query->setAttribute($this->getInternalKeyForAttribute($query->getAttribute())); + $query->setAttributeRight($this->getInternalKeyForAttribute($query->getAttributeRight())); $attribute = $this->filter($query->getAttribute()); $attribute = $this->quote($attribute); - $alias = $this->quote(Query::DEFAULT_ALIAS); + $alias = $query->getAlias(); + $alias = $this->filter($alias); + $alias = $this->quote($alias); $placeholder = ID::unique(); $operator = null; @@ -1776,6 +1779,12 @@ protected function getSQLCondition(Query $query, array &$binds): string $binds[":{$placeholder}_1"] = $query->getValues()[1]; return "{$alias}.{$attribute} NOT BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; + case Query::TYPE_RELATION_EQUAL: + $attributeRight = $this->quote($this->filter($query->getAttributeRight())); + $aliasRight = $this->quote($query->getRightAlias()); + + return "{$alias}.{$attribute}={$aliasRight}.{$attributeRight}"; + case Query::TYPE_IS_NULL: case Query::TYPE_IS_NOT_NULL: return "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())}"; diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 9fb62db3a..ee563f194 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -17,6 +17,7 @@ use Utopia\Database\Exception\Transaction as TransactionException; use Utopia\Database\Operator; use Utopia\Database\Query; +use Utopia\Database\QueryContext; abstract class SQL extends Adapter { @@ -365,16 +366,15 @@ public function getDocument(Document $collection, string $id, array $queries = [ $collection = $collection->getId(); $name = $this->filter($collection); - $selections = $this->getAttributeSelections($queries); $forUpdate = $forUpdate ? 'FOR UPDATE' : ''; $alias = Query::DEFAULT_ALIAS; $sql = " - SELECT {$this->getAttributeProjection($selections, $alias)} + SELECT {$this->getAttributeProjection($queries)} FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} - WHERE {$this->quote($alias)}.{$this->quote('_uid')} = :_uid + WHERE {$this->quote($alias)}._uid = :_uid {$this->getTenantQuery($collection, $alias)} "; @@ -1797,6 +1797,12 @@ protected function getSQLOperator(string $method): string case Query::TYPE_VECTOR_COSINE: case Query::TYPE_VECTOR_EUCLIDEAN: throw new DatabaseException('Vector queries are not supported by this database'); + case Query::TYPE_INNER_JOIN: + return 'JOIN'; + case Query::TYPE_RIGHT_JOIN: + return 'RIGHT JOIN'; + case Query::TYPE_LEFT_JOIN: + return 'LEFT JOIN'; default: throw new DatabaseException('Unknown method: ' . $method); } @@ -2297,7 +2303,8 @@ public function getTenantQuery( string $collection, string $alias = '', int $tenantCount = 0, - string $condition = 'AND' + string $condition = 'AND', + bool $forceIsNull = false ): string { if (!$this->sharedTables) { return ''; @@ -2320,7 +2327,7 @@ public function getTenantQuery( $bindings = \implode(',', $bindings); $orIsNull = ''; - if ($collection === Database::METADATA) { + if ($collection === Database::METADATA || $forceIsNull) { $orIsNull = " OR {$alias}{$dot}_tenant IS NULL"; } @@ -2330,40 +2337,48 @@ public function getTenantQuery( /** * Get the SQL projection given the selected attributes * - * @param array $selections - * @param string $prefix - * @return mixed + * @param array $selects + * @return string * @throws Exception */ - protected function getAttributeProjection(array $selections, string $prefix): mixed + protected function getAttributeProjection(array $selects): string { - if (empty($selections) || \in_array('*', $selections)) { - return "{$this->quote($prefix)}.*"; + + if (empty($selects)) { + return $this->quote(Query::DEFAULT_ALIAS).'.*'; } - // Handle specific selections with spatial conversion where needed - $internalKeys = [ - '$id', - '$sequence', - '$permissions', - '$createdAt', - '$updatedAt', - ]; + $string = ''; + foreach ($selects as $select) { + if ($select->getAttribute() === '$collection') { + continue; + } - $selections = \array_diff($selections, [...$internalKeys, '$collection']); + $alias = $select->getAlias(); + $alias = $this->filter($alias); + + $attribute = $this->getInternalKeyForAttribute($select->getAttribute()); - foreach ($internalKeys as $internalKey) { - $selections[] = $this->getInternalKeyForAttribute($internalKey); - } - $projections = []; - foreach ($selections as $selection) { - $filteredSelection = $this->filter($selection); - $quotedSelection = $this->quote($filteredSelection); - $projections[] = "{$this->quote($prefix)}.{$quotedSelection}"; + if ($attribute !== '*') { + $attribute = $this->filter($attribute); + $attribute = $this->quote($attribute); + } + + $as = $select->getAs(); + + if (!empty($as)) { + $as = ' as '.$this->quote($this->filter($as)); + } + + if (!empty($string)) { + $string .= ', '; + } + + $string .= "{$this->quote($alias)}.{$attribute}{$as}"; } - return \implode(',', $projections); + return $string; } protected function getInternalKeyForAttribute(string $attribute): string @@ -2947,74 +2962,74 @@ protected function convertArrayToWKT(array $geometry): string /** * Find Documents * - * @param Document $collection - * @param array $queries + * @param QueryContext $context * @param int|null $limit * @param int|null $offset - * @param array $orderAttributes - * @param array $orderTypes * @param array $cursor * @param string $cursorDirection * @param string $forPermission + * @param array $selects + * @param array $filters + * @param array $joins + * @param array $vectors + * @param array $orderQueries * @return array * @throws DatabaseException * @throws TimeoutException * @throws Exception */ - public function find(Document $collection, 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 - { - $collection = $collection->getId(); - $name = $this->filter($collection); - $roles = $this->authorization->getRoles(); - $where = []; - $orders = []; + public function find( + QueryContext $context, + ?int $limit = 25, + ?int $offset = null, + array $cursor = [], + string $cursorDirection = Database::CURSOR_AFTER, + string $forPermission = Database::PERMISSION_READ, + array $selects = [], + array $filters = [], + array $joins = [], + array $vectors = [], + array $orderQueries = [] + ): array { $alias = Query::DEFAULT_ALIAS; $binds = []; - $queries = array_map(fn ($query) => clone $query, $queries); + $name = $context->getMainCollection()->getId(); + $name = $this->filter($name); - // Extract vector queries for ORDER BY - $vectorQueries = []; - $otherQueries = []; - foreach ($queries as $query) { - if (in_array($query->getMethod(), Query::VECTOR_TYPES)) { - $vectorQueries[] = $query; - } else { - $otherQueries[] = $query; - } - } + $roles = $this->authorization->getRoles(); + $where = []; + $orders = []; - $queries = $otherQueries; + $filters = array_map(fn ($query) => clone $query, $filters); $cursorWhere = []; - foreach ($orderAttributes as $i => $originalAttribute) { - $orderType = $orderTypes[$i] ?? Database::ORDER_ASC; + foreach ($orderQueries as $i => $order) { + $orderAlias = $order->getAlias(); + $attribute = $order->getAttribute(); + $originalAttribute = $attribute; + $attribute = $this->getInternalKeyForAttribute($originalAttribute); + $attribute = $this->filter($attribute); + + $direction = $order->getOrderDirection(); - // Handle random ordering - if ($orderType === Database::ORDER_RANDOM) { + // Handle random ordering specially + if ($direction === Database::ORDER_RANDOM) { $orders[] = $this->getRandomOrder(); continue; } - $attribute = $this->getInternalKeyForAttribute($originalAttribute); - $attribute = $this->filter($attribute); - - $orderType = $this->filter($orderType); - $direction = $orderType; - if ($cursorDirection === Database::CURSOR_BEFORE) { - $direction = ($direction === Database::ORDER_ASC) - ? Database::ORDER_DESC - : Database::ORDER_ASC; + $direction = ($direction === Database::ORDER_ASC) ? Database::ORDER_DESC : Database::ORDER_ASC; } - $orders[] = "{$this->quote($attribute)} {$direction}"; + $orders[] = "{$this->quote($orderAlias)}.{$this->quote($attribute)} {$direction}"; // Build pagination WHERE clause only if we have a cursor if (!empty($cursor)) { // Special case: No tie breaks. only 1 attribute and it's a unique primary key - if (count($orderAttributes) === 1 && $i === 0 && $originalAttribute === '$sequence') { + if (count($orderQueries) === 1 && $i === 0 && $originalAttribute === '$sequence') { $operator = ($direction === Database::ORDER_DESC) ? Query::TYPE_LESSER : Query::TYPE_GREATER; @@ -3022,7 +3037,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $bindName = ":cursor_pk"; $binds[$bindName] = $cursor[$originalAttribute]; - $cursorWhere[] = "{$this->quote($alias)}.{$this->quote($attribute)} {$this->getSQLOperator($operator)} {$bindName}"; + $cursorWhere[] = "{$this->quote($orderAlias)}.{$this->quote($attribute)} {$this->getSQLOperator($operator)} {$bindName}"; break; } @@ -3030,13 +3045,14 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 // Add equality conditions for previous attributes for ($j = 0; $j < $i; $j++) { - $prevOriginal = $orderAttributes[$j]; + $prevQuery = $orderQueries[$j]; + $prevOriginal = $prevQuery->getAttribute(); $prevAttr = $this->filter($this->getInternalKeyForAttribute($prevOriginal)); $bindName = ":cursor_{$j}"; $binds[$bindName] = $cursor[$prevOriginal]; - $conditions[] = "{$this->quote($alias)}.{$this->quote($prevAttr)} = {$bindName}"; + $conditions[] = "{$this->quote($orderAlias)}.{$this->quote($prevAttr)} = {$bindName}"; } // Add comparison for current attribute @@ -3047,7 +3063,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $bindName = ":cursor_{$i}"; $binds[$bindName] = $cursor[$originalAttribute]; - $conditions[] = "{$this->quote($alias)}.{$this->quote($attribute)} {$this->getSQLOperator($operator)} {$bindName}"; + $conditions[] = "{$this->quote($orderAlias)}.{$this->quote($attribute)} {$this->getSQLOperator($operator)} {$bindName}"; $cursorWhere[] = '(' . implode(' AND ', $conditions) . ')'; } @@ -3057,25 +3073,53 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $where[] = '(' . implode(' OR ', $cursorWhere) . ')'; } - $conditions = $this->getSQLConditions($queries, $binds); + $rightJoins = false; + $sqlJoin = ''; + foreach ($joins as $join) { + $permissions = ''; + $collection = $join->getCollection(); + $collection = $this->filter($collection); + + $skipAuth = $context->skipAuth($collection, $forPermission, $this->authorization); + if (! $skipAuth) { + $permissions = 'AND '.$this->getSQLPermissionsCondition($collection, $roles, $join->getAlias(), $forPermission); + } + + $sqlJoin .= "{$this->getSQLOperator($join->getMethod())} {$this->getSQLTable($collection)} AS {$this->quote($join->getAlias())} + ON {$this->getSQLConditions($join->getValues(), $binds)} + {$permissions} + {$this->getTenantQuery($collection, $join->getAlias())} + "; + + if ($join->getMethod() === Query::TYPE_RIGHT_JOIN) { + $rightJoins = true; + } + } + + $conditions = $this->getSQLConditions($filters, $binds); if (!empty($conditions)) { $where[] = $conditions; } - if ($this->authorization->getStatus()) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias, $forPermission); + $skipAuth = $context->skipAuth($name, $forPermission, $this->authorization); + if (! $skipAuth) { + $permissionsCondition = $this->getSQLPermissionsCondition($name, $roles, $alias, $forPermission); + if ($rightJoins) { + $permissionsCondition = "($permissionsCondition OR {$alias}._uid IS NULL)"; + } + $where[] = $permissionsCondition; } if ($this->sharedTables) { $binds[':_tenant'] = $this->tenant; - $where[] = "{$this->getTenantQuery($collection, $alias, condition: '')}"; + $where[] = "{$this->getTenantQuery($name, $alias, condition: '', forceIsNull: $rightJoins)}"; } $sqlWhere = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; // Add vector distance calculations to ORDER BY $vectorOrders = []; - foreach ($vectorQueries as $query) { + foreach ($vectors as $query) { $vectorOrder = $this->getVectorDistanceOrder($query, $binds, $alias); if ($vectorOrder) { $vectorOrders[] = $vectorOrder; @@ -3100,11 +3144,10 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $sqlLimit .= ' OFFSET :offset'; } - $selections = $this->getAttributeSelections($queries); - $sql = " - SELECT {$this->getAttributeProjection($selections, $alias)} + SELECT {$this->getAttributeProjection($selects)} FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} + {$sqlJoin} {$sqlWhere} {$sqlOrder} {$sqlLimit}; @@ -3124,13 +3167,13 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 } $this->execute($stmt); + $results = $stmt->fetchAll(); + $stmt->closeCursor(); + } catch (PDOException $e) { throw $this->processException($e); } - $results = $stmt->fetchAll(); - $stmt->closeCursor(); - foreach ($results as $index => $document) { if (\array_key_exists('_uid', $document)) { $results[$index]['$id'] = $document['_uid']; @@ -3170,81 +3213,110 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 /** * Count Documents * - * @param Document $collection - * @param array $queries + * @param QueryContext $context * @param int|null $max + * @param array $filters + * @param array $joins + * * @return int - * @throws Exception - * @throws PDOException + * + * @throws DatabaseException */ - public function count(Document $collection, array $queries = [], ?int $max = null): int + public function count(QueryContext $context, ?int $max, array $filters, array $joins): int { - $collection = $collection->getId(); - $name = $this->filter($collection); - $roles = $this->authorization->getRoles(); + $alias = Query::DEFAULT_ALIAS; $binds = []; + + $name = $context->getMainCollection()->getId(); + $name = $this->filter($name); + + $roles = $this->authorization->getRoles(); $where = []; - $alias = Query::DEFAULT_ALIAS; - $limit = ''; - if (! \is_null($max)) { - $binds[':limit'] = $max; - $limit = 'LIMIT :limit'; - } + $filters = array_map(fn ($query) => clone $query, $filters); - $queries = array_map(fn ($query) => clone $query, $queries); + $rightJoins = false; + $sqlJoin = ''; + foreach ($joins as $join) { + $permissions = ''; + $collection = $join->getCollection(); + $collection = $this->filter($collection); - $otherQueries = []; - foreach ($queries as $query) { - if (!in_array($query->getMethod(), Query::VECTOR_TYPES)) { - $otherQueries[] = $query; + $skipAuth = $context->skipAuth($collection, Database::PERMISSION_READ, $this->authorization); + if (! $skipAuth) { + $permissions = 'AND '.$this->getSQLPermissionsCondition($collection, $roles, $join->getAlias()); + } + + $sqlJoin .= "{$this->getSQLOperator($join->getMethod())} {$this->getSQLTable($collection)} AS {$this->quote($join->getAlias())} + ON {$this->getSQLConditions($join->getValues(), $binds)} + {$permissions} + {$this->getTenantQuery($collection, $join->getAlias())} + "; + + if ($join->getMethod() === Query::TYPE_RIGHT_JOIN) { + $rightJoins = true; } } - $conditions = $this->getSQLConditions($otherQueries, $binds); + $conditions = $this->getSQLConditions($filters, $binds); if (!empty($conditions)) { $where[] = $conditions; } - if ($this->authorization->getStatus()) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias); + $skipAuth = $context->skipAuth($name, Database::PERMISSION_READ, $this->authorization); + if (! $skipAuth) { + $permissionsCondition = $this->getSQLPermissionsCondition($name, $roles, $alias); + if ($rightJoins) { + $permissionsCondition = "($permissionsCondition OR {$alias}._uid IS NULL)"; + } + $where[] = $permissionsCondition; } if ($this->sharedTables) { $binds[':_tenant'] = $this->tenant; - $where[] = "{$this->getTenantQuery($collection, $alias, condition: '')}"; + $where[] = "{$this->getTenantQuery($name, $alias, condition: '', forceIsNull: $rightJoins)}"; } - $sqlWhere = !empty($where) - ? 'WHERE ' . \implode(' AND ', $where) - : ''; + $sqlWhere = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + + $sqlLimit = ''; + if (! \is_null($max)) { + $binds[':limit'] = $max; + $sqlLimit = 'LIMIT :limit'; + } $sql = " SELECT COUNT(1) as sum FROM ( SELECT 1 FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} + {$sqlJoin} {$sqlWhere} - {$limit} + {$sqlLimit} ) table_count "; $sql = $this->trigger(Database::EVENT_DOCUMENT_COUNT, $sql); - $stmt = $this->getPDO()->prepare($sql); + try { + $stmt = $this->getPDO()->prepare($sql); - foreach ($binds as $key => $value) { - $stmt->bindValue($key, $value, $this->getPDOType($value)); - } + 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); + $this->execute($stmt); + $result = $stmt->fetchAll(); + $stmt->closeCursor(); - $result = $stmt->fetchAll(); - $stmt->closeCursor(); - if (!empty($result)) { - $result = $result[0]; + } catch (PDOException $e) { + throw $this->processException($e); } - return $result['sum'] ?? 0; + return $result[0]['sum'] ?? 0; } /** diff --git a/src/Database/Database.php b/src/Database/Database.php index 5fe03cc54..379c7aa39 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -31,8 +31,7 @@ use Utopia\Database\Validator\IndexDependency as IndexDependencyValidator; use Utopia\Database\Validator\PartialStructure; 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\Queries\V2 as DocumentsValidator; use Utopia\Database\Validator\Spatial; use Utopia\Database\Validator\Structure; @@ -4293,12 +4292,22 @@ public function getDocument(string $collection, string $id, array $queries = [], throw new NotFoundException('Collection not found'); } - $attributes = $collection->getAttribute('attributes', []); + $context = new QueryContext(); + $context->add($collection); $this->checkQueryTypes($queries); + $selects = Query::getSelectQueries($queries); + if (count($selects) != count($queries)) { + throw new QueryException('Only Select queries are permitted'); + } + if ($this->validate) { - $validator = new DocumentValidator($attributes, $this->adapter->getSupportForAttributes()); + $validator = new DocumentsValidator( + $context, + idAttributeType:$this->adapter->getIdAttributeType(), + supportForAttributes:$this->adapter->getSupportForAttributes(), + ); if (!$validator->isValid($queries)) { throw new QueryException($validator->getDescription()); } @@ -4309,16 +4318,16 @@ public function getDocument(string $collection, string $id, array $queries = [], fn (Document $attribute) => $attribute->getAttribute('type') === self::VAR_RELATIONSHIP ); - $selects = Query::groupByType($queries)['selections']; - $selections = $this->validateSelections($collection, $selects); - $nestedSelections = $this->processRelationshipQueries($relationships, $queries); + [$selects, $nestedSelections] = $this->processRelationshipQueries($relationships, $selects); + + [$selects, $permissionsAdded] = $context::addSelect($selects, Query::select('$permissions', system: true)); $documentSecurity = $collection->getAttribute('documentSecurity', false); [$collectionKey, $documentKey, $hashKey] = $this->getCacheKeys( $collection->getId(), $id, - $selections + $selects ); try { @@ -4349,7 +4358,7 @@ public function getDocument(string $collection, string $id, array $queries = [], $document = $this->adapter->getDocument( $collection, $id, - $queries, + $selects, $forUpdate ); @@ -4375,8 +4384,8 @@ public function getDocument(string $collection, string $id, array $queries = [], } } - $document = $this->casting($collection, $document); - $document = $this->decode($collection, $document, $selections); + $document = $this->casting($context, $document, $selects); + $document = $this->decode($context, $document, $selects); // 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))) { @@ -4399,6 +4408,10 @@ public function getDocument(string $collection, string $id, array $queries = [], } } + if ($permissionsAdded) { // Or remove all queries added by system + $document->removeAttribute('$permissions'); + } + $this->trigger(self::EVENT_DOCUMENT_READ, $document); return $document; @@ -4516,7 +4529,7 @@ private function populateDocumentsRelationships( fn ($attr) => $attr['type'] === Database::VAR_RELATIONSHIP ); - $nextSelects = $this->processRelationshipQueries($relatedCollectionRelationships, $relationshipQueries); + [$selects, $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) @@ -4972,10 +4985,9 @@ private function applySelectFiltersToDocuments(array $documents, array $selectQu // Collect all attributes to keep from select queries $attributesToKeep = []; + foreach ($selectQueries as $selectQuery) { - foreach ($selectQuery->getValues() as $value) { - $attributesToKeep[$value] = true; - } + $attributesToKeep[$selectQuery->getAttribute()] = true; } // Early return if wildcard selector present @@ -5103,8 +5115,11 @@ public function createDocument(string $collection, Document $document): Document $document = $this->adapter->castingAfter($collection, $documents[0]); } - $document = $this->casting($collection, $document); - $document = $this->decode($collection, $document); + $context = new QueryContext(); + $context->add($collection); + + $document = $this->casting($context, $document); + $document = $this->decode($context, $document); // Convert to custom document type if mapped if (isset($this->documentTypes[$collection->getId()])) { @@ -5153,6 +5168,9 @@ public function createDocuments( } } + $context = new QueryContext(); + $context->add($collection); + $time = DateTime::now(); $modified = 0; @@ -5215,8 +5233,8 @@ public function createDocuments( foreach ($batch as $document) { $document = $this->adapter->castingAfter($collection, $document); - $document = $this->casting($collection, $document); - $document = $this->decode($collection, $document); + $document = $this->casting($context, $document); + $document = $this->decode($context, $document); try { $onNext && $onNext($document); @@ -5797,7 +5815,10 @@ public function updateDocument(string $collection, string $id, Document $documen $document = $documents[0]; } - $document = $this->decode($collection, $document); + $context = new QueryContext(); + $context->add($collection); + + $document = $this->decode($context, $document); // Convert to custom document type if mapped if (isset($this->documentTypes[$collection->getId()])) { @@ -5855,21 +5876,20 @@ public function updateDocuments( throw new AuthorizationException($this->authorization->getDescription()); } - $attributes = $collection->getAttribute('attributes', []); - $indexes = $collection->getAttribute('indexes', []); + $context = new QueryContext(); + $context->add($collection); $this->checkQueryTypes($queries); if ($this->validate) { $validator = new DocumentsValidator( - $attributes, - $indexes, - $this->adapter->getIdAttributeType(), - $this->maxQueryValues, - $this->adapter->getMaxUIDLength(), - $this->adapter->getMinDateTime(), - $this->adapter->getMaxDateTime(), - $this->adapter->getSupportForAttributes() + $context, + idAttributeType: $this->adapter->getIdAttributeType(), + maxValuesCount: $this->maxQueryValues, + minAllowedDate: $this->adapter->getMinDateTime(), + maxAllowedDate: $this->adapter->getMaxDateTime(), + supportForAttributes: $this->adapter->getSupportForAttributes(), + maxUIDLength: $this->adapter->getMaxUIDLength() ); if (!$validator->isValid($queries)) { @@ -5951,6 +5971,15 @@ public function updateDocuments( break; } + /** + * Check and tests for required attributes + */ + foreach (['$permissions', '$sequence'] as $required) { + if (!$batch[0]->offsetExists($required)) { + throw new QueryException("Missing required attribute {$required} in select query"); + } + } + $old = array_map(fn ($doc) => clone $doc, $batch); $currentPermissions = $updates->getPermissions(); sort($currentPermissions); @@ -6020,7 +6049,7 @@ public function updateDocuments( $doc = $this->adapter->castingAfter($collection, $doc); $doc->removeAttribute('$skipPermissionsUpdate'); $this->purgeCachedDocument($collection->getId(), $doc->getId()); - $doc = $this->decode($collection, $doc); + $doc = $this->decode($context, $doc); try { $onNext && $onNext($doc, $old[$index]); } catch (Throwable $th) { @@ -6108,7 +6137,7 @@ private function updateDocumentRelationships(Document $collection, Document $old } if (\is_string($value)) { - $related = $this->skipRelationships(fn () => $this->getDocument($relatedCollection->getId(), $value, [Query::select(['$id'])])); + $related = $this->skipRelationships(fn () => $this->getDocument($relatedCollection->getId(), $value, [Query::select('$id')])); if ($related->isEmpty()) { // If no such document exists in related collection // For one-one we need to update the related key to null if no relation exists @@ -6137,7 +6166,7 @@ private function updateDocumentRelationships(Document $collection, Document $old switch (\gettype($value)) { case 'string': $related = $this->skipRelationships( - fn () => $this->getDocument($relatedCollection->getId(), $value, [Query::select(['$id'])]) + fn () => $this->getDocument($relatedCollection->getId(), $value, [Query::select('$id')]) ); if ($related->isEmpty()) { @@ -6149,7 +6178,7 @@ private function updateDocumentRelationships(Document $collection, Document $old if ( $oldValue?->getId() !== $value && !($this->skipRelationships(fn () => $this->findOne($relatedCollection->getId(), [ - Query::select(['$id']), + Query::select('$id'), Query::equal($twoWayKey, [$value]), ]))->isEmpty()) ) { @@ -6170,7 +6199,7 @@ private function updateDocumentRelationships(Document $collection, Document $old if ( $oldValue?->getId() !== $value->getId() && !($this->skipRelationships(fn () => $this->findOne($relatedCollection->getId(), [ - Query::select(['$id']), + Query::select('$id'), Query::equal($twoWayKey, [$value->getId()]), ]))->isEmpty()) ) { @@ -6251,7 +6280,7 @@ private function updateDocumentRelationships(Document $collection, Document $old foreach ($value as $relation) { if (\is_string($relation)) { $related = $this->skipRelationships( - fn () => $this->getDocument($relatedCollection->getId(), $relation, [Query::select(['$id'])]) + fn () => $this->getDocument($relatedCollection->getId(), $relation, [Query::select('$id')]) ); if ($related->isEmpty()) { @@ -6265,7 +6294,7 @@ private function updateDocumentRelationships(Document $collection, Document $old )); } elseif ($relation instanceof Document) { $related = $this->skipRelationships( - fn () => $this->getDocument($relatedCollection->getId(), $relation->getId(), [Query::select(['$id'])]) + fn () => $this->getDocument($relatedCollection->getId(), $relation->getId(), [Query::select('$id')]) ); if ($related->isEmpty()) { @@ -6294,7 +6323,7 @@ private function updateDocumentRelationships(Document $collection, Document $old if (\is_string($value)) { $related = $this->skipRelationships( - fn () => $this->getDocument($relatedCollection->getId(), $value, [Query::select(['$id'])]) + fn () => $this->getDocument($relatedCollection->getId(), $value, [Query::select('$id')]) ); if ($related->isEmpty()) { @@ -6305,7 +6334,7 @@ private function updateDocumentRelationships(Document $collection, Document $old $this->purgeCachedDocument($relatedCollection->getId(), $value); } elseif ($value instanceof Document) { $related = $this->skipRelationships( - fn () => $this->getDocument($relatedCollection->getId(), $value->getId(), [Query::select(['$id'])]) + fn () => $this->getDocument($relatedCollection->getId(), $value->getId(), [Query::select('$id')]) ); if ($related->isEmpty()) { @@ -6375,11 +6404,11 @@ private function updateDocumentRelationships(Document $collection, Document $old foreach ($value as $relation) { if (\is_string($relation)) { - if (\in_array($relation, $oldIds) || $this->getDocument($relatedCollection->getId(), $relation, [Query::select(['$id'])])->isEmpty()) { + if (\in_array($relation, $oldIds) || $this->getDocument($relatedCollection->getId(), $relation, [Query::select('$id')])->isEmpty()) { continue; } } elseif ($relation instanceof Document) { - $related = $this->getDocument($relatedCollection->getId(), $relation->getId(), [Query::select(['$id'])]); + $related = $this->getDocument($relatedCollection->getId(), $relation->getId(), [Query::select('$id')]); if ($related->isEmpty()) { if (!isset($value['$permissions'])) { @@ -6532,6 +6561,10 @@ public function upsertDocumentsWithIncrease( $created = 0; $updated = 0; $seenIds = []; + + $context = new QueryContext(); + $context->add($collection); + foreach ($documents as $key => $document) { if ($this->getSharedTables() && $this->getTenantPerDocument()) { $old = $this->authorization->skip(fn () => $this->withTenant($document->getTenant(), fn () => $this->silent(fn () => $this->getDocument( @@ -6763,7 +6796,7 @@ public function upsertDocumentsWithIncrease( foreach ($batch as $index => $doc) { $doc = $this->adapter->castingAfter($collection, $doc); if (!$hasOperators) { - $doc = $this->decode($collection, $doc); + $doc = $this->decode($context, $doc); } if ($this->getSharedTables() && $this->getTenantPerDocument()) { @@ -7195,7 +7228,7 @@ private function deleteRestrict( ) { $this->authorization->skip(function () use ($document, $relatedCollection, $twoWayKey) { $related = $this->findOne($relatedCollection->getId(), [ - Query::select(['$id']), + Query::select('$id'), Query::equal($twoWayKey, [$document->getId()]) ]); @@ -7218,7 +7251,7 @@ private function deleteRestrict( && $side === Database::RELATION_SIDE_CHILD ) { $related = $this->authorization->skip(fn () => $this->findOne($relatedCollection->getId(), [ - Query::select(['$id']), + Query::select('$id'), Query::equal($twoWayKey, [$document->getId()]) ])); @@ -7256,14 +7289,14 @@ private function deleteSetNull(Document $collection, Document $relatedCollection $this->authorization->skip(function () use ($document, $value, $relatedCollection, $twoWay, $twoWayKey, $side) { if (!$twoWay && $side === Database::RELATION_SIDE_CHILD) { $related = $this->findOne($relatedCollection->getId(), [ - Query::select(['$id']), + Query::select('$id'), Query::equal($twoWayKey, [$document->getId()]) ]); } else { if (empty($value)) { return; } - $related = $this->getDocument($relatedCollection->getId(), $value->getId(), [Query::select(['$id'])]); + $related = $this->getDocument($relatedCollection->getId(), $value->getId(), [Query::select('$id')]); } if ($related->isEmpty()) { @@ -7304,7 +7337,7 @@ private function deleteSetNull(Document $collection, Document $relatedCollection if (!$twoWay) { $value = $this->find($relatedCollection->getId(), [ - Query::select(['$id']), + Query::select('$id'), Query::equal($twoWayKey, [$document->getId()]), Query::limit(PHP_INT_MAX) ]); @@ -7327,7 +7360,7 @@ private function deleteSetNull(Document $collection, Document $relatedCollection $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); $junctions = $this->find($junction, [ - Query::select(['$id']), + Query::select('$id'), Query::equal($twoWayKey, [$document->getId()]), Query::limit(PHP_INT_MAX) ]); @@ -7397,7 +7430,7 @@ private function deleteCascade(Document $collection, Document $relatedCollection } $value = $this->find($relatedCollection->getId(), [ - Query::select(['$id']), + Query::select('$id'), Query::equal($twoWayKey, [$document->getId()]), Query::limit(PHP_INT_MAX), ]); @@ -7418,7 +7451,8 @@ private function deleteCascade(Document $collection, Document $relatedCollection $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); $junctions = $this->skipRelationships(fn () => $this->find($junction, [ - Query::select(['$id', $key]), + Query::select('$id'), + Query::select($key), Query::equal($twoWayKey, [$document->getId()]), Query::limit(PHP_INT_MAX) ])); @@ -7483,21 +7517,20 @@ public function deleteDocuments( throw new AuthorizationException($this->authorization->getDescription()); } - $attributes = $collection->getAttribute('attributes', []); - $indexes = $collection->getAttribute('indexes', []); + $context = new QueryContext(); + $context->add($collection); $this->checkQueryTypes($queries); if ($this->validate) { $validator = new DocumentsValidator( - $attributes, - $indexes, - $this->adapter->getIdAttributeType(), - $this->maxQueryValues, - $this->adapter->getMaxUIDLength(), - $this->adapter->getMinDateTime(), - $this->adapter->getMaxDateTime(), - $this->adapter->getSupportForAttributes() + $context, + idAttributeType: $this->adapter->getIdAttributeType(), + maxValuesCount: $this->maxQueryValues, + minAllowedDate: $this->adapter->getMinDateTime(), + maxAllowedDate: $this->adapter->getMaxDateTime(), + supportForAttributes: $this->adapter->getSupportForAttributes(), + maxUIDLength: $this->adapter->getMaxUIDLength() ); if (!$validator->isValid($queries)) { @@ -7545,6 +7578,15 @@ public function deleteDocuments( break; } + /** + * Check and tests for required attributes + */ + foreach (['$permissions', '$sequence'] as $required) { + if (!$batch[0]->offsetExists($required)) { + throw new QueryException("Missing required attribute {$required} in select query"); + } + } + $old = array_map(fn ($doc) => clone $doc, $batch); $sequences = []; $permissionIds = []; @@ -7705,66 +7747,80 @@ public function find(string $collection, array $queries = [], string $forPermiss throw new NotFoundException('Collection not found'); } - $attributes = $collection->getAttribute('attributes', []); - $indexes = $collection->getAttribute('indexes', []); + $context = new QueryContext(); + $context->add($collection); + + $joins = Query::getJoinQueries($queries); + + foreach ($joins as $join) { + $context->add( + $this->silent(fn () => $this->getCollection($join->getCollection())), + $join->getAlias() + ); + } + + foreach ($context->getCollections() as $_collection) { + $documentSecurity = $_collection->getAttribute('documentSecurity', false); + $skipAuth = $this->authorization->isValid(new Input($forPermission, $_collection->getPermissionsByType($forPermission))); + + if (!$skipAuth && !$documentSecurity && $_collection->getId() !== self::METADATA) { + throw new AuthorizationException($this->authorization->getDescription()); + } + + $context->addSkipAuth($this->adapter->filter($_collection->getId()), $forPermission, $skipAuth); + } $this->checkQueryTypes($queries); if ($this->validate) { $validator = new DocumentsValidator( - $attributes, - $indexes, - $this->adapter->getIdAttributeType(), - $this->maxQueryValues, - $this->adapter->getMaxUIDLength(), - $this->adapter->getMinDateTime(), - $this->adapter->getMaxDateTime(), - $this->adapter->getSupportForAttributes() + $context, + idAttributeType: $this->adapter->getIdAttributeType(), + maxValuesCount: $this->maxQueryValues, + minAllowedDate: $this->adapter->getMinDateTime(), + maxAllowedDate: $this->adapter->getMaxDateTime(), + supportForAttributes: $this->adapter->getSupportForAttributes(), + maxUIDLength: $this->adapter->getMaxUIDLength() ); if (!$validator->isValid($queries)) { throw new QueryException($validator->getDescription()); } } - $documentSecurity = $collection->getAttribute('documentSecurity', false); - $skipAuth = $this->authorization->isValid(new Input($forPermission, $collection->getPermissionsByType($forPermission))); - - if (!$skipAuth && !$documentSecurity && $collection->getId() !== self::METADATA) { - throw new AuthorizationException($this->authorization->getDescription()); - } - $relationships = \array_filter( $collection->getAttribute('attributes', []), fn (Document $attribute) => $attribute->getAttribute('type') === self::VAR_RELATIONSHIP ); + $queries = $this->convertQueries($context, $queries); + $grouped = Query::groupByType($queries); - $filters = $grouped['filters']; - $selects = $grouped['selections']; - $limit = $grouped['limit']; - $offset = $grouped['offset']; - $orderAttributes = $grouped['orderAttributes']; - $orderTypes = $grouped['orderTypes']; $cursor = $grouped['cursor']; $cursorDirection = $grouped['cursorDirection'] ?? Database::CURSOR_AFTER; + $selects = Query::getSelectQueries($queries); + $limit = Query::getLimitQuery($queries, 25); + $offset = Query::getOffsetQuery($queries, 0); + $orders = Query::getOrderQueries($queries); + $vectors = Query::getVectorQueries($queries); + $uniqueOrderBy = false; - foreach ($orderAttributes as $order) { - if ($order === '$id' || $order === '$sequence') { + foreach ($orders as $order) { + if ($order->getAttribute() === '$id' || $order->getAttribute() === '$sequence') { $uniqueOrderBy = true; } } if ($uniqueOrderBy === false) { - $orderAttributes[] = '$sequence'; + $orders[] = Query::orderAsc(); // In joins we should not add a default order, we should validate when using a cursor we should have a unique order } if (!empty($cursor)) { - foreach ($orderAttributes as $order) { - if ($cursor->getAttribute($order) === null) { + foreach ($orders as $order) { + if ($cursor->getAttribute($order->getAttribute()) === null) { throw new OrderException( - message: "Order attribute '{$order}' is empty", - attribute: $order + message: "Order attribute '{$order->getAttribute()}' is empty", + attribute: $order->getAttribute() ); } } @@ -7782,37 +7838,31 @@ public function find(string $collection, array $queries = [], string $forPermiss $cursor = []; } - /** @var array $queries */ - $queries = \array_merge( - $selects, - $this->convertQueries($collection, $filters) - ); - - $selections = $this->validateSelections($collection, $selects); - $nestedSelections = $this->processRelationshipQueries($relationships, $queries); + [$selects, $nestedSelections] = $this->processRelationshipQueries($relationships, $selects); // Convert relationship filter queries to SQL-level subqueries - $queriesOrNull = $this->convertRelationshipQueries($relationships, $queries); + $queriesOrNull = $this->convertRelationshipFiltersToSubqueries($relationships, $queries); // If conversion returns null, it means no documents can match (relationship filter found no matches) if ($queriesOrNull === null) { $results = []; } else { $queries = $queriesOrNull; + $filters = Query::getFilterQueries($queries); - $getResults = fn () => $this->adapter->find( - $collection, - $queries, + $results = $this->adapter->find( + $context, $limit ?? 25, $offset ?? 0, - $orderAttributes, - $orderTypes, $cursor, $cursorDirection, - $forPermission + $forPermission, + selects: $selects, + filters: $filters, + joins: $joins, + vectors: $vectors, + orderQueries: $orders ); - - $results = $skipAuth ? $this->authorization->skip($getResults) : $getResults(); } if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships && !empty($relationships) && (empty($selects) || !empty($nestedSelections))) { @@ -7823,8 +7873,8 @@ public function find(string $collection, array $queries = [], string $forPermiss foreach ($results as $index => $node) { $node = $this->adapter->castingAfter($collection, $node); - $node = $this->casting($collection, $node); - $node = $this->decode($collection, $node, $selections); + $node = $this->casting($context, $node, $selects); + $node = $this->decode($context, $node, $selects); // Convert to custom document type if mapped if (isset($this->documentTypes[$collection->getId()])) { @@ -7937,56 +7987,83 @@ public function findOne(string $collection, array $queries = []): Document * * @return int * @throws DatabaseException + * @throws Exception */ public function count(string $collection, array $queries = [], ?int $max = null): int { + if (!is_null($max) && $max < 1) { + throw new DatabaseException('Invalid max value, must be a valid integer and greater than 0'); + } + $collection = $this->silent(fn () => $this->getCollection($collection)); - $attributes = $collection->getAttribute('attributes', []); - $indexes = $collection->getAttribute('indexes', []); + + if ($collection->isEmpty()) { + throw new NotFoundException('Collection not found'); + } + + $context = new QueryContext(); + $context->add($collection); + + $joins = Query::getJoinQueries($queries); + + foreach ($joins as $join) { + $context->add( + $this->silent(fn () => $this->getCollection($join->getCollection())), + $join->getAlias() + ); + } + + foreach ($context->getCollections() as $_collection) { + $documentSecurity = $_collection->getAttribute('documentSecurity', false); + $skipAuth = $this->authorization->isValid(new Input(self::PERMISSION_READ, $_collection->getRead())); + + if (!$skipAuth && !$documentSecurity && $_collection->getId() !== self::METADATA) { + throw new AuthorizationException($this->authorization->getDescription()); + } + + $context->addSkipAuth($this->adapter->filter($_collection->getId()), self::PERMISSION_READ, $skipAuth); + } $this->checkQueryTypes($queries); if ($this->validate) { $validator = new DocumentsValidator( - $attributes, - $indexes, - $this->adapter->getIdAttributeType(), - $this->maxQueryValues, - $this->adapter->getMaxUIDLength(), - $this->adapter->getMinDateTime(), - $this->adapter->getMaxDateTime(), - $this->adapter->getSupportForAttributes() + $context, + idAttributeType: $this->adapter->getIdAttributeType(), + maxValuesCount: $this->maxQueryValues, + minAllowedDate: $this->adapter->getMinDateTime(), + maxAllowedDate: $this->adapter->getMaxDateTime(), + supportForAttributes: $this->adapter->getSupportForAttributes(), + maxUIDLength: $this->adapter->getMaxUIDLength() ); if (!$validator->isValid($queries)) { throw new QueryException($validator->getDescription()); } } - $documentSecurity = $collection->getAttribute('documentSecurity', false); - $skipAuth = $this->authorization->isValid(new Input(self::PERMISSION_READ, $collection->getRead())); - - if (!$skipAuth && !$documentSecurity && $collection->getId() !== self::METADATA) { - throw new AuthorizationException($this->authorization->getDescription()); - } - $relationships = \array_filter( $collection->getAttribute('attributes', []), fn (Document $attribute) => $attribute->getAttribute('type') === self::VAR_RELATIONSHIP ); - $queries = Query::groupByType($queries)['filters']; - $queries = $this->convertQueries($collection, $queries); + $queries = $this->convertQueries($context, $queries); - $queriesOrNull = $this->convertRelationshipQueries($relationships, $queries); + $filters = Query::getFilterQueries($queries); - if ($queriesOrNull === null) { + // Convert relationship filter queries to SQL-level subqueries + $filters = $this->convertRelationshipFiltersToSubqueries($relationships, $filters); + + // If conversion returns null, it means no documents can match (relationship filter found no matches) + if ($filters === null) { return 0; } - $queries = $queriesOrNull; - - $getCount = fn () => $this->adapter->count($collection, $queries, $max); - $count = $skipAuth ? $this->authorization->skip($getCount) : $getCount(); + $count = $this->adapter->count( + $context, + $max, + $filters, + $joins, + ); $this->trigger(self::EVENT_DOCUMENT_COUNT, $count); @@ -8009,21 +8086,20 @@ public function count(string $collection, array $queries = [], ?int $max = null) public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null): float|int { $collection = $this->silent(fn () => $this->getCollection($collection)); - $attributes = $collection->getAttribute('attributes', []); - $indexes = $collection->getAttribute('indexes', []); + $context = new QueryContext(); + $context->add($collection); $this->checkQueryTypes($queries); if ($this->validate) { $validator = new DocumentsValidator( - $attributes, - $indexes, - $this->adapter->getIdAttributeType(), - $this->maxQueryValues, - $this->adapter->getMaxUIDLength(), - $this->adapter->getMinDateTime(), - $this->adapter->getMaxDateTime(), - $this->adapter->getSupportForAttributes() + $context, + idAttributeType: $this->adapter->getIdAttributeType(), + maxValuesCount: $this->maxQueryValues, + minAllowedDate: $this->adapter->getMinDateTime(), + maxAllowedDate: $this->adapter->getMaxDateTime(), + supportForAttributes: $this->adapter->getSupportForAttributes(), + maxUIDLength: $this->adapter->getMaxUIDLength() ); if (!$validator->isValid($queries)) { throw new QueryException($validator->getDescription()); @@ -8042,8 +8118,8 @@ public function sum(string $collection, string $attribute, array $queries = [], fn (Document $attribute) => $attribute->getAttribute('type') === self::VAR_RELATIONSHIP ); - $queries = $this->convertQueries($collection, $queries); - $queriesOrNull = $this->convertRelationshipQueries($relationships, $queries); + $queries = $this->convertQueries($context, $queries); + $queriesOrNull = $this->convertRelationshipFiltersToSubqueries($relationships, $queries); // If conversion returns null, it means no documents can match (relationship filter found no matches) if ($queriesOrNull === null) { @@ -8155,148 +8231,174 @@ public function encode(Document $collection, Document $document, bool $applyDefa /** * Decode Document * - * @param Document $collection + * @param QueryContext $context * @param Document $document - * @param array $selections + * @param array $selects * @return Document * @throws DatabaseException */ - public function decode(Document $collection, Document $document, array $selections = []): Document + public function decode(QueryContext $context, Document $document, array $selects = []): Document { - $attributes = \array_filter( - $collection->getAttribute('attributes', []), - fn ($attribute) => $attribute['type'] !== self::VAR_RELATIONSHIP - ); - - $relationships = \array_filter( - $collection->getAttribute('attributes', []), - fn ($attribute) => $attribute['type'] === self::VAR_RELATIONSHIP - ); - - $filteredValue = []; + $internals = []; + $schema = []; - foreach ($relationships as $relationship) { - $key = $relationship['$id'] ?? ''; + foreach (Database::INTERNAL_ATTRIBUTES as $attribute) { + $internals[$attribute['$id']] = $attribute; + } - if ( - \array_key_exists($key, (array)$document) - || \array_key_exists($this->adapter->filter($key), (array)$document) - ) { - $value = $document->getAttribute($key); - $value ??= $document->getAttribute($this->adapter->filter($key)); - $document->removeAttribute($this->adapter->filter($key)); - $document->setAttribute($key, $value); + foreach ($context->getCollections() as $collection) { + foreach ($collection->getAttribute('attributes', []) as $attribute) { + $key = $attribute->getAttribute('key', $attribute->getAttribute('$id')); + $key = $this->adapter->filter($key); + $schema[$collection->getId()][$key] = $attribute->getArrayCopy(); } } - foreach ($this->getInternalAttributes() as $attribute) { - $attributes[] = $attribute; - } + foreach ($document as $key => $value) { + $alias = Query::DEFAULT_ALIAS; + $attributeKey = ''; - foreach ($attributes as $attribute) { - $key = $attribute['$id'] ?? ''; - $type = $attribute['type'] ?? ''; - $array = $attribute['array'] ?? false; - $filters = $attribute['filters'] ?? []; - $value = $document->getAttribute($key); + foreach ($selects as $select) { + if ($select->getAs() === $key) { + $attributeKey = $key; + $key = $select->getAttribute(); + $alias = $select->getAlias(); + break; + } - if ($key === '$permissions') { - continue; + if ($select->getAttribute() == $key || + $this->adapter->filter($select->getAttribute()) == $key) { + $alias = $select->getAlias(); + break; + } } - if (\is_null($value)) { - $value = $document->getAttribute($this->adapter->filter($key)); + $collection = $context->getCollectionByAlias($alias); + if ($collection->isEmpty()) { + throw new \Exception('Invalid query: Unknown Alias context'); + } - if (!\is_null($value)) { - $document->removeAttribute($this->adapter->filter($key)); + $attribute = $internals[$key] + ?? $schema[$collection->getId()][$this->adapter->filter($key)] + ?? null; + + if ($attribute === null) { + if (!$this->adapter->getSupportForAttributes()) { + $document->setAttribute($key, $value); // schemaless } + continue; } - // Skip decoding for Operator objects (shouldn't happen, but safety check) + if (empty($attributeKey)) { + $attributeKey = $attribute['$id']; + } + + $array = $attribute['array'] ?? false; + $filters = $attribute['filters'] ?? []; + + // Skip decoding for Operator objects if ($value instanceof Operator) { continue; } - $value = ($array) ? $value : [$value]; - $value = (is_null($value)) ? [] : $value; + $value = $array ? $value : [$value]; + $value = is_null($value) ? [] : $value; foreach ($value as $index => $node) { - foreach (\array_reverse($filters) as $filter) { + foreach (array_reverse($filters) as $filter) { $node = $this->decodeAttribute($filter, $node, $document, $key); } $value[$index] = $node; } - $filteredValue[$key] = ($array) ? $value : $value[0]; - - if ( - empty($selections) - || \in_array($key, $selections) - || \in_array('*', $selections) - ) { - $document->setAttribute($key, ($array) ? $value : $value[0]); - } - } - - $hasRelationshipSelections = false; - if (!empty($selections)) { - foreach ($selections as $selection) { - if (\str_contains($selection, '.')) { - $hasRelationshipSelections = true; - break; - } - } + $document->setAttribute( + $attributeKey, + $array ? $value : ($value[0] ?? null) + ); } - if ($hasRelationshipSelections && !empty($selections) && !\in_array('*', $selections)) { - foreach ($collection->getAttribute('attributes', []) as $attribute) { - $key = $attribute['$id'] ?? ''; - - if ($attribute['type'] === self::VAR_RELATIONSHIP || $key === '$permissions') { - continue; - } - - if (!in_array($key, $selections) && isset($filteredValue[$key])) { - $document->setAttribute($key, $filteredValue[$key]); - } - } - } return $document; } /** * Casting * - * @param Document $collection + * @param QueryContext $context * @param Document $document - * + * @param array $selects * @return Document + * @throws Exception */ - public function casting(Document $collection, Document $document): Document + public function casting(QueryContext $context, Document $document, array $selects = []): Document { if (!$this->adapter->getSupportForCasting()) { return $document; } - $attributes = $collection->getAttribute('attributes', []); + $internals = []; + $schema = []; - foreach ($this->getInternalAttributes() as $attribute) { - $attributes[] = $attribute; + foreach (Database::INTERNAL_ATTRIBUTES as $attribute) { + $internals[$attribute['$id']] = $attribute; } - foreach ($attributes as $attribute) { - $key = $attribute['$id'] ?? ''; - $type = $attribute['type'] ?? ''; - $array = $attribute['array'] ?? false; - $value = $document->getAttribute($key, null); - if (is_null($value)) { + foreach ($context->getCollections() as $collection) { + foreach ($collection->getAttribute('attributes', []) as $attribute) { + $key = $attribute->getAttribute('key', $attribute->getAttribute('$id')); + $key = $this->adapter->filter($key); + $schema[$collection->getId()][$key] = $attribute->getArrayCopy(); + } + } + + $new = $this->createDocumentInstance($context->getMainCollection()->getId(), []); + + foreach ($document as $key => $value) { + $alias = Query::DEFAULT_ALIAS; + $attributeKey = ''; + + foreach ($selects as $select) { + if ($select->getAs() === $key) { + $attributeKey = $key; + $key = $select->getAttribute(); + $alias = $select->getAlias(); + + break; + } + + if ($select->getAttribute() == $key || $this->adapter->filter($select->getAttribute()) == $key) { + $alias = $select->getAlias(); + + break; + } + } + + $collection = $context->getCollectionByAlias($alias); + if ($collection->isEmpty()) { + throw new \Exception('Invalid query: Unknown Alias context'); + } + + $attribute = $internals[$key] ?? null; + + if (is_null($attribute)) { + $attribute = $schema[$collection->getId()][$this->adapter->filter($key)] ?? null; + } + + if (is_null($attribute)) { continue; } - if ($key === '$permissions') { + if (empty($attributeKey)) { + $attributeKey = $attribute['$id']; + } + + if (is_null($value)) { + $new->setAttribute($attributeKey, null); continue; } + $type = $attribute['type'] ?? ''; + $array = $attribute['array'] ?? false; + if ($array) { $value = !is_string($value) ? $value @@ -8329,10 +8431,12 @@ public function casting(Document $collection, Document $document): Document $value[$index] = $node; } - $document->setAttribute($key, ($array) ? $value : $value[0]); + $value = ($array) ? $value : $value[0]; + + $new->setAttribute($attributeKey, $value); } - return $document; + return $new; } @@ -8404,66 +8508,6 @@ protected function decodeAttribute(string $filter, mixed $value, Document $docum return $value; } - /** - * Validate if a set of attributes can be selected from the collection - * - * @param Document $collection - * @param array $queries - * @return array - * @throws QueryException - */ - private function validateSelections(Document $collection, array $queries): array - { - if (empty($queries)) { - return []; - } - - $selections = []; - $relationshipSelections = []; - - foreach ($queries as $query) { - if ($query->getMethod() == Query::TYPE_SELECT) { - foreach ($query->getValues() as $value) { - if (\str_contains($value, '.')) { - $relationshipSelections[] = $value; - continue; - } - $selections[] = $value; - } - } - } - - // Allow querying internal attributes - $keys = \array_map( - fn ($attribute) => $attribute['$id'], - $this->getInternalAttributes() - ); - - foreach ($collection->getAttribute('attributes', []) as $attribute) { - if ($attribute['type'] !== self::VAR_RELATIONSHIP) { - // Fallback to $id when key property is not present in metadata table for some tables such as Indexes or Attributes - $keys[] = $attribute['key'] ?? $attribute['$id']; - } - } - if ($this->adapter->getSupportForAttributes()) { - $invalid = \array_diff($selections, $keys); - if (!empty($invalid) && !\in_array('*', $invalid)) { - throw new QueryException('Cannot select attributes: ' . \implode(', ', $invalid)); - } - } - - $selections = \array_merge($selections, $relationshipSelections); - - $selections[] = '$id'; - $selections[] = '$sequence'; - $selections[] = '$collection'; - $selections[] = '$createdAt'; - $selections[] = '$updatedAt'; - $selections[] = '$permissions'; - - return \array_values(\array_unique($selections)); - } - /** * Get adapter attribute limit, accounting for internal metadata * Returns 0 to indicate no limit @@ -8490,21 +8534,19 @@ public function getLimitForIndexes(): int } /** - * @param Document $collection * @param array $queries * @return array - * @throws QueryException - * @throws \Utopia\Database\Exception + * @throws Exception */ - public function convertQueries(Document $collection, array $queries): array + public function convertQueries(QueryContext $context, array $queries): array { foreach ($queries as $index => $query) { - if ($query->isNested()) { - $values = $this->convertQueries($collection, $query->getValues()); + if ($query->isNested() || $query->isJoin()) { + $values = $this->convertQueries($context, $query->getValues()); $query->setValues($values); } - $query = $this->convertQuery($collection, $query); + $query = $this->convertQuery($context, $query); $queries[$index] = $query; } @@ -8513,14 +8555,20 @@ public function convertQueries(Document $collection, array $queries): array } /** - * @param Document $collection - * @param Query $query - * @return Query - * @throws QueryException - * @throws \Utopia\Database\Exception + * @throws Exception */ - public function convertQuery(Document $collection, Query $query): Query + public function convertQuery(QueryContext $context, Query $query): Query { + if ($query->getMethod() == Query::TYPE_SELECT) { + return $query; + } + + $collection = clone $context->getCollectionByAlias($query->getAlias()); + + if ($collection->isEmpty()) { + throw new QueryException('Unknown Alias context'); + } + /** * @var array $attributes */ @@ -8591,8 +8639,8 @@ public function getSchemaAttributes(string $collection): array /** * @param string $collectionId * @param string|null $documentId - * @param array $selects - * @return array{0: string, 1: string, 2: string} + * @param array $selects + * @return array{0: ?string, 1: ?string, 2: ?string} */ public function getCacheKeys(string $collectionId, ?string $documentId = null, array $selects = []): array { @@ -8619,7 +8667,7 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a $documentKey = $documentHashKey = "{$collectionKey}:{$documentId}"; if (!empty($selects)) { - $documentHashKey = $documentKey . ':' . \md5(\implode($selects)); + $documentHashKey = $documentKey . ':' . \md5(\serialize($selects)); } } @@ -8653,86 +8701,85 @@ private function checkQueryTypes(array $queries): void * * @param array $relationships * @param array $queries - * @return array> $selects + * @return array{0: array, 1: array>} + * @throws Exception */ private function processRelationshipQueries( array $relationships, - array $queries, + array $queries ): array { $nestedSelections = []; - foreach ($queries as $query) { + foreach ($queries as $index => $query) { if ($query->getMethod() !== Query::TYPE_SELECT) { continue; } - $values = $query->getValues(); - foreach ($values as $valueIndex => $value) { - if (!\str_contains($value, '.')) { - continue; - } + $value = $query->getAttribute(); - $nesting = \explode('.', $value); - $selectedKey = \array_shift($nesting); // Remove and return first item + if (!\str_contains($value, '.')) { + continue; + } - $relationship = \array_values(\array_filter( - $relationships, - fn (Document $relationship) => $relationship->getAttribute('key') === $selectedKey, - ))[0] ?? null; + $nesting = \explode('.', $value); + $selectedKey = \array_shift($nesting); - if (!$relationship) { - continue; - } + $relationship = \array_values(\array_filter( + $relationships, + fn (Document $relationship) => $relationship->getAttribute('key') === $selectedKey + ))[0] ?? null; - // Shift the top level off the dot-path to pass the selection down the chain - // 'foo.bar.baz' becomes 'bar.baz' + if (!$relationship) { + continue; + } - $nestingPath = \implode('.', $nesting); + // Shift the top level off the dot-path to pass the selection down the chain + // 'foo.bar.baz' becomes 'bar.baz' - // If nestingPath is empty, it means we want all attributes (*) for this relationship - if (empty($nestingPath)) { - $nestedSelections[$selectedKey][] = Query::select(['*']); - } else { - $nestedSelections[$selectedKey][] = Query::select([$nestingPath]); - } + $nestingPath = \implode('.', $nesting); - $type = $relationship->getAttribute('options')['relationType']; - $side = $relationship->getAttribute('options')['side']; + // If nestingPath is empty, it means we want all attributes (*) for this relationship + if (empty($nestingPath)) { + $nestedSelections[$selectedKey][] = Query::select('*'); + } else { + $nestedSelections[$selectedKey][] = Query::select($nestingPath); + } - switch ($type) { - case Database::RELATION_MANY_TO_MANY: - unset($values[$valueIndex]); - break; - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_PARENT) { - unset($values[$valueIndex]); - } else { - $values[$valueIndex] = $selectedKey; - } - break; - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { - $values[$valueIndex] = $selectedKey; - } else { - unset($values[$valueIndex]); - } - break; - case Database::RELATION_ONE_TO_ONE: - $values[$valueIndex] = $selectedKey; - break; - } + $type = $relationship->getAttribute('options')['relationType']; + $side = $relationship->getAttribute('options')['side']; + + switch ($type) { + case Database::RELATION_MANY_TO_MANY: + $value = null; + break; + case Database::RELATION_ONE_TO_MANY: + $value = ($side === Database::RELATION_SIDE_PARENT) ? null : $selectedKey; + break; + case Database::RELATION_MANY_TO_ONE: + $value = ($side === Database::RELATION_SIDE_PARENT) ? $selectedKey : null; + break; + case Database::RELATION_ONE_TO_ONE: + $value = $selectedKey; + break; } - $finalValues = \array_values($values); - if ($query->getMethod() === Query::TYPE_SELECT) { - if (empty($finalValues)) { - $finalValues = ['*']; - } + if ($value === null) { + unset($queries[$index]); // remove query if value is unset + } else { + $query->setAttribute($value); } - $query->setValues($finalValues); } - return $nestedSelections; + $queries = array_values($queries); + + /** + * In order to populateDocumentRelationships we need $id + */ + if (!empty($relationships)) { + [$queries, $idAdded] = QueryContext::addSelect($queries, Query::select('$id', system: true)); + } + + return [$queries, $nestedSelections]; } /** @@ -8811,14 +8858,14 @@ private function processNestedRelationshipPath(string $startCollection, array $q // Now walk backwards from the deepest collection to the starting collection $leafQueries = []; foreach ($queryGroup as $q) { - $leafQueries[] = new Query($q['method'], $q['attribute'], $q['values']); + $leafQueries[] = Query::parseQuery($q); } // Query the deepest collection $matchingDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find( $currentCollection, \array_merge($leafQueries, [ - Query::select(['$id']), + Query::select('$id'), Query::limit(PHP_INT_MAX), ]) ))); @@ -8848,7 +8895,8 @@ private function processNestedRelationshipPath(string $startCollection, array $q $link['toCollection'], [ Query::equal('$id', $matchingIds), - Query::select(['$id', $link['twoWayKey']]), + Query::select('$id'), + Query::select($link['twoWayKey']), Query::limit(PHP_INT_MAX), ] ))); @@ -8881,7 +8929,7 @@ private function processNestedRelationshipPath(string $startCollection, array $q $link['fromCollection'], [ Query::equal($link['key'], $matchingIds), - Query::select(['$id']), + Query::select('$id'), Query::limit(PHP_INT_MAX), ] ))); @@ -8921,7 +8969,7 @@ private function processNestedRelationshipPath(string $startCollection, array $q * @param array $queries * @return array|null Returns null if relationship filters cannot match any documents */ - private function convertRelationshipQueries( + private function convertRelationshipFiltersToSubqueries( array $relationships, array $queries, ): ?array { @@ -8998,11 +9046,7 @@ private function convertRelationshipQueries( // Build combined queries for the related collection $relatedQueries = []; foreach ($group['queries'] as $queryData) { - $relatedQueries[] = new Query( - $queryData['method'], - $queryData['attribute'], - $queryData['values'] - ); + $relatedQueries[] = Query::parseQuery($queryData); } try { @@ -9060,7 +9104,7 @@ private function convertRelationshipQueries( $matchingDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find( $relatedCollection, \array_merge($relatedQueries, [ - Query::select(['$id']), + Query::select('$id'), Query::limit(PHP_INT_MAX), ]) ))); diff --git a/src/Database/Query.php b/src/Database/Query.php index 60ec1d712..ae51717d1 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -41,6 +41,9 @@ class Query public const TYPE_TOUCHES = 'touches'; public const TYPE_NOT_TOUCHES = 'notTouches'; + // Joins query methods + public const TYPE_RELATION_EQUAL = 'relationEqual'; + // Vector query methods public const TYPE_VECTOR_DOT = 'vectorDot'; public const TYPE_VECTOR_COSINE = 'vectorCosine'; @@ -63,6 +66,11 @@ class Query public const TYPE_AND = 'and'; public const TYPE_OR = 'or'; + // Join methods + public const TYPE_INNER_JOIN = 'innerJoin'; + public const TYPE_LEFT_JOIN = 'leftJoin'; + public const TYPE_RIGHT_JOIN = 'rightJoin'; + public const DEFAULT_ALIAS = 'main'; public const TYPES = [ @@ -122,9 +130,57 @@ class Query self::TYPE_OR, ]; + protected const JOINS_TYPES = [ + self::TYPE_INNER_JOIN, + self::TYPE_LEFT_JOIN, + self::TYPE_RIGHT_JOIN, + ]; + + protected const FILTER_TYPES = [ + self::TYPE_EQUAL, + self::TYPE_NOT_EQUAL, + self::TYPE_LESSER, + self::TYPE_LESSER_EQUAL, + self::TYPE_GREATER, + self::TYPE_GREATER_EQUAL, + self::TYPE_CONTAINS, + self::TYPE_NOT_CONTAINS, + self::TYPE_SEARCH, + self::TYPE_NOT_SEARCH, + self::TYPE_IS_NULL, + self::TYPE_IS_NOT_NULL, + self::TYPE_BETWEEN, + self::TYPE_NOT_BETWEEN, + self::TYPE_STARTS_WITH, + self::TYPE_NOT_STARTS_WITH, + self::TYPE_ENDS_WITH, + self::TYPE_NOT_ENDS_WITH, + self::TYPE_CROSSES, + self::TYPE_NOT_CROSSES, + self::TYPE_DISTANCE_EQUAL, + self::TYPE_DISTANCE_NOT_EQUAL, + self::TYPE_DISTANCE_GREATER_THAN, + self::TYPE_DISTANCE_LESS_THAN, + self::TYPE_INTERSECTS, + self::TYPE_NOT_INTERSECTS, + self::TYPE_OVERLAPS, + self::TYPE_NOT_OVERLAPS, + self::TYPE_TOUCHES, + self::TYPE_NOT_TOUCHES, + self::TYPE_AND, + self::TYPE_OR, + self::TYPE_RELATION_EQUAL, + ]; + protected string $method = ''; + protected string $collection = ''; + protected string $alias = ''; protected string $attribute = ''; protected string $attributeType = ''; + protected string $aliasRight = ''; + protected string $attributeRight = ''; + protected string $as = ''; + protected bool $system = false; protected bool $onArray = false; /** @@ -139,15 +195,41 @@ class Query * @param string $attribute * @param array $values */ - public function __construct(string $method, string $attribute = '', array $values = []) - { + protected function __construct( + string $method, + string $attribute = '', + array $values = [], + string $alias = '', + string $attributeRight = '', + string $aliasRight = '', + string $collection = '', + string $as = '', + bool $system = false, + ) { if ($attribute === '' && \in_array($method, [Query::TYPE_ORDER_ASC, Query::TYPE_ORDER_DESC])) { $attribute = '$sequence'; } + /** + * We can not make the fallback in the Query::static() calls , because parse method skips it + */ + if (empty($alias)) { + $alias = Query::DEFAULT_ALIAS; + } + + if (empty($aliasRight)) { + $aliasRight = Query::DEFAULT_ALIAS; + } + $this->method = $method; + $this->alias = $alias; $this->attribute = $attribute; $this->values = $values; + $this->aliasRight = $aliasRight; + $this->attributeRight = $attributeRight; + $this->collection = $collection; + $this->as = $as; + $this->system = $system; } public function __clone(): void @@ -192,6 +274,31 @@ public function getValue(mixed $default = null): mixed return $this->values[0] ?? $default; } + public function getAlias(): string + { + return $this->alias; + } + + public function getRightAlias(): string + { + return $this->aliasRight; + } + + public function getAttributeRight(): string + { + return $this->attributeRight; + } + + public function getAs(): string + { + return $this->as; + } + + public function getCollection(): string + { + return $this->collection; + } + /** * Sets method * @@ -218,6 +325,45 @@ public function setAttribute(string $attribute): self return $this; } + /** + * Sets right attribute + */ + public function setAttributeRight(string $attribute): self + { + $this->attributeRight = $attribute; + + return $this; + } + + public function getCursorDirection(): string + { + if ($this->method === self::TYPE_CURSOR_AFTER) { + return Database::CURSOR_AFTER; + } + + if ($this->method === self::TYPE_CURSOR_BEFORE) { + return Database::CURSOR_BEFORE; + } + + throw new \Exception('Invalid method: Get cursor direction on "'.$this->method.'" Query'); + } + + public function getOrderDirection(): string + { + if ($this->method === self::TYPE_ORDER_ASC) { + return Database::ORDER_ASC; + } + + if ($this->method === self::TYPE_ORDER_DESC) { + return Database::ORDER_DESC; + } + + if ($this->method === self::TYPE_ORDER_RANDOM) { + return Database::ORDER_RANDOM; + } + + throw new \Exception('Invalid method: Get order direction on "'.$this->method.'" Query'); + } /** * Sets values * @@ -292,6 +438,10 @@ public static function isMethod(string $value): bool self::TYPE_OR, self::TYPE_AND, self::TYPE_SELECT, + self::TYPE_RELATION_EQUAL, + self::TYPE_INNER_JOIN, + self::TYPE_LEFT_JOIN, + self::TYPE_RIGHT_JOIN, self::TYPE_VECTOR_DOT, self::TYPE_VECTOR_COSINE, self::TYPE_VECTOR_EUCLIDEAN => true, @@ -300,12 +450,12 @@ public static function isMethod(string $value): bool } /** - * Check if method is a spatial-only query method + * @param string $method * @return bool */ - public function isSpatialQuery(): bool + public static function isSpatialQuery(string $method): bool { - return match ($this->method) { + return match ($method) { self::TYPE_CROSSES, self::TYPE_NOT_CROSSES, self::TYPE_DISTANCE_EQUAL, @@ -322,6 +472,15 @@ public function isSpatialQuery(): bool }; } + /** + * @param string $method + * @return bool + */ + public static function isVectorQuery(string $method): bool + { + return \in_array($method, Query::VECTOR_TYPES); + } + /** * Parse query * @@ -355,7 +514,12 @@ public static function parseQuery(array $query): self { $method = $query['method'] ?? ''; $attribute = $query['attribute'] ?? ''; + $attributeRight = $query['attributeRight'] ?? ''; $values = $query['values'] ?? []; + $alias = $query['alias'] ?? ''; + $aliasRight = $query['aliasRight'] ?? ''; + $as = $query['as'] ?? ''; + $collection = $query['collection'] ?? ''; if (!\is_string($method)) { throw new QueryException('Invalid query method. Must be a string, got ' . \gettype($method)); @@ -373,13 +537,22 @@ public static function parseQuery(array $query): self throw new QueryException('Invalid query values. Must be an array, got ' . \gettype($values)); } - if (\in_array($method, self::LOGICAL_TYPES)) { + if (\in_array($method, self::LOGICAL_TYPES) || \in_array($method, self::JOINS_TYPES)) { foreach ($values as $index => $value) { $values[$index] = self::parseQuery($value); } } - return new self($method, $attribute, $values); + return new self( + $method, + $attribute, + $values, + alias: $alias, + attributeRight: $attributeRight, + aliasRight: $aliasRight, + collection: $collection, + as: $as, + ); } /** @@ -412,7 +585,27 @@ public function toArray(): array $array['attribute'] = $this->attribute; } - if (\in_array($array['method'], self::LOGICAL_TYPES)) { + if (!empty($this->attributeRight)) { + $array['attributeRight'] = $this->attributeRight; + } + + if (!empty($this->alias) && $this->alias != Query::DEFAULT_ALIAS) { + $array['alias'] = $this->alias; + } + + if (!empty($this->aliasRight) && $this->aliasRight != Query::DEFAULT_ALIAS) { + $array['aliasRight'] = $this->aliasRight; + } + + if (!empty($this->as)) { + $array['as'] = $this->as; + } + + if (!empty($this->collection)) { + $array['collection'] = $this->collection; + } + + if ($this->isNested() || $this->isJoin()) { foreach ($this->values as $index => $value) { $array['values'][$index] = $value->toArray(); } @@ -449,9 +642,9 @@ public function toString(): string * @param array> $values * @return Query */ - public static function equal(string $attribute, array $values): self + public static function equal(string $attribute, array $values, string $alias = ''): self { - return new self(self::TYPE_EQUAL, $attribute, $values); + return new self(self::TYPE_EQUAL, $attribute, $values, alias: $alias); } /** @@ -461,13 +654,13 @@ public static function equal(string $attribute, array $values): self * @param string|int|float|bool|array $value * @return Query */ - public static function notEqual(string $attribute, string|int|float|bool|array $value): self + public static function notEqual(string $attribute, string|int|float|bool|array $value, string $alias = ''): self { // maps or not an array if ((is_array($value) && !array_is_list($value)) || !is_array($value)) { $value = [$value]; } - return new self(self::TYPE_NOT_EQUAL, $attribute, $value); + return new self(self::TYPE_NOT_EQUAL, $attribute, $value, alias: $alias); } /** @@ -477,9 +670,9 @@ public static function notEqual(string $attribute, string|int|float|bool|array $ * @param string|int|float|bool $value * @return Query */ - public static function lessThan(string $attribute, string|int|float|bool $value): self + public static function lessThan(string $attribute, string|int|float|bool $value, string $alias = ''): self { - return new self(self::TYPE_LESSER, $attribute, [$value]); + return new self(self::TYPE_LESSER, $attribute, [$value], alias: $alias); } /** @@ -489,9 +682,9 @@ public static function lessThan(string $attribute, string|int|float|bool $value) * @param string|int|float|bool $value * @return Query */ - public static function lessThanEqual(string $attribute, string|int|float|bool $value): self + public static function lessThanEqual(string $attribute, string|int|float|bool $value, string $alias = ''): self { - return new self(self::TYPE_LESSER_EQUAL, $attribute, [$value]); + return new self(self::TYPE_LESSER_EQUAL, $attribute, [$value], alias: $alias); } /** @@ -501,9 +694,9 @@ public static function lessThanEqual(string $attribute, string|int|float|bool $v * @param string|int|float|bool $value * @return Query */ - public static function greaterThan(string $attribute, string|int|float|bool $value): self + public static function greaterThan(string $attribute, string|int|float|bool $value, string $alias = ''): self { - return new self(self::TYPE_GREATER, $attribute, [$value]); + return new self(self::TYPE_GREATER, $attribute, [$value], alias: $alias); } /** @@ -513,9 +706,9 @@ public static function greaterThan(string $attribute, string|int|float|bool $val * @param string|int|float|bool $value * @return Query */ - public static function greaterThanEqual(string $attribute, string|int|float|bool $value): self + public static function greaterThanEqual(string $attribute, string|int|float|bool $value, string $alias = ''): self { - return new self(self::TYPE_GREATER_EQUAL, $attribute, [$value]); + return new self(self::TYPE_GREATER_EQUAL, $attribute, [$value], alias: $alias); } /** @@ -550,9 +743,9 @@ public static function notContains(string $attribute, array $values): self * @param string|int|float|bool $end * @return Query */ - public static function between(string $attribute, string|int|float|bool $start, string|int|float|bool $end): self + public static function between(string $attribute, string|int|float|bool $start, string|int|float|bool $end, string $alias = ''): self { - return new self(self::TYPE_BETWEEN, $attribute, [$start, $end]); + return new self(self::TYPE_BETWEEN, $attribute, [$start, $end], alias: $alias); } /** @@ -592,15 +785,9 @@ public static function notSearch(string $attribute, string $value): self return new self(self::TYPE_NOT_SEARCH, $attribute, [$value]); } - /** - * Helper method to create Query with select method - * - * @param array $attributes - * @return Query - */ - public static function select(array $attributes): self + public static function select(string $attribute, string $alias = '', string $as = '', bool $system = false): self { - return new self(self::TYPE_SELECT, values: $attributes); + return new self(self::TYPE_SELECT, $attribute, [], alias: $alias, as: $as, system: $system); } /** @@ -609,9 +796,9 @@ public static function select(array $attributes): self * @param string $attribute * @return Query */ - public static function orderDesc(string $attribute = ''): self + public static function orderDesc(string $attribute = '', string $alias = ''): self { - return new self(self::TYPE_ORDER_DESC, $attribute); + return new self(self::TYPE_ORDER_DESC, $attribute, alias: $alias); } /** @@ -620,9 +807,9 @@ public static function orderDesc(string $attribute = ''): self * @param string $attribute * @return Query */ - public static function orderAsc(string $attribute = ''): self + public static function orderAsc(string $attribute = '', string $alias = ''): self { - return new self(self::TYPE_ORDER_ASC, $attribute); + return new self(self::TYPE_ORDER_ASC, $attribute, alias: $alias); } /** @@ -807,6 +994,55 @@ public static function and(array $queries): self return new self(self::TYPE_AND, '', $queries); } + /** + * @param string $collection + * @param string $alias + * @param array $queries + * @return self + */ + public static function join(string $collection, string $alias, array $queries = []): self + { + return new self(self::TYPE_INNER_JOIN, values: $queries, alias: $alias, collection: $collection); + } + + /** + * @param string $collection + * @param string $alias + * @param array $queries + * @return self + */ + public static function innerJoin(string $collection, string $alias, array $queries = []): self + { + return new self(self::TYPE_INNER_JOIN, values: $queries, alias: $alias, collection: $collection); + } + + /** + * @param string $collection + * @param string $alias + * @param array $queries + * @return self + */ + public static function leftJoin(string $collection, string $alias, array $queries = []): self + { + return new self(self::TYPE_LEFT_JOIN, values: $queries, alias: $alias, collection: $collection); + } + + /** + * @param string $collection + * @param string $alias + * @param array $queries + * @return self + */ + public static function rightJoin(string $collection, string $alias, array $queries = []): self + { + return new self(self::TYPE_RIGHT_JOIN, values: $queries, alias: $alias, collection: $collection); + } + + public static function relationEqual(string $leftAlias, string $leftColumn, string $rightAlias, string $rightColumn): self + { + return new self(self::TYPE_RELATION_EQUAL, $leftColumn, [], alias: $leftAlias, attributeRight: $rightColumn, aliasRight: $rightAlias); + } + /** * Filters $queries for $types * @@ -814,7 +1050,7 @@ public static function and(array $queries): self * @param array $types * @return array */ - public static function getByType(array $queries, array $types): array + protected static function getByType(array $queries, array $types): array { $filtered = []; @@ -827,6 +1063,137 @@ public static function getByType(array $queries, array $types): array return $filtered; } + /** + * @param array $queries + * @return array + */ + public static function getSelectQueries(array $queries): array + { + return self::getByType($queries, [ + Query::TYPE_SELECT, + ]); + } + + /** + * @param array $queries + * @return array + */ + public static function getJoinQueries(array $queries): array + { + return self::getByType($queries, self::JOINS_TYPES); + } + + /** + * @param array $queries + * @return array + */ + public static function getLimitQueries(array $queries): array + { + foreach ($queries as $query) { + if ($query->getMethod() === Query::TYPE_LIMIT) { + return [clone $query]; + } + } + + return []; + } + + /** + * @param array $queries + * @param int|null $default + * @return int|null + */ + public static function getLimitQuery(array $queries, ?int $default = null): ?int + { + $queries = self::getLimitQueries($queries); + + if (empty($queries)) { + return $default; + } + + return $queries[0]->getValue(); + } + + /** + * @param array $queries + * @return array + */ + public static function getOffsetQueries(array $queries): array + { + foreach ($queries as $query) { + if ($query->getMethod() === Query::TYPE_OFFSET) { + return [clone $query]; + } + } + + return []; + } + + /** + * @param array $queries + * @param int|null $default + * @return int|null + */ + public static function getOffsetQuery(array $queries, ?int $default = null): ?int + { + $queries = self::getOffsetQueries($queries); + + if (empty($queries)) { + return $default; + } + + return $queries[0]->getValue(); + } + + /** + * @param array $queries + * @return array + */ + public static function getOrderQueries(array $queries): array + { + return self::getByType($queries, [ + Query::TYPE_ORDER_ASC, + Query::TYPE_ORDER_DESC, + Query::TYPE_ORDER_RANDOM, + ]); + } + + /** + * @param array $queries + * @return Query|null + */ + public static function getCursorQueries(array $queries): ?Query + { + $queries = self::getByType($queries, [ + Query::TYPE_CURSOR_AFTER, + Query::TYPE_CURSOR_BEFORE, + ]); + + if (empty($queries)) { + return null; + } + + return $queries[0]; + } + + /** + * @param array $queries + * @return array + */ + public static function getFilterQueries(array $queries): array + { + return self::getByType($queries, self::FILTER_TYPES); + } + + /** + * @param array $queries + * @return array + */ + public static function getVectorQueries(array $queries): array + { + return self::getByType($queries, self::VECTOR_TYPES); + } + /** * Iterates through queries are groups them by type * @@ -941,8 +1308,18 @@ public function isNested(): bool } /** - * @return bool + * Is this query able to contain other queries */ + public function isJoin(): bool + { + return in_array($this->getMethod(), self::JOINS_TYPES); + } + + public static function isFilter(string $method): bool + { + return in_array($method, self::FILTER_TYPES); + } + public function onArray(): bool { return $this->onArray; @@ -1143,6 +1520,7 @@ public static function notTouches(string $attribute, array $values): self return new self(self::TYPE_NOT_TOUCHES, $attribute, [$values]); } + /** * Helper method to create Query with vectorDot method * diff --git a/src/Database/QueryContext.php b/src/Database/QueryContext.php new file mode 100644 index 000000000..f5c9a3e25 --- /dev/null +++ b/src/Database/QueryContext.php @@ -0,0 +1,136 @@ + + */ + protected array $collections = []; + + /** + * @var array + */ + protected array $aliases = []; + + /** + * @var array + */ + protected array $skipAuthCollections = []; + + public function __construct() + { + } + + /** + * @return array + */ + public function getCollections(): array + { + return $this->collections; + } + + /** + * @return Document + */ + public function getMainCollection(): Document + { + return $this->getCollections()[0]; + } + + public function getCollectionByAlias(string $alias): Document + { + /** + * $alias can be an empty string + */ + $collectionId = $this->aliases[$alias] ?? null; + + if (is_null($collectionId)) { + return new Document(); + } + + foreach ($this->collections as $collection) { + if ($collection->getId() === $collectionId) { + return $collection; + } + } + + return new Document(); + } + + /** + * @throws QueryException + */ + public function add(Document $collection, string $alias = Query::DEFAULT_ALIAS): void + { + if (! empty($this->aliases[$alias])) { + throw new QueryException('Ambiguous alias for collection "'.$collection->getId().'".'); + } + + $this->collections[] = $collection; + $this->aliases[$alias] = $collection->getId(); + } + + public function addSkipAuth(string $collection, string $permission, bool $skipAuth): void + { + $this->skipAuthCollections[$collection][$permission] = $skipAuth; + } + + public function skipAuth(string $collection, string $permission, Authorization $authorization): bool + { + if (!$authorization->getStatus()) { // for Authorization::disable(); + return true; + } + + if (empty($this->skipAuthCollections[$collection][$permission])) { + return false; + } + + return true; + } + + /** + * @param array $queries + * @param Query $query + * @return array{array, bool} + * @throws \Exception + */ + public static function addSelect(array $queries, Query $query): array + { + $merge = true; + $found = false; + + foreach ($queries as $q) { + if ($q->getMethod() === Query::TYPE_SELECT) { + $found = true; + + if ($q->getAlias() === $query->getAlias()) { + if ($q->getAttribute() === '*') { + $merge = false; + } + + if ($q->getAttribute() === $query->getAttribute()) { + if ($q->getAs() === $query->getAs()) { + $merge = false; + } + } + } + } + } + + if ($found && $merge) { + $queries = [ + ...$queries, + $query + ]; + + return [$queries, true]; + } + + return [$queries, false]; + } +} diff --git a/src/Database/Validator/Alias.php b/src/Database/Validator/Alias.php new file mode 100644 index 000000000..7e3ecf8f2 --- /dev/null +++ b/src/Database/Validator/Alias.php @@ -0,0 +1,70 @@ +message; + } + + /** + * Is valid. + * Returns true if valid or false if not. + * @param mixed $value + * @return bool + */ + public function isValid($value): bool + { + if (! \is_string($value)) { + return false; + } + + if (empty($value)) { + return true; + } + + if (! preg_match('/^[a-zA-Z0-9_]+$/', $value)) { + return false; + } + + if (\mb_strlen($value) >= 64) { + return false; + } + + return true; + } + + /** + * Is array + * + * Function will return true if object is array. + * + * @return bool + */ + public function isArray(): bool + { + return false; + } + + /** + * Get Type + * + * Returns validator type. + * + * @return string + */ + public function getType(): string + { + return self::TYPE_STRING; + } +} diff --git a/src/Database/Validator/AsQuery.php b/src/Database/Validator/AsQuery.php new file mode 100644 index 000000000..93ab12724 --- /dev/null +++ b/src/Database/Validator/AsQuery.php @@ -0,0 +1,84 @@ +message; + } + + /** + * Is valid. + * Returns true if valid or false if not. + * @param mixed $value + * @return bool + */ + public function isValid($value): bool + { + if (! \is_string($value)) { + return false; + } + + if (empty($value)) { + return true; + } + + if (! preg_match('/^[a-zA-Z0-9_]+$/', $value)) { + return false; + } + + if (\mb_strlen($value) >= 64) { + return false; + } + + if ($this->attribute === '*') { + $this->message = 'Invalid "as" on attribute "*"'; + return false; + } + + return true; + } + + /** + * Is array + * + * Function will return true if object is array. + * + * @return bool + */ + public function isArray(): bool + { + return false; + } + + /** + * Get Type + * + * Returns validator type. + * + * @return string + */ + public function getType(): string + { + return self::TYPE_STRING; + } +} diff --git a/src/Database/Validator/IndexedQueries.php b/src/Database/Validator/IndexedQueries.php index a24e0d21d..9dc9164b7 100644 --- a/src/Database/Validator/IndexedQueries.php +++ b/src/Database/Validator/IndexedQueries.php @@ -1,147 +1,148 @@ - */ - protected array $attributes = []; - - /** - * @var array - */ - protected array $indexes = []; - - /** - * Expression constructor - * - * This Queries Validator filters indexes for only available indexes - * - * @param array $attributes - * @param array $indexes - * @param array $validators - * @throws Exception - */ - public function __construct(array $attributes = [], array $indexes = [], array $validators = []) - { - $this->attributes = $attributes; - - $this->indexes[] = new Document([ - 'type' => Database::INDEX_UNIQUE, - 'attributes' => ['$id'] - ]); - - $this->indexes[] = new Document([ - 'type' => Database::INDEX_KEY, - 'attributes' => ['$createdAt'] - ]); - - $this->indexes[] = new Document([ - 'type' => Database::INDEX_KEY, - 'attributes' => ['$updatedAt'] - ]); - - foreach ($indexes as $index) { - $this->indexes[] = $index; - } - - parent::__construct($validators); - } - - /** - * Count vector queries across entire query tree - * - * @param array $queries - * @return int - */ - private function countVectorQueries(array $queries): int - { - $count = 0; - - foreach ($queries as $query) { - if (in_array($query->getMethod(), Query::VECTOR_TYPES)) { - $count++; - } - - if ($query->isNested()) { - $count += $this->countVectorQueries($query->getValues()); - } - } - - return $count; - } - - /** - * @param mixed $value - * @return bool - * @throws Exception - */ - public function isValid($value): bool - { - if (!parent::isValid($value)) { - return false; - } - $queries = []; - foreach ($value as $query) { - if (! $query instanceof Query) { - try { - $query = Query::parse($query); - } catch (\Throwable $e) { - $this->message = 'Invalid query: '.$e->getMessage(); - - return false; - } - } - - if ($query->isNested()) { - if (! self::isValid($query->getValues())) { - return false; - } - } - - $queries[] = $query; - } - - $vectorQueryCount = $this->countVectorQueries($queries); - if ($vectorQueryCount > 1) { - $this->message = 'Cannot use multiple vector queries in a single request'; - return false; - } - - $grouped = Query::groupByType($queries); - $filters = $grouped['filters']; - - foreach ($filters as $filter) { - if ( - $filter->getMethod() === Query::TYPE_SEARCH || - $filter->getMethod() === Query::TYPE_NOT_SEARCH - ) { - $matched = false; - - foreach ($this->indexes as $index) { - if ( - $index->getAttribute('type') === Database::INDEX_FULLTEXT - && $index->getAttribute('attributes') === [$filter->getAttribute()] - ) { - $matched = true; - } - } - - if (!$matched) { - $this->message = "Searching by attribute \"{$filter->getAttribute()}\" requires a fulltext index."; - return false; - } - } - } - - return true; - } -} +// +//namespace Utopia\Database\Validator; +// +//use Exception; +//use Utopia\Database\Database; +//use Utopia\Database\Document; +//use Utopia\Database\Query; +//use Utopia\Database\Validator\Query\Base; +// +//class IndexedQueries extends Queries +//{ +// /** +// * @var array +// */ +// protected array $attributes = []; +// +// /** +// * @var array +// */ +// protected array $indexes = []; +// +// /** +// * Expression constructor +// * +// * This Queries Validator filters indexes for only available indexes +// * +// * @param array $attributes +// * @param array $indexes +// * @param array $validators +// * @throws Exception +// */ +// public function __construct(array $attributes = [], array $indexes = [], array $validators = []) +// { +// $this->attributes = $attributes; +// +// $this->indexes[] = new Document([ +// 'type' => Database::INDEX_UNIQUE, +// 'attributes' => ['$id'] +// ]); +// +// $this->indexes[] = new Document([ +// 'type' => Database::INDEX_KEY, +// 'attributes' => ['$createdAt'] +// ]); +// +// $this->indexes[] = new Document([ +// 'type' => Database::INDEX_KEY, +// 'attributes' => ['$updatedAt'] +// ]); +// +// foreach ($indexes as $index) { +// $this->indexes[] = $index; +// } +// +// parent::__construct($validators); +// } +// +// /** +// * Count vector queries across entire query tree +// * +// * @param array $queries +// * @return int +// */ +// private function countVectorQueries(array $queries): int +// { +// $count = 0; +// +// foreach ($queries as $query) { +// if (in_array($query->getMethod(), Query::VECTOR_TYPES)) { +// $count++; +// } +// +// if ($query->isNested()) { +// $count += $this->countVectorQueries($query->getValues()); +// } +// } +// +// return $count; +// } +// +// /** +// * @param mixed $value +// * @return bool +// * @throws Exception +// */ +// public function isValid($value): bool +// { +// if (!parent::isValid($value)) { +// return false; +// } +// $queries = []; +// foreach ($value as $query) { +// if (! $query instanceof Query) { +// try { +// $query = Query::parse($query); +// } catch (\Throwable $e) { +// $this->message = 'Invalid query: '.$e->getMessage(); +// +// return false; +// } +// } +// +// if ($query->isNested()) { +// if (! self::isValid($query->getValues())) { +// return false; +// } +// } +// +// $queries[] = $query; +// } +// +// $vectorQueryCount = $this->countVectorQueries($queries); +// if ($vectorQueryCount > 1) { +// $this->message = 'Cannot use multiple vector queries in a single request'; +// return false; +// } +// +// $grouped = Query::groupByType($queries); +// $filters = $grouped['filters']; +// +// foreach ($filters as $filter) { +// if ( +// $filter->getMethod() === Query::TYPE_SEARCH || +// $filter->getMethod() === Query::TYPE_NOT_SEARCH +// ) { +// $matched = false; +// +// foreach ($this->indexes as $index) { +// if ( +// $index->getAttribute('type') === Database::INDEX_FULLTEXT +// && $index->getAttribute('attributes') === [$filter->getAttribute()] +// ) { +// $matched = true; +// } +// } +// +// if (!$matched) { +// $this->message = "Searching by attribute \"{$filter->getAttribute()}\" requires a fulltext index."; +// return false; +// } +// } +// } +// +// return true; +// } +//} diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index 8066228e3..75297bf8e 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -1,173 +1,174 @@ - */ - protected array $validators; - - /** - * @var int - */ - protected int $length; - - /** - * Queries constructor - * - * @param array $validators - */ - public function __construct(array $validators = [], int $length = 0) - { - $this->validators = $validators; - $this->length = $length; - } - - /** - * Get Description. - * - * Returns validator description - * - * @return string - */ - public function getDescription(): string - { - return $this->message; - } - - /** - * @param array $value - * @return bool - */ - public function isValid($value): bool - { - if (!is_array($value)) { - $this->message = 'Queries must be an array'; - return false; - } - - if ($this->length && \count($value) > $this->length) { - return false; - } - - foreach ($value as $query) { - if (!$query instanceof Query) { - try { - $query = Query::parse($query); - } catch (\Throwable $e) { - $this->message = 'Invalid query: ' . $e->getMessage(); - return false; - } - } - - if ($query->isNested()) { - if (!self::isValid($query->getValues())) { - return false; - } - } - - $method = $query->getMethod(); - $methodType = match ($method) { - Query::TYPE_SELECT => Base::METHOD_TYPE_SELECT, - Query::TYPE_LIMIT => Base::METHOD_TYPE_LIMIT, - Query::TYPE_OFFSET => Base::METHOD_TYPE_OFFSET, - Query::TYPE_CURSOR_AFTER, - Query::TYPE_CURSOR_BEFORE => Base::METHOD_TYPE_CURSOR, - Query::TYPE_ORDER_ASC, - Query::TYPE_ORDER_DESC, - Query::TYPE_ORDER_RANDOM => Base::METHOD_TYPE_ORDER, - Query::TYPE_EQUAL, - Query::TYPE_NOT_EQUAL, - Query::TYPE_LESSER, - Query::TYPE_LESSER_EQUAL, - Query::TYPE_GREATER, - Query::TYPE_GREATER_EQUAL, - Query::TYPE_SEARCH, - Query::TYPE_NOT_SEARCH, - Query::TYPE_IS_NULL, - Query::TYPE_IS_NOT_NULL, - Query::TYPE_BETWEEN, - Query::TYPE_NOT_BETWEEN, - Query::TYPE_STARTS_WITH, - Query::TYPE_NOT_STARTS_WITH, - Query::TYPE_ENDS_WITH, - Query::TYPE_NOT_ENDS_WITH, - Query::TYPE_CONTAINS, - Query::TYPE_NOT_CONTAINS, - Query::TYPE_AND, - Query::TYPE_OR, - Query::TYPE_CROSSES, - Query::TYPE_NOT_CROSSES, - Query::TYPE_DISTANCE_EQUAL, - Query::TYPE_DISTANCE_NOT_EQUAL, - Query::TYPE_DISTANCE_GREATER_THAN, - Query::TYPE_DISTANCE_LESS_THAN, - Query::TYPE_INTERSECTS, - Query::TYPE_NOT_INTERSECTS, - Query::TYPE_OVERLAPS, - Query::TYPE_NOT_OVERLAPS, - Query::TYPE_TOUCHES, - Query::TYPE_NOT_TOUCHES, - Query::TYPE_VECTOR_DOT, - Query::TYPE_VECTOR_COSINE, - Query::TYPE_VECTOR_EUCLIDEAN => Base::METHOD_TYPE_FILTER, - default => '', - }; - - $methodIsValid = false; - foreach ($this->validators as $validator) { - if ($validator->getMethodType() !== $methodType) { - continue; - } - if (!$validator->isValid($query)) { - $this->message = 'Invalid query: ' . $validator->getDescription(); - return false; - } - - $methodIsValid = true; - } - - if (!$methodIsValid) { - $this->message = 'Invalid query method: ' . $method; - return false; - } - } - - return true; - } - - /** - * Is array - * - * Function will return true if object is array. - * - * @return bool - */ - public function isArray(): bool - { - return true; - } - - /** - * Get Type - * - * Returns validator type. - * - * @return string - */ - public function getType(): string - { - return self::TYPE_OBJECT; - } -} +// +//namespace Utopia\Database\Validator; +// +//use Utopia\Database\Query; +//use Utopia\Database\Validator\Query\Base; +//use Utopia\Validator; +// +//class Queries extends Validator +//{ +// /** +// * @var string +// */ +// protected string $message = 'Invalid queries'; +// +// /** +// * @var array +// */ +// protected array $validators; +// +// /** +// * @var int +// */ +// protected int $length; +// +// /** +// * Queries constructor +// * +// * @param array $validators +// */ +// public function __construct(array $validators = [], int $length = 0) +// { +// $this->validators = $validators; +// $this->length = $length; +// } +// +// /** +// * Get Description. +// * +// * Returns validator description +// * +// * @return string +// */ +// public function getDescription(): string +// { +// return $this->message; +// } +// +// /** +// * @param array $value +// * @return bool +// */ +// public function isValid($value): bool +// { +// if (!is_array($value)) { +// $this->message = 'Queries must be an array'; +// return false; +// } +// +// if ($this->length && \count($value) > $this->length) { +// return false; +// } +// +// foreach ($value as $query) { +// if (!$query instanceof Query) { +// try { +// $query = Query::parse($query); +// } catch (\Throwable $e) { +// $this->message = 'Invalid query: ' . $e->getMessage(); +// return false; +// } +// } +// +// if ($query->isNested()) { +// if (!self::isValid($query->getValues())) { +// return false; +// } +// } +// +// $method = $query->getMethod(); +// $methodType = match ($method) { +// Query::TYPE_SELECT => Base::METHOD_TYPE_SELECT, +// Query::TYPE_LIMIT => Base::METHOD_TYPE_LIMIT, +// Query::TYPE_OFFSET => Base::METHOD_TYPE_OFFSET, +// Query::TYPE_CURSOR_AFTER, +// Query::TYPE_CURSOR_BEFORE => Base::METHOD_TYPE_CURSOR, +// Query::TYPE_ORDER_ASC, +// Query::TYPE_ORDER_DESC, +// Query::TYPE_ORDER_RANDOM => Base::METHOD_TYPE_ORDER, +// Query::TYPE_EQUAL, +// Query::TYPE_NOT_EQUAL, +// Query::TYPE_LESSER, +// Query::TYPE_LESSER_EQUAL, +// Query::TYPE_GREATER, +// Query::TYPE_GREATER_EQUAL, +// Query::TYPE_SEARCH, +// Query::TYPE_NOT_SEARCH, +// Query::TYPE_IS_NULL, +// Query::TYPE_IS_NOT_NULL, +// Query::TYPE_BETWEEN, +// Query::TYPE_NOT_BETWEEN, +// Query::TYPE_STARTS_WITH, +// Query::TYPE_NOT_STARTS_WITH, +// Query::TYPE_ENDS_WITH, +// Query::TYPE_NOT_ENDS_WITH, +// Query::TYPE_CONTAINS, +// Query::TYPE_NOT_CONTAINS, +// Query::TYPE_AND, +// Query::TYPE_OR, +// Query::TYPE_CROSSES, +// Query::TYPE_NOT_CROSSES, +// Query::TYPE_DISTANCE_EQUAL, +// Query::TYPE_DISTANCE_NOT_EQUAL, +// Query::TYPE_DISTANCE_GREATER_THAN, +// Query::TYPE_DISTANCE_LESS_THAN, +// Query::TYPE_INTERSECTS, +// Query::TYPE_NOT_INTERSECTS, +// Query::TYPE_OVERLAPS, +// Query::TYPE_NOT_OVERLAPS, +// Query::TYPE_TOUCHES, +// Query::TYPE_NOT_TOUCHES, +// Query::TYPE_VECTOR_DOT, +// Query::TYPE_VECTOR_COSINE, +// Query::TYPE_VECTOR_EUCLIDEAN => Base::METHOD_TYPE_FILTER, +// default => '', +// }; +// +// $methodIsValid = false; +// foreach ($this->validators as $validator) { +// if ($validator->getMethodType() !== $methodType) { +// continue; +// } +// if (!$validator->isValid($query)) { +// $this->message = 'Invalid query: ' . $validator->getDescription(); +// return false; +// } +// +// $methodIsValid = true; +// } +// +// if (!$methodIsValid) { +// $this->message = 'Invalid query method: ' . $method; +// return false; +// } +// } +// +// return true; +// } +// +// /** +// * Is array +// * +// * Function will return true if object is array. +// * +// * @return bool +// */ +// public function isArray(): bool +// { +// return true; +// } +// +// /** +// * Get Type +// * +// * Returns validator type. +// * +// * @return string +// */ +// public function getType(): string +// { +// return self::TYPE_OBJECT; +// } +//} diff --git a/src/Database/Validator/Queries/Document.php b/src/Database/Validator/Queries/Document.php index 5907c50e7..8edf478d3 100644 --- a/src/Database/Validator/Queries/Document.php +++ b/src/Database/Validator/Queries/Document.php @@ -1,44 +1,44 @@ $attributes - * @param bool $supportForAttributes - * @throws Exception - */ - public function __construct(array $attributes, bool $supportForAttributes = true) - { - $attributes[] = new \Utopia\Database\Document([ - '$id' => '$id', - 'key' => '$id', - 'type' => Database::VAR_STRING, - 'array' => false, - ]); - $attributes[] = new \Utopia\Database\Document([ - '$id' => '$createdAt', - 'key' => '$createdAt', - 'type' => Database::VAR_DATETIME, - 'array' => false, - ]); - $attributes[] = new \Utopia\Database\Document([ - '$id' => '$updatedAt', - 'key' => '$updatedAt', - 'type' => Database::VAR_DATETIME, - 'array' => false, - ]); - - $validators = [ - new Select($attributes, $supportForAttributes), - ]; - - parent::__construct($validators); - } -} +// +//namespace Utopia\Database\Validator\Queries; +// +//use Exception; +//use Utopia\Database\Database; +//use Utopia\Database\Validator\Queries; +//use Utopia\Database\Validator\Query\Select; +// +//class Document extends Queries +//{ +// /** +// * @param array $attributes +// * @throws Exception +// */ +// public function __construct(array $attributes) +// { +// $attributes[] = new \Utopia\Database\Document([ +// '$id' => '$id', +// 'key' => '$id', +// 'type' => Database::VAR_STRING, +// 'array' => false, +// ]); +// $attributes[] = new \Utopia\Database\Document([ +// '$id' => '$createdAt', +// 'key' => '$createdAt', +// 'type' => Database::VAR_DATETIME, +// 'array' => false, +// ]); +// $attributes[] = new \Utopia\Database\Document([ +// '$id' => '$updatedAt', +// 'key' => '$updatedAt', +// 'type' => Database::VAR_DATETIME, +// 'array' => false, +// ]); +// +// $validators = [ +// new Select($attributes), +// ]; +// +// parent::__construct($validators); +// } +//} diff --git a/src/Database/Validator/Queries/Documents.php b/src/Database/Validator/Queries/Documents.php index e55852bb8..22776a3eb 100644 --- a/src/Database/Validator/Queries/Documents.php +++ b/src/Database/Validator/Queries/Documents.php @@ -1,80 +1,81 @@ $attributes - * @param array $indexes - * @param string $idAttributeType - * @param int $maxValuesCount - * @param \DateTime $minAllowedDate - * @param \DateTime $maxAllowedDate - * @param bool $supportForAttributes - * @throws \Utopia\Database\Exception - */ - public function __construct( - array $attributes, - array $indexes, - string $idAttributeType, - int $maxValuesCount = 5000, - int $maxUIDLength = 36, - \DateTime $minAllowedDate = new \DateTime('0000-01-01'), - \DateTime $maxAllowedDate = new \DateTime('9999-12-31'), - bool $supportForAttributes = true - ) { - $attributes[] = new Document([ - '$id' => '$id', - 'key' => '$id', - 'type' => Database::VAR_STRING, - 'array' => false, - ]); - $attributes[] = new Document([ - '$id' => '$sequence', - 'key' => '$sequence', - 'type' => Database::VAR_ID, - 'array' => false, - ]); - $attributes[] = new Document([ - '$id' => '$createdAt', - 'key' => '$createdAt', - 'type' => Database::VAR_DATETIME, - 'array' => false, - ]); - $attributes[] = new Document([ - '$id' => '$updatedAt', - 'key' => '$updatedAt', - 'type' => Database::VAR_DATETIME, - 'array' => false, - ]); - - $validators = [ - new Limit(), - new Offset(), - new Cursor($maxUIDLength), - new Filter( - $attributes, - $idAttributeType, - $maxValuesCount, - $minAllowedDate, - $maxAllowedDate, - $supportForAttributes - ), - new Order($attributes, $supportForAttributes), - new Select($attributes, $supportForAttributes), - ]; - - parent::__construct($attributes, $indexes, $validators); - } -} +// +//namespace Utopia\Database\Validator\Queries; +// +//use Utopia\Database\Database; +//use Utopia\Database\Document; +//use Utopia\Database\Validator\IndexedQueries; +//use Utopia\Database\Validator\Query\Cursor; +//use Utopia\Database\Validator\Query\Filter; +//use Utopia\Database\Validator\Query\Limit; +//use Utopia\Database\Validator\Query\Offset; +//use Utopia\Database\Validator\Query\Order; +//use Utopia\Database\Validator\Query\Select; +// +//class Documents extends IndexedQueries +//{ +// /** +// * @param array $attributes +// * @param array $indexes +// * @param string $idAttributeType +// * @param int $maxValuesCount +// * @param \DateTime $minAllowedDate +// * @param \DateTime $maxAllowedDate +// * @param bool $supportForAttributes +// * @throws \Utopia\Database\Exception +// */ +// public function __construct( +// array $attributes, +// array $indexes, +// string $idAttributeType, +// int $maxValuesCount = 5000, +// int $maxUIDLength = 36, +// \DateTime $minAllowedDate = new \DateTime('0000-01-01'), +// \DateTime $maxAllowedDate = new \DateTime('9999-12-31'), +// bool $supportForAttributes = true +// ) { +// $attributes[] = new Document([ +// '$id' => '$id', +// 'key' => '$id', +// 'type' => Database::VAR_STRING, +// 'array' => false, +// ]); +// $attributes[] = new Document([ +// '$id' => '$sequence', +// 'key' => '$sequence', +// 'type' => Database::VAR_ID, +// 'array' => false, +// ]); +// $attributes[] = new Document([ +// '$id' => '$createdAt', +// 'key' => '$createdAt', +// 'type' => Database::VAR_DATETIME, +// 'array' => false, +// ]); +// $attributes[] = new Document([ +// '$id' => '$updatedAt', +// 'key' => '$updatedAt', +// 'type' => Database::VAR_DATETIME, +// 'array' => false, +// ]); +// +// $validators = [ +// new Limit(), +// new Offset(), +// new Cursor($maxUIDLength), +// new Filter( +// $attributes, +// $idAttributeType, +// $maxValuesCount, +// $minAllowedDate, +// $maxAllowedDate, +// $supportForAttributes +// ), +// new Order($attributes, $supportForAttributes), +// new Select($attributes, $supportForAttributes), +// ]; +// +// parent::__construct($attributes, $indexes, $validators); +// } +//} diff --git a/src/Database/Validator/Queries/V2.php b/src/Database/Validator/Queries/V2.php new file mode 100644 index 000000000..7660383f5 --- /dev/null +++ b/src/Database/Validator/Queries/V2.php @@ -0,0 +1,714 @@ + + */ + protected array $schema = []; + + protected int $maxQueriesCount; + + private int $maxValuesCount; + + protected int $maxLimit; + + protected int $maxOffset; + + protected QueryContext $context; + + protected \DateTime $minAllowedDate; + + protected \DateTime $maxAllowedDate; + protected string $idAttributeType; + protected int $vectors = 0; + + /** + * @var array + */ + protected array $joinsAliasOrder = [Query::DEFAULT_ALIAS]; + + /** + * @throws Exception + */ + public function __construct( + QueryContext $context, + string $idAttributeType, + int $maxValuesCount = 100, + int $maxQueriesCount = 0, + \DateTime $minAllowedDate = new \DateTime('0000-01-01'), + \DateTime $maxAllowedDate = new \DateTime('9999-12-31'), + int $maxLimit = PHP_INT_MAX, + int $maxOffset = PHP_INT_MAX, + protected bool $supportForAttributes = true, + protected int $maxUIDLength = Database::MAX_UID_DEFAULT_LENGTH + ) { + $this->context = $context; + $this->idAttributeType = $idAttributeType; + $this->maxQueriesCount = $maxQueriesCount; + $this->maxValuesCount = $maxValuesCount; + $this->maxLimit = $maxLimit; + $this->maxOffset = $maxOffset; + $this->minAllowedDate = $minAllowedDate; + $this->maxAllowedDate = $maxAllowedDate; + + // $validators = [ + // new Limit(), + // new Offset(), + // new Cursor(), + // new Filter($collections), + // new Order($collections), + // new Select($collections), + // new Join($collections), + // ]; + + /** + * Since $context includes Documents , clone if original data is changes. + */ + foreach ($context->getCollections() as $collection) { + $collection = clone $collection; + + $attributes = $collection->getAttribute('attributes', []); + + $attributes[] = new Document([ + '$id' => '$id', + 'key' => '$id', + 'type' => Database::VAR_STRING, + 'array' => false, + ]); + + $attributes[] = new Document([ + '$id' => '$sequence', + 'key' => '$sequence', + 'type' => Database::VAR_STRING, + 'array' => false, + ]); + + $attributes[] = new Document([ + '$id' => '$createdAt', + 'key' => '$createdAt', + 'type' => Database::VAR_DATETIME, + 'array' => false, + ]); + + $attributes[] = new Document([ + '$id' => '$updatedAt', + 'key' => '$updatedAt', + 'type' => Database::VAR_DATETIME, + 'array' => false, + ]); + + foreach ($attributes as $attribute) { + $key = $attribute->getAttribute('key', $attribute->getAttribute('$id')); + $this->schema[$collection->getId()][$key] = $attribute->getArrayCopy(); + } + } + } + + /** + * Get Description. + * + * Returns validator description + */ + public function getDescription(): string + { + return $this->message; + } + + /** + * Is array + * + * Function will return true if object is array. + */ + public function isArray(): bool + { + return true; + } + + /** + * Get Type + * + * Returns validator type. + */ + public function getType(): string + { + return self::TYPE_OBJECT; + } + + /** + * @param array $values + */ + protected function isEmpty(array $values): bool + { + if (count($values) === 0) { + return true; + } + + if (is_array($values[0]) && count($values[0]) === 0) { + return true; + } + + return false; + } + + /** + * @throws \Exception + */ + protected function validateAttributeExist(string $attributeId, string $alias, string $method = ''): void + { + /** + * This is for making query::select('$permissions')) pass + */ + if ($attributeId === '$permissions' || $attributeId === '$collection') { + return; + } + + $collection = $this->context->getCollectionByAlias($alias); + if ($collection->isEmpty()) { + throw new \Exception('Invalid query: Unknown Alias context'); + } + + $isNested = false; + + + if (\str_contains($attributeId, '.')) { + /** + * This attribute name has a special symbol `.` or is a relationship + */ + if (empty($this->schema[$collection->getId()][$attributeId])) { + /** + * relationships, just validate the top level. + * will validate each nested level during the recursive calls. + */ + $attributeId = \explode('.', $attributeId)[0]; + $isNested = true; + } + } + + $attribute = $this->schema[$collection->getId()][$attributeId] ?? []; + if (empty($attribute) && $this->supportForAttributes) { + throw new \Exception('Invalid query: Attribute not found in schema: '.$attributeId); + } + + if (\in_array('encrypt', $attribute['filters'] ?? [])) { + throw new \Exception('Cannot query encrypted attribute: ' . $attributeId); + } + + if ($isNested && $attribute['type'] != Database::VAR_RELATIONSHIP) { + throw new \Exception('Only nested relationships allowed'); + } + + if ($isNested && \in_array($method, [Query::TYPE_ORDER_ASC, Query::TYPE_ORDER_DESC])) { + throw new \Exception('Cannot order by nested attribute: ' . $attributeId); + } + } + + /** + * @throws \Exception + */ + protected function validateAlias(Query $query): void + { + $validator = new AliasValidator(); + + if (! $validator->isValid($query->getAlias())) { + throw new \Exception('Query '.\ucfirst($query->getMethod()).': '.$validator->getDescription()); + } + + if (! $validator->isValid($query->getRightAlias())) { + throw new \Exception('Query '.\ucfirst($query->getMethod()).': '.$validator->getDescription()); + } + } + + /** + * @throws \Exception + */ + protected function validateFilterQueries(Query $query): void + { + $filters = Query::getFilterQueries($query->getValues()); + + if (count($query->getValues()) !== count($filters)) { + throw new \Exception('Invalid query: '.\ucfirst($query->getMethod()).' queries can only contain filter queries'); + } + } + + /** + * @param string $attributeId + * @param string $alias + * @param array $values + * @param string $method + * @return void + * @throws \Exception + */ + protected function validateValues(string $attributeId, string $alias, array $values, string $method): void + { + if (count($values) > $this->maxValuesCount) { + throw new \Exception('Invalid query: Query on attribute has greater than '.$this->maxValuesCount.' values: '.$attributeId); + } + + $collection = $this->context->getCollectionByAlias($alias); + if ($collection->isEmpty()) { + throw new \Exception('Unknown Alias context'); + } + + $isNested = false; + + if (\str_contains($attributeId, '.')) { + /** + * This attribute name has a special symbol `.` or is a relationship + */ + if (empty($this->schema[$collection->getId()][$attributeId])) { + /** + * relationships, just validate the top level. + * will validate each nested level during the recursive calls. + */ + $attributeId = \explode('.', $attributeId)[0]; + $isNested = true; + } + } + + $attribute = $this->schema[$collection->getId()][$attributeId] ?? []; + if (empty($attribute) && !$this->supportForAttributes) { + return; + } + + /** + * Skip value validation for nested relationship queries (e.g., author.age) + * The values will be validated when querying the related collection + */ + if ($attribute['type'] === Database::VAR_RELATIONSHIP && $isNested) { + return; + } + + $array = $attribute['array'] ?? false; + $size = $attribute['size'] ?? 0; + + if (Query::isSpatialQuery($method) && !in_array($attribute['type'], Database::SPATIAL_TYPES, true)) { + /** + * If the query method is spatial-only, the attribute must be a spatial type + */ + throw new \Exception('Invalid query: Spatial query "' . $method . '" cannot be applied on non-spatial attribute: ' . $attributeId); + } + + if (Query::isVectorQuery($method) && $attribute['type'] !== Database::VAR_VECTOR) { + throw new \Exception('Vector queries can only be used on vector attributes'); + } + + foreach ($values as $value) { + $validator = null; + + switch ($attribute['type']) { + case Database::VAR_ID: + $validator = new Sequence($this->idAttributeType, $attributeId === '$sequence'); + break; + + case Database::VAR_STRING: + $validator = new Text(0, 0); + break; + + case Database::VAR_INTEGER: + $validator = new Integer(); + break; + + case Database::VAR_FLOAT: + $validator = new FloatValidator(); + break; + + case Database::VAR_BOOLEAN: + $validator = new Boolean(); + break; + + case Database::VAR_DATETIME: + $validator = new DatetimeValidator( + min: $this->minAllowedDate, + max: $this->maxAllowedDate + ); + break; + + case Database::VAR_RELATIONSHIP: + $validator = new Text(255, 0); // The query is always on uid + break; + + case Database::VAR_OBJECT: + // value for object can be of any type as its a hashmap + // eg; ['key'=>value'] + continue 2; + + case Database::VAR_POINT: + case Database::VAR_LINESTRING: + case Database::VAR_POLYGON: + if (!is_array($value)) { + throw new \Exception('Spatial data must be an array'); + } + + continue 2; + + case Database::VAR_VECTOR: + if ($this->vectors > 0) { + throw new \Exception('Cannot use multiple vector queries in a single request'); + } + + $this->vectors++; + + // For vector queries, validate that the value is an array of floats + if (!is_array($value)) { + throw new \Exception('Vector query value must be an array'); + } + + foreach ($value as $component) { + if (!is_numeric($component)) { + throw new \Exception('Vector query value must contain only numeric values'); + } + } + // Check size match + if (count($value) !== $size) { + throw new \Exception("Vector query value must have {$size} elements"); + } + + continue 2; + default: + throw new \Exception('Unknown Data type'); + } + + if (! $validator->isValid($value)) { + throw new \Exception('Invalid query: Query value is invalid for attribute "'.$attributeId.'"'); + } + } + + if ($attribute['type'] === 'relationship') { + /** + * We can not disable relationship query since we have logic that use it, + * so instead we validate against the relation type + */ + $options = $attribute['options']; + + if ($options['relationType'] === Database::RELATION_ONE_TO_ONE && $options['twoWay'] === false && $options['side'] === Database::RELATION_SIDE_CHILD) { + throw new \Exception('Cannot query on virtual relationship attribute'); + } + + if ($options['relationType'] === Database::RELATION_ONE_TO_MANY && $options['side'] === Database::RELATION_SIDE_PARENT) { + throw new \Exception('Cannot query on virtual relationship attribute'); + } + + if ($options['relationType'] === Database::RELATION_MANY_TO_ONE && $options['side'] === Database::RELATION_SIDE_CHILD) { + throw new \Exception('Cannot query on virtual relationship attribute'); + } + + if ($options['relationType'] === Database::RELATION_MANY_TO_MANY) { + throw new \Exception('Cannot query on virtual relationship attribute'); + } + } + + if ( + ! $array && + in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS]) && + $attribute['type'] !== Database::VAR_STRING && + $attribute['type'] !== Database::VAR_OBJECT && + !in_array($attribute['type'], Database::SPATIAL_TYPES) + ) { + throw new \Exception('Invalid query: Cannot query '.$method.' on attribute "'.$attributeId.'" because it is not an array, string, or object.'); + } + + if ( + $array && + !in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS, Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL]) + ) { + throw new \Exception('Invalid query: Cannot query '.$method.' on attribute "'.$attributeId.'" because it is an array.'); + } + } + + /** + * @throws \Exception + */ + public function validateFulltextIndex(Query $query): void + { + if (!in_array($query->getMethod(), [Query::TYPE_SEARCH, Query::TYPE_NOT_SEARCH])) { + return; + } + + $collection = $this->context->getCollectionByAlias($query->getAlias()); + if ($collection->isEmpty()) { + throw new \Exception('Unknown Alias context'); + } + + $indexes = $collection->getAttribute('indexes', []); + + foreach ($indexes as $index) { + if ( + $index->getAttribute('type') === Database::INDEX_FULLTEXT && + $index->getAttribute('attributes') === [$query->getAttribute()] + ) { + return; + } + } + + throw new \Exception('Searching by attribute "'.$query->getAttribute().'" requires a fulltext index.'); + } + + /** + * @param array $queries + * @param string $alias + * @return bool + */ + public function isRelationExist(array $queries, string $alias): bool + { + foreach ($queries as $query) { + if ($query->isNested()) { + if ($this->isRelationExist($query->getValues(), $alias)) { + return true; + } + } + + if ($query->getMethod() === Query::TYPE_RELATION_EQUAL) { + if ($query->getAlias() === $alias || $query->getRightAlias() === $alias) { + return true; + } + } + } + + return false; + } + + /** + * @param array $value + * + * @throws \Utopia\Database\Exception\Query|\Throwable + */ + public function isValid($value, string $scope = ''): bool + { + try { + if (! is_array($value)) { + throw new \Exception('Queries must be an array'); + } + + if (! array_is_list($value)) { + throw new \Exception('Queries must be an array list'); + } + + if ($this->maxQueriesCount > 0 && \count($value) > $this->maxQueriesCount) { + throw new \Exception('Queries count is greater than '.$this->maxQueriesCount); + } + + foreach ($value as $query) { + if (!$query instanceof Query) { + try { + $query = Query::parse($query); + } catch (\Throwable $e) { + throw new \Exception('Invalid query: ' . $e->getMessage()); + } + } + + $this->validateAlias($query); + + if ($query->isNested()) { + if (! $this->isValid($query->getValues(), $scope)) { + throw new \Exception($this->message); + } + } + + if ($scope === 'joins') { + if (!in_array($query->getAlias(), $this->joinsAliasOrder) || !in_array($query->getRightAlias(), $this->joinsAliasOrder)) { + throw new \Exception('Invalid query: '.\ucfirst($query->getMethod()).' alias reference in join has not been defined.'); + } + } + + $method = $query->getMethod(); + + switch ($method) { + case Query::TYPE_EQUAL: + case Query::TYPE_CONTAINS: + case Query::TYPE_NOT_CONTAINS: + if ($this->isEmpty($query->getValues())) { + throw new \Exception('Invalid query: '.\ucfirst($method).' queries require at least one value.'); + } + + $this->validateAttributeExist($query->getAttribute(), $query->getAlias()); + $this->validateValues($query->getAttribute(), $query->getAlias(), $query->getValues(), $method); + + break; + + case Query::TYPE_DISTANCE_EQUAL: + case Query::TYPE_DISTANCE_NOT_EQUAL: + case Query::TYPE_DISTANCE_GREATER_THAN: + case Query::TYPE_DISTANCE_LESS_THAN: + if (count($query->getValues()) !== 1 || !is_array($query->getValues()[0]) || count($query->getValues()[0]) !== 3) { + throw new \Exception('Distance query requires [[geometry, distance]] parameters'); + } + + $this->validateAttributeExist($query->getAttribute(), $query->getAlias()); + $this->validateValues($query->getAttribute(), $query->getAlias(), $query->getValues(), $method); + break; + + case Query::TYPE_CROSSES: + case Query::TYPE_NOT_CROSSES: + case Query::TYPE_INTERSECTS: + case Query::TYPE_NOT_INTERSECTS: + case Query::TYPE_OVERLAPS: + case Query::TYPE_NOT_OVERLAPS: + case Query::TYPE_TOUCHES: + case Query::TYPE_NOT_TOUCHES : + if ($this->isEmpty($query->getValues())) { + throw new \Exception('Invalid query: '.\ucfirst($method).' queries require at least one value.'); + } + + $this->validateAttributeExist($query->getAttribute(), $query->getAlias()); + $this->validateValues($query->getAttribute(), $query->getAlias(), $query->getValues(), $method); + break; + + case Query::TYPE_NOT_EQUAL: + case Query::TYPE_LESSER: + case Query::TYPE_LESSER_EQUAL: + case Query::TYPE_GREATER: + case Query::TYPE_GREATER_EQUAL: + case Query::TYPE_SEARCH: + case Query::TYPE_NOT_SEARCH: + case Query::TYPE_STARTS_WITH: + case Query::TYPE_NOT_STARTS_WITH: + case Query::TYPE_ENDS_WITH: + case Query::TYPE_NOT_ENDS_WITH: + if (count($query->getValues()) != 1) { + throw new \Exception('Invalid query: '.\ucfirst($method).' queries require exactly one value.'); + } + + $this->validateAttributeExist($query->getAttribute(), $query->getAlias()); + $this->validateValues($query->getAttribute(), $query->getAlias(), $query->getValues(), $method); + $this->validateFulltextIndex($query); + + break; + case Query::TYPE_BETWEEN: + case Query::TYPE_NOT_BETWEEN: + if (count($query->getValues()) != 2) { + throw new \Exception('Invalid query: '.\ucfirst($method).' queries require exactly two values.'); + } + + $this->validateAttributeExist($query->getAttribute(), $query->getAlias()); + $this->validateValues($query->getAttribute(), $query->getAlias(), $query->getValues(), $method); + + break; + case Query::TYPE_IS_NULL: + case Query::TYPE_IS_NOT_NULL: + $this->validateAttributeExist($query->getAttribute(), $query->getAlias()); + $this->validateValues($query->getAttribute(), $query->getAlias(), $query->getValues(), $method); + + break; + case Query::TYPE_OR: + case Query::TYPE_AND: + $this->validateFilterQueries($query); + $filters = Query::getFilterQueries($query->getValues()); + + if (count($filters) < 2) { + throw new \Exception('Invalid query: '.\ucfirst($method).' queries require at least two queries'); + } + + break; + case Query::TYPE_INNER_JOIN: + case Query::TYPE_LEFT_JOIN: + case Query::TYPE_RIGHT_JOIN: + $this->joinsAliasOrder[] = $query->getAlias(); + + $this->validateFilterQueries($query); + + if (! $this->isValid($query->getValues(), 'joins')) { + throw new \Exception($this->message); + } + + if (! $this->isRelationExist($query->getValues(), $query->getAlias())) { + throw new \Exception('Invalid query: At least one relation query is required on the joined collection.'); + } + + break; + case Query::TYPE_RELATION_EQUAL: + if ($scope !== 'joins') { + throw new \Exception('Invalid query: Relations are only valid within joins.'); + } + + $this->validateAttributeExist($query->getAttribute(), $query->getAlias()); + $this->validateAttributeExist($query->getAttributeRight(), $query->getRightAlias()); + + break; + case Query::TYPE_LIMIT: + $validator = new Limit($this->maxLimit); + if (! $validator->isValid($query)) { + throw new \Exception($validator->getDescription()); + } + + break; + case Query::TYPE_OFFSET: + $validator = new Offset($this->maxOffset); + if (! $validator->isValid($query)) { + throw new \Exception($validator->getDescription()); + } + + break; + case Query::TYPE_SELECT: + $asValidator = new AsValidator($query->getAttribute()); + if (! $asValidator->isValid($query->getAs())) { + throw new \Exception('Invalid query: '.\ucfirst($method).' '.$asValidator->getDescription()); + } + + if ($query->getAttribute() !== '*') { + $this->validateAttributeExist($query->getAttribute(), $query->getAlias()); + } + + break; + case Query::TYPE_ORDER_RANDOM: + + break; + case Query::TYPE_ORDER_ASC: + case Query::TYPE_ORDER_DESC: + $this->validateAttributeExist($query->getAttribute(), $query->getAlias(), $query->getMethod()); + + break; + case Query::TYPE_CURSOR_AFTER: + case Query::TYPE_CURSOR_BEFORE: + $validator = new Cursor($this->maxUIDLength); + if (! $validator->isValid($query)) { + throw new \Exception($validator->getDescription()); + } + + break; + case Query::TYPE_VECTOR_DOT: + case Query::TYPE_VECTOR_COSINE: + case Query::TYPE_VECTOR_EUCLIDEAN: + $this->validateAttributeExist($query->getAttribute(), $query->getAlias()); + + if (count($query->getValues()) != 1) { + throw new \Exception(\ucfirst($method) . ' queries require exactly one vector value.'); + } + + $this->validateValues($query->getAttribute(), $query->getAlias(), $query->getValues(), $method); + break; + default: + throw new \Exception('Invalid query: Method not found '); + } + } + + } catch (\Throwable $e) { + $this->message = $e->getMessage(); + return false; + } + + return true; + } +} diff --git a/src/Database/Validator/Query/Base.php b/src/Database/Validator/Query/Base.php index a37fdd65a..6b40c37af 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_JOIN = 'join'; protected string $message = 'Invalid query'; diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 11053f14c..af982db4b 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -1,422 +1,423 @@ - */ - protected array $schema = []; - - /** - * @param array $attributes - * @param int $maxValuesCount - * @param \DateTime $minAllowedDate - * @param \DateTime $maxAllowedDate - */ - public function __construct( - array $attributes, - private readonly string $idAttributeType, - private readonly int $maxValuesCount = 5000, - private readonly \DateTime $minAllowedDate = new \DateTime('0000-01-01'), - private readonly \DateTime $maxAllowedDate = new \DateTime('9999-12-31'), - private bool $supportForAttributes = true - ) { - foreach ($attributes as $attribute) { - $this->schema[$attribute->getAttribute('key', $attribute->getId())] = $attribute->getArrayCopy(); - } - } - - /** - * @param string $attribute - * @return bool - */ - protected function isValidAttribute(string $attribute): bool - { - if ( - \in_array('encrypt', $this->schema[$attribute]['filters'] ?? []) - ) { - $this->message = 'Cannot query encrypted attribute: ' . $attribute; - return false; - } - - if (\str_contains($attribute, '.')) { - // Check for special symbol `.` - if (isset($this->schema[$attribute])) { - return true; - } - - // For relationships, just validate the top level. - // will validate each nested level during the recursive calls. - $attribute = \explode('.', $attribute)[0]; - } - - // Search for attribute in schema - if ($this->supportForAttributes && !isset($this->schema[$attribute])) { - $this->message = 'Attribute not found in schema: ' . $attribute; - return false; - } - - return true; - } - - /** - * @param string $attribute - * @param array $values - * @param string $method - * @return bool - */ - protected function isValidAttributeAndValues(string $attribute, array $values, string $method): bool - { - if (!$this->isValidAttribute($attribute)) { - return false; - } - - $originalAttribute = $attribute; - // isset check if for special symbols "." in the attribute name - if (\str_contains($attribute, '.') && !isset($this->schema[$attribute])) { - // For relationships, just validate the top level. - // Utopia will validate each nested level during the recursive calls. - $attribute = \explode('.', $attribute)[0]; - } - - if (!$this->supportForAttributes && !isset($this->schema[$attribute])) { - // First check maxValuesCount guard for any IN-style value arrays - if (count($values) > $this->maxValuesCount) { - $this->message = 'Query on attribute has greater than ' . $this->maxValuesCount . ' values: ' . $attribute; - return false; - } - - return true; - } - $attributeSchema = $this->schema[$attribute]; - - // Skip value validation for nested relationship queries (e.g., author.age) - // The values will be validated when querying the related collection - if ($attributeSchema['type'] === Database::VAR_RELATIONSHIP && $originalAttribute !== $attribute) { - return true; - } - - if (count($values) > $this->maxValuesCount) { - $this->message = 'Query on attribute has greater than ' . $this->maxValuesCount . ' values: ' . $attribute; - return false; - } - - if (!$this->supportForAttributes && !isset($this->schema[$attribute])) { - return true; - } - $attributeSchema = $this->schema[$attribute]; - - $attributeType = $attributeSchema['type']; - - // If the query method is spatial-only, the attribute must be a spatial type - $query = new Query($method); - if ($query->isSpatialQuery() && !in_array($attributeType, Database::SPATIAL_TYPES, true)) { - $this->message = 'Spatial query "' . $method . '" cannot be applied on non-spatial attribute: ' . $attribute; - return false; - } - - foreach ($values as $value) { - $validator = null; - - switch ($attributeType) { - case Database::VAR_ID: - $validator = new Sequence($this->idAttributeType, $attribute === '$sequence'); - break; - - case Database::VAR_STRING: - $validator = new Text(0, 0); - break; - - case Database::VAR_INTEGER: - $validator = new Integer(); - break; - - case Database::VAR_FLOAT: - $validator = new FloatValidator(); - break; - - case Database::VAR_BOOLEAN: - $validator = new Boolean(); - break; - - case Database::VAR_DATETIME: - $validator = new DatetimeValidator( - min: $this->minAllowedDate, - max: $this->maxAllowedDate - ); - break; - - case Database::VAR_RELATIONSHIP: - $validator = new Text(255, 0); // The query is always on uid - break; - - case Database::VAR_OBJECT: - // value for object can be of any type as its a hashmap - // eg; ['key'=>value'] - continue 2; - - case Database::VAR_POINT: - case Database::VAR_LINESTRING: - case Database::VAR_POLYGON: - if (!is_array($value)) { - $this->message = 'Spatial data must be an array'; - return false; - } - continue 2; - - case Database::VAR_VECTOR: - // For vector queries, validate that the value is an array of floats - if (!is_array($value)) { - $this->message = 'Vector query value must be an array'; - return false; - } - foreach ($value as $component) { - if (!is_numeric($component)) { - $this->message = 'Vector query value must contain only numeric values'; - return false; - } - } - // Check size match - $expectedSize = $attributeSchema['size'] ?? 0; - if (count($value) !== $expectedSize) { - $this->message = "Vector query value must have {$expectedSize} elements"; - return false; - } - continue 2; - default: - $this->message = 'Unknown Data type'; - return false; - } - - if (!$validator->isValid($value)) { - $this->message = 'Query value is invalid for attribute "' . $attribute . '"'; - return false; - } - } - - if ($attributeSchema['type'] === 'relationship') { - /** - * We can not disable relationship query since we have logic that use it, - * so instead we validate against the relation type - */ - $options = $attributeSchema['options']; - - if ($options['relationType'] === Database::RELATION_ONE_TO_ONE && $options['twoWay'] === false && $options['side'] === Database::RELATION_SIDE_CHILD) { - $this->message = 'Cannot query on virtual relationship attribute'; - return false; - } - - if ($options['relationType'] === Database::RELATION_ONE_TO_MANY && $options['side'] === Database::RELATION_SIDE_PARENT) { - $this->message = 'Cannot query on virtual relationship attribute'; - return false; - } - - if ($options['relationType'] === Database::RELATION_MANY_TO_ONE && $options['side'] === Database::RELATION_SIDE_CHILD) { - $this->message = 'Cannot query on virtual relationship attribute'; - return false; - } - - if ($options['relationType'] === Database::RELATION_MANY_TO_MANY) { - $this->message = 'Cannot query on virtual relationship attribute'; - return false; - } - } - - $array = $attributeSchema['array'] ?? false; - - if ( - !$array && - in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS]) && - $attributeSchema['type'] !== Database::VAR_STRING && - $attributeSchema['type'] !== Database::VAR_OBJECT && - !in_array($attributeSchema['type'], Database::SPATIAL_TYPES) - ) { - $queryType = $method === Query::TYPE_NOT_CONTAINS ? 'notContains' : 'contains'; - $this->message = 'Cannot query ' . $queryType . ' on attribute "' . $attribute . '" because it is not an array, string, or object.'; - return false; - } - - if ( - $array && - !in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS, Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL]) - ) { - $this->message = 'Cannot query '. $method .' on attribute "' . $attribute . '" because it is an array.'; - return false; - } - - // Vector queries can only be used on vector attributes (not arrays) - if (\in_array($method, Query::VECTOR_TYPES)) { - if ($attributeSchema['type'] !== Database::VAR_VECTOR) { - $this->message = 'Vector queries can only be used on vector attributes'; - return false; - } - if ($array) { - $this->message = 'Vector queries cannot be used on array attributes'; - return false; - } - } - - return true; - } - - /** - * @param array $values - * @return bool - */ - protected function isEmpty(array $values): bool - { - if (count($values) === 0) { - return true; - } - - if (is_array($values[0]) && count($values[0]) === 0) { - return true; - } - - return false; - } - - /** - * Is valid. - * - * Returns true if method is a filter method, attribute exists, and value matches attribute type - * - * Otherwise, returns false - * - * @param Query $value - * @return bool - */ - public function isValid($value): bool - { - $method = $value->getMethod(); - $attribute = $value->getAttribute(); - switch ($method) { - case Query::TYPE_EQUAL: - case Query::TYPE_CONTAINS: - case Query::TYPE_NOT_CONTAINS: - if ($this->isEmpty($value->getValues())) { - $this->message = \ucfirst($method) . ' queries require at least one value.'; - return false; - } - - return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); - - case Query::TYPE_DISTANCE_EQUAL: - case Query::TYPE_DISTANCE_NOT_EQUAL: - case Query::TYPE_DISTANCE_GREATER_THAN: - case Query::TYPE_DISTANCE_LESS_THAN: - if (count($value->getValues()) !== 1 || !is_array($value->getValues()[0]) || count($value->getValues()[0]) !== 3) { - $this->message = 'Distance query requires [[geometry, distance]] parameters'; - return false; - } - return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); - - case Query::TYPE_NOT_EQUAL: - case Query::TYPE_LESSER: - case Query::TYPE_LESSER_EQUAL: - case Query::TYPE_GREATER: - case Query::TYPE_GREATER_EQUAL: - case Query::TYPE_SEARCH: - case Query::TYPE_NOT_SEARCH: - case Query::TYPE_STARTS_WITH: - case Query::TYPE_NOT_STARTS_WITH: - case Query::TYPE_ENDS_WITH: - case Query::TYPE_NOT_ENDS_WITH: - if (count($value->getValues()) != 1) { - $this->message = \ucfirst($method) . ' queries require exactly one value.'; - return false; - } - - return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); - - case Query::TYPE_BETWEEN: - case Query::TYPE_NOT_BETWEEN: - if (count($value->getValues()) != 2) { - $this->message = \ucfirst($method) . ' queries require exactly two values.'; - return false; - } - - return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); - - case Query::TYPE_IS_NULL: - case Query::TYPE_IS_NOT_NULL: - return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); - - case Query::TYPE_VECTOR_DOT: - case Query::TYPE_VECTOR_COSINE: - case Query::TYPE_VECTOR_EUCLIDEAN: - // Validate that the attribute is a vector type - if (!$this->isValidAttribute($attribute)) { - return false; - } - - // Handle dotted attributes (relationships) - $attributeKey = $attribute; - if (\str_contains($attributeKey, '.') && !isset($this->schema[$attributeKey])) { - $attributeKey = \explode('.', $attributeKey)[0]; - } - - $attributeSchema = $this->schema[$attributeKey]; - if ($attributeSchema['type'] !== Database::VAR_VECTOR) { - $this->message = 'Vector queries can only be used on vector attributes'; - return false; - } - - if (count($value->getValues()) != 1) { - $this->message = \ucfirst($method) . ' queries require exactly one vector value.'; - return false; - } - - return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); - case Query::TYPE_OR: - case Query::TYPE_AND: - $filters = Query::groupByType($value->getValues())['filters']; - - if (count($value->getValues()) !== count($filters)) { - $this->message = \ucfirst($method) . ' queries can only contain filter queries'; - return false; - } - - if (count($filters) < 2) { - $this->message = \ucfirst($method) . ' queries require at least two queries'; - return false; - } - - return true; - - default: - // Handle spatial query types and any other query types - if ($value->isSpatialQuery()) { - if ($this->isEmpty($value->getValues())) { - $this->message = \ucfirst($method) . ' queries require at least one value.'; - return false; - } - return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); - } - - return false; - } - } - - public function getMaxValuesCount(): int - { - return $this->maxValuesCount; - } - - public function getMethodType(): string - { - return self::METHOD_TYPE_FILTER; - } -} +// +//namespace Utopia\Database\Validator\Query; +// +//use Utopia\Database\Database; +//use Utopia\Database\Document; +//use Utopia\Database\Query; +//use Utopia\Database\Validator\Datetime as DatetimeValidator; +//use Utopia\Database\Validator\Sequence; +//use Utopia\Validator\Boolean; +//use Utopia\Validator\FloatValidator; +//use Utopia\Validator\Integer; +//use Utopia\Validator\Text; +// +//class Filter extends Base +//{ +// /** +// * @var array +// */ +// protected array $schema = []; +// +// /** +// * @param array $attributes +// * @param int $maxValuesCount +// * @param \DateTime $minAllowedDate +// * @param \DateTime $maxAllowedDate +// */ +// public function __construct( +// array $attributes, +// private readonly string $idAttributeType, +// private readonly int $maxValuesCount = 5000, +// private readonly \DateTime $minAllowedDate = new \DateTime('0000-01-01'), +// private readonly \DateTime $maxAllowedDate = new \DateTime('9999-12-31'), +// private bool $supportForAttributes = true +// ) { +// foreach ($attributes as $attribute) { +// $this->schema[$attribute->getAttribute('key', $attribute->getId())] = $attribute->getArrayCopy(); +// } +// } +// +// /** +// * @param string $attribute +// * @return bool +// */ +// protected function isValidAttribute(string $attribute): bool +// { +// if ( +// \in_array('encrypt', $this->schema[$attribute]['filters'] ?? []) +// ) { +// $this->message = 'Cannot query encrypted attribute: ' . $attribute; +// return false; +// } +// +// if (\str_contains($attribute, '.')) { +// // Check for special symbol `.` +// if (isset($this->schema[$attribute])) { +// return true; +// } +// +// // For relationships, just validate the top level. +// // will validate each nested level during the recursive calls. +// $attribute = \explode('.', $attribute)[0]; +// } +// +// // Search for attribute in schema +// if ($this->supportForAttributes && !isset($this->schema[$attribute])) { +// $this->message = 'Attribute not found in schema: ' . $attribute; +// return false; +// } +// +// return true; +// } +// +// /** +// * @param string $attribute +// * @param array $values +// * @param string $method +// * @return bool +// */ +// protected function isValidAttributeAndValues(string $attribute, array $values, string $method): bool +// { +// if (!$this->isValidAttribute($attribute)) { +// return false; +// } +// +// $originalAttribute = $attribute; +// // isset check if for special symbols "." in the attribute name +// if (\str_contains($attribute, '.') && !isset($this->schema[$attribute])) { +// // For relationships, just validate the top level. +// // Utopia will validate each nested level during the recursive calls. +// $attribute = \explode('.', $attribute)[0]; +// } +// +// if (!$this->supportForAttributes && !isset($this->schema[$attribute])) { +// // First check maxValuesCount guard for any IN-style value arrays +// if (count($values) > $this->maxValuesCount) { +// $this->message = 'Query on attribute has greater than ' . $this->maxValuesCount . ' values: ' . $attribute; +// return false; +// } +// +// return true; +// } +// $attributeSchema = $this->schema[$attribute]; +// +// // Skip value validation for nested relationship queries (e.g., author.age) +// // The values will be validated when querying the related collection +// if ($attributeSchema['type'] === Database::VAR_RELATIONSHIP && $originalAttribute !== $attribute) { +// return true; +// } +// +// if (count($values) > $this->maxValuesCount) { +// $this->message = 'Query on attribute has greater than ' . $this->maxValuesCount . ' values: ' . $attribute; +// return false; +// } +// +// if (!$this->supportForAttributes && !isset($this->schema[$attribute])) { +// return true; +// } +// $attributeSchema = $this->schema[$attribute]; +// +// $attributeType = $attributeSchema['type']; +// +// // If the query method is spatial-only, the attribute must be a spatial type +// $query = new Query($method); +// if ($query->isSpatialQuery() && !in_array($attributeType, Database::SPATIAL_TYPES, true)) { +// $this->message = 'Spatial query "' . $method . '" cannot be applied on non-spatial attribute: ' . $attribute; +// return false; +// } +// +// foreach ($values as $value) { +// $validator = null; +// +// switch ($attributeType) { +// case Database::VAR_ID: +// $validator = new Sequence($this->idAttributeType, $attribute === '$sequence'); +// break; +// +// case Database::VAR_STRING: +// $validator = new Text(0, 0); +// break; +// +// case Database::VAR_INTEGER: +// $validator = new Integer(); +// break; +// +// case Database::VAR_FLOAT: +// $validator = new FloatValidator(); +// break; +// +// case Database::VAR_BOOLEAN: +// $validator = new Boolean(); +// break; +// +// case Database::VAR_DATETIME: +// $validator = new DatetimeValidator( +// min: $this->minAllowedDate, +// max: $this->maxAllowedDate +// ); +// break; +// +// case Database::VAR_RELATIONSHIP: +// $validator = new Text(255, 0); // The query is always on uid +// break; +// +// case Database::VAR_OBJECT: +// // value for object can be of any type as its a hashmap +// // eg; ['key'=>value'] +// continue 2; +// +// case Database::VAR_POINT: +// case Database::VAR_LINESTRING: +// case Database::VAR_POLYGON: +// if (!is_array($value)) { +// $this->message = 'Spatial data must be an array'; +// return false; +// } +// continue 2; +// +// case Database::VAR_VECTOR: +// // For vector queries, validate that the value is an array of floats +// if (!is_array($value)) { +// $this->message = 'Vector query value must be an array'; +// return false; +// } +// foreach ($value as $component) { +// if (!is_numeric($component)) { +// $this->message = 'Vector query value must contain only numeric values'; +// return false; +// } +// } +// // Check size match +// $expectedSize = $attributeSchema['size'] ?? 0; +// if (count($value) !== $expectedSize) { +// $this->message = "Vector query value must have {$expectedSize} elements"; +// return false; +// } +// continue 2; +// default: +// $this->message = 'Unknown Data type'; +// return false; +// } +// +// if (!$validator->isValid($value)) { +// $this->message = 'Query value is invalid for attribute "' . $attribute . '"'; +// return false; +// } +// } +// +// if ($attributeSchema['type'] === 'relationship') { +// /** +// * We can not disable relationship query since we have logic that use it, +// * so instead we validate against the relation type +// */ +// $options = $attributeSchema['options']; +// +// if ($options['relationType'] === Database::RELATION_ONE_TO_ONE && $options['twoWay'] === false && $options['side'] === Database::RELATION_SIDE_CHILD) { +// $this->message = 'Cannot query on virtual relationship attribute'; +// return false; +// } +// +// if ($options['relationType'] === Database::RELATION_ONE_TO_MANY && $options['side'] === Database::RELATION_SIDE_PARENT) { +// $this->message = 'Cannot query on virtual relationship attribute'; +// return false; +// } +// +// if ($options['relationType'] === Database::RELATION_MANY_TO_ONE && $options['side'] === Database::RELATION_SIDE_CHILD) { +// $this->message = 'Cannot query on virtual relationship attribute'; +// return false; +// } +// +// if ($options['relationType'] === Database::RELATION_MANY_TO_MANY) { +// $this->message = 'Cannot query on virtual relationship attribute'; +// return false; +// } +// } +// +// $array = $attributeSchema['array'] ?? false; +// +// if ( +// !$array && +// in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS]) && +// $attributeSchema['type'] !== Database::VAR_STRING && +// $attributeSchema['type'] !== Database::VAR_OBJECT && +// !in_array($attributeSchema['type'], Database::SPATIAL_TYPES) +// ) { +// $queryType = $method === Query::TYPE_NOT_CONTAINS ? 'notContains' : 'contains'; +// $this->message = 'Cannot query ' . $queryType . ' on attribute "' . $attribute . '" because it is not an array, string, or object.'; +// return false; +// } +// +// if ( +// $array && +// !in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS, Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL]) +// ) { +// $this->message = 'Cannot query '. $method .' on attribute "' . $attribute . '" because it is an array.'; +// return false; +// } +// +// // Vector queries can only be used on vector attributes (not arrays) +// if (\in_array($method, Query::VECTOR_TYPES)) { +// if ($attributeSchema['type'] !== Database::VAR_VECTOR) { +// $this->message = 'Vector queries can only be used on vector attributes'; +// return false; +// } +// if ($array) { +// $this->message = 'Vector queries cannot be used on array attributes'; +// return false; +// } +// } +// +// return true; +// } +// +// /** +// * @param array $values +// * @return bool +// */ +// protected function isEmpty(array $values): bool +// { +// if (count($values) === 0) { +// return true; +// } +// +// if (is_array($values[0]) && count($values[0]) === 0) { +// return true; +// } +// +// return false; +// } +// +// /** +// * Is valid. +// * +// * Returns true if method is a filter method, attribute exists, and value matches attribute type +// * +// * Otherwise, returns false +// * +// * @param Query $value +// * @return bool +// */ +// public function isValid($value): bool +// { +// $method = $value->getMethod(); +// $attribute = $value->getAttribute(); +// switch ($method) { +// case Query::TYPE_EQUAL: +// case Query::TYPE_CONTAINS: +// case Query::TYPE_NOT_CONTAINS: +// if ($this->isEmpty($value->getValues())) { +// $this->message = \ucfirst($method) . ' queries require at least one value.'; +// return false; +// } +// +// return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); +// +// case Query::TYPE_DISTANCE_EQUAL: +// case Query::TYPE_DISTANCE_NOT_EQUAL: +// case Query::TYPE_DISTANCE_GREATER_THAN: +// case Query::TYPE_DISTANCE_LESS_THAN: +// if (count($value->getValues()) !== 1 || !is_array($value->getValues()[0]) || count($value->getValues()[0]) !== 3) { +// $this->message = 'Distance query requires [[geometry, distance]] parameters'; +// return false; +// } +// return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); +// +// case Query::TYPE_NOT_EQUAL: +// case Query::TYPE_LESSER: +// case Query::TYPE_LESSER_EQUAL: +// case Query::TYPE_GREATER: +// case Query::TYPE_GREATER_EQUAL: +// case Query::TYPE_SEARCH: +// case Query::TYPE_NOT_SEARCH: +// case Query::TYPE_STARTS_WITH: +// case Query::TYPE_NOT_STARTS_WITH: +// case Query::TYPE_ENDS_WITH: +// case Query::TYPE_NOT_ENDS_WITH: +// if (count($value->getValues()) != 1) { +// $this->message = \ucfirst($method) . ' queries require exactly one value.'; +// return false; +// } +// +// return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); +// +// case Query::TYPE_BETWEEN: +// case Query::TYPE_NOT_BETWEEN: +// if (count($value->getValues()) != 2) { +// $this->message = \ucfirst($method) . ' queries require exactly two values.'; +// return false; +// } +// +// return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); +// +// case Query::TYPE_IS_NULL: +// case Query::TYPE_IS_NOT_NULL: +// return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); +// +// case Query::TYPE_VECTOR_DOT: +// case Query::TYPE_VECTOR_COSINE: +// case Query::TYPE_VECTOR_EUCLIDEAN: +// // Validate that the attribute is a vector type +// if (!$this->isValidAttribute($attribute)) { +// return false; +// } +// +// // Handle dotted attributes (relationships) +// $attributeKey = $attribute; +// if (\str_contains($attributeKey, '.') && !isset($this->schema[$attributeKey])) { +// $attributeKey = \explode('.', $attributeKey)[0]; +// } +// +// $attributeSchema = $this->schema[$attributeKey]; +// if ($attributeSchema['type'] !== Database::VAR_VECTOR) { +// $this->message = 'Vector queries can only be used on vector attributes'; +// return false; +// } +// +// if (count($value->getValues()) != 1) { +// $this->message = \ucfirst($method) . ' queries require exactly one vector value.'; +// return false; +// } +// +// return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); +// case Query::TYPE_OR: +// case Query::TYPE_AND: +// $filters = Query::groupByType($value->getValues())['filters']; +// +// if (count($value->getValues()) !== count($filters)) { +// $this->message = \ucfirst($method) . ' queries can only contain filter queries'; +// return false; +// } +// +// if (count($filters) < 2) { +// $this->message = \ucfirst($method) . ' queries require at least two queries'; +// return false; +// } +// +// return true; +// +// default: +// // Handle spatial query types and any other query types +// if ($value->isSpatialQuery()) { +// if ($this->isEmpty($value->getValues())) { +// $this->message = \ucfirst($method) . ' queries require at least one value.'; +// return false; +// } +// return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); +// } +// +// return false; +// } +// } +// +// public function getMaxValuesCount(): int +// { +// return $this->maxValuesCount; +// } +// +// public function getMethodType(): string +// { +// return self::METHOD_TYPE_FILTER; +// } +//} diff --git a/src/Database/Validator/Query/Offset.php b/src/Database/Validator/Query/Offset.php index 8d59be4d0..8b302be47 100644 --- a/src/Database/Validator/Query/Offset.php +++ b/src/Database/Validator/Query/Offset.php @@ -39,7 +39,7 @@ public function isValid($value): bool $validator = new Numeric(); if (!$validator->isValid($offset)) { - $this->message = 'Invalid limit: ' . $validator->getDescription(); + $this->message = 'Invalid offset: ' . $validator->getDescription(); return false; } diff --git a/src/Database/Validator/Query/Order.php b/src/Database/Validator/Query/Order.php index 5d9970a01..8d5f6b10e 100644 --- a/src/Database/Validator/Query/Order.php +++ b/src/Database/Validator/Query/Order.php @@ -1,91 +1,92 @@ - */ - protected array $schema = []; - - /** - * @param array $attributes - * @param bool $supportForAttributes - */ - public function __construct(array $attributes = [], protected bool $supportForAttributes = true) - { - foreach ($attributes as $attribute) { - $this->schema[$attribute->getAttribute('key', $attribute->getAttribute('$id'))] = $attribute->getArrayCopy(); - } - } - - /** - * @param string $attribute - * @return bool - */ - protected function isValidAttribute(string $attribute): bool - { - if (\str_contains($attribute, '.')) { - // Check for special symbol `.` - if (isset($this->schema[$attribute])) { - return true; - } - - // For relationships, just validate the top level. - // Will validate each nested level during the recursive calls. - $attribute = \explode('.', $attribute)[0]; - - if (isset($this->schema[$attribute])) { - $this->message = 'Cannot order by nested attribute: ' . $attribute; - return false; - } - } - - // Search for attribute in schema - if ($this->supportForAttributes && !isset($this->schema[$attribute])) { - $this->message = 'Attribute not found in schema: ' . $attribute; - return false; - } - - return true; - } - - /** - * Is valid. - * - * Returns true if method is ORDER_ASC or ORDER_DESC and attributes are valid - * - * Otherwise, returns false - * - * @param Query $value - * @return bool - */ - public function isValid($value): bool - { - if (!$value instanceof Query) { - return false; - } - - $method = $value->getMethod(); - $attribute = $value->getAttribute(); - - if ($method === Query::TYPE_ORDER_ASC || $method === Query::TYPE_ORDER_DESC) { - return $this->isValidAttribute($attribute); - } - - if ($method === Query::TYPE_ORDER_RANDOM) { - return true; // orderRandom doesn't need an attribute - } - - return false; - } - - public function getMethodType(): string - { - return self::METHOD_TYPE_ORDER; - } -} +// +//namespace Utopia\Database\Validator\Query; +// +//use Utopia\Database\Document; +//use Utopia\Database\Query; +// +//class Order extends Base +//{ +// /** +// * @var array +// */ +// protected array $schema = []; +// +// /** +// * @param array $attributes +// * @param bool $supportForAttributes +// */ +// public function __construct(array $attributes = [], protected bool $supportForAttributes = true) +// { +// foreach ($attributes as $attribute) { +// $this->schema[$attribute->getAttribute('key', $attribute->getAttribute('$id'))] = $attribute->getArrayCopy(); +// } +// } +// +// /** +// * @param string $attribute +// * @return bool +// */ +// protected function isValidAttribute(string $attribute): bool +// { +// if (\str_contains($attribute, '.')) { +// // Check for special symbol `.` +// if (isset($this->schema[$attribute])) { +// return true; +// } +// +// // For relationships, just validate the top level. +// // Will validate each nested level during the recursive calls. +// $attribute = \explode('.', $attribute)[0]; +// +// if (isset($this->schema[$attribute])) { +// $this->message = 'Cannot order by nested attribute: ' . $attribute; +// return false; +// } +// } +// +// // Search for attribute in schema +// if ($this->supportForAttributes && !isset($this->schema[$attribute])) { +// $this->message = 'Attribute not found in schema: ' . $attribute; +// return false; +// } +// +// return true; +// } +// +// /** +// * Is valid. +// * +// * Returns true if method is ORDER_ASC or ORDER_DESC and attributes are valid +// * +// * Otherwise, returns false +// * +// * @param Query $value +// * @return bool +// */ +// public function isValid($value): bool +// { +// if (!$value instanceof Query) { +// return false; +// } +// +// $method = $value->getMethod(); +// $attribute = $value->getAttribute(); +// +// if ($method === Query::TYPE_ORDER_ASC || $method === Query::TYPE_ORDER_DESC) { +// return $this->isValidAttribute($attribute); +// } +// +// if ($method === Query::TYPE_ORDER_RANDOM) { +// return true; // orderRandom doesn't need an attribute +// } +// +// return false; +// } +// +// public function getMethodType(): string +// { +// return self::METHOD_TYPE_ORDER; +// } +//} diff --git a/src/Database/Validator/Query/Select.php b/src/Database/Validator/Query/Select.php index b0ed9e564..75fb9702e 100644 --- a/src/Database/Validator/Query/Select.php +++ b/src/Database/Validator/Query/Select.php @@ -1,105 +1,106 @@ - */ - protected array $schema = []; - - /** - * List of internal attributes - * - * @var array - */ - protected const INTERNAL_ATTRIBUTES = [ - '$id', - '$sequence', - '$createdAt', - '$updatedAt', - '$permissions', - '$collection', - ]; - - /** - * @param array $attributes - * @param bool $supportForAttributes - */ - public function __construct(array $attributes = [], protected bool $supportForAttributes = true) - { - foreach ($attributes as $attribute) { - $this->schema[$attribute->getAttribute('key', $attribute->getAttribute('$id'))] = $attribute->getArrayCopy(); - } - } - - /** - * Is valid. - * - * Returns true if method is TYPE_SELECT selections are valid - * - * Otherwise, returns false - * - * @param Query $value - * @return bool - */ - public function isValid($value): bool - { - if (!$value instanceof Query) { - return false; - } - - if ($value->getMethod() !== Query::TYPE_SELECT) { - return false; - } - - $internalKeys = \array_map( - fn ($attr) => $attr['$id'], - Database::INTERNAL_ATTRIBUTES - ); - - if (\count($value->getValues()) === 0) { - $this->message = 'No attributes selected'; - return false; - } - - if (\count($value->getValues()) !== \count(\array_unique($value->getValues()))) { - $this->message = 'Duplicate attributes selected'; - return false; - - } - foreach ($value->getValues() as $attribute) { - if (\str_contains($attribute, '.')) { - //special symbols with `dots` - if (isset($this->schema[$attribute])) { - continue; - } - - // For relationships, just validate the top level. - // Will validate each nested level during the recursive calls. - $attribute = \explode('.', $attribute)[0]; - } - - // Skip internal attributes - if (\in_array($attribute, $internalKeys)) { - continue; - } - - if ($this->supportForAttributes && !isset($this->schema[$attribute]) && $attribute !== '*') { - $this->message = 'Attribute not found in schema: ' . $attribute; - return false; - } - } - return true; - } - - public function getMethodType(): string - { - return self::METHOD_TYPE_SELECT; - } -} +// +//namespace Utopia\Database\Validator\Query; +// +//use Utopia\Database\Database; +//use Utopia\Database\Document; +//use Utopia\Database\Query; +// +//class Select extends Base +//{ +// /** +// * @var array +// */ +// protected array $schema = []; +// +// /** +// * List of internal attributes +// * +// * @var array +// */ +// protected const INTERNAL_ATTRIBUTES = [ +// '$id', +// '$sequence', +// '$createdAt', +// '$updatedAt', +// '$permissions', +// '$collection', +// ]; +// +// /** +// * @param array $attributes +// * @param bool $supportForAttributes +// */ +// public function __construct(array $attributes = [], protected bool $supportForAttributes = true) +// { +// foreach ($attributes as $attribute) { +// $this->schema[$attribute->getAttribute('key', $attribute->getAttribute('$id'))] = $attribute->getArrayCopy(); +// } +// } +// +// /** +// * Is valid. +// * +// * Returns true if method is TYPE_SELECT selections are valid +// * +// * Otherwise, returns false +// * +// * @param Query $value +// * @return bool +// */ +// public function isValid($value): bool +// { +// if (!$value instanceof Query) { +// return false; +// } +// +// if ($value->getMethod() !== Query::TYPE_SELECT) { +// return false; +// } +// +// $internalKeys = \array_map( +// fn ($attr) => $attr['$id'], +// Database::INTERNAL_ATTRIBUTES +// ); +// +// if (\count($value->getValues()) === 0) { +// $this->message = 'No attributes selected'; +// return false; +// } +// +// if (\count($value->getValues()) !== \count(\array_unique($value->getValues()))) { +// $this->message = 'Duplicate attributes selected'; +// return false; +// +// } +// foreach ($value->getValues() as $attribute) { +// if (\str_contains($attribute, '.')) { +// //special symbols with `dots` +// if (isset($this->schema[$attribute])) { +// continue; +// } +// +// // For relationships, just validate the top level. +// // Will validate each nested level during the recursive calls. +// $attribute = \explode('.', $attribute)[0]; +// } +// +// // Skip internal attributes +// if (\in_array($attribute, $internalKeys)) { +// continue; +// } +// +// if ($this->supportForAttributes && !isset($this->schema[$attribute]) && $attribute !== '*') { +// $this->message = 'Attribute not found in schema: ' . $attribute; +// return false; +// } +// } +// return true; +// } +// +// public function getMethodType(): string +// { +// return self::METHOD_TYPE_SELECT; +// } +//} diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index 4baeba35b..58b8b01a0 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -9,6 +9,7 @@ use Tests\E2E\Adapter\Scopes\DocumentTests; use Tests\E2E\Adapter\Scopes\GeneralTests; use Tests\E2E\Adapter\Scopes\IndexTests; +use Tests\E2E\Adapter\Scopes\JoinsTests; use Tests\E2E\Adapter\Scopes\ObjectAttributeTests; use Tests\E2E\Adapter\Scopes\OperatorTests; use Tests\E2E\Adapter\Scopes\PermissionTests; @@ -23,6 +24,7 @@ abstract class Base extends TestCase { + use JoinsTests; use CollectionTests; use CustomDocumentTypeTests; use DocumentTests; diff --git a/tests/e2e/Adapter/MirrorTest.php b/tests/e2e/Adapter/MirrorTest.php index 31bf3f3b6..4db613997 100644 --- a/tests/e2e/Adapter/MirrorTest.php +++ b/tests/e2e/Adapter/MirrorTest.php @@ -76,7 +76,8 @@ protected function getDatabase(bool $fresh = false): Mirror 'schema1', 'schema2', 'sharedTables', - 'sharedTablesTenantPerDocument' + 'sharedTablesTenantPerDocument', + 'hellodb' ]; /** diff --git a/tests/e2e/Adapter/Scopes/AttributeTests.php b/tests/e2e/Adapter/Scopes/AttributeTests.php index f62c94fe8..7cd5e38c0 100644 --- a/tests/e2e/Adapter/Scopes/AttributeTests.php +++ b/tests/e2e/Adapter/Scopes/AttributeTests.php @@ -62,6 +62,11 @@ public function testCreateDeleteAttribute(): void $database->createCollection('attributes'); + $collection = $database->getCollection('attributes'); + $this->assertEquals([], $collection->getAttribute('attributes')); + $this->assertEquals(true, $collection->getAttribute('documentSecurity')); + $this->assertEquals(['create("any")'], $collection->getAttribute('$permissions')); + $this->assertEquals(true, $database->createAttribute('attributes', 'string1', Database::VAR_STRING, 128, true)); $this->assertEquals(true, $database->createAttribute('attributes', 'string2', Database::VAR_STRING, 16382 + 1, true)); $this->assertEquals(true, $database->createAttribute('attributes', 'string3', Database::VAR_STRING, 65535 + 1, true)); @@ -237,7 +242,7 @@ public function testAttributeNamesWithDots(): void )); $document = $database->find('dots.parent', [ - Query::select(['dots.name']), + Query::select('dots.name'), ]); $this->assertEmpty($document); @@ -278,7 +283,7 @@ public function testAttributeNamesWithDots(): void ])); $documents = $database->find('dots.parent', [ - Query::select(['*']), + Query::select('*'), ]); $this->assertEquals('Bill clinton', $documents[0]['dots.name']); diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index ce809f426..4a19cdc36 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -522,8 +522,12 @@ public function testPurgeCollectionCache(): void $this->assertEquals(true, $database->createAttribute('redis', 'age', Database::VAR_INTEGER, 0, true)); $document = $database->getDocument('redis', 'doc1'); + $this->assertEquals('Richard', $document->getAttribute('name')); - $this->assertArrayHasKey('age', $document); + + if ($this->getDatabase()->getAdapter()->getSupportForSchemas()) { + $this->assertArrayHasKey('age', $document); // Issue in Mongo with Document Decode , Since no attribute exist + } } public function testSchemaAttributes(): void diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 0765ca0a1..2a432d2eb 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -19,6 +19,7 @@ use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; +use Utopia\Database\QueryContext; trait DocumentTests { @@ -1436,7 +1437,8 @@ public function testGetDocumentSelect(Document $document): Document $database = $this->getDatabase(); $document = $database->getDocument('documents', $documentId, [ - Query::select(['string', 'integer_signed']), + Query::select('string'), + Query::select('integer_signed'), ]); $this->assertFalse($document->isEmpty()); @@ -1448,22 +1450,24 @@ public function testGetDocumentSelect(Document $document): Document $this->assertArrayNotHasKey('boolean', $document->getAttributes()); $this->assertArrayNotHasKey('colors', $document->getAttributes()); $this->assertArrayNotHasKey('with-dash', $document->getAttributes()); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); + $this->assertArrayNotHasKey('$id', $document); + $this->assertArrayNotHasKey('$sequence', $document); + $this->assertArrayNotHasKey('$createdAt', $document); + $this->assertArrayNotHasKey('$updatedAt', $document); + $this->assertArrayNotHasKey('$permissions', $document); $this->assertArrayHasKey('$collection', $document); $document = $database->getDocument('documents', $documentId, [ - Query::select(['string', 'integer_signed', '$id']), + Query::select('string'), + Query::select('integer_signed'), + Query::select('$id'), ]); $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); + $this->assertArrayNotHasKey('$sequence', $document); + $this->assertArrayNotHasKey('$createdAt', $document); + $this->assertArrayNotHasKey('$updatedAt', $document); + $this->assertArrayNotHasKey('$permissions', $document); $this->assertArrayHasKey('$collection', $document); $this->assertArrayHasKey('string', $document); $this->assertArrayHasKey('integer_signed', $document); @@ -1471,6 +1475,42 @@ public function testGetDocumentSelect(Document $document): Document return $document; } + + /** + * @depends testCreateDocument + */ + public function testGetDocumentOnlySelectQueries(Document $document): Document + { + $documentId = $document->getId(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + $invalidMessage = 'Only Select queries are permitted'; + + try { + $database->getDocument('documents', $documentId, [ + Query::equal('$id', ['id']), + ]); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertEquals($invalidMessage, $e->getMessage()); + $this->assertTrue($e instanceof DatabaseException); + } + + try { + $database->getDocument('documents', $documentId, [ + Query::limit(1), + ]); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertEquals($invalidMessage, $e->getMessage()); + $this->assertTrue($e instanceof DatabaseException); + } + + return $document; + } + /** * @return array */ @@ -1954,6 +1994,12 @@ public function testFindFulltext(): void $this->assertEquals(2, count($documents)); + $documents = $database->find('movies', [ + Query::notSearch('name', 'captain'), + ]); + + $this->assertEquals(4, count($documents)); + /** * Fulltext search (wildcard) */ @@ -3146,7 +3192,7 @@ public function testOrNested(): void $database = $this->getDatabase(); $queries = [ - Query::select(['director']), + Query::select('director'), Query::equal('director', ['Joe Johnston']), Query::or([ Query::equal('name', ['Frozen']), @@ -3742,7 +3788,8 @@ public function testFindSelect(): void $database = $this->getDatabase(); $documents = $database->find('movies', [ - Query::select(['name', 'year']) + Query::select('name'), + Query::select('year') ]); foreach ($documents as $document) { @@ -3751,16 +3798,18 @@ public function testFindSelect(): void $this->assertArrayNotHasKey('director', $document); $this->assertArrayNotHasKey('price', $document); $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayNotHasKey('$id', $document); + $this->assertArrayNotHasKey('$sequence', $document); $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); + $this->assertArrayNotHasKey('$createdAt', $document); + $this->assertArrayNotHasKey('$updatedAt', $document); + $this->assertArrayNotHasKey('$permissions', $document); } $documents = $database->find('movies', [ - Query::select(['name', 'year', '$id']) + Query::select('name'), + Query::select('year'), + Query::select('$id') ]); foreach ($documents as $document) { @@ -3770,15 +3819,17 @@ public function testFindSelect(): void $this->assertArrayNotHasKey('price', $document); $this->assertArrayNotHasKey('active', $document); $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayNotHasKey('$sequence', $document); $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); + $this->assertArrayNotHasKey('$createdAt', $document); + $this->assertArrayNotHasKey('$updatedAt', $document); + $this->assertArrayNotHasKey('$permissions', $document); } $documents = $database->find('movies', [ - Query::select(['name', 'year', '$sequence']) + Query::select('name'), + Query::select('year'), + Query::select('$sequence') ]); foreach ($documents as $document) { @@ -3787,16 +3838,18 @@ public function testFindSelect(): void $this->assertArrayNotHasKey('director', $document); $this->assertArrayNotHasKey('price', $document); $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); + $this->assertArrayNotHasKey('$id', $document); $this->assertArrayHasKey('$sequence', $document); $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); + $this->assertArrayNotHasKey('$createdAt', $document); + $this->assertArrayNotHasKey('$updatedAt', $document); + $this->assertArrayNotHasKey('$permissions', $document); } $documents = $database->find('movies', [ - Query::select(['name', 'year', '$collection']) + Query::select('name'), + Query::select('year'), + Query::select('$collection') ]); foreach ($documents as $document) { @@ -3805,16 +3858,18 @@ public function testFindSelect(): void $this->assertArrayNotHasKey('director', $document); $this->assertArrayNotHasKey('price', $document); $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayNotHasKey('$id', $document); + $this->assertArrayNotHasKey('$sequence', $document); $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); + $this->assertArrayNotHasKey('$createdAt', $document); + $this->assertArrayNotHasKey('$updatedAt', $document); + $this->assertArrayNotHasKey('$permissions', $document); } - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$createdAt']) + $documents = static::getDatabase()->find('movies', [ + Query::select('name'), + Query::select('year'), + Query::select('$createdAt') ]); foreach ($documents as $document) { @@ -3823,16 +3878,18 @@ public function testFindSelect(): void $this->assertArrayNotHasKey('director', $document); $this->assertArrayNotHasKey('price', $document); $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayNotHasKey('$id', $document); + $this->assertArrayNotHasKey('$sequence', $document); $this->assertArrayHasKey('$collection', $document); $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); + $this->assertArrayNotHasKey('$updatedAt', $document); + $this->assertArrayNotHasKey('$permissions', $document); } $documents = $database->find('movies', [ - Query::select(['name', 'year', '$updatedAt']) + Query::select('name'), + Query::select('year'), + Query::select('$updatedAt') ]); foreach ($documents as $document) { @@ -3841,16 +3898,18 @@ public function testFindSelect(): void $this->assertArrayNotHasKey('director', $document); $this->assertArrayNotHasKey('price', $document); $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayNotHasKey('$id', $document); + $this->assertArrayNotHasKey('$sequence', $document); $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); + $this->assertArrayNotHasKey('$createdAt', $document); $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); + $this->assertArrayNotHasKey('$permissions', $document); } - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$permissions']) + $documents = static::getDatabase()->find('movies', [ + Query::select('name'), + Query::select('year'), + Query::select('$permissions') ]); foreach ($documents as $document) { @@ -3859,11 +3918,11 @@ public function testFindSelect(): void $this->assertArrayNotHasKey('director', $document); $this->assertArrayNotHasKey('price', $document); $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayNotHasKey('$id', $document); + $this->assertArrayNotHasKey('$sequence', $document); $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); + $this->assertArrayNotHasKey('$createdAt', $document); + $this->assertArrayNotHasKey('$updatedAt', $document); $this->assertArrayHasKey('$permissions', $document); } } @@ -4215,7 +4274,10 @@ public function testEncodeDecode(): void $this->assertEquals(['admin', 'developer', 'tester',], $result->getAttribute('roles')); $this->assertEquals(['{"$id":"1","label":"x"}', '{"$id":"2","label":"y"}', '{"$id":"3","label":"z"}',], $result->getAttribute('tags')); - $result = $database->decode($collection, $document); + $context = new QueryContext(); + $context->add($collection); + + $result = $database->decode($context, $document); $this->assertEquals('608fdbe51361a', $result->getAttribute('$id')); $this->assertContains('read("any")', $result->getAttribute('$permissions')); @@ -4958,7 +5020,8 @@ public function testDeleteBulkDocuments(): void $count = $database->deleteDocuments( collection: 'bulk_delete', queries: [ - Query::select([...$selects, '$createdAt']), + Query::select('$createdAt'), + ...array_map(fn ($f) => Query::select($f), $selects), Query::cursorAfter($docs[6]), Query::greaterThan('$createdAt', '2000-01-01'), Query::orderAsc('$createdAt'), @@ -5166,7 +5229,8 @@ public function testDeleteBulkDocumentsWithCallbackSupport(): void $database->deleteDocuments( collection: 'bulk_delete_with_callback', queries: [ - Query::select([...$selects, '$createdAt']), + ...array_map(fn ($f) => Query::select($f), $selects), + Query::select('$createdAt'), Query::lessThan('$createdAt', '1800-01-01'), Query::orderAsc('$createdAt'), Query::orderAsc(), @@ -5188,7 +5252,8 @@ public function testDeleteBulkDocumentsWithCallbackSupport(): void $count = $database->deleteDocuments( collection: 'bulk_delete_with_callback', queries: [ - Query::select([...$selects, '$createdAt']), + ...array_map(fn ($f) => Query::select($f), $selects), + Query::select('$createdAt'), Query::cursorAfter($docs[6]), Query::greaterThan('$createdAt', '2000-01-01'), Query::orderAsc('$createdAt'), @@ -5440,7 +5505,7 @@ public function testEmptyTenant(): void if ($database->getAdapter()->getSharedTables()) { $documents = $database->find( 'documents', - [Query::select(['*'])] // Mongo bug with Integer UID + [Query::select('*')] // Mongo bug with Integer UID ); $document = $documents[0]; diff --git a/tests/e2e/Adapter/Scopes/JoinsTests.php b/tests/e2e/Adapter/Scopes/JoinsTests.php new file mode 100644 index 000000000..4a49f5fa1 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/JoinsTests.php @@ -0,0 +1,855 @@ +getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + //Authorization::setRole('user:bob'); + + $db->createCollection('__users'); + $db->createAttribute('__users', 'username', Database::VAR_STRING, 100, false); + $db->createAttribute('__users', 'bank_internal_id', Database::VAR_INTEGER, 8, false); + + $db->createCollection('__sessions'); + $db->createAttribute('__sessions', 'user_id', Database::VAR_STRING, 100, false); + $db->createAttribute('__sessions', 'user_internal_id', Database::VAR_ID, 8, false); + $db->createAttribute('__sessions', 'boolean', Database::VAR_BOOLEAN, 0, false); + $db->createAttribute('__sessions', 'float', Database::VAR_FLOAT, 0, false); + + $db->createCollection( + '__banks', + permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], + documentSecurity: false + ); + $db->createAttribute('__banks', 'name', Database::VAR_STRING, 100, false); + + $bank1 = $db->createDocument('__banks', new Document([ + 'name' => 'Chase' + ])); + + $user1 = $db->createDocument('__users', new Document([ + 'username' => 'Donald', + 'bank_internal_id' => (int)$bank1->getSequence(), + '$permissions' => [ + Permission::read(Role::any()), + Permission::read(Role::user('bob')), + ], + ])); + + $sessionNoPermissions = $db->createDocument('__sessions', new Document([ + 'user_id' => $user1->getId(), + 'user_internal_id' => $user1->getSequence(), + '$permissions' => [], + ])); + + /** + * Test $session1 does not have read permissions + * Test right attribute is internal attribute + */ + $documents = $db->find( + '__users', + [ + Query::join( + '__sessions', + 'B', + [ + Query::relationEqual('B', 'user_id', '', '$id'), + ] + ), + ] + ); + $this->assertCount(0, $documents); + + $session2 = $db->createDocument('__sessions', new Document([ + 'user_id' => $user1->getId(), + 'user_internal_id' => $user1->getSequence(), + '$permissions' => [ + Permission::read(Role::any()), + ], + 'boolean' => false, + 'float' => 10.5, + ])); + + $user2 = $db->createDocument('__users', new Document([ + 'username' => 'Abraham', + + '$permissions' => [ + Permission::read(Role::any()), + Permission::read(Role::user('bob')), + ], + ])); + + $session3 = $db->createDocument('__sessions', new Document([ + 'user_id' => $user2->getId(), + 'user_internal_id' => $user2->getSequence(), + '$permissions' => [ + Permission::read(Role::any()), + ], + 'boolean' => true, + 'float' => 5.5, + ])); + + /** + * Test $session2 has read permissions + * Test right attribute is internal attribute + */ + $documents = $db->find( + '__users', + [ + Query::join( + '__sessions', + 'B', + [ + Query::relationEqual('B', 'user_id', '', '$id'), + ] + ), + ] + ); + $this->assertCount(2, $documents); + + $documents = $db->find( + '__users', + [ + Query::join( + '__sessions', + 'B', + [ + Query::relationEqual('B', 'user_id', '', '$id'), + Query::equal('user_id', [$user1->getId()], 'B'), + ] + ), + ] + ); + $this->assertCount(1, $documents); + + /** + * Test alias does not exist + */ + try { + $db->find( + '__sessions', + [ + Query::equal('user_id', ['bob'], 'alias_not_found') + ] + ); + $this->fail('Failed to throw exception'); + } catch (\Throwable $e) { + $this->assertTrue($e instanceof QueryException); + $this->assertEquals('Invalid query: Unknown Alias context', $e->getMessage()); + } + + /** + * Test Ambiguous alias + */ + try { + $db->find('__users', [ + Query::join('__sessions', Query::DEFAULT_ALIAS) + ]); + $this->fail('Failed to throw exception'); + } catch (\Throwable $e) { + $this->assertTrue($e instanceof QueryException); + $this->assertEquals('Ambiguous alias for collection "__sessions".', $e->getMessage()); + } + + /** + * Test relation query exist, but not on the join alias + */ + try { + $db->find( + '__users', + [ + Query::join( + '__sessions', + 'B', + [ + Query::relationEqual('', '$id', '', '$id'), + ] + ), + ] + ); + $this->fail('Failed to throw exception'); + } catch (\Throwable $e) { + $this->assertTrue($e instanceof QueryException); + $this->assertEquals('Invalid query: At least one relation query is required on the joined collection.', $e->getMessage()); + } + + /** + * Test if relation query exists in the join queries list + */ + try { + $db->find( + '__users', + [ + Query::join('__sessions', 'B', []), + ] + ); + $this->fail('Failed to throw exception'); + } catch (\Throwable $e) { + $this->assertTrue($e instanceof QueryException); + $this->assertEquals('Invalid query: At least one relation query is required on the joined collection.', $e->getMessage()); + } + + /** + * Test allow only filter queries in joins ON clause + */ + try { + $db->find( + '__users', + [ + Query::join('__sessions', 'B', [ + Query::orderAsc() + ]), + ] + ); + $this->fail('Failed to throw exception'); + } catch (\Throwable $e) { + $this->assertTrue($e instanceof QueryException); + $this->assertEquals('Invalid query: InnerJoin queries can only contain filter queries', $e->getMessage()); + } + + /** + * Test Relations are valid within joins + */ + try { + $db->find( + '__users', + [ + Query::relationEqual('', '$id', '', '$sequence'), + ] + ); + $this->fail('Failed to throw exception'); + } catch (\Throwable $e) { + $this->assertTrue($e instanceof QueryException); + $this->assertEquals('Invalid query: Relations are only valid within joins.', $e->getMessage()); + } + + /** + * Test invalid alias name + */ + try { + $alias = 'drop schema;'; + $db->find( + '__users', + [ + Query::join( + '__sessions', + $alias, + [ + Query::relationEqual($alias, 'user_id', '', '$id'), + ] + ), + ] + ); + $this->fail('Failed to throw exception'); + } catch (\Throwable $e) { + $this->assertTrue($e instanceof QueryException); + $this->assertEquals('Query InnerJoin: Alias must contain at most 64 chars. Valid chars are a-z, A-Z, 0-9, and underscore.', $e->getMessage()); + } + + /** + * Test join same collection + */ + $documents = $db->find( + '__users', + [ + Query::join( + '__sessions', + 'B', + [ + Query::relationEqual('B', 'user_id', '', '$id'), + ] + ), + Query::join( + '__sessions', + 'C', + [ + Query::relationEqual('C', 'user_id', 'B', 'user_id'), + ] + ), + ] + ); + $this->assertCount(2, $documents); + + /** + * Test order by related collection + */ + $documents = $db->find( + '__users', + [ + Query::join( + '__sessions', + 'B', + [ + Query::relationEqual('B', 'user_id', '', '$id'), + ] + ), + Query::orderAsc('$createdAt', 'B') + ] + ); + $this->assertEquals('Donald', $documents[0]['username']); + $this->assertEquals('Abraham', $documents[1]['username']); + + $documents = $db->find( + '__users', + [ + Query::join( + '__sessions', + 'B', + [ + Query::relationEqual('B', 'user_id', '', '$id'), + ] + ), + Query::orderDesc('$createdAt', 'B') + ] + ); + $this->assertEquals('Abraham', $documents[0]['username']); + $this->assertEquals('Donald', $documents[1]['username']); + + /** + * Select queries + */ + $documents = $db->find( + '__users', + [ + Query::select('*', 'main'), + Query::select('user_id', 'S'), + Query::select('float', 'S'), + Query::select('boolean', 'S'), + Query::join( + '__sessions', + 'S', + [ + Query::relationEqual('', '$id', 'S', 'user_id'), + Query::greaterThan('float', 1.1, 'S'), + ] + ), + Query::orderDesc('float', 'S'), + ] + ); + + /** + * Since we use main.* we should see all attributes + */ + $document = $documents[0]; + $this->assertArrayHasKey('$id', $document); + $this->assertIsFloat($document->getAttribute('float')); + $this->assertEquals(10.5, $document->getAttribute('float')); + $this->assertIsBool($document->getAttribute('boolean')); + $this->assertEquals(false, $document->getAttribute('boolean')); + + /** + * Test invalid as + */ + try { + $db->find('__users', [ + Query::select('$id', as: 'truncate schema;'), + ]); + $this->fail('Failed to throw exception'); + } catch (\Throwable $e) { + $this->assertTrue($e instanceof QueryException); + $this->assertEquals('Invalid query: Select "as" must contain at most 64 chars. Valid chars are a-z, A-Z, 0-9, and underscore.', $e->getMessage()); + } + + try { + $db->find('__users', [ + Query::select('*', as: 'as'), + ]); + $this->fail('Failed to throw exception'); + } catch (\Throwable $e) { + $this->assertTrue($e instanceof QueryException); + $this->assertEquals('Invalid query: Select Invalid "as" on attribute "*"', $e->getMessage()); + } + + /** + * Simple `as` query getDocument + */ + $document = $db->getDocument( + '__sessions', + $session2->getId(), + [ + Query::select('$permissions', as: '___permissions'), + Query::select('$id', as: '___uid'), + Query::select('$sequence', as: '___id'), + Query::select('$createdAt', as: '___created'), + Query::select('user_id', as: 'user_id_as'), + Query::select('float', as: 'float_as'), + Query::select('boolean', as: 'boolean_as'), + ] + ); + + $this->assertArrayNotHasKey('$permissions', $document); + $this->assertArrayHasKey('___permissions', $document); + $this->assertArrayHasKey('___uid', $document); + $this->assertArrayNotHasKey('$id', $document); + $this->assertArrayHasKey('___id', $document); + $this->assertArrayNotHasKey('$sequence', $document); + $this->assertArrayHasKey('___created', $document); + $this->assertArrayNotHasKey('$createdAt', $document); + $this->assertArrayHasKey('user_id_as', $document); + $this->assertArrayNotHasKey('user_id', $document); + $this->assertArrayHasKey('float_as', $document); + $this->assertArrayNotHasKey('float', $document); + $this->assertIsFloat($document->getAttribute('float_as')); + $this->assertEquals(10.5, $document->getAttribute('float_as')); + $this->assertArrayHasKey('boolean_as', $document); + $this->assertArrayNotHasKey('boolean', $document); + $this->assertIsBool($document->getAttribute('boolean_as')); + $this->assertEquals(false, $document->getAttribute('boolean_as')); + + /** + * Simple `as` query getDocument + */ + $document = $db->getDocument( + '__sessions', + $session2->getId(), + [ + Query::select('$permissions', as: '___permissions'), + ] + ); + $this->assertArrayHasKey('___permissions', $document); + $this->assertArrayNotHasKey('$permissions', $document); + $this->assertArrayNotHasKey('$id', $document); + $this->assertArrayHasKey('$collection', $document); + + /** + * Simple `as` query find + */ + $document = $db->findOne( + '__sessions', + [ + Query::select('$id', as: '___uid'), + Query::select('$sequence', as: '___id'), + Query::select('$createdAt', as: '___created'), + Query::select('user_id', as: 'user_id_as'), + Query::select('float', as: 'float_as'), + Query::select('boolean', as: 'boolean_as'), + ] + ); + + $this->assertArrayHasKey('___uid', $document); + $this->assertArrayNotHasKey('$id', $document); + $this->assertArrayHasKey('___id', $document); + $this->assertArrayNotHasKey('$sequence', $document); + $this->assertArrayHasKey('___created', $document); + $this->assertArrayNotHasKey('$createdAt', $document); + $this->assertArrayHasKey('user_id_as', $document); + $this->assertArrayNotHasKey('user_id', $document); + $this->assertArrayHasKey('float_as', $document); + $this->assertArrayNotHasKey('float', $document); + $this->assertIsFloat($document->getAttribute('float_as')); + $this->assertEquals(10.5, $document->getAttribute('float_as')); + $this->assertArrayHasKey('boolean_as', $document); + $this->assertArrayNotHasKey('boolean', $document); + $this->assertIsBool($document->getAttribute('boolean_as')); + $this->assertEquals(false, $document->getAttribute('boolean_as')); + + /** + * Select queries + */ + $document = $db->findOne( + '__users', + [ + Query::select('username', '', as: 'as_username'), + Query::select('user_id', 'S', as: 'as_user_id'), + Query::select('float', 'S', as: 'as_float'), + Query::select('boolean', 'S', as: 'as_boolean'), + Query::select('$permissions', 'S', as: 'as_permissions'), + Query::join( + '__sessions', + 'S', + [ + Query::relationEqual('', '$id', 'S', 'user_id'), + ] + ) + ] + ); + + $this->assertArrayHasKey('as_username', $document); + $this->assertArrayHasKey('as_user_id', $document); + $this->assertArrayHasKey('as_float', $document); + $this->assertArrayHasKey('as_boolean', $document); + $this->assertArrayHasKey('as_permissions', $document); + $this->assertIsArray($document->getAttribute('as_permissions')); + + // /** + // * ambiguous and duplications selects + // */ + // try { + // $db->find( + // '__users', + // [ + // Query::select('$id', 'main'), + // Query::select('$id', 'S'), + // Query::join('__sessions', 'S', + // [ + // Query::relationEqual('', '$id', 'S', 'user_id'), + // ] + // ) + // ] + // ); + // $this->fail('Failed to throw exception'); + // } catch (\Throwable $e) { + // $this->assertTrue($e instanceof QueryException); + // $this->assertEquals('Invalid Query Select: ambiguous column "$id"', $e->getMessage()); + // } + + // + // try { + // $db->find( + // '__users', + // [ + // Query::select('*', 'main'), + // Query::select('*', 'S'), + // Query::join('__sessions', 'S', + // [ + // Query::relationEqual('', '$id', 'S', 'user_id'), + // ] + // ) + // ] + // ); + // $this->fail('Failed to throw exception'); + // } catch (\Throwable $e) { + // $this->assertTrue($e instanceof QueryException); + // $this->assertEquals('Invalid Query Select: ambiguous column "*"', $e->getMessage()); + // } + // + // try { + // $db->find('__users', + // [ + // Query::select('$id'), + // Query::select('$id'), + // ] + // ); + // $this->fail('Failed to throw exception'); + // } catch (\Throwable $e) { + // $this->assertTrue($e instanceof QueryException); + // $this->assertEquals('Duplicate Query Select on "main.$id"', $e->getMessage()); + // } + // + // /** + // * This should fail? since 2 _uid attributes will be returned? + // */ + // try { + // $db->find( + // '__users', + // [ + // Query::select('*', 'main'), + // Query::select('$id', 'S'), + // Query::join('__sessions', 'S', + // [ + // Query::relationEqual('', '$id', 'S', 'user_id'), + // ] + // ) + // ] + // ); + // $this->fail('Failed to throw exception'); + // } catch (\Throwable $e) { + // $this->assertTrue($e instanceof QueryException); + // $this->assertEquals('Invalid Query Select: ambiguous column "$id"', $e->getMessage()); + // } + } + + public function testLeftJoin(): void + { + /** + * @var Database $db + */ + $db = static::getDatabase(); + + if (!$db->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $documents = $db->find( + '__users', + [ + Query::select('username'), + Query::select('float', 'B'), + Query::leftJoin( + '__sessions', + 'B', + [ + Query::relationEqual('', '$id', 'B', 'user_id'), + ] + ), + Query::orderAsc('username') + ] + ); + + $this->assertEquals(2, count($documents)); + $this->assertEquals('Abraham', $documents[0]->getAttribute('username')); + $this->assertEquals('Donald', $documents[1]->getAttribute('username')); + $this->assertEquals(5.5, $documents[0]->getAttribute('float')); + $this->assertEquals(10.5, $documents[1]->getAttribute('float')); + + /** + * Left join skip permissions + */ + $documents = $db->getAuthorization()->skip(function () use ($db) { + return $db->find( + '__users', + [ + Query::select('username'), + Query::select('float', 'B'), + Query::leftJoin( + '__sessions', + 'B', + [ + Query::relationEqual('', '$id', 'B', 'user_id'), + Query::relationEqual('', '$sequence', 'B', 'user_internal_id'), + ] + ), + Query::orderAsc('$sequence', 'B') + ] + ); + }); + $this->assertEquals(3, count($documents)); + $this->assertEquals('Donald', $documents[0]->getAttribute('username')); + $this->assertEquals('Donald', $documents[1]->getAttribute('username')); + $this->assertEquals('Abraham', $documents[2]->getAttribute('username')); + $this->assertEquals(null, $documents[0]->getAttribute('float')); + $this->assertEquals(10.5, $documents[1]->getAttribute('float')); + $this->assertEquals(5.5, $documents[2]->getAttribute('float')); + + /** + * Left join with additional query in ON clause + */ + $documents = $db->find( + '__users', + [ + Query::select('username'), + Query::select('float', 'B'), + Query::leftJoin( + '__sessions', + 'B', + [ + Query::relationEqual('', '$id', 'B', 'user_id'), + Query::equal('float', [5.5], 'B'), + ] + ), + Query::orderAsc('username') + ] + ); + + $this->assertEquals(2, count($documents)); + $this->assertEquals('Abraham', $documents[0]->getAttribute('username')); + $this->assertEquals('Donald', $documents[1]->getAttribute('username')); + $this->assertEquals(5.5, $documents[0]->getAttribute('float')); + $this->assertEquals(null, $documents[1]->getAttribute('float')); + } + + public function testRightJoin(): void + { + /** + * @var Database $db + */ + $db = static::getDatabase(); + + if (!$db->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $documents = $db->find( + '__sessions', + [ + Query::select('float'), + Query::select('username', 'B'), + Query::rightJoin( + '__users', + 'B', + [ + Query::relationEqual('B', '$id', '', 'user_id'), + ] + ), + Query::orderAsc('username', 'B') + ] + ); + + $this->assertEquals(2, count($documents)); + $this->assertEquals('Abraham', $documents[0]->getAttribute('username')); + $this->assertEquals('Donald', $documents[1]->getAttribute('username')); + $this->assertEquals(5.5, $documents[0]->getAttribute('float')); + $this->assertEquals(10.5, $documents[1]->getAttribute('float')); + + /** + * Right join skip permissions + */ + $documents = $db->getAuthorization()->skip(function () use ($db) { + return $db->find( + '__sessions', + [ + Query::select('float'), + Query::select('username', 'B'), + Query::rightJoin( + '__users', + 'B', + [ + Query::relationEqual('B', '$id', '', 'user_id'), + ] + ), + Query::orderAsc('username', 'B') + ] + ); + }); + $this->assertEquals(3, count($documents)); + $this->assertEquals('Abraham', $documents[0]->getAttribute('username')); + $this->assertEquals('Donald', $documents[1]->getAttribute('username')); + $this->assertEquals('Donald', $documents[2]->getAttribute('username')); + $this->assertEquals(5.5, $documents[0]->getAttribute('float')); + $this->assertEquals(null, $documents[1]->getAttribute('float')); + $this->assertEquals(10.5, $documents[2]->getAttribute('float')); + + /** + * Right join with additional query in ON clause + */ + $documents = $db->find( + '__sessions', + [ + Query::select('float'), + Query::select('username', 'B'), + Query::rightJoin( + '__users', + 'B', + [ + Query::relationEqual('B', '$id', '', 'user_id'), + Query::equal('float', [5.5]), + ] + ), + Query::orderAsc('username', 'B') + ] + ); + + /** + * Issue because right join query return nulls and permissions query + tenant + * Since right join return null for main collection, which are filtered by the above + */ + $this->assertEquals(2, count($documents)); + } + + public function testJoinsScopeOrder(): void + { + /** + * @var Database $db + */ + $db = static::getDatabase(); + + if (!$db->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + $documents = $db->find( + '__sessions', + [ + Query::select('username', 'B'), + Query::select('name', 'C', 'bank'), + Query::join( + '__users', + 'B', + [ + Query::relationEqual('B', '$id', '', 'user_id'), + ] + ), + Query::join( + '__banks', + 'C', + [ + Query::relationEqual('C', '$sequence', 'B', 'bank_internal_id'), + ] + ), + ] + ); + $this->assertEquals(1, count($documents)); + $this->assertEquals('Donald', $documents[0]->getAttribute('username')); + $this->assertEquals('Chase', $documents[0]->getAttribute('bank')); + + try { + $db->find( + '__sessions', + [ + Query::join( + '__banks', + 'A', + [ + Query::relationEqual('A', '$sequence', 'BAD', 'bank_internal_id'), + ] + ), + ] + ); + $this->fail('Failed to throw exception'); + } catch (\Throwable $e) { + $this->assertTrue($e instanceof QueryException); + $this->assertEquals('Invalid query: RelationEqual alias reference in join has not been defined.', $e->getMessage()); + } + } + + public function testJoinsSum(): void + { + /** + * @var Database $db + */ + $db = static::getDatabase(); + + if (!$db->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $queries = [ + Query::join( + '__users', + 'B', + [ + Query::relationEqual('B', '$id', '', 'user_id'), + ] + ) + ]; + + $documents = $db->find('__sessions', $queries); + $this->assertEquals(2, count($documents)); + + $count = $db->count('__sessions', $queries); + $this->assertEquals(2, $count); + } +} diff --git a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php index 2e9dc78f7..161027f3e 100644 --- a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -194,7 +194,8 @@ public function testObjectAttribute(): void // Test 11b: Test Query::select to limit returned attributes $results = $database->find($collectionId, [ - Query::select(['$id', 'meta']), + Query::select('$id'), + Query::select('meta'), Query::equal('meta', [['age' => 26]]) ]); $this->assertCount(1, $results); @@ -204,7 +205,7 @@ public function testObjectAttribute(): void // Test 11c: Test Query::select with only $id (exclude meta) $results = $database->find($collectionId, [ - Query::select(['$id']), + Query::select('$id'), Query::equal('meta', [['age' => 30]]) ]); $this->assertCount(1, $results); @@ -506,7 +507,9 @@ public function testObjectAttribute(): void // Test 34: Test Query::select with complex nested structures $results = $database->find($collectionId, [ - Query::select(['$id', '$permissions', 'meta']), + Query::select('$id'), + Query::select('$permissions'), + Query::select('meta'), Query::equal('meta', [[ 'level1' => [ 'level2' => [ @@ -525,7 +528,8 @@ public function testObjectAttribute(): void // Test 35: Test selecting multiple documents and verifying object attributes $allDocs = $database->find($collectionId, [ - Query::select(['$id', 'meta']), + Query::select('$id'), + Query::select('meta'), Query::limit(25) ]); $this->assertGreaterThan(10, count($allDocs)); @@ -540,7 +544,7 @@ public function testObjectAttribute(): void // Test 36: Test Query::select with only meta attribute $results = $database->find($collectionId, [ - Query::select(['meta']), + Query::select('meta'), Query::equal('meta', [['tags' => ['php', 'javascript', 'python', 'go', 'rust']]]) ]); $this->assertCount(1, $results); @@ -808,7 +812,8 @@ public function testObjectAttributeInvalidCases(): void // Test 12: Test Query::select with valid document $results = $database->find($collectionId, [ - Query::select(['$id', 'meta']), + Query::select('$id'), + Query::select('meta'), Query::equal('meta', [['name' => 'John']]) ]); $this->assertCount(1, $results); @@ -827,7 +832,8 @@ public function testObjectAttributeInvalidCases(): void // Test 14: Test Query::select excluding meta $results = $database->find($collectionId, [ - Query::select(['$id', '$permissions']), + Query::select('$id'), + Query::select('$permissions'), Query::equal('meta', [['fruits' => ['apple', 'banana', 'orange']]]) ]); $this->assertCount(1, $results); diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index be4b74a6f..13e21252e 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -319,10 +319,8 @@ public function testZoo(): void $this->assertEquals(0, count($president['votes'])); $president = $database->findOne('presidents', [ - Query::select([ - '*', - 'votes.*', - ]), + Query::select('*'), + Query::select('votes.*'), Query::equal('$id', ['trump']) ]); @@ -332,11 +330,9 @@ public function testZoo(): void $this->assertArrayNotHasKey('animals', $president['votes'][0]); // Not exist $president = $database->findOne('presidents', [ - Query::select([ - '*', - 'votes.*', - 'votes.animals.*', - ]), + Query::select('*'), + Query::select('votes.*'), + Query::select('votes.animals.*'), Query::equal('$id', ['trump']) ]); @@ -349,7 +345,7 @@ public function testZoo(): void * Check Selects queries */ $veterinarian = $database->findOne('veterinarians', [ - Query::select(['*']), // No resolving + Query::select('*'), // No resolving Query::equal('$id', ['dr.pol']), ]); @@ -360,9 +356,7 @@ public function testZoo(): void $veterinarian = $database->findOne( 'veterinarians', [ - Query::select([ - 'animals.*', - ]) + Query::select('animals.*') ] ); @@ -380,11 +374,9 @@ public function testZoo(): void $veterinarian = $database->findOne( 'veterinarians', [ - Query::select([ - 'animals.*', - 'animals.zoo.*', - 'animals.president.*', - ]) + Query::select('animals.*'), + Query::select('animals.zoo.*'), + Query::select('animals.president.*'), ] ); @@ -1418,7 +1410,8 @@ public function testSelectRelationshipAttributes(): void // Select some parent attributes, some child attributes $make = $database->findOne('make', [ - Query::select(['name', 'models.name']), + Query::select('name'), + Query::select('models.name'), ]); if ($make->isEmpty()) { @@ -1431,16 +1424,17 @@ public function testSelectRelationshipAttributes(): void $this->assertEquals('Focus', $make['models'][1]['name']); $this->assertArrayNotHasKey('year', $make['models'][0]); $this->assertArrayNotHasKey('year', $make['models'][1]); - $this->assertArrayHasKey('$id', $make); - $this->assertArrayHasKey('$sequence', $make); - $this->assertArrayHasKey('$permissions', $make); + $this->assertArrayHasKey('$id', $make); // Was added by system in processRelationshipQueries + $this->assertArrayNotHasKey('$sequence', $make); + $this->assertArrayNotHasKey('$permissions', $make); $this->assertArrayHasKey('$collection', $make); - $this->assertArrayHasKey('$createdAt', $make); - $this->assertArrayHasKey('$updatedAt', $make); + $this->assertArrayNotHasKey('$createdAt', $make); + $this->assertArrayNotHasKey('$updatedAt', $make); // Select internal attributes $make = $database->findOne('make', [ - Query::select(['name', '$id']), + Query::select('name'), + Query::select('$id'), ]); if ($make->isEmpty()) { @@ -1449,14 +1443,15 @@ public function testSelectRelationshipAttributes(): void $this->assertArrayHasKey('name', $make); $this->assertArrayHasKey('$id', $make); - $this->assertArrayHasKey('$sequence', $make); + $this->assertArrayNotHasKey('$sequence', $make); $this->assertArrayHasKey('$collection', $make); - $this->assertArrayHasKey('$createdAt', $make); - $this->assertArrayHasKey('$updatedAt', $make); - $this->assertArrayHasKey('$permissions', $make); + $this->assertArrayNotHasKey('$createdAt', $make); + $this->assertArrayNotHasKey('$updatedAt', $make); + $this->assertArrayNotHasKey('$permissions', $make); $make = $database->findOne('make', [ - Query::select(['name', '$sequence']), + Query::select('name'), + Query::select('$sequence'), ]); if ($make->isEmpty()) { @@ -1467,12 +1462,13 @@ public function testSelectRelationshipAttributes(): void $this->assertArrayHasKey('$id', $make); $this->assertArrayHasKey('$sequence', $make); $this->assertArrayHasKey('$collection', $make); - $this->assertArrayHasKey('$createdAt', $make); - $this->assertArrayHasKey('$updatedAt', $make); - $this->assertArrayHasKey('$permissions', $make); + $this->assertArrayNotHasKey('$createdAt', $make); + $this->assertArrayNotHasKey('$updatedAt', $make); + $this->assertArrayNotHasKey('$permissions', $make); $make = $database->findOne('make', [ - Query::select(['name', '$collection']), + Query::select('name'), + Query::select('$collection'), ]); if ($make->isEmpty()) { @@ -1481,14 +1477,15 @@ public function testSelectRelationshipAttributes(): void $this->assertArrayHasKey('name', $make); $this->assertArrayHasKey('$id', $make); - $this->assertArrayHasKey('$sequence', $make); + $this->assertArrayNotHasKey('$sequence', $make); $this->assertArrayHasKey('$collection', $make); - $this->assertArrayHasKey('$createdAt', $make); - $this->assertArrayHasKey('$updatedAt', $make); - $this->assertArrayHasKey('$permissions', $make); + $this->assertArrayNotHasKey('$createdAt', $make); + $this->assertArrayNotHasKey('$updatedAt', $make); + $this->assertArrayNotHasKey('$permissions', $make); $make = $database->findOne('make', [ - Query::select(['name', '$createdAt']), + Query::select('name'), + Query::select('$createdAt'), ]); if ($make->isEmpty()) { @@ -1497,14 +1494,15 @@ public function testSelectRelationshipAttributes(): void $this->assertArrayHasKey('name', $make); $this->assertArrayHasKey('$id', $make); - $this->assertArrayHasKey('$sequence', $make); + $this->assertArrayNotHasKey('$sequence', $make); $this->assertArrayHasKey('$collection', $make); $this->assertArrayHasKey('$createdAt', $make); - $this->assertArrayHasKey('$updatedAt', $make); - $this->assertArrayHasKey('$permissions', $make); + $this->assertArrayNotHasKey('$updatedAt', $make); + $this->assertArrayNotHasKey('$permissions', $make); $make = $database->findOne('make', [ - Query::select(['name', '$updatedAt']), + Query::select('name'), + Query::select('$updatedAt'), ]); if ($make->isEmpty()) { @@ -1513,14 +1511,15 @@ public function testSelectRelationshipAttributes(): void $this->assertArrayHasKey('name', $make); $this->assertArrayHasKey('$id', $make); - $this->assertArrayHasKey('$sequence', $make); + $this->assertArrayNotHasKey('$sequence', $make); $this->assertArrayHasKey('$collection', $make); - $this->assertArrayHasKey('$createdAt', $make); + $this->assertArrayNotHasKey('$createdAt', $make); $this->assertArrayHasKey('$updatedAt', $make); - $this->assertArrayHasKey('$permissions', $make); + $this->assertArrayNotHasKey('$permissions', $make); $make = $database->findOne('make', [ - Query::select(['name', '$permissions']), + Query::select('name'), + Query::select('$permissions'), ]); if ($make->isEmpty()) { @@ -1529,15 +1528,16 @@ public function testSelectRelationshipAttributes(): void $this->assertArrayHasKey('name', $make); $this->assertArrayHasKey('$id', $make); - $this->assertArrayHasKey('$sequence', $make); + $this->assertArrayNotHasKey('$sequence', $make); $this->assertArrayHasKey('$collection', $make); - $this->assertArrayHasKey('$createdAt', $make); - $this->assertArrayHasKey('$updatedAt', $make); + $this->assertArrayNotHasKey('$createdAt', $make); + $this->assertArrayNotHasKey('$updatedAt', $make); $this->assertArrayHasKey('$permissions', $make); // Select all parent attributes, some child attributes $make = $database->findOne('make', [ - Query::select(['*', 'models.year']), + Query::select('*'), + Query::select('models.year'), ]); if ($make->isEmpty()) { @@ -1545,6 +1545,8 @@ public function testSelectRelationshipAttributes(): void } $this->assertEquals('Ford', $make['name']); + $this->assertEquals('USA', $make['origin']); + $this->assertArrayHasKey('$createdAt', $make); $this->assertEquals(2, \count($make['models'])); $this->assertArrayNotHasKey('name', $make['models'][0]); $this->assertArrayNotHasKey('name', $make['models'][1]); @@ -1553,7 +1555,29 @@ public function testSelectRelationshipAttributes(): void // Select all parent attributes, all child attributes $make = $database->findOne('make', [ - Query::select(['*', 'models.*']), + Query::select('*'), + Query::select('models.*'), + ]); + + if ($make->isEmpty()) { + throw new Exception('Make not found'); + } + + $this->assertEquals('Ford', $make['name']); + $this->assertEquals('USA', $make['origin']); + $this->assertArrayHasKey('$createdAt', $make); + $this->assertEquals(2, \count($make['models'])); + $this->assertEquals('Fiesta', $make['models'][0]['name']); + $this->assertEquals('Focus', $make['models'][1]['name']); + $this->assertEquals(2010, $make['models'][0]['year']); + $this->assertEquals(2011, $make['models'][1]['year']); + + /** + * Select queries only on nested will Select all parent attributes as well. + * In getDocument we add $permissions by system, check we add it after processRelationshipQueries + */ + $make = $database->getDocument('make', 'ford', [ + Query::select('models.*'), ]); if ($make->isEmpty()) { @@ -1561,6 +1585,8 @@ public function testSelectRelationshipAttributes(): void } $this->assertEquals('Ford', $make['name']); + $this->assertEquals('USA', $make['origin']); + $this->assertArrayHasKey('$createdAt', $make); $this->assertEquals(2, \count($make['models'])); $this->assertEquals('Fiesta', $make['models'][0]['name']); $this->assertEquals('Focus', $make['models'][1]['name']); @@ -1570,7 +1596,7 @@ public function testSelectRelationshipAttributes(): void // Select all parent attributes, all child attributes // Must select parent if selecting children $make = $database->findOne('make', [ - Query::select(['models.*']), + Query::select('models.*'), ]); if ($make->isEmpty()) { @@ -1578,6 +1604,8 @@ public function testSelectRelationshipAttributes(): void } $this->assertEquals('Ford', $make['name']); + $this->assertEquals('USA', $make['origin']); + $this->assertArrayHasKey('$createdAt', $make); $this->assertEquals(2, \count($make['models'])); $this->assertEquals('Fiesta', $make['models'][0]['name']); $this->assertEquals('Focus', $make['models'][1]['name']); @@ -1586,7 +1614,7 @@ public function testSelectRelationshipAttributes(): void // Select all parent attributes, no child attributes $make = $database->findOne('make', [ - Query::select(['name']), + Query::select('name'), ]); if ($make->isEmpty()) { @@ -1597,7 +1625,8 @@ public function testSelectRelationshipAttributes(): void // Select some parent attributes, all child attributes $make = $database->findOne('make', [ - Query::select(['name', 'models.*']), + Query::select('name'), + Query::select('models.*'), ]); $this->assertEquals('Ford', $make['name']); @@ -1609,7 +1638,8 @@ public function testSelectRelationshipAttributes(): void // Select some parent attributes, some child attributes $model = $database->findOne('model', [ - Query::select(['name', 'make.name']), + Query::select('name'), + Query::select('make.name'), ]); $this->assertEquals('Fiesta', $model['name']); @@ -1620,7 +1650,8 @@ public function testSelectRelationshipAttributes(): void // Select all parent attributes, some child attributes $model = $database->findOne('model', [ - Query::select(['*', 'make.name']), + Query::select('*'), + Query::select('make.name'), ]); $this->assertEquals('Fiesta', $model['name']); @@ -1629,7 +1660,8 @@ public function testSelectRelationshipAttributes(): void // Select all parent attributes, all child attributes $model = $database->findOne('model', [ - Query::select(['*', 'make.*']), + Query::select('*'), + Query::select('make.*'), ]); $this->assertEquals('Fiesta', $model['name']); @@ -1639,7 +1671,7 @@ public function testSelectRelationshipAttributes(): void // Select all parent attributes, no child attributes $model = $database->findOne('model', [ - Query::select(['*']), + Query::select('*'), ]); $this->assertEquals('Fiesta', $model['name']); @@ -1648,7 +1680,8 @@ public function testSelectRelationshipAttributes(): void // Select some parent attributes, all child attributes $model = $database->findOne('model', [ - Query::select(['name', 'make.*']), + Query::select('name'), + Query::select('make.*'), ]); $this->assertEquals('Fiesta', $model['name']); @@ -2799,11 +2832,9 @@ public function testMultiDocumentNestedRelationships(): void // Query all cars with nested relationship selections $cars = $database->find('car', [ - Query::select([ - '*', - 'customer.*', - 'customer.inspections.type', - ]), + Query::select('*'), + Query::select('customer.*'), + Query::select('customer.inspections.type'), ]); $this->assertCount(3, $cars); @@ -2852,11 +2883,9 @@ public function testMultiDocumentNestedRelationships(): void ]); $cars = $database->find('car', [ - Query::select([ - '*', - 'customer.*', - 'customer.inspections.type', - ]), + Query::select('*'), + Query::select('customer.*'), + Query::select('customer.inspections.type'), ]); // Verify all cars still have nested relationships after batch create diff --git a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php index 4df1ad461..edbebea49 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php @@ -108,8 +108,8 @@ public function testManyToManyOneWayRelationship(): void // Assert document does not contain non existing relation document. $this->assertEquals(1, \count($playlist1Document->getAttribute('songs'))); - $documents = $database->find('playlist', [ - Query::select(['name']), + $documents = static::getDatabase()->find('playlist', [ + Query::select('name'), Query::limit(1) ]); @@ -139,18 +139,19 @@ public function testManyToManyOneWayRelationship(): void // Select related document attributes $playlist = $database->findOne('playlist', [ - Query::select(['*', 'songs.name']) + Query::select('*'), + Query::select('songs.name') ]); if ($playlist->isEmpty()) { throw new Exception('Playlist not found'); } - $this->assertEquals('Song 1', $playlist->getAttribute('songs')[0]->getAttribute('name')); $this->assertArrayNotHasKey('length', $playlist->getAttribute('songs')[0]); $playlist = $database->getDocument('playlist', 'playlist1', [ - Query::select(['*', 'songs.name']) + Query::select('*'), + Query::select('songs.name') ]); $this->assertEquals('Song 1', $playlist->getAttribute('songs')[0]->getAttribute('name')); @@ -524,7 +525,8 @@ public function testManyToManyTwoWayRelationship(): void // Select related document attributes $student = $database->findOne('students', [ - Query::select(['*', 'classes.name']) + Query::select('*'), + Query::select('classes.name') ]); if ($student->isEmpty()) { @@ -535,7 +537,8 @@ public function testManyToManyTwoWayRelationship(): void $this->assertArrayNotHasKey('number', $student->getAttribute('classes')[0]); $student = $database->getDocument('students', 'student1', [ - Query::select(['*', 'classes.name']) + Query::select('*'), + Query::select('classes.name') ]); $this->assertEquals('Class 1', $student->getAttribute('classes')[0]->getAttribute('name')); @@ -1581,7 +1584,8 @@ public function testSelectManyToMany(): void // Use select query to get only name of the related documents $docs = $database->find('select_m2m_collection1', [ - Query::select(['name', 'select_m2m_collection2.name']), + Query::select('name'), + Query::select('select_m2m_collection2.name'), ]); $this->assertCount(1, $docs); @@ -1685,7 +1689,9 @@ public function testSelectAcrossMultipleCollections(): void // Query with nested select $artists = $database->find('artists', [ - Query::select(['name', 'albums.name', 'albums.tracks.title']) + Query::select('name'), + Query::select('albums.name'), + Query::select('albums.tracks.title') ]); $this->assertCount(1, $artists); diff --git a/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php b/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php index e62ff735c..8d4d24234 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php @@ -145,7 +145,8 @@ public function testManyToOneOneWayRelationship(): void $this->assertArrayNotHasKey('reviews', $movie); $documents = $database->find('review', [ - Query::select(['date', 'movie.date']) + Query::select('date'), + Query::select('movie.date') ]); $this->assertCount(3, $documents); @@ -176,7 +177,8 @@ public function testManyToOneOneWayRelationship(): void // Select related document attributes $review = $database->findOne('review', [ - Query::select(['*', 'movie.name']) + Query::select('*'), + Query::select('movie.name') ]); if ($review->isEmpty()) { @@ -187,7 +189,8 @@ public function testManyToOneOneWayRelationship(): void $this->assertArrayNotHasKey('length', $review->getAttribute('movie')); $review = $database->getDocument('review', 'review1', [ - Query::select(['*', 'movie.name']) + Query::select('*'), + Query::select('movie.name') ]); $this->assertEquals('Movie 1', $review->getAttribute('movie')->getAttribute('name')); @@ -556,7 +559,8 @@ public function testManyToOneTwoWayRelationship(): void // Select related document attributes $product = $database->findOne('product', [ - Query::select(['*', 'store.name']) + Query::select('*'), + Query::select('store.name') ]); if ($product->isEmpty()) { @@ -567,7 +571,8 @@ public function testManyToOneTwoWayRelationship(): void $this->assertArrayNotHasKey('opensAt', $product->getAttribute('store')); $product = $database->getDocument('product', 'product1', [ - Query::select(['*', 'store.name']) + Query::select('*'), + Query::select('store.name') ]); $this->assertEquals('Store 1', $product->getAttribute('store')->getAttribute('name')); diff --git a/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php b/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php index 97b4cca4e..6e07148e3 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php @@ -117,7 +117,7 @@ public function testOneToManyOneWayRelationship(): void ])); $documents = $database->find('artist', [ - Query::select(['name']), + Query::select('name'), Query::limit(1) ]); $this->assertArrayNotHasKey('albums', $documents[0]); @@ -148,7 +148,8 @@ public function testOneToManyOneWayRelationship(): void // Select related document attributes $artist = $database->findOne('artist', [ - Query::select(['*', 'albums.name']) + Query::select('*'), + Query::select('albums.name') ]); if ($artist->isEmpty()) { @@ -159,7 +160,8 @@ public function testOneToManyOneWayRelationship(): void $this->assertArrayNotHasKey('price', $artist->getAttribute('albums')[0]); $artist = $database->getDocument('artist', 'artist1', [ - Query::select(['*', 'albums.name']) + Query::select('*'), + Query::select('albums.name') ]); $this->assertEquals('Album 1', $artist->getAttribute('albums')[0]->getAttribute('name')); @@ -584,7 +586,8 @@ public function testOneToManyTwoWayRelationship(): void // Select related document attributes $customer = $database->findOne('customer', [ - Query::select(['*', 'accounts.name']) + Query::select('*'), + Query::select('accounts.name') ]); if ($customer->isEmpty()) { @@ -595,7 +598,8 @@ public function testOneToManyTwoWayRelationship(): void $this->assertArrayNotHasKey('number', $customer->getAttribute('accounts')[0]); $customer = $database->getDocument('customer', 'customer1', [ - Query::select(['*', 'accounts.name']) + Query::select('*'), + Query::select('accounts.name') ]); $this->assertEquals('Account 1', $customer->getAttribute('accounts')[0]->getAttribute('name')); @@ -918,21 +922,23 @@ public function testNestedOneToMany_OneToOneRelationship(): void $this->assertEquals('Mayor 1', $documents[0]['cities'][0]['mayor']['name']); $documents = $database->find('countries', [ - Query::select(['name']), + Query::select('name'), Query::limit(1) ]); $this->assertArrayHasKey('name', $documents[0]); $this->assertArrayNotHasKey('cities', $documents[0]); $documents = $database->find('countries', [ - Query::select(['*']), + Query::select('*'), Query::limit(1) ]); $this->assertArrayHasKey('name', $documents[0]); $this->assertArrayNotHasKey('cities', $documents[0]); $documents = $database->find('countries', [ - Query::select(['*', 'cities.*', 'cities.mayor.*']), + Query::select('*'), + Query::select('cities.*'), + Query::select('cities.mayor.*'), Query::limit(1) ]); diff --git a/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php b/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php index 56f2ba5c5..767327566 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php @@ -169,7 +169,7 @@ public function testOneToOneOneWayRelationship(): void $this->assertArrayNotHasKey('person', $library); $people = $database->find('person', [ - Query::select(['name']) + Query::select('name') ]); $this->assertArrayNotHasKey('library', $people[0]); @@ -179,7 +179,8 @@ public function testOneToOneOneWayRelationship(): void // Select related document attributes $person = $database->findOne('person', [ - Query::select(['*', 'library.name']) + Query::select('*'), + Query::select('library.name') ]); if ($person->isEmpty()) { @@ -190,7 +191,9 @@ public function testOneToOneOneWayRelationship(): void $this->assertArrayNotHasKey('area', $person->getAttribute('library')); $person = $database->getDocument('person', 'person1', [ - Query::select(['*', 'library.name', '$id']) + Query::select('*'), + Query::select('library.name'), + Query::select('$id') ]); $this->assertEquals('Library 1', $person->getAttribute('library')->getAttribute('name')); @@ -199,18 +202,18 @@ public function testOneToOneOneWayRelationship(): void $document = $database->getDocument('person', $person->getId(), [ - Query::select(['name']), + Query::select('name'), ]); $this->assertArrayNotHasKey('library', $document); $this->assertEquals('Person 1', $document['name']); $document = $database->getDocument('person', $person->getId(), [ - Query::select(['*']), + Query::select('*'), ]); $this->assertEquals('library1', $document['library']); $document = $database->getDocument('person', $person->getId(), [ - Query::select(['library.*']), + Query::select('library.*'), ]); $this->assertEquals('Library 1', $document['library']['name']); $this->assertArrayNotHasKey('name', $document); @@ -658,7 +661,8 @@ public function testOneToOneTwoWayRelationship(): void // Select related document attributes $country = $database->findOne('country', [ - Query::select(['*', 'city.name']) + Query::select('*'), + Query::select('city.name') ]); if ($country->isEmpty()) { @@ -669,7 +673,8 @@ public function testOneToOneTwoWayRelationship(): void $this->assertArrayNotHasKey('code', $country->getAttribute('city')); $country = $database->getDocument('country', 'country1', [ - Query::select(['*', 'city.name']) + Query::select('*'), + Query::select('city.name') ]); $this->assertEquals('London', $country->getAttribute('city')->getAttribute('name')); diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index 436be6edd..444488b2c 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -172,13 +172,13 @@ public function testSchemalessSelectionOnUnknownAttributes(): void ]; $this->assertEquals(3, $database->createDocuments($colName, $docs)); - $docA = $database->getDocument($colName, 'doc1', [Query::select(['freeA'])]); + $docA = $database->getDocument($colName, 'doc1', [Query::select('freeA')]); $this->assertEquals('doc1', $docA->getAttribute('freeA')); - $docC = $database->getDocument($colName, 'doc1', [Query::select(['freeC'])]); + $docC = $database->getDocument($colName, 'doc1', [Query::select('freeC')]); $this->assertNull($docC->getAttribute('freeC')); - $docs = $database->find($colName, [Query::equal('$id', ['doc1','doc2']),Query::select(['freeC'])]); + $docs = $database->find($colName, [Query::equal('$id', ['doc1','doc2']),Query::select('freeC')]); foreach ($docs as $doc) { $this->assertNull($doc->getAttribute('freeC')); // since not selected @@ -188,13 +188,13 @@ public function testSchemalessSelectionOnUnknownAttributes(): void $docA = $database->find($colName, [ Query::equal('$id', ['doc1']), - Query::select(['freeA']) + Query::select('freeA') ]); $this->assertEquals('doc1', $docA[0]->getAttribute('freeA')); $docC = $database->find($colName, [ Query::equal('$id', ['doc1']), - Query::select(['freeC']) + Query::select('freeC') ]); $this->assertArrayNotHasKey('freeC', $docC[0]->getAttributes()); } @@ -860,7 +860,13 @@ public function testSchemalessInternalAttributes(): void $this->assertContains(Permission::delete(Role::any()), $perms); $selected = $database->getDocument($col, 'i1', [ - Query::select(['name', '$id', '$sequence', '$collection', '$createdAt', '$updatedAt', '$permissions']) + Query::select('name'), + Query::select('$id'), + Query::select('$sequence'), + Query::select('$collection'), + Query::select('$createdAt'), + Query::select('$updatedAt'), + Query::select('$permissions') ]); $this->assertEquals('alpha', $selected->getAttribute('name')); $this->assertArrayHasKey('$id', $selected); @@ -872,7 +878,12 @@ public function testSchemalessInternalAttributes(): void $found = $database->find($col, [ Query::equal('$id', ['i1']), - Query::select(['$id', '$sequence', '$collection', '$createdAt', '$updatedAt', '$permissions']) + Query::select('$id'), + Query::select('$sequence'), + Query::select('$collection'), + Query::select('$createdAt'), + Query::select('$updatedAt'), + Query::select('$permissions') ]); $this->assertCount(1, $found); $this->assertArrayHasKey('$id', $found[0]); diff --git a/tests/e2e/Adapter/Scopes/SpatialTests.php b/tests/e2e/Adapter/Scopes/SpatialTests.php index 5ee56e68d..c1c0cc2a3 100644 --- a/tests/e2e/Adapter/Scopes/SpatialTests.php +++ b/tests/e2e/Adapter/Scopes/SpatialTests.php @@ -12,6 +12,7 @@ use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; +use Utopia\Database\QueryContext; trait SpatialTests { @@ -1592,17 +1593,25 @@ public function testSpatialBulkOperation(): void $this->assertGreaterThan(1, count($document->getAttribute('area')[0])); // POLYGON has multiple points } - $results = $database->find($collectionName, [Query::select(["name"])]); + $results = $database->find($collectionName, [ + Query::select('name') + ]); foreach ($results as $document) { $this->assertNotEmpty($document->getAttribute('name')); } - $results = $database->find($collectionName, [Query::select(["location"])]); + $results = $database->find($collectionName, [ + Query::select('location') + ]); foreach ($results as $document) { $this->assertCount(2, $document->getAttribute('location')); // POINT has 2 coordinates } - $results = $database->find($collectionName, [Query::select(["area","location"])]); + $results = $database->find($collectionName, [ + Query::select('$sequence'), + Query::select('area'), + Query::select('location') + ]); foreach ($results as $document) { $this->assertCount(2, $document->getAttribute('location')); // POINT has 2 coordinates $this->assertGreaterThan(1, count($document->getAttribute('area')[0])); // POLYGON has multiple points @@ -2477,20 +2486,23 @@ public function testSpatialEncodeDecode(): void $this->assertEquals($result->getAttribute('line'), $line); $this->assertEquals($result->getAttribute('poly'), $poly); + $context = new QueryContext(); + $context->add($collection); - $result = $database->decode($collection, $doc); + $result = $database->decode($context, $doc); $this->assertEquals($result->getAttribute('point'), $pointArr); $this->assertEquals($result->getAttribute('line'), $lineArr); $this->assertEquals($result->getAttribute('poly'), $polyArr); $stringDoc = new Document(['point' => $point,'line' => $line, 'poly' => $poly]); - $result = $database->decode($collection, $stringDoc); + $result = $database->decode($context, $stringDoc); $this->assertEquals($result->getAttribute('point'), $pointArr); $this->assertEquals($result->getAttribute('line'), $lineArr); $this->assertEquals($result->getAttribute('poly'), $polyArr); $nullDoc = new Document(['point' => null,'line' => null, 'poly' => null]); - $result = $database->decode($collection, $nullDoc); + $result = $database->decode($context, $nullDoc); + $this->assertEquals($result->getAttribute('point'), null); $this->assertEquals($result->getAttribute('line'), null); $this->assertEquals($result->getAttribute('poly'), null); diff --git a/tests/unit/QueryTest.php b/tests/unit/QueryTest.php index e23193ecb..d86210279 100644 --- a/tests/unit/QueryTest.php +++ b/tests/unit/QueryTest.php @@ -19,19 +19,21 @@ public function tearDown(): void public function testCreate(): void { - $query = new Query(Query::TYPE_EQUAL, 'title', ['Iron Man']); + $query = Query::equal('title', ['Iron Man'], 'users'); $this->assertEquals(Query::TYPE_EQUAL, $query->getMethod()); $this->assertEquals('title', $query->getAttribute()); $this->assertEquals('Iron Man', $query->getValues()[0]); + $this->assertEquals('users', $query->getAlias()); - $query = new Query(Query::TYPE_ORDER_DESC, 'score'); + $query = Query::orderDesc('score', 'users'); $this->assertEquals(Query::TYPE_ORDER_DESC, $query->getMethod()); $this->assertEquals('score', $query->getAttribute()); $this->assertEquals([], $query->getValues()); + $this->assertEquals('users', $query->getAlias()); - $query = new Query(Query::TYPE_LIMIT, values: [10]); + $query = Query::limit(10); $this->assertEquals(Query::TYPE_LIMIT, $query->getMethod()); $this->assertEquals('', $query->getAttribute()); @@ -179,7 +181,6 @@ public function testCreate(): void } /** - * @return void * @throws QueryException */ public function testParse(): void @@ -279,10 +280,11 @@ public function testParse(): void $this->assertEquals('director', $query->getAttribute()); $this->assertEquals(['Tarantino'], $query->getValues()); - $query = Query::parse(Query::select(['title', 'director'])->toString()); + $query = Query::parse(Query::select('title', alias: 'alias', as: 'as')->toString()); $this->assertEquals('select', $query->getMethod()); - $this->assertEquals(null, $query->getAttribute()); - $this->assertEquals(['title', 'director'], $query->getValues()); + $this->assertEquals('title', $query->getAttribute()); + $this->assertEquals('alias', $query->getAlias()); + $this->assertEquals('as', $query->getAs()); // Test new date query wrapper methods parsing $query = Query::parse(Query::createdBefore('2023-01-01T00:00:00.000Z')->toString()); @@ -347,7 +349,7 @@ public function testParse(): void $json = Query::or([ Query::equal('actors', ['Brad Pitt']), - Query::equal('actors', ['Johnny Depp']) + Query::equal('actors', ['Johnny Depp']), ])->toString(); $query = Query::parse($json); @@ -468,4 +470,130 @@ public function testNewQueryTypesInTypesArray(): void $this->assertContains(Query::TYPE_NOT_BETWEEN, Query::TYPES); $this->assertContains(Query::TYPE_ORDER_RANDOM, Query::TYPES); } + + + /** + * @throws QueryException + */ + public function testJoins(): void + { + $query = + Query::join( + 'users', + 'u', + [ + Query::relationEqual('', 'id', 'u', 'user_id'), + Query::equal('id', ['usa'], 'u'), + ] + ); + + $this->assertEquals(Query::TYPE_INNER_JOIN, $query->getMethod()); + $this->assertEquals('users', $query->getCollection()); + $this->assertEquals('u', $query->getAlias()); + $this->assertCount(2, $query->getValues()); + + /** @var Query $query0 */ + $query0 = $query->getValues()[0]; + $this->assertEquals(Query::TYPE_RELATION_EQUAL, $query0->getMethod()); + $this->assertEquals(Query::DEFAULT_ALIAS, $query0->getAlias()); + $this->assertEquals('id', $query0->getAttribute()); + $this->assertEquals('u', $query0->getRightAlias()); + $this->assertEquals('user_id', $query0->getAttributeRight()); + + /** @var Query $query1 */ + $query1 = $query->getValues()[1]; + $this->assertEquals(Query::TYPE_EQUAL, $query1->getMethod()); + $this->assertEquals('u', $query1->getAlias()); + $this->assertEquals('id', $query1->getAttribute()); + $this->assertEquals(Query::DEFAULT_ALIAS, $query1->getRightAlias()); + $this->assertEquals('', $query1->getAttributeRight()); + } + + /** + * @throws QueryException + */ + public function testJoinsParse(): void + { + $string = Query::relationEqual('left', 'id1', 'right', 'id2')->toString(); + $this->assertEquals($string, '{"method":"relationEqual","attribute":"id1","attributeRight":"id2","alias":"left","aliasRight":"right","values":[]}'); + + $query = Query::parse($string); + $this->assertEquals('relationEqual', $query->getMethod()); + $this->assertEquals('left', $query->getAlias()); + $this->assertEquals('right', $query->getRightAlias()); + $this->assertEquals('id1', $query->getAttribute()); + $this->assertEquals('id2', $query->getAttributeRight()); + + /** + * Inner join + */ + $string = Query::join( + 'users', + 'U', + [ + Query::relationEqual('left', 'id1', 'right', 'id2'), + ] + )->toString(); + + $this->assertEquals($string, '{"method":"innerJoin","alias":"U","collection":"users","values":[{"method":"relationEqual","attribute":"id1","attributeRight":"id2","alias":"left","aliasRight":"right","values":[]}]}'); + + $join = Query::parse($string); + $this->assertEquals('innerJoin', $join->getMethod()); + $this->assertEquals('users', $join->getCollection()); + + $query = $join->getValues()[0]; + $this->assertEquals('relationEqual', $query->getMethod()); + $this->assertEquals('left', $query->getAlias()); + $this->assertEquals('right', $query->getRightAlias()); + $this->assertEquals('id1', $query->getAttribute()); + $this->assertEquals('id2', $query->getAttributeRight()); + + /** + * Left join + */ + $string = Query::leftJoin( + 'users', + 'U', + [ + Query::relationEqual('left', 'id1', 'right', 'id2'), + ] + )->toString(); + + $this->assertEquals($string, '{"method":"leftJoin","alias":"U","collection":"users","values":[{"method":"relationEqual","attribute":"id1","attributeRight":"id2","alias":"left","aliasRight":"right","values":[]}]}'); + + $join = Query::parse($string); + $this->assertEquals('leftJoin', $join->getMethod()); + $this->assertEquals('users', $join->getCollection()); + + $query = $join->getValues()[0]; + $this->assertEquals('relationEqual', $query->getMethod()); + $this->assertEquals('left', $query->getAlias()); + $this->assertEquals('right', $query->getRightAlias()); + $this->assertEquals('id1', $query->getAttribute()); + $this->assertEquals('id2', $query->getAttributeRight()); + + /** + * Right join + */ + $string = Query::rightJoin( + 'users', + 'U', + [ + Query::relationEqual('left', 'id1', 'right', 'id2'), + ] + )->toString(); + + $this->assertEquals($string, '{"method":"rightJoin","alias":"U","collection":"users","values":[{"method":"relationEqual","attribute":"id1","attributeRight":"id2","alias":"left","aliasRight":"right","values":[]}]}'); + + $join = Query::parse($string); + $this->assertEquals('rightJoin', $join->getMethod()); + $this->assertEquals('users', $join->getCollection()); + + $query = $join->getValues()[0]; + $this->assertEquals('relationEqual', $query->getMethod()); + $this->assertEquals('left', $query->getAlias()); + $this->assertEquals('right', $query->getRightAlias()); + $this->assertEquals('id1', $query->getAttribute()); + $this->assertEquals('id2', $query->getAttributeRight()); + } } diff --git a/tests/unit/Validator/DocumentQueriesTest.php b/tests/unit/Validator/DocumentQueriesTest.php index 558d0b455..dd6cf6768 100644 --- a/tests/unit/Validator/DocumentQueriesTest.php +++ b/tests/unit/Validator/DocumentQueriesTest.php @@ -8,21 +8,19 @@ use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Query; -use Utopia\Database\Validator\Queries\Document as DocumentQueries; +use Utopia\Database\QueryContext; +use Utopia\Database\Validator\Queries\V2 as DocumentsValidator; class DocumentQueriesTest extends TestCase { - /** - * @var array - */ - protected array $collection = []; + protected QueryContext $context; /** * @throws Exception */ public function setUp(): void { - $this->collection = [ + $collection = [ '$collection' => ID::custom(Database::METADATA), '$id' => ID::custom('movies'), 'name' => 'movies', @@ -49,6 +47,13 @@ public function setUp(): void ]) ] ]; + + $collection = new Document($collection); + + $context = new QueryContext(); + $context->add($collection); + + $this->context = $context; } public function tearDown(): void @@ -60,25 +65,22 @@ public function tearDown(): void */ public function testValidQueries(): void { - $validator = new DocumentQueries($this->collection['attributes']); + $validator = new DocumentsValidator( + $this->context, + Database::VAR_INTEGER + ); $queries = [ - Query::select(['title']), + Query::select('title'), ]; $this->assertEquals(true, $validator->isValid($queries)); - $queries[] = Query::select(['price.relation']); - $this->assertEquals(true, $validator->isValid($queries)); - } - - /** - * @throws Exception - */ - public function testInvalidQueries(): void - { - $validator = new DocumentQueries($this->collection['attributes']); - $queries = [Query::limit(1)]; + /** + * Check the top level is a relationship attribute + */ + $queries[] = Query::select('price.relation'); $this->assertEquals(false, $validator->isValid($queries)); + $this->assertEquals('Only nested relationships allowed', $validator->getDescription()); } } diff --git a/tests/unit/Validator/DocumentsQueriesTest.php b/tests/unit/Validator/DocumentsQueriesTest.php index 6530ad299..3469107cb 100644 --- a/tests/unit/Validator/DocumentsQueriesTest.php +++ b/tests/unit/Validator/DocumentsQueriesTest.php @@ -8,21 +8,19 @@ use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Query; -use Utopia\Database\Validator\Queries\Documents; +use Utopia\Database\QueryContext; +use Utopia\Database\Validator\Queries\V2 as DocumentsValidator; class DocumentsQueriesTest extends TestCase { - /** - * @var array - */ - protected array $collection = []; + protected QueryContext $context; /** * @throws Exception */ public function setUp(): void { - $this->collection = [ + $collection = [ '$id' => Database::METADATA, '$collection' => Database::METADATA, 'name' => 'movies', @@ -112,6 +110,13 @@ public function setUp(): void ]), ], ]; + + $collection = new Document($collection); + + $context = new QueryContext(); + $context->add($collection); + + $this->context = $context; } public function tearDown(): void @@ -123,9 +128,8 @@ public function tearDown(): void */ public function testValidQueries(): void { - $validator = new Documents( - $this->collection['attributes'], - $this->collection['indexes'], + $validator = new DocumentsValidator( + $this->context, Database::VAR_INTEGER ); @@ -161,9 +165,8 @@ public function testValidQueries(): void */ public function testInvalidQueries(): void { - $validator = new Documents( - $this->collection['attributes'], - $this->collection['indexes'], + $validator = new DocumentsValidator( + $this->context, Database::VAR_INTEGER ); @@ -181,7 +184,7 @@ public function testInvalidQueries(): void $queries = [Query::limit(-1)]; $this->assertEquals(false, $validator->isValid($queries)); - $this->assertEquals('Invalid query: Invalid limit: Value must be a valid range between 1 and ' . number_format(PHP_INT_MAX), $validator->getDescription()); + $this->assertEquals('Invalid limit: Value must be a valid range between 1 and ' . number_format(PHP_INT_MAX), $validator->getDescription()); $queries = [Query::equal('title', [])]; // empty array $this->assertEquals(false, $validator->isValid($queries)); diff --git a/tests/unit/Validator/IndexedQueriesTest.php b/tests/unit/Validator/IndexedQueriesTest.php index 409fcf365..f86b92b00 100644 --- a/tests/unit/Validator/IndexedQueriesTest.php +++ b/tests/unit/Validator/IndexedQueriesTest.php @@ -7,17 +7,28 @@ use Utopia\Database\Document; use Utopia\Database\Exception; use Utopia\Database\Query; -use Utopia\Database\Validator\IndexedQueries; -use Utopia\Database\Validator\Query\Cursor; -use Utopia\Database\Validator\Query\Filter; -use Utopia\Database\Validator\Query\Limit; -use Utopia\Database\Validator\Query\Offset; -use Utopia\Database\Validator\Query\Order; +use Utopia\Database\QueryContext; +use Utopia\Database\Validator\Queries\V2 as DocumentsValidator; class IndexedQueriesTest extends TestCase { + protected Document $collection; + + /** + * @throws Exception + * @throws Exception\Query + */ public function setUp(): void { + $collection = new Document([ + '$id' => Database::METADATA, + '$collection' => Database::METADATA, + 'name' => 'movies', + 'attributes' => [], + 'indexes' => [], + ]); + + $this->collection = $collection; } public function tearDown(): void @@ -26,45 +37,70 @@ public function tearDown(): void public function testEmptyQueries(): void { - $validator = new IndexedQueries(); + $context = new QueryContext(); + $context->add($this->collection); + + $validator = new DocumentsValidator( + $context, + Database::VAR_INTEGER + ); $this->assertEquals(true, $validator->isValid([])); } public function testInvalidQuery(): void { - $validator = new IndexedQueries(); + $context = new QueryContext(); + $context->add($this->collection); + + $validator = new DocumentsValidator( + $context, + Database::VAR_INTEGER + ); $this->assertEquals(false, $validator->isValid(["this.is.invalid"])); } public function testInvalidMethod(): void { - $validator = new IndexedQueries(); - $this->assertEquals(false, $validator->isValid(['equal("attr", "value")'])); + $context = new QueryContext(); + $context->add($this->collection); + + $validator = new DocumentsValidator( + $context, + Database::VAR_INTEGER + ); - $validator = new IndexedQueries([], [], [new Limit()]); $this->assertEquals(false, $validator->isValid(['equal("attr", "value")'])); } public function testInvalidValue(): void { - $validator = new IndexedQueries([], [], [new Limit()]); + $context = new QueryContext(); + $context->add($this->collection); + + $validator = new DocumentsValidator( + $context, + Database::VAR_INTEGER + ); + $this->assertEquals(false, $validator->isValid(['limit(-1)'])); } public function testValid(): void { - $attributes = [ + $collection = $this->collection; + + $collection->setAttribute('attributes', [ new Document([ '$id' => 'name', 'key' => 'name', 'type' => Database::VAR_STRING, 'array' => false, ]), - ]; + ]); - $indexes = [ + $collection->setAttribute('indexes', [ new Document([ 'type' => Database::INDEX_KEY, 'attributes' => ['name'], @@ -73,18 +109,15 @@ public function testValid(): void 'type' => Database::INDEX_FULLTEXT, 'attributes' => ['name'], ]), - ]; - - $validator = new IndexedQueries( - $attributes, - $indexes, - [ - new Cursor(), - new Filter($attributes, Database::VAR_INTEGER), - new Limit(), - new Offset(), - new Order($attributes) - ] + ]); + + $context = new QueryContext(); + + $context->add($this->collection); + + $validator = new DocumentsValidator( + $context, + Database::VAR_INTEGER ); $query = Query::cursorAfter(new Document(['$id' => 'abc'])); @@ -123,31 +156,29 @@ public function testValid(): void public function testMissingIndex(): void { - $attributes = [ + $collection = $this->collection; + + $collection->setAttribute('attributes', [ new Document([ 'key' => 'name', 'type' => Database::VAR_STRING, 'array' => false, ]), - ]; + ]); - $indexes = [ + $collection->setAttribute('indexes', [ new Document([ 'type' => Database::INDEX_KEY, 'attributes' => ['name'], ]), - ]; - - $validator = new IndexedQueries( - $attributes, - $indexes, - [ - new Cursor(), - new Filter($attributes, Database::VAR_INTEGER), - new Limit(), - new Offset(), - new Order($attributes) - ] + ]); + + $context = new QueryContext(); + $context->add($this->collection); + + $validator = new DocumentsValidator( + $context, + Database::VAR_INTEGER ); $query = Query::equal('dne', ['value']); @@ -169,7 +200,9 @@ public function testMissingIndex(): void public function testTwoAttributesFulltext(): void { - $attributes = [ + $collection = $this->collection; + + $collection->setAttribute('attributes', [ new Document([ '$id' => 'ft1', 'key' => 'ft1', @@ -182,25 +215,21 @@ public function testTwoAttributesFulltext(): void 'type' => Database::VAR_STRING, 'array' => false, ]), - ]; + ]); - $indexes = [ + $collection->setAttribute('indexes', [ new Document([ 'type' => Database::INDEX_FULLTEXT, 'attributes' => ['ft1','ft2'], ]), - ]; - - $validator = new IndexedQueries( - $attributes, - $indexes, - [ - new Cursor(), - new Filter($attributes, Database::VAR_INTEGER), - new Limit(), - new Offset(), - new Order($attributes) - ] + ]); + + $context = new QueryContext(); + $context->add($this->collection); + + $validator = new DocumentsValidator( + $context, + Database::VAR_INTEGER ); $this->assertEquals(false, $validator->isValid([Query::search('ft1', 'value')])); diff --git a/tests/unit/Validator/QueriesTest.php b/tests/unit/Validator/QueriesTest.php index 265e9cbd0..31752ba0c 100644 --- a/tests/unit/Validator/QueriesTest.php +++ b/tests/unit/Validator/QueriesTest.php @@ -1,79 +1,80 @@ assertEquals(true, $validator->isValid([])); - } - - public function testInvalidMethod(): void - { - $validator = new Queries(); - $this->assertEquals(false, $validator->isValid([Query::equal('attr', ["value"])])); - - $validator = new Queries([new Limit()]); - $this->assertEquals(false, $validator->isValid([Query::equal('attr', ["value"])])); - } - - public function testInvalidValue(): void - { - $validator = new Queries([new Limit()]); - $this->assertEquals(false, $validator->isValid([Query::limit(-1)])); - } - - /** - * @throws Exception - */ - public function testValid(): void - { - $attributes = [ - new Document([ - '$id' => 'name', - 'key' => 'name', - 'type' => Database::VAR_STRING, - 'array' => false, - ]) - ]; - - $validator = new Queries( - [ - new Cursor(), - new Filter($attributes, Database::VAR_INTEGER), - new Limit(), - new Offset(), - new Order($attributes) - ] - ); - - $this->assertEquals(true, $validator->isValid([Query::cursorAfter(new Document(['$id' => 'asdf']))]), $validator->getDescription()); - $this->assertEquals(true, $validator->isValid([Query::equal('name', ['value'])]), $validator->getDescription()); - $this->assertEquals(true, $validator->isValid([Query::limit(10)]), $validator->getDescription()); - $this->assertEquals(true, $validator->isValid([Query::offset(10)]), $validator->getDescription()); - $this->assertEquals(true, $validator->isValid([Query::orderAsc('name')]), $validator->getDescription()); - } -} +// +//namespace Tests\Unit\Validator; +// +//use Exception; +//use PHPUnit\Framework\TestCase; +//use Utopia\Database\Database; +//use Utopia\Database\Document; +//use Utopia\Database\Query; +//use Utopia\Database\Validator\Queries; +//use Utopia\Database\Validator\Query\Cursor; +//use Utopia\Database\Validator\Query\Filter; +//use Utopia\Database\Validator\Query\Limit; +//use Utopia\Database\Validator\Query\Offset; +//use Utopia\Database\Validator\Query\Order; +// +//class QueriesTest extends TestCase +//{ +// public function setUp(): void +// { +// } +// +// public function tearDown(): void +// { +// } +// +// public function testEmptyQueries(): void +// { +// $validator = new Queries(); +// +// $this->assertEquals(true, $validator->isValid([])); +// } +// +// public function testInvalidMethod(): void +// { +// $validator = new Queries(); +// $this->assertEquals(false, $validator->isValid([Query::equal('attr', ["value"])])); +// +// $validator = new Queries([new Limit()]); +// $this->assertEquals(false, $validator->isValid([Query::equal('attr', ["value"])])); +// } +// +// public function testInvalidValue(): void +// { +// $validator = new Queries([new Limit()]); +// $this->assertEquals(false, $validator->isValid([Query::limit(-1)])); +// } +// +// /** +// * @throws Exception +// */ +// public function testValid(): void +// { +// $attributes = [ +// new Document([ +// '$id' => 'name', +// 'key' => 'name', +// 'type' => Database::VAR_STRING, +// 'array' => false, +// ]) +// ]; +// +// $validator = new Queries( +// [ +// new Cursor(), +// new Filter($attributes), +// new Limit(), +// new Offset(), +// new Order($attributes) +// ] +// ); +// +// $this->assertEquals(true, $validator->isValid([Query::cursorAfter(new Document(['$id' => 'asdf']))]), $validator->getDescription()); +// $this->assertEquals(true, $validator->isValid([Query::equal('name', ['value'])]), $validator->getDescription()); +// $this->assertEquals(true, $validator->isValid([Query::limit(10)]), $validator->getDescription()); +// $this->assertEquals(true, $validator->isValid([Query::offset(10)]), $validator->getDescription()); +// $this->assertEquals(true, $validator->isValid([Query::orderAsc('name')]), $validator->getDescription()); +// } +//} diff --git a/tests/unit/Validator/Query/CursorTest.php b/tests/unit/Validator/Query/CursorTest.php index 7f1806549..bb2c1ffe3 100644 --- a/tests/unit/Validator/Query/CursorTest.php +++ b/tests/unit/Validator/Query/CursorTest.php @@ -3,20 +3,25 @@ namespace Tests\Unit\Validator\Query; use PHPUnit\Framework\TestCase; +use Utopia\Database\Document; +use Utopia\Database\Exception; use Utopia\Database\Query; use Utopia\Database\Validator\Query\Cursor; class CursorTest extends TestCase { - public function testValueSuccess(): void + /** + * @throws Exception + */ + public function test_value_success(): void { $validator = new Cursor(); - $this->assertTrue($validator->isValid(new Query(Query::TYPE_CURSOR_AFTER, values: ['asdf']))); - $this->assertTrue($validator->isValid(new Query(Query::TYPE_CURSOR_BEFORE, values: ['asdf']))); + $this->assertTrue($validator->isValid(Query::cursorAfter(new Document(['$id' => 'asb'])))); + $this->assertTrue($validator->isValid(Query::cursorBefore(new Document(['$id' => 'asb'])))); } - public function testValueFailure(): void + public function test_value_failure(): void { $validator = new Cursor(); diff --git a/tests/unit/Validator/Query/FilterTest.php b/tests/unit/Validator/Query/FilterTest.php index a0ec65eeb..5a1599578 100644 --- a/tests/unit/Validator/Query/FilterTest.php +++ b/tests/unit/Validator/Query/FilterTest.php @@ -5,19 +5,30 @@ use PHPUnit\Framework\TestCase; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Helpers\ID; use Utopia\Database\Query; -use Utopia\Database\Validator\Query\Filter; +use Utopia\Database\QueryContext; +use Utopia\Database\Validator\Queries\V2 as DocumentsValidator; class FilterTest extends TestCase { - protected Filter|null $validator = null; + protected DocumentsValidator $validator; + protected int $maxValuesCount = 10; /** * @throws \Utopia\Database\Exception */ public function setUp(): void { - $attributes = [ + $collection = new Document([ + '$id' => Database::METADATA, + '$collection' => Database::METADATA, + 'name' => 'movies', + 'attributes' => [], + 'indexes' => [], + ]); + + $collection->setAttribute('attributes', [ new Document([ '$id' => 'string', 'key' => 'string', @@ -42,150 +53,200 @@ public function setUp(): void 'type' => Database::VAR_INTEGER, 'array' => false, ]), - ]; + new Document([ + '$id' => 'search', + 'key' => 'search', + 'type' => Database::VAR_STRING, + 'array' => false, + ]), + ]); + + $collection->setAttribute('indexes', [ + new Document([ + '$id' => ID::custom('ft'), + 'type' => Database::INDEX_FULLTEXT, + 'attributes' => ['search'], + 'lengths' => [], + 'orders' => [], + ]), + ]); + + $context = new QueryContext(); + $context->add($collection); - $this->validator = new Filter( - $attributes, - Database::VAR_INTEGER + $this->validator = new DocumentsValidator( + $context, + Database::VAR_INTEGER, + maxValuesCount: $this->maxValuesCount ); } public function testSuccess(): void { - $this->assertTrue($this->validator->isValid(Query::between('string', '1975-12-06', '2050-12-06'))); - $this->assertTrue($this->validator->isValid(Query::isNotNull('string'))); - $this->assertTrue($this->validator->isValid(Query::isNull('string'))); - $this->assertTrue($this->validator->isValid(Query::startsWith('string', 'super'))); - $this->assertTrue($this->validator->isValid(Query::endsWith('string', 'man'))); - $this->assertTrue($this->validator->isValid(Query::contains('string_array', ['super']))); - $this->assertTrue($this->validator->isValid(Query::contains('integer_array', [100,10,-1]))); - $this->assertTrue($this->validator->isValid(Query::contains('string_array', ["1","10","-1"]))); - $this->assertTrue($this->validator->isValid(Query::contains('string', ['super']))); + $this->assertTrue($this->validator->isValid([Query::between('string', '1975-12-06', '2050-12-06')])); + $this->assertTrue($this->validator->isValid([Query::isNotNull('string')])); + $this->assertTrue($this->validator->isValid([Query::isNull('string')])); + $this->assertTrue($this->validator->isValid([Query::startsWith('string', 'super')])); + $this->assertTrue($this->validator->isValid([Query::endsWith('string', 'man')])); + $this->assertTrue($this->validator->isValid([Query::contains('string_array', ['super'])])); + $this->assertTrue($this->validator->isValid([Query::contains('integer_array', [100,10,-1])])); + $this->assertTrue($this->validator->isValid([Query::contains('string_array', ["1","10","-1"])])); + $this->assertTrue($this->validator->isValid([Query::contains('string', ['super'])])); + + /** + * Non filters, Now we allow all types + */ + + $this->assertTrue($this->validator->isValid([Query::limit(1)])); + $this->assertTrue($this->validator->isValid([Query::limit(5000)])); + $this->assertTrue($this->validator->isValid([Query::offset(1)])); + $this->assertTrue($this->validator->isValid([Query::offset(5000)])); + $this->assertTrue($this->validator->isValid([Query::offset(0)])); + $this->assertTrue($this->validator->isValid([Query::orderAsc('string')])); + $this->assertTrue($this->validator->isValid([Query::orderDesc('string')])); + } public function testFailure(): void { - $this->assertFalse($this->validator->isValid(Query::select(['attr']))); - $this->assertEquals('Invalid query', $this->validator->getDescription()); - $this->assertFalse($this->validator->isValid(Query::limit(1))); - $this->assertFalse($this->validator->isValid(Query::limit(0))); - $this->assertFalse($this->validator->isValid(Query::limit(100))); - $this->assertFalse($this->validator->isValid(Query::limit(-1))); - $this->assertFalse($this->validator->isValid(Query::limit(101))); - $this->assertFalse($this->validator->isValid(Query::offset(1))); - $this->assertFalse($this->validator->isValid(Query::offset(0))); - $this->assertFalse($this->validator->isValid(Query::offset(5000))); - $this->assertFalse($this->validator->isValid(Query::offset(-1))); - $this->assertFalse($this->validator->isValid(Query::offset(5001))); - $this->assertFalse($this->validator->isValid(Query::equal('dne', ['v']))); - $this->assertFalse($this->validator->isValid(Query::equal('', ['v']))); - $this->assertFalse($this->validator->isValid(Query::orderAsc('string'))); - $this->assertFalse($this->validator->isValid(Query::orderDesc('string'))); - $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_CURSOR_AFTER, values: ['asdf']))); - $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_CURSOR_BEFORE, values: ['asdf']))); - $this->assertFalse($this->validator->isValid(Query::contains('integer', ['super']))); - $this->assertFalse($this->validator->isValid(Query::equal('integer_array', [100,-1]))); - $this->assertFalse($this->validator->isValid(Query::contains('integer_array', [10.6]))); + $this->assertFalse($this->validator->isValid([Query::select('attr')])); + $this->assertEquals('Invalid query: Attribute not found in schema: attr', $this->validator->getDescription()); + $this->assertFalse($this->validator->isValid([Query::limit(0)])); + $this->assertFalse($this->validator->isValid([Query::limit(-1)])); + $this->assertFalse($this->validator->isValid([Query::offset(-1)])); + $this->assertFalse($this->validator->isValid([Query::equal('dne', ['v'])])); + $this->assertFalse($this->validator->isValid([Query::equal('', ['v'])])); + $this->assertFalse($this->validator->isValid([Query::cursorAfter(new Document(['$uid' => 'asdf']))])); + $this->assertFalse($this->validator->isValid([Query::cursorBefore(new Document(['$uid' => 'asdf']))])); + $this->assertFalse($this->validator->isValid([Query::contains('integer', ['super'])])); + $this->assertFalse($this->validator->isValid([Query::equal('integer_array', [100,-1])])); + $this->assertFalse($this->validator->isValid([Query::contains('integer_array', [10.6])])); } public function testTypeMismatch(): void { - $this->assertFalse($this->validator->isValid(Query::equal('string', [false]))); - $this->assertEquals('Query value is invalid for attribute "string"', $this->validator->getDescription()); + $this->assertFalse($this->validator->isValid([Query::equal('string', [false])])); + $this->assertEquals('Invalid query: Query value is invalid for attribute "string"', $this->validator->getDescription()); - $this->assertFalse($this->validator->isValid(Query::equal('string', [1]))); - $this->assertEquals('Query value is invalid for attribute "string"', $this->validator->getDescription()); + $this->assertFalse($this->validator->isValid([Query::equal('string', [1])])); + $this->assertEquals('Invalid query: Query value is invalid for attribute "string"', $this->validator->getDescription()); } public function testEmptyValues(): void { - $this->assertFalse($this->validator->isValid(Query::contains('string', []))); - $this->assertEquals('Contains queries require at least one value.', $this->validator->getDescription()); + $this->assertFalse($this->validator->isValid([Query::contains('string', [])])); + $this->assertEquals('Invalid query: Contains queries require at least one value.', $this->validator->getDescription()); - $this->assertFalse($this->validator->isValid(Query::equal('string', []))); - $this->assertEquals('Equal queries require at least one value.', $this->validator->getDescription()); + $this->assertFalse($this->validator->isValid([Query::equal('string', [])])); + $this->assertEquals('Invalid query: Equal queries require at least one value.', $this->validator->getDescription()); } public function testMaxValuesCount(): void { - $max = $this->validator->getMaxValuesCount(); + $max = $this->maxValuesCount; $values = []; for ($i = 1; $i <= $max + 1; $i++) { $values[] = $i; } - $this->assertFalse($this->validator->isValid(Query::equal('integer', $values))); - $this->assertEquals('Query on attribute has greater than '.$max.' values: integer', $this->validator->getDescription()); + $this->assertFalse($this->validator->isValid([Query::equal('integer', $values)])); + $this->assertEquals('Invalid query: Query on attribute has greater than '.$max.' values: integer', $this->validator->getDescription()); } public function testNotContains(): void { // Test valid notContains queries - $this->assertTrue($this->validator->isValid(Query::notContains('string', ['unwanted']))); - $this->assertTrue($this->validator->isValid(Query::notContains('string_array', ['spam', 'unwanted']))); - $this->assertTrue($this->validator->isValid(Query::notContains('integer_array', [100, 200]))); + $this->assertTrue($this->validator->isValid([Query::notContains('string', ['unwanted'])])); + $this->assertTrue($this->validator->isValid([Query::notContains('string_array', ['spam', 'unwanted'])])); + $this->assertTrue($this->validator->isValid([Query::notContains('integer_array', [100, 200])])); // Test invalid notContains queries (empty values) - $this->assertFalse($this->validator->isValid(Query::notContains('string', []))); - $this->assertEquals('NotContains queries require at least one value.', $this->validator->getDescription()); + $this->assertFalse($this->validator->isValid([Query::notContains('string', [])])); + $this->assertEquals('Invalid query: NotContains queries require at least one value.', $this->validator->getDescription()); } public function testNotSearch(): void { + $this->assertTrue($this->validator->isValid([Query::notSearch('search', 'unwanted')])); + + // Test valid notSearch queries + $this->assertFalse($this->validator->isValid([Query::notSearch('string', 'unwanted')])); + $this->assertEquals('Searching by attribute "string" requires a fulltext index.', $this->validator->getDescription()); + + // Test that arrays cannot use notSearch + $this->assertFalse($this->validator->isValid([Query::notSearch('string_array', 'unwanted')])); + $this->assertEquals('Invalid query: Cannot query notSearch on attribute "string_array" because it is an array.', $this->validator->getDescription()); + + // Test multiple values not allowed + $query = Query::parse('{"method":"notSearch","attribute":"string","values":["word1", "word2"]}'); + $this->assertFalse($this->validator->isValid([$query])); + $this->assertEquals('Invalid query: NotSearch queries require exactly one value.', $this->validator->getDescription()); + } + + public function testSearch(): void + { + $this->assertTrue($this->validator->isValid([Query::search('search', 'unwanted')])); + // Test valid notSearch queries - $this->assertTrue($this->validator->isValid(Query::notSearch('string', 'unwanted'))); + $this->assertFalse($this->validator->isValid([Query::search('string', 'unwanted')])); + $this->assertEquals('Searching by attribute "string" requires a fulltext index.', $this->validator->getDescription()); // Test that arrays cannot use notSearch - $this->assertFalse($this->validator->isValid(Query::notSearch('string_array', 'unwanted'))); - $this->assertEquals('Cannot query notSearch on attribute "string_array" because it is an array.', $this->validator->getDescription()); + $this->assertFalse($this->validator->isValid([Query::search('string_array', 'unwanted')])); + $this->assertEquals('Invalid query: Cannot query search on attribute "string_array" because it is an array.', $this->validator->getDescription()); // Test multiple values not allowed - $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_NOT_SEARCH, 'string', ['word1', 'word2']))); - $this->assertEquals('NotSearch queries require exactly one value.', $this->validator->getDescription()); + $query = Query::parse('{"method":"search","attribute":"string","values":["word1", "word2"]}'); + $this->assertFalse($this->validator->isValid([$query])); + $this->assertEquals('Invalid query: Search queries require exactly one value.', $this->validator->getDescription()); } public function testNotStartsWith(): void { // Test valid notStartsWith queries - $this->assertTrue($this->validator->isValid(Query::notStartsWith('string', 'temp'))); + $this->assertTrue($this->validator->isValid([Query::notStartsWith('string', 'temp')])); // Test that arrays cannot use notStartsWith - $this->assertFalse($this->validator->isValid(Query::notStartsWith('string_array', 'temp'))); - $this->assertEquals('Cannot query notStartsWith on attribute "string_array" because it is an array.', $this->validator->getDescription()); + $this->assertFalse($this->validator->isValid([Query::notStartsWith('string_array', 'temp')])); + $this->assertEquals('Invalid query: Cannot query notStartsWith on attribute "string_array" because it is an array.', $this->validator->getDescription()); // Test multiple values not allowed - $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_NOT_STARTS_WITH, 'string', ['prefix1', 'prefix2']))); - $this->assertEquals('NotStartsWith queries require exactly one value.', $this->validator->getDescription()); + $query = Query::parse('{"method":"notStartsWith","attribute":"string","values":["prefix1", "prefix2"]}'); + $this->assertFalse($this->validator->isValid([$query])); + $this->assertEquals('Invalid query: NotStartsWith queries require exactly one value.', $this->validator->getDescription()); } public function testNotEndsWith(): void { // Test valid notEndsWith queries - $this->assertTrue($this->validator->isValid(Query::notEndsWith('string', '.tmp'))); + $this->assertTrue($this->validator->isValid([Query::notEndsWith('string', '.tmp')])); // Test that arrays cannot use notEndsWith - $this->assertFalse($this->validator->isValid(Query::notEndsWith('string_array', '.tmp'))); - $this->assertEquals('Cannot query notEndsWith on attribute "string_array" because it is an array.', $this->validator->getDescription()); + $this->assertFalse($this->validator->isValid([Query::notEndsWith('string_array', '.tmp')])); + $this->assertEquals('Invalid query: Cannot query notEndsWith on attribute "string_array" because it is an array.', $this->validator->getDescription()); // Test multiple values not allowed - $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_NOT_ENDS_WITH, 'string', ['suffix1', 'suffix2']))); - $this->assertEquals('NotEndsWith queries require exactly one value.', $this->validator->getDescription()); + $query = Query::parse('{"method":"notEndsWith","attribute":"string","values":["suffix1", "suffix2"]}'); + $this->assertFalse($this->validator->isValid([$query])); + $this->assertEquals('Invalid query: NotEndsWith queries require exactly one value.', $this->validator->getDescription()); } public function testNotBetween(): void { // Test valid notBetween queries - $this->assertTrue($this->validator->isValid(Query::notBetween('integer', 0, 50))); + $this->assertTrue($this->validator->isValid([Query::notBetween('integer', 0, 50)])); // Test that arrays cannot use notBetween - $this->assertFalse($this->validator->isValid(Query::notBetween('integer_array', 1, 10))); - $this->assertEquals('Cannot query notBetween on attribute "integer_array" because it is an array.', $this->validator->getDescription()); + $this->assertFalse($this->validator->isValid([Query::notBetween('integer_array', 1, 10)])); + $this->assertEquals('Invalid query: Cannot query notBetween on attribute "integer_array" because it is an array.', $this->validator->getDescription()); // Test wrong number of values - $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_NOT_BETWEEN, 'integer', [10]))); - $this->assertEquals('NotBetween queries require exactly two values.', $this->validator->getDescription()); + $query = Query::parse('{"method":"notBetween","attribute":"integer","values":[10]}'); + $this->assertFalse($this->validator->isValid([$query])); + $this->assertEquals('Invalid query: NotBetween queries require exactly two values.', $this->validator->getDescription()); - $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_NOT_BETWEEN, 'integer', [10, 20, 30]))); - $this->assertEquals('NotBetween queries require exactly two values.', $this->validator->getDescription()); + $query = Query::parse('{"method":"notBetween","attribute":"integer","values":[10, 20, 30]}'); + $this->assertFalse($this->validator->isValid([$query])); + $this->assertEquals('Invalid query: NotBetween queries require exactly two values.', $this->validator->getDescription()); } } diff --git a/tests/unit/Validator/Query/OrderTest.php b/tests/unit/Validator/Query/OrderTest.php index b84d896d1..4e2a14358 100644 --- a/tests/unit/Validator/Query/OrderTest.php +++ b/tests/unit/Validator/Query/OrderTest.php @@ -7,55 +7,70 @@ use Utopia\Database\Document; use Utopia\Database\Exception; use Utopia\Database\Query; -use Utopia\Database\Validator\Query\Base; -use Utopia\Database\Validator\Query\Order; +use Utopia\Database\QueryContext; +use Utopia\Database\Validator\Queries\V2 as DocumentsValidator; class OrderTest extends TestCase { - protected Base|null $validator = null; + protected DocumentsValidator $validator; /** * @throws Exception */ public function setUp(): void { - $this->validator = new Order( - attributes: [ - new Document([ - '$id' => 'attr', - 'key' => 'attr', - 'type' => Database::VAR_STRING, - 'array' => false, - ]), - new Document([ - '$id' => '$sequence', - 'key' => '$sequence', - 'type' => Database::VAR_STRING, - 'array' => false, - ]), - ], + $collection = new Document([ + '$id' => Database::METADATA, + '$collection' => Database::METADATA, + 'name' => 'movies', + 'attributes' => [], + 'indexes' => [], + ]); + + $collection->setAttribute('attributes', [ + new Document([ + '$id' => 'attr', + 'key' => 'attr', + 'type' => Database::VAR_STRING, + 'array' => false, + ]), + new Document([ + '$id' => '$sequence', + 'key' => '$sequence', + 'type' => Database::VAR_STRING, + 'array' => false, + ]), + ]); + + $context = new QueryContext(); + $context->add($collection); + + $this->validator = new DocumentsValidator( + $context, + Database::VAR_INTEGER, ); } public function testValueSuccess(): void { - $this->assertTrue($this->validator->isValid(Query::orderAsc('attr'))); - $this->assertTrue($this->validator->isValid(Query::orderAsc())); - $this->assertTrue($this->validator->isValid(Query::orderDesc('attr'))); - $this->assertTrue($this->validator->isValid(Query::orderDesc())); + $this->assertTrue($this->validator->isValid([Query::orderAsc('attr')])); + $this->assertTrue($this->validator->isValid([Query::orderAsc()])); + $this->assertTrue($this->validator->isValid([Query::orderDesc('attr')])); + $this->assertTrue($this->validator->isValid([Query::orderDesc()])); + $this->assertTrue($this->validator->isValid([Query::limit(101)])); + $this->assertTrue($this->validator->isValid([Query::offset(5001)])); + $this->assertTrue($this->validator->isValid([Query::equal('attr', ['v'])])); } public function testValueFailure(): void { - $this->assertFalse($this->validator->isValid(Query::limit(-1))); - $this->assertEquals('Invalid query', $this->validator->getDescription()); - $this->assertFalse($this->validator->isValid(Query::limit(101))); - $this->assertFalse($this->validator->isValid(Query::offset(-1))); - $this->assertFalse($this->validator->isValid(Query::offset(5001))); - $this->assertFalse($this->validator->isValid(Query::equal('attr', ['v']))); - $this->assertFalse($this->validator->isValid(Query::equal('dne', ['v']))); - $this->assertFalse($this->validator->isValid(Query::equal('', ['v']))); - $this->assertFalse($this->validator->isValid(Query::orderDesc('dne'))); - $this->assertFalse($this->validator->isValid(Query::orderAsc('dne'))); + $this->assertFalse($this->validator->isValid([Query::limit(-1)])); + $this->assertFalse($this->validator->isValid([Query::limit(0)])); + $this->assertEquals('Invalid limit: Value must be a valid range between 1 and 9,223,372,036,854,775,807', $this->validator->getDescription()); + $this->assertFalse($this->validator->isValid([Query::offset(-1)])); + $this->assertFalse($this->validator->isValid([Query::equal('dne', ['v'])])); + $this->assertFalse($this->validator->isValid([Query::equal('', ['v'])])); + $this->assertFalse($this->validator->isValid([Query::orderDesc('dne')])); + $this->assertFalse($this->validator->isValid([Query::orderAsc('dne')])); } } diff --git a/tests/unit/Validator/Query/SelectTest.php b/tests/unit/Validator/Query/SelectTest.php index 2dafdb94c..d8439c1c7 100644 --- a/tests/unit/Validator/Query/SelectTest.php +++ b/tests/unit/Validator/Query/SelectTest.php @@ -7,46 +7,60 @@ use Utopia\Database\Document; use Utopia\Database\Exception; use Utopia\Database\Query; -use Utopia\Database\Validator\Query\Base; -use Utopia\Database\Validator\Query\Select; +use Utopia\Database\QueryContext; +use Utopia\Database\Validator\Queries\V2 as DocumentsValidator; class SelectTest extends TestCase { - protected Base|null $validator = null; + protected DocumentsValidator $validator; /** * @throws Exception */ public function setUp(): void { - $this->validator = new Select( - attributes: [ - new Document([ - '$id' => 'attr', - 'key' => 'attr', - 'type' => Database::VAR_STRING, - 'array' => false, - ]), - new Document([ - '$id' => 'artist', - 'key' => 'artist', - 'type' => Database::VAR_RELATIONSHIP, - 'array' => false, - ]), - ], + $collection = new Document([ + '$id' => Database::METADATA, + '$collection' => Database::METADATA, + 'name' => 'movies', + 'attributes' => [], + 'indexes' => [], + ]); + + $collection->setAttribute('attributes', [ + new Document([ + '$id' => 'attr', + 'key' => 'attr', + 'type' => Database::VAR_STRING, + 'array' => false, + ]), + new Document([ + '$id' => 'artist', + 'key' => 'artist', + 'type' => Database::VAR_RELATIONSHIP, + 'array' => false, + ]), + ]); + + $context = new QueryContext(); + $context->add($collection); + + $this->validator = new DocumentsValidator( + $context, + Database::VAR_INTEGER, ); } public function testValueSuccess(): void { - $this->assertTrue($this->validator->isValid(Query::select(['*', 'attr']))); - $this->assertTrue($this->validator->isValid(Query::select(['artist.name']))); + $this->assertTrue($this->validator->isValid([Query::select('*'), Query::select('attr')])); + $this->assertTrue($this->validator->isValid([Query::select('artist.name')])); + $this->assertTrue($this->validator->isValid([Query::limit(1)])); } public function testValueFailure(): void { - $this->assertFalse($this->validator->isValid(Query::limit(1))); $this->assertEquals('Invalid query', $this->validator->getDescription()); - $this->assertFalse($this->validator->isValid(Query::select(['name.artist']))); + $this->assertFalse($this->validator->isValid([Query::select('name.artist')])); } } diff --git a/tests/unit/Validator/QueryTest.php b/tests/unit/Validator/QueryTest.php index dbe7a6b52..65faf45d3 100644 --- a/tests/unit/Validator/QueryTest.php +++ b/tests/unit/Validator/QueryTest.php @@ -7,14 +7,12 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Query; -use Utopia\Database\Validator\Queries\Documents; +use Utopia\Database\QueryContext; +use Utopia\Database\Validator\Queries\V2 as DocumentsValidator; class QueryTest extends TestCase { - /** - * @var array - */ - protected array $attributes; + protected QueryContext $context; /** * @throws Exception @@ -94,9 +92,23 @@ public function setUp(): void ], ]; - foreach ($attributes as $attribute) { - $this->attributes[] = new Document($attribute); - } + $attributes = array_map( + fn ($attribute) => new Document($attribute), + $attributes + ); + + $collection = new Document([ + '$id' => Database::METADATA, + '$collection' => Database::METADATA, + 'name' => 'movies', + 'attributes' => $attributes, + 'indexes' => [], + ]); + + $context = new QueryContext(); + $context->add($collection); + + $this->context = $context; } public function tearDown(): void @@ -108,7 +120,7 @@ public function tearDown(): void */ public function testQuery(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new DocumentsValidator($this->context, Database::VAR_INTEGER); $this->assertEquals(true, $validator->isValid([Query::equal('$id', ['Iron Man', 'Ant Man'])])); $this->assertEquals(true, $validator->isValid([Query::equal('$id', ['Iron Man'])])); @@ -129,7 +141,10 @@ public function testQuery(): void $this->assertEquals(true, $validator->isValid([Query::between('birthDay', '2024-01-01', '2023-01-01')])); $this->assertEquals(true, $validator->isValid([Query::startsWith('title', 'Fro')])); $this->assertEquals(true, $validator->isValid([Query::endsWith('title', 'Zen')])); - $this->assertEquals(true, $validator->isValid([Query::select(['title', 'description'])])); + $this->assertEquals(true, $validator->isValid([ + Query::select('title'), + Query::select('description') + ])); $this->assertEquals(true, $validator->isValid([Query::notEqual('title', '')])); } @@ -138,7 +153,7 @@ public function testQuery(): void */ public function testAttributeNotFound(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new DocumentsValidator($this->context, Database::VAR_INTEGER); $response = $validator->isValid([Query::equal('name', ['Iron Man'])]); $this->assertEquals(false, $response); @@ -154,7 +169,7 @@ public function testAttributeNotFound(): void */ public function testAttributeWrongType(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new DocumentsValidator($this->context, Database::VAR_INTEGER); $response = $validator->isValid([Query::equal('title', [1776])]); $this->assertEquals(false, $response); @@ -166,7 +181,7 @@ public function testAttributeWrongType(): void */ public function testQueryDate(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new DocumentsValidator($this->context, Database::VAR_INTEGER); $response = $validator->isValid([Query::greaterThan('birthDay', '1960-01-01 10:10:10')]); $this->assertEquals(true, $response); @@ -177,7 +192,7 @@ public function testQueryDate(): void */ public function testQueryLimit(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new DocumentsValidator($this->context, Database::VAR_INTEGER); $response = $validator->isValid([Query::limit(25)]); $this->assertEquals(true, $response); @@ -191,7 +206,7 @@ public function testQueryLimit(): void */ public function testQueryOffset(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new DocumentsValidator($this->context, Database::VAR_INTEGER); $response = $validator->isValid([Query::offset(25)]); $this->assertEquals(true, $response); @@ -205,7 +220,7 @@ public function testQueryOffset(): void */ public function testQueryOrder(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new DocumentsValidator($this->context, Database::VAR_INTEGER); $response = $validator->isValid([Query::orderAsc('title')]); $this->assertEquals(true, $response); @@ -225,7 +240,7 @@ public function testQueryOrder(): void */ public function testQueryCursor(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new DocumentsValidator($this->context, Database::VAR_INTEGER); $response = $validator->isValid([Query::cursorAfter(new Document(['$id' => 'asdf']))]); $this->assertEquals(true, $response); @@ -238,16 +253,18 @@ public function testQueryGetByType(): void { $queries = [ Query::equal('key', ['value']), - Query::select(['attr1', 'attr2']), + Query::select('attr1'), + Query::select('attr2'), Query::cursorBefore(new Document([])), Query::cursorAfter(new Document([])), ]; - $queries = Query::getByType($queries, [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); - $this->assertCount(2, $queries); - foreach ($queries as $query) { - $this->assertEquals(true, in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE])); - } + $query = Query::getCursorQueries($queries); + + $this->assertNotNull($query); + $this->assertInstanceOf(Query::class, $query); + $this->assertEquals($query->getMethod(), Query::TYPE_CURSOR_BEFORE); + $this->assertNotEquals($query->getMethod(), Query::TYPE_CURSOR_AFTER); } /** @@ -255,7 +272,7 @@ public function testQueryGetByType(): void */ public function testQueryEmpty(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new DocumentsValidator($this->context, Database::VAR_INTEGER); $response = $validator->isValid([Query::equal('title', [''])]); $this->assertEquals(true, $response); @@ -284,7 +301,7 @@ public function testQueryEmpty(): void */ public function testOrQuery(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new DocumentsValidator($this->context, Database::VAR_INTEGER); $this->assertFalse($validator->isValid( [Query::or( @@ -311,7 +328,7 @@ public function testOrQuery(): void Query::equal('price', [10]), Query::or( [ - Query::select(['price']), + Query::select('price'), Query::limit(1) ] )]