diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 8453300cc..6f0b65689 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -12,6 +12,7 @@ use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Truncate as TruncateException; +use Utopia\Database\Helpers\ID; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; @@ -1718,25 +1719,21 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $roles = Authorization::getRoles(); $where = []; $orders = []; + $defaultAlias = Query::DEFAULT_ALIAS; + $binds = []; $queries = array_map(fn ($query) => clone $query, $queries); - $orderAttributes = \array_map(fn ($orderAttribute) => match ($orderAttribute) { - '$id' => '_uid', - '$internalId' => '_id', - '$tenant' => '_tenant', - '$createdAt' => '_createdAt', - '$updatedAt' => '_updatedAt', - default => $orderAttribute - }, $orderAttributes); - $hasIdAttribute = false; foreach ($orderAttributes as $i => $attribute) { + $originalAttribute = $attribute; + + $attribute = $this->getInternalKeyForAttribute($attribute); + $attribute = $this->filter($attribute); if (\in_array($attribute, ['_uid', '_id'])) { $hasIdAttribute = true; } - $attribute = $this->filter($attribute); $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); // Get most dominant/first order attribute @@ -1750,19 +1747,25 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $orderMethod = $orderType === Database::ORDER_DESC ? Query::TYPE_LESSER : Query::TYPE_GREATER; } + if (\is_null($cursor[$originalAttribute] ?? null)) { + throw new DatabaseException("Order attribute '{$originalAttribute}' is empty"); + } + + $binds[':cursor'] = $cursor[$originalAttribute]; + $where[] = "( - table_main.`{$attribute}` {$this->getSQLOperator($orderMethod)} :cursor + {$this->quote($defaultAlias)}.{$this->quote($attribute)} {$this->getSQLOperator($orderMethod)} :cursor OR ( - table_main.`{$attribute}` = :cursor + {$this->quote($defaultAlias)}.{$this->quote($attribute)} = :cursor AND - table_main._id {$this->getSQLOperator($orderMethodInternalId)} {$cursor['$internalId']} + {$this->quote($defaultAlias)}._id {$this->getSQLOperator($orderMethodInternalId)} {$cursor['$internalId']} ) )"; } elseif ($cursorDirection === Database::CURSOR_BEFORE) { $orderType = $orderType === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; } - $orders[] = "`{$attribute}` {$orderType}"; + $orders[] = "{$this->quote($attribute)} {$orderType}"; } // Allow after pagination without any order @@ -1779,7 +1782,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, : Query::TYPE_LESSER; } - $where[] = "( table_main._id {$this->getSQLOperator($orderMethod)} {$cursor['$internalId']} )"; + $where[] = "({$this->quote($defaultAlias)}._id {$this->getSQLOperator($orderMethod)} {$cursor['$internalId']})"; } // Allow order type without any order attribute, fallback to the natural order (_id) @@ -1790,41 +1793,45 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $order = $order === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; } - $orders[] = 'table_main._id ' . $this->filter($order); + $orders[] = "{$this->quote($defaultAlias)}._id ".$this->filter($order); } else { - $orders[] = 'table_main._id ' . ($cursorDirection === Database::CURSOR_AFTER ? Database::ORDER_ASC : Database::ORDER_DESC); // Enforce last ORDER by '_id' + $orders[] = "{$this->quote($defaultAlias)}._id " . ($cursorDirection === Database::CURSOR_AFTER ? Database::ORDER_ASC : Database::ORDER_DESC); // Enforce last ORDER by '_id' } } - $conditions = $this->getSQLConditions($queries); + $conditions = $this->getSQLConditions($queries, $binds); if (!empty($conditions)) { $where[] = $conditions; } if (Authorization::$status) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $forPermission); + $where[] = $this->getSQLPermissionsCondition($name, $roles, $defaultAlias, $forPermission); } if ($this->sharedTables) { - $orIsNull = ''; - - if ($collection === Database::METADATA) { - $orIsNull = " OR table_main._tenant IS NULL"; - } - - $where[] = "(table_main._tenant = :_tenant {$orIsNull})"; + $binds[':_tenant'] = $this->tenant; + $where[] = "{$this->getTenantQuery($collection, $defaultAlias, and: '')}"; } $sqlWhere = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; $sqlOrder = 'ORDER BY ' . implode(', ', $orders); - $sqlLimit = \is_null($limit) ? '' : 'LIMIT :limit'; - $sqlLimit .= \is_null($offset) ? '' : ' OFFSET :offset'; + + $sqlLimit = ''; + if (! \is_null($limit)) { + $binds[':limit'] = $limit; + $sqlLimit = 'LIMIT :limit'; + } + + if (! \is_null($offset)) { + $binds[':offset'] = $offset; + $sqlLimit .= ' OFFSET :offset'; + } $selections = $this->getAttributeSelections($queries); $sql = " - SELECT {$this->getAttributeProjection($selections, 'table_main')} - FROM {$this->getSQLTable($name)} AS table_main + SELECT {$this->getAttributeProjection($selections, $defaultAlias)} + FROM {$this->getSQLTable($name)} AS {$this->quote($defaultAlias)} {$sqlWhere} {$sqlOrder} {$sqlLimit}; @@ -1832,41 +1839,13 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $sql = $this->trigger(Database::EVENT_DOCUMENT_FIND, $sql); - $stmt = $this->getPDO()->prepare($sql); - - foreach ($queries as $query) { - $this->bindConditionValue($stmt, $query); - } - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } - - if (!empty($cursor) && !empty($orderAttributes) && array_key_exists(0, $orderAttributes)) { - $attribute = $orderAttributes[0]; - - $attribute = match ($attribute) { - '_uid' => '$id', - '_id' => '$internalId', - '_tenant' => '$tenant', - '_createdAt' => '$createdAt', - '_updatedAt' => '$updatedAt', - default => $attribute - }; + try { + $stmt = $this->getPDO()->prepare($sql); - if (\is_null($cursor[$attribute] ?? null)) { - throw new DatabaseException("Order attribute '{$attribute}' is empty"); + foreach ($binds as $key => $value) { + $stmt->bindValue($key, $value, $this->getPDOType($value)); } - $stmt->bindValue(':cursor', $cursor[$attribute], $this->getPDOType($cursor[$attribute])); - } - if (!\is_null($limit)) { - $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); - } - if (!\is_null($offset)) { - $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); - } - - try { $stmt->execute(); } catch (PDOException $e) { throw $this->processException($e); @@ -1925,28 +1904,30 @@ public function count(string $collection, array $queries = [], ?int $max = null) { $name = $this->filter($collection); $roles = Authorization::getRoles(); + $binds = []; $where = []; - $limit = \is_null($max) ? '' : 'LIMIT :max'; + $defaultAlias = Query::DEFAULT_ALIAS; + + $limit = ''; + if (! \is_null($max)) { + $binds[':limit'] = $max; + $limit = 'LIMIT :limit'; + } $queries = array_map(fn ($query) => clone $query, $queries); - $conditions = $this->getSQLConditions($queries); + $conditions = $this->getSQLConditions($queries, $binds); if (!empty($conditions)) { $where[] = $conditions; } if (Authorization::$status) { - $where[] = $this->getSQLPermissionsCondition($name, $roles); + $where[] = $this->getSQLPermissionsCondition($name, $roles, $defaultAlias); } if ($this->sharedTables) { - $orIsNull = ''; - - if ($collection === Database::METADATA) { - $orIsNull = " OR table_main._tenant IS NULL"; - } - - $where[] = "(table_main._tenant = :_tenant {$orIsNull})"; + $binds[':_tenant'] = $this->tenant; + $where[] = "{$this->getTenantQuery($collection, $defaultAlias, and: '')}"; } $sqlWhere = !empty($where) @@ -1956,7 +1937,7 @@ public function count(string $collection, array $queries = [], ?int $max = null) $sql = " SELECT COUNT(1) as sum FROM ( SELECT 1 - FROM {$this->getSQLTable($name)} table_main + FROM {$this->getSQLTable($name)} AS {$this->quote($defaultAlias)} {$sqlWhere} {$limit} ) table_count @@ -1966,16 +1947,8 @@ public function count(string $collection, array $queries = [], ?int $max = null) $stmt = $this->getPDO()->prepare($sql); - foreach ($queries as $query) { - $this->bindConditionValue($stmt, $query); - } - - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } - - if (!\is_null($max)) { - $stmt->bindValue(':max', $max, PDO::PARAM_INT); + foreach ($binds as $key => $value) { + $stmt->bindValue($key, $value, $this->getPDOType($value)); } $stmt->execute(); @@ -2005,26 +1978,29 @@ public function sum(string $collection, string $attribute, array $queries = [], $name = $this->filter($collection); $roles = Authorization::getRoles(); $where = []; - $limit = \is_null($max) ? '' : 'LIMIT :max'; + $defaultAlias = Query::DEFAULT_ALIAS; + $binds = []; + + $limit = ''; + if (! \is_null($max)) { + $binds[':limit'] = $max; + $limit = 'LIMIT :limit'; + } $queries = array_map(fn ($query) => clone $query, $queries); - foreach ($queries as $query) { - $where[] = $this->getSQLCondition($query); + $conditions = $this->getSQLConditions($queries, $binds); + if (!empty($conditions)) { + $where[] = $conditions; } if (Authorization::$status) { - $where[] = $this->getSQLPermissionsCondition($name, $roles); + $where[] = $this->getSQLPermissionsCondition($name, $roles, $defaultAlias); } if ($this->sharedTables) { - $orIsNull = ''; - - if ($collection === Database::METADATA) { - $orIsNull = " OR table_main._tenant IS NULL"; - } - - $where[] = "(table_main._tenant = :_tenant {$orIsNull})"; + $binds[':_tenant'] = $this->tenant; + $where[] = "{$this->getTenantQuery($collection, $defaultAlias, and: '')}"; } $sqlWhere = !empty($where) @@ -2032,9 +2008,9 @@ public function sum(string $collection, string $attribute, array $queries = [], : ''; $sql = " - SELECT SUM({$attribute}) as sum FROM ( - SELECT {$attribute} - FROM {$this->getSQLTable($name)} table_main + SELECT SUM({$this->quote($attribute)}) as sum FROM ( + SELECT {$this->quote($attribute)} + FROM {$this->getSQLTable($name)} AS {$this->quote($defaultAlias)} {$sqlWhere} {$limit} ) table_count @@ -2044,16 +2020,8 @@ public function sum(string $collection, string $attribute, array $queries = [], $stmt = $this->getPDO()->prepare($sql); - foreach ($queries as $query) { - $this->bindConditionValue($stmt, $query); - } - - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } - - if (!\is_null($max)) { - $stmt->bindValue(':max', $max, PDO::PARAM_INT); + foreach ($binds as $key => $value) { + $stmt->bindValue($key, $value, $this->getPDOType($value)); } $stmt->execute(); @@ -2067,75 +2035,23 @@ public function sum(string $collection, string $attribute, array $queries = [], return $result['sum'] ?? 0; } - /** - * Get the SQL projection given the selected attributes - * - * @param array $selections - * @param string $prefix - * @return mixed - * @throws Exception - */ - protected function getAttributeProjection(array $selections, string $prefix = ''): mixed - { - if (empty($selections) || \in_array('*', $selections)) { - if (!empty($prefix)) { - return "`{$prefix}`.*"; - } - return '*'; - } - - // Remove $id, $permissions and $collection if present since it is always selected by default - $selections = \array_diff($selections, ['$id', '$permissions', '$collection']); - - $selections[] = '_uid'; - $selections[] = '_permissions'; - - if (\in_array('$internalId', $selections)) { - $selections[] = '_id'; - $selections = \array_diff($selections, ['$internalId']); - } - if (\in_array('$createdAt', $selections)) { - $selections[] = '_createdAt'; - $selections = \array_diff($selections, ['$createdAt']); - } - if (\in_array('$updatedAt', $selections)) { - $selections[] = '_updatedAt'; - $selections = \array_diff($selections, ['$updatedAt']); - } - - if (!empty($prefix)) { - foreach ($selections as &$selection) { - $selection = "`{$prefix}`.`{$this->filter($selection)}`"; - } - } else { - foreach ($selections as &$selection) { - $selection = "`{$this->filter($selection)}`"; - } - } - - return \implode(', ', $selections); - } - /** * Get SQL Condition * * @param Query $query + * @param array $binds * @return string * @throws Exception */ - protected function getSQLCondition(Query $query): string + protected function getSQLCondition(Query $query, array &$binds): string { - $query->setAttribute(match ($query->getAttribute()) { - '$id' => '_uid', - '$internalId' => '_id', - '$tenant' => '_tenant', - '$createdAt' => '_createdAt', - '$updatedAt' => '_updatedAt', - default => $query->getAttribute() - }); + $query->setAttribute($this->getInternalKeyForAttribute($query->getAttribute())); - $attribute = "`{$query->getAttribute()}`"; - $placeholder = $this->getSQLPlaceholder($query); + $attribute = $query->getAttribute(); + $attribute = $this->filter($attribute); + $attribute = $this->quote($attribute); + $alias = $this->quote(Query::DEFAULT_ALIAS); + $placeholder = ID::unique(); switch ($query->getMethod()) { case Query::TYPE_OR: @@ -2143,33 +2059,50 @@ protected function getSQLCondition(Query $query): string $conditions = []; /* @var $q Query */ foreach ($query->getValue() as $q) { - $conditions[] = $this->getSQLCondition($q); + $conditions[] = $this->getSQLCondition($q, $binds); } $method = strtoupper($query->getMethod()); + return empty($conditions) ? '' : ' '. $method .' (' . implode(' AND ', $conditions) . ')'; case Query::TYPE_SEARCH: - return "MATCH(`table_main`.{$attribute}) AGAINST (:{$placeholder}_0 IN BOOLEAN MODE)"; + $binds[":{$placeholder}_0"] = $this->getFulltextValue($query->getValue()); + + return "MATCH({$alias}.{$attribute}) AGAINST (:{$placeholder}_0 IN BOOLEAN MODE)"; case Query::TYPE_BETWEEN: - return "`table_main`.{$attribute} BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; + $binds[":{$placeholder}_0"] = $query->getValues()[0]; + $binds[":{$placeholder}_1"] = $query->getValues()[1]; + + return "{$alias}.{$attribute} BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; case Query::TYPE_IS_NULL: case Query::TYPE_IS_NOT_NULL: - return "`table_main`.{$attribute} {$this->getSQLOperator($query->getMethod())}"; + + return "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())}"; case Query::TYPE_CONTAINS: if ($this->getSupportForJSONOverlaps() && $query->onArray()) { - return "JSON_OVERLAPS(`table_main`.{$attribute}, :{$placeholder}_0)"; + $binds[":{$placeholder}_0"] = json_encode($query->getValues()); + return "JSON_OVERLAPS({$alias}.{$attribute}, :{$placeholder}_0)"; } - // no break + // no break! continue to default case default: $conditions = []; foreach ($query->getValues() as $key => $value) { - $conditions[] = "{$attribute} {$this->getSQLOperator($query->getMethod())} :{$placeholder}_{$key}"; + $value = match ($query->getMethod()) { + Query::TYPE_STARTS_WITH => $this->escapeWildcards($value) . '%', + Query::TYPE_ENDS_WITH => '%' . $this->escapeWildcards($value), + Query::TYPE_CONTAINS => $query->onArray() ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', + default => $value + }; + + $binds[":{$placeholder}_{$key}"] = $value; + $conditions[] = "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())} :{$placeholder}_{$key}"; } + return empty($conditions) ? '' : '(' . implode(' OR ', $conditions) . ')'; } } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index f2099ffad..754b11ade 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -12,6 +12,7 @@ use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Transaction as TransactionException; use Utopia\Database\Exception\Truncate as TruncateException; +use Utopia\Database\Helpers\ID; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; @@ -1497,8 +1498,6 @@ public function deleteDocument(string $collection, string $id): bool /** * Find Documents * - * Find data sets using chosen queries - * * @param string $collection * @param array $queries * @param int|null $limit @@ -1508,12 +1507,10 @@ public function deleteDocument(string $collection, string $id): bool * @param array $cursor * @param string $cursorDirection * @param string $forPermission - * * @return array * @throws DatabaseException * @throws TimeoutException - - * @throws TimeoutException + * @throws Exception */ public function find(string $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 { @@ -1521,25 +1518,21 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $roles = Authorization::getRoles(); $where = []; $orders = []; + $defaultAlias = Query::DEFAULT_ALIAS; + $binds = []; $queries = array_map(fn ($query) => clone $query, $queries); - $orderAttributes = \array_map(fn ($orderAttribute) => match ($orderAttribute) { - '$id' => '_uid', - '$internalId' => '_id', - '$tenant' => '_tenant', - '$createdAt' => '_createdAt', - '$updatedAt' => '_updatedAt', - default => $orderAttribute - }, $orderAttributes); - $hasIdAttribute = false; foreach ($orderAttributes as $i => $attribute) { + $originalAttribute = $attribute; + + $attribute = $this->getInternalKeyForAttribute($attribute); + $attribute = $this->filter($attribute); if (\in_array($attribute, ['_uid', '_id'])) { $hasIdAttribute = true; } - $attribute = $this->filter($attribute); $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); // Get most dominant/first order attribute @@ -1553,30 +1546,42 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $orderMethod = $orderType === Database::ORDER_DESC ? Query::TYPE_LESSER : Query::TYPE_GREATER; } + if (\is_null($cursor[$originalAttribute] ?? null)) { + throw new DatabaseException("Order attribute '{$originalAttribute}' is empty"); + } + + $binds[':cursor'] = $cursor[$originalAttribute]; + $where[] = "( - table_main.\"{$attribute}\" {$this->getSQLOperator($orderMethod)} :cursor + {$this->quote($defaultAlias)}.{$this->quote($attribute)} {$this->getSQLOperator($orderMethod)} :cursor OR ( - table_main.\"{$attribute}\" = :cursor + {$this->quote($defaultAlias)}.{$this->quote($attribute)} = :cursor AND - table_main._id {$this->getSQLOperator($orderMethodInternalId)} {$cursor['$internalId']} + {$this->quote($defaultAlias)}._id {$this->getSQLOperator($orderMethodInternalId)} {$cursor['$internalId']} ) )"; } elseif ($cursorDirection === Database::CURSOR_BEFORE) { $orderType = $orderType === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; } - $orders[] = '"' . $attribute . '" ' . $orderType; + $orders[] = "{$this->quote($attribute)} {$orderType}"; } // Allow after pagination without any order if (empty($orderAttributes) && !empty($cursor)) { $orderType = $orderTypes[0] ?? Database::ORDER_ASC; - $orderMethod = $cursorDirection === Database::CURSOR_AFTER ? ( - $orderType === Database::ORDER_DESC ? Query::TYPE_LESSER : Query::TYPE_GREATER - ) : ( - $orderType === Database::ORDER_DESC ? Query::TYPE_GREATER : Query::TYPE_LESSER - ); - $where[] = "( table_main._id {$this->getSQLOperator($orderMethod)} {$cursor['$internalId']} )"; + + if ($cursorDirection === Database::CURSOR_AFTER) { + $orderMethod = $orderType === Database::ORDER_DESC + ? Query::TYPE_LESSER + : Query::TYPE_GREATER; + } else { + $orderMethod = $orderType === Database::ORDER_DESC + ? Query::TYPE_GREATER + : Query::TYPE_LESSER; + } + + $where[] = "({$this->quote($defaultAlias)}._id {$this->getSQLOperator($orderMethod)} {$cursor['$internalId']})"; } // Allow order type without any order attribute, fallback to the natural order (_id) @@ -1587,40 +1592,45 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $order = $order === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; } - $orders[] = 'table_main._id ' . $this->filter($order); + $orders[] = "{$this->quote($defaultAlias)}._id ".$this->filter($order); } else { - $orders[] = 'table_main._id ' . ($cursorDirection === Database::CURSOR_AFTER ? Database::ORDER_ASC : Database::ORDER_DESC); // Enforce last ORDER by '_id' + $orders[] = "{$this->quote($defaultAlias)}._id " . ($cursorDirection === Database::CURSOR_AFTER ? Database::ORDER_ASC : Database::ORDER_DESC); // Enforce last ORDER by '_id' } } - $conditions = $this->getSQLConditions($queries); + $conditions = $this->getSQLConditions($queries, $binds); if (!empty($conditions)) { $where[] = $conditions; } + if (Authorization::$status) { + $where[] = $this->getSQLPermissionsCondition($name, $roles, $defaultAlias, $forPermission); + } + if ($this->sharedTables) { - $orIsNull = ''; + $binds[':_tenant'] = $this->tenant; + $where[] = "{$this->getTenantQuery($collection, $defaultAlias, and: '')}"; + } - if ($collection === Database::METADATA) { - $orIsNull = " OR table_main._tenant IS NULL"; - } + $sqlWhere = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + $sqlOrder = 'ORDER BY ' . implode(', ', $orders); - $where[] = "(table_main._tenant = :_tenant {$orIsNull})"; + $sqlLimit = ''; + if (! \is_null($limit)) { + $binds[':limit'] = $limit; + $sqlLimit = 'LIMIT :limit'; } - if (Authorization::$status) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $forPermission); + if (! \is_null($offset)) { + $binds[':offset'] = $offset; + $sqlLimit .= ' OFFSET :offset'; } - $sqlWhere = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; - $sqlOrder = 'ORDER BY ' . implode(', ', $orders); - $sqlLimit = \is_null($limit) ? '' : 'LIMIT :limit'; - $sqlLimit .= \is_null($offset) ? '' : ' OFFSET :offset'; $selections = $this->getAttributeSelections($queries); $sql = " - SELECT {$this->getAttributeProjection($selections, 'table_main')} - FROM {$this->getSQLTable($name)} as table_main + SELECT {$this->getAttributeProjection($selections, $defaultAlias)} + FROM {$this->getSQLTable($name)} AS {$this->quote($defaultAlias)} {$sqlWhere} {$sqlOrder} {$sqlLimit}; @@ -1628,41 +1638,13 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $sql = $this->trigger(Database::EVENT_DOCUMENT_FIND, $sql); - $stmt = $this->getPDO()->prepare($sql); - - foreach ($queries as $query) { - $this->bindConditionValue($stmt, $query); - } - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } - - if (!empty($cursor) && !empty($orderAttributes) && array_key_exists(0, $orderAttributes)) { - $attribute = $orderAttributes[0]; - - $attribute = match ($attribute) { - '_uid' => '$id', - '_id' => '$internalId', - '_tenant' => '$tenant', - '_createdAt' => '$createdAt', - '_updatedAt' => '$updatedAt', - default => $attribute - }; + try { + $stmt = $this->getPDO()->prepare($sql); - if (\is_null($cursor[$attribute] ?? null)) { - throw new DatabaseException("Order attribute '{$attribute}' is empty."); + foreach ($binds as $key => $value) { + $stmt->bindValue($key, $value, $this->getPDOType($value)); } - $stmt->bindValue(':cursor', $cursor[$attribute], $this->getPDOType($cursor[$attribute])); - } - if (!\is_null($limit)) { - $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); - } - if (!\is_null($offset)) { - $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); - } - - try { $stmt->execute(); } catch (PDOException $e) { throw $this->processException($e); @@ -1681,7 +1663,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, unset($results[$index]['_id']); } if (\array_key_exists('_tenant', $document)) { - $results[$index]['$tenant'] = $document['_tenant'] === null ? null : (int)$document['_tenant']; + $document['$tenant'] = $document['_tenant'] === null ? null : (int)$document['_tenant']; unset($results[$index]['_tenant']); } if (\array_key_exists('_createdAt', $document)) { @@ -1701,7 +1683,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, } if ($cursorDirection === Database::CURSOR_BEFORE) { - $results = array_reverse($results); + $results = \array_reverse($results); } return $results; @@ -1710,70 +1692,72 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, /** * Count Documents * - * Count data set size using chosen queries - * * @param string $collection * @param array $queries * @param int|null $max - * * @return int + * @throws Exception + * @throws PDOException */ public function count(string $collection, array $queries = [], ?int $max = null): int { $name = $this->filter($collection); $roles = Authorization::getRoles(); + $binds = []; $where = []; - $limit = \is_null($max) ? '' : 'LIMIT :max'; + $defaultAlias = Query::DEFAULT_ALIAS; + + $limit = ''; + if (! \is_null($max)) { + $binds[':limit'] = $max; + $limit = 'LIMIT :limit'; + } $queries = array_map(fn ($query) => clone $query, $queries); - $conditions = $this->getSQLConditions($queries); + $conditions = $this->getSQLConditions($queries, $binds); if (!empty($conditions)) { $where[] = $conditions; } - if ($this->sharedTables) { - $orIsNull = ''; - - if ($collection === Database::METADATA) { - $orIsNull = " OR table_main._tenant IS NULL"; - } - - $where[] = "(table_main._tenant = :_tenant {$orIsNull})"; + if (Authorization::$status) { + $where[] = $this->getSQLPermissionsCondition($name, $roles, $defaultAlias); } - if (Authorization::$status) { - $where[] = $this->getSQLPermissionsCondition($name, $roles); + if ($this->sharedTables) { + $binds[':_tenant'] = $this->tenant; + $where[] = "{$this->getTenantQuery($collection, $defaultAlias, and: '')}"; } - $sqlWhere = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + $sqlWhere = !empty($where) + ? 'WHERE ' . \implode(' AND ', $where) + : ''; + $sql = " SELECT COUNT(1) as sum FROM ( SELECT 1 - FROM {$this->getSQLTable($name)} table_main + FROM {$this->getSQLTable($name)} AS {$this->quote($defaultAlias)} {$sqlWhere} {$limit} ) table_count "; + $sql = $this->trigger(Database::EVENT_DOCUMENT_COUNT, $sql); $stmt = $this->getPDO()->prepare($sql); - foreach ($queries as $query) { - $this->bindConditionValue($stmt, $query); - } - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } - - if (!\is_null($max)) { - $stmt->bindValue(':max', $max, PDO::PARAM_INT); + foreach ($binds as $key => $value) { + $stmt->bindValue($key, $value, $this->getPDOType($value)); } $stmt->execute(); - $result = $stmt->fetch(); + $result = $stmt->fetchAll(); + $stmt->closeCursor(); + if (!empty($result)) { + $result = $result[0]; + } return $result['sum'] ?? 0; } @@ -1781,40 +1765,42 @@ public function count(string $collection, array $queries = [], ?int $max = null) /** * Sum an Attribute * - * Sum an attribute using chosen queries - * * @param string $collection * @param string $attribute * @param array $queries * @param int|null $max - * * @return int|float + * @throws Exception + * @throws PDOException */ public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null): int|float { $name = $this->filter($collection); $roles = Authorization::getRoles(); $where = []; - $limit = \is_null($max) ? '' : 'LIMIT :max'; - - $queries = array_map(fn ($query) => clone $query, $queries); + $defaultAlias = Query::DEFAULT_ALIAS; + $binds = []; - foreach ($queries as $query) { - $where[] = $this->getSQLCondition($query); + $limit = ''; + if (! \is_null($max)) { + $binds[':limit'] = $max; + $limit = 'LIMIT :limit'; } - if ($this->sharedTables) { - $orIsNull = ''; - - if ($collection === Database::METADATA) { - $orIsNull = " OR table_main._tenant IS NULL"; - } + $queries = array_map(fn ($query) => clone $query, $queries); - $where[] = "(table_main._tenant = :_tenant {$orIsNull})"; + $conditions = $this->getSQLConditions($queries, $binds); + if (!empty($conditions)) { + $where[] = $conditions; } if (Authorization::$status) { - $where[] = $this->getSQLPermissionsCondition($name, $roles); + $where[] = $this->getSQLPermissionsCondition($name, $roles, $defaultAlias); + } + + if ($this->sharedTables) { + $binds[':_tenant'] = $this->tenant; + $where[] = "{$this->getTenantQuery($collection, $defaultAlias, and: '')}"; } $sqlWhere = !empty($where) @@ -1822,9 +1808,9 @@ public function sum(string $collection, string $attribute, array $queries = [], : ''; $sql = " - SELECT SUM({$attribute}) as sum FROM ( - SELECT {$attribute} - FROM {$this->getSQLTable($name)} table_main + SELECT SUM({$this->quote($attribute)}) as sum FROM ( + SELECT {$this->quote($attribute)} + FROM {$this->getSQLTable($name)} AS {$this->quote($defaultAlias)} {$sqlWhere} {$limit} ) table_count @@ -1834,106 +1820,63 @@ public function sum(string $collection, string $attribute, array $queries = [], $stmt = $this->getPDO()->prepare($sql); - foreach ($queries as $query) { - $this->bindConditionValue($stmt, $query); - } - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } - - if (!\is_null($max)) { - $stmt->bindValue(':max', $max, PDO::PARAM_INT); + foreach ($binds as $key => $value) { + $stmt->bindValue($key, $value, $this->getPDOType($value)); } $stmt->execute(); - $result = $stmt->fetch(); - - return $result['sum'] ?? 0; - } - - /** - * Get the SQL projection given the selected attributes - * - * @param string[] $selections - * @param string $prefix - * @return string - * @throws Exception - */ - protected function getAttributeProjection(array $selections, string $prefix = ''): string - { - if (empty($selections) || \in_array('*', $selections)) { - if (!empty($prefix)) { - return "\"{$prefix}\".*"; - } - return '*'; - } - - // Remove $id ,$permissions and $collection from selections if present since they are always selected - $selections = \array_diff($selections, ['$id', '$permissions', '$collection']); - - $selections[] = '_uid'; - $selections[] = '_permissions'; - - if (\in_array('$internalId', $selections)) { - $selections[] = '_id'; - $selections = \array_diff($selections, ['$internalId']); - } - if (\in_array('$createdAt', $selections)) { - $selections[] = '_createdAt'; - $selections = \array_diff($selections, ['$createdAt']); - } - if (\in_array('$updatedAt', $selections)) { - $selections[] = '_updatedAt'; - $selections = \array_diff($selections, ['$updatedAt']); - } - - if (!empty($prefix)) { - foreach ($selections as &$selection) { - $selection = "\"{$prefix}\".\"{$this->filter($selection)}\""; - } - } else { - foreach ($selections as &$selection) { - $selection = "\"{$this->filter($selection)}\""; - } + $result = $stmt->fetchAll(); + $stmt->closeCursor(); + if (!empty($result)) { + $result = $result[0]; } - return \implode(', ', $selections); + return $result['sum'] ?? 0; } - /** * Get SQL Condition * * @param Query $query + * @param array $binds * @return string * @throws Exception */ - protected function getSQLCondition(Query $query): string + protected function getSQLCondition(Query $query, array &$binds): string { - $query->setAttribute(match ($query->getAttribute()) { - '$id' => '_uid', - '$internalId' => '_id', - '$tenant' => '_tenant', - '$createdAt' => '_createdAt', - '$updatedAt' => '_updatedAt', - default => $query->getAttribute() - }); + $query->setAttribute($this->getInternalKeyForAttribute($query->getAttribute())); - $attribute = "\"{$query->getAttribute()}\""; - $placeholder = $this->getSQLPlaceholder($query); + $attribute = $this->filter($query->getAttribute()); + $attribute = $this->quote($attribute); + $alias = $this->quote(Query::DEFAULT_ALIAS); + $placeholder = ID::unique(); $operator = null; switch ($query->getMethod()) { + case Query::TYPE_OR: + case Query::TYPE_AND: + $conditions = []; + /* @var $q Query */ + foreach ($query->getValue() as $q) { + $conditions[] = $this->getSQLCondition($q, $binds); + } + + $method = strtoupper($query->getMethod()); + return empty($conditions) ? '' : ' '. $method .' (' . implode(' AND ', $conditions) . ')'; + case Query::TYPE_SEARCH: + $binds[":{$placeholder}_0"] = $this->getFulltextValue($query->getValue()); return "to_tsvector(regexp_replace({$attribute}, '[^\w]+',' ','g')) @@ websearch_to_tsquery(:{$placeholder}_0)"; case Query::TYPE_BETWEEN: - return "table_main.{$attribute} BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; + $binds[":{$placeholder}_0"] = $query->getValues()[0]; + $binds[":{$placeholder}_1"] = $query->getValues()[1]; + return "{$alias}.{$attribute} BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; case Query::TYPE_IS_NULL: case Query::TYPE_IS_NOT_NULL: - return "table_main.{$attribute} {$this->getSQLOperator($query->getMethod())}"; + return "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())}"; case Query::TYPE_CONTAINS: $operator = $query->onArray() ? '@>' : null; @@ -1942,11 +1885,21 @@ protected function getSQLCondition(Query $query): string default: $conditions = []; $operator = $operator ?? $this->getSQLOperator($query->getMethod()); + foreach ($query->getValues() as $key => $value) { - $conditions[] = $attribute.' '.$operator.' :'.$placeholder.'_'.$key; + $value = match ($query->getMethod()) { + Query::TYPE_STARTS_WITH => $this->escapeWildcards($value) . '%', + Query::TYPE_ENDS_WITH => '%' . $this->escapeWildcards($value), + Query::TYPE_CONTAINS => $query->onArray() ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', + default => $value + }; + + $binds[":{$placeholder}_{$key}"] = $value; + + $conditions[] = "{$alias}.{$attribute} {$operator} :{$placeholder}_{$key}"; } - $condition = implode(' OR ', $conditions); - return empty($condition) ? '' : '(' . $condition . ')'; + + return empty($conditions) ? '' : '(' . implode(' OR ', $conditions) . ')'; } } diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index e197618c4..41e6c7e51 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -891,46 +891,6 @@ public function getSupportForReconnection(): bool return true; } - /** - * @param mixed $stmt - * @param Query $query - * @return void - * @throws Exception - */ - protected function bindConditionValue(mixed $stmt, Query $query): void - { - if ($query->getMethod() == Query::TYPE_SELECT) { - return; - } - - if ($query->isNested()) { - foreach ($query->getValues() as $value) { - $this->bindConditionValue($stmt, $value); - } - return; - } - - if ($this->getSupportForJSONOverlaps() && $query->onArray() && $query->getMethod() == Query::TYPE_CONTAINS) { - $placeholder = $this->getSQLPlaceholder($query) . '_0'; - $stmt->bindValue($placeholder, json_encode($query->getValues()), PDO::PARAM_STR); - return; - } - - foreach ($query->getValues() as $key => $value) { - $value = match ($query->getMethod()) { - Query::TYPE_STARTS_WITH => $this->escapeWildcards($value) . '%', - Query::TYPE_ENDS_WITH => '%' . $this->escapeWildcards($value), - Query::TYPE_SEARCH => $this->getFulltextValue($value), - Query::TYPE_CONTAINS => $query->onArray() ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', - default => $value - }; - - $placeholder = $this->getSQLPlaceholder($query) . '_' . $key; - - $stmt->bindValue($placeholder, $value, $this->getPDOType($value)); - } - } - /** * @param string $value * @return string @@ -994,22 +954,6 @@ protected function getSQLOperator(string $method): string } } - /** - * @param Query $query - * @return string - * @throws Exception - */ - protected function getSQLPlaceholder(Query $query): string - { - $json = \json_encode([$query->getAttribute(), $query->getMethod(), $query->getValues()]); - - if ($json === false) { - throw new DatabaseException('Failed to encode query'); - } - - return \md5($json); - } - public function escapeWildcards(string $value): string { $wildcards = ['%', '_', '[', ']', '^', '-', '.', '*', '+', '?', '(', ')', '{', '}', '|']; @@ -1057,6 +1001,7 @@ protected function getSQLIndexType(string $type): string protected function getSQLPermissionsCondition( string $collection, array $roles, + string $alias, string $type = Database::PERMISSION_READ ): string { if (!\in_array($type, Database::PERMISSIONS)) { @@ -1066,7 +1011,7 @@ protected function getSQLPermissionsCondition( $roles = \array_map(fn ($role) => $this->getPDO()->quote($role), $roles); $roles = \implode(', ', $roles); - return "table_main._uid IN ( + return "{$this->quote($alias)}.{$this->quote('_uid')} IN ( SELECT _document FROM {$this->getSQLTable($collection . '_perms')} WHERE _permission IN ({$roles}) @@ -1084,7 +1029,7 @@ protected function getSQLPermissionsCondition( */ protected function getSQLTable(string $name): string { - return "`{$this->getDatabase()}`.`{$this->getNamespace()}_{$this->filter($name)}`"; + return "{$this->quote($this->getDatabase())}.{$this->quote($this->getNamespace().'_'.$this->filter($name))}"; } /** @@ -1143,18 +1088,20 @@ public function getMaxIndexLength(): int /** * @param Query $query + * @param array $binds * @return string * @throws Exception */ - abstract protected function getSQLCondition(Query $query): string; + abstract protected function getSQLCondition(Query $query, array &$binds): string; /** * @param array $queries + * @param array $binds * @param string $separator * @return string * @throws Exception */ - public function getSQLConditions(array $queries = [], string $separator = 'AND'): string + public function getSQLConditions(array $queries, array &$binds, string $separator = 'AND'): string { $conditions = []; foreach ($queries as $query) { @@ -1164,9 +1111,9 @@ public function getSQLConditions(array $queries = [], string $separator = 'AND') } if ($query->isNested()) { - $conditions[] = $this->getSQLConditions($query->getValues(), $query->getMethod()); + $conditions[] = $this->getSQLConditions($query->getValues(), $binds, $query->getMethod()); } else { - $conditions[] = $this->getSQLCondition($query); + $conditions[] = $this->getSQLCondition($query, $binds); } } @@ -1192,25 +1139,26 @@ public function getSchemaAttributes(string $collection): array return []; } - public function getTenantQuery(string $collection, string $parentAlias = ''): string + public function getTenantQuery(string $collection, string $parentAlias = '', string $and = 'AND'): string { if (!$this->sharedTables) { return ''; } - if (!empty($parentAlias) || $parentAlias === '0') { - $parentAlias .= '.'; + $dot = ''; + + if ($parentAlias !== '') { + $dot = '.'; + $parentAlias = $this->quote($parentAlias); } - $query = "AND ({$parentAlias}_tenant = :_tenant"; + $orIsNull = ''; if ($collection === Database::METADATA) { - $query .= " OR {$parentAlias}_tenant IS NULL"; + $orIsNull = " OR {$parentAlias}{$dot}_tenant IS NULL"; } - $query .= ")"; - - return $query; + return "{$and} ({$parentAlias}{$dot}_tenant = :_tenant {$orIsNull})"; } protected function processException(PDOException $e): \Exception @@ -1535,4 +1483,65 @@ public function updateDocuments(string $collection, Document $updates, array $do * @return string */ abstract protected function quote(string $string): string; + + /** + * Get the SQL projection given the selected attributes + * + * @param array $selections + * @param string $prefix + * @return mixed + * @throws Exception + */ + protected function getAttributeProjection(array $selections, string $prefix = ''): mixed + { + if (empty($selections) || \in_array('*', $selections)) { + if (!empty($prefix)) { + return "{$this->quote($prefix)}.*"; + } + return '*'; + } + + // Remove $id, $permissions and $collection if present since it is always selected by default + $selections = \array_diff($selections, ['$id', '$permissions', '$collection']); + + $selections[] = '_uid'; + $selections[] = '_permissions'; + + if (\in_array('$internalId', $selections)) { + $selections[] = '_id'; + $selections = \array_diff($selections, ['$internalId']); + } + if (\in_array('$createdAt', $selections)) { + $selections[] = '_createdAt'; + $selections = \array_diff($selections, ['$createdAt']); + } + if (\in_array('$updatedAt', $selections)) { + $selections[] = '_updatedAt'; + $selections = \array_diff($selections, ['$updatedAt']); + } + + if (!empty($prefix)) { + foreach ($selections as &$selection) { + $selection = "{$this->quote($prefix)}.{$this->quote($this->filter($selection))}"; + } + } else { + foreach ($selections as &$selection) { + $selection = "{$this->quote($this->filter($selection))}"; + } + } + + return \implode(', ', $selections); + } + + protected function getInternalKeyForAttribute(string $attribute): string + { + return match ($attribute) { + '$id' => '_uid', + '$internalId' => '_id', + '$tenant' => '_tenant', + '$createdAt' => '_createdAt', + '$updatedAt' => '_updatedAt', + default => $attribute + }; + } } diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 930562c7c..8366e9627 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -1014,10 +1014,11 @@ protected function getSQLIndex(string $collection, string $id, string $type, arr * @return string * @throws Exception */ - protected function getSQLPermissionsCondition(string $collection, array $roles, string $type = Database::PERMISSION_READ): string + protected function getSQLPermissionsCondition(string $collection, array $roles, string $alias, string $type = Database::PERMISSION_READ): string { $roles = array_map(fn (string $role) => $this->getPDO()->quote($role), $roles); - return "table_main._uid IN ( + + return "{$this->quote($alias)}.{$this->quote('_uid')} IN ( SELECT distinct(_document) FROM `{$this->getNamespace()}_{$collection}_perms` WHERE _permission IN (" . implode(', ', $roles) . ") diff --git a/src/Database/Query.php b/src/Database/Query.php index 3e9684bb1..745227401 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -38,6 +38,8 @@ class Query public const TYPE_AND = 'and'; public const TYPE_OR = 'or'; + public const DEFAULT_ALIAS = 'main'; + public const TYPES = [ self::TYPE_EQUAL, self::TYPE_NOT_EQUAL,