diff --git a/composer.lock b/composer.lock index 017f29b9b..26bad1fd7 100644 --- a/composer.lock +++ b/composer.lock @@ -68,16 +68,16 @@ }, { "name": "composer/semver", - "version": "3.4.3", + "version": "3.4.4", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12" + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", "shasum": "" }, "require": { @@ -129,7 +129,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.3" + "source": "https://github.com/composer/semver/tree/3.4.4" }, "funding": [ { @@ -139,13 +139,9 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-09-19T14:15:21+00:00" + "time": "2025-08-20T19:15:30+00:00" }, { "name": "google/protobuf", @@ -1567,7 +1563,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -1628,7 +1624,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" }, "funding": [ { @@ -1639,6 +1635,10 @@ "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" @@ -1648,7 +1648,7 @@ }, { "name": "symfony/polyfill-php82", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php82.git", @@ -1704,7 +1704,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.33.0" }, "funding": [ { @@ -1715,6 +1715,10 @@ "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" @@ -1724,16 +1728,16 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "6fedf31ce4e3648f4ff5ca58bfd53127d38f05fd" + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/6fedf31ce4e3648f4ff5ca58bfd53127d38f05fd", - "reference": "6fedf31ce4e3648f4ff5ca58bfd53127d38f05fd", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", "shasum": "" }, "require": { @@ -1780,7 +1784,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" }, "funding": [ { @@ -1791,12 +1795,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-05-02T08:40:52+00:00" + "time": "2025-06-23T16:12:55+00:00" }, { "name": "symfony/service-contracts", @@ -2033,16 +2041,16 @@ }, { "name": "utopia-php/framework", - "version": "0.33.20", + "version": "0.33.22", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "e1c7ab4e0b5b0a9a70256b1e00912e101e76a131" + "reference": "c01a815cb976c9255e045fc3bcc3f5fcf477e0bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/e1c7ab4e0b5b0a9a70256b1e00912e101e76a131", - "reference": "e1c7ab4e0b5b0a9a70256b1e00912e101e76a131", + "url": "https://api.github.com/repos/utopia-php/http/zipball/c01a815cb976c9255e045fc3bcc3f5fcf477e0bc", + "reference": "c01a815cb976c9255e045fc3bcc3f5fcf477e0bc", "shasum": "" }, "require": { @@ -2074,9 +2082,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.33.20" + "source": "https://github.com/utopia-php/http/tree/0.33.22" }, - "time": "2025-05-18T23:51:21+00:00" + "time": "2025-08-26T10:29:50+00:00" }, { "name": "utopia-php/mongo", @@ -3092,16 +3100,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.24", + "version": "9.6.25", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "ea49afa29aeea25ea7bf9de9fdd7cab163cc0701" + "reference": "049c011e01be805202d8eebedef49f769a8ec7b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ea49afa29aeea25ea7bf9de9fdd7cab163cc0701", - "reference": "ea49afa29aeea25ea7bf9de9fdd7cab163cc0701", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/049c011e01be805202d8eebedef49f769a8ec7b7", + "reference": "049c011e01be805202d8eebedef49f769a8ec7b7", "shasum": "" }, "require": { @@ -3175,7 +3183,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.24" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.25" }, "funding": [ { @@ -3199,7 +3207,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T08:32:42+00:00" + "time": "2025-08-20T14:38:31+00:00" }, { "name": "rregeer/phpunit-coverage-check", diff --git a/docker-compose.yml b/docker-compose.yml index 33a984521..8e571d3c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: - ./dev/xdebug.ini:/usr/local/etc/php/conf.d/xdebug.ini - /var/run/docker.sock:/var/run/docker.sock - ./docker-compose.yml:/usr/src/code/docker-compose.yml - - ./vendor/utopia-php/mongo/src/Client.php:/usr/src/code/vendor/utopia-php/mongo/src/Client.php + #- ./vendor/utopia-php/mongo/src/Client.php:/usr/src/code/vendor/utopia-php/mongo/src/Client.php environment: PHP_IDE_CONFIG: serverName=tests @@ -31,7 +31,11 @@ services: - database postgres: - image: postgres:16.4 + build: + context: . + dockerfile: postgres.dockerfile + args: + POSTGRES_VERSION: 16 container_name: utopia-postgres networks: - database @@ -40,9 +44,14 @@ services: environment: POSTGRES_USER: root POSTGRES_PASSWORD: password + POSTGRES_DB: root postgres-mirror: - image: postgres:16.4 + build: + context: . + dockerfile: postgres.dockerfile + args: + POSTGRES_VERSION: 16 container_name: utopia-postgres-mirror networks: - database @@ -51,6 +60,7 @@ services: environment: POSTGRES_USER: root POSTGRES_PASSWORD: password + POSTGRES_DB: root mariadb: image: mariadb:10.11 diff --git a/postgres.dockerfile b/postgres.dockerfile new file mode 100644 index 000000000..0854120b6 --- /dev/null +++ b/postgres.dockerfile @@ -0,0 +1,7 @@ +FROM postgres:16 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + postgresql-16-postgis-3 \ + postgresql-16-postgis-3-scripts \ + && rm -rf /var/lib/apt/lists/* \ No newline at end of file diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 7ccea8767..e6ec4adfc 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -534,7 +534,7 @@ abstract public function analyzeCollection(string $collection): bool; * @throws TimeoutException * @throws DuplicateException */ - abstract public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false): bool; + abstract public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool; /** * Create Attributes @@ -661,54 +661,54 @@ abstract public function deleteIndex(string $collection, string $id): bool; /** * Get Document * - * @param string $collection + * @param Document $collection * @param string $id * @param array $queries * @param bool $forUpdate * @return Document */ - abstract public function getDocument(string $collection, string $id, array $queries = [], bool $forUpdate = false): Document; + abstract public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document; /** * Create Document * - * @param string $collection + * @param Document $collection * @param Document $document * * @return Document */ - abstract public function createDocument(string $collection, Document $document): Document; + abstract public function createDocument(Document $collection, Document $document): Document; /** * Create Documents in batches * - * @param string $collection + * @param Document $collection * @param array $documents * * @return array * * @throws DatabaseException */ - abstract public function createDocuments(string $collection, array $documents): array; + abstract public function createDocuments(Document $collection, array $documents): array; /** * Update Document * - * @param string $collection + * @param Document $collection * @param string $id * @param Document $document * @param bool $skipPermissions * * @return Document */ - abstract public function updateDocument(string $collection, string $id, Document $document, bool $skipPermissions): Document; + abstract public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document; /** * Update documents * * Updates all documents which match the given query. * - * @param string $collection + * @param Document $collection * @param Document $updates * @param array $documents * @@ -716,20 +716,20 @@ abstract public function updateDocument(string $collection, string $id, Document * * @throws DatabaseException */ - abstract public function updateDocuments(string $collection, Document $updates, array $documents): int; + abstract public function updateDocuments(Document $collection, Document $updates, array $documents): int; /** * Create documents if they do not exist, otherwise update them. * * If attribute is not empty, only the specified attribute will be increased, by the new value in each document. * - * @param string $collection + * @param Document $collection * @param string $attribute * @param array $changes * @return array */ abstract public function createOrUpdateDocuments( - string $collection, + Document $collection, string $attribute, array $changes ): array; @@ -767,7 +767,7 @@ abstract public function deleteDocuments(string $collection, array $sequences, a * * Find data sets using chosen queries * - * @param string $collection + * @param Document $collection * @param array $queries * @param int|null $limit * @param int|null $offset @@ -776,33 +776,32 @@ abstract public function deleteDocuments(string $collection, array $sequences, a * @param array $cursor * @param string $cursorDirection * @param string $forPermission - * * @return array */ - abstract 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; + 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; /** * Sum an attribute * - * @param string $collection + * @param Document $collection * @param string $attribute * @param array $queries * @param int|null $max * * @return int|float */ - abstract public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null): float|int; + abstract public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int; /** * Count Documents * - * @param string $collection + * @param Document $collection * @param array $queries * @param int|null $max * * @return int */ - abstract public function count(string $collection, array $queries = [], ?int $max = null): int; + abstract public function count(Document $collection, array $queries = [], ?int $max = null): int; /** * Get Collection Size of the raw data @@ -1029,6 +1028,34 @@ abstract public function getSupportForHostname(): bool; */ abstract public function getSupportForBatchCreateAttributes(): bool; + /** + * Is spatial attributes supported? + * + * @return bool + */ + abstract public function getSupportForSpatialAttributes(): bool; + + /** + * Does the adapter support null values in spatial indexes? + * + * @return bool + */ + abstract public function getSupportForSpatialIndexNull(): bool; + + /** + * Does the adapter support order attribute in spatial indexes? + * + * @return bool + */ + abstract public function getSupportForSpatialIndexOrder(): bool; + + /** + * Does the adapter includes boundary during spatial contains? + * + * @return bool + */ + abstract public function getSupportForBoundaryInclusiveContains(): bool; + /** * Get current attribute count from collection document * diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index aa8276b90..1badc3966 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -94,7 +94,8 @@ public function createCollection(string $name, array $attributes = [], array $in $attribute->getAttribute('type'), $attribute->getAttribute('size', 0), $attribute->getAttribute('signed', true), - $attribute->getAttribute('array', false) + $attribute->getAttribute('array', false), + $attribute->getAttribute('required', false) ); // Ignore relationships with virtual attributes @@ -126,6 +127,9 @@ public function createCollection(string $name, array $attributes = [], array $in $indexLength = $index->getAttribute('lengths')[$nested] ?? ''; $indexLength = (empty($indexLength)) ? '' : '(' . (int)$indexLength . ')'; $indexOrder = $index->getAttribute('orders')[$nested] ?? ''; + if ($indexType === Database::INDEX_SPATIAL && !$this->getSupportForSpatialIndexOrder() && !empty($indexOrder)) { + throw new DatabaseException('Spatial indexes with explicit orders are not supported. Remove the orders to create this index.'); + } $indexAttribute = $this->getInternalKeyForAttribute($attribute); $indexAttribute = $this->filter($indexAttribute); @@ -142,7 +146,7 @@ public function createCollection(string $name, array $attributes = [], array $in $indexAttributes = \implode(", ", $indexAttributes); - if ($this->sharedTables && $indexType !== Database::INDEX_FULLTEXT) { + if ($this->sharedTables && $indexType !== Database::INDEX_FULLTEXT && $indexType !== Database::INDEX_SPATIAL) { // Add tenant as first index column for best performance $indexAttributes = "_tenant, {$indexAttributes}"; } @@ -415,7 +419,7 @@ public function updateAttribute(string $collection, string $id, string $type, in $name = $this->filter($collection); $id = $this->filter($id); $newKey = empty($newKey) ? null : $this->filter($newKey); - $type = $this->getSQLType($type, $size, $signed, $array); + $type = $this->getSQLType($type, $size, $signed, $array, false); if (!empty($newKey)) { $sql = "ALTER TABLE {$this->getSQLTable($name)} CHANGE COLUMN `{$id}` `{$newKey}` {$type};"; @@ -458,7 +462,7 @@ public function createRelationship( $relatedTable = $this->getSQLTable($relatedName); $id = $this->filter($id); $twoWayKey = $this->filter($twoWayKey); - $sqlType = $this->getSQLType(Database::VAR_RELATIONSHIP, 0, false); + $sqlType = $this->getSQLType(Database::VAR_RELATIONSHIP, 0, false, false, false); switch ($type) { case Database::RELATION_ONE_TO_ONE: @@ -559,8 +563,9 @@ public function updateRelationship( } break; case Database::RELATION_MANY_TO_MANY: - $collection = $this->getDocument(Database::METADATA, $collection); - $relatedCollection = $this->getDocument(Database::METADATA, $relatedCollection); + $metadataCollection = new Document(['$id' => Database::METADATA]); + $collection = $this->getDocument($metadataCollection, $collection); + $relatedCollection = $this->getDocument($metadataCollection, $relatedCollection); $junction = $this->getSQLTable('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence()); @@ -642,8 +647,9 @@ public function deleteRelationship( } break; case Database::RELATION_MANY_TO_MANY: - $collection = $this->getDocument(Database::METADATA, $collection); - $relatedCollection = $this->getDocument(Database::METADATA, $relatedCollection); + $metadataCollection = new Document(['$id' => Database::METADATA]); + $collection = $this->getDocument($metadataCollection, $collection); + $relatedCollection = $this->getDocument($metadataCollection, $relatedCollection); $junction = $side === Database::RELATION_SIDE_PARENT ? $this->getSQLTable('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence()) @@ -709,7 +715,8 @@ public function renameIndex(string $collection, string $old, string $new): bool */ public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = []): bool { - $collection = $this->getDocument(Database::METADATA, $collection); + $metadataCollection = new Document(['$id' => Database::METADATA]); + $collection = $this->getDocument($metadataCollection, $collection); if ($collection->isEmpty()) { throw new NotFoundException('Collection not found'); @@ -748,12 +755,13 @@ public function createIndex(string $collection, string $id, string $type, array Database::INDEX_KEY => 'INDEX', Database::INDEX_UNIQUE => 'UNIQUE INDEX', Database::INDEX_FULLTEXT => 'FULLTEXT INDEX', - default => throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT), + Database::INDEX_SPATIAL => 'SPATIAL INDEX', + default => throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT . ', ' . Database::INDEX_SPATIAL), }; $attributes = \implode(', ', $attributes); - if ($this->sharedTables && $type !== Database::INDEX_FULLTEXT) { + if ($this->sharedTables && $type !== Database::INDEX_FULLTEXT && $type !== Database::INDEX_SPATIAL) { // Add tenant as first index column for best performance $attributes = "_tenant, {$attributes}"; } @@ -804,7 +812,7 @@ public function deleteIndex(string $collection, string $id): bool /** * Create Document * - * @param string $collection + * @param Document $collection * @param Document $document * @return Document * @throws Exception @@ -812,9 +820,11 @@ public function deleteIndex(string $collection, string $id): bool * @throws DuplicateException * @throws \Throwable */ - public function createDocument(string $collection, Document $document): Document + public function createDocument(Document $collection, Document $document): Document { try { + $spatialAttributes = $this->getSpatialAttributes($collection); + $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); @@ -836,7 +846,11 @@ public function createDocument(string $collection, Document $document): Document $column = $this->filter($attribute); $bindKey = 'key_' . $bindIndex; $columns .= "`{$column}`, "; - $columnNames .= ':' . $bindKey . ', '; + if (in_array($attribute, $spatialAttributes)) { + $columnNames .= 'ST_GeomFromText(:' . $bindKey . '), '; + } else { + $columnNames .= ':' . $bindKey . ', '; + } $bindIndex++; } @@ -922,7 +936,7 @@ public function createDocument(string $collection, Document $document): Document /** * Update Document * - * @param string $collection + * @param Document $collection * @param string $id * @param Document $document * @param bool $skipPermissions @@ -932,9 +946,11 @@ public function createDocument(string $collection, Document $document): Document * @throws DuplicateException * @throws \Throwable */ - public function updateDocument(string $collection, string $id, Document $document, bool $skipPermissions): Document + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document { try { + $spatialAttributes = $this->getSpatialAttributes($collection); + $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); @@ -1099,7 +1115,12 @@ public function updateDocument(string $collection, string $id, Document $documen foreach ($attributes as $attribute => $value) { $column = $this->filter($attribute); $bindKey = 'key_' . $bindIndex; - $columns .= "`{$column}`" . '=:' . $bindKey . ','; + + if (in_array($attribute, $spatialAttributes)) { + $columns .= "`{$column}`" . '=ST_GeomFromText(:' . $bindKey . '),'; + } else { + $columns .= "`{$column}`" . '=:' . $bindKey . ','; + } $bindIndex++; } @@ -1336,7 +1357,7 @@ public function deleteDocument(string $collection, string $id): bool /** * Find Documents * - * @param string $collection + * @param Document $collection * @param array $queries * @param int|null $limit * @param int|null $offset @@ -1350,8 +1371,12 @@ public function deleteDocument(string $collection, string $id): bool * @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 + 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 { + $spatialAttributes = $this->getSpatialAttributes($collection); + $attributes = $collection->getAttribute('attributes', []); + + $collection = $collection->getId(); $name = $this->filter($collection); $roles = Authorization::getRoles(); $where = []; @@ -1424,7 +1449,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $where[] = '(' . implode(' OR ', $cursorWhere) . ')'; } - $conditions = $this->getSQLConditions($queries, $binds); + $conditions = $this->getSQLConditions($queries, $binds, attributes:$attributes); if (!empty($conditions)) { $where[] = $conditions; } @@ -1454,8 +1479,9 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $selections = $this->getAttributeSelections($queries); + $sql = " - SELECT {$this->getAttributeProjection($selections, $alias)} + SELECT {$this->getAttributeProjection($selections, $alias, $spatialAttributes)} FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} {$sqlWhere} {$sqlOrder} @@ -1468,7 +1494,11 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $stmt = $this->getPDO()->prepare($sql); foreach ($binds as $key => $value) { - $stmt->bindValue($key, $value, $this->getPDOType($value)); + if (gettype($value) === 'double') { + $stmt->bindValue($key, $this->getFloatPrecision($value), PDO::PARAM_STR); + } else { + $stmt->bindValue($key, $value, $this->getPDOType($value)); + } } $stmt->execute(); @@ -1518,15 +1548,17 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, /** * Count Documents * - * @param string $collection + * @param Document $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 + public function count(Document $collection, array $queries = [], ?int $max = null): int { + $attributes = $collection->getAttribute("attributes", []); + $collection = $collection->getId(); $name = $this->filter($collection); $roles = Authorization::getRoles(); $binds = []; @@ -1541,7 +1573,7 @@ public function count(string $collection, array $queries = [], ?int $max = null) $queries = array_map(fn ($query) => clone $query, $queries); - $conditions = $this->getSQLConditions($queries, $binds); + $conditions = $this->getSQLConditions($queries, $binds, attributes:$attributes); if (!empty($conditions)) { $where[] = $conditions; } @@ -1590,7 +1622,7 @@ public function count(string $collection, array $queries = [], ?int $max = null) /** * Sum an Attribute * - * @param string $collection + * @param Document $collection * @param string $attribute * @param array $queries * @param int|null $max @@ -1598,8 +1630,10 @@ public function count(string $collection, array $queries = [], ?int $max = null) * @throws Exception * @throws PDOException */ - public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null): int|float + public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): int|float { + $collectionAttributes = $collection->getAttribute("attributes", []); + $collection = $collection->getId(); $name = $this->filter($collection); $roles = Authorization::getRoles(); $where = []; @@ -1614,7 +1648,7 @@ public function sum(string $collection, string $attribute, array $queries = [], $queries = array_map(fn ($query) => clone $query, $queries); - $conditions = $this->getSQLConditions($queries, $binds); + $conditions = $this->getSQLConditions($queries, $binds, attributes:$collectionAttributes); if (!empty($conditions)) { $where[] = $conditions; } @@ -1660,15 +1694,106 @@ public function sum(string $collection, string $attribute, array $queries = [], return $result['sum'] ?? 0; } + /** + * Handle spatial queries + * + * @param Query $query + * @param array $binds + * @param string $attribute + * @param string $alias + * @param string $placeholder + * @return string + */ + protected function handleSpatialQueries(Query $query, array &$binds, string $attribute, string $alias, string $placeholder): string + { + switch ($query->getMethod()) { + case Query::TYPE_CROSSES: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "ST_Crosses({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_NOT_CROSSES: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "NOT ST_Crosses({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_DISTANCE_EQUAL: + $distanceParams = $query->getValues()[0]; + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); + $binds[":{$placeholder}_1"] = $distanceParams[1]; + return "ST_Distance({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0)) = :{$placeholder}_1"; + + case Query::TYPE_DISTANCE_NOT_EQUAL: + $distanceParams = $query->getValues()[0]; + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); + $binds[":{$placeholder}_1"] = $distanceParams[1]; + return "ST_Distance({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0)) != :{$placeholder}_1"; + + case Query::TYPE_DISTANCE_GREATER_THAN: + $distanceParams = $query->getValues()[0]; + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); + $binds[":{$placeholder}_1"] = $distanceParams[1]; + return "ST_Distance({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0)) > :{$placeholder}_1"; + + case Query::TYPE_DISTANCE_LESS_THAN: + $distanceParams = $query->getValues()[0]; + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); + $binds[":{$placeholder}_1"] = $distanceParams[1]; + return "ST_Distance({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0)) < :{$placeholder}_1"; + + case Query::TYPE_INTERSECTS: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "ST_Intersects({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_NOT_INTERSECTS: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "NOT ST_Intersects({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_OVERLAPS: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "ST_Overlaps({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_NOT_OVERLAPS: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "NOT ST_Overlaps({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_TOUCHES: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "ST_Touches({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_NOT_TOUCHES: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "NOT ST_Touches({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_EQUAL: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "ST_Equals({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_NOT_EQUAL: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "NOT ST_Equals({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_CONTAINS: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "ST_Contains({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_NOT_CONTAINS: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "NOT ST_Contains({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + default: + throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); + } + } + /** * Get SQL Condition * * @param Query $query * @param array $binds + * @param array $attributes * @return string * @throws Exception */ - protected function getSQLCondition(Query $query, array &$binds): string + protected function getSQLCondition(Query $query, array &$binds, array $attributes = []): string { $query->setAttribute($this->getInternalKeyForAttribute($query->getAttribute())); @@ -1678,13 +1803,19 @@ protected function getSQLCondition(Query $query, array &$binds): string $alias = $this->quote(Query::DEFAULT_ALIAS); $placeholder = ID::unique(); + $attributeType = $this->getAttributeType($query->getAttribute(), $attributes); + + if (in_array($attributeType, Database::SPATIAL_TYPES)) { + return $this->handleSpatialQueries($query, $binds, $attribute, $alias, $placeholder); + } + 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); + $conditions[] = $this->getSQLCondition($q, $binds, $attributes); } $method = strtoupper($query->getMethod()); @@ -1696,39 +1827,66 @@ protected function getSQLCondition(Query $query, array &$binds): string return "MATCH({$alias}.{$attribute}) AGAINST (:{$placeholder}_0 IN BOOLEAN MODE)"; + case Query::TYPE_NOT_SEARCH: + $binds[":{$placeholder}_0"] = $this->getFulltextValue($query->getValue()); + + return "NOT (MATCH({$alias}.{$attribute}) AGAINST (:{$placeholder}_0 IN BOOLEAN MODE))"; + case Query::TYPE_BETWEEN: $binds[":{$placeholder}_0"] = $query->getValues()[0]; $binds[":{$placeholder}_1"] = $query->getValues()[1]; return "{$alias}.{$attribute} BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; + case Query::TYPE_NOT_BETWEEN: + $binds[":{$placeholder}_0"] = $query->getValues()[0]; + $binds[":{$placeholder}_1"] = $query->getValues()[1]; + + return "{$alias}.{$attribute} NOT BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; + case Query::TYPE_IS_NULL: case Query::TYPE_IS_NOT_NULL: return "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())}"; - case Query::TYPE_CONTAINS: + case Query::TYPE_NOT_CONTAINS: if ($this->getSupportForJSONOverlaps() && $query->onArray()) { $binds[":{$placeholder}_0"] = json_encode($query->getValues()); - return "JSON_OVERLAPS({$alias}.{$attribute}, :{$placeholder}_0)"; + $isNot = $query->getMethod() === Query::TYPE_NOT_CONTAINS; + return $isNot + ? "NOT (JSON_OVERLAPS({$alias}.{$attribute}, :{$placeholder}_0))" + : "JSON_OVERLAPS({$alias}.{$attribute}, :{$placeholder}_0)"; } - - // no break! continue to default case + // no break default: $conditions = []; + $isNotQuery = in_array($query->getMethod(), [ + Query::TYPE_NOT_STARTS_WITH, + Query::TYPE_NOT_ENDS_WITH, + Query::TYPE_NOT_CONTAINS + ]); + foreach ($query->getValues() as $key => $value) { $value = match ($query->getMethod()) { Query::TYPE_STARTS_WITH => $this->escapeWildcards($value) . '%', + Query::TYPE_NOT_STARTS_WITH => $this->escapeWildcards($value) . '%', Query::TYPE_ENDS_WITH => '%' . $this->escapeWildcards($value), - Query::TYPE_CONTAINS => $query->onArray() ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', + Query::TYPE_NOT_ENDS_WITH => '%' . $this->escapeWildcards($value), + Query::TYPE_CONTAINS => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', + Query::TYPE_NOT_CONTAINS => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', default => $value }; $binds[":{$placeholder}_{$key}"] = $value; - $conditions[] = "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())} :{$placeholder}_{$key}"; + if ($isNotQuery) { + $conditions[] = "{$alias}.{$attribute} NOT {$this->getSQLOperator($query->getMethod())} :{$placeholder}_{$key}"; + } else { + $conditions[] = "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())} :{$placeholder}_{$key}"; + } } - return empty($conditions) ? '' : '(' . implode(' OR ', $conditions) . ')'; + $separator = $isNotQuery ? ' AND ' : ' OR '; + return empty($conditions) ? '' : '(' . implode($separator, $conditions) . ')'; } } @@ -1739,10 +1897,11 @@ protected function getSQLCondition(Query $query, array &$binds): string * @param int $size * @param bool $signed * @param bool $array + * @param bool $required * @return string * @throws DatabaseException */ - protected function getSQLType(string $type, int $size, bool $signed = true, bool $array = false): string + protected function getSQLType(string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): string { if ($array === true) { return 'JSON'; @@ -1790,8 +1949,18 @@ protected function getSQLType(string $type, int $size, bool $signed = true, bool case Database::VAR_DATETIME: return 'DATETIME(3)'; + + case Database::VAR_POINT: + return 'POINT' . ($required && !$this->getSupportForSpatialIndexNull() ? ' NOT NULL' : ''); + + case Database::VAR_LINESTRING: + return 'LINESTRING' . ($required && !$this->getSupportForSpatialIndexNull() ? ' NOT NULL' : ''); + + case Database::VAR_POLYGON: + return 'POLYGON' . ($required && !$this->getSupportForSpatialIndexNull() ? ' NOT NULL' : ''); + default: - throw new DatabaseException('Unknown type: ' . $type . '. Must be one of ' . Database::VAR_STRING . ', ' . Database::VAR_INTEGER . ', ' . Database::VAR_FLOAT . ', ' . Database::VAR_BOOLEAN . ', ' . Database::VAR_DATETIME . ', ' . Database::VAR_RELATIONSHIP); + throw new DatabaseException('Unknown type: ' . $type . '. Must be one of ' . Database::VAR_STRING . ', ' . Database::VAR_INTEGER . ', ' . Database::VAR_FLOAT . ', ' . Database::VAR_BOOLEAN . ', ' . Database::VAR_DATETIME . ', ' . Database::VAR_RELATIONSHIP . ', ' . ', ' . Database::VAR_POINT . ', ' . Database::VAR_LINESTRING . ', ' . Database::VAR_POLYGON); } } @@ -1805,13 +1974,24 @@ protected function getSQLType(string $type, int $size, bool $signed = true, bool protected function getPDOType(mixed $value): int { return match (gettype($value)) { - 'double', 'string' => PDO::PARAM_STR, + 'string','double' => PDO::PARAM_STR, 'integer', 'boolean' => PDO::PARAM_INT, 'NULL' => PDO::PARAM_NULL, default => throw new DatabaseException('Unknown PDO Type for ' . \gettype($value)), }; } + /** + * Size of POINT spatial type + * + * @return int + */ + protected function getMaxPointSize(): int + { + // https://dev.mysql.com/doc/refman/8.4/en/gis-data-formats.html#gis-internal-format + return 25; + } + public function getMinDateTime(): \DateTime { return new \DateTime('1000-01-01 00:00:00'); @@ -1971,9 +2151,40 @@ public function getSupportForNumericCasting(): bool public function getSupportForIndexArray(): bool { - /** - * Disabled to be compatible with Mysql adapter - */ + return true; + } + + public function getSupportForSpatialAttributes(): bool + { + return true; + } + + /** + * Get Support for Null Values in Spatial Indexes + * + * @return bool + */ + public function getSupportForSpatialIndexNull(): bool + { return false; } + /** + * Does the adapter includes boundary during spatial contains? + * + * @return bool + */ + + public function getSupportForBoundaryInclusiveContains(): bool + { + return true; + } + /** + * Does the adapter support order attribute in spatial indexes? + * + * @return bool + */ + public function getSupportForSpatialIndexOrder(): bool + { + return true; + } } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 81c8a14c3..dc3c67b69 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -3,7 +3,6 @@ namespace Utopia\Database\Adapter; use Exception; -use MongoDB\BSON\ObjectId; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; use Utopia\Database\Adapter; @@ -410,7 +409,7 @@ public function analyzeCollection(string $collection): bool } /** - * Create Attribute + * Create Attribute * * @param string $collection * @param string $id @@ -418,10 +417,9 @@ public function analyzeCollection(string $collection): bool * @param int $size * @param bool $signed * @param bool $array - * * @return bool */ - public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false): bool + public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool { return true; } @@ -557,8 +555,9 @@ public function updateRelationship( } break; case Database::RELATION_MANY_TO_MANY: - $collection = $this->getDocument(Database::METADATA, $collection); - $relatedCollection = $this->getDocument(Database::METADATA, $relatedCollection); + $metadataCollection = new Document(['$id' => Database::METADATA]); + $collection = $this->getDocument($metadataCollection, $collection); + $relatedCollection = $this->getDocument($metadataCollection, $relatedCollection); $junction = $this->getNamespace() . '_' . $this->filter('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence()); @@ -641,6 +640,7 @@ public function deleteRelationship( * @param array $attributes * @param array $lengths * @param array $orders + * @param array $indexAttributeTypes * @param array $collation * @return bool * @throws Exception @@ -649,7 +649,6 @@ public function createIndex(string $collection, string $id, string $type, array { $name = $this->getNamespace() . '_' . $this->filter($collection); $id = $this->filter($id); - $indexes = []; $options = []; $indexes['name'] = $id; @@ -660,9 +659,11 @@ public function createIndex(string $collection, string $id, string $type, array } foreach ($attributes as $i => $attribute) { - $attributes[$i] = $this->filter($this->getInternalKeyForAttribute($attribute)); + + $attributes[$i] = $this->filter($this->getInternalKeyForAttribute($attribute)); + $orderType = $this->getOrder($this->filter($orders[$i] ?? Database::ORDER_ASC)); - $indexes['key'][$attributes[$i] ] = $orderType; + $indexes['key'][$attributes[$i]] = $orderType; switch ($type) { case Database::INDEX_KEY: @@ -697,7 +698,7 @@ public function createIndex(string $collection, string $id, string $type, array if (in_array($type, [Database::INDEX_UNIQUE, Database::INDEX_KEY])) { $partialFilter = []; foreach ($attributes as $i => $attr) { - $attrType = $indexAttributeTypes[$i] ?? 'string'; // Default to string if type not provided + $attrType = $indexAttributeTypes[$i] ?? Database::VAR_STRING; // Default to string if type not provided $attrType = $this->getMongoTypeCode($attrType); $partialFilter[$attr] = ['$exists' => true, '$type' => $attrType]; } @@ -722,7 +723,8 @@ public function createIndex(string $collection, string $id, string $type, array public function renameIndex(string $collection, string $old, string $new): bool { $collection = $this->filter($collection); - $collectionDocument = $this->getDocument(Database::METADATA, $collection); + $metadataCollection = new Document(['$id' => Database::METADATA]); + $collectionDocument = $this->getDocument($metadataCollection, $collection); $old = $this->filter($old); $new = $this->filter($new); $indexes = json_decode($collectionDocument['indexes'], true); @@ -744,7 +746,7 @@ public function renameIndex(string $collection, string $old, string $new): bool foreach ($index['attributes'] as $attrName) { foreach ($attributes as $attr) { if ($attr['key'] === $attrName) { - $indexAttributeTypes[] = $attr['type']; + $indexAttributeTypes[$attrName] = $attr['type']; break; } } @@ -791,20 +793,20 @@ public function deleteIndex(string $collection, string $id): bool /** * Get Document * - * @param string $collection + * @param Document $collection * @param string $id * @param Query[] $queries * @return Document * @throws MongoException */ - public function getDocument(string $collection, string $id, array $queries = [], bool $forUpdate = false): Document + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document { - $name = $this->getNamespace() . '_' . $this->filter($collection); + $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); $filters = ['_uid' => $id]; if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection); + $filters['_tenant'] = $this->getTenantFilters($collection->getId()); } $options = []; @@ -815,7 +817,11 @@ public function getDocument(string $collection, string $id, array $queries = [], $options['projection'] = $this->getAttributeProjection($selections); } - $result = $this->client->find($name, $filters, $options)->cursor->firstBatch; + try { + $result = $this->client->find($name, $filters, $options)->cursor->firstBatch; + } catch (MongoException $e) { + throw $this->processException($e); + } if (empty($result)) { return new Document([]); @@ -829,16 +835,16 @@ public function getDocument(string $collection, string $id, array $queries = [], /** * Create Document * - * @param string $collection + * @param Document $collection * @param Document $document * * @return Document * @throws Exception */ - public function createDocument(string $collection, Document $document): Document + public function createDocument(Document $collection, Document $document): Document { - $name = $this->getNamespace() . '_' . $this->filter($collection); + $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); $sequence = $document->getSequence(); @@ -984,16 +990,16 @@ public function castingBefore(Document $collection, Document $document): Documen /** * Create Documents in batches * - * @param string $collection + * @param Document $collection * @param array $documents * * @return array * * @throws Duplicate */ - public function createDocuments(string $collection, array $documents): array + public function createDocuments(Document $collection, array $documents): array { - $name = $this->getNamespace() . '_' . $this->filter($collection); + $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); $records = []; $hasSequence = null; @@ -1049,11 +1055,15 @@ private function insertDocument(string $name, array $document): array $filters['_tenant'] = $this->getTenantFilters($name); } - $result = $this->client->find( - $name, - $filters, - ['limit' => 1] - )->cursor->firstBatch[0]; + try { + $result = $this->client->find( + $name, + $filters, + ['limit' => 1] + )->cursor->firstBatch[0]; + } catch (MongoException $e) { + throw $this->processException($e); + } return $this->client->toArray($result); } catch (MongoException $e) { @@ -1064,7 +1074,7 @@ private function insertDocument(string $name, array $document): array /** * Update Document * - * @param string $collection + * @param Document $collection * @param string $id * @param Document $document * @param bool $skipPermissions @@ -1072,9 +1082,9 @@ private function insertDocument(string $name, array $document): array * @throws DatabaseException * @throws Duplicate */ - public function updateDocument(string $collection, string $id, Document $document, bool $skipPermissions): Document + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document { - $name = $this->getNamespace() . '_' . $this->filter($collection); + $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); $record = $document->getArrayCopy(); $record = $this->replaceChars('$', '_', $record); @@ -1083,7 +1093,7 @@ public function updateDocument(string $collection, string $id, Document $documen $filters['_uid'] = $id; if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection); + $filters['_tenant'] = $this->getTenantFilters($collection->getId()); } try { @@ -1102,7 +1112,7 @@ public function updateDocument(string $collection, string $id, Document $documen * * Updates all documents which match the given query. * - * @param string $collection + * @param Document $collection * @param Document $updates * @param array $documents * @@ -1110,10 +1120,10 @@ public function updateDocument(string $collection, string $id, Document $documen * * @throws DatabaseException */ - public function updateDocuments(string $collection, Document $updates, array $documents): int + public function updateDocuments(Document $collection, Document $updates, array $documents): int { ; - $name = $this->getNamespace() . '_' . $this->filter($collection); + $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); $queries = [ Query::equal('$sequence', \array_map(fn ($document) => $document->getSequence(), $documents)) @@ -1122,7 +1132,7 @@ public function updateDocuments(string $collection, Document $updates, array $do $filters = $this->buildFilters($queries); if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection); + $filters['_tenant'] = $this->getTenantFilters($collection->getId()); } $record = $updates->getArrayCopy(); @@ -1142,19 +1152,19 @@ public function updateDocuments(string $collection, Document $updates, array $do } /** - * @param string $collection + * @param Document $collection * @param string $attribute * @param array $changes * @return array */ - public function createOrUpdateDocuments(string $collection, string $attribute, array $changes): array + public function createOrUpdateDocuments(Document $collection, string $attribute, array $changes): array { if (empty($changes)) { return $changes; } try { - $name = $this->getNamespace() . '_' . $this->filter($collection); + $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); $attribute = $this->filter($attribute); $operations = []; @@ -1180,7 +1190,7 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a $filters = ['_uid' => $document->getId()]; if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection); + $filters['_tenant'] = $this->getTenantFilters($collection->getId()); } unset($record['_id']); // Don't update _id @@ -1266,8 +1276,11 @@ public function getSequences(string $collection, array $documents): array if ($this->sharedTables) { $filters['_tenant'] = $this->getTenantFilters($collection, $documentTenants); } - - $results = $this->client->find($name, $filters, ['projection' => ['_uid' => 1, '_id' => 1]]); + try { + $results = $this->client->find($name, $filters, ['projection' => ['_uid' => 1, '_id' => 1]]); + } catch (MongoException $e) { + throw $this->processException($e); + } foreach ($results->cursor->firstBatch as $result) { $sequences[$result->_uid] = (string)$result->_id; @@ -1432,33 +1445,33 @@ protected function getInternalKeyForAttribute(string $attribute): string /** - * Find Documents - * - * Find data sets using chosen queries - * - * @param string $collection - * @param array $queries - * @param int|null $limit - * @param int|null $offset - * @param array $orderAttributes - * @param array $orderTypes - * @param array $cursor - * @param string $cursorDirection - * @param string $forPermission - * - * @return array - * @throws Exception - * @throws Timeout - */ - 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 - { - $name = $this->getNamespace() . '_' . $this->filter($collection); + * Find Documents + * + * Find data sets using chosen queries + * + * @param Document $collection + * @param array $queries + * @param int|null $limit + * @param int|null $offset + * @param array $orderAttributes + * @param array $orderTypes + * @param array $cursor + * @param string $cursorDirection + * @param string $forPermission + * + * @return array + * @throws Exception + * @throws Timeout + */ + 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 + { + $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); $queries = array_map(fn ($query) => clone $query, $queries); $filters = $this->buildFilters($queries); if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection); + $filters['_tenant'] = $this->getTenantFilters($collection->getId()); } // permissions @@ -1468,6 +1481,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, } $options = []; + if (!\is_null($limit)) { $options['limit'] = $limit; } @@ -1560,19 +1574,51 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $found = []; try { - $results = $this->client->find($name, $filters, $options)->cursor->firstBatch ?? []; - } catch (MongoException $e) { - throw $this->processException($e); - } + // Use proper cursor iteration with reasonable batch size + $batchSize = 1000; + $options['batchSize'] = $batchSize; - if (empty($results)) { - return $found; - } + $response = $this->client->find($name, $filters, $options); + $results = $response->cursor->firstBatch ?? []; + + // Process first batch + foreach ($results as $result) { + $record = $this->replaceChars('_', '$', (array)$result); + $found[] = new Document($record); + } + + // Get cursor ID for subsequent batches + $cursorId = $response->cursor->id ?? null; + + // Continue fetching with getMore + while ($cursorId && $cursorId !== 0) { + // Check if limit is reached + if (!\is_null($limit) && count($found) >= $limit) { + break; + } + + $moreResponse = $this->client->getMore($cursorId, $name, $batchSize); + $moreResults = $moreResponse->cursor->nextBatch ?? []; + + if (empty($moreResults)) { + break; + } + + foreach ($moreResults as $result) { + $record = $this->replaceChars('_', '$', (array)$result); + $found[] = new Document($record); + + // Check limit again after each document + if (!\is_null($limit) && count($found) >= $limit) { + break 2; // Break both inner and outer loops + } + } - foreach ($this->client->toArray($results) as $result) { - $record = $this->replaceChars('_', '$', (array)$result); + $cursorId = $moreResponse->cursor->id ?? 0; + } - $found[] = new Document($record); + } catch (MongoException $e) { + throw $this->processException($e); } if ($cursorDirection === Database::CURSOR_BEFORE) { @@ -1644,26 +1690,24 @@ private function replaceInternalIdsKeys(array $array, string $from, string $to, /** - * Count Documents - * - * @param string $collection - * @param array $queries - * @param int|null $max - * - * @return int - * @throws Exception - */ - public function count(string $collection, array $queries = [], ?int $max = null): int + * Count Documents + * + * @param Document $collection + * @param array $queries + * @param int|null $max + * @return int + * @throws Exception + */ + public function count(Document $collection, array $queries = [], ?int $max = null): int { - $name = $this->getNamespace() . '_' . $this->filter($collection); + $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); $queries = array_map(fn ($query) => clone $query, $queries); $filters = []; $options = []; - // set max limit - if ($max > 0) { + if (!\is_null($max) && $max > 0) { $options['limit'] = $max; } @@ -1671,26 +1715,85 @@ public function count(string $collection, array $queries = [], ?int $max = null) $options['maxTimeMS'] = $this->timeout; } - // queries + // Build filters from queries $filters = $this->buildFilters($queries); if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection); + $filters['_tenant'] = $this->getTenantFilters($collection->getId()); } - // permissions - if (Authorization::$status) { // skip if authorization is disabled + // Add permissions filter if authorization is enabled + if (Authorization::$status) { $roles = \implode('|', Authorization::getRoles()); $filters['_permissions']['$in'] = [new Regex("read\(\".*(?:{$roles}).*\"\)", 'i')]; } - return $this->client->count($name, $filters, $options); + /** + * Use MongoDB aggregation pipeline for accurate counting + * Accuracy and Sharded Clusters + * "On a sharded cluster, the count command when run without a query predicate can result in an inaccurate + * count if orphaned documents exist or if a chunk migration is in progress. + * To avoid these situations, on a sharded cluster, use the db.collection.aggregate() method" + * https://www.mongodb.com/docs/manual/reference/command/count/#response + **/ + + // Original count command (commented for reference and fallback) + // Use this for single-instance MongoDB when performance is critical and accuracy is not a concern + // return $this->client->count($name, $filters, $options); + + $pipeline = []; + + // Add match stage if filters are provided + if (!empty($filters)) { + $pipeline[] = ['$match' => $this->client->toObject($filters)]; + } + + // Add limit stage if specified + if (!\is_null($max) && $max > 0) { + $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) { + // When limit is specified, use $group and $sum to count limited documents + $pipeline[] = [ + '$group' => [ + '_id' => null, + 'total' => ['$sum' => 1]] + ]; + } else { + // When no limit is passed, use $count for better performance + $pipeline[] = [ + '$count' => 'total' + ]; + } + + try { + $result = $this->client->aggregate($name, $pipeline); + + // Aggregation returns stdClass with cursor property containing firstBatch + if (isset($result->cursor) && !empty($result->cursor->firstBatch)) { + $firstResult = $result->cursor->firstBatch[0]; + + // Handle both $count and $group response formats + if (isset($firstResult->total)) { + return (int)$firstResult->total; + } + } + + return 0; + } catch (MongoException $e) { + return 0; + } } + /** * Sum an attribute * - * @param string $collection + * @param Document $collection * @param string $attribute * @param array $queries * @param int|null $max @@ -1698,16 +1801,17 @@ public function count(string $collection, array $queries = [], ?int $max = null) * @return int|float * @throws Exception */ - public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null): float|int + + public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int { - $name = $this->getNamespace() . '_' . $this->filter($collection); + $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); // queries $queries = array_map(fn ($query) => clone $query, $queries); $filters = $this->buildFilters($queries); if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection); + $filters['_tenant'] = $this->getTenantFilters($collection->getId()); } // permissions @@ -2128,7 +2232,7 @@ public function getSupportForFulltextWildcardIndex(): bool */ public function getSupportForQueryContains(): bool { - return true; + return false; } /** @@ -2306,6 +2410,45 @@ public function getSupportForCasting(): bool { return true; } + /** + * Is spatial attributes supported? + * + * @return bool + */ + public function getSupportForSpatialAttributes(): bool + { + return false; + } + + /** + * Get Support for Null Values in Spatial Indexes + * + * @return bool + */ + public function getSupportForSpatialIndexNull(): bool + { + return false; + } + /** + * Does the adapter includes boundary during spatial contains? + * + * @return bool + */ + + public function getSupportForBoundaryInclusiveContains(): bool + { + return false; + } + /** + * Does the adapter support order attribute in spatial indexes? + * + * @return bool + */ + public function getSupportForSpatialIndexOrder(): bool + { + return false; + } + /** * Flattens the array. @@ -2357,6 +2500,7 @@ public function getKeywords(): array protected function processException(Exception $e): \Exception { + // Timeout if ($e->getCode() === 50) { return new Timeout('Query timed out', $e->getCode(), $e); diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index e222930b0..be0cd79d3 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -81,10 +81,9 @@ public function getSizeOfCollectionOnDisk(string $collection): int public function getSupportForIndexArray(): bool { /** - * Disabling index creation due to Mysql bug * @link https://bugs.mysql.com/bug.php?id=111037 */ - return false; + return true; } public function getSupportForCastIndexArray(): bool @@ -110,4 +109,22 @@ protected function processException(PDOException $e): \Exception return parent::processException($e); } + /** + * Does the adapter includes boundary during spatial contains? + * + * @return bool + */ + public function getSupportForBoundaryInclusiveContains(): bool + { + return false; + } + /** + * Does the adapter support order attribute in spatial indexes? + * + * @return bool + */ + public function getSupportForSpatialIndexOrder(): bool + { + return false; + } } diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 060d6bb45..21190d11d 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -165,7 +165,7 @@ public function analyzeCollection(string $collection): bool return $this->delegate(__FUNCTION__, \func_get_args()); } - public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false): bool + public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool { return $this->delegate(__FUNCTION__, \func_get_args()); } @@ -220,32 +220,32 @@ public function deleteIndex(string $collection, string $id): bool return $this->delegate(__FUNCTION__, \func_get_args()); } - public function getDocument(string $collection, string $id, array $queries = [], bool $forUpdate = false): Document + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document { return $this->delegate(__FUNCTION__, \func_get_args()); } - public function createDocument(string $collection, Document $document): Document + public function createDocument(Document $collection, Document $document): Document { return $this->delegate(__FUNCTION__, \func_get_args()); } - public function createDocuments(string $collection, array $documents): array + public function createDocuments(Document $collection, array $documents): array { return $this->delegate(__FUNCTION__, \func_get_args()); } - public function updateDocument(string $collection, string $id, Document $document, bool $skipPermissions): Document + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document { return $this->delegate(__FUNCTION__, \func_get_args()); } - public function updateDocuments(string $collection, Document $updates, array $documents): int + public function updateDocuments(Document $collection, Document $updates, array $documents): int { return $this->delegate(__FUNCTION__, \func_get_args()); } - public function createOrUpdateDocuments(string $collection, string $attribute, array $changes): array + public function createOrUpdateDocuments(Document $collection, string $attribute, array $changes): array { return $this->delegate(__FUNCTION__, \func_get_args()); } @@ -260,17 +260,17 @@ public function deleteDocuments(string $collection, array $sequences, array $per return $this->delegate(__FUNCTION__, \func_get_args()); } - 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 + 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 { return $this->delegate(__FUNCTION__, \func_get_args()); } - public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null): float|int + public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int { return $this->delegate(__FUNCTION__, \func_get_args()); } - public function count(string $collection, array $queries = [], ?int $max = null): int + public function count(Document $collection, array $queries = [], ?int $max = null): int { return $this->delegate(__FUNCTION__, \func_get_args()); } @@ -425,6 +425,16 @@ public function getSupportForBatchCreateAttributes(): bool return $this->delegate(__FUNCTION__, \func_get_args()); } + public function getSupportForSpatialAttributes(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + public function getSupportForSpatialIndexNull(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + public function getCountOfAttributes(Document $collection): int { return $this->delegate(__FUNCTION__, \func_get_args()); @@ -460,7 +470,13 @@ public function getKeywords(): array return $this->delegate(__FUNCTION__, \func_get_args()); } - protected function getAttributeProjection(array $selections, string $prefix): mixed + /** + * @param array $selections + * @param string $prefix + * @param array $spatialAttributes + * @return mixed + */ + protected function getAttributeProjection(array $selections, string $prefix, array $spatialAttributes = []): mixed { return $this->delegate(__FUNCTION__, \func_get_args()); } @@ -485,6 +501,11 @@ public function getSchemaAttributes(string $collection): array return $this->delegate(__FUNCTION__, \func_get_args()); } + public function getTenantQuery(string $collection, string $alias = ''): string + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + protected function execute(mixed $stmt): bool { return $this->delegate(__FUNCTION__, \func_get_args()); @@ -500,6 +521,16 @@ public function getSequences(string $collection, array $documents): array return $this->delegate(__FUNCTION__, \func_get_args()); } + public function getSupportForBoundaryInclusiveContains(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + public function getSupportForSpatialIndexOrder(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + public function castingBefore(Document $collection, Document $document): Document { return $this->delegate(__FUNCTION__, \func_get_args()); @@ -510,12 +541,12 @@ public function castingAfter(Document $collection, Document $document): Document return $this->delegate(__FUNCTION__, \func_get_args()); } - public function getSupportForInternalCasting(): bool + public function isMongo(): bool { return $this->delegate(__FUNCTION__, \func_get_args()); } - public function isMongo(): bool + public function getSupportForInternalCasting(): bool { return $this->delegate(__FUNCTION__, \func_get_args()); } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 5430ba17f..13583bb1b 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -148,6 +148,9 @@ public function create(string $name): bool ->prepare($sql) ->execute(); + // extension for supporting spatial types + $this->getPDO()->prepare('CREATE EXTENSION IF NOT EXISTS postgis;')->execute(); + $collation = " CREATE COLLATION IF NOT EXISTS utf8_ci ( provider = icu, @@ -203,7 +206,8 @@ public function createCollection(string $name, array $attributes = [], array $in $attribute->getAttribute('type'), $attribute->getAttribute('size', 0), $attribute->getAttribute('signed', true), - $attribute->getAttribute('array', false) + $attribute->getAttribute('array', false), + $attribute->getAttribute('required', false) ); // Ignore relationships with virtual attributes @@ -304,6 +308,9 @@ public function createCollection(string $name, array $attributes = [], array $in } } $indexOrders = $index->getAttribute('orders', []); + if ($indexType === Database::INDEX_SPATIAL && count($indexOrders)) { + throw new DatabaseException('Spatial indexes with explicit orders are not supported. Remove the orders to create this index.'); + } $this->createIndex( $id, $indexId, @@ -437,11 +444,11 @@ public function analyzeCollection(string $collection): bool * @return bool * @throws DatabaseException */ - public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false): bool + public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool { $name = $this->filter($collection); $id = $this->filter($id); - $type = $this->getSQLType($type, $size, $signed, $array); + $type = $this->getSQLType($type, $size, $signed, $array, $required); $sql = " ALTER TABLE {$this->getSQLTable($name)} @@ -538,7 +545,7 @@ public function updateAttribute(string $collection, string $id, string $type, in $name = $this->filter($collection); $id = $this->filter($id); $newKey = empty($newKey) ? null : $this->filter($newKey); - $type = $this->getSQLType($type, $size, $signed, $array); + $type = $this->getSQLType($type, $size, $signed, $array, false); if ($type == 'TIMESTAMP(3)') { $type = "TIMESTAMP(3) without time zone USING TO_TIMESTAMP(\"$id\", 'YYYY-MM-DD HH24:MI:SS.MS')"; @@ -605,7 +612,7 @@ public function createRelationship( $relatedTable = $this->getSQLTable($relatedName); $id = $this->filter($id); $twoWayKey = $this->filter($twoWayKey); - $sqlType = $this->getSQLType(Database::VAR_RELATIONSHIP, 0, false); + $sqlType = $this->getSQLType(Database::VAR_RELATIONSHIP, 0, false, false, false); switch ($type) { case Database::RELATION_ONE_TO_ONE: @@ -705,8 +712,9 @@ public function updateRelationship( } break; case Database::RELATION_MANY_TO_MANY: - $collection = $this->getDocument(Database::METADATA, $collection); - $relatedCollection = $this->getDocument(Database::METADATA, $relatedCollection); + $metadataCollection = new Document(['$id' => Database::METADATA]); + $collection = $this->getDocument($metadataCollection, $collection); + $relatedCollection = $this->getDocument($metadataCollection, $relatedCollection); $junction = $this->getSQLTable('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence()); @@ -789,8 +797,9 @@ public function deleteRelationship( } break; case Database::RELATION_MANY_TO_MANY: - $collection = $this->getDocument(Database::METADATA, $collection); - $relatedCollection = $this->getDocument(Database::METADATA, $relatedCollection); + $metadataCollection = new Document(['$id' => Database::METADATA]); + $collection = $this->getDocument($metadataCollection, $collection); + $relatedCollection = $this->getDocument($metadataCollection, $relatedCollection); $junction = $side === Database::RELATION_SIDE_PARENT ? $this->getSQLTable('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence()) @@ -860,18 +869,27 @@ public function createIndex(string $collection, string $id, string $type, array Database::INDEX_KEY, Database::INDEX_FULLTEXT => 'INDEX', Database::INDEX_UNIQUE => 'UNIQUE INDEX', - default => throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT), + Database::INDEX_SPATIAL => 'INDEX', + default => throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT . ', ' . Database::INDEX_SPATIAL), }; $key = "\"{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}\""; $attributes = \implode(', ', $attributes); - if ($this->sharedTables && $type !== Database::INDEX_FULLTEXT) { + // Spatial indexes can't include _tenant because GIST indexes require all columns to have compatible operator classes + if ($this->sharedTables && $type !== Database::INDEX_FULLTEXT && $type !== Database::INDEX_SPATIAL) { // Add tenant as first index column for best performance $attributes = "_tenant, {$attributes}"; } - $sql = "CREATE {$sqlType} {$key} ON {$this->getSQLTable($collection)} ({$attributes});"; + $sql = "CREATE {$sqlType} {$key} ON {$this->getSQLTable($collection)}"; + + // Add USING GIST for spatial indexes + if ($type === Database::INDEX_SPATIAL) { + $sql .= " USING GIST"; + } + + $sql .= " ({$attributes});"; $sql = $this->trigger(Database::EVENT_INDEX_CREATE, $sql); @@ -934,13 +952,14 @@ public function renameIndex(string $collection, string $old, string $new): bool /** * Create Document * - * @param string $collection + * @param Document $collection * @param Document $document * * @return Document */ - public function createDocument(string $collection, Document $document): Document + public function createDocument(Document $collection, Document $document): Document { + $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); @@ -1044,7 +1063,7 @@ public function createDocument(string $collection, Document $document): Document * Update Document * * - * @param string $collection + * @param Document $collection * @param string $id * @param Document $document * @param bool $skipPermissions @@ -1052,8 +1071,9 @@ public function createDocument(string $collection, Document $document): Document * @throws DatabaseException * @throws DuplicateException */ - public function updateDocument(string $collection, string $id, Document $document, bool $skipPermissions): Document + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document { + $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); @@ -1427,7 +1447,7 @@ public function deleteDocument(string $collection, string $id): bool /** * Find Documents * - * @param string $collection + * @param Document $collection * @param array $queries * @param int|null $limit * @param int|null $offset @@ -1441,8 +1461,11 @@ public function deleteDocument(string $collection, string $id): bool * @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 + 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 { + $spatialAttributes = $this->getSpatialAttributes($collection); + $attributes = $collection->getAttribute('attributes', []); + $collection = $collection->getId(); $name = $this->filter($collection); $roles = Authorization::getRoles(); $where = []; @@ -1515,7 +1538,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $where[] = '(' . implode(' OR ', $cursorWhere) . ')'; } - $conditions = $this->getSQLConditions($queries, $binds); + $conditions = $this->getSQLConditions($queries, $binds, attributes:$attributes); if (!empty($conditions)) { $where[] = $conditions; } @@ -1546,7 +1569,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $selections = $this->getAttributeSelections($queries); $sql = " - SELECT {$this->getAttributeProjection($selections, $alias)} + SELECT {$this->getAttributeProjection($selections, $alias, $spatialAttributes)} FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} {$sqlWhere} {$sqlOrder} @@ -1557,10 +1580,14 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, try { $stmt = $this->getPDO()->prepare($sql); + foreach ($binds as $key => $value) { - $stmt->bindValue($key, $value, $this->getPDOType($value)); + if (gettype($value) === 'double') { + $stmt->bindValue($key, $this->getFloatPrecision($value), PDO::PARAM_STR); + } else { + $stmt->bindValue($key, $value, $this->getPDOType($value)); + } } - $this->execute($stmt); } catch (PDOException $e) { throw $this->processException($e); @@ -1607,16 +1634,17 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, /** * Count Documents - * - * @param string $collection + * @param Document $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 + public function count(Document $collection, array $queries = [], ?int $max = null): int { + $collectionAttributes = $collection->getAttribute("attributes", []); + $collection = $collection->getId(); $name = $this->filter($collection); $roles = Authorization::getRoles(); $binds = []; @@ -1631,7 +1659,7 @@ public function count(string $collection, array $queries = [], ?int $max = null) $queries = array_map(fn ($query) => clone $query, $queries); - $conditions = $this->getSQLConditions($queries, $binds); + $conditions = $this->getSQLConditions($queries, $binds, attributes:$collectionAttributes); if (!empty($conditions)) { $where[] = $conditions; } @@ -1681,7 +1709,7 @@ public function count(string $collection, array $queries = [], ?int $max = null) /** * Sum an Attribute * - * @param string $collection + * @param Document $collection * @param string $attribute * @param array $queries * @param int|null $max @@ -1689,8 +1717,10 @@ public function count(string $collection, array $queries = [], ?int $max = null) * @throws Exception * @throws PDOException */ - public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null): int|float + public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): int|float { + $collectionAttributes = $collection->getAttribute("attributes", []); + $collection = $collection->getId(); $name = $this->filter($collection); $roles = Authorization::getRoles(); $where = []; @@ -1705,7 +1735,7 @@ public function sum(string $collection, string $attribute, array $queries = [], $queries = array_map(fn ($query) => clone $query, $queries); - $conditions = $this->getSQLConditions($queries, $binds); + $conditions = $this->getSQLConditions($queries, $binds, attributes:$collectionAttributes); if (!empty($conditions)) { $where[] = $conditions; } @@ -1760,15 +1790,108 @@ public function getConnectionId(): string return $stmt->fetchColumn(); } + /** + * Handle spatial queries + * + * @param Query $query + * @param array $binds + * @param string $attribute + * @param string $alias + * @param string $placeholder + * @return string + */ + protected function handleSpatialQueries(Query $query, array &$binds, string $attribute, string $alias, string $placeholder): string + { + switch ($query->getMethod()) { + case Query::TYPE_CROSSES: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "ST_Crosses({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_NOT_CROSSES: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "NOT ST_Crosses({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_DISTANCE_EQUAL: + $distanceParams = $query->getValues()[0]; + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); + $binds[":{$placeholder}_1"] = $distanceParams[1]; + return "ST_DWithin({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0), :{$placeholder}_1)"; + + case Query::TYPE_DISTANCE_NOT_EQUAL: + $distanceParams = $query->getValues()[0]; + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); + $binds[":{$placeholder}_1"] = $distanceParams[1]; + return "NOT ST_DWithin({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0), :{$placeholder}_1)"; + + case Query::TYPE_DISTANCE_GREATER_THAN: + $distanceParams = $query->getValues()[0]; + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); + $binds[":{$placeholder}_1"] = $distanceParams[1]; + return "ST_Distance({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0)) > :{$placeholder}_1"; + + case Query::TYPE_DISTANCE_LESS_THAN: + $distanceParams = $query->getValues()[0]; + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); + $binds[":{$placeholder}_1"] = $distanceParams[1]; + return "ST_Distance({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0)) < :{$placeholder}_1"; + + case Query::TYPE_EQUAL: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "ST_Equals({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_NOT_EQUAL: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "NOT ST_Equals({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_INTERSECTS: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "ST_Intersects({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_NOT_INTERSECTS: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "NOT ST_Intersects({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_OVERLAPS: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "ST_Overlaps({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_NOT_OVERLAPS: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "NOT ST_Overlaps({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_TOUCHES: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "ST_Touches({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_NOT_TOUCHES: + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return "NOT ST_Touches({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + case Query::TYPE_CONTAINS: + case Query::TYPE_NOT_CONTAINS: + // using st_cover instead of contains to match the boundary matching behaviour of the mariadb st_contains + // postgis st_contains excludes matching the boundary + $isNot = $query->getMethod() === Query::TYPE_NOT_CONTAINS; + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); + return $isNot + ? "NOT ST_Covers({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))" + : "ST_Covers({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + + default: + throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); + } + } + /** * Get SQL Condition * * @param Query $query * @param array $binds + * @param array $attributes * @return string * @throws Exception */ - protected function getSQLCondition(Query $query, array &$binds): string + protected function getSQLCondition(Query $query, array &$binds, array $attributes = []): string { $query->setAttribute($this->getInternalKeyForAttribute($query->getAttribute())); @@ -1776,15 +1899,21 @@ protected function getSQLCondition(Query $query, array &$binds): string $attribute = $this->quote($attribute); $alias = $this->quote(Query::DEFAULT_ALIAS); $placeholder = ID::unique(); + + $attributeType = $this->getAttributeType($query->getAttribute(), $attributes); $operator = null; + if (in_array($attributeType, Database::SPATIAL_TYPES)) { + return $this->handleSpatialQueries($query, $binds, $attribute, $alias, $placeholder); + } + 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); + $conditions[] = $this->getSQLCondition($q, $binds, $attributes); } $method = strtoupper($query->getMethod()); @@ -1794,36 +1923,67 @@ protected function getSQLCondition(Query $query, array &$binds): string $binds[":{$placeholder}_0"] = $this->getFulltextValue($query->getValue()); return "to_tsvector(regexp_replace({$attribute}, '[^\w]+',' ','g')) @@ websearch_to_tsquery(:{$placeholder}_0)"; + case Query::TYPE_NOT_SEARCH: + $binds[":{$placeholder}_0"] = $this->getFulltextValue($query->getValue()); + return "NOT (to_tsvector(regexp_replace({$attribute}, '[^\w]+',' ','g')) @@ websearch_to_tsquery(:{$placeholder}_0))"; + case Query::TYPE_BETWEEN: $binds[":{$placeholder}_0"] = $query->getValues()[0]; $binds[":{$placeholder}_1"] = $query->getValues()[1]; return "{$alias}.{$attribute} BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; + case Query::TYPE_NOT_BETWEEN: + $binds[":{$placeholder}_0"] = $query->getValues()[0]; + $binds[":{$placeholder}_1"] = $query->getValues()[1]; + return "{$alias}.{$attribute} NOT BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; + case Query::TYPE_IS_NULL: case Query::TYPE_IS_NOT_NULL: return "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())}"; case Query::TYPE_CONTAINS: - $operator = $query->onArray() ? '@>' : null; + case Query::TYPE_NOT_CONTAINS: + if ($query->onArray()) { + $operator = '@>'; + } else { + $operator = null; + } // no break default: $conditions = []; $operator = $operator ?? $this->getSQLOperator($query->getMethod()); + $isNotQuery = in_array($query->getMethod(), [ + Query::TYPE_NOT_STARTS_WITH, + Query::TYPE_NOT_ENDS_WITH, + Query::TYPE_NOT_CONTAINS + ]); foreach ($query->getValues() as $key => $value) { $value = match ($query->getMethod()) { Query::TYPE_STARTS_WITH => $this->escapeWildcards($value) . '%', + Query::TYPE_NOT_STARTS_WITH => $this->escapeWildcards($value) . '%', Query::TYPE_ENDS_WITH => '%' . $this->escapeWildcards($value), - Query::TYPE_CONTAINS => $query->onArray() ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', + Query::TYPE_NOT_ENDS_WITH => '%' . $this->escapeWildcards($value), + Query::TYPE_CONTAINS => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', + Query::TYPE_NOT_CONTAINS => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', default => $value }; $binds[":{$placeholder}_{$key}"] = $value; - $conditions[] = "{$alias}.{$attribute} {$operator} :{$placeholder}_{$key}"; + + if ($isNotQuery && $query->onArray()) { + // For array NOT queries, wrap the entire condition in NOT() + $conditions[] = "NOT ({$alias}.{$attribute} {$operator} :{$placeholder}_{$key})"; + } elseif ($isNotQuery && !$query->onArray()) { + $conditions[] = "{$alias}.{$attribute} NOT {$operator} :{$placeholder}_{$key}"; + } else { + $conditions[] = "{$alias}.{$attribute} {$operator} :{$placeholder}_{$key}"; + } } - return empty($conditions) ? '' : '(' . implode(' OR ', $conditions) . ')'; + $separator = $isNotQuery ? ' AND ' : ' OR '; + return empty($conditions) ? '' : '(' . implode($separator, $conditions) . ')'; } } @@ -1852,10 +2012,11 @@ protected function getFulltextValue(string $value): string * @param int $size in chars * @param bool $signed * @param bool $array + * @param bool $required * @return string * @throws DatabaseException */ - protected function getSQLType(string $type, int $size, bool $signed = true, bool $array = false): string + protected function getSQLType(string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): string { if ($array === true) { return 'JSONB'; @@ -1893,8 +2054,18 @@ protected function getSQLType(string $type, int $size, bool $signed = true, bool case Database::VAR_DATETIME: return 'TIMESTAMP(3)'; + + case Database::VAR_POINT: + return 'GEOMETRY(POINT)'; + + case Database::VAR_LINESTRING: + return 'GEOMETRY(LINESTRING)'; + + case Database::VAR_POLYGON: + return 'GEOMETRY(POLYGON)'; + default: - throw new DatabaseException('Unknown Type: ' . $type); + throw new DatabaseException('Unknown Type: ' . $type . '. Must be one of ' . Database::VAR_STRING . ', ' . Database::VAR_INTEGER . ', ' . Database::VAR_FLOAT . ', ' . Database::VAR_BOOLEAN . ', ' . Database::VAR_DATETIME . ', ' . Database::VAR_RELATIONSHIP . ', ' . Database::VAR_POINT . ', ' . Database::VAR_LINESTRING . ', ' . Database::VAR_POLYGON); } } @@ -1931,6 +2102,18 @@ protected function getPDOType(mixed $value): int }; } + /** + * Size of POINT spatial type + * + * @return int + */ + protected function getMaxPointSize(): int + { + // https://stackoverflow.com/questions/30455025/size-of-data-type-geographypoint-4326-in-postgis + return 32; + } + + /** * Encode array * @@ -2069,4 +2252,44 @@ protected function quote(string $string): string { return "\"{$string}\""; } + + /** + * Is spatial attributes supported? + * + * @return bool + */ + public function getSupportForSpatialAttributes(): bool + { + return true; + } + + /** + * Does the adapter support null values in spatial indexes? + * + * @return bool + */ + public function getSupportForSpatialIndexNull(): bool + { + return true; + } + + /** + * Does the adapter includes boundary during spatial contains? + * + * @return bool + */ + public function getSupportForBoundaryInclusiveContains(): bool + { + return true; + } + + /** + * Does the adapter support order attribute in spatial indexes? + * + * @return bool + */ + public function getSupportForSpatialIndexOrder(): bool + { + return false; + } } diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index f9e74a5a6..3e81fc704 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -13,11 +13,33 @@ use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Transaction as TransactionException; use Utopia\Database\Query; +use Utopia\Database\Validator\Spatial; abstract class SQL extends Adapter { protected mixed $pdo; + /** + * Controls how many fractional digits are used when binding float parameters. + */ + protected int $floatPrecision = 17; + + /** + * Configure float precision for parameter binding/logging. + */ + public function setFloatPrecision(int $precision): void + { + $this->floatPrecision = $precision; + } + + /** + * Helper to format a float value according to configured precision for binding/logging. + */ + protected function getFloatPrecision(float $value): string + { + return sprintf('%.'. $this->floatPrecision . 'F', $value); + } + /** * Constructor. * @@ -57,7 +79,7 @@ public function startTransaction(): bool } $this->inTransaction++; - return $result; + return true; } /** @@ -214,11 +236,10 @@ public function list(): array * @throws Exception * @throws PDOException */ - public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false): bool + public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool { $id = $this->quote($this->filter($id)); - $type = $this->getSQLType($type, $size, $signed, $array); - + $type = $this->getSQLType($type, $size, $signed, $array, $required); $sql = "ALTER TABLE {$this->getSQLTable($collection)} ADD COLUMN {$id} {$type};"; $sql = $this->trigger(Database::EVENT_ATTRIBUTE_CREATE, $sql); @@ -249,6 +270,7 @@ public function createAttributes(string $collection, array $attributes): bool $attribute['size'], $attribute['signed'] ?? true, $attribute['array'] ?? false, + $attribute['required'] ?? false, ); $parts[] = "{$id} {$type}"; } @@ -324,15 +346,18 @@ public function deleteAttribute(string $collection, string $id, bool $array = fa /** * Get Document * - * @param string $collection + * @param Document $collection * @param string $id * @param Query[] $queries * @param bool $forUpdate * @return Document * @throws DatabaseException */ - public function getDocument(string $collection, string $id, array $queries = [], bool $forUpdate = false): Document + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document { + $spatialAttributes = $this->getSpatialAttributes($collection); + $collection = $collection->getId(); + $name = $this->filter($collection); $selections = $this->getAttributeSelections($queries); @@ -341,7 +366,7 @@ public function getDocument(string $collection, string $id, array $queries = [], $alias = Query::DEFAULT_ALIAS; $sql = " - SELECT {$this->getAttributeProjection($selections, $alias)} + SELECT {$this->getAttributeProjection($selections, $alias, $spatialAttributes)} FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} WHERE {$this->quote($alias)}.{$this->quote('_uid')} = :_uid {$this->getTenantQuery($collection, $alias)} @@ -398,968 +423,646 @@ public function getDocument(string $collection, string $id, array $queries = [], } /** - * Create Documents in batches + * Helper method to extract spatial type attributes from collection attributes * - * @param string $collection + * @param Document $collection + * @return array + */ + protected function getSpatialAttributes(Document $collection): array + { + $collectionAttributes = $collection->getAttribute('attributes', []); + $spatialAttributes = []; + foreach ($collectionAttributes as $attr) { + if ($attr instanceof Document) { + $attributeType = $attr->getAttribute('type'); + if (in_array($attributeType, Database::SPATIAL_TYPES)) { + $spatialAttributes[] = $attr->getId(); + } + } + } + return $spatialAttributes; + } + + /** + * Update documents + * + * Updates all documents which match the given query. + * + * @param Document $collection + * @param Document $updates * @param array $documents * - * @return array + * @return int * - * @throws DuplicateException - * @throws \Throwable + * @throws DatabaseException */ - public function createDocuments(string $collection, array $documents): array + public function updateDocuments(Document $collection, Document $updates, array $documents): int { if (empty($documents)) { - return $documents; + return 0; } + $spatialAttributes = $this->getSpatialAttributes($collection); + $collection = $collection->getId(); - try { - $name = $this->filter($collection); + $attributes = $updates->getAttributes(); - $attributeKeys = Database::INTERNAL_ATTRIBUTE_KEYS; + if (!empty($updates->getUpdatedAt())) { + $attributes['_updatedAt'] = $updates->getUpdatedAt(); + } - $hasSequence = null; - foreach ($documents as $document) { - $attributes = $document->getAttributes(); - $attributeKeys = [...$attributeKeys, ...\array_keys($attributes)]; + if (!empty($updates->getCreatedAt())) { + $attributes['_createdAt'] = $updates->getCreatedAt(); + } - if ($hasSequence === null) { - $hasSequence = !empty($document->getSequence()); - } elseif ($hasSequence == empty($document->getSequence())) { - throw new DatabaseException('All documents must have an sequence if one is set'); - } - } + if ($updates->offsetExists('$permissions')) { + $attributes['_permissions'] = json_encode($updates->getPermissions()); + } - $attributeKeys = array_unique($attributeKeys); + if (empty($attributes)) { + return 0; + } - if ($hasSequence) { - $attributeKeys[] = '_id'; + $bindIndex = 0; + $columns = ''; + foreach ($attributes as $attribute => $value) { + $column = $this->filter($attribute); + + if (in_array($attribute, $spatialAttributes)) { + $columns .= "{$this->quote($column)} = ST_GeomFromText(:key_{$bindIndex})"; + } else { + $columns .= "{$this->quote($column)} = :key_{$bindIndex}"; } - if ($this->sharedTables) { - $attributeKeys[] = '_tenant'; + if ($attribute !== \array_key_last($attributes)) { + $columns .= ','; } - $columns = []; - foreach ($attributeKeys as $key => $attribute) { - $columns[$key] = $this->quote($this->filter($attribute)); + $bindIndex++; + } + + $name = $this->filter($collection); + $sequences = \array_map(fn ($document) => $document->getSequence(), $documents); + + $sql = " + UPDATE {$this->getSQLTable($name)} + SET {$columns} + WHERE _id IN (" . \implode(', ', \array_map(fn ($index) => ":_id_{$index}", \array_keys($sequences))) . ") + {$this->getTenantQuery($collection)} + "; + + $sql = $this->trigger(Database::EVENT_DOCUMENTS_UPDATE, $sql); + $stmt = $this->getPDO()->prepare($sql); + + if ($this->sharedTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + + foreach ($sequences as $id => $value) { + $stmt->bindValue(":_id_{$id}", $value); + } + + $attributeIndex = 0; + foreach ($attributes as $attributeName => $value) { + if (!isset($spatialAttributes[$attributeName]) && is_array($value)) { + $value = json_encode($value); } - $columns = '(' . \implode(', ', $columns) . ')'; + $bindKey = 'key_' . $attributeIndex; + $value = (is_bool($value)) ? (int)$value : $value; + $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); + $attributeIndex++; + } - $bindIndex = 0; - $batchKeys = []; - $bindValues = []; - $permissions = []; + $stmt->execute(); + $affected = $stmt->rowCount(); - foreach ($documents as $index => $document) { - $attributes = $document->getAttributes(); - $attributes['_uid'] = $document->getId(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = \json_encode($document->getPermissions()); + // Permissions logic + if ($updates->offsetExists('$permissions')) { + $removeQueries = []; + $removeBindValues = []; - if (!empty($document->getSequence())) { - $attributes['_id'] = $document->getSequence(); + $addQuery = ''; + $addBindValues = []; + + foreach ($documents as $index => $document) { + if ($document->getAttribute('$skipPermissionsUpdate', false)) { + continue; } + $sql = " + SELECT _type, _permission + FROM {$this->getSQLTable($name . '_perms')} + WHERE _document = :_uid + {$this->getTenantQuery($collection)} + "; + + $sql = $this->trigger(Database::EVENT_PERMISSIONS_READ, $sql); + + $permissionsStmt = $this->getPDO()->prepare($sql); + $permissionsStmt->bindValue(':_uid', $document->getId()); + if ($this->sharedTables) { - $attributes['_tenant'] = $document->getTenant(); + $permissionsStmt->bindValue(':_tenant', $this->tenant); } - $bindKeys = []; + $permissionsStmt->execute(); + $permissions = $permissionsStmt->fetchAll(); + $permissionsStmt->closeCursor(); - foreach ($attributeKeys as $key) { - $value = $attributes[$key] ?? null; - if (\is_array($value)) { - $value = \json_encode($value); - } - $value = (\is_bool($value)) ? (int)$value : $value; - $bindKey = 'key_' . $bindIndex; - $bindKeys[] = ':' . $bindKey; - $bindValues[$bindKey] = $value; - $bindIndex++; + $initial = []; + foreach (Database::PERMISSIONS as $type) { + $initial[$type] = []; } - $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; + $permissions = \array_reduce($permissions, function (array $carry, array $item) { + $carry[$item['_type']][] = $item['_permission']; + return $carry; + }, $initial); + // Get removed Permissions + $removals = []; foreach (Database::PERMISSIONS as $type) { - foreach ($document->getPermissionsByType($type) as $permission) { - $tenantBind = $this->sharedTables ? ", :_tenant_{$index}" : ''; - $permission = \str_replace('"', '', $permission); - $permission = "('{$type}', '{$permission}', :_uid_{$index} {$tenantBind})"; - $permissions[] = $permission; + $diff = array_diff($permissions[$type], $updates->getPermissionsByType($type)); + if (!empty($diff)) { + $removals[$type] = $diff; } } - } - $batchKeys = \implode(', ', $batchKeys); + // Build inner query to remove permissions + if (!empty($removals)) { + foreach ($removals as $type => $permissionsToRemove) { + $bindKey = '_uid_' . $index; + $removeBindKeys[] = ':_uid_' . $index; + $removeBindValues[$bindKey] = $document->getId(); - $stmt = $this->getPDO()->prepare(" - INSERT INTO {$this->getSQLTable($name)} {$columns} - VALUES {$batchKeys} - "); + $removeQueries[] = "( + _document = :_uid_{$index} + {$this->getTenantQuery($collection)} + AND _type = '{$type}' + AND _permission IN (" . \implode(', ', \array_map(function (string $i) use ($permissionsToRemove, $index, $type, &$removeBindKeys, &$removeBindValues) { + $bindKey = 'remove_' . $type . '_' . $index . '_' . $i; + $removeBindKeys[] = ':' . $bindKey; + $removeBindValues[$bindKey] = $permissionsToRemove[$i]; - foreach ($bindValues as $key => $value) { - $stmt->bindValue($key, $value, $this->getPDOType($value)); - } + return ':' . $bindKey; + }, \array_keys($permissionsToRemove))) . + ") + )"; + } + } - $this->execute($stmt); + // Get added Permissions + $additions = []; + foreach (Database::PERMISSIONS as $type) { + $diff = \array_diff($updates->getPermissionsByType($type), $permissions[$type]); + if (!empty($diff)) { + $additions[$type] = $diff; + } + } - if (!empty($permissions)) { - $tenantColumn = $this->sharedTables ? ', _tenant' : ''; - $permissions = \implode(', ', $permissions); + // Build inner query to add permissions + if (!empty($additions)) { + foreach ($additions as $type => $permissionsToAdd) { + foreach ($permissionsToAdd as $i => $permission) { + $bindKey = '_uid_' . $index; + $addBindValues[$bindKey] = $document->getId(); - $sqlPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document {$tenantColumn}) - VALUES {$permissions}; - "; + $bindKey = 'add_' . $type . '_' . $index . '_' . $i; + $addBindValues[$bindKey] = $permission; - $stmtPermissions = $this->getPDO()->prepare($sqlPermissions); + $addQuery .= "(:_uid_{$index}, '{$type}', :{$bindKey}"; + + if ($this->sharedTables) { + $addQuery .= ", :_tenant)"; + } else { + $addQuery .= ")"; + } - foreach ($documents as $index => $document) { - $stmtPermissions->bindValue(":_uid_{$index}", $document->getId()); - if ($this->sharedTables) { - $stmtPermissions->bindValue(":_tenant_{$index}", $document->getTenant()); + if ($i !== \array_key_last($permissionsToAdd) || $type !== \array_key_last($additions)) { + $addQuery .= ', '; + } + } + } + if ($index !== \array_key_last($documents)) { + $addQuery .= ', '; } } - - $this->execute($stmtPermissions); } - } catch (PDOException $e) { - throw $this->processException($e); - } - - return $documents; - } - - /** - * @param string $collection - * @param string $attribute - * @param array $changes - * @return array - * @throws DatabaseException - */ - public function createOrUpdateDocuments( - string $collection, - string $attribute, - array $changes - ): array { - if (empty($changes)) { - return $changes; - } - - try { - $name = $this->filter($collection); - $attribute = $this->filter($attribute); - - $attributes = []; - $bindIndex = 0; - $batchKeys = []; - $bindValues = []; + if (!empty($removeQueries)) { + $removeQuery = \implode(' OR ', $removeQueries); - foreach ($changes as $change) { - $document = $change->getNew(); - $attributes = $document->getAttributes(); - $attributes['_uid'] = $document->getId(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = \json_encode($document->getPermissions()); + $stmtRemovePermissions = $this->getPDO()->prepare(" + DELETE + FROM {$this->getSQLTable($name . '_perms')} + WHERE ({$removeQuery}) + "); - if (!empty($document->getSequence())) { - $attributes['_id'] = $document->getSequence(); + foreach ($removeBindValues as $key => $value) { + $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); } if ($this->sharedTables) { - $attributes['_tenant'] = $document->getTenant(); + $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); } + $stmtRemovePermissions->execute(); + } - \ksort($attributes); + if (!empty($addQuery)) { + $sqlAddPermissions = " + INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission + "; - $columns = []; - foreach (\array_keys($attributes) as $key => $attr) { - /** - * @var string $attr - */ - $columns[$key] = "{$this->quote($this->filter($attr))}"; + if ($this->sharedTables) { + $sqlAddPermissions .= ', _tenant)'; + } else { + $sqlAddPermissions .= ')'; } - $columns = '(' . \implode(', ', $columns) . ')'; - $bindKeys = []; + $sqlAddPermissions .= " VALUES {$addQuery}"; - foreach ($attributes as $attrValue) { - if (\is_array($attrValue)) { - $attrValue = \json_encode($attrValue); - } - $attrValue = (\is_bool($attrValue)) ? (int)$attrValue : $attrValue; - $bindKey = 'key_' . $bindIndex; - $bindKeys[] = ':' . $bindKey; - $bindValues[$bindKey] = $attrValue; - $bindIndex++; + $stmtAddPermissions = $this->getPDO()->prepare($sqlAddPermissions); + + foreach ($addBindValues as $key => $value) { + $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); } - $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; + if ($this->sharedTables) { + $stmtAddPermissions->bindValue(':_tenant', $this->tenant); + } + + $stmtAddPermissions->execute(); } + } - $stmt = $this->getUpsertStatement($name, $columns, $batchKeys, $attributes, $bindValues, $attribute); - $stmt->execute(); - $stmt->closeCursor(); + return $affected; + } - $removeQueries = []; - $removeBindValues = []; - $addQueries = []; - $addBindValues = []; - foreach ($changes as $index => $change) { - $old = $change->getOld(); - $document = $change->getNew(); + /** + * Delete Documents + * + * @param string $collection + * @param array $sequences + * @param array $permissionIds + * + * @return int + * @throws DatabaseException + */ + public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int + { + if (empty($sequences)) { + return 0; + } - $current = []; - foreach (Database::PERMISSIONS as $type) { - $current[$type] = $old->getPermissionsByType($type); - } + try { + $name = $this->filter($collection); - // Calculate removals - foreach (Database::PERMISSIONS as $type) { - $toRemove = \array_diff($current[$type], $document->getPermissionsByType($type)); - if (!empty($toRemove)) { - $removeQueries[] = "( - _document = :_uid_{$index} - " . ($this->sharedTables ? " AND _tenant = :_tenant_{$index}" : '') . " - AND _type = '{$type}' - AND _permission IN (" . \implode(',', \array_map(fn ($i) => ":remove_{$type}_{$index}_{$i}", \array_keys($toRemove))) . ") - )"; - $removeBindValues[":_uid_{$index}"] = $document->getId(); - if ($this->sharedTables) { - $removeBindValues[":_tenant_{$index}"] = $document->getTenant(); - } - foreach ($toRemove as $i => $perm) { - $removeBindValues[":remove_{$type}_{$index}_{$i}"] = $perm; - } - } - } + $sql = " + DELETE FROM {$this->getSQLTable($name)} + WHERE _id IN (" . \implode(', ', \array_map(fn ($index) => ":_id_{$index}", \array_keys($sequences))) . ") + {$this->getTenantQuery($collection)} + "; - // Calculate additions - foreach (Database::PERMISSIONS as $type) { - $toAdd = \array_diff($document->getPermissionsByType($type), $current[$type]); + $sql = $this->trigger(Database::EVENT_DOCUMENTS_DELETE, $sql); - foreach ($toAdd as $i => $permission) { - $addQuery = "(:_uid_{$index}, '{$type}', :add_{$type}_{$index}_{$i}"; + $stmt = $this->getPDO()->prepare($sql); - if ($this->sharedTables) { - $addQuery .= ", :_tenant_{$index}"; - } + foreach ($sequences as $id => $value) { + $stmt->bindValue(":_id_{$id}", $value); + } - $addQuery .= ")"; - $addQueries[] = $addQuery; - $addBindValues[":_uid_{$index}"] = $document->getId(); - $addBindValues[":add_{$type}_{$index}_{$i}"] = $permission; + if ($this->sharedTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } - if ($this->sharedTables) { - $addBindValues[":_tenant_{$index}"] = $document->getTenant(); - } - } - } + if (!$stmt->execute()) { + throw new DatabaseException('Failed to delete documents'); } - // Execute permission removals - if (!empty($removeQueries)) { - $removeQuery = \implode(' OR ', $removeQueries); - $stmtRemovePermissions = $this->getPDO()->prepare("DELETE FROM {$this->getSQLTable($name . '_perms')} WHERE {$removeQuery}"); - foreach ($removeBindValues as $key => $value) { - $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); + if (!empty($permissionIds)) { + $sql = " + DELETE FROM {$this->getSQLTable($name . '_perms')} + WHERE _document IN (" . \implode(', ', \array_map(fn ($index) => ":_id_{$index}", \array_keys($permissionIds))) . ") + {$this->getTenantQuery($collection)} + "; + + $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql); + + $stmtPermissions = $this->getPDO()->prepare($sql); + + foreach ($permissionIds as $id => $value) { + $stmtPermissions->bindValue(":_id_{$id}", $value); } - $stmtRemovePermissions->execute(); - } - // Execute permission additions - if (!empty($addQueries)) { - $sqlAddPermissions = "INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission"; if ($this->sharedTables) { - $sqlAddPermissions .= ", _tenant"; + $stmtPermissions->bindValue(':_tenant', $this->tenant); } - $sqlAddPermissions .= ") VALUES " . \implode(', ', $addQueries); - $stmtAddPermissions = $this->getPDO()->prepare($sqlAddPermissions); - foreach ($addBindValues as $key => $value) { - $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); + + if (!$stmtPermissions->execute()) { + throw new DatabaseException('Failed to delete permissions'); } - $stmtAddPermissions->execute(); } - } catch (PDOException $e) { - throw $this->processException($e); + } catch (\Throwable $e) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); } - return \array_map(fn ($change) => $change->getNew(), $changes); + return $stmt->rowCount(); } /** - * Update documents - * - * Updates all documents which match the given query. + * Assign internal IDs for the given documents * * @param string $collection - * @param Document $updates * @param array $documents - * - * @return int - * + * @return array * @throws DatabaseException */ - public function updateDocuments(string $collection, Document $updates, array $documents): int + public function getSequences(string $collection, array $documents): array { - if (empty($documents)) { - return 0; - } + $documentIds = []; + $keys = []; + $binds = []; - $attributes = $updates->getAttributes(); + foreach ($documents as $i => $document) { + if (empty($document->getSequence())) { + $documentIds[] = $document->getId(); - if (!empty($updates->getUpdatedAt())) { - $attributes['_updatedAt'] = $updates->getUpdatedAt(); - } + $key = ":uid_{$i}"; - if (!empty($updates->getCreatedAt())) { - $attributes['_createdAt'] = $updates->getCreatedAt(); - } + $binds[$key] = $document->getId(); + $keys[] = $key; - if ($updates->offsetExists('$permissions')) { - $attributes['_permissions'] = json_encode($updates->getPermissions()); + if ($this->sharedTables) { + $binds[':_tenant_'.$i] = $document->getTenant(); + } + } } - if (empty($attributes)) { - return 0; + if (empty($documentIds)) { + return $documents; } - $bindIndex = 0; - $columns = ''; - foreach ($attributes as $attribute => $value) { - $column = $this->filter($attribute); - $columns .= "{$this->quote($column)} = :key_{$bindIndex}"; - - if ($attribute !== \array_key_last($attributes)) { - $columns .= ','; - } - - $bindIndex++; - } - - $name = $this->filter($collection); - $sequences = \array_map(fn ($document) => $document->getSequence(), $documents); + $placeholders = implode(',', array_values($keys)); $sql = " - UPDATE {$this->getSQLTable($name)} - SET {$columns} - WHERE _id IN (" . \implode(', ', \array_map(fn ($index) => ":_id_{$index}", \array_keys($sequences))) . ") - {$this->getTenantQuery($collection)} - "; + SELECT _uid, _id + FROM {$this->getSQLTable($collection)} + WHERE {$this->quote('_uid')} IN ({$placeholders}) + {$this->getTenantQuery($collection, tenantCount: \count($documentIds))} + "; - $sql = $this->trigger(Database::EVENT_DOCUMENTS_UPDATE, $sql); $stmt = $this->getPDO()->prepare($sql); - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); + foreach ($binds as $key => $value) { + $stmt->bindValue($key, $value); } - foreach ($sequences as $id => $value) { - $stmt->bindValue(":_id_{$id}", $value); - } + $stmt->execute(); + $sequences = $stmt->fetchAll(\PDO::FETCH_KEY_PAIR); // Fetch as [documentId => sequence] + $stmt->closeCursor(); - $attributeIndex = 0; - foreach ($attributes as $value) { - if (is_array($value)) { - $value = json_encode($value); + foreach ($documents as $document) { + if (isset($sequences[$document->getId()])) { + $document['$sequence'] = $sequences[$document->getId()]; } - - $bindKey = 'key_' . $attributeIndex; - $value = (is_bool($value)) ? (int)$value : $value; - $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); - $attributeIndex++; } - $stmt->execute(); - $affected = $stmt->rowCount(); - - // Permissions logic - if ($updates->offsetExists('$permissions')) { - $removeQueries = []; - $removeBindValues = []; + return $documents; + } - $addQuery = ''; - $addBindValues = []; + /** + * Get max STRING limit + * + * @return int + */ + public function getLimitForString(): int + { + return 4294967295; + } - foreach ($documents as $index => $document) { - if ($document->getAttribute('$skipPermissionsUpdate', false)) { - continue; - } + /** + * Get max INT limit + * + * @return int + */ + public function getLimitForInt(): int + { + return 4294967295; + } - $sql = " - SELECT _type, _permission - FROM {$this->getSQLTable($name . '_perms')} - WHERE _document = :_uid - {$this->getTenantQuery($collection)} - "; + /** + * Get maximum column limit. + * https://mariadb.com/kb/en/innodb-limitations/#limitations-on-schema + * Can be inherited by MySQL since we utilize the InnoDB engine + * + * @return int + */ + public function getLimitForAttributes(): int + { + return 1017; + } - $sql = $this->trigger(Database::EVENT_PERMISSIONS_READ, $sql); + /** + * Get maximum index limit. + * https://mariadb.com/kb/en/innodb-limitations/#limitations-on-schema + * + * @return int + */ + public function getLimitForIndexes(): int + { + return 64; + } - $permissionsStmt = $this->getPDO()->prepare($sql); - $permissionsStmt->bindValue(':_uid', $document->getId()); + /** + * Is schemas supported? + * + * @return bool + */ + public function getSupportForSchemas(): bool + { + return true; + } - if ($this->sharedTables) { - $permissionsStmt->bindValue(':_tenant', $this->tenant); - } + /** + * Is index supported? + * + * @return bool + */ + public function getSupportForIndex(): bool + { + return true; + } - $permissionsStmt->execute(); - $permissions = $permissionsStmt->fetchAll(); - $permissionsStmt->closeCursor(); + /** + * Are attributes supported? + * + * @return bool + */ + public function getSupportForAttributes(): bool + { + return true; + } - $initial = []; - foreach (Database::PERMISSIONS as $type) { - $initial[$type] = []; - } + /** + * Is unique index supported? + * + * @return bool + */ + public function getSupportForUniqueIndex(): bool + { + return true; + } - $permissions = \array_reduce($permissions, function (array $carry, array $item) { - $carry[$item['_type']][] = $item['_permission']; - return $carry; - }, $initial); + /** + * Is fulltext index supported? + * + * @return bool + */ + public function getSupportForFulltextIndex(): bool + { + return true; + } - // Get removed Permissions - $removals = []; - foreach (Database::PERMISSIONS as $type) { - $diff = array_diff($permissions[$type], $updates->getPermissionsByType($type)); - if (!empty($diff)) { - $removals[$type] = $diff; - } - } + /** + * Are FOR UPDATE locks supported? + * + * @return bool + */ + public function getSupportForUpdateLock(): bool + { + return true; + } - // Build inner query to remove permissions - if (!empty($removals)) { - foreach ($removals as $type => $permissionsToRemove) { - $bindKey = '_uid_' . $index; - $removeBindKeys[] = ':_uid_' . $index; - $removeBindValues[$bindKey] = $document->getId(); + /** + * Is Attribute Resizing Supported? + * + * @return bool + */ + public function getSupportForAttributeResizing(): bool + { + return true; + } - $removeQueries[] = "( - _document = :_uid_{$index} - {$this->getTenantQuery($collection)} - AND _type = '{$type}' - AND _permission IN (" . \implode(', ', \array_map(function (string $i) use ($permissionsToRemove, $index, $type, &$removeBindKeys, &$removeBindValues) { - $bindKey = 'remove_' . $type . '_' . $index . '_' . $i; - $removeBindKeys[] = ':' . $bindKey; - $removeBindValues[$bindKey] = $permissionsToRemove[$i]; + /** + * Are batch operations supported? + * + * @return bool + */ + public function getSupportForBatchOperations(): bool + { + return true; + } - return ':' . $bindKey; - }, \array_keys($permissionsToRemove))) . - ") - )"; - } - } + /** + * Is get connection id supported? + * + * @return bool + */ + public function getSupportForGetConnectionId(): bool + { + return true; + } - // Get added Permissions - $additions = []; - foreach (Database::PERMISSIONS as $type) { - $diff = \array_diff($updates->getPermissionsByType($type), $permissions[$type]); - if (!empty($diff)) { - $additions[$type] = $diff; - } - } - - // Build inner query to add permissions - if (!empty($additions)) { - foreach ($additions as $type => $permissionsToAdd) { - foreach ($permissionsToAdd as $i => $permission) { - $bindKey = '_uid_' . $index; - $addBindValues[$bindKey] = $document->getId(); - - $bindKey = 'add_' . $type . '_' . $index . '_' . $i; - $addBindValues[$bindKey] = $permission; - - $addQuery .= "(:_uid_{$index}, '{$type}', :{$bindKey}"; - - if ($this->sharedTables) { - $addQuery .= ", :_tenant)"; - } else { - $addQuery .= ")"; - } - - if ($i !== \array_key_last($permissionsToAdd) || $type !== \array_key_last($additions)) { - $addQuery .= ', '; - } - } - } - if ($index !== \array_key_last($documents)) { - $addQuery .= ', '; - } - } - } - - if (!empty($removeQueries)) { - $removeQuery = \implode(' OR ', $removeQueries); - - $stmtRemovePermissions = $this->getPDO()->prepare(" - DELETE - FROM {$this->getSQLTable($name . '_perms')} - WHERE ({$removeQuery}) - "); - - foreach ($removeBindValues as $key => $value) { - $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); - } - - if ($this->sharedTables) { - $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); - } - $stmtRemovePermissions->execute(); - } - - if (!empty($addQuery)) { - $sqlAddPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission - "; - - if ($this->sharedTables) { - $sqlAddPermissions .= ', _tenant)'; - } else { - $sqlAddPermissions .= ')'; - } - - $sqlAddPermissions .= " VALUES {$addQuery}"; - - $stmtAddPermissions = $this->getPDO()->prepare($sqlAddPermissions); - - foreach ($addBindValues as $key => $value) { - $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); - } - - if ($this->sharedTables) { - $stmtAddPermissions->bindValue(':_tenant', $this->tenant); - } - - $stmtAddPermissions->execute(); - } - } - - return $affected; - } + /** + * Is cache fallback supported? + * + * @return bool + */ + public function getSupportForCacheSkipOnFailure(): bool + { + return true; + } + /** + * Is hostname supported? + * + * @return bool + */ + public function getSupportForHostname(): bool + { + return true; + } /** - * Delete Documents - * - * @param string $collection - * @param array $sequences - * @param array $permissionIds + * Get current attribute count from collection document * + * @param Document $collection * @return int - * @throws DatabaseException */ - public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int + public function getCountOfAttributes(Document $collection): int { - if (empty($sequences)) { - return 0; - } - - try { - $name = $this->filter($collection); - - $sql = " - DELETE FROM {$this->getSQLTable($name)} - WHERE _id IN (" . \implode(', ', \array_map(fn ($index) => ":_id_{$index}", \array_keys($sequences))) . ") - {$this->getTenantQuery($collection)} - "; - - $sql = $this->trigger(Database::EVENT_DOCUMENTS_DELETE, $sql); - - $stmt = $this->getPDO()->prepare($sql); - - foreach ($sequences as $id => $value) { - $stmt->bindValue(":_id_{$id}", $value); - } - - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } - - if (!$stmt->execute()) { - throw new DatabaseException('Failed to delete documents'); - } - - if (!empty($permissionIds)) { - $sql = " - DELETE FROM {$this->getSQLTable($name . '_perms')} - WHERE _document IN (" . \implode(', ', \array_map(fn ($index) => ":_id_{$index}", \array_keys($permissionIds))) . ") - {$this->getTenantQuery($collection)} - "; - - $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql); - - $stmtPermissions = $this->getPDO()->prepare($sql); - - foreach ($permissionIds as $id => $value) { - $stmtPermissions->bindValue(":_id_{$id}", $value); - } - - if ($this->sharedTables) { - $stmtPermissions->bindValue(':_tenant', $this->tenant); - } - - if (!$stmtPermissions->execute()) { - throw new DatabaseException('Failed to delete permissions'); - } - } - } catch (\Throwable $e) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); - } + $attributes = \count($collection->getAttribute('attributes') ?? []); - return $stmt->rowCount(); + return $attributes + $this->getCountOfDefaultAttributes(); } /** - * Assign internal IDs for the given documents + * Get current index count from collection document * - * @param string $collection - * @param array $documents - * @return array - * @throws DatabaseException + * @param Document $collection + * @return int */ - public function getSequences(string $collection, array $documents): array + public function getCountOfIndexes(Document $collection): int { - $documentIds = []; - $keys = []; - $binds = []; - - foreach ($documents as $i => $document) { - if (empty($document->getSequence())) { - $documentIds[] = $document->getId(); - - $key = ":uid_{$i}"; - - $binds[$key] = $document->getId(); - $keys[] = $key; - - if ($this->sharedTables) { - $binds[':_tenant_'.$i] = $document->getTenant(); - } - } - } - - if (empty($documentIds)) { - return $documents; - } - - $placeholders = implode(',', array_values($keys)); - - $sql = " - SELECT _uid, _id - FROM {$this->getSQLTable($collection)} - WHERE {$this->quote('_uid')} IN ({$placeholders}) - {$this->getTenantQuery($collection, tenantCount: \count($documentIds))} - "; - - $stmt = $this->getPDO()->prepare($sql); - - foreach ($binds as $key => $value) { - $stmt->bindValue($key, $value); - } - - $stmt->execute(); - $sequences = $stmt->fetchAll(\PDO::FETCH_KEY_PAIR); // Fetch as [documentId => sequence] - $stmt->closeCursor(); - - foreach ($documents as $document) { - if (isset($sequences[$document->getId()])) { - $document['$sequence'] = $sequences[$document->getId()]; - } - } - - return $documents; + $indexes = \count($collection->getAttribute('indexes') ?? []); + return $indexes + $this->getCountOfDefaultIndexes(); } /** - * Get max STRING limit + * Returns number of attributes used by default. * * @return int */ - public function getLimitForString(): int + public function getCountOfDefaultAttributes(): int { - return 4294967295; + return \count(Database::INTERNAL_ATTRIBUTES); } /** - * Get max INT limit + * Returns number of indexes used by default. * * @return int */ - public function getLimitForInt(): int + public function getCountOfDefaultIndexes(): int { - return 4294967295; + return \count(Database::INTERNAL_INDEXES); } /** - * Get maximum column limit. - * https://mariadb.com/kb/en/innodb-limitations/#limitations-on-schema - * Can be inherited by MySQL since we utilize the InnoDB engine + * Get maximum width, in bytes, allowed for a SQL row + * Return 0 when no restrictions apply * * @return int */ - public function getLimitForAttributes(): int + public function getDocumentSizeLimit(): int { - return 1017; + return 65535; } /** - * Get maximum index limit. - * https://mariadb.com/kb/en/innodb-limitations/#limitations-on-schema + * Estimate maximum number of bytes required to store a document in $collection. + * Byte requirement varies based on column type and size. + * Needed to satisfy MariaDB/MySQL row width limit. * + * @param Document $collection * @return int + * @throws DatabaseException */ - public function getLimitForIndexes(): int + public function getAttributeWidth(Document $collection): int { - return 64; - } + /** + * @link https://dev.mysql.com/doc/refman/8.0/en/storage-requirements.html + * + * `_id` bigint => 8 bytes + * `_uid` varchar(255) => 1021 (4 * 255 + 1) bytes + * `_tenant` int => 4 bytes + * `_createdAt` datetime(3) => 7 bytes + * `_updatedAt` datetime(3) => 7 bytes + * `_permissions` mediumtext => 20 + */ - /** - * Is schemas supported? - * - * @return bool - */ - public function getSupportForSchemas(): bool - { - return true; - } - - /** - * Is internal casting supported? - * - * @return bool - */ - public function getSupportForInternalCasting(): bool - { - return false; - } - - /** - * Returns the document after casting - * @param Document $collection - * @param Document $document - * @return Document - */ - public function castingBefore(Document $collection, Document $document): Document - { - return $document; - } - - /** - * Returns the document after casting - * @param Document $collection - * @param Document $document - * @return Document - */ - public function castingAfter(Document $collection, Document $document): Document - { - return $document; - } - - /** - * Is Mongo? - * - * @return bool - */ - public function isMongo(): bool - { - return false; - } - - /** - * Set UTC Datetime - * - * @param string $value - * @return mixed - */ - public function setUTCDatetime(string $value): mixed - { - return $value; - } - - /** - * Is index supported? - * - * @return bool - */ - public function getSupportForIndex(): bool - { - return true; - } - - /** - * Are attributes supported? - * - * @return bool - */ - public function getSupportForAttributes(): bool - { - return true; - } - - /** - * Is unique index supported? - * - * @return bool - */ - public function getSupportForUniqueIndex(): bool - { - return true; - } - - /** - * Is fulltext index supported? - * - * @return bool - */ - public function getSupportForFulltextIndex(): bool - { - return true; - } - - /** - * Are FOR UPDATE locks supported? - * - * @return bool - */ - public function getSupportForUpdateLock(): bool - { - return true; - } - - /** - * Is Attribute Resizing Supported? - * - * @return bool - */ - public function getSupportForAttributeResizing(): bool - { - return true; - } - - /** - * Are batch operations supported? - * - * @return bool - */ - public function getSupportForBatchOperations(): bool - { - return true; - } - - /** - * Is get connection id supported? - * - * @return bool - */ - public function getSupportForGetConnectionId(): bool - { - return true; - } - - /** - * Is cache fallback supported? - * - * @return bool - */ - public function getSupportForCacheSkipOnFailure(): bool - { - return true; - } - - /** - * Is hostname supported? - * - * @return bool - */ - public function getSupportForHostname(): bool - { - return true; - } - - /** - * Get current attribute count from collection document - * - * @param Document $collection - * @return int - */ - public function getCountOfAttributes(Document $collection): int - { - $attributes = \count($collection->getAttribute('attributes') ?? []); - - return $attributes + $this->getCountOfDefaultAttributes(); - } - - /** - * Get current index count from collection document - * - * @param Document $collection - * @return int - */ - public function getCountOfIndexes(Document $collection): int - { - $indexes = \count($collection->getAttribute('indexes') ?? []); - return $indexes + $this->getCountOfDefaultIndexes(); - } - - /** - * Returns number of attributes used by default. - * - * @return int - */ - public function getCountOfDefaultAttributes(): int - { - return \count(Database::INTERNAL_ATTRIBUTES); - } - - /** - * Returns number of indexes used by default. - * - * @return int - */ - public function getCountOfDefaultIndexes(): int - { - return \count(Database::INTERNAL_INDEXES); - } - - /** - * Get maximum width, in bytes, allowed for a SQL row - * Return 0 when no restrictions apply - * - * @return int - */ - public function getDocumentSizeLimit(): int - { - return 65535; - } - - /** - * Estimate maximum number of bytes required to store a document in $collection. - * Byte requirement varies based on column type and size. - * Needed to satisfy MariaDB/MySQL row width limit. - * - * @param Document $collection - * @return int - * @throws DatabaseException - */ - public function getAttributeWidth(Document $collection): int - { - /** - * @link https://dev.mysql.com/doc/refman/8.0/en/storage-requirements.html - * - * `_id` bigint => 8 bytes - * `_uid` varchar(255) => 1021 (4 * 255 + 1) bytes - * `_tenant` int => 4 bytes - * `_createdAt` datetime(3) => 7 bytes - * `_updatedAt` datetime(3) => 7 bytes - * `_permissions` mediumtext => 20 - */ - - $total = 1067; + $total = 1067; $attributes = $collection->getAttributes()['attributes']; @@ -1424,6 +1127,15 @@ public function getAttributeWidth(Document $collection): int */ $total += 7; break; + + case Database::VAR_POINT: + $total += $this->getMaxPointSize(); + break; + case Database::VAR_LINESTRING: + case Database::VAR_POLYGON: + $total += 20; + break; + default: throw new DatabaseException('Unknown type: ' . $attribute['type']); } @@ -1777,38 +1489,98 @@ public function getSupportForBatchCreateAttributes(): bool } /** - * @param string $tableName - * @param string $columns - * @param array $batchKeys - * @param array $bindValues - * @param array $attributes - * @param string $attribute - * @return mixed - */ - abstract protected function getUpsertStatement( - string $tableName, - string $columns, - array $batchKeys, - array $attributes, - array $bindValues, - string $attribute = '', - ): mixed; + * Is spatial attributes supported? + * + * @return bool + */ + public function getSupportForSpatialAttributes(): bool + { + return false; + } /** - * @param string $value - * @return string + * Does the adapter support null values in spatial indexes? + * + * @return bool */ - protected function getFulltextValue(string $value): string + public function getSupportForSpatialIndexNull(): bool { - $exact = str_ends_with($value, '"') && str_starts_with($value, '"'); - - /** Replace reserved chars with space. */ - $specialChars = '@,+,-,*,),(,<,>,~,"'; - $value = str_replace(explode(',', $specialChars), ' ', $value); - $value = preg_replace('/\s+/', ' ', $value); // Remove multiple whitespaces - $value = trim($value); + return false; + } - if (empty($value)) { + /** + * Does the adapter support order attribute in spatial indexes? + * + * @return bool + */ + public function getSupportForSpatialIndexOrder(): bool + { + return false; + } + + /** + * Is internal casting supported? + * + * @return bool + */ + public function getSupportForInternalCasting(): bool + { + return false; + } + + public function isMongo(): bool + { + return false; + } + + public function setUTCDatetime(string $value): mixed + { + return $value; + } + + public function castingBefore(Document $collection, Document $document): Document + { + return $document; + } + + public function castingAfter(Document $collection, Document $document): Document + { + return $document; + } + + /** + * @param string $tableName + * @param string $columns + * @param array $batchKeys + * @param array $bindValues + * @param array $attributes + * @param string $attribute + * @return mixed + */ + abstract protected function getUpsertStatement( + string $tableName, + string $columns, + array $batchKeys, + array $attributes, + array $bindValues, + string $attribute = '', + ): mixed; + + /** + * @param string $value + * @return string + */ + protected function getFulltextValue(string $value): string + { + $exact = str_ends_with($value, '"') && str_starts_with($value, '"'); + + /** Replace reserved chars with space. */ + $specialChars = '@,+,-,*,),(,<,>,~,"'; + $value = str_replace(explode(',', $specialChars), ' ', $value); + $value = preg_replace('/\s+/', ' ', $value); // Remove multiple whitespaces + $value = trim($value); + + if (empty($value)) { return ''; } @@ -1851,6 +1623,9 @@ protected function getSQLOperator(string $method): string case Query::TYPE_STARTS_WITH: case Query::TYPE_ENDS_WITH: case Query::TYPE_CONTAINS: + case Query::TYPE_NOT_STARTS_WITH: + case Query::TYPE_NOT_ENDS_WITH: + case Query::TYPE_NOT_CONTAINS: return $this->getLikeOperator(); default: throw new DatabaseException('Unknown method: ' . $method); @@ -1861,7 +1636,8 @@ abstract protected function getSQLType( string $type, int $size, bool $signed = true, - bool $array = false + bool $array = false, + bool $required = false ): string; /** @@ -1969,199 +1745,610 @@ public function getHostname(): string } } - /** - * @return int - */ - public function getMaxVarcharLength(): int - { - return 16381; // Floor value for Postgres:16383 | MySQL:16381 | MariaDB:16382 - } + /** + * @return int + */ + public function getMaxVarcharLength(): int + { + return 16381; // Floor value for Postgres:16383 | MySQL:16381 | MariaDB:16382 + } + + /** + * Size of POINT spatial type + * + * @return int + */ + abstract protected function getMaxPointSize(): int; + /** + * @return string + */ + public function getIdAttributeType(): string + { + return Database::VAR_INTEGER; + } + + /** + * @return int + */ + public function getMaxIndexLength(): int + { + /** + * $tenant int = 1 + */ + return $this->sharedTables ? 767 : 768; + } + + /** + * @param Query $query + * @param array $binds + * @param array $attributes + * @return string + * @throws Exception + */ + abstract protected function getSQLCondition(Query $query, array &$binds, array $attributes = []): string; + + /** + * @param array $queries + * @param array $binds + * @param string $separator + * @param array $attributes + * @return string + * @throws Exception + */ + public function getSQLConditions(array $queries, array &$binds, string $separator = 'AND', array $attributes = []): string + { + $conditions = []; + foreach ($queries as $query) { + if ($query->getMethod() === Query::TYPE_SELECT) { + continue; + } + + if ($query->isNested()) { + $conditions[] = $this->getSQLConditions($query->getValues(), $binds, $query->getMethod(), $attributes); + } else { + $conditions[] = $this->getSQLCondition($query, $binds, $attributes); + } + } + + $tmp = implode(' ' . $separator . ' ', $conditions); + return empty($tmp) ? '' : '(' . $tmp . ')'; + } + + /** + * @return string + */ + public function getLikeOperator(): string + { + return 'LIKE'; + } + + public function getInternalIndexesKeys(): array + { + return []; + } + + public function getSchemaAttributes(string $collection): array + { + return []; + } + + public function getTenantQuery( + string $collection, + string $alias = '', + int $tenantCount = 0, + string $condition = 'AND' + ): string { + if (!$this->sharedTables) { + return ''; + } + + $dot = ''; + if ($alias !== '') { + $dot = '.'; + $alias = $this->quote($alias); + } + + $bindings = []; + if ($tenantCount === 0) { + $bindings[] = ':_tenant'; + } else { + for ($index = 0; $index < $tenantCount; $index++) { + $bindings[] = ":_tenant_{$index}"; + } + } + $bindings = \implode(',', $bindings); + + $orIsNull = ''; + if ($collection === Database::METADATA) { + $orIsNull = " OR {$alias}{$dot}_tenant IS NULL"; + } + + return "{$condition} ({$alias}{$dot}_tenant IN ({$bindings}) {$orIsNull})"; + } + + /** + * Get the SQL projection given the selected attributes + * + * @param array $selections + * @param string $prefix + * @param array $spatialAttributes + * @return mixed + * @throws Exception + */ + protected function getAttributeProjection(array $selections, string $prefix, array $spatialAttributes = []): mixed + { + if (empty($selections) || \in_array('*', $selections)) { + if (empty($spatialAttributes)) { + return "{$this->quote($prefix)}.*"; + } + + $projections = []; + $projections[] = "{$this->quote($prefix)}.*"; + + $internalColumns = ['_id', '_uid', '_createdAt', '_updatedAt', '_permissions']; + if ($this->sharedTables) { + $internalColumns[] = '_tenant'; + } + foreach ($internalColumns as $col) { + $projections[] = "{$this->quote($prefix)}.{$this->quote($col)}"; + } + + foreach ($spatialAttributes as $spatialAttr) { + $filteredAttr = $this->filter($spatialAttr); + $quotedAttr = $this->quote($filteredAttr); + $projections[] = "ST_AsText({$this->quote($prefix)}.{$quotedAttr}) AS {$quotedAttr}"; + } + + + return implode(', ', $projections); + } + + // Handle specific selections with spatial conversion where needed + $internalKeys = [ + '$id', + '$sequence', + '$permissions', + '$createdAt', + '$updatedAt', + ]; + + $selections = \array_diff($selections, [...$internalKeys, '$collection']); + + foreach ($internalKeys as $internalKey) { + $selections[] = $this->getInternalKeyForAttribute($internalKey); + } + + $projections = []; + foreach ($selections as $selection) { + $filteredSelection = $this->filter($selection); + $quotedSelection = $this->quote($filteredSelection); + + if (in_array($selection, $spatialAttributes)) { + $projections[] = "ST_AsText({$this->quote($prefix)}.{$quotedSelection}) AS {$quotedSelection}"; + } else { + $projections[] = "{$this->quote($prefix)}.{$quotedSelection}"; + } + } + + return \implode(',', $projections); + } + + protected function getInternalKeyForAttribute(string $attribute): string + { + return match ($attribute) { + '$id' => '_uid', + '$sequence' => '_id', + '$collection' => '_collection', + '$tenant' => '_tenant', + '$createdAt' => '_createdAt', + '$updatedAt' => '_updatedAt', + '$permissions' => '_permissions', + default => $attribute + }; + } + + protected function escapeWildcards(string $value): string + { + $wildcards = ['%', '_', '[', ']', '^', '-', '.', '*', '+', '?', '(', ')', '{', '}', '|']; + + foreach ($wildcards as $wildcard) { + $value = \str_replace($wildcard, "\\$wildcard", $value); + } + + return $value; + } + + protected function processException(PDOException $e): \Exception + { + return $e; + } + + /** + * @param mixed $stmt + * @return bool + */ + protected function execute(mixed $stmt): bool + { + return $stmt->execute(); + } + + /** + * Create Documents in batches + * + * @param Document $collection + * @param array $documents + * + * @return array + * + * @throws DuplicateException + * @throws \Throwable + */ + public function createDocuments(Document $collection, array $documents): array + { + if (empty($documents)) { + return $documents; + } + $spatialAttributes = $this->getSpatialAttributes($collection); + $collection = $collection->getId(); + try { + $name = $this->filter($collection); + + $attributeKeys = Database::INTERNAL_ATTRIBUTE_KEYS; + + $hasSequence = null; + foreach ($documents as $document) { + $attributes = $document->getAttributes(); + $attributeKeys = [...$attributeKeys, ...\array_keys($attributes)]; + + if ($hasSequence === null) { + $hasSequence = !empty($document->getSequence()); + } elseif ($hasSequence == empty($document->getSequence())) { + throw new DatabaseException('All documents must have an sequence if one is set'); + } + } + + $attributeKeys = array_unique($attributeKeys); + + if ($hasSequence) { + $attributeKeys[] = '_id'; + } + + if ($this->sharedTables) { + $attributeKeys[] = '_tenant'; + } + + $columns = []; + foreach ($attributeKeys as $key => $attribute) { + $columns[$key] = $this->quote($this->filter($attribute)); + } + + $columns = '(' . \implode(', ', $columns) . ')'; + + $bindIndex = 0; + $batchKeys = []; + $bindValues = []; + $permissions = []; + $bindValuesPermissions = []; + + foreach ($documents as $index => $document) { + $attributes = $document->getAttributes(); + $attributes['_uid'] = $document->getId(); + $attributes['_createdAt'] = $document->getCreatedAt(); + $attributes['_updatedAt'] = $document->getUpdatedAt(); + $attributes['_permissions'] = \json_encode($document->getPermissions()); + + if (!empty($document->getSequence())) { + $attributes['_id'] = $document->getSequence(); + } + + if ($this->sharedTables) { + $attributes['_tenant'] = $document->getTenant(); + } + + $bindKeys = []; + + foreach ($attributeKeys as $key) { + $value = $attributes[$key] ?? null; + if (\is_array($value)) { + $value = \json_encode($value); + } + if (in_array($key, $spatialAttributes)) { + $bindKey = 'key_' . $bindIndex; + $bindKeys[] = "ST_GeomFromText(:" . $bindKey . ")"; + } else { + $value = (\is_bool($value)) ? (int)$value : $value; + $bindKey = 'key_' . $bindIndex; + $bindKeys[] = ':' . $bindKey; + } + $bindValues[$bindKey] = $value; + $bindIndex++; + } + + $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; + + foreach (Database::PERMISSIONS as $type) { + foreach ($document->getPermissionsByType($type) as $permission) { + $tenantBind = $this->sharedTables ? ", :_tenant_{$index}" : ''; + $permission = \str_replace('"', '', $permission); + $permission = "('{$type}', '{$permission}', :_uid_{$index} {$tenantBind})"; + $permissions[] = $permission; + $bindValuesPermissions[":_uid_{$index}"] = $document->getId(); + if ($this->sharedTables) { + $bindValuesPermissions[":_tenant_{$index}"] = $document->getTenant(); + } + } + } + } + + $batchKeys = \implode(', ', $batchKeys); + + $stmt = $this->getPDO()->prepare(" + INSERT INTO {$this->getSQLTable($name)} {$columns} + VALUES {$batchKeys} + "); + + foreach ($bindValues as $key => $value) { + $stmt->bindValue($key, $value, $this->getPDOType($value)); + } + + $this->execute($stmt); + + if (!empty($permissions)) { + $tenantColumn = $this->sharedTables ? ', _tenant' : ''; + $permissions = \implode(', ', $permissions); + + $sqlPermissions = " + INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document {$tenantColumn}) + VALUES {$permissions}; + "; + + $stmtPermissions = $this->getPDO()->prepare($sqlPermissions); + + foreach ($bindValuesPermissions as $key => $value) { + $stmtPermissions->bindValue($key, $value, $this->getPDOType($value)); + } + + $this->execute($stmtPermissions); + } + + } catch (PDOException $e) { + throw $this->processException($e); + } + + return $documents; + } + + /** + * @param Document $collection + * @param string $attribute + * @param array $changes + * @return array + * @throws DatabaseException + */ + public function createOrUpdateDocuments( + Document $collection, + string $attribute, + array $changes + ): array { + if (empty($changes)) { + return $changes; + } + try { + $spatialAttributes = $this->getSpatialAttributes($collection); + $collection = $collection->getId(); + $name = $this->filter($collection); + $attribute = $this->filter($attribute); + + $attributes = []; + $bindIndex = 0; + $batchKeys = []; + $bindValues = []; + + foreach ($changes as $change) { + $document = $change->getNew(); + $attributes = $document->getAttributes(); + $attributes['_uid'] = $document->getId(); + $attributes['_createdAt'] = $document->getCreatedAt(); + $attributes['_updatedAt'] = $document->getUpdatedAt(); + $attributes['_permissions'] = \json_encode($document->getPermissions()); + + if (!empty($document->getSequence())) { + $attributes['_id'] = $document->getSequence(); + } + + if ($this->sharedTables) { + $attributes['_tenant'] = $document->getTenant(); + } + + \ksort($attributes); + + $columns = []; + foreach (\array_keys($attributes) as $key => $attr) { + /** + * @var string $attr + */ + $columns[$key] = "{$this->quote($this->filter($attr))}"; + } + $columns = '(' . \implode(', ', $columns) . ')'; - /** - * @return string - */ - public function getIdAttributeType(): string - { - return Database::VAR_INTEGER; - } + $bindKeys = []; - /** - * @return int - */ - public function getMaxIndexLength(): int - { - /** - * $tenant int = 1 - */ - return $this->sharedTables ? 767 : 768; - } + foreach ($attributes as $attributeKey => $attrValue) { + if (\is_array($attrValue)) { + $attrValue = \json_encode($attrValue); + } - /** - * @param Query $query - * @param array $binds - * @return string - * @throws Exception - */ - abstract protected function getSQLCondition(Query $query, array &$binds): string; + if (in_array($attributeKey, $spatialAttributes)) { + $bindKey = 'key_' . $bindIndex; + $bindKeys[] = "ST_GeomFromText(:" . $bindKey . ")"; + } else { + $attrValue = (\is_bool($attrValue)) ? (int)$attrValue : $attrValue; + $bindKey = 'key_' . $bindIndex; + $bindKeys[] = ':' . $bindKey; + } + $bindValues[$bindKey] = $attrValue; + $bindIndex++; + } - /** - * @param array $queries - * @param array $binds - * @param string $separator - * @return string - * @throws Exception - */ - public function getSQLConditions(array $queries, array &$binds, string $separator = 'AND'): string - { - $conditions = []; - foreach ($queries as $query) { - if ($query->getMethod() === Query::TYPE_SELECT) { - continue; + $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; } - if ($query->isNested()) { - $conditions[] = $this->getSQLConditions($query->getValues(), $binds, $query->getMethod()); - } else { - $conditions[] = $this->getSQLCondition($query, $binds); - } - } + $stmt = $this->getUpsertStatement($name, $columns, $batchKeys, $attributes, $bindValues, $attribute); + $stmt->execute(); + $stmt->closeCursor(); - $tmp = implode(' ' . $separator . ' ', $conditions); - return empty($tmp) ? '' : '(' . $tmp . ')'; - } + $removeQueries = []; + $removeBindValues = []; + $addQueries = []; + $addBindValues = []; - /** - * @return string - */ - public function getLikeOperator(): string - { - return 'LIKE'; - } + foreach ($changes as $index => $change) { + $old = $change->getOld(); + $document = $change->getNew(); - public function getInternalIndexesKeys(): array - { - return []; - } + $current = []; + foreach (Database::PERMISSIONS as $type) { + $current[$type] = $old->getPermissionsByType($type); + } - public function getSchemaAttributes(string $collection): array - { - return []; - } + // Calculate removals + foreach (Database::PERMISSIONS as $type) { + $toRemove = \array_diff($current[$type], $document->getPermissionsByType($type)); + if (!empty($toRemove)) { + $removeQueries[] = "( + _document = :_uid_{$index} + " . ($this->sharedTables ? " AND _tenant = :_tenant_{$index}" : '') . " + AND _type = '{$type}' + AND _permission IN (" . \implode(',', \array_map(fn ($i) => ":remove_{$type}_{$index}_{$i}", \array_keys($toRemove))) . ") + )"; + $removeBindValues[":_uid_{$index}"] = $document->getId(); + if ($this->sharedTables) { + $removeBindValues[":_tenant_{$index}"] = $document->getTenant(); + } + foreach ($toRemove as $i => $perm) { + $removeBindValues[":remove_{$type}_{$index}_{$i}"] = $perm; + } + } + } - /** - * Get the query to check for tenant when in shared tables mode - * - * @param string $collection The collection being queried - * @param string $alias The alias of the parent collection if in a subquery - * @param int $tenantCount The number of tenants to check against - * @param string $condition - * @return string - */ - public function getTenantQuery( - string $collection, - string $alias = '', - int $tenantCount = 0, - string $condition = 'AND' - ): string { - if (!$this->sharedTables) { - return ''; - } + // Calculate additions + foreach (Database::PERMISSIONS as $type) { + $toAdd = \array_diff($document->getPermissionsByType($type), $current[$type]); - $dot = ''; - if ($alias !== '') { - $dot = '.'; - $alias = $this->quote($alias); - } + foreach ($toAdd as $i => $permission) { + $addQuery = "(:_uid_{$index}, '{$type}', :add_{$type}_{$index}_{$i}"; - $bindings = []; - if ($tenantCount === 0) { - $bindings[] = ':_tenant'; - } else { - for ($index = 0; $index < $tenantCount; $index++) { - $bindings[] = ":_tenant_{$index}"; + if ($this->sharedTables) { + $addQuery .= ", :_tenant_{$index}"; + } + + $addQuery .= ")"; + $addQueries[] = $addQuery; + $addBindValues[":_uid_{$index}"] = $document->getId(); + $addBindValues[":add_{$type}_{$index}_{$i}"] = $permission; + + if ($this->sharedTables) { + $addBindValues[":_tenant_{$index}"] = $document->getTenant(); + } + } + } } - } - $bindings = \implode(',', $bindings); - $orIsNull = ''; - if ($collection === Database::METADATA) { - $orIsNull = " OR {$alias}{$dot}_tenant IS NULL"; + // Execute permission removals + if (!empty($removeQueries)) { + $removeQuery = \implode(' OR ', $removeQueries); + $stmtRemovePermissions = $this->getPDO()->prepare("DELETE FROM {$this->getSQLTable($name . '_perms')} WHERE {$removeQuery}"); + foreach ($removeBindValues as $key => $value) { + $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); + } + $stmtRemovePermissions->execute(); + } + + // Execute permission additions + if (!empty($addQueries)) { + $sqlAddPermissions = "INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission"; + if ($this->sharedTables) { + $sqlAddPermissions .= ", _tenant"; + } + $sqlAddPermissions .= ") VALUES " . \implode(', ', $addQueries); + $stmtAddPermissions = $this->getPDO()->prepare($sqlAddPermissions); + foreach ($addBindValues as $key => $value) { + $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); + } + $stmtAddPermissions->execute(); + } + } catch (PDOException $e) { + throw $this->processException($e); } - return "{$condition} ({$alias}{$dot}_tenant IN ({$bindings}) {$orIsNull})"; + return \array_map(fn ($change) => $change->getNew(), $changes); } /** - * Get the SQL projection given the selected attributes + * Build geometry WKT string from array input for spatial queries * - * @param array $selections - * @param string $prefix - * @return mixed - * @throws Exception + * @param array $geometry + * @return string + * @throws DatabaseException */ - protected function getAttributeProjection(array $selections, string $prefix): mixed + protected function convertArrayToWKT(array $geometry): string { - if (empty($selections) || \in_array('*', $selections)) { - return "{$this->quote($prefix)}.*"; - } - - $internalKeys = [ - '$id', - '$sequence', - '$permissions', - '$createdAt', - '$updatedAt', - ]; - - $selections = \array_diff($selections, [...$internalKeys, '$collection']); - - foreach ($internalKeys as $internalKey) { - $selections[] = $this->getInternalKeyForAttribute($internalKey); + // point [x, y] + if (count($geometry) === 2 && is_numeric($geometry[0]) && is_numeric($geometry[1])) { + return "POINT({$geometry[0]} {$geometry[1]})"; } - foreach ($selections as &$selection) { - $selection = "{$this->quote($prefix)}.{$this->quote($this->filter($selection))}"; + // linestring [[x1, y1], [x2, y2], ...] + if (is_array($geometry[0]) && count($geometry[0]) === 2 && is_numeric($geometry[0][0])) { + $points = []; + foreach ($geometry as $point) { + if (!is_array($point) || count($point) !== 2 || !is_numeric($point[0]) || !is_numeric($point[1])) { + throw new DatabaseException('Invalid point format in geometry array'); + } + $points[] = "{$point[0]} {$point[1]}"; + } + return 'LINESTRING(' . implode(', ', $points) . ')'; } - return \implode(',', $selections); - } - - protected function getInternalKeyForAttribute(string $attribute): string - { - return match ($attribute) { - '$id' => '_uid', - '$sequence' => '_id', - '$collection' => '_collection', - '$tenant' => '_tenant', - '$createdAt' => '_createdAt', - '$updatedAt' => '_updatedAt', - '$permissions' => '_permissions', - default => $attribute - }; - } - - protected function escapeWildcards(string $value): string - { - $wildcards = ['%', '_', '[', ']', '^', '-', '.', '*', '+', '?', '(', ')', '{', '}', '|']; - - foreach ($wildcards as $wildcard) { - $value = \str_replace($wildcard, "\\$wildcard", $value); + // polygon [[[x1, y1], [x2, y2], ...], ...] + if (is_array($geometry[0]) && is_array($geometry[0][0]) && count($geometry[0][0]) === 2) { + $rings = []; + foreach ($geometry as $ring) { + if (!is_array($ring)) { + throw new DatabaseException('Invalid ring format in polygon geometry'); + } + $points = []; + foreach ($ring as $point) { + if (!is_array($point) || count($point) !== 2 || !is_numeric($point[0]) || !is_numeric($point[1])) { + throw new DatabaseException('Invalid point format in polygon ring'); + } + $points[] = "{$point[0]} {$point[1]}"; + } + $rings[] = '(' . implode(', ', $points) . ')'; + } + return 'POLYGON(' . implode(', ', $rings) . ')'; } - return $value; - } - - protected function processException(PDOException $e): \Exception - { - return $e; + throw new DatabaseException('Unrecognized geometry array format'); } /** - * @param mixed $stmt - * @return bool + * Helper method to get attribute type from attributes array + * + * @param string $attributeName + * @param array $attributes + * @return string|null */ - protected function execute(mixed $stmt): bool + protected function getAttributeType(string $attributeName, array $attributes): ?string { - return $stmt->execute(); + foreach ($attributes as $attribute) { + if (isset($attribute['$id']) && $attribute['$id'] === $attributeName) { + return $attribute['type'] ?? null; + } + if (isset($attribute['key']) && $attribute['key'] === $attributeName) { + return $attribute['type'] ?? null; + } + } + return null; } } diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 0c8d502c6..e9a2800aa 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -152,7 +152,8 @@ public function createCollection(string $name, array $attributes = [], array $in $attribute->getAttribute('type'), $attribute->getAttribute('size', 0), $attribute->getAttribute('signed', true), - $attribute->getAttribute('array', false) + $attribute->getAttribute('array', false), + $attribute->getAttribute('required', false) ); $attributeStrings[$key] = "`{$attrId}` {$attrType}, "; @@ -353,8 +354,8 @@ public function deleteAttribute(string $collection, string $id, bool $array = fa { $name = $this->filter($collection); $id = $this->filter($id); - - $collection = $this->getDocument(Database::METADATA, $name); + $metadataCollection = new Document(['$id' => Database::METADATA]); + $collection = $this->getDocument($metadataCollection, $name); if ($collection->isEmpty()) { throw new NotFoundException('Collection not found'); @@ -401,7 +402,8 @@ public function deleteAttribute(string $collection, string $id, bool $array = fa */ public function renameIndex(string $collection, string $old, string $new): bool { - $collection = $this->getDocument(Database::METADATA, $collection); + $metadataCollection = new Document(['$id' => Database::METADATA]); + $collection = $this->getDocument($metadataCollection, $collection); if ($collection->isEmpty()) { throw new NotFoundException('Collection not found'); @@ -509,15 +511,16 @@ public function deleteIndex(string $collection, string $id): bool /** * Create Document * - * @param string $collection + * @param Document $collection * @param Document $document * @return Document * @throws Exception * @throws PDOException * @throws Duplicate */ - public function createDocument(string $collection, Document $document): Document + public function createDocument(Document $collection, Document $document): Document { + $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); @@ -628,7 +631,7 @@ public function createDocument(string $collection, Document $document): Document /** * Update Document * - * @param string $collection + * @param Document $collection * @param string $id * @param Document $document * @param bool $skipPermissions @@ -637,8 +640,9 @@ public function createDocument(string $collection, Document $document): Document * @throws PDOException * @throws Duplicate */ - public function updateDocument(string $collection, string $id, Document $document, bool $skipPermissions): Document + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document { + $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); @@ -963,6 +967,16 @@ public function getSupportForBatchCreateAttributes(): bool return false; } + public function getSupportForSpatialAttributes(): bool + { + return false; // SQLite doesn't have native spatial support + } + + public function getSupportForSpatialIndexNull(): bool + { + return false; // SQLite doesn't have native spatial support + } + /** * Get SQL Index Type * @@ -1240,4 +1254,13 @@ protected function processException(PDOException $e): \Exception return $e; } + + public function getSupportForSpatialIndexOrder(): bool + { + return false; + } + public function getSupportForBoundaryInclusiveContains(): bool + { + return false; + } } diff --git a/src/Database/Database.php b/src/Database/Database.php index 7b4fe3188..85fad18ed 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -31,6 +31,7 @@ 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\Spatial; use Utopia\Database\Validator\Structure; class Database @@ -51,6 +52,13 @@ class Database // Relationship Types public const VAR_RELATIONSHIP = 'relationship'; + // Spatial Types + public const VAR_POINT = 'point'; + public const VAR_LINESTRING = 'linestring'; + public const VAR_POLYGON = 'polygon'; + + public const SPATIAL_TYPES = [self::VAR_POINT,self::VAR_LINESTRING, self::VAR_POLYGON]; + // Index Types public const INDEX_KEY = 'key'; public const INDEX_FULLTEXT = 'fulltext'; @@ -348,6 +356,11 @@ class Database protected bool $filter = true; + /** + * @var array|null + */ + protected ?array $disabledFilters = []; + protected bool $validate = true; protected bool $preserveDates = false; @@ -813,17 +826,35 @@ public function disableFilters(): static * * @template T * @param callable(): T $callback + * @param array|null $filters * @return T */ - public function skipFilters(callable $callback): mixed + public function skipFilters(callable $callback, ?array $filters = null): mixed { - $initial = $this->filter; - $this->disableFilters(); + if (empty($filters)) { + $initial = $this->filter; + $this->disableFilters(); + + try { + return $callback(); + } finally { + $this->filter = $initial; + } + } + + $previous = $this->filter; + $previousDisabled = $this->disabledFilters; + $disabled = []; + foreach ($filters as $name) { + $disabled[$name] = true; + } + $this->disabledFilters = $disabled; try { return $callback(); } finally { - $this->filter = $initial; + $this->filter = $previous; + $this->disabledFilters = $previousDisabled; } } @@ -1275,7 +1306,10 @@ public function createCollection(string $id, array $attributes = [], array $inde $attributes, $this->adapter->getMaxIndexLength(), $this->adapter->getInternalIndexesKeys(), - $this->adapter->getSupportForIndexArray() + $this->adapter->getSupportForIndexArray(), + $this->adapter->getSupportForSpatialAttributes(), + $this->adapter->getSupportForSpatialIndexNull(), + $this->adapter->getSupportForSpatialIndexOrder(), ); foreach ($indexes as $index) { if (!$validator->isValid($index)) { @@ -1583,7 +1617,7 @@ public function createAttribute(string $collection, string $id, string $type, in ); try { - $created = $this->adapter->createAttribute($collection->getId(), $id, $type, $size, $signed, $array); + $created = $this->adapter->createAttribute($collection->getId(), $id, $type, $size, $signed, $array, $required); if (!$created) { throw new DatabaseException('Failed to create attribute'); @@ -1811,8 +1845,16 @@ private function validateAttribute( case self::VAR_DATETIME: case self::VAR_RELATIONSHIP: break; + case self::VAR_POINT: + case self::VAR_LINESTRING: + case self::VAR_POLYGON: + // Check if adapter supports spatial attributes + if (!$this->adapter->getSupportForSpatialAttributes()) { + throw new DatabaseException('Spatial attributes are not supported'); + } + break; default: - throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . self::VAR_STRING . ', ' . self::VAR_INTEGER . ', ' . self::VAR_FLOAT . ', ' . self::VAR_BOOLEAN . ', ' . self::VAR_DATETIME . ', ' . self::VAR_RELATIONSHIP); + throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . self::VAR_STRING . ', ' . self::VAR_INTEGER . ', ' . self::VAR_FLOAT . ', ' . self::VAR_BOOLEAN . ', ' . self::VAR_DATETIME . ', ' . self::VAR_RELATIONSHIP . ', ' . self::VAR_POINT . ', ' . self::VAR_LINESTRING . ', ' . self::VAR_POLYGON); } // Only execute when $default is given @@ -1881,8 +1923,16 @@ protected function validateDefaultTypes(string $type, mixed $default): void throw new DatabaseException('Default value ' . $default . ' does not match given type ' . $type); } break; + case self::VAR_POINT: + case self::VAR_LINESTRING: + case self::VAR_POLYGON: + // Spatial types expect arrays as default values + if ($defaultType !== 'array') { + throw new DatabaseException('Default value for spatial type ' . $type . ' must be an array'); + } + // no break default: - throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . self::VAR_STRING . ', ' . self::VAR_INTEGER . ', ' . self::VAR_FLOAT . ', ' . self::VAR_BOOLEAN . ', ' . self::VAR_DATETIME . ', ' . self::VAR_RELATIONSHIP); + throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . self::VAR_STRING . ', ' . self::VAR_INTEGER . ', ' . self::VAR_FLOAT . ', ' . self::VAR_BOOLEAN . ', ' . self::VAR_DATETIME . ', ' . self::VAR_RELATIONSHIP . ', ' . self::VAR_POINT . ', ' . self::VAR_LINESTRING . ', ' . self::VAR_POLYGON); } } @@ -2204,7 +2254,10 @@ public function updateAttribute(string $collection, string $id, ?string $type = $attributes, $this->adapter->getMaxIndexLength(), $this->adapter->getInternalIndexesKeys(), - $this->adapter->getSupportForIndexArray() + $this->adapter->getSupportForIndexArray(), + $this->adapter->getSupportForSpatialAttributes(), + $this->adapter->getSupportForSpatialIndexNull(), + $this->adapter->getSupportForSpatialIndexOrder(), ); foreach ($indexes as $index) { @@ -3061,17 +3114,28 @@ public function createIndex(string $collection, string $id, string $type, array } break; + case self::INDEX_SPATIAL: + if (!$this->adapter->getSupportForSpatialAttributes()) { + throw new DatabaseException('Spatial indexes are not supported'); + } + if (!empty($orders) && !$this->adapter->getSupportForSpatialIndexOrder()) { + throw new DatabaseException('Spatial indexes with explicit orders are not supported. Remove the orders to create this index.'); + } + break; + default: - throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT); + throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT . ', ' . Database::INDEX_SPATIAL); } /** @var array $collectionAttributes */ $collectionAttributes = $collection->getAttribute('attributes', []); $indexAttributesWithTypes = []; + $indexAttributesRequired = []; foreach ($attributes as $i => $attr) { foreach ($collectionAttributes as $collectionAttribute) { if ($collectionAttribute->getAttribute('key') === $attr) { $indexAttributesWithTypes[$attr] = $collectionAttribute->getAttribute('type'); + $indexAttributesRequired[$attr] = $collectionAttribute->getAttribute('required', false); /** * mysql does not save length in collection when length = attributes size @@ -3094,6 +3158,29 @@ public function createIndex(string $collection, string $id, string $type, array } } + // Validate spatial index constraints + if ($type === self::INDEX_SPATIAL) { + foreach ($attributes as $attr) { + if (!isset($indexAttributesWithTypes[$attr])) { + throw new DatabaseException('Attribute "' . $attr . '" not found in collection'); + } + + $attributeType = $indexAttributesWithTypes[$attr]; + if (!in_array($attributeType, [self::VAR_POINT, self::VAR_LINESTRING, self::VAR_POLYGON])) { + throw new DatabaseException('Spatial index can only be created on spatial attributes (point, linestring, polygon). Attribute "' . $attr . '" is of type "' . $attributeType . '"'); + } + } + + // Check spatial index null constraints for adapters that don't support null values + if (!$this->adapter->getSupportForSpatialIndexNull()) { + foreach ($attributes as $attr) { + if (!$indexAttributesRequired[$attr]) { + throw new IndexException('Spatial indexes do not allow null values. Mark the attribute "' . $attr . '" as required or create the index on a column with no null values.'); + } + } + } + } + $index = new Document([ '$id' => ID::custom($id), 'key' => $id, @@ -3110,7 +3197,10 @@ public function createIndex(string $collection, string $id, string $type, array $collection->getAttribute('attributes', []), $this->adapter->getMaxIndexLength(), $this->adapter->getInternalIndexesKeys(), - $this->adapter->getSupportForIndexArray() + $this->adapter->getSupportForIndexArray(), + $this->adapter->getSupportForSpatialAttributes(), + $this->adapter->getSupportForSpatialIndexNull(), + $this->adapter->getSupportForSpatialIndexOrder(), ); if (!$validator->isValid($index)) { throw new IndexException($validator->getDescription()); @@ -3264,7 +3354,7 @@ public function getDocument(string $collection, string $id, array $queries = [], } $document = $this->adapter->getDocument( - $collection->getId(), + $collection, $id, $queries, $forUpdate @@ -3326,9 +3416,15 @@ private function populateDocumentRelationships(Document $collection, Document $d { $attributes = $collection->getAttribute('attributes', []); - $relationships = \array_filter($attributes, function ($attribute) { - return $attribute['type'] === Database::VAR_RELATIONSHIP; - }); + $relationships = []; + + foreach ($attributes as $attribute) { + if ($attribute['type'] === Database::VAR_RELATIONSHIP) { + if (empty($selects) || array_key_exists($attribute['key'], $selects)) { + $relationships[] = $attribute; + } + } + } foreach ($relationships as $relationship) { $key = $relationship['key']; @@ -3659,7 +3755,7 @@ public function createDocument(string $collection, Document $document): Document if ($this->resolveRelationships) { $document = $this->silent(fn () => $this->createDocumentRelationships($collection, $document)); } - return $this->adapter->createDocument($collection->getId(), $document); + return $this->adapter->createDocument($collection, $document); }); $document = $this->adapter->castingAfter($collection, $document); @@ -3760,7 +3856,7 @@ public function createDocuments( foreach (\array_chunk($documents, $batchSize) as $chunk) { $batch = $this->withTransaction(function () use ($collection, $chunk) { - return $this->adapter->createDocuments($collection->getId(), $chunk); + return $this->adapter->createDocuments($collection, $chunk); }); $batch = $this->adapter->getSequences($collection->getId(), $batch); @@ -4303,9 +4399,10 @@ public function updateDocument(string $collection, string $id, Document $documen $document = $this->silent(fn () => $this->updateDocumentRelationships($collection, $old, $document)); } + $document = $this->adapter->castingBefore($collection, $document); - $this->adapter->updateDocument($collection->getId(), $id, $document, $skipPermissionsUpdate); + $this->adapter->updateDocument($collection, $id, $document, $skipPermissionsUpdate); $document = $this->adapter->castingAfter($collection, $document); @@ -4503,7 +4600,7 @@ public function updateDocuments( } $this->adapter->updateDocuments( - $collection->getId(), + $collection, $updates, $batch ); @@ -5144,7 +5241,7 @@ public function createOrUpdateDocumentsWithIncrease( * @var array $chunk */ $batch = $this->withTransaction(fn () => Authorization::skip(fn () => $this->adapter->createOrUpdateDocuments( - $collection->getId(), + $collection, $attribute, $chunk ))); @@ -5214,7 +5311,7 @@ public function increaseDocumentAttribute( int|float|null $max = null ): Document { if ($value <= 0) { // Can be a float - throw new DatabaseException('Value must be numeric and greater than 0'); + throw new \InvalidArgumentException('Value must be numeric and greater than 0'); } $collection = $this->silent(fn () => $this->getCollection($collection)); @@ -5311,7 +5408,7 @@ public function decreaseDocumentAttribute( int|float|null $min = null ): Document { if ($value <= 0) { // Can be a float - throw new DatabaseException('Value must be numeric and greater than 0'); + throw new \InvalidArgumentException('Value must be numeric and greater than 0'); } $collection = $this->silent(fn () => $this->getCollection($collection)); @@ -6155,7 +6252,7 @@ public function find(string $collection, array $queries = [], string $forPermiss $nestedSelections = $this->processRelationshipQueries($relationships, $queries); $getResults = fn () => $this->adapter->find( - $collection->getId(), + $collection, $queries, $limit ?? 25, $offset ?? 0, @@ -6316,7 +6413,7 @@ public function count(string $collection, array $queries = [], ?int $max = null) $queries = Query::groupByType($queries)['filters']; $queries = $this->convertQueries($collection, $queries); - $getCount = fn () => $this->adapter->count($collection->getId(), $queries, $max); + $getCount = fn () => $this->adapter->count($collection, $queries, $max); $count = $skipAuth ?? false ? Authorization::skip($getCount) : $getCount(); $this->trigger(self::EVENT_DOCUMENT_COUNT, $count); @@ -6361,7 +6458,7 @@ public function sum(string $collection, string $attribute, array $queries = [], $queries = $this->convertQueries($collection, $queries); - $sum = $this->adapter->sum($collection->getId(), $attribute, $queries, $max); + $sum = $this->adapter->sum($collection, $attribute, $queries, $max); $this->trigger(self::EVENT_DOCUMENT_SUM, $sum); @@ -6434,6 +6531,14 @@ public function encode(Document $collection, Document $document): Document foreach ($value as $index => $node) { if ($node !== null) { + // Handle spatial data encoding + $attributeType = $attribute['type'] ?? ''; + if (in_array($attributeType, Database::SPATIAL_TYPES)) { + if (is_array($node)) { + $node = $this->encodeSpatialData($node, $attributeType); + } + } + foreach ($filters as $filter) { $node = $this->encodeAttribute($filter, $node, $document); } @@ -6491,6 +6596,7 @@ public function decode(Document $collection, Document $document, array $selectio foreach ($attributes as $attribute) { $key = $attribute['$id'] ?? ''; + $type = $attribute['type'] ?? ''; $array = $attribute['array'] ?? false; $filters = $attribute['filters'] ?? []; $value = $document->getAttribute($key); @@ -6511,6 +6617,10 @@ public function decode(Document $collection, Document $document, array $selectio $value = (is_null($value)) ? [] : $value; foreach ($value as $index => $node) { + if (is_string($node) && in_array($type, Database::SPATIAL_TYPES)) { + $node = $this->decodeSpatialData($node); + } + foreach (array_reverse($filters) as $filter) { $node = $this->decodeAttribute($filter, $node, $document, $key); } @@ -6651,6 +6761,10 @@ protected function decodeAttribute(string $filter, mixed $value, Document $docum return $value; } + if (!\is_null($this->disabledFilters) && isset($this->disabledFilters[$filter])) { + return $value; + } + if (!array_key_exists($filter, self::$filters) && !array_key_exists($filter, $this->instanceFilters)) { throw new NotFoundException("Filter \"{$filter}\" not found for attribute \"{$attribute}\""); } @@ -6749,79 +6863,72 @@ public function getLimitForIndexes(): int } /** - * @param Document $collection - * @param array $queries - * @return array - * @throws QueryException - */ + * @param Document $collection + * @param array $queries + * @return array + * @throws QueryException + * @throws \Utopia\Database\Exception + */ public function convertQueries(Document $collection, array $queries): array { + foreach ($queries as $index => $query) { + if ($query->isNested()) { + $values = $this->convertQueries($collection, $query->getValues()); + $query->setValues($values); + } - $attributes = $collection->getAttribute('attributes', []); - foreach (Database::INTERNAL_ATTRIBUTES as $attribute) { - $attributes[] = new Document($attribute); - } - - $map = []; - foreach ($attributes as $attribute) { - $map[$attribute->getId()] = $attribute; - } + $query = $this->convertQuery($collection, $query); - foreach ($queries as $i => $query) { - $queries[$i] = $this->processQuery($query, $map); + $queries[$index] = $query; } return $queries; } /** - * Recursively normalizes a single Query (and any nested Query objects inside its values). - * - * @param \Utopia\Database\Query $query - * @param array $map - * @return \Utopia\Database\Query + * @param Document $collection + * @param Query $query + * @return Query * @throws QueryException + * @throws \Utopia\Database\Exception */ - private function processQuery(\Utopia\Database\Query $query, array $map): \Utopia\Database\Query + public function convertQuery(Document $collection, Query $query): Query { - $attrId = $query->getAttribute(); + /** + * @var array $attributes + */ + $attributes = $collection->getAttribute('attributes', []); - if (!empty($map[$attrId])) { - $attr = $map[$attrId]; + foreach (Database::INTERNAL_ATTRIBUTES as $attribute) { + $attributes[] = new Document($attribute); + } - $query->setOnArray((bool) $attr->getAttribute('array', false)); + $attribute = new Document(); - // Normalize datetime values if needed - if ($attr->getAttribute('type') === Database::VAR_DATETIME) { + foreach ($attributes as $attr) { + if ($attr->getId() === $query->getAttribute()) { + $attribute = $attr; + } + } + + if (! $attribute->isEmpty()) { + $query->setOnArray($attribute->getAttribute('array', false)); + + if ($attribute->getAttribute('type') == Database::VAR_DATETIME) { $values = $query->getValues(); - foreach ($values as $idx => $val) { + foreach ($values as $valueIndex => $value) { try { - $values[$idx] = $this->adapter->isMongo() - ? $this->adapter->setUTCDatetime($val) - : DateTime::setTimezone($val); + $values[$valueIndex] = $this->adapter->isMongo() + ? $this->adapter->setUTCDatetime($value) + : DateTime::setTimezone($value); } catch (\Throwable $e) { - throw new QueryException($e->getMessage(), (int) $e->getCode(), $e); + throw new QueryException($e->getMessage(), $e->getCode(), $e); } } $query->setValues($values); } } - $values = $query->getValues(); - foreach ($values as $i => $v) { - if ($v instanceof \Utopia\Database\Query) { - $values[$i] = $this->processQuery($v, $map); - } elseif (is_array($v)) { - foreach ($v as $j => $vv) { - if ($vv instanceof \Utopia\Database\Query) { - $v[$j] = $this->processQuery($vv, $map); - } - } - $values[$i] = $v; - } - } - $query->setValues($values); - return $query; } @@ -6987,4 +7094,106 @@ private function processRelationshipQueries( return $nestedSelections; } + + /** + * Encode spatial data from array format to WKT (Well-Known Text) format + * + * @param mixed $value + * @param string $type + * @return string + * @throws DatabaseException + */ + protected function encodeSpatialData(mixed $value, string $type): string + { + $validator = new Spatial($type); + $validator->isValid($value); + + switch ($type) { + case self::VAR_POINT: + return "POINT({$value[0]} {$value[1]})"; + + case self::VAR_LINESTRING: + $points = []; + foreach ($value as $point) { + $points[] = "{$point[0]} {$point[1]}"; + } + return 'LINESTRING(' . implode(', ', $points) . ')'; + + case self::VAR_POLYGON: + // Check if this is a single ring (flat array of points) or multiple rings + $isSingleRing = count($value) > 0 && is_array($value[0]) && + count($value[0]) === 2 && is_numeric($value[0][0]) && is_numeric($value[0][1]); + + if ($isSingleRing) { + // Convert single ring format [[x1,y1], [x2,y2], ...] to multi-ring format + $value = [$value]; + } + + $rings = []; + foreach ($value as $ring) { + $points = []; + foreach ($ring as $point) { + $points[] = "{$point[0]} {$point[1]}"; + } + $rings[] = '(' . implode(', ', $points) . ')'; + } + return 'POLYGON(' . implode(', ', $rings) . ')'; + + default: + throw new DatabaseException('Unknown spatial type: ' . $type); + } + } + + /** + * Decode spatial data from WKT (Well-Known Text) format to array format + * + * @param string $wkt + * @return array + * @throws DatabaseException + */ + public function decodeSpatialData(string $wkt): array + { + $upper = strtoupper($wkt); + + // POINT(x y) + if (str_starts_with($upper, 'POINT(')) { + $start = strpos($wkt, '(') + 1; + $end = strrpos($wkt, ')'); + $inside = substr($wkt, $start, $end - $start); + + $coords = explode(' ', trim($inside)); + return [(float)$coords[0], (float)$coords[1]]; + } + + // LINESTRING(x1 y1, x2 y2, ...) + if (str_starts_with($upper, 'LINESTRING(')) { + $start = strpos($wkt, '(') + 1; + $end = strrpos($wkt, ')'); + $inside = substr($wkt, $start, $end - $start); + + $points = explode(',', $inside); + return array_map(function ($point) { + $coords = explode(' ', trim($point)); + return [(float)$coords[0], (float)$coords[1]]; + }, $points); + } + + // POLYGON((x1,y1),(x2,y2)) + if (str_starts_with($upper, 'POLYGON((')) { + $start = strpos($wkt, '((') + 2; + $end = strrpos($wkt, '))'); + $inside = substr($wkt, $start, $end - $start); + + $rings = explode('),(', $inside); + return array_map(function ($ring) { + $points = explode(',', $ring); + return array_map(function ($point) { + $coords = explode(' ', trim($point)); + return [(float)$coords[0], (float)$coords[1]]; + }, $points); + }, $rings); + } + + return [$wkt]; + } } diff --git a/src/Database/Document.php b/src/Database/Document.php index 566a37444..e8a7a3a08 100644 --- a/src/Database/Document.php +++ b/src/Database/Document.php @@ -66,11 +66,17 @@ public function getId(): string } /** - * @return string + * @return string|null */ - public function getSequence(): string + public function getSequence(): ?string { - return $this->getAttribute('$sequence', ''); + $sequence = $this->getAttribute('$sequence'); + + if ($sequence === null) { + return null; + } + + return $sequence; } /** @@ -172,10 +178,12 @@ public function getUpdatedAt(): ?string public function getTenant(): ?int { $tenant = $this->getAttribute('$tenant'); - if ($tenant !== null) { - return (int)$tenant; + + if ($tenant === null) { + return null; } - return null; + + return (int) $tenant; } /** diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index 5934cba03..25df6c888 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -781,6 +781,75 @@ function ($doc) use ($onNext, &$modified) { return $modified; } + public function createOrUpdateDocuments(string $collection, array $documents, int $batchSize = Database::INSERT_BATCH_SIZE, callable|null $onNext = null): int + { + $modified = 0; + $this->source->createOrUpdateDocuments( + $collection, + $documents, + $batchSize, + function ($doc) use ($onNext, &$modified) { + $onNext && $onNext($doc); + $modified++; + } + ); + + if ( + \in_array($collection, self::SOURCE_ONLY_COLLECTIONS) + || $this->destination === null + ) { + return $modified; + } + + $upgrade = $this->silent(fn () => $this->getUpgradeStatus($collection)); + if ($upgrade === null || $upgrade->getAttribute('status', '') !== 'upgraded') { + return $modified; + } + + try { + $clones = []; + + foreach ($documents as $document) { + $clone = clone $document; + + foreach ($this->writeFilters as $filter) { + $clone = $filter->beforeCreateOrUpdateDocument( + source: $this->source, + destination: $this->destination, + collectionId: $collection, + document: $clone, + ); + } + + $clones[] = $clone; + } + + $modified = $this->destination->withPreserveDates( + fn () => + $this->destination->createOrUpdateDocuments( + $collection, + $clones, + $batchSize, + null, + ) + ); + + foreach ($clones as $clone) { + foreach ($this->writeFilters as $filter) { + $filter->afterCreateOrUpdateDocument( + source: $this->source, + destination: $this->destination, + collectionId: $collection, + document: $clone, + ); + } + } + } catch (\Throwable $err) { + $this->logError('createDocuments', $err); + } + return $modified; + } + public function deleteDocument(string $collection, string $id): bool { $result = $this->source->deleteDocument($collection, $id); diff --git a/src/Database/Mirroring/Filter.php b/src/Database/Mirroring/Filter.php index c440ea4b6..2da00534b 100644 --- a/src/Database/Mirroring/Filter.php +++ b/src/Database/Mirroring/Filter.php @@ -359,4 +359,40 @@ public function afterDeleteDocuments( array $queries ): void { } + + /** + * Called before document is upserted in the destination database + * + * @param Database $source + * @param Database $destination + * @param string $collectionId + * @param Document $document + * @return Document + */ + public function beforeCreateOrUpdateDocument( + Database $source, + Database $destination, + string $collectionId, + Document $document, + ): Document { + return $document; + } + + /** + * Called after document is upserted in the destination database + * + * @param Database $source + * @param Database $destination + * @param string $collectionId + * @param Document $document + * @return Document + */ + public function afterCreateOrUpdateDocument( + Database $source, + Database $destination, + string $collectionId, + Document $document, + ): Document { + return $document; + } } diff --git a/src/Database/Query.php b/src/Database/Query.php index 90c15914f..24f40eece 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -15,12 +15,31 @@ class Query public const TYPE_GREATER = 'greaterThan'; public const TYPE_GREATER_EQUAL = 'greaterThanEqual'; public const TYPE_CONTAINS = 'contains'; + public const TYPE_NOT_CONTAINS = 'notContains'; public const TYPE_SEARCH = 'search'; + public const TYPE_NOT_SEARCH = 'notSearch'; public const TYPE_IS_NULL = 'isNull'; public const TYPE_IS_NOT_NULL = 'isNotNull'; public const TYPE_BETWEEN = 'between'; + public const TYPE_NOT_BETWEEN = 'notBetween'; public const TYPE_STARTS_WITH = 'startsWith'; + public const TYPE_NOT_STARTS_WITH = 'notStartsWith'; public const TYPE_ENDS_WITH = 'endsWith'; + public const TYPE_NOT_ENDS_WITH = 'notEndsWith'; + + // General spatial method constants (for spatial-only operations) + public const TYPE_CROSSES = 'crosses'; + public const TYPE_NOT_CROSSES = 'notCrosses'; + public const TYPE_DISTANCE_EQUAL = 'distanceEqual'; + public const TYPE_DISTANCE_NOT_EQUAL = 'distanceNotEqual'; + public const TYPE_DISTANCE_GREATER_THAN = 'distanceGreaterThan'; + public const TYPE_DISTANCE_LESS_THAN = 'distanceLessThan'; + public const TYPE_INTERSECTS = 'intersects'; + public const TYPE_NOT_INTERSECTS = 'notIntersects'; + public const TYPE_OVERLAPS = 'overlaps'; + public const TYPE_NOT_OVERLAPS = 'notOverlaps'; + public const TYPE_TOUCHES = 'touches'; + public const TYPE_NOT_TOUCHES = 'notTouches'; public const TYPE_SELECT = 'select'; @@ -48,12 +67,29 @@ class Query 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_SELECT, self::TYPE_ORDER_DESC, self::TYPE_ORDER_ASC, @@ -206,7 +242,9 @@ public static function isMethod(string $value): bool self::TYPE_GREATER, self::TYPE_GREATER_EQUAL, self::TYPE_CONTAINS, + self::TYPE_NOT_CONTAINS, self::TYPE_SEARCH, + self::TYPE_NOT_SEARCH, self::TYPE_ORDER_ASC, self::TYPE_ORDER_DESC, self::TYPE_LIMIT, @@ -216,8 +254,23 @@ public static function isMethod(string $value): bool 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_OR, self::TYPE_AND, self::TYPE_SELECT => true, @@ -225,6 +278,29 @@ public static function isMethod(string $value): bool }; } + /** + * Check if method is a spatial-only query method + * @return bool + */ + public function isSpatialQuery(): bool + { + return match ($this->method) { + 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 => true, + default => false, + }; + } + /** * Parse query * @@ -349,7 +425,7 @@ public function toString(): string * Helper method to create Query with equal method * * @param string $attribute - * @param array $values + * @param array> $values * @return Query */ public static function equal(string $attribute, array $values): self @@ -361,12 +437,12 @@ public static function equal(string $attribute, array $values): self * Helper method to create Query with notEqual method * * @param string $attribute - * @param string|int|float|bool $value + * @param string|int|float|bool|array $value * @return Query */ - public static function notEqual(string $attribute, string|int|float|bool $value): self + public static function notEqual(string $attribute, string|int|float|bool|array $value): self { - return new self(self::TYPE_NOT_EQUAL, $attribute, [$value]); + return new self(self::TYPE_NOT_EQUAL, $attribute, is_array($value) ? $value : [$value]); } /** @@ -429,6 +505,18 @@ public static function contains(string $attribute, array $values): self return new self(self::TYPE_CONTAINS, $attribute, $values); } + /** + * Helper method to create Query with notContains method + * + * @param string $attribute + * @param array $values + * @return Query + */ + public static function notContains(string $attribute, array $values): self + { + return new self(self::TYPE_NOT_CONTAINS, $attribute, $values); + } + /** * Helper method to create Query with between method * @@ -442,6 +530,19 @@ public static function between(string $attribute, string|int|float|bool $start, return new self(self::TYPE_BETWEEN, $attribute, [$start, $end]); } + /** + * Helper method to create Query with notBetween method + * + * @param string $attribute + * @param string|int|float|bool $start + * @param string|int|float|bool $end + * @return Query + */ + public static function notBetween(string $attribute, string|int|float|bool $start, string|int|float|bool $end): self + { + return new self(self::TYPE_NOT_BETWEEN, $attribute, [$start, $end]); + } + /** * Helper method to create Query with search method * @@ -454,6 +555,18 @@ public static function search(string $attribute, string $value): self return new self(self::TYPE_SEARCH, $attribute, [$value]); } + /** + * Helper method to create Query with notSearch method + * + * @param string $attribute + * @param string $value + * @return Query + */ + 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 * @@ -558,11 +671,65 @@ public static function startsWith(string $attribute, string $value): self return new self(self::TYPE_STARTS_WITH, $attribute, [$value]); } + public static function notStartsWith(string $attribute, string $value): self + { + return new self(self::TYPE_NOT_STARTS_WITH, $attribute, [$value]); + } + public static function endsWith(string $attribute, string $value): self { return new self(self::TYPE_ENDS_WITH, $attribute, [$value]); } + public static function notEndsWith(string $attribute, string $value): self + { + return new self(self::TYPE_NOT_ENDS_WITH, $attribute, [$value]); + } + + /** + * Helper method to create Query for documents created before a specific date + * + * @param string $value + * @return Query + */ + public static function createdBefore(string $value): self + { + return self::lessThan('$createdAt', $value); + } + + /** + * Helper method to create Query for documents created after a specific date + * + * @param string $value + * @return Query + */ + public static function createdAfter(string $value): self + { + return self::greaterThan('$createdAt', $value); + } + + /** + * Helper method to create Query for documents updated before a specific date + * + * @param string $value + * @return Query + */ + public static function updatedBefore(string $value): self + { + return self::lessThan('$updatedAt', $value); + } + + /** + * Helper method to create Query for documents updated after a specific date + * + * @param string $value + * @return Query + */ + public static function updatedAfter(string $value): self + { + return self::greaterThan('$updatedAt', $value); + } + /** * @param array $queries * @return Query @@ -727,4 +894,154 @@ public function setOnArray(bool $bool): void { $this->onArray = $bool; } + + // Spatial query methods + + /** + * Helper method to create Query with distanceEqual method + * + * @param string $attribute + * @param array $values + * @param int|float $distance + * @return Query + */ + public static function distanceEqual(string $attribute, array $values, int|float $distance): self + { + return new self(self::TYPE_DISTANCE_EQUAL, $attribute, [[$values,$distance]]); + } + + /** + * Helper method to create Query with distanceNotEqual method + * + * @param string $attribute + * @param array $values + * @param int|float $distance + * @return Query + */ + public static function distanceNotEqual(string $attribute, array $values, int|float $distance): self + { + return new self(self::TYPE_DISTANCE_NOT_EQUAL, $attribute, [[$values,$distance]]); + } + + /** + * Helper method to create Query with distanceGreaterThan method + * + * @param string $attribute + * @param array $values + * @param int|float $distance + * @return Query + */ + public static function distanceGreaterThan(string $attribute, array $values, int|float $distance): self + { + return new self(self::TYPE_DISTANCE_GREATER_THAN, $attribute, [[$values,$distance]]); + } + + /** + * Helper method to create Query with distanceLessThan method + * + * @param string $attribute + * @param array $values + * @param int|float $distance + * @return Query + */ + public static function distanceLessThan(string $attribute, array $values, int|float $distance): self + { + return new self(self::TYPE_DISTANCE_LESS_THAN, $attribute, [[$values,$distance]]); + } + + /** + * Helper method to create Query with intersects method + * + * @param string $attribute + * @param array $values + * @return Query + */ + public static function intersects(string $attribute, array $values): self + { + return new self(self::TYPE_INTERSECTS, $attribute, $values); + } + + /** + * Helper method to create Query with notIntersects method + * + * @param string $attribute + * @param array $values + * @return Query + */ + public static function notIntersects(string $attribute, array $values): self + { + return new self(self::TYPE_NOT_INTERSECTS, $attribute, $values); + } + + /** + * Helper method to create Query with crosses method + * + * @param string $attribute + * @param array $values + * @return Query + */ + public static function crosses(string $attribute, array $values): self + { + return new self(self::TYPE_CROSSES, $attribute, $values); + } + + /** + * Helper method to create Query with notCrosses method + * + * @param string $attribute + * @param array $values + * @return Query + */ + public static function notCrosses(string $attribute, array $values): self + { + return new self(self::TYPE_NOT_CROSSES, $attribute, $values); + } + + /** + * Helper method to create Query with overlaps method + * + * @param string $attribute + * @param array $values + * @return Query + */ + public static function overlaps(string $attribute, array $values): self + { + return new self(self::TYPE_OVERLAPS, $attribute, $values); + } + + /** + * Helper method to create Query with notOverlaps method + * + * @param string $attribute + * @param array $values + * @return Query + */ + public static function notOverlaps(string $attribute, array $values): self + { + return new self(self::TYPE_NOT_OVERLAPS, $attribute, $values); + } + + /** + * Helper method to create Query with touches method + * + * @param string $attribute + * @param array $values + * @return Query + */ + public static function touches(string $attribute, array $values): self + { + return new self(self::TYPE_TOUCHES, $attribute, $values); + } + + /** + * Helper method to create Query with notTouches method + * + * @param string $attribute + * @param array $values + * @return Query + */ + public static function notTouches(string $attribute, array $values): self + { + return new self(self::TYPE_NOT_TOUCHES, $attribute, $values); + } } diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index 2c1337c77..87fa51e78 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -24,18 +24,30 @@ class Index extends Validator protected bool $arrayIndexSupport; + protected bool $spatialIndexSupport; + + protected bool $spatialIndexNullSupport; + + protected bool $spatialIndexOrderSupport; + /** * @param array $attributes * @param int $maxLength * @param array $reservedKeys * @param bool $arrayIndexSupport + * @param bool $spatialIndexSupport + * @param bool $spatialIndexNullSupport + * @param bool $spatialIndexOrderSupport * @throws DatabaseException */ - public function __construct(array $attributes, int $maxLength, array $reservedKeys = [], bool $arrayIndexSupport = false) + public function __construct(array $attributes, int $maxLength, array $reservedKeys = [], bool $arrayIndexSupport = false, bool $spatialIndexSupport = false, bool $spatialIndexNullSupport = false, bool $spatialIndexOrderSupport = false) { $this->maxLength = $maxLength; $this->reservedKeys = $reservedKeys; $this->arrayIndexSupport = $arrayIndexSupport; + $this->spatialIndexSupport = $spatialIndexSupport; + $this->spatialIndexNullSupport = $spatialIndexNullSupport; + $this->spatialIndexOrderSupport = $spatialIndexOrderSupport; foreach ($attributes as $attribute) { $key = \strtolower($attribute->getAttribute('key', $attribute->getAttribute('$id'))); @@ -289,6 +301,10 @@ public function isValid($value): bool return false; } + if (!$this->checkSpatialIndex($value)) { + return false; + } + return true; } @@ -315,4 +331,47 @@ public function getType(): string { return self::TYPE_OBJECT; } + + /** + * @param Document $index + * @return bool + */ + public function checkSpatialIndex(Document $index): bool + { + $type = $index->getAttribute('type'); + if ($type !== Database::INDEX_SPATIAL) { + return true; + } + + if (!$this->spatialIndexSupport) { + $this->message = 'Spatial indexes are not supported'; + return false; + } + + $attributes = $index->getAttribute('attributes', []); + $orders = $index->getAttribute('orders', []); + + foreach ($attributes as $attributeName) { + $attribute = $this->attributes[\strtolower($attributeName)] ?? new Document(); + $attributeType = $attribute->getAttribute('type', ''); + + if (!\in_array($attributeType, Database::SPATIAL_TYPES, true)) { + $this->message = 'Spatial index can only be created on spatial attributes (point, linestring, polygon). Attribute "' . $attributeName . '" is of type "' . $attributeType . '"'; + return false; + } + + $required = (bool) $attribute->getAttribute('required', false); + if (!$required && !$this->spatialIndexNullSupport) { + $this->message = 'Spatial indexes do not allow null values. Mark the attribute "' . $attributeName . '" as required or create the index on a column with no null values.'; + return false; + } + } + + if (!empty($orders) && !$this->spatialIndexOrderSupport) { + $this->message = 'Spatial indexes with explicit orders are not supported. Remove the orders to create this index.'; + return false; + } + + return true; + } } diff --git a/src/Database/Validator/IndexedQueries.php b/src/Database/Validator/IndexedQueries.php index cb727c0fb..8e324b215 100644 --- a/src/Database/Validator/IndexedQueries.php +++ b/src/Database/Validator/IndexedQueries.php @@ -91,7 +91,10 @@ public function isValid($value): bool $filters = $grouped['filters']; foreach ($filters as $filter) { - if ($filter->getMethod() === Query::TYPE_SEARCH) { + if ( + $filter->getMethod() === Query::TYPE_SEARCH || + $filter->getMethod() === Query::TYPE_NOT_SEARCH + ) { $matched = false; foreach ($this->indexes as $index) { diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index b1d67aad0..a2363101b 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -93,14 +93,31 @@ public function isValid($value): bool 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_CONTAINS, + 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 => Base::METHOD_TYPE_FILTER, + 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 => Base::METHOD_TYPE_FILTER, default => '', }; diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 9c2533558..9f331d871 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -101,9 +101,15 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s return false; } - // Extract the type of desired attribute from collection $schema $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; @@ -138,6 +144,16 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s case Database::VAR_RELATIONSHIP: $validator = new Text(255, 0); // The query is always on uid break; + + 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; + default: $this->message = 'Unknown Data type'; return false; @@ -181,16 +197,18 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s if ( !$array && - $method === Query::TYPE_CONTAINS && - $attributeSchema['type'] !== Database::VAR_STRING + in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS]) && + $attributeSchema['type'] !== Database::VAR_STRING && + !in_array($attributeSchema['type'], Database::SPATIAL_TYPES) ) { - $this->message = 'Cannot query contains on attribute "' . $attribute . '" because it is not an array or string.'; + $queryType = $method === Query::TYPE_NOT_CONTAINS ? 'notContains' : 'contains'; + $this->message = 'Cannot query ' . $queryType . ' on attribute "' . $attribute . '" because it is not an array or string.'; return false; } if ( $array && - !in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL]) + !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; @@ -233,6 +251,7 @@ public function isValid($value): bool 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; @@ -240,14 +259,27 @@ public function isValid($value): bool 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]) !== 2) { + $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; @@ -256,6 +288,7 @@ public function isValid($value): bool 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; @@ -284,6 +317,15 @@ public function isValid($value): bool 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; } } diff --git a/src/Database/Validator/Spatial.php b/src/Database/Validator/Spatial.php new file mode 100644 index 000000000..30026dfe2 --- /dev/null +++ b/src/Database/Validator/Spatial.php @@ -0,0 +1,203 @@ +spatialType = $spatialType; + } + + /** + * Validate spatial data according to its type + * + * @param mixed $value + * @param string $type + * @return bool + * @throws Exception + */ + public static function validate(mixed $value, string $type): bool + { + if (!is_array($value)) { + throw new Exception('Spatial data must be provided as an array'); + } + + switch ($type) { + case Database::VAR_POINT: + return self::validatePoint($value); + + case Database::VAR_LINESTRING: + return self::validateLineString($value); + + case Database::VAR_POLYGON: + return self::validatePolygon($value); + + default: + throw new Exception('Unknown spatial type: ' . $type); + } + } + + /** + * Validate POINT data + * + * @param array $value + * @return bool + * @throws Exception + */ + protected static function validatePoint(array $value): bool + { + if (count($value) !== 2) { + throw new Exception('Point must be an array of two numeric values [x, y]'); + } + + if (!is_numeric($value[0]) || !is_numeric($value[1])) { + throw new Exception('Point coordinates must be numeric values'); + } + + return true; + } + + /** + * Validate LINESTRING data + * + * @param array $value + * @return bool + * @throws Exception + */ + protected static function validateLineString(array $value): bool + { + if (count($value) < 2) { + throw new Exception('LineString must contain at least one point'); + } + + foreach ($value as $point) { + if (!is_array($point) || count($point) !== 2) { + throw new Exception('Each point in LineString must be an array of two values [x, y]'); + } + + if (!is_numeric($point[0]) || !is_numeric($point[1])) { + throw new Exception('Each point in LineString must have numeric coordinates'); + } + } + + return true; + } + + /** + * Validate POLYGON data + * + * @param array $value + * @return bool + * @throws Exception + */ + protected static function validatePolygon(array $value): bool + { + if (empty($value)) { + throw new Exception('Polygon must contain at least one ring'); + } + + // Detect single-ring polygon: [[x, y], [x, y], ...] + $isSingleRing = isset($value[0]) && is_array($value[0]) && + count($value[0]) === 2 && is_numeric($value[0][0]) && is_numeric($value[0][1]); + + if ($isSingleRing) { + $value = [$value]; // Wrap single ring into multi-ring format + } + + foreach ($value as $ring) { + if (!is_array($ring) || empty($ring)) { + throw new Exception('Each ring in Polygon must be an array of points'); + } + + foreach ($ring as $point) { + if (!is_array($point) || count($point) !== 2) { + throw new Exception('Each point in Polygon ring must be an array of two values [x, y]'); + } + if (!is_numeric($point[0]) || !is_numeric($point[1])) { + throw new Exception('Each point in Polygon ring must have numeric coordinates'); + } + } + } + + return true; + } + + /** + * Check if a value is valid WKT string + * + * @param string $value + * @return bool + */ + public static function isWKTString(string $value): bool + { + $value = trim($value); + return (bool) preg_match('/^(POINT|LINESTRING|POLYGON)\s*\(/i', $value); + } + + /** + * Get validator description + * + * @return string + */ + public function getDescription(): string + { + return 'Value must be a valid ' . $this->spatialType . ' format (array or WKT string)'; + } + + /** + * Is array + * + * @return bool + */ + public function isArray(): bool + { + return false; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return 'spatial'; + } + + /** + * Is valid + * + * @param mixed $value + * @return bool + */ + public function isValid($value): bool + { + if (is_null($value)) { + return true; + } + + if (is_string($value)) { + // Check if it's a valid WKT string + return self::isWKTString($value); + } + + if (is_array($value)) { + // Validate the array format according to the specific spatial type + try { + self::validate($value, $this->spatialType); + return true; + } catch (\Exception $e) { + return false; + } + } + + return false; + } +} diff --git a/src/Database/Validator/Structure.php b/src/Database/Validator/Structure.php index 037331dcd..cfb12fa3a 100644 --- a/src/Database/Validator/Structure.php +++ b/src/Database/Validator/Structure.php @@ -350,6 +350,12 @@ protected function checkForInvalidAttributeValues(array $structure, array $keys) ); break; + case Database::VAR_POINT: + case Database::VAR_LINESTRING: + case Database::VAR_POLYGON: + $validators[] = new Spatial($type); + break; + default: $this->message = 'Unknown attribute type "'.$type.'"'; return false; diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index a57fe2748..37ad7cce3 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -10,6 +10,7 @@ use Tests\E2E\Adapter\Scopes\IndexTests; use Tests\E2E\Adapter\Scopes\PermissionTests; use Tests\E2E\Adapter\Scopes\RelationshipTests; +use Tests\E2E\Adapter\Scopes\SpatialTests; use Utopia\Database\Database; use Utopia\Database\Validator\Authorization; @@ -23,6 +24,7 @@ abstract class Base extends TestCase use IndexTests; use PermissionTests; use RelationshipTests; + use SpatialTests; use GeneralTests; protected static string $namespace; diff --git a/tests/e2e/Adapter/Scopes/AttributeTests.php b/tests/e2e/Adapter/Scopes/AttributeTests.php index 5826da4b8..25ee025d8 100644 --- a/tests/e2e/Adapter/Scopes/AttributeTests.php +++ b/tests/e2e/Adapter/Scopes/AttributeTests.php @@ -1678,6 +1678,17 @@ public function testCreateDatetime(): void Query::equal('date', [$date]) ]); $this->assertCount(0, $docs); + + /** + * Test convertQueries on nested queries + */ + $docs = $database->find('datetime', [ + Query::or([ + Query::equal('$createdAt', [$date]), + Query::equal('date', [$date]) + ]), + ]); + $this->assertCount(0, $docs); } } diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index a05a2b915..2ddad7e49 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -43,7 +43,7 @@ public function testCreateDocument(): Document $this->assertEquals(true, $database->createAttribute('documents', 'id', Database::VAR_ID, 0, false, null)); $sequence = '1000000'; - if ($database->getAdapter()->getIdAttributeType() == Database::VAR_UUID7) { + if ($database->getAdapter()->getIdAttributeType() == Database::VAR_UUID7) { $sequence = '01890dd5-7331-7f3a-9c1b-123456789abc' ; } @@ -102,7 +102,7 @@ public function testCreateDocument(): Document $sequence = '56000'; - if ($database->getAdapter()->getIdAttributeType() == Database::VAR_UUID7) { + if ($database->getAdapter()->getIdAttributeType() == Database::VAR_UUID7) { $sequence = '01890dd5-7331-7f3a-9c1b-123456789def' ; } @@ -1993,9 +1993,6 @@ public function testFindByInternalID(array $data): void ]); $this->assertEquals(1, count($documents)); - - $empty = new Document(); - $this->assertEquals('', $empty->getSequence()); } public function testFindOrderBy(): void @@ -2669,6 +2666,110 @@ public function testFindOrderByUpdateDateAndCursor(): void $this->assertEquals($documentsTest[1]['$id'], $documents[0]['$id']); } + public function testFindCreatedBefore(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + /** + * Test Query::createdBefore wrapper + */ + $futureDate = '2050-01-01T00:00:00.000Z'; + $pastDate = '1900-01-01T00:00:00.000Z'; + + $documents = $database->find('movies', [ + Query::createdBefore($futureDate), + Query::limit(1) + ]); + + $this->assertGreaterThan(0, count($documents)); + + $documents = $database->find('movies', [ + Query::createdBefore($pastDate), + Query::limit(1) + ]); + + $this->assertEquals(0, count($documents)); + } + + public function testFindCreatedAfter(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + /** + * Test Query::createdAfter wrapper + */ + $futureDate = '2050-01-01T00:00:00.000Z'; + $pastDate = '1900-01-01T00:00:00.000Z'; + + $documents = $database->find('movies', [ + Query::createdAfter($pastDate), + Query::limit(1) + ]); + + $this->assertGreaterThan(0, count($documents)); + + $documents = $database->find('movies', [ + Query::createdAfter($futureDate), + Query::limit(1) + ]); + + $this->assertEquals(0, count($documents)); + } + + public function testFindUpdatedBefore(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + /** + * Test Query::updatedBefore wrapper + */ + $futureDate = '2050-01-01T00:00:00.000Z'; + $pastDate = '1900-01-01T00:00:00.000Z'; + + $documents = $database->find('movies', [ + Query::updatedBefore($futureDate), + Query::limit(1) + ]); + + $this->assertGreaterThan(0, count($documents)); + + $documents = $database->find('movies', [ + Query::updatedBefore($pastDate), + Query::limit(1) + ]); + + $this->assertEquals(0, count($documents)); + } + + public function testFindUpdatedAfter(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + /** + * Test Query::updatedAfter wrapper + */ + $futureDate = '2050-01-01T00:00:00.000Z'; + $pastDate = '1900-01-01T00:00:00.000Z'; + + $documents = $database->find('movies', [ + Query::updatedAfter($pastDate), + Query::limit(1) + ]); + + $this->assertGreaterThan(0, count($documents)); + + $documents = $database->find('movies', [ + Query::updatedAfter($futureDate), + Query::limit(1) + ]); + + $this->assertEquals(0, count($documents)); + } + public function testFindLimit(): void { /** @var Database $database */ @@ -3055,6 +3156,316 @@ public function testFindEndsWith(): void $this->assertEquals(1, count($documents)); } + public function testFindNotContains(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForQueryContains()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Test notContains with array attributes - should return documents that don't contain specified genres + $documents = $database->find('movies', [ + Query::notContains('genres', ['comics']) + ]); + + $this->assertEquals(4, count($documents)); // All movies except the 2 with 'comics' genre + + // Test notContains with multiple values (AND logic - exclude documents containing ANY of these) + $documents = $database->find('movies', [ + Query::notContains('genres', ['comics', 'kids']), + ]); + + $this->assertEquals(2, count($documents)); // Movies that have neither 'comics' nor 'kids' + + // Test notContains with non-existent genre - should return all documents + $documents = $database->find('movies', [ + Query::notContains('genres', ['non-existent']), + ]); + + $this->assertEquals(6, count($documents)); + + // Test notContains with string attribute (substring search) + $documents = $database->find('movies', [ + Query::notContains('name', ['Captain']) + ]); + $this->assertEquals(4, count($documents)); // All movies except those containing 'Captain' + + // Test notContains combined with other queries (AND logic) + $documents = $database->find('movies', [ + Query::notContains('genres', ['comics']), + Query::greaterThan('year', 2000) + ]); + $this->assertLessThanOrEqual(4, count($documents)); // Subset of movies without 'comics' and after 2000 + + // Test notContains with case sensitivity + $documents = $database->find('movies', [ + Query::notContains('genres', ['COMICS']) // Different case + ]); + $this->assertEquals(6, count($documents)); // All movies since case doesn't match + + // Test error handling for invalid attribute type + try { + $database->find('movies', [ + Query::notContains('price', [10.5]), + ]); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertEquals('Invalid query: Cannot query notContains on attribute "price" because it is not an array or string.', $e->getMessage()); + $this->assertTrue($e instanceof DatabaseException); + } + } + + // public function testFindNotSearch(): void + // { + // /** @var Database $database */ + // $database = static::getDatabase(); + + // // Only test if fulltext search is supported + // if ($this->getDatabase()->getAdapter()->getSupportForFulltextIndex()) { + // // Ensure fulltext index exists (may already exist from previous tests) + // try { + // $database->createIndex('movies', 'name', Database::INDEX_FULLTEXT, ['name']); + // } catch (Throwable $e) { + // // Index may already exist, ignore duplicate error + // if (!str_contains($e->getMessage(), 'already exists')) { + // throw $e; + // } + // } + + // // Test notSearch - should return documents that don't match the search term + // $documents = $database->find('movies', [ + // Query::notSearch('name', 'captain'), + // ]); + + // $this->assertEquals(4, count($documents)); // All movies except the 2 with 'captain' in name + + // // Test notSearch with term that doesn't exist - should return all documents + // $documents = $database->find('movies', [ + // Query::notSearch('name', 'nonexistent'), + // ]); + + // $this->assertEquals(6, count($documents)); + + // // Test notSearch with partial term + // if ($this->getDatabase()->getAdapter()->getSupportForFulltextWildCardIndex()) { + // $documents = $database->find('movies', [ + // Query::notSearch('name', 'cap'), + // ]); + + // $this->assertEquals(4, count($documents)); // All movies except those matching 'cap' + // } + + // // Test notSearch with empty string - should return all documents + // $documents = $database->find('movies', [ + // Query::notSearch('name', ''), + // ]); + // $this->assertEquals(6, count($documents)); // All movies since empty search matches nothing + + // // Test notSearch combined with other filters + // $documents = $database->find('movies', [ + // Query::notSearch('name', 'captain'), + // Query::lessThan('year', 2010) + // ]); + // $this->assertLessThanOrEqual(4, count($documents)); // Subset of non-captain movies before 2010 + + // // Test notSearch with special characters + // $documents = $database->find('movies', [ + // Query::notSearch('name', '@#$%'), + // ]); + // $this->assertEquals(6, count($documents)); // All movies since special chars don't match + // } + + // $this->assertEquals(true, true); // Test must do an assertion + // } + + // public function testFindNotStartsWith(): void + // { + // /** @var Database $database */ + // $database = static::getDatabase(); + + // // Test notStartsWith - should return documents that don't start with 'Work' + // $documents = $database->find('movies', [ + // Query::notStartsWith('name', 'Work'), + // ]); + + // $this->assertEquals(4, count($documents)); // All movies except the 2 starting with 'Work' + + // // Test notStartsWith with non-existent prefix - should return all documents + // $documents = $database->find('movies', [ + // Query::notStartsWith('name', 'NonExistent'), + // ]); + + // $this->assertEquals(6, count($documents)); + + // // Test notStartsWith with wildcard characters (should treat them literally) + // if ($this->getDatabase()->getAdapter() instanceof SQL) { + // $documents = $database->find('movies', [ + // Query::notStartsWith('name', '%ork'), + // ]); + // } else { + // $documents = $database->find('movies', [ + // Query::notStartsWith('name', '.*ork'), + // ]); + // } + + // $this->assertEquals(6, count($documents)); // Should return all since no movie starts with these patterns + + // // Test notStartsWith with empty string - should return no documents (all strings start with empty) + // $documents = $database->find('movies', [ + // Query::notStartsWith('name', ''), + // ]); + // $this->assertEquals(0, count($documents)); // No documents since all strings start with empty string + + // // Test notStartsWith with single character + // $documents = $database->find('movies', [ + // Query::notStartsWith('name', 'C'), + // ]); + // $this->assertGreaterThanOrEqual(4, count($documents)); // Movies not starting with 'C' + + // // Test notStartsWith with case sensitivity (may be case-insensitive depending on DB) + // $documents = $database->find('movies', [ + // Query::notStartsWith('name', 'work'), // lowercase vs 'Work' + // ]); + // $this->assertGreaterThanOrEqual(4, count($documents)); // May match case-insensitively + + // // Test notStartsWith combined with other queries + // $documents = $database->find('movies', [ + // Query::notStartsWith('name', 'Work'), + // Query::equal('year', [2006]) + // ]); + // $this->assertLessThanOrEqual(4, count($documents)); // Subset of non-Work movies from 2006 + // } + + // public function testFindNotEndsWith(): void + // { + // /** @var Database $database */ + // $database = static::getDatabase(); + + // // Test notEndsWith - should return documents that don't end with 'Marvel' + // $documents = $database->find('movies', [ + // Query::notEndsWith('name', 'Marvel'), + // ]); + + // $this->assertEquals(5, count($documents)); // All movies except the 1 ending with 'Marvel' + + // // Test notEndsWith with non-existent suffix - should return all documents + // $documents = $database->find('movies', [ + // Query::notEndsWith('name', 'NonExistent'), + // ]); + + // $this->assertEquals(6, count($documents)); + + // // Test notEndsWith with partial suffix + // $documents = $database->find('movies', [ + // Query::notEndsWith('name', 'vel'), + // ]); + + // $this->assertEquals(5, count($documents)); // All movies except the 1 ending with 'vel' (from 'Marvel') + + // // Test notEndsWith with empty string - should return no documents (all strings end with empty) + // $documents = $database->find('movies', [ + // Query::notEndsWith('name', ''), + // ]); + // $this->assertEquals(0, count($documents)); // No documents since all strings end with empty string + + // // Test notEndsWith with single character + // $documents = $database->find('movies', [ + // Query::notEndsWith('name', 'l'), + // ]); + // $this->assertGreaterThanOrEqual(5, count($documents)); // Movies not ending with 'l' + + // // Test notEndsWith with case sensitivity (may be case-insensitive depending on DB) + // $documents = $database->find('movies', [ + // Query::notEndsWith('name', 'marvel'), // lowercase vs 'Marvel' + // ]); + // $this->assertGreaterThanOrEqual(5, count($documents)); // May match case-insensitively + + // // Test notEndsWith combined with limit + // $documents = $database->find('movies', [ + // Query::notEndsWith('name', 'Marvel'), + // Query::limit(3) + // ]); + // $this->assertEquals(3, count($documents)); // Limited to 3 results + // $this->assertLessThanOrEqual(5, count($documents)); // But still excluding Marvel movies + // } + + public function testFindNotBetween(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Test notBetween with price range - should return documents outside the range + $documents = $database->find('movies', [ + Query::notBetween('price', 25.94, 25.99), + ]); + $this->assertEquals(4, count($documents)); // All movies except the 2 in the price range + + // Test notBetween with range that includes no documents - should return all documents + $documents = $database->find('movies', [ + Query::notBetween('price', 30, 35), + ]); + $this->assertEquals(6, count($documents)); + + // Test notBetween with date range + $documents = $database->find('movies', [ + Query::notBetween('$createdAt', '1975-12-06', '2050-12-06'), + ]); + $this->assertEquals(0, count($documents)); // No movies outside this wide date range + + // Test notBetween with narrower date range + $documents = $database->find('movies', [ + Query::notBetween('$createdAt', '2000-01-01', '2001-01-01'), + ]); + $this->assertEquals(6, count($documents)); // All movies should be outside this narrow range + + // Test notBetween with updated date range + $documents = $database->find('movies', [ + Query::notBetween('$updatedAt', '2000-01-01T00:00:00.000+00:00', '2001-01-01T00:00:00.000+00:00'), + ]); + $this->assertEquals(6, count($documents)); // All movies should be outside this narrow range + + // Test notBetween with year range (integer values) + $documents = $database->find('movies', [ + Query::notBetween('year', 2005, 2007), + ]); + $this->assertLessThanOrEqual(6, count($documents)); // Movies outside 2005-2007 range + + // Test notBetween with reversed range (start > end) - should still work + $documents = $database->find('movies', [ + Query::notBetween('price', 25.99, 25.94), // Note: reversed order + ]); + $this->assertGreaterThanOrEqual(4, count($documents)); // Should handle reversed range gracefully + + // Test notBetween with same start and end values + $documents = $database->find('movies', [ + Query::notBetween('year', 2006, 2006), + ]); + $this->assertGreaterThanOrEqual(5, count($documents)); // All movies except those from exactly 2006 + + // Test notBetween combined with other filters + $documents = $database->find('movies', [ + Query::notBetween('price', 25.94, 25.99), + Query::orderDesc('year'), + Query::limit(2) + ]); + $this->assertEquals(2, count($documents)); // Limited results, ordered, excluding price range + + // Test notBetween with extreme ranges + $documents = $database->find('movies', [ + Query::notBetween('year', -1000, 1000), // Very wide range + ]); + $this->assertLessThanOrEqual(6, count($documents)); // Movies outside this range + + // Test notBetween with float precision + $documents = $database->find('movies', [ + Query::notBetween('price', 25.945, 25.955), // Very narrow range + ]); + $this->assertGreaterThanOrEqual(4, count($documents)); // Most movies should be outside this narrow range + } + public function testFindSelect(): void { /** @var Database $database */ @@ -5407,4 +5818,119 @@ public function testUpsertDateOperations(): void $database->setPreserveDates(false); $database->deleteCollection($collection); } + + public function testUpdateDocumentsCount(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForUpserts()) { + return; + } + + $collectionName = "update_count"; + $database->createCollection($collectionName); + + $database->createAttribute($collectionName, 'key', Database::VAR_STRING, 60, false); + $database->createAttribute($collectionName, 'value', Database::VAR_STRING, 60, false); + + $permissions = [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())]; + + $docs = [ + new Document([ + '$id' => 'bulk_upsert1', + '$permissions' => $permissions, + 'key' => 'bulk_upsert1_initial', + ]), + new Document([ + '$id' => 'bulk_upsert2', + '$permissions' => $permissions, + 'key' => 'bulk_upsert2_initial', + ]), + new Document([ + '$id' => 'bulk_upsert3', + '$permissions' => $permissions, + 'key' => 'bulk_upsert3_initial', + ]), + new Document([ + '$id' => 'bulk_upsert4', + '$permissions' => $permissions, + 'key' => 'bulk_upsert4_initial' + ]) + ]; + $upsertUpdateResults = []; + $count = $database->createOrUpdateDocuments($collectionName, $docs, onNext: function ($doc) use (&$upsertUpdateResults) { + $upsertUpdateResults[] = $doc; + }); + $this->assertCount(4, $upsertUpdateResults); + $this->assertEquals(4, $count); + + $updates = new Document(['value' => 'test']); + $newDocs = []; + $count = $database->updateDocuments($collectionName, $updates, onNext:function ($doc) use (&$newDocs) { + $newDocs[] = $doc; + }); + + $this->assertCount(4, $newDocs); + $this->assertEquals(4, $count); + + $database->deleteCollection($collectionName); + } + + public function testCreateUpdateDocumentsMismatch(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // with different set of attributes + $colName = "docs_with_diff"; + $database->createCollection($colName); + $database->createAttribute($colName, 'key', Database::VAR_STRING, 50, true); + $database->createAttribute($colName, 'value', Database::VAR_STRING, 50, false, 'value'); + $permissions = [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())]; + $docs = [ + new Document([ + '$id' => 'doc1', + 'key' => 'doc1', + ]), + new Document([ + '$id' => 'doc2', + 'key' => 'doc2', + 'value' => 'test', + ]), + new Document([ + '$id' => 'doc3', + '$permissions' => $permissions, + 'key' => 'doc3' + ]), + ]; + $this->assertEquals(3, $database->createDocuments($colName, $docs)); + // we should get only one document as read permission provided to the last document only + $addedDocs = $database->find($colName); + $this->assertCount(1, $addedDocs); + $doc = $addedDocs[0]; + $this->assertEquals('doc3', $doc->getId()); + $this->assertNotEmpty($doc->getPermissions()); + $this->assertCount(3, $doc->getPermissions()); + + $database->createDocument($colName, new Document([ + '$id' => 'doc4', + '$permissions' => $permissions, + 'key' => 'doc4' + ])); + + $this->assertEquals(2, $database->updateDocuments($colName, new Document(['key' => 'new doc']))); + $doc = $database->getDocument($colName, 'doc4'); + $this->assertEquals('doc4', $doc->getId()); + $this->assertEquals('value', $doc->getAttribute('value')); + + $addedDocs = $database->find($colName); + $this->assertCount(2, $addedDocs); + foreach ($addedDocs as $doc) { + $this->assertNotEmpty($doc->getPermissions()); + $this->assertCount(3, $doc->getPermissions()); + $this->assertEquals('value', $doc->getAttribute('value')); + } + $database->deleteCollection($colName); + } } diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index edc2f3e0b..9e6077b35 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -95,7 +95,7 @@ public function testZoo(): void twoWayKey: 'animals' ); - $zoo1 = $database->createDocument('zoo', new Document([ + $zoo = $database->createDocument('zoo', new Document([ '$id' => 'zoo1', '$permissions' => [ Permission::read(Role::any()), @@ -104,7 +104,10 @@ public function testZoo(): void 'name' => 'Bronx Zoo' ])); - $animal1 = $database->createDocument('__animals', new Document([ + $this->assertEquals('zoo1', $zoo->getId()); + $this->assertArrayHasKey('animals', $zoo); + + $iguana = $database->createDocument('__animals', new Document([ '$id' => 'iguana', '$permissions' => [ Permission::read(Role::any()), @@ -121,10 +124,10 @@ public function testZoo(): void 'enum' => 'maybe', 'ip' => '127.0.0.1', 'url' => 'https://appwrite.io/', - 'zoo' => $zoo1->getId(), + 'zoo' => $zoo->getId(), ])); - $animal2 = $database->createDocument('__animals', new Document([ + $tiger = $database->createDocument('__animals', new Document([ '$id' => 'tiger', '$permissions' => [ Permission::read(Role::any()), @@ -141,10 +144,10 @@ public function testZoo(): void 'enum' => 'yes', 'ip' => '255.0.0.1', 'url' => 'https://appwrite.io/', - 'zoo' => $zoo1->getId(), + 'zoo' => $zoo->getId(), ])); - $animal3 = $database->createDocument('__animals', new Document([ + $lama = $database->createDocument('__animals', new Document([ '$id' => 'lama', '$permissions' => [ Permission::read(Role::any()), @@ -183,7 +186,7 @@ public function testZoo(): void 'animals' => ['tiger'], ])); - $president1 = $database->createDocument('presidents', new Document([ + $trump = $database->createDocument('presidents', new Document([ '$id' => 'trump', '$permissions' => [ Permission::read(Role::any()), @@ -197,7 +200,7 @@ public function testZoo(): void ], ])); - $president2 = $database->createDocument('presidents', new Document([ + $bush = $database->createDocument('presidents', new Document([ '$id' => 'bush', '$permissions' => [ Permission::read(Role::any()), @@ -208,7 +211,7 @@ public function testZoo(): void 'animal' => 'iguana', ])); - $president3 = $database->createDocument('presidents', new Document([ + $biden = $database->createDocument('presidents', new Document([ '$id' => 'biden', '$permissions' => [ Permission::read(Role::any()), @@ -219,23 +222,183 @@ public function testZoo(): void 'animal' => 'tiger', ])); - var_dump('=== start === === start === === start === === start === === start === === start === === start === === start === === start ==='); + /** + * Check Zoo data + */ + $zoo = $database->getDocument('zoo', 'zoo1'); + + $this->assertEquals('zoo1', $zoo->getId()); + $this->assertEquals('Bronx Zoo', $zoo->getAttribute('name')); + $this->assertArrayHasKey('animals', $zoo); + $this->assertEquals(2, count($zoo->getAttribute('animals'))); + $this->assertArrayHasKey('president', $zoo->getAttribute('animals')[0]); + $this->assertArrayHasKey('veterinarian', $zoo->getAttribute('animals')[0]); + + $zoo = $database->findOne('zoo'); + + $this->assertEquals('zoo1', $zoo->getId()); + $this->assertEquals('Bronx Zoo', $zoo->getAttribute('name')); + $this->assertArrayHasKey('animals', $zoo); + $this->assertEquals(2, count($zoo->getAttribute('animals'))); + $this->assertArrayHasKey('president', $zoo->getAttribute('animals')[0]); + $this->assertArrayHasKey('veterinarian', $zoo->getAttribute('animals')[0]); + + /** + * Check Veterinarians data + */ + $veterinarian = $database->getDocument('veterinarians', 'dr.pol'); + + $this->assertEquals('dr.pol', $veterinarian->getId()); + $this->assertArrayHasKey('presidents', $veterinarian); + $this->assertEquals(1, count($veterinarian->getAttribute('presidents'))); + $this->assertArrayHasKey('animal', $veterinarian->getAttribute('presidents')[0]); + $this->assertArrayHasKey('animals', $veterinarian); + $this->assertEquals(1, count($veterinarian->getAttribute('animals'))); + $this->assertArrayHasKey('zoo', $veterinarian->getAttribute('animals')[0]); + $this->assertArrayHasKey('president', $veterinarian->getAttribute('animals')[0]); + + $veterinarian = $database->findOne('veterinarians', [ + Query::equal('$id', ['dr.pol']) + ]); + + $this->assertEquals('dr.pol', $veterinarian->getId()); + $this->assertArrayHasKey('presidents', $veterinarian); + $this->assertEquals(1, count($veterinarian->getAttribute('presidents'))); + $this->assertArrayHasKey('animal', $veterinarian->getAttribute('presidents')[0]); + $this->assertArrayHasKey('animals', $veterinarian); + $this->assertEquals(1, count($veterinarian->getAttribute('animals'))); + $this->assertArrayHasKey('zoo', $veterinarian->getAttribute('animals')[0]); + $this->assertArrayHasKey('president', $veterinarian->getAttribute('animals')[0]); + + /** + * Check Animals data + */ + $animal = $database->getDocument('__animals', 'iguana'); + + $this->assertEquals('iguana', $animal->getId()); + $this->assertArrayHasKey('zoo', $animal); + $this->assertEquals('Bronx Zoo', $animal['zoo']->getAttribute('name')); + $this->assertArrayHasKey('veterinarian', $animal); + $this->assertEquals('dr.pol', $animal['veterinarian']->getId()); + $this->assertArrayHasKey('presidents', $animal['veterinarian']); + $this->assertArrayHasKey('president', $animal); + $this->assertEquals('bush', $animal['president']->getId()); + + $animal = $database->findOne('__animals', [ + Query::equal('$id', ['tiger']) + ]); + + $this->assertEquals('tiger', $animal->getId()); + $this->assertArrayHasKey('zoo', $animal); + $this->assertEquals('Bronx Zoo', $animal['zoo']->getAttribute('name')); + $this->assertArrayHasKey('veterinarian', $animal); + $this->assertEquals('dr.seuss', $animal['veterinarian']->getId()); + $this->assertArrayHasKey('presidents', $animal['veterinarian']); + $this->assertArrayHasKey('president', $animal); + $this->assertEquals('biden', $animal['president']->getId()); + + /** + * Check President data + */ + $president = $database->getDocument('presidents', 'trump'); + + $this->assertEquals('trump', $president->getId()); + $this->assertArrayHasKey('animal', $president); + $this->assertArrayHasKey('votes', $president); + $this->assertEquals(2, count($president['votes'])); + + /** + * Check President data + */ + $president = $database->findOne('presidents', [ + Query::equal('$id', ['bush']) + ]); + + $this->assertEquals('bush', $president->getId()); + $this->assertArrayHasKey('animal', $president); + $this->assertArrayHasKey('votes', $president); + $this->assertEquals(0, count($president['votes'])); - $docs = $database->find( + $president = $database->findOne('presidents', [ + Query::select([ + '*', + 'votes.*', + ]), + Query::equal('$id', ['trump']) + ]); + + $this->assertEquals('trump', $president->getId()); + $this->assertArrayHasKey('votes', $president); + $this->assertEquals(2, count($president['votes'])); + $this->assertArrayNotHasKey('animals', $president['votes'][0]); // Not exist + + $president = $database->findOne('presidents', [ + Query::select([ + '*', + 'votes.*', + 'votes.animals.*', + ]), + Query::equal('$id', ['trump']) + ]); + + $this->assertEquals('trump', $president->getId()); + $this->assertArrayHasKey('votes', $president); + $this->assertEquals(2, count($president['votes'])); + $this->assertArrayHasKey('animals', $president['votes'][0]); // Exist + + /** + * Check Selects queries + */ + $veterinarian = $database->findOne('veterinarians', [ + Query::select(['*']), // No resolving + Query::equal('$id', ['dr.pol']), + ]); + + $this->assertEquals('dr.pol', $veterinarian->getId()); + $this->assertArrayNotHasKey('presidents', $veterinarian); + $this->assertArrayNotHasKey('animals', $veterinarian); + + $veterinarian = $database->findOne( + 'veterinarians', + [ + Query::select([ + 'animals.*', + ]) + ] + ); + + $this->assertEquals('dr.pol', $veterinarian->getId()); + $this->assertArrayHasKey('animals', $veterinarian); + $this->assertArrayNotHasKey('presidents', $veterinarian); + + $animal = $veterinarian['animals'][0]; + + $this->assertArrayHasKey('president', $animal); + $this->assertEquals('bush', $animal->getAttribute('president')); // Check president is a value + $this->assertArrayHasKey('zoo', $animal); + $this->assertEquals('zoo1', $animal->getAttribute('zoo')); // Check zoo is a value + + $veterinarian = $database->findOne( 'veterinarians', [ Query::select([ - '*', 'animals.*', 'animals.zoo.*', - //'animals.president.*', + 'animals.president.*', ]) ] ); - var_dump($docs); + $this->assertEquals('dr.pol', $veterinarian->getId()); + $this->assertArrayHasKey('animals', $veterinarian); + $this->assertArrayNotHasKey('presidents', $veterinarian); + + $animal = $veterinarian['animals'][0]; - //$this->assertEquals('shmuel', 'fogel'); + $this->assertArrayHasKey('president', $animal); + $this->assertEquals('Bush', $animal->getAttribute('president')->getAttribute('last_name')); // Check president is an object + $this->assertArrayHasKey('zoo', $animal); + $this->assertEquals('Bronx Zoo', $animal->getAttribute('zoo')->getAttribute('name')); // Check zoo is an object } public function testDeleteRelatedCollection(): void diff --git a/tests/e2e/Adapter/Scopes/SpatialTests.php b/tests/e2e/Adapter/Scopes/SpatialTests.php new file mode 100644 index 000000000..8f6c2898b --- /dev/null +++ b/tests/e2e/Adapter/Scopes/SpatialTests.php @@ -0,0 +1,1776 @@ +getAdapter()->getSupportForSpatialAttributes()) { + $this->markTestSkipped('Adapter does not support spatial attributes'); + }; + $attributes = [ + new Document([ + '$id' => ID::custom('attribute1'), + 'type' => Database::VAR_STRING, + 'size' => 256, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => ID::custom('attribute2'), + 'type' => Database::VAR_POINT, + 'size' => 0, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]) + ]; + + $indexes = [ + new Document([ + '$id' => ID::custom('index1'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['attribute1'], + 'lengths' => [256], + 'orders' => [], + ]), + new Document([ + '$id' => ID::custom('index2'), + 'type' => Database::INDEX_SPATIAL, + 'attributes' => ['attribute2'], + 'lengths' => [], + 'orders' => [], + ]), + ]; + + $col = $database->createCollection($collectionName, $attributes, $indexes); + + $this->assertIsArray($col->getAttribute('attributes')); + $this->assertCount(2, $col->getAttribute('attributes')); + + $this->assertIsArray($col->getAttribute('indexes')); + $this->assertCount(2, $col->getAttribute('indexes')); + + $col = $database->getCollection($collectionName); + $this->assertIsArray($col->getAttribute('attributes')); + $this->assertCount(2, $col->getAttribute('attributes')); + + $this->assertIsArray($col->getAttribute('indexes')); + $this->assertCount(2, $col->getAttribute('indexes')); + + $database->createAttribute($collectionName, 'attribute3', Database::VAR_POINT, 0, true); + $database->createIndex($collectionName, ID::custom("index3"), Database::INDEX_SPATIAL, ['attribute3']); + + $col = $database->getCollection($collectionName); + $this->assertIsArray($col->getAttribute('attributes')); + $this->assertCount(3, $col->getAttribute('attributes')); + + $this->assertIsArray($col->getAttribute('indexes')); + $this->assertCount(3, $col->getAttribute('indexes')); + + $database->deleteCollection($collectionName); + } + + public function testSpatialTypeDocuments(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->markTestSkipped('Adapter does not support spatial attributes'); + } + + $collectionName = 'test_spatial_doc_'; + try { + + // Create collection first + $database->createCollection($collectionName); + + // Create spatial attributes using createAttribute method + $this->assertEquals(true, $database->createAttribute($collectionName, 'pointAttr', Database::VAR_POINT, 0, $database->getAdapter()->getSupportForSpatialIndexNull() ? false : true)); + $this->assertEquals(true, $database->createAttribute($collectionName, 'lineAttr', Database::VAR_LINESTRING, 0, $database->getAdapter()->getSupportForSpatialIndexNull() ? false : true)); + $this->assertEquals(true, $database->createAttribute($collectionName, 'polyAttr', Database::VAR_POLYGON, 0, $database->getAdapter()->getSupportForSpatialIndexNull() ? false : true)); + + // Create spatial indexes + $this->assertEquals(true, $database->createIndex($collectionName, 'point_spatial', Database::INDEX_SPATIAL, ['pointAttr'])); + $this->assertEquals(true, $database->createIndex($collectionName, 'line_spatial', Database::INDEX_SPATIAL, ['lineAttr'])); + $this->assertEquals(true, $database->createIndex($collectionName, 'poly_spatial', Database::INDEX_SPATIAL, ['polyAttr'])); + + // Create test document + $doc1 = new Document([ + '$id' => 'doc1', + 'pointAttr' => [5.0, 5.0], + 'lineAttr' => [[1.0, 2.0], [3.0, 4.0]], + 'polyAttr' => [[[0.0, 0.0], [0.0, 10.0], [10.0, 10.0], [0.0, 0.0]]], + '$permissions' => [Permission::update(Role::any()), Permission::read(Role::any())] + ]); + $createdDoc = $database->createDocument($collectionName, $doc1); + $this->assertInstanceOf(Document::class, $createdDoc); + $this->assertEquals([5.0, 5.0], $createdDoc->getAttribute('pointAttr')); + + // Update spatial data + $doc1->setAttribute('pointAttr', [6.0, 6.0]); + $updatedDoc = $database->updateDocument($collectionName, 'doc1', $doc1); + $this->assertEquals([6.0, 6.0], $updatedDoc->getAttribute('pointAttr')); + + // Test spatial queries with appropriate operations for each geometry type + // Point attribute tests - use operations valid for points + $pointQueries = [ + 'equals' => Query::equal('pointAttr', [[6.0, 6.0]]), + 'notEquals' => Query::notEqual('pointAttr', [[1.0, 1.0]]), + 'distanceEqual' => Query::distanceEqual('pointAttr', [5.0, 5.0], 1.4142135623730951), + 'distanceNotEqual' => Query::distanceNotEqual('pointAttr', [1.0, 1.0], 0.0), + 'intersects' => Query::intersects('pointAttr', [[6.0, 6.0]]), + 'notIntersects' => Query::notIntersects('pointAttr', [[1.0, 1.0]]) + ]; + + foreach ($pointQueries as $queryType => $query) { + $result = $database->find($collectionName, [$query], Database::PERMISSION_READ); + $this->assertNotEmpty($result, sprintf('Failed spatial query: %s on pointAttr', $queryType)); + $this->assertEquals('doc1', $result[0]->getId(), sprintf('Incorrect document returned for %s on pointAttr', $queryType)); + } + + // LineString attribute tests - use operations valid for linestrings + $lineQueries = [ + 'contains' => Query::contains('lineAttr', [[1.0, 2.0]]), // Point on the line (endpoint) + 'notContains' => Query::notContains('lineAttr', [[5.0, 6.0]]), // Point not on the line + 'equals' => query::equal('lineAttr', [[[1.0, 2.0], [3.0, 4.0]]]), // Exact same linestring + 'notEquals' => query::notEqual('lineAttr', [[[5.0, 6.0], [7.0, 8.0]]]), // Different linestring + 'intersects' => Query::intersects('lineAttr', [[1.0, 2.0]]), // Point on the line should intersect + 'notIntersects' => Query::notIntersects('lineAttr', [[5.0, 6.0]]) // Point not on the line should not intersect + ]; + + foreach ($lineQueries as $queryType => $query) { + if (!$database->getAdapter()->getSupportForBoundaryInclusiveContains() && in_array($queryType, ['contains','notContains'])) { + continue; + } + $result = $database->find($collectionName, [$query], Database::PERMISSION_READ); + $this->assertNotEmpty($result, sprintf('Failed spatial query: %s on polyAttr', $queryType)); + $this->assertEquals('doc1', $result[0]->getId(), sprintf('Incorrect document returned for %s on polyAttr', $queryType)); + } + + // Distance queries for linestring attribute + $lineDistanceQueries = [ + 'distanceEqual' => Query::distanceEqual('lineAttr', [[1.0, 2.0], [3.0, 4.0]], 0.0), + 'distanceNotEqual' => Query::distanceNotEqual('lineAttr', [[5.0, 6.0], [7.0, 8.0]], 0.0), + 'distanceLessThan' => Query::distanceLessThan('lineAttr', [[1.0, 2.0], [3.0, 4.0]], 0.1), + 'distanceGreaterThan' => Query::distanceGreaterThan('lineAttr', [[5.0, 6.0], [7.0, 8.0]], 0.1) + ]; + + foreach ($lineDistanceQueries as $queryType => $query) { + $result = $database->find($collectionName, [$query], Database::PERMISSION_READ); + $this->assertNotEmpty($result, sprintf('Failed distance query: %s on lineAttr', $queryType)); + $this->assertEquals('doc1', $result[0]->getId(), sprintf('Incorrect document for distance %s on lineAttr', $queryType)); + } + + // Polygon attribute tests - use operations valid for polygons + $polyQueries = [ + 'contains' => Query::contains('polyAttr', [[5.0, 5.0]]), // Point inside polygon + 'notContains' => Query::notContains('polyAttr', [[15.0, 15.0]]), // Point outside polygon + 'intersects' => Query::intersects('polyAttr', [[5.0, 5.0]]), // Point inside polygon should intersect + 'notIntersects' => Query::notIntersects('polyAttr', [[15.0, 15.0]]), // Point outside polygon should not intersect + 'equals' => query::equal('polyAttr', [[[[0.0, 0.0], [0.0, 10.0], [10.0, 10.0], [0.0, 0.0]]]]), // Exact same polygon + 'notEquals' => query::notEqual('polyAttr', [[[[20.0, 20.0], [20.0, 30.0], [30.0, 30.0], [20.0, 20.0]]]]), // Different polygon + 'overlaps' => Query::overlaps('polyAttr', [[[[5.0, 5.0], [5.0, 15.0], [15.0, 15.0], [15.0, 5.0], [5.0, 5.0]]]]), // Overlapping polygon + 'notOverlaps' => Query::notOverlaps('polyAttr', [[[[20.0, 20.0], [20.0, 30.0], [30.0, 30.0], [30.0, 20.0], [20.0, 20.0]]]]) // Non-overlapping polygon + ]; + + foreach ($polyQueries as $queryType => $query) { + if (!$database->getAdapter()->getSupportForBoundaryInclusiveContains() && in_array($queryType, ['contains','notContains'])) { + continue; + } + $result = $database->find($collectionName, [$query], Database::PERMISSION_READ); + $this->assertNotEmpty($result, sprintf('Failed spatial query: %s on polyAttr', $queryType)); + $this->assertEquals('doc1', $result[0]->getId(), sprintf('Incorrect document returned for %s on polyAttr', $queryType)); + } + + // Distance queries for polygon attribute + $polyDistanceQueries = [ + 'distanceEqual' => Query::distanceEqual('polyAttr', [[[0.0, 0.0], [0.0, 10.0], [10.0, 10.0], [0.0, 0.0]]], 0.0), + 'distanceNotEqual' => Query::distanceNotEqual('polyAttr', [[[20.0, 20.0], [20.0, 30.0], [30.0, 30.0], [20.0, 20.0]]], 0.0), + 'distanceLessThan' => Query::distanceLessThan('polyAttr', [[[0.0, 0.0], [0.0, 10.0], [10.0, 10.0], [0.0, 0.0]]], 0.1), + 'distanceGreaterThan' => Query::distanceGreaterThan('polyAttr', [[[20.0, 20.0], [20.0, 30.0], [30.0, 30.0], [20.0, 20.0]]], 0.1) + ]; + + foreach ($polyDistanceQueries as $queryType => $query) { + $result = $database->find($collectionName, [$query], Database::PERMISSION_READ); + $this->assertNotEmpty($result, sprintf('Failed distance query: %s on polyAttr', $queryType)); + $this->assertEquals('doc1', $result[0]->getId(), sprintf('Incorrect document for distance %s on polyAttr', $queryType)); + } + } finally { + // $database->deleteCollection($collectionName); + } + } + + public function testSpatialRelationshipOneToOne(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('location'); + $database->createCollection('building'); + + $database->createAttribute('location', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('location', 'coordinates', Database::VAR_POINT, 0, true); + $database->createAttribute('building', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('building', 'area', Database::VAR_STRING, 255, true); + + // Create spatial indexes + $database->createIndex('location', 'coordinates_spatial', Database::INDEX_SPATIAL, ['coordinates']); + + // Create building document first + $building1 = $database->createDocument('building', new Document([ + '$id' => 'building1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Empire State Building', + 'area' => 'Manhattan', + ])); + + $database->createRelationship( + collection: 'location', + relatedCollection: 'building', + type: Database::RELATION_ONE_TO_ONE, + id: 'building', + twoWay: false + ); + + // Create location with spatial data and relationship + $location1 = $database->createDocument('location', new Document([ + '$id' => 'location1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Downtown', + 'coordinates' => [40.7128, -74.0060], // New York coordinates + 'building' => 'building1', + ])); + + $this->assertInstanceOf(Document::class, $location1); + $this->assertEquals([40.7128, -74.0060], $location1->getAttribute('coordinates')); + + // Check if building attribute is populated (could be ID string or Document object) + $buildingAttr = $location1->getAttribute('building'); + if (is_string($buildingAttr)) { + $this->assertEquals('building1', $buildingAttr); + } else { + $this->assertInstanceOf(Document::class, $buildingAttr); + $this->assertEquals('building1', $buildingAttr->getId()); + } + + // Test spatial queries on related documents + $nearbyLocations = $database->find('location', [ + Query::distanceLessThan('coordinates', [40.7128, -74.0060], 0.1) + ], Database::PERMISSION_READ); + + $this->assertNotEmpty($nearbyLocations); + $this->assertEquals('location1', $nearbyLocations[0]->getId()); + + // Test relationship with spatial data update + $location1->setAttribute('coordinates', [40.7589, -73.9851]); // Times Square coordinates + $updatedLocation = $database->updateDocument('location', 'location1', $location1); + + $this->assertEquals([40.7589, -73.9851], $updatedLocation->getAttribute('coordinates')); + + // Test spatial query after update + $timesSquareLocations = $database->find('location', [ + Query::distanceLessThan('coordinates', [40.7589, -73.9851], 0.1) + ], Database::PERMISSION_READ); + + $this->assertNotEmpty($timesSquareLocations); + $this->assertEquals('location1', $timesSquareLocations[0]->getId()); + + // Test relationship integrity with spatial data + $building = $database->getDocument('building', 'building1'); + $this->assertInstanceOf(Document::class, $building); + $this->assertEquals('building1', $building->getId()); + + // Test one-way relationship (building doesn't have location attribute) + $this->assertArrayNotHasKey('location', $building->getArrayCopy()); + + // Test basic relationship integrity + $this->assertInstanceOf(Document::class, $building); + $this->assertEquals('Empire State Building', $building->getAttribute('name')); + + // Clean up + $database->deleteCollection('location'); + $database->deleteCollection('building'); + } + + public function testSpatialAttributes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionName = 'spatial_attrs_'; + try { + $database->createCollection($collectionName); + + $required = $database->getAdapter()->getSupportForSpatialIndexNull() ? false : true; + $this->assertEquals(true, $database->createAttribute($collectionName, 'pointAttr', Database::VAR_POINT, 0, $required)); + $this->assertEquals(true, $database->createAttribute($collectionName, 'lineAttr', Database::VAR_LINESTRING, 0, $required)); + $this->assertEquals(true, $database->createAttribute($collectionName, 'polyAttr', Database::VAR_POLYGON, 0, $required)); + + // Create spatial indexes + $this->assertEquals(true, $database->createIndex($collectionName, 'idx_point', Database::INDEX_SPATIAL, ['pointAttr'])); + $this->assertEquals(true, $database->createIndex($collectionName, 'idx_line', Database::INDEX_SPATIAL, ['lineAttr'])); + $this->assertEquals(true, $database->createIndex($collectionName, 'idx_poly', Database::INDEX_SPATIAL, ['polyAttr'])); + + $collection = $database->getCollection($collectionName); + $this->assertIsArray($collection->getAttribute('attributes')); + $this->assertCount(3, $collection->getAttribute('attributes')); + $this->assertIsArray($collection->getAttribute('indexes')); + $this->assertCount(3, $collection->getAttribute('indexes')); + + // Create a simple document to ensure structure is valid + $doc = $database->createDocument($collectionName, new Document([ + '$id' => ID::custom('sdoc'), + 'pointAttr' => [1.0, 1.0], + 'lineAttr' => [[0.0, 0.0], [1.0, 1.0]], + 'polyAttr' => [[[0.0, 0.0], [0.0, 2.0], [2.0, 2.0], [0.0, 0.0]]], + '$permissions' => [Permission::read(Role::any())] + ])); + $this->assertInstanceOf(Document::class, $doc); + } finally { + $database->deleteCollection($collectionName); + } + } + + public function testSpatialOneToMany(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $parent = 'regions_'; + $child = 'places_'; + try { + $database->createCollection($parent); + $database->createCollection($child); + + $database->createAttribute($parent, 'name', Database::VAR_STRING, 255, true); + $database->createAttribute($child, 'name', Database::VAR_STRING, 255, true); + $database->createAttribute($child, 'coord', Database::VAR_POINT, 0, true); + $database->createIndex($child, 'coord_spatial', Database::INDEX_SPATIAL, ['coord']); + + $database->createRelationship( + collection: $parent, + relatedCollection: $child, + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'places', + twoWayKey: 'region' + ); + + $r1 = $database->createDocument($parent, new Document([ + '$id' => 'r1', + 'name' => 'Region 1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ])); + $this->assertInstanceOf(Document::class, $r1); + + $p1 = $database->createDocument($child, new Document([ + '$id' => 'p1', + 'name' => 'Place 1', + 'coord' => [10.0, 10.0], + 'region' => 'r1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ])); + $p2 = $database->createDocument($child, new Document([ + '$id' => 'p2', + 'name' => 'Place 2', + 'coord' => [10.1, 10.1], + 'region' => 'r1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ])); + $this->assertInstanceOf(Document::class, $p1); + $this->assertInstanceOf(Document::class, $p2); + + // Spatial query on child collection + $near = $database->find($child, [ + Query::distanceLessThan('coord', [10.0, 10.0], 1.0) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($near); + + // Test distanceGreaterThan: places far from center (should find p2 which is 0.141 units away) + $far = $database->find($child, [ + Query::distanceGreaterThan('coord', [10.0, 10.0], 0.05) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($far); + + // Test distanceLessThan: places very close to center (should find p1 which is exactly at center) + $close = $database->find($child, [ + Query::distanceLessThan('coord', [10.0, 10.0], 0.2) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($close); + + // Test distanceGreaterThan with various thresholds + // Test: places more than 0.12 units from center (should find p2) + $moderatelyFar = $database->find($child, [ + Query::distanceGreaterThan('coord', [10.0, 10.0], 0.12) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($moderatelyFar); + + // Test: places more than 0.05 units from center (should find p2) + $slightlyFar = $database->find($child, [ + Query::distanceGreaterThan('coord', [10.0, 10.0], 0.05) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($slightlyFar); + + // Test: places more than 10 units from center (should find none) + $extremelyFar = $database->find($child, [ + Query::distanceGreaterThan('coord', [10.0, 10.0], 10.0) + ], Database::PERMISSION_READ); + $this->assertEmpty($extremelyFar); + + // Equal-distanceEqual semantics: distanceEqual (<=) and distanceNotEqual (>), threshold exactly at 0 + $equalZero = $database->find($child, [ + Query::distanceEqual('coord', [10.0, 10.0], 0.0) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($equalZero); + $this->assertEquals('p1', $equalZero[0]->getId()); + + $notEqualZero = $database->find($child, [ + Query::distanceNotEqual('coord', [10.0, 10.0], 0.0) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($notEqualZero); + $this->assertEquals('p2', $notEqualZero[0]->getId()); + + $region = $database->getDocument($parent, 'r1'); + $this->assertArrayHasKey('places', $region); + $this->assertEquals(2, \count($region['places'])); + } finally { + $database->deleteCollection($child); + $database->deleteCollection($parent); + } + } + + public function testSpatialManyToOne(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $parent = 'cities_'; + $child = 'stops_'; + try { + $database->createCollection($parent); + $database->createCollection($child); + + $database->createAttribute($parent, 'name', Database::VAR_STRING, 255, true); + $database->createAttribute($child, 'name', Database::VAR_STRING, 255, true); + $database->createAttribute($child, 'coord', Database::VAR_POINT, 0, true); + $database->createIndex($child, 'coord_spatial', Database::INDEX_SPATIAL, ['coord']); + + $database->createRelationship( + collection: $child, + relatedCollection: $parent, + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'city', + twoWayKey: 'stops' + ); + + $c1 = $database->createDocument($parent, new Document([ + '$id' => 'c1', + 'name' => 'City 1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ])); + + $s1 = $database->createDocument($child, new Document([ + '$id' => 's1', + 'name' => 'Stop 1', + 'coord' => [20.0, 20.0], + 'city' => 'c1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ])); + $s2 = $database->createDocument($child, new Document([ + '$id' => 's2', + 'name' => 'Stop 2', + 'coord' => [20.2, 20.2], + 'city' => 'c1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ])); + $this->assertInstanceOf(Document::class, $c1); + $this->assertInstanceOf(Document::class, $s1); + $this->assertInstanceOf(Document::class, $s2); + + $near = $database->find($child, [ + Query::distanceLessThan('coord', [20.0, 20.0], 1.0) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($near); + + // Test distanceLessThan: stops very close to center (should find s1 which is exactly at center) + $close = $database->find($child, [ + Query::distanceLessThan('coord', [20.0, 20.0], 0.1) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($close); + + // Test distanceGreaterThan with various thresholds + // Test: stops more than 0.25 units from center (should find s2) + $moderatelyFar = $database->find($child, [ + Query::distanceGreaterThan('coord', [20.0, 20.0], 0.25) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($moderatelyFar); + + // Test: stops more than 0.05 units from center (should find s2) + $slightlyFar = $database->find($child, [ + Query::distanceGreaterThan('coord', [20.0, 20.0], 0.05) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($slightlyFar); + + // Test: stops more than 5 units from center (should find none) + $veryFar = $database->find($child, [ + Query::distanceGreaterThan('coord', [20.0, 20.0], 5.0) + ], Database::PERMISSION_READ); + $this->assertEmpty($veryFar); + + // Equal-distanceEqual semantics: distanceEqual (<=) and distanceNotEqual (>), threshold exactly at 0 + $equalZero = $database->find($child, [ + Query::distanceEqual('coord', [20.0, 20.0], 0.0) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($equalZero); + $this->assertEquals('s1', $equalZero[0]->getId()); + + $notEqualZero = $database->find($child, [ + Query::distanceNotEqual('coord', [20.0, 20.0], 0.0) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($notEqualZero); + $this->assertEquals('s2', $notEqualZero[0]->getId()); + + $city = $database->getDocument($parent, 'c1'); + $this->assertArrayHasKey('stops', $city); + $this->assertEquals(2, \count($city['stops'])); + } finally { + $database->deleteCollection($child); + $database->deleteCollection($parent); + } + } + + public function testSpatialManyToMany(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $a = 'drivers_'; + $b = 'routes_'; + try { + $database->createCollection($a); + $database->createCollection($b); + + $database->createAttribute($a, 'name', Database::VAR_STRING, 255, true); + $database->createAttribute($a, 'home', Database::VAR_POINT, 0, true); + $database->createIndex($a, 'home_spatial', Database::INDEX_SPATIAL, ['home']); + $database->createAttribute($b, 'title', Database::VAR_STRING, 255, true); + $database->createAttribute($b, 'area', Database::VAR_POLYGON, 0, true); + $database->createIndex($b, 'area_spatial', Database::INDEX_SPATIAL, ['area']); + + $database->createRelationship( + collection: $a, + relatedCollection: $b, + type: Database::RELATION_MANY_TO_MANY, + twoWay: true, + id: 'routes', + twoWayKey: 'drivers' + ); + + $d1 = $database->createDocument($a, new Document([ + '$id' => 'd1', + 'name' => 'Driver 1', + 'home' => [30.0, 30.0], + 'routes' => [ + [ + '$id' => 'rte1', + 'title' => 'Route 1', + 'area' => [[[29.5,29.5],[29.5,30.5],[30.5,30.5],[29.5,29.5]]] + ] + ], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ])); + $this->assertInstanceOf(Document::class, $d1); + + // Spatial query on "drivers" using point distanceEqual + $near = $database->find($a, [ + Query::distanceLessThan('home', [30.0, 30.0], 0.5) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($near); + + // Test distanceGreaterThan: drivers far from center (using large threshold to find the driver) + $far = $database->find($a, [ + Query::distanceGreaterThan('home', [30.0, 30.0], 100.0) + ], Database::PERMISSION_READ); + $this->assertEmpty($far); + + // Test distanceLessThan: drivers very close to center (should find d1 which is exactly at center) + $close = $database->find($a, [ + Query::distanceLessThan('home', [30.0, 30.0], 0.1) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($close); + + // Test distanceGreaterThan with various thresholds + // Test: drivers more than 0.05 units from center (should find none since d1 is exactly at center) + $slightlyFar = $database->find($a, [ + Query::distanceGreaterThan('home', [30.0, 30.0], 0.05) + ], Database::PERMISSION_READ); + $this->assertEmpty($slightlyFar); + + // Test: drivers more than 0.001 units from center (should find none since d1 is exactly at center) + $verySlightlyFar = $database->find($a, [ + Query::distanceGreaterThan('home', [30.0, 30.0], 0.001) + ], Database::PERMISSION_READ); + $this->assertEmpty($verySlightlyFar); + + // Test: drivers more than 0.5 units from center (should find none since d1 is at center) + $moderatelyFar = $database->find($a, [ + Query::distanceGreaterThan('home', [30.0, 30.0], 0.5) + ], Database::PERMISSION_READ); + $this->assertEmpty($moderatelyFar); + + // Equal-distanceEqual semantics: distanceEqual (<=) and distanceNotEqual (>), threshold exactly at 0 + $equalZero = $database->find($a, [ + Query::distanceEqual('home', [30.0, 30.0], 0.0) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($equalZero); + $this->assertEquals('d1', $equalZero[0]->getId()); + + $notEqualZero = $database->find($a, [ + Query::distanceNotEqual('home', [30.0, 30.0], 0.0) + ], Database::PERMISSION_READ); + $this->assertEmpty($notEqualZero); + + // Ensure relationship present + $d1 = $database->getDocument($a, 'd1'); + $this->assertArrayHasKey('routes', $d1); + $this->assertEquals(1, \count($d1['routes'])); + } finally { + $database->deleteCollection($b); + $database->deleteCollection($a); + } + } + + public function testSpatialIndex(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Basic spatial index create/delete + $collectionName = 'spatial_index_'; + try { + $database->createCollection($collectionName); + $database->createAttribute($collectionName, 'loc', Database::VAR_POINT, 0, true); + $this->assertEquals(true, $database->createIndex($collectionName, 'loc_spatial', Database::INDEX_SPATIAL, ['loc'])); + + $collection = $database->getCollection($collectionName); + $this->assertIsArray($collection->getAttribute('indexes')); + $this->assertCount(1, $collection->getAttribute('indexes')); + $this->assertEquals('loc_spatial', $collection->getAttribute('indexes')[0]['$id']); + $this->assertEquals(Database::INDEX_SPATIAL, $collection->getAttribute('indexes')[0]['type']); + + $this->assertEquals(true, $database->deleteIndex($collectionName, 'loc_spatial')); + $collection = $database->getCollection($collectionName); + $this->assertCount(0, $collection->getAttribute('indexes')); + } finally { + $database->deleteCollection($collectionName); + } + + // Edge cases: Spatial Index Order support (createCollection and createIndex) + $orderSupported = $database->getAdapter()->getSupportForSpatialIndexOrder(); + + // createCollection with orders + $collOrderCreate = 'spatial_idx_order_create'; + try { + $attributes = [new Document([ + '$id' => ID::custom('loc'), + 'type' => Database::VAR_POINT, + 'size' => 0, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ])]; + $indexes = [new Document([ + '$id' => ID::custom('idx_loc'), + 'type' => Database::INDEX_SPATIAL, + 'attributes' => ['loc'], + 'lengths' => [], + 'orders' => $orderSupported ? [Database::ORDER_ASC] : ['ASC'], + ])]; + + if ($orderSupported) { + $database->createCollection($collOrderCreate, $attributes, $indexes); + $meta = $database->getCollection($collOrderCreate); + $this->assertEquals('idx_loc', $meta->getAttribute('indexes')[0]['$id']); + } else { + try { + $database->createCollection($collOrderCreate, $attributes, $indexes); + $this->fail('Expected exception when orders are provided for spatial index on unsupported adapter'); + } catch (\Throwable $e) { + $this->assertStringContainsString('Spatial index', $e->getMessage()); + } + } + } finally { + if ($orderSupported) { + $database->deleteCollection($collOrderCreate); + } + } + + // createIndex with orders + $collOrderIndex = 'spatial_idx_order_index_' . uniqid(); + try { + $database->createCollection($collOrderIndex); + $database->createAttribute($collOrderIndex, 'loc', Database::VAR_POINT, 0, true); + if ($orderSupported) { + $this->assertTrue($database->createIndex($collOrderIndex, 'idx_loc', Database::INDEX_SPATIAL, ['loc'], [], [Database::ORDER_DESC])); + } else { + try { + $database->createIndex($collOrderIndex, 'idx_loc', Database::INDEX_SPATIAL, ['loc'], [], ['DESC']); + $this->fail('Expected exception when orders are provided for spatial index on unsupported adapter'); + } catch (\Throwable $e) { + $this->assertStringContainsString('Spatial index', $e->getMessage()); + } + } + } finally { + $database->deleteCollection($collOrderIndex); + } + + // Edge cases: Spatial Index Nullability (createCollection and createIndex) + $nullSupported = $database->getAdapter()->getSupportForSpatialIndexNull(); + + // createCollection with required=false + $collNullCreate = 'spatial_idx_null_create_' . uniqid(); + try { + $attributes = [new Document([ + '$id' => ID::custom('loc'), + 'type' => Database::VAR_POINT, + 'size' => 0, + 'required' => false, // edge case + 'signed' => true, + 'array' => false, + 'filters' => [], + ])]; + $indexes = [new Document([ + '$id' => ID::custom('idx_loc'), + 'type' => Database::INDEX_SPATIAL, + 'attributes' => ['loc'], + 'lengths' => [], + 'orders' => [], + ])]; + + if ($nullSupported) { + $database->createCollection($collNullCreate, $attributes, $indexes); + $meta = $database->getCollection($collNullCreate); + $this->assertEquals('idx_loc', $meta->getAttribute('indexes')[0]['$id']); + } else { + try { + $database->createCollection($collNullCreate, $attributes, $indexes); + $this->fail('Expected exception when spatial index is created on NULL-able geometry attribute'); + } catch (\Throwable $e) { + $this->assertTrue(true); // exception expected; exact message is adapter-specific + } + } + } finally { + if ($nullSupported) { + $database->deleteCollection($collNullCreate); + } + } + + // createIndex with required=false + $collNullIndex = 'spatial_idx_null_index_' . uniqid(); + try { + $database->createCollection($collNullIndex); + $database->createAttribute($collNullIndex, 'loc', Database::VAR_POINT, 0, false); + if ($nullSupported) { + $this->assertTrue($database->createIndex($collNullIndex, 'idx_loc', Database::INDEX_SPATIAL, ['loc'])); + } else { + try { + $database->createIndex($collNullIndex, 'idx_loc', Database::INDEX_SPATIAL, ['loc']); + $this->fail('Expected exception when spatial index is created on NULL-able geometry attribute'); + } catch (\Throwable $e) { + $this->assertTrue(true); // exception expected; exact message is adapter-specific + } + } + } finally { + $database->deleteCollection($collNullIndex); + } + } + + public function testComplexGeometricShapes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->markTestSkipped('Adapter does not support spatial attributes'); + } + + $collectionName = 'complex_shapes_'; + try { + $database->createCollection($collectionName); + + // Create spatial attributes for different geometric shapes + $this->assertEquals(true, $database->createAttribute($collectionName, 'rectangle', Database::VAR_POLYGON, 0, true)); + $this->assertEquals(true, $database->createAttribute($collectionName, 'square', Database::VAR_POLYGON, 0, true)); + $this->assertEquals(true, $database->createAttribute($collectionName, 'triangle', Database::VAR_POLYGON, 0, true)); + $this->assertEquals(true, $database->createAttribute($collectionName, 'circle_center', Database::VAR_POINT, 0, true)); + $this->assertEquals(true, $database->createAttribute($collectionName, 'complex_polygon', Database::VAR_POLYGON, 0, true)); + $this->assertEquals(true, $database->createAttribute($collectionName, 'multi_linestring', Database::VAR_LINESTRING, 0, true)); + + // Create spatial indexes + $this->assertEquals(true, $database->createIndex($collectionName, 'idx_rectangle', Database::INDEX_SPATIAL, ['rectangle'])); + $this->assertEquals(true, $database->createIndex($collectionName, 'idx_square', Database::INDEX_SPATIAL, ['square'])); + $this->assertEquals(true, $database->createIndex($collectionName, 'idx_triangle', Database::INDEX_SPATIAL, ['triangle'])); + $this->assertEquals(true, $database->createIndex($collectionName, 'idx_circle_center', Database::INDEX_SPATIAL, ['circle_center'])); + $this->assertEquals(true, $database->createIndex($collectionName, 'idx_complex_polygon', Database::INDEX_SPATIAL, ['complex_polygon'])); + $this->assertEquals(true, $database->createIndex($collectionName, 'idx_multi_linestring', Database::INDEX_SPATIAL, ['multi_linestring'])); + + // Create documents with different geometric shapes + $doc1 = new Document([ + '$id' => 'rect1', + 'rectangle' => [[[0, 0], [0, 10], [20, 10], [20, 0], [0, 0]]], // 20x10 rectangle + 'square' => [[[5, 5], [5, 15], [15, 15], [15, 5], [5, 5]]], // 10x10 square + 'triangle' => [[[25, 0], [35, 20], [15, 20], [25, 0]]], // triangle + 'circle_center' => [10, 5], // center of rectangle + 'complex_polygon' => [[[0, 0], [0, 20], [20, 20], [20, 15], [15, 15], [15, 5], [20, 5], [20, 0], [0, 0]]], // L-shaped polygon + 'multi_linestring' => [[0, 0], [10, 10], [20, 0], [0, 20], [20, 20]], // single linestring with multiple points + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ]); + + $doc2 = new Document([ + '$id' => 'rect2', + 'rectangle' => [[[30, 0], [30, 8], [50, 8], [50, 0], [30, 0]]], // 20x8 rectangle + 'square' => [[[35, 10], [35, 20], [45, 20], [45, 10], [35, 10]]], // 10x10 square + 'triangle' => [[[55, 0], [65, 15], [45, 15], [55, 0]]], // triangle + 'circle_center' => [40, 4], // center of second rectangle + 'complex_polygon' => [[[30, 0], [30, 20], [50, 20], [50, 10], [40, 10], [40, 0], [30, 0]]], // T-shaped polygon + 'multi_linestring' => [[30, 0], [40, 10], [50, 0], [30, 20], [50, 20]], // single linestring with multiple points + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ]); + + $createdDoc1 = $database->createDocument($collectionName, $doc1); + $createdDoc2 = $database->createDocument($collectionName, $doc2); + + $this->assertInstanceOf(Document::class, $createdDoc1); + $this->assertInstanceOf(Document::class, $createdDoc2); + + // Test rectangle contains point + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $insideRect1 = $database->find($collectionName, [ + Query::contains('rectangle', [[5, 5]]) // Point inside first rectangle + ], Database::PERMISSION_READ); + $this->assertNotEmpty($insideRect1); + $this->assertEquals('rect1', $insideRect1[0]->getId()); + } + + // Test rectangle doesn't contain point outside + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $outsideRect1 = $database->find($collectionName, [ + Query::notContains('rectangle', [[25, 25]]) // Point outside first rectangle + ], Database::PERMISSION_READ); + $this->assertNotEmpty($outsideRect1); + } + + // Test failure case: rectangle should NOT contain distant point + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $distantPoint = $database->find($collectionName, [ + Query::contains('rectangle', [[100, 100]]) // Point far outside rectangle + ], Database::PERMISSION_READ); + $this->assertEmpty($distantPoint); + } + + // Test failure case: rectangle should NOT contain point outside + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $outsidePoint = $database->find($collectionName, [ + Query::contains('rectangle', [[-1, -1]]) // Point clearly outside rectangle + ], Database::PERMISSION_READ); + $this->assertEmpty($outsidePoint); + } + + // Test rectangle intersects with another rectangle + $overlappingRect = $database->find($collectionName, [ + Query::and([ + Query::intersects('rectangle', [[[15, 5], [15, 15], [25, 15], [25, 5], [15, 5]]]), + Query::notTouches('rectangle', [[[15, 5], [15, 15], [25, 15], [25, 5], [15, 5]]]) + ]), + ], Database::PERMISSION_READ); + $this->assertNotEmpty($overlappingRect); + + + // Test square contains point + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $insideSquare1 = $database->find($collectionName, [ + Query::contains('square', [[10, 10]]) // Point inside first square + ], Database::PERMISSION_READ); + $this->assertNotEmpty($insideSquare1); + $this->assertEquals('rect1', $insideSquare1[0]->getId()); + } + + // Test rectangle contains square (shape contains shape) + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $rectContainsSquare = $database->find($collectionName, [ + Query::contains('rectangle', [[[5, 2], [5, 8], [15, 8], [15, 2], [5, 2]]]) // Square geometry that fits within rectangle + ], Database::PERMISSION_READ); + $this->assertNotEmpty($rectContainsSquare); + $this->assertEquals('rect1', $rectContainsSquare[0]->getId()); + } + + // Test rectangle contains triangle (shape contains shape) + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $rectContainsTriangle = $database->find($collectionName, [ + Query::contains('rectangle', [[[10, 2], [18, 2], [14, 8], [10, 2]]]) // Triangle geometry that fits within rectangle + ], Database::PERMISSION_READ); + $this->assertNotEmpty($rectContainsTriangle); + $this->assertEquals('rect1', $rectContainsTriangle[0]->getId()); + } + + // Test L-shaped polygon contains smaller rectangle (shape contains shape) + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $lShapeContainsRect = $database->find($collectionName, [ + Query::contains('complex_polygon', [[[5, 5], [5, 10], [10, 10], [10, 5], [5, 5]]]) // Small rectangle inside L-shape + ], Database::PERMISSION_READ); + $this->assertNotEmpty($lShapeContainsRect); + $this->assertEquals('rect1', $lShapeContainsRect[0]->getId()); + } + + // Test T-shaped polygon contains smaller square (shape contains shape) + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $tShapeContainsSquare = $database->find($collectionName, [ + Query::contains('complex_polygon', [[[35, 5], [35, 10], [40, 10], [40, 5], [35, 5]]]) // Small square inside T-shape + ], Database::PERMISSION_READ); + $this->assertNotEmpty($tShapeContainsSquare); + $this->assertEquals('rect2', $tShapeContainsSquare[0]->getId()); + } + + // Test failure case: square should NOT contain rectangle (smaller shape cannot contain larger shape) + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $squareNotContainsRect = $database->find($collectionName, [ + Query::notContains('square', [[[0, 0], [0, 20], [20, 20], [20, 0], [0, 0]]]) // Larger rectangle + ], Database::PERMISSION_READ); + $this->assertNotEmpty($squareNotContainsRect); + } + + // Test failure case: triangle should NOT contain rectangle + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $triangleNotContainsRect = $database->find($collectionName, [ + Query::notContains('triangle', [[[20, 0], [20, 25], [30, 25], [30, 0], [20, 0]]]) // Rectangle that extends beyond triangle + ], Database::PERMISSION_READ); + $this->assertNotEmpty($triangleNotContainsRect); + } + + // Test failure case: L-shape should NOT contain T-shape (different complex polygons) + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $lShapeNotContainsTShape = $database->find($collectionName, [ + Query::notContains('complex_polygon', [[[30, 0], [30, 20], [50, 20], [50, 0], [30, 0]]]) // T-shape geometry + ], Database::PERMISSION_READ); + $this->assertNotEmpty($lShapeNotContainsTShape); + } + + // Test square doesn't contain point outside + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $outsideSquare1 = $database->find($collectionName, [ + Query::notContains('square', [[20, 20]]) // Point outside first square + ], Database::PERMISSION_READ); + $this->assertNotEmpty($outsideSquare1); + } + + // Test failure case: square should NOT contain distant point + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $distantPointSquare = $database->find($collectionName, [ + Query::contains('square', [[100, 100]]) // Point far outside square + ], Database::PERMISSION_READ); + $this->assertEmpty($distantPointSquare); + } + + // Test failure case: square should NOT contain point on boundary + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $boundaryPointSquare = $database->find($collectionName, [ + Query::contains('square', [[5, 5]]) // Point on square boundary (should be empty if boundary not inclusive) + ], Database::PERMISSION_READ); + // Note: This may or may not be empty depending on boundary inclusivity + } + + // Test square equals same geometry using contains when supported, otherwise intersects + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $exactSquare = $database->find($collectionName, [ + Query::contains('square', [[[5, 5], [5, 15], [15, 15], [15, 5], [5, 5]]]) + ], Database::PERMISSION_READ); + } else { + $exactSquare = $database->find($collectionName, [ + Query::intersects('square', [[[5, 5], [5, 15], [15, 15], [15, 5], [5, 5]]]) + ], Database::PERMISSION_READ); + } + $this->assertNotEmpty($exactSquare); + $this->assertEquals('rect1', $exactSquare[0]->getId()); + + // Test square doesn't equal different square + $differentSquare = $database->find($collectionName, [ + query::notEqual('square', [[[0, 0], [0, 10], [10, 10], [10, 0], [0, 0]]]) // Different square + ], Database::PERMISSION_READ); + $this->assertNotEmpty($differentSquare); + + // Test triangle contains point + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $insideTriangle1 = $database->find($collectionName, [ + Query::contains('triangle', [[25, 10]]) // Point inside first triangle + ], Database::PERMISSION_READ); + $this->assertNotEmpty($insideTriangle1); + $this->assertEquals('rect1', $insideTriangle1[0]->getId()); + } + + // Test triangle doesn't contain point outside + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $outsideTriangle1 = $database->find($collectionName, [ + Query::notContains('triangle', [[25, 25]]) // Point outside first triangle + ], Database::PERMISSION_READ); + $this->assertNotEmpty($outsideTriangle1); + } + + // Test failure case: triangle should NOT contain distant point + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $distantPointTriangle = $database->find($collectionName, [ + Query::contains('triangle', [[100, 100]]) // Point far outside triangle + ], Database::PERMISSION_READ); + $this->assertEmpty($distantPointTriangle); + } + + // Test failure case: triangle should NOT contain point outside its area + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $outsideTriangleArea = $database->find($collectionName, [ + Query::contains('triangle', [[35, 25]]) // Point outside triangle area + ], Database::PERMISSION_READ); + $this->assertEmpty($outsideTriangleArea); + } + + // Test triangle intersects with point + $intersectingTriangle = $database->find($collectionName, [ + Query::intersects('triangle', [[25, 10]]) // Point inside triangle should intersect + ], Database::PERMISSION_READ); + $this->assertNotEmpty($intersectingTriangle); + + // Test triangle doesn't intersect with distant point + $nonIntersectingTriangle = $database->find($collectionName, [ + Query::notIntersects('triangle', [[100, 100]]) // Distant point should not intersect + ], Database::PERMISSION_READ); + $this->assertNotEmpty($nonIntersectingTriangle); + + // Test L-shaped polygon contains point + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $insideLShape = $database->find($collectionName, [ + Query::contains('complex_polygon', [[10, 10]]) // Point inside L-shape + ], Database::PERMISSION_READ); + $this->assertNotEmpty($insideLShape); + $this->assertEquals('rect1', $insideLShape[0]->getId()); + } + + // Test L-shaped polygon doesn't contain point in "hole" + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $inHole = $database->find($collectionName, [ + Query::notContains('complex_polygon', [[17, 10]]) // Point in the "hole" of L-shape + ], Database::PERMISSION_READ); + $this->assertNotEmpty($inHole); + } + + // Test failure case: L-shaped polygon should NOT contain distant point + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $distantPointLShape = $database->find($collectionName, [ + Query::contains('complex_polygon', [[100, 100]]) // Point far outside L-shape + ], Database::PERMISSION_READ); + $this->assertEmpty($distantPointLShape); + } + + // Test failure case: L-shaped polygon should NOT contain point in the hole + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $holePoint = $database->find($collectionName, [ + Query::contains('complex_polygon', [[17, 10]]) // Point in the "hole" of L-shape + ], Database::PERMISSION_READ); + $this->assertEmpty($holePoint); + } + + // Test T-shaped polygon contains point + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $insideTShape = $database->find($collectionName, [ + Query::contains('complex_polygon', [[40, 5]]) // Point inside T-shape + ], Database::PERMISSION_READ); + $this->assertNotEmpty($insideTShape); + $this->assertEquals('rect2', $insideTShape[0]->getId()); + } + + // Test failure case: T-shaped polygon should NOT contain distant point + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $distantPointTShape = $database->find($collectionName, [ + Query::contains('complex_polygon', [[100, 100]]) // Point far outside T-shape + ], Database::PERMISSION_READ); + $this->assertEmpty($distantPointTShape); + } + + // Test failure case: T-shaped polygon should NOT contain point outside its area + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $outsideTShapeArea = $database->find($collectionName, [ + Query::contains('complex_polygon', [[25, 25]]) // Point outside T-shape area + ], Database::PERMISSION_READ); + $this->assertEmpty($outsideTShapeArea); + } + + // Test complex polygon intersects with line + $intersectingLine = $database->find($collectionName, [ + Query::intersects('complex_polygon', [[[0, 10], [20, 10]]]) // Horizontal line through L-shape + ], Database::PERMISSION_READ); + $this->assertNotEmpty($intersectingLine); + + // Test linestring contains point + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $onLine1 = $database->find($collectionName, [ + Query::contains('multi_linestring', [[5, 5]]) // Point on first line segment + ], Database::PERMISSION_READ); + $this->assertNotEmpty($onLine1); + } + + // Test linestring doesn't contain point off line + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $offLine1 = $database->find($collectionName, [ + Query::notContains('multi_linestring', [[5, 15]]) // Point not on any line + ], Database::PERMISSION_READ); + $this->assertNotEmpty($offLine1); + } + + // Test linestring intersects with point + $intersectingPoint = $database->find($collectionName, [ + Query::intersects('multi_linestring', [[10, 10]]) // Point on diagonal line + ], Database::PERMISSION_READ); + $this->assertNotEmpty($intersectingPoint); + + // Test linestring intersects with a horizontal line coincident at y=20 + $touchingLine = $database->find($collectionName, [ + Query::intersects('multi_linestring', [[[0, 20], [20, 20]]]) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($touchingLine); + + // Test distanceEqual queries between shapes + $nearCenter = $database->find($collectionName, [ + Query::distanceLessThan('circle_center', [10, 5], 5.0) // Points within 5 units of first center + ], Database::PERMISSION_READ); + $this->assertNotEmpty($nearCenter); + $this->assertEquals('rect1', $nearCenter[0]->getId()); + + // Test distanceEqual queries to find nearby shapes + $nearbyShapes = $database->find($collectionName, [ + Query::distanceLessThan('circle_center', [40, 4], 15.0) // Points within 15 units of second center + ], Database::PERMISSION_READ); + $this->assertNotEmpty($nearbyShapes); + $this->assertEquals('rect2', $nearbyShapes[0]->getId()); + + // Test distanceGreaterThan queries + $farShapes = $database->find($collectionName, [ + Query::distanceGreaterThan('circle_center', [10, 5], 10.0) // Points more than 10 units from first center + ], Database::PERMISSION_READ); + $this->assertNotEmpty($farShapes); + $this->assertEquals('rect2', $farShapes[0]->getId()); + + // Test distanceLessThan queries + $closeShapes = $database->find($collectionName, [ + Query::distanceLessThan('circle_center', [10, 5], 3.0) // Points less than 3 units from first center + ], Database::PERMISSION_READ); + $this->assertNotEmpty($closeShapes); + $this->assertEquals('rect1', $closeShapes[0]->getId()); + + // Test distanceGreaterThan queries with various thresholds + // Test: points more than 20 units from first center (should find rect2) + $veryFarShapes = $database->find($collectionName, [ + Query::distanceGreaterThan('circle_center', [10, 5], 20.0) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($veryFarShapes); + $this->assertEquals('rect2', $veryFarShapes[0]->getId()); + + // Test: points more than 5 units from second center (should find rect1) + $farFromSecondCenter = $database->find($collectionName, [ + Query::distanceGreaterThan('circle_center', [40, 4], 5.0) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($farFromSecondCenter); + $this->assertEquals('rect1', $farFromSecondCenter[0]->getId()); + + // Test: points more than 30 units from origin (should find only rect2) + $farFromOrigin = $database->find($collectionName, [ + Query::distanceGreaterThan('circle_center', [0, 0], 30.0) + ], Database::PERMISSION_READ); + $this->assertCount(1, $farFromOrigin); + + // Equal-distanceEqual semantics for circle_center + // rect1 is exactly at [10,5], so distanceEqual 0 + $equalZero = $database->find($collectionName, [ + Query::distanceEqual('circle_center', [10, 5], 0.0) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($equalZero); + $this->assertEquals('rect1', $equalZero[0]->getId()); + + $notEqualZero = $database->find($collectionName, [ + Query::distanceNotEqual('circle_center', [10, 5], 0.0) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($notEqualZero); + $this->assertEquals('rect2', $notEqualZero[0]->getId()); + + // Additional distance queries for complex shapes (polygon and linestring) + $rectDistanceEqual = $database->find($collectionName, [ + Query::distanceEqual('rectangle', [[[0, 0], [0, 10], [20, 10], [20, 0], [0, 0]]], 0.0) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($rectDistanceEqual); + $this->assertEquals('rect1', $rectDistanceEqual[0]->getId()); + + $lineDistanceEqual = $database->find($collectionName, [ + Query::distanceEqual('multi_linestring', [[0, 0], [10, 10], [20, 0], [0, 20], [20, 20]], 0.0) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($lineDistanceEqual); + $this->assertEquals('rect1', $lineDistanceEqual[0]->getId()); + + } finally { + $database->deleteCollection($collectionName); + } + } + + public function testSpatialQueryCombinations(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->markTestSkipped('Adapter does not support spatial attributes'); + } + + $collectionName = 'spatial_combinations_'; + try { + $database->createCollection($collectionName); + + // Create spatial attributes + $this->assertEquals(true, $database->createAttribute($collectionName, 'location', Database::VAR_POINT, 0, true)); + $this->assertEquals(true, $database->createAttribute($collectionName, 'area', Database::VAR_POLYGON, 0, true)); + $this->assertEquals(true, $database->createAttribute($collectionName, 'route', Database::VAR_LINESTRING, 0, true)); + $this->assertEquals(true, $database->createAttribute($collectionName, 'name', Database::VAR_STRING, 255, true)); + + // Create spatial indexes + $this->assertEquals(true, $database->createIndex($collectionName, 'idx_location', Database::INDEX_SPATIAL, ['location'])); + $this->assertEquals(true, $database->createIndex($collectionName, 'idx_area', Database::INDEX_SPATIAL, ['area'])); + $this->assertEquals(true, $database->createIndex($collectionName, 'idx_route', Database::INDEX_SPATIAL, ['route'])); + + // Create test documents + $doc1 = new Document([ + '$id' => 'park1', + 'name' => 'Central Park', + 'location' => [40.7829, -73.9654], + 'area' => [[[40.7649, -73.9814], [40.7649, -73.9494], [40.8009, -73.9494], [40.8009, -73.9814], [40.7649, -73.9814]]], + 'route' => [[40.7649, -73.9814], [40.8009, -73.9494]], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ]); + + $doc2 = new Document([ + '$id' => 'park2', + 'name' => 'Prospect Park', + 'location' => [40.6602, -73.9690], + 'area' => [[[40.6502, -73.9790], [40.6502, -73.9590], [40.6702, -73.9590], [40.6702, -73.9790], [40.6502, -73.9790]]], + 'route' => [[40.6502, -73.9790], [40.6702, -73.9590]], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ]); + + $doc3 = new Document([ + '$id' => 'park3', + 'name' => 'Battery Park', + 'location' => [40.6033, -74.0170], + 'area' => [[[40.5933, -74.0270], [40.5933, -74.0070], [40.6133, -74.0070], [40.6133, -74.0270], [40.5933, -74.0270]]], + 'route' => [[40.5933, -74.0270], [40.6133, -74.0070]], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ]); + + $database->createDocument($collectionName, $doc1); + $database->createDocument($collectionName, $doc2); + $database->createDocument($collectionName, $doc3); + + // Test complex spatial queries with logical combinations + // Test AND combination: parks within area AND near specific location + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $nearbyAndInArea = $database->find($collectionName, [ + Query::and([ + Query::distanceLessThan('location', [40.7829, -73.9654], 0.01), // Near Central Park + Query::contains('area', [[40.7829, -73.9654]]) // Location is within area + ]) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($nearbyAndInArea); + $this->assertEquals('park1', $nearbyAndInArea[0]->getId()); + } + + // Test OR combination: parks near either location + $nearEitherLocation = $database->find($collectionName, [ + Query::or([ + Query::distanceLessThan('location', [40.7829, -73.9654], 0.01), // Near Central Park + Query::distanceLessThan('location', [40.6602, -73.9690], 0.01) // Near Prospect Park + ]) + ], Database::PERMISSION_READ); + $this->assertCount(2, $nearEitherLocation); + + // Test distanceGreaterThan: parks far from Central Park + $farFromCentral = $database->find($collectionName, [ + Query::distanceGreaterThan('location', [40.7829, -73.9654], 0.1) // More than 0.1 degrees from Central Park + ], Database::PERMISSION_READ); + $this->assertNotEmpty($farFromCentral); + + // Test distanceLessThan: parks very close to Central Park + $veryCloseToCentral = $database->find($collectionName, [ + Query::distanceLessThan('location', [40.7829, -73.9654], 0.001) // Less than 0.001 degrees from Central Park + ], Database::PERMISSION_READ); + $this->assertNotEmpty($veryCloseToCentral); + + // Test distanceGreaterThan with various thresholds + // Test: parks more than 0.3 degrees from Central Park (should find none since all parks are closer) + $veryFarFromCentral = $database->find($collectionName, [ + Query::distanceGreaterThan('location', [40.7829, -73.9654], 0.3) + ], Database::PERMISSION_READ); + $this->assertCount(0, $veryFarFromCentral); + + // Test: parks more than 0.3 degrees from Prospect Park (should find other parks) + $farFromProspect = $database->find($collectionName, [ + Query::distanceGreaterThan('location', [40.6602, -73.9690], 0.1) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($farFromProspect); + + // Test: parks more than 0.3 degrees from Times Square (should find none since all parks are closer) + $farFromTimesSquare = $database->find($collectionName, [ + Query::distanceGreaterThan('location', [40.7589, -73.9851], 0.3) + ], Database::PERMISSION_READ); + $this->assertCount(0, $farFromTimesSquare); + + // Test ordering by distanceEqual from a specific point + $orderedByDistance = $database->find($collectionName, [ + Query::distanceLessThan('location', [40.7829, -73.9654], 0.01), // Within ~1km + Query::limit(10) + ], Database::PERMISSION_READ); + + $this->assertNotEmpty($orderedByDistance); + // First result should be closest to the reference point + $this->assertEquals('park1', $orderedByDistance[0]->getId()); + + // Test spatial queries with limits + $limitedResults = $database->find($collectionName, [ + Query::distanceLessThan('location', [40.7829, -73.9654], 1.0), // Within 1 degree + Query::limit(2) + ], Database::PERMISSION_READ); + + $this->assertCount(2, $limitedResults); + } finally { + $database->deleteCollection($collectionName); + } + } + + public function testSpatialBulkOperation(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->markTestSkipped('Adapter does not support spatial attributes'); + } + + $collectionName = 'test_spatial_bulk_ops'; + + // Create collection with spatial attributes + $attributes = [ + new Document([ + '$id' => ID::custom('name'), + 'type' => Database::VAR_STRING, + 'size' => 256, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => ID::custom('location'), + 'type' => Database::VAR_POINT, + 'size' => 0, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => ID::custom('area'), + 'type' => Database::VAR_POLYGON, + 'size' => 0, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]) + ]; + + $indexes = [ + new Document([ + '$id' => ID::custom('spatial_idx'), + 'type' => Database::INDEX_SPATIAL, + 'attributes' => ['location'], + 'lengths' => [], + 'orders' => [], + ]) + ]; + + $database->createCollection($collectionName, $attributes, $indexes); + + // Test 1: createDocuments with spatial data + $spatialDocuments = []; + for ($i = 0; $i < 5; $i++) { + $spatialDocuments[] = new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Location ' . $i, + 'location' => [10.0 + $i, 20.0 + $i], // POINT + 'area' => [ + [10.0 + $i, 20.0 + $i], + [11.0 + $i, 20.0 + $i], + [11.0 + $i, 21.0 + $i], + [10.0 + $i, 21.0 + $i], + [10.0 + $i, 20.0 + $i] + ] // POLYGON + ]); + } + + $results = []; + $count = $database->createDocuments($collectionName, $spatialDocuments, 3, onNext: function ($doc) use (&$results) { + $results[] = $doc; + }); + + $this->assertEquals(5, $count); + $this->assertEquals(5, count($results)); + + // Verify created documents + foreach ($results as $document) { + $this->assertNotEmpty($document->getId()); + $this->assertNotEmpty($document->getAttribute('name')); + $this->assertNotEmpty($document->getSequence()); + $this->assertIsArray($document->getAttribute('location')); + $this->assertIsArray($document->getAttribute('area')); + $this->assertCount(2, $document->getAttribute('location')); // POINT has 2 coordinates + $this->assertGreaterThan(1, count($document->getAttribute('area')[0])); // POLYGON has multiple points + } + + $results = $database->find($collectionName); + foreach ($results as $document) { + $this->assertNotEmpty($document->getId()); + $this->assertNotEmpty($document->getAttribute('name')); + $this->assertNotEmpty($document->getSequence()); + $this->assertIsArray($document->getAttribute('location')); + $this->assertIsArray($document->getAttribute('area')); + $this->assertCount(2, $document->getAttribute('location')); // POINT has 2 coordinates + $this->assertGreaterThan(1, count($document->getAttribute('area')[0])); // POLYGON has multiple points + } + + foreach ($results as $doc) { + $document = $database->getDocument($collectionName, $doc->getId()); + $this->assertNotEmpty($document->getId()); + $this->assertNotEmpty($document->getAttribute('name')); + $this->assertEquals($document->getAttribute('name'), $doc->getAttribute('name')); + $this->assertNotEmpty($document->getSequence()); + $this->assertIsArray($document->getAttribute('location')); + $this->assertIsArray($document->getAttribute('area')); + $this->assertCount(2, $document->getAttribute('location')); // POINT has 2 coordinates + $this->assertGreaterThan(1, count($document->getAttribute('area')[0])); // POLYGON has multiple points + } + + $results = $database->find($collectionName, [Query::select(["name"])]); + foreach ($results as $document) { + $this->assertNotEmpty($document->getAttribute('name')); + } + + $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"])]); + 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 + } + + // Test 2: updateDocuments with spatial data + $updateResults = []; + $updateCount = $database->updateDocuments($collectionName, new Document([ + 'name' => 'Updated Location', + 'location' => [15.0, 25.0], // New POINT + 'area' => [ + [15.0, 25.0], + [16.0, 25.0], + [16.0, 26.0], + [15.0, 26.0], + [15.0, 25.0] + ] // New POLYGON + ]), [ + Query::greaterThanEqual('$sequence', $results[0]->getSequence()) + ], onNext: function ($doc) use (&$updateResults) { + $updateResults[] = $doc; + }); + + $this->assertGreaterThan(0, $updateCount); + + // Verify updated documents + foreach ($updateResults as $document) { + $this->assertEquals('Updated Location', $document->getAttribute('name')); + $this->assertEquals([15.0, 25.0], $document->getAttribute('location')); + $this->assertEquals([[ + [15.0, 25.0], + [16.0, 25.0], + [16.0, 26.0], + [15.0, 26.0], + [15.0, 25.0] + ]], $document->getAttribute('area')); + } + + // Test 3: createOrUpdateDocuments with spatial data + $upsertDocuments = [ + new Document([ + '$id' => 'upsert1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Upsert Location 1', + 'location' => [30.0, 40.0], + 'area' => [ + [30.0, 40.0], + [31.0, 40.0], + [31.0, 41.0], + [30.0, 41.0], + [30.0, 40.0] + ] + ]), + new Document([ + '$id' => 'upsert2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Upsert Location 2', + 'location' => [35.0, 45.0], + 'area' => [ + [35.0, 45.0], + [36.0, 45.0], + [36.0, 46.0], + [35.0, 46.0], + [35.0, 45.0] + ] + ]) + ]; + + $upsertResults = []; + $upsertCount = $database->createOrUpdateDocuments($collectionName, $upsertDocuments, onNext: function ($doc) use (&$upsertResults) { + $upsertResults[] = $doc; + }); + + $this->assertEquals(2, $upsertCount); + $this->assertEquals(2, count($upsertResults)); + + // Verify upserted documents + foreach ($upsertResults as $document) { + $this->assertNotEmpty($document->getId()); + $this->assertNotEmpty($document->getSequence()); + $this->assertIsArray($document->getAttribute('location')); + $this->assertIsArray($document->getAttribute('area')); + } + + // Test 4: Query spatial data after bulk operations + $allDocuments = $database->find($collectionName, [ + Query::orderAsc('$sequence') + ]); + + $this->assertGreaterThan(5, count($allDocuments)); // Should have original 5 + upserted 2 + + // Test 5: Spatial queries on bulk created data + $nearbyDocuments = $database->find($collectionName, [ + Query::distanceLessThan('location', [15.0, 25.0], 1.0) // Find documents within 1 unit + ]); + + $this->assertGreaterThan(0, count($nearbyDocuments)); + + // Test 6: distanceGreaterThan queries on bulk created data + $farDocuments = $database->find($collectionName, [ + Query::distanceGreaterThan('location', [15.0, 25.0], 5.0) // Find documents more than 5 units away + ]); + + $this->assertGreaterThan(0, count($farDocuments)); + + // Test 7: distanceLessThan queries on bulk created data + $closeDocuments = $database->find($collectionName, [ + Query::distanceLessThan('location', [15.0, 25.0], 0.5) // Find documents less than 0.5 units away + ]); + + $this->assertGreaterThan(0, count($closeDocuments)); + + // Test 8: Additional distanceGreaterThan queries on bulk created data + $veryFarDocuments = $database->find($collectionName, [ + Query::distanceGreaterThan('location', [15.0, 25.0], 10.0) // Find documents more than 10 units away + ]); + + $this->assertGreaterThan(0, count($veryFarDocuments)); + + // Test 9: distanceGreaterThan with very small threshold (should find most documents) + $slightlyFarDocuments = $database->find($collectionName, [ + Query::distanceGreaterThan('location', [15.0, 25.0], 0.1) // Find documents more than 0.1 units away + ]); + + $this->assertGreaterThan(0, count($slightlyFarDocuments)); + + // Test 10: distanceGreaterThan with very large threshold (should find none) + $extremelyFarDocuments = $database->find($collectionName, [ + Query::distanceGreaterThan('location', [15.0, 25.0], 100.0) // Find documents more than 100 units away + ]); + + $this->assertEquals(0, count($extremelyFarDocuments)); + + // Test 11: Update specific spatial documents + $specificUpdateCount = $database->updateDocuments($collectionName, new Document([ + 'name' => 'Specifically Updated' + ]), [ + Query::equal('$id', ['upsert1']) + ]); + + $this->assertEquals(1, $specificUpdateCount); + + // Verify the specific update + $specificDoc = $database->find($collectionName, [ + Query::equal('$id', ['upsert1']) + ]); + + $this->assertCount(1, $specificDoc); + $this->assertEquals('Specifically Updated', $specificDoc[0]->getAttribute('name')); + + // Cleanup + $database->deleteCollection($collectionName); + } + + public function testSptialAggregation(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->markTestSkipped('Adapter does not support spatial attributes'); + } + $collectionName = 'spatial_agg_'; + try { + // Create collection with spatial and numeric attributes + $database->createCollection($collectionName); + $database->createAttribute($collectionName, 'name', Database::VAR_STRING, 255, true); + $database->createAttribute($collectionName, 'loc', Database::VAR_POINT, 0, true); + $database->createAttribute($collectionName, 'area', Database::VAR_POLYGON, 0, true); + $database->createAttribute($collectionName, 'score', Database::VAR_INTEGER, 0, true); + + // Spatial indexes + $database->createIndex($collectionName, 'idx_loc', Database::INDEX_SPATIAL, ['loc']); + $database->createIndex($collectionName, 'idx_area', Database::INDEX_SPATIAL, ['area']); + + // Seed documents + $a = $database->createDocument($collectionName, new Document([ + '$id' => 'a', + 'name' => 'A', + 'loc' => [10.0, 10.0], + 'area' => [[[9.0, 9.0], [9.0, 11.0], [11.0, 11.0], [11.0, 9.0], [9.0, 9.0]]], + 'score' => 10, + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ])); + $b = $database->createDocument($collectionName, new Document([ + '$id' => 'b', + 'name' => 'B', + 'loc' => [10.05, 10.05], + 'area' => [[[9.5, 9.5], [9.5, 10.6], [10.6, 10.6], [10.6, 9.5], [9.5, 9.5]]], + 'score' => 20, + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ])); + $c = $database->createDocument($collectionName, new Document([ + '$id' => 'c', + 'name' => 'C', + 'loc' => [50.0, 50.0], + 'area' => [[[49.0, 49.0], [49.0, 51.0], [51.0, 51.0], [51.0, 49.0], [49.0, 49.0]]], + 'score' => 30, + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ])); + + $this->assertInstanceOf(Document::class, $a); + $this->assertInstanceOf(Document::class, $b); + $this->assertInstanceOf(Document::class, $c); + + // COUNT with spatial distanceEqual filter + $queries = [ + Query::distanceLessThan('loc', [10.0, 10.0], 0.1) + ]; + $this->assertEquals(2, $database->count($collectionName, $queries)); + $this->assertCount(2, $database->find($collectionName, $queries)); + + // SUM with spatial distanceEqual filter + $sumNear = $database->sum($collectionName, 'score', $queries); + $this->assertEquals(10 + 20, $sumNear); + + // COUNT and SUM with distanceGreaterThan (should only include far point "c") + $queriesFar = [ + Query::distanceGreaterThan('loc', [10.0, 10.0], 10.0) + ]; + $this->assertEquals(1, $database->count($collectionName, $queriesFar)); + $this->assertEquals(30, $database->sum($collectionName, 'score', $queriesFar)); + + // COUNT and SUM with polygon contains filter (adapter-dependent boundary inclusivity) + if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + $queriesContain = [ + Query::contains('area', [[10.0, 10.0]]) + ]; + $this->assertEquals(2, $database->count($collectionName, $queriesContain)); + $this->assertEquals(30, $database->sum($collectionName, 'score', $queriesContain)); + + $queriesNotContain = [ + Query::notContains('area', [[10.0, 10.0]]) + ]; + $this->assertEquals(1, $database->count($collectionName, $queriesNotContain)); + $this->assertEquals(30, $database->sum($collectionName, 'score', $queriesNotContain)); + } + } finally { + $database->deleteCollection($collectionName); + } + } +} diff --git a/tests/unit/DocumentTest.php b/tests/unit/DocumentTest.php index a9ca44e1e..9a41ab534 100644 --- a/tests/unit/DocumentTest.php +++ b/tests/unit/DocumentTest.php @@ -409,4 +409,12 @@ public function testGetArrayCopy(): void ], $this->document->getArrayCopy()); $this->assertEquals([], $this->empty->getArrayCopy()); } + + public function testEmptyDocumentSequence(): void + { + $empty = new Document(); + + $this->assertNull($empty->getSequence()); + $this->assertNotSame('', $empty->getSequence()); + } } diff --git a/tests/unit/QueryTest.php b/tests/unit/QueryTest.php index d9ad6cd93..3084abaa0 100644 --- a/tests/unit/QueryTest.php +++ b/tests/unit/QueryTest.php @@ -85,6 +85,62 @@ public function testCreate(): void $this->assertEquals(Query::TYPE_IS_NOT_NULL, $query->getMethod()); $this->assertEquals('title', $query->getAttribute()); $this->assertEquals([], $query->getValues()); + + // Test new NOT query types + $query = Query::notContains('tags', ['test', 'example']); + + $this->assertEquals(Query::TYPE_NOT_CONTAINS, $query->getMethod()); + $this->assertEquals('tags', $query->getAttribute()); + $this->assertEquals(['test', 'example'], $query->getValues()); + + $query = Query::notSearch('content', 'keyword'); + + $this->assertEquals(Query::TYPE_NOT_SEARCH, $query->getMethod()); + $this->assertEquals('content', $query->getAttribute()); + $this->assertEquals(['keyword'], $query->getValues()); + + $query = Query::notStartsWith('title', 'prefix'); + + $this->assertEquals(Query::TYPE_NOT_STARTS_WITH, $query->getMethod()); + $this->assertEquals('title', $query->getAttribute()); + $this->assertEquals(['prefix'], $query->getValues()); + + $query = Query::notEndsWith('url', '.html'); + + $this->assertEquals(Query::TYPE_NOT_ENDS_WITH, $query->getMethod()); + $this->assertEquals('url', $query->getAttribute()); + $this->assertEquals(['.html'], $query->getValues()); + + $query = Query::notBetween('score', 10, 20); + + $this->assertEquals(Query::TYPE_NOT_BETWEEN, $query->getMethod()); + $this->assertEquals('score', $query->getAttribute()); + $this->assertEquals([10, 20], $query->getValues()); + + // Test new date query wrapper methods + $query = Query::createdBefore('2023-01-01T00:00:00.000Z'); + + $this->assertEquals(Query::TYPE_LESSER, $query->getMethod()); + $this->assertEquals('$createdAt', $query->getAttribute()); + $this->assertEquals(['2023-01-01T00:00:00.000Z'], $query->getValues()); + + $query = Query::createdAfter('2023-01-01T00:00:00.000Z'); + + $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()); + $this->assertEquals('$createdAt', $query->getAttribute()); + $this->assertEquals(['2023-01-01T00:00:00.000Z'], $query->getValues()); + + $query = Query::updatedBefore('2023-12-31T23:59:59.999Z'); + + $this->assertEquals(Query::TYPE_LESSER, $query->getMethod()); + $this->assertEquals('$updatedAt', $query->getAttribute()); + $this->assertEquals(['2023-12-31T23:59:59.999Z'], $query->getValues()); + + $query = Query::updatedAfter('2023-12-31T23:59:59.999Z'); + + $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()); + $this->assertEquals('$updatedAt', $query->getAttribute()); + $this->assertEquals(['2023-12-31T23:59:59.999Z'], $query->getValues()); } /** @@ -138,6 +194,32 @@ public function testParse(): void $this->assertEquals('score', $query->getAttribute()); $this->assertEquals(8.5, $query->getValues()[0]); + // Test new NOT query types parsing + $query = Query::parse(Query::notContains('tags', ['unwanted', 'spam'])->toString()); + $this->assertEquals('notContains', $query->getMethod()); + $this->assertEquals('tags', $query->getAttribute()); + $this->assertEquals(['unwanted', 'spam'], $query->getValues()); + + $query = Query::parse(Query::notSearch('content', 'unwanted content')->toString()); + $this->assertEquals('notSearch', $query->getMethod()); + $this->assertEquals('content', $query->getAttribute()); + $this->assertEquals(['unwanted content'], $query->getValues()); + + $query = Query::parse(Query::notStartsWith('title', 'temp')->toString()); + $this->assertEquals('notStartsWith', $query->getMethod()); + $this->assertEquals('title', $query->getAttribute()); + $this->assertEquals(['temp'], $query->getValues()); + + $query = Query::parse(Query::notEndsWith('filename', '.tmp')->toString()); + $this->assertEquals('notEndsWith', $query->getMethod()); + $this->assertEquals('filename', $query->getAttribute()); + $this->assertEquals(['.tmp'], $query->getValues()); + + $query = Query::parse(Query::notBetween('score', 0, 50)->toString()); + $this->assertEquals('notBetween', $query->getMethod()); + $this->assertEquals('score', $query->getAttribute()); + $this->assertEquals([0, 50], $query->getValues()); + $query = Query::parse(Query::notEqual('director', 'null')->toString()); $this->assertEquals('notEqual', $query->getMethod()); $this->assertEquals('director', $query->getAttribute()); @@ -168,6 +250,27 @@ public function testParse(): void $this->assertEquals(null, $query->getAttribute()); $this->assertEquals(['title', 'director'], $query->getValues()); + // Test new date query wrapper methods parsing + $query = Query::parse(Query::createdBefore('2023-01-01T00:00:00.000Z')->toString()); + $this->assertEquals('lessThan', $query->getMethod()); + $this->assertEquals('$createdAt', $query->getAttribute()); + $this->assertEquals(['2023-01-01T00:00:00.000Z'], $query->getValues()); + + $query = Query::parse(Query::createdAfter('2023-01-01T00:00:00.000Z')->toString()); + $this->assertEquals('greaterThan', $query->getMethod()); + $this->assertEquals('$createdAt', $query->getAttribute()); + $this->assertEquals(['2023-01-01T00:00:00.000Z'], $query->getValues()); + + $query = Query::parse(Query::updatedBefore('2023-12-31T23:59:59.999Z')->toString()); + $this->assertEquals('lessThan', $query->getMethod()); + $this->assertEquals('$updatedAt', $query->getAttribute()); + $this->assertEquals(['2023-12-31T23:59:59.999Z'], $query->getValues()); + + $query = Query::parse(Query::updatedAfter('2023-12-31T23:59:59.999Z')->toString()); + $this->assertEquals('greaterThan', $query->getMethod()); + $this->assertEquals('$updatedAt', $query->getAttribute()); + $this->assertEquals(['2023-12-31T23:59:59.999Z'], $query->getValues()); + $query = Query::parse(Query::between('age', 15, 18)->toString()); $this->assertEquals('between', $query->getMethod()); $this->assertEquals('age', $query->getAttribute()); @@ -251,7 +354,13 @@ public function testIsMethod(): void $this->assertTrue(Query::isMethod('greaterThan')); $this->assertTrue(Query::isMethod('greaterThanEqual')); $this->assertTrue(Query::isMethod('contains')); + $this->assertTrue(Query::isMethod('notContains')); $this->assertTrue(Query::isMethod('search')); + $this->assertTrue(Query::isMethod('notSearch')); + $this->assertTrue(Query::isMethod('startsWith')); + $this->assertTrue(Query::isMethod('notStartsWith')); + $this->assertTrue(Query::isMethod('endsWith')); + $this->assertTrue(Query::isMethod('notEndsWith')); $this->assertTrue(Query::isMethod('orderDesc')); $this->assertTrue(Query::isMethod('orderAsc')); $this->assertTrue(Query::isMethod('limit')); @@ -261,6 +370,7 @@ public function testIsMethod(): void $this->assertTrue(Query::isMethod('isNull')); $this->assertTrue(Query::isMethod('isNotNull')); $this->assertTrue(Query::isMethod('between')); + $this->assertTrue(Query::isMethod('notBetween')); $this->assertTrue(Query::isMethod('select')); $this->assertTrue(Query::isMethod('or')); $this->assertTrue(Query::isMethod('and')); @@ -272,7 +382,13 @@ public function testIsMethod(): void $this->assertTrue(Query::isMethod(Query::TYPE_GREATER)); $this->assertTrue(Query::isMethod(Query::TYPE_GREATER_EQUAL)); $this->assertTrue(Query::isMethod(Query::TYPE_CONTAINS)); + $this->assertTrue(Query::isMethod(Query::TYPE_NOT_CONTAINS)); $this->assertTrue(Query::isMethod(QUERY::TYPE_SEARCH)); + $this->assertTrue(Query::isMethod(QUERY::TYPE_NOT_SEARCH)); + $this->assertTrue(Query::isMethod(QUERY::TYPE_STARTS_WITH)); + $this->assertTrue(Query::isMethod(QUERY::TYPE_NOT_STARTS_WITH)); + $this->assertTrue(Query::isMethod(QUERY::TYPE_ENDS_WITH)); + $this->assertTrue(Query::isMethod(QUERY::TYPE_NOT_ENDS_WITH)); $this->assertTrue(Query::isMethod(QUERY::TYPE_ORDER_ASC)); $this->assertTrue(Query::isMethod(QUERY::TYPE_ORDER_DESC)); $this->assertTrue(Query::isMethod(QUERY::TYPE_LIMIT)); @@ -282,6 +398,7 @@ public function testIsMethod(): void $this->assertTrue(Query::isMethod(QUERY::TYPE_IS_NULL)); $this->assertTrue(Query::isMethod(QUERY::TYPE_IS_NOT_NULL)); $this->assertTrue(Query::isMethod(QUERY::TYPE_BETWEEN)); + $this->assertTrue(Query::isMethod(QUERY::TYPE_NOT_BETWEEN)); $this->assertTrue(Query::isMethod(QUERY::TYPE_SELECT)); $this->assertTrue(Query::isMethod(QUERY::TYPE_OR)); $this->assertTrue(Query::isMethod(QUERY::TYPE_AND)); @@ -289,4 +406,14 @@ public function testIsMethod(): void $this->assertFalse(Query::isMethod('invalid')); $this->assertFalse(Query::isMethod('lte ')); } + + public function testNewQueryTypesInTypesArray(): void + { + // Test that all new query types are included in the TYPES array + $this->assertContains(Query::TYPE_NOT_CONTAINS, Query::TYPES); + $this->assertContains(Query::TYPE_NOT_SEARCH, Query::TYPES); + $this->assertContains(Query::TYPE_NOT_STARTS_WITH, Query::TYPES); + $this->assertContains(Query::TYPE_NOT_ENDS_WITH, Query::TYPES); + $this->assertContains(Query::TYPE_NOT_BETWEEN, Query::TYPES); + } } diff --git a/tests/unit/Validator/Query/FilterTest.php b/tests/unit/Validator/Query/FilterTest.php index 2465ea3e3..ff7bd2630 100644 --- a/tests/unit/Validator/Query/FilterTest.php +++ b/tests/unit/Validator/Query/FilterTest.php @@ -114,4 +114,75 @@ public function testMaxValuesCount(): void $this->assertFalse($this->validator->isValid(Query::equal('integer', $values))); $this->assertEquals('Query on attribute has greater than 100 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]))); + + // 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()); + } + + public function testNotSearch(): void + { + // Test valid notSearch queries + $this->assertTrue($this->validator->isValid(Query::notSearch('string', 'unwanted'))); + + // 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()); + + // 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()); + } + + public function testNotStartsWith(): void + { + // Test valid notStartsWith queries + $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()); + + // 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()); + } + + public function testNotEndsWith(): void + { + // Test valid notEndsWith queries + $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()); + + // 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()); + } + + public function testNotBetween(): void + { + // Test valid notBetween queries + $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()); + + // 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()); + + $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()); + } } diff --git a/tests/unit/Validator/StructureTest.php b/tests/unit/Validator/StructureTest.php index 68fa73bf8..a0b448ff5 100644 --- a/tests/unit/Validator/StructureTest.php +++ b/tests/unit/Validator/StructureTest.php @@ -716,7 +716,7 @@ public function testId(): void ); $sqlId = '1000'; - $mongoId = '507f1f77bcf86cd799439011'; + $mongoId = '0198fffb-d664-710a-9765-f922b3e81e3d'; $this->assertEquals(true, $validator->isValid(new Document([ '$collection' => ID::custom('posts'), @@ -748,7 +748,7 @@ public function testId(): void $validator = new Structure( new Document($this->collection), - Database::VAR_OBJECT_ID + Database::VAR_UUID7 ); $this->assertEquals(true, $validator->isValid(new Document([