diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index eea0e7842..025894dd5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -72,12 +72,14 @@ jobs: matrix: adapter: [ + MongoDB, MariaDB, MySQL, Postgres, SQLite, Mirror, Pool, + SharedTables/MongoDB, SharedTables/MariaDB, SharedTables/MySQL, SharedTables/Postgres, diff --git a/composer.json b/composer.json index 1824795de..4f3ff5b19 100755 --- a/composer.json +++ b/composer.json @@ -39,7 +39,7 @@ "utopia-php/framework": "0.33.*", "utopia-php/cache": "0.13.*", "utopia-php/pools": "0.8.*", - "utopia-php/mongo": "dev-feat-create-UUID as 0.5.3" + "utopia-php/mongo": "0.6.0" }, "require-dev": { "fakerphp/faker": "1.23.*", diff --git a/composer.lock b/composer.lock index 26bad1fd7..5b4b39a9c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,29 +4,29 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "94af8bc26007c57d8f0ad1a76f38c16e", + "content-hash": "e23429f4a3f7e66afaa960e249ee7525", "packages": [ { "name": "brick/math", - "version": "0.13.1", + "version": "0.14.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04" + "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/fc7ed316430118cc7836bf45faff18d5dfc8de04", - "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04", + "url": "https://api.github.com/repos/brick/math/zipball/113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", + "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.2" }, "require-dev": { "php-coveralls/php-coveralls": "^2.2", - "phpunit/phpunit": "^10.1", - "vimeo/psalm": "6.8.8" + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" }, "type": "library", "autoload": { @@ -56,7 +56,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.13.1" + "source": "https://github.com/brick/math/tree/0.14.0" }, "funding": [ { @@ -64,7 +64,7 @@ "type": "github" } ], - "time": "2025-03-29T13:50:30+00:00" + "time": "2025-08-29T12:40:03+00:00" }, { "name": "composer/semver", @@ -413,16 +413,16 @@ }, { "name": "open-telemetry/api", - "version": "1.4.0", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "b3a9286f9c1c8247c83493c5b1fa475cd0cec7f7" + "reference": "7692075f486c14d8cfd37fba98a08a5667f089e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/b3a9286f9c1c8247c83493c5b1fa475cd0cec7f7", - "reference": "b3a9286f9c1c8247c83493c5b1fa475cd0cec7f7", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/7692075f486c14d8cfd37fba98a08a5667f089e5", + "reference": "7692075f486c14d8cfd37fba98a08a5667f089e5", "shasum": "" }, "require": { @@ -479,7 +479,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-06-19T23:36:51+00:00" + "time": "2025-08-07T23:07:38+00:00" }, { "name": "open-telemetry/context", @@ -669,22 +669,22 @@ }, { "name": "open-telemetry/sdk", - "version": "1.7.0", + "version": "1.7.1", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "86287cf30fd6549444d7b8f7d8758d92e24086ac" + "reference": "52690d4b37ae4f091af773eef3c238ed2bc0aa06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/86287cf30fd6549444d7b8f7d8758d92e24086ac", - "reference": "86287cf30fd6549444d7b8f7d8758d92e24086ac", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/52690d4b37ae4f091af773eef3c238ed2bc0aa06", + "reference": "52690d4b37ae4f091af773eef3c238ed2bc0aa06", "shasum": "" }, "require": { "ext-json": "*", "nyholm/psr7-server": "^1.1", - "open-telemetry/api": "~1.4.0", + "open-telemetry/api": "^1.4", "open-telemetry/context": "^1.0", "open-telemetry/sem-conv": "^1.0", "php": "^8.1", @@ -762,20 +762,20 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-08-06T03:07:06+00:00" + "time": "2025-09-05T07:17:06+00:00" }, { "name": "open-telemetry/sem-conv", - "version": "1.36.0", + "version": "1.37.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sem-conv.git", - "reference": "60dd18fd21d45e6f4234ecab89c14021b6e3de9a" + "reference": "8da7ec497c881e39afa6657d72586e27efbd29a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/60dd18fd21d45e6f4234ecab89c14021b6e3de9a", - "reference": "60dd18fd21d45e6f4234ecab89c14021b6e3de9a", + "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/8da7ec497c881e39afa6657d72586e27efbd29a1", + "reference": "8da7ec497c881e39afa6657d72586e27efbd29a1", "shasum": "" }, "require": { @@ -819,7 +819,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-08-04T03:22:08+00:00" + "time": "2025-09-03T12:08:10+00:00" }, { "name": "php-http/discovery", @@ -1241,20 +1241,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.0", + "version": "4.9.1", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0" + "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/4e0e23cc785f0724a0e838279a9eb03f28b092a0", - "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13", + "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -1313,9 +1313,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.0" + "source": "https://github.com/ramsey/uuid/tree/4.9.1" }, - "time": "2025-06-25T14:20:11+00:00" + "time": "2025-09-04T20:59:21+00:00" }, { "name": "symfony/deprecation-contracts", @@ -1386,16 +1386,16 @@ }, { "name": "symfony/http-client", - "version": "v7.3.2", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "1c064a0c67749923483216b081066642751cc2c7" + "reference": "333b9bd7639cbdaecd25a3a48a9d2dcfaa86e019" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/1c064a0c67749923483216b081066642751cc2c7", - "reference": "1c064a0c67749923483216b081066642751cc2c7", + "url": "https://api.github.com/repos/symfony/http-client/zipball/333b9bd7639cbdaecd25a3a48a9d2dcfaa86e019", + "reference": "333b9bd7639cbdaecd25a3a48a9d2dcfaa86e019", "shasum": "" }, "require": { @@ -1403,6 +1403,7 @@ "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", "symfony/service-contracts": "^2.5|^3" }, "conflict": { @@ -1461,7 +1462,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.3.2" + "source": "https://github.com/symfony/http-client/tree/v7.3.3" }, "funding": [ { @@ -1481,7 +1482,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:36:08+00:00" + "time": "2025-08-27T07:45:05+00:00" }, { "name": "symfony/http-client-contracts", @@ -1726,6 +1727,86 @@ ], "time": "2024-09-09T11:45:10+00:00" }, + { + "name": "symfony/polyfill-php83", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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-07-08T02:45:35+00:00" + }, { "name": "symfony/polyfill-php85", "version": "v1.33.0", @@ -2041,16 +2122,16 @@ }, { "name": "utopia-php/framework", - "version": "0.33.22", + "version": "0.33.27", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "c01a815cb976c9255e045fc3bcc3f5fcf477e0bc" + "reference": "d9d10a895e85c8c7675220347cc6109db9d3bd37" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/c01a815cb976c9255e045fc3bcc3f5fcf477e0bc", - "reference": "c01a815cb976c9255e045fc3bcc3f5fcf477e0bc", + "url": "https://api.github.com/repos/utopia-php/http/zipball/d9d10a895e85c8c7675220347cc6109db9d3bd37", + "reference": "d9d10a895e85c8c7675220347cc6109db9d3bd37", "shasum": "" }, "require": { @@ -2082,22 +2163,22 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.33.22" + "source": "https://github.com/utopia-php/http/tree/0.33.27" }, - "time": "2025-08-26T10:29:50+00:00" + "time": "2025-09-07T18:40:53+00:00" }, { "name": "utopia-php/mongo", - "version": "dev-feat-create-UUID", + "version": "0.6.0", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "f25c14e4e3037093ad5679398da4805abb3dfec1" + "reference": "589e329a7fe4200e23ca87d65f3eb25a70ef0505" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/f25c14e4e3037093ad5679398da4805abb3dfec1", - "reference": "f25c14e4e3037093ad5679398da4805abb3dfec1", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/589e329a7fe4200e23ca87d65f3eb25a70ef0505", + "reference": "589e329a7fe4200e23ca87d65f3eb25a70ef0505", "shasum": "" }, "require": { @@ -2143,9 +2224,9 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/feat-create-UUID" + "source": "https://github.com/utopia-php/mongo/tree/0.6.0" }, - "time": "2025-08-18T14:00:43+00:00" + "time": "2025-09-11T13:26:21+00:00" }, { "name": "utopia-php/pools", @@ -3100,16 +3181,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.25", + "version": "9.6.26", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "049c011e01be805202d8eebedef49f769a8ec7b7" + "reference": "a0139ea157533454f611038326f3020b3051f129" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/049c011e01be805202d8eebedef49f769a8ec7b7", - "reference": "049c011e01be805202d8eebedef49f769a8ec7b7", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a0139ea157533454f611038326f3020b3051f129", + "reference": "a0139ea157533454f611038326f3020b3051f129", "shasum": "" }, "require": { @@ -3183,7 +3264,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.25" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.26" }, "funding": [ { @@ -3207,7 +3288,7 @@ "type": "tidelift" } ], - "time": "2025-08-20T14:38:31+00:00" + "time": "2025-09-11T06:17:45+00:00" }, { "name": "rregeer/phpunit-coverage-check", @@ -4390,18 +4471,9 @@ "time": "2022-10-09T10:19:07+00:00" } ], - "aliases": [ - { - "package": "utopia-php/mongo", - "version": "dev-feat-create-UUID", - "alias": "0.5.3", - "alias_normalized": "0.5.3.0" - } - ], + "aliases": [], "minimum-stability": "stable", - "stability-flags": { - "utopia-php/mongo": 20 - }, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index e6ec4adfc..de19db484 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -4,7 +4,12 @@ use Exception; use Utopia\Database\Exception as DatabaseException; +use Utopia\Database\Exception\Authorization as AuthorizationException; +use Utopia\Database\Exception\Conflict as ConflictException; use Utopia\Database\Exception\Duplicate as DuplicateException; +use Utopia\Database\Exception\Limit as LimitException; +use Utopia\Database\Exception\Relationship as RelationshipException; +use Utopia\Database\Exception\Restricted as RestrictedException; use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Transaction as TransactionException; @@ -371,7 +376,10 @@ public function inTransaction(): bool */ public function withTransaction(callable $callback): mixed { - for ($attempts = 0; $attempts < 3; $attempts++) { + $sleep = 50_000; // 50 milliseconds + $retries = 2; + + for ($attempts = 0; $attempts <= $retries; $attempts++) { try { $this->startTransaction(); $result = $callback(); @@ -380,9 +388,22 @@ public function withTransaction(callable $callback): mixed } catch (\Throwable $action) { try { $this->rollbackTransaction(); + + if ( + $action instanceof DuplicateException || + $action instanceof RestrictedException || + $action instanceof AuthorizationException || + $action instanceof RelationshipException || + $action instanceof ConflictException || + $action instanceof LimitException + ) { + $this->inTransaction = 0; + throw $action; + } + } catch (\Throwable $rollback) { - if ($attempts < 2) { - \usleep(5000); // 5ms + if ($attempts < $retries) { + \usleep($sleep * ($attempts + 1)); continue; } @@ -390,8 +411,8 @@ public function withTransaction(callable $callback): mixed throw $rollback; } - if ($attempts < 2) { - \usleep(5000); // 5ms + if ($attempts < $retries) { + \usleep($sleep * ($attempts + 1)); continue; } @@ -557,10 +578,11 @@ abstract public function createAttributes(string $collection, array $attributes) * @param bool $signed * @param bool $array * @param string|null $newKey + * @param bool $required * * @return bool */ - abstract public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null): bool; + abstract public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool; /** * Delete Attribute @@ -728,7 +750,7 @@ abstract public function updateDocuments(Document $collection, Document $updates * @param array $changes * @return array */ - abstract public function createOrUpdateDocuments( + abstract public function upsertDocuments( Document $collection, string $attribute, array $changes @@ -1049,6 +1071,13 @@ abstract public function getSupportForSpatialIndexNull(): bool; */ abstract public function getSupportForSpatialIndexOrder(): bool; + /** + * Does the adapter support spatial axis order specification? + * + * @return bool + */ + abstract public function getSupportForSpatialAxisOrder(): bool; + /** * Does the adapter includes boundary during spatial contains? * @@ -1056,6 +1085,13 @@ abstract public function getSupportForSpatialIndexOrder(): bool; */ abstract public function getSupportForBoundaryInclusiveContains(): bool; + /** + * Does the adapter support calculating distance(in meters) between multidimension geometry(line, polygon,etc)? + * + * @return bool + */ + abstract public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool; + /** * Get current attribute count from collection document * diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 1badc3966..c78d6637c 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -3,18 +3,17 @@ namespace Utopia\Database\Adapter; use Exception; -use PDO; use PDOException; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\NotFound as NotFoundException; +use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Truncate as TruncateException; use Utopia\Database\Helpers\ID; use Utopia\Database\Query; -use Utopia\Database\Validator\Authorization; class MariaDB extends SQL { @@ -411,16 +410,16 @@ public function getSchemaAttributes(string $collection): array * @param bool $signed * @param bool $array * @param string|null $newKey + * @param bool $required * @return bool * @throws DatabaseException */ - public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null): bool + public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool { $name = $this->filter($collection); $id = $this->filter($id); $newKey = empty($newKey) ? null : $this->filter($newKey); - $type = $this->getSQLType($type, $size, $signed, $array, false); - + $type = $this->getSQLType($type, $size, $signed, $array, $required); if (!empty($newKey)) { $sql = "ALTER TABLE {$this->getSQLTable($name)} CHANGE COLUMN `{$id}` `{$newKey}` {$type};"; } else { @@ -847,7 +846,7 @@ public function createDocument(Document $collection, Document $document): Docume $bindKey = 'key_' . $bindIndex; $columns .= "`{$column}`, "; if (in_array($attribute, $spatialAttributes)) { - $columnNames .= 'ST_GeomFromText(:' . $bindKey . '), '; + $columnNames .= $this->getSpatialGeomFromText(':' . $bindKey) . ", "; } else { $columnNames .= ':' . $bindKey . ', '; } @@ -1117,7 +1116,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $bindKey = 'key_' . $bindIndex; if (in_array($attribute, $spatialAttributes)) { - $columns .= "`{$column}`" . '=ST_GeomFromText(:' . $bindKey . '),'; + $columns .= "`{$column}`" . '=' . $this->getSpatialGeomFromText(':' . $bindKey) . ','; } else { $columns .= "`{$column}`" . '=:' . $bindKey . ','; } @@ -1355,343 +1354,51 @@ public function deleteDocument(string $collection, string $id): bool } /** - * Find Documents + * Handle distance spatial 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 DatabaseException - * @throws TimeoutException - * @throws Exception - */ - public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array - { - $spatialAttributes = $this->getSpatialAttributes($collection); - $attributes = $collection->getAttribute('attributes', []); - - $collection = $collection->getId(); - $name = $this->filter($collection); - $roles = Authorization::getRoles(); - $where = []; - $orders = []; - $alias = Query::DEFAULT_ALIAS; - $binds = []; - - $queries = array_map(fn ($query) => clone $query, $queries); - - $cursorWhere = []; - - foreach ($orderAttributes as $i => $originalAttribute) { - $attribute = $this->getInternalKeyForAttribute($originalAttribute); - $attribute = $this->filter($attribute); - - $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); - $direction = $orderType; - - if ($cursorDirection === Database::CURSOR_BEFORE) { - $direction = ($direction === Database::ORDER_ASC) - ? Database::ORDER_DESC - : Database::ORDER_ASC; - } - - $orders[] = "{$this->quote($attribute)} {$direction}"; - - // Build pagination WHERE clause only if we have a cursor - if (!empty($cursor)) { - // Special case: No tie breaks. only 1 attribute and it's a unique primary key - if (count($orderAttributes) === 1 && $i === 0 && $originalAttribute === '$sequence') { - $operator = ($direction === Database::ORDER_DESC) - ? Query::TYPE_LESSER - : Query::TYPE_GREATER; - - $bindName = ":cursor_pk"; - $binds[$bindName] = $cursor[$originalAttribute]; - - $cursorWhere[] = "{$this->quote($alias)}.{$this->quote($attribute)} {$this->getSQLOperator($operator)} {$bindName}"; - break; - } - - $conditions = []; - - // Add equality conditions for previous attributes - for ($j = 0; $j < $i; $j++) { - $prevOriginal = $orderAttributes[$j]; - $prevAttr = $this->filter($this->getInternalKeyForAttribute($prevOriginal)); - - $bindName = ":cursor_{$j}"; - $binds[$bindName] = $cursor[$prevOriginal]; - - $conditions[] = "{$this->quote($alias)}.{$this->quote($prevAttr)} = {$bindName}"; - } - - // Add comparison for current attribute - $operator = ($direction === Database::ORDER_DESC) - ? Query::TYPE_LESSER - : Query::TYPE_GREATER; - - $bindName = ":cursor_{$i}"; - $binds[$bindName] = $cursor[$originalAttribute]; - - $conditions[] = "{$this->quote($alias)}.{$this->quote($attribute)} {$this->getSQLOperator($operator)} {$bindName}"; - - $cursorWhere[] = '(' . implode(' AND ', $conditions) . ')'; - } - } - - if (!empty($cursorWhere)) { - $where[] = '(' . implode(' OR ', $cursorWhere) . ')'; - } - - $conditions = $this->getSQLConditions($queries, $binds, attributes:$attributes); - if (!empty($conditions)) { - $where[] = $conditions; - } - - if (Authorization::$status) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias, $forPermission); - } - - if ($this->sharedTables) { - $binds[':_tenant'] = $this->tenant; - $where[] = "{$this->getTenantQuery($collection, $alias, condition: '')}"; - } - - $sqlWhere = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; - $sqlOrder = 'ORDER BY ' . implode(', ', $orders); - - $sqlLimit = ''; - if (! \is_null($limit)) { - $binds[':limit'] = $limit; - $sqlLimit = 'LIMIT :limit'; - } - - if (! \is_null($offset)) { - $binds[':offset'] = $offset; - $sqlLimit .= ' OFFSET :offset'; - } - - $selections = $this->getAttributeSelections($queries); - - - $sql = " - SELECT {$this->getAttributeProjection($selections, $alias, $spatialAttributes)} - FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} - {$sqlWhere} - {$sqlOrder} - {$sqlLimit}; - "; - - $sql = $this->trigger(Database::EVENT_DOCUMENT_FIND, $sql); - - try { - $stmt = $this->getPDO()->prepare($sql); - - foreach ($binds as $key => $value) { - if (gettype($value) === 'double') { - $stmt->bindValue($key, $this->getFloatPrecision($value), PDO::PARAM_STR); - } else { - $stmt->bindValue($key, $value, $this->getPDOType($value)); - } - } - - $stmt->execute(); - } catch (PDOException $e) { - throw $this->processException($e); - } - - $results = $stmt->fetchAll(); - $stmt->closeCursor(); - - foreach ($results as $index => $document) { - if (\array_key_exists('_uid', $document)) { - $results[$index]['$id'] = $document['_uid']; - unset($results[$index]['_uid']); - } - if (\array_key_exists('_id', $document)) { - $results[$index]['$sequence'] = $document['_id']; - unset($results[$index]['_id']); - } - if (\array_key_exists('_tenant', $document)) { - $results[$index]['$tenant'] = $document['_tenant']; - unset($results[$index]['_tenant']); - } - if (\array_key_exists('_createdAt', $document)) { - $results[$index]['$createdAt'] = $document['_createdAt']; - unset($results[$index]['_createdAt']); - } - if (\array_key_exists('_updatedAt', $document)) { - $results[$index]['$updatedAt'] = $document['_updatedAt']; - unset($results[$index]['_updatedAt']); - } - if (\array_key_exists('_permissions', $document)) { - $results[$index]['$permissions'] = \json_decode($document['_permissions'] ?? '[]', true); - unset($results[$index]['_permissions']); - } - - $results[$index] = new Document($results[$index]); - } - - if ($cursorDirection === Database::CURSOR_BEFORE) { - $results = \array_reverse($results); - } - - return $results; - } - - /** - * Count Documents - * - * @param Document $collection - * @param array $queries - * @param int|null $max - * @return int - * @throws Exception - * @throws PDOException - */ - 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 = []; - $where = []; - $alias = Query::DEFAULT_ALIAS; - - $limit = ''; - if (! \is_null($max)) { - $binds[':limit'] = $max; - $limit = 'LIMIT :limit'; - } - - $queries = array_map(fn ($query) => clone $query, $queries); - - $conditions = $this->getSQLConditions($queries, $binds, attributes:$attributes); - if (!empty($conditions)) { - $where[] = $conditions; - } - - if (Authorization::$status) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias); - } - - if ($this->sharedTables) { - $binds[':_tenant'] = $this->tenant; - $where[] = "{$this->getTenantQuery($collection, $alias, condition: '')}"; - } - - $sqlWhere = !empty($where) - ? 'WHERE ' . \implode(' AND ', $where) - : ''; - - $sql = " - SELECT COUNT(1) as sum FROM ( - SELECT 1 - FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} - {$sqlWhere} - {$limit} - ) table_count - "; - - $sql = $this->trigger(Database::EVENT_DOCUMENT_COUNT, $sql); - - $stmt = $this->getPDO()->prepare($sql); - - foreach ($binds as $key => $value) { - $stmt->bindValue($key, $value, $this->getPDOType($value)); - } - - $stmt->execute(); - - $result = $stmt->fetchAll(); - $stmt->closeCursor(); - if (!empty($result)) { - $result = $result[0]; - } - - return $result['sum'] ?? 0; - } - - /** - * Sum an Attribute - * - * @param Document $collection + * @param Query $query + * @param array $binds * @param string $attribute - * @param array $queries - * @param int|null $max - * @return int|float - * @throws Exception - * @throws PDOException - */ - public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): int|float + * @param string $type + * @param string $alias + * @param string $placeholder + * @return string + */ + protected function handleDistanceSpatialQueries(Query $query, array &$binds, string $attribute, string $type, string $alias, string $placeholder): string { - $collectionAttributes = $collection->getAttribute("attributes", []); - $collection = $collection->getId(); - $name = $this->filter($collection); - $roles = Authorization::getRoles(); - $where = []; - $alias = Query::DEFAULT_ALIAS; - $binds = []; - - $limit = ''; - if (! \is_null($max)) { - $binds[':limit'] = $max; - $limit = 'LIMIT :limit'; - } - - $queries = array_map(fn ($query) => clone $query, $queries); + $distanceParams = $query->getValues()[0]; + $wkt = $this->convertArrayToWKT($distanceParams[0]); + $binds[":{$placeholder}_0"] = $wkt; + $binds[":{$placeholder}_1"] = $distanceParams[1]; - $conditions = $this->getSQLConditions($queries, $binds, attributes:$collectionAttributes); - if (!empty($conditions)) { - $where[] = $conditions; - } - - if (Authorization::$status) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias); - } + $useMeters = isset($distanceParams[2]) && $distanceParams[2] === true; - if ($this->sharedTables) { - $binds[':_tenant'] = $this->tenant; - $where[] = "{$this->getTenantQuery($collection, $alias, condition: '')}"; - } - - $sqlWhere = !empty($where) - ? 'WHERE ' . \implode(' AND ', $where) - : ''; - - $sql = " - SELECT SUM({$this->quote($attribute)}) as sum FROM ( - SELECT {$this->quote($attribute)} - FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} - {$sqlWhere} - {$limit} - ) table_count - "; - - $sql = $this->trigger(Database::EVENT_DOCUMENT_SUM, $sql); - - $stmt = $this->getPDO()->prepare($sql); - - foreach ($binds as $key => $value) { - $stmt->bindValue($key, $value, $this->getPDOType($value)); + switch ($query->getMethod()) { + case Query::TYPE_DISTANCE_EQUAL: + $operator = '='; + break; + case Query::TYPE_DISTANCE_NOT_EQUAL: + $operator = '!='; + break; + case Query::TYPE_DISTANCE_GREATER_THAN: + $operator = '>'; + break; + case Query::TYPE_DISTANCE_LESS_THAN: + $operator = '<'; + break; + default: + throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); } - $stmt->execute(); - - $result = $stmt->fetchAll(); - $stmt->closeCursor(); - if (!empty($result)) { - $result = $result[0]; + if ($useMeters) { + $wktType = $this->getSpatialTypeFromWKT($wkt); + $attrType = strtolower($type); + if ($wktType != Database::VAR_POINT || $attrType != Database::VAR_POINT) { + throw new QueryException('Distance in meters is not supported between '.$attrType . ' and '. $wktType); + } + return "ST_DISTANCE_SPHERE({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ", " . Database::EARTH_RADIUS . ") {$operator} :{$placeholder}_1"; } - - return $result['sum'] ?? 0; + return "ST_Distance({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ") {$operator} :{$placeholder}_1"; } /** @@ -1700,84 +1407,67 @@ public function sum(Document $collection, string $attribute, array $queries = [] * @param Query $query * @param array $binds * @param string $attribute + * @param string $type * @param string $alias * @param string $placeholder * @return string */ - protected function handleSpatialQueries(Query $query, array &$binds, string $attribute, string $alias, string $placeholder): string + protected function handleSpatialQueries(Query $query, array &$binds, string $attribute, string $type, 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))"; + return "ST_Crosses({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; case Query::TYPE_NOT_CROSSES: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Crosses({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "NOT ST_Crosses({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; 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"; + return $this->handleDistanceSpatialQueries($query, $binds, $attribute, $type, $alias, $placeholder); case Query::TYPE_INTERSECTS: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Intersects({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "ST_Intersects({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; case Query::TYPE_NOT_INTERSECTS: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Intersects({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "NOT ST_Intersects({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; case Query::TYPE_OVERLAPS: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Overlaps({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "ST_Overlaps({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; case Query::TYPE_NOT_OVERLAPS: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Overlaps({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "NOT ST_Overlaps({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; case Query::TYPE_TOUCHES: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Touches({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "ST_Touches({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; case Query::TYPE_NOT_TOUCHES: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Touches({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "NOT ST_Touches({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; case Query::TYPE_EQUAL: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Equals({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "ST_Equals({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; case Query::TYPE_NOT_EQUAL: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Equals({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "NOT ST_Equals({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; case Query::TYPE_CONTAINS: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Contains({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "ST_Contains({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; case Query::TYPE_NOT_CONTAINS: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Contains({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "NOT ST_Contains({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; default: throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); @@ -1806,7 +1496,7 @@ protected function getSQLCondition(Query $query, array &$binds, array $attribute $attributeType = $this->getAttributeType($query->getAttribute(), $attributes); if (in_array($attributeType, Database::SPATIAL_TYPES)) { - return $this->handleSpatialQueries($query, $binds, $attribute, $alias, $placeholder); + return $this->handleSpatialQueries($query, $binds, $attribute, $attributeType, $alias, $placeholder); } switch ($query->getMethod()) { @@ -1903,6 +1593,9 @@ protected function getSQLCondition(Query $query, array &$binds, array $attribute */ protected function getSQLType(string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): string { + if (in_array($type, Database::SPATIAL_TYPES)) { + return $this->getSpatialSQLType($type, $required); + } if ($array === true) { return 'JSON'; } @@ -1949,16 +1642,6 @@ 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 . ', ' . ', ' . Database::VAR_POINT . ', ' . Database::VAR_LINESTRING . ', ' . Database::VAR_POLYGON); } @@ -1974,9 +1657,9 @@ protected function getSQLType(string $type, int $size, bool $signed = true, bool protected function getPDOType(mixed $value): int { return match (gettype($value)) { - 'string','double' => PDO::PARAM_STR, - 'integer', 'boolean' => PDO::PARAM_INT, - 'NULL' => PDO::PARAM_NULL, + 'string','double' => \PDO::PARAM_STR, + 'integer', 'boolean' => \PDO::PARAM_INT, + 'NULL' => \PDO::PARAM_NULL, default => throw new DatabaseException('Unknown PDO Type for ' . \gettype($value)), }; } @@ -2187,4 +1870,51 @@ public function getSupportForSpatialIndexOrder(): bool { return true; } + + /** + * Does the adapter support calculating distance(in meters) between multidimension geometry(line, polygon,etc)? + * + * @return bool + */ + public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool + { + return false; + } + + public function getSpatialSQLType(string $type, bool $required): string + { + $srid = Database::SRID; + $nullability = ''; + + if (!$this->getSupportForSpatialIndexNull()) { + if ($required) { + $nullability = ' NOT NULL'; + } else { + $nullability = ' NULL'; + } + } + + switch ($type) { + case Database::VAR_POINT: + return "POINT($srid)$nullability"; + + case Database::VAR_LINESTRING: + return "LINESTRING($srid)$nullability"; + + case Database::VAR_POLYGON: + return "POLYGON($srid)$nullability"; + } + + return ''; + } + + /** + * Does the adapter support spatial axis order specification? + * + * @return bool + */ + public function getSupportForSpatialAxisOrder(): bool + { + return false; + } } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index dc3c67b69..dcb920144 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1157,7 +1157,7 @@ public function updateDocuments(Document $collection, Document $updates, array $ * @param array $changes * @return array */ - public function createOrUpdateDocuments(Document $collection, string $attribute, array $changes): array + public function upsertDocuments(Document $collection, string $attribute, array $changes): array { if (empty($changes)) { return $changes; @@ -1416,7 +1416,7 @@ public function deleteDocuments(string $collection, array $sequences, array $per * * @return bool */ - public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null): bool + public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool { if (!empty($newKey) && $newKey !== $id) { return $this->renameAttribute($collection, $id, $newKey); @@ -2450,6 +2450,27 @@ public function getSupportForSpatialIndexOrder(): bool } + /** + * Does the adapter support spatial axis order specification? + * + * @return bool + */ + public function getSupportForSpatialAxisOrder(): bool + { + return false; + } + + /** + * Does the adapter support calculating distance(in meters) between multidimension geometry(line, polygon,etc)? + * + * @return bool + */ + public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool + { + return false; + } + + /** * Flattens the array. * diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index be0cd79d3..bd3da3d0b 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -7,6 +7,7 @@ use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Dependency as DependencyException; use Utopia\Database\Exception\Timeout as TimeoutException; +use Utopia\Database\Query; class MySQL extends MariaDB { @@ -78,6 +79,53 @@ public function getSizeOfCollectionOnDisk(string $collection): int return $size; } + /** + * Handle distance spatial queries + * + * @param Query $query + * @param array $binds + * @param string $attribute + * @param string $type + * @param string $alias + * @param string $placeholder + * @return string + */ + protected function handleDistanceSpatialQueries(Query $query, array &$binds, string $attribute, string $type, string $alias, string $placeholder): string + { + $distanceParams = $query->getValues()[0]; + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); + $binds[":{$placeholder}_1"] = $distanceParams[1]; + + $useMeters = isset($distanceParams[2]) && $distanceParams[2] === true; + + switch ($query->getMethod()) { + case Query::TYPE_DISTANCE_EQUAL: + $operator = '='; + break; + case Query::TYPE_DISTANCE_NOT_EQUAL: + $operator = '!='; + break; + case Query::TYPE_DISTANCE_GREATER_THAN: + $operator = '>'; + break; + case Query::TYPE_DISTANCE_LESS_THAN: + $operator = '<'; + break; + default: + throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); + } + + if ($useMeters) { + $attr = "ST_SRID({$alias}.{$attribute}, " . Database::SRID . ")"; + $geom = $this->getSpatialGeomFromText(":{$placeholder}_0", null); + return "ST_Distance({$attr}, {$geom}, 'metre') {$operator} :{$placeholder}_1"; + } + // need to use srid 0 because of geometric distance + $attr = "ST_SRID({$alias}.{$attribute}, " . 0 . ")"; + $geom = $this->getSpatialGeomFromText(":{$placeholder}_0", 0); + return "ST_Distance({$attr}, {$geom}) {$operator} :{$placeholder}_1"; + } + public function getSupportForIndexArray(): bool { /** @@ -127,4 +175,78 @@ public function getSupportForSpatialIndexOrder(): bool { return false; } + + /** + * Does the adapter support calculating distance(in meters) between multidimension geometry(line, polygon,etc)? + * + * @return bool + */ + public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool + { + return true; + } + + /** + * Spatial type attribute + */ + public function getSpatialSQLType(string $type, bool $required): string + { + switch ($type) { + case Database::VAR_POINT: + $type = 'POINT SRID 4326'; + if (!$this->getSupportForSpatialIndexNull()) { + if ($required) { + $type .= ' NOT NULL'; + } else { + $type .= ' NULL'; + } + } + return $type; + + case Database::VAR_LINESTRING: + $type = 'LINESTRING SRID 4326'; + if (!$this->getSupportForSpatialIndexNull()) { + if ($required) { + $type .= ' NOT NULL'; + } else { + $type .= ' NULL'; + } + } + return $type; + + + case Database::VAR_POLYGON: + $type = 'POLYGON SRID 4326'; + if (!$this->getSupportForSpatialIndexNull()) { + if ($required) { + $type .= ' NOT NULL'; + } else { + $type .= ' NULL'; + } + } + return $type; + } + return ''; + } + + /** + * Does the adapter support spatial axis order specification? + * + * @return bool + */ + public function getSupportForSpatialAxisOrder(): bool + { + return true; + } + + /** + * Get the spatial axis order specification string for MySQL + * MySQL with SRID 4326 expects lat-long by default, but our data is in long-lat format + * + * @return string + */ + protected function getSpatialAxisOrderSpec(): string + { + return "'axis-order=long-lat'"; + } } diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 21190d11d..c8d909aca 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -175,7 +175,7 @@ public function createAttributes(string $collection, array $attributes): bool return $this->delegate(__FUNCTION__, \func_get_args()); } - public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null): bool + public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool { return $this->delegate(__FUNCTION__, \func_get_args()); } @@ -245,7 +245,7 @@ public function updateDocuments(Document $collection, Document $updates, array $ return $this->delegate(__FUNCTION__, \func_get_args()); } - public function createOrUpdateDocuments(Document $collection, string $attribute, array $changes): array + public function upsertDocuments(Document $collection, string $attribute, array $changes): array { return $this->delegate(__FUNCTION__, \func_get_args()); } @@ -530,6 +530,25 @@ public function getSupportForSpatialIndexOrder(): bool { return $this->delegate(__FUNCTION__, \func_get_args()); } + /** + * Does the adapter support calculating distance(in meters) between multidimension geometry(line, polygon,etc)? + * + * @return bool + */ + public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + /** + * Does the adapter support spatial axis order specification? + * + * @return bool + */ + public function getSupportForSpatialAxisOrder(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } public function castingBefore(Document $collection, Document $document): Document { diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 13583bb1b..f666d1184 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -15,7 +15,6 @@ use Utopia\Database\Exception\Truncate as TruncateException; use Utopia\Database\Helpers\ID; use Utopia\Database\Query; -use Utopia\Database\Validator\Authorization; class Postgres extends SQL { @@ -536,16 +535,17 @@ public function renameAttribute(string $collection, string $old, string $new): b * @param bool $signed * @param bool $array * @param string|null $newKey + * @param bool $required * @return bool * @throws Exception * @throws PDOException */ - public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null): bool + public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool { $name = $this->filter($collection); $id = $this->filter($id); $newKey = empty($newKey) ? null : $this->filter($newKey); - $type = $this->getSQLType($type, $size, $signed, $array, false); + $type = $this->getSQLType($type, $size, $signed, $array, $required); if ($type == 'TIMESTAMP(3)') { $type = "TIMESTAMP(3) without time zone USING TO_TIMESTAMP(\"$id\", 'YYYY-MM-DD HH24:MI:SS.MS')"; @@ -1445,350 +1445,59 @@ public function deleteDocument(string $collection, string $id): bool } /** - * Find Documents - * - * @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 DatabaseException - * @throws TimeoutException - * @throws Exception - */ - public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array - { - $spatialAttributes = $this->getSpatialAttributes($collection); - $attributes = $collection->getAttribute('attributes', []); - $collection = $collection->getId(); - $name = $this->filter($collection); - $roles = Authorization::getRoles(); - $where = []; - $orders = []; - $alias = Query::DEFAULT_ALIAS; - $binds = []; - - $queries = array_map(fn ($query) => clone $query, $queries); - - $cursorWhere = []; - - foreach ($orderAttributes as $i => $originalAttribute) { - $attribute = $this->getInternalKeyForAttribute($originalAttribute); - $attribute = $this->filter($attribute); - - $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); - $direction = $orderType; - - if ($cursorDirection === Database::CURSOR_BEFORE) { - $direction = ($direction === Database::ORDER_ASC) - ? Database::ORDER_DESC - : Database::ORDER_ASC; - } - - $orders[] = "{$this->quote($attribute)} {$direction}"; - - // Build pagination WHERE clause only if we have a cursor - if (!empty($cursor)) { - // Special case: only 1 attribute and it's a unique primary key - if (count($orderAttributes) === 1 && $i === 0 && $originalAttribute === '$sequence') { - $operator = ($direction === Database::ORDER_DESC) - ? Query::TYPE_LESSER - : Query::TYPE_GREATER; - - $bindName = ":cursor_pk"; - $binds[$bindName] = $cursor[$originalAttribute]; - - $cursorWhere[] = "{$this->quote($alias)}.{$this->quote($attribute)} {$this->getSQLOperator($operator)} {$bindName}"; - break; - } - - $conditions = []; - - // Add equality conditions for previous attributes - for ($j = 0; $j < $i; $j++) { - $prevOriginal = $orderAttributes[$j]; - $prevAttr = $this->filter($this->getInternalKeyForAttribute($prevOriginal)); - - $bindName = ":cursor_{$j}"; - $binds[$bindName] = $cursor[$prevOriginal]; - - $conditions[] = "{$this->quote($alias)}.{$this->quote($prevAttr)} = {$bindName}"; - } - - // Add comparison for current attribute - $operator = ($direction === Database::ORDER_DESC) - ? Query::TYPE_LESSER - : Query::TYPE_GREATER; - - $bindName = ":cursor_{$i}"; - $binds[$bindName] = $cursor[$originalAttribute]; - - $conditions[] = "{$this->quote($alias)}.{$this->quote($attribute)} {$this->getSQLOperator($operator)} {$bindName}"; - - $cursorWhere[] = '(' . implode(' AND ', $conditions) . ')'; - } - } - - if (!empty($cursorWhere)) { - $where[] = '(' . implode(' OR ', $cursorWhere) . ')'; - } - - $conditions = $this->getSQLConditions($queries, $binds, attributes:$attributes); - if (!empty($conditions)) { - $where[] = $conditions; - } - - if (Authorization::$status) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias, $forPermission); - } - - if ($this->sharedTables) { - $binds[':_tenant'] = $this->tenant; - $where[] = "{$this->getTenantQuery($collection, $alias, condition: '')}"; - } - - $sqlWhere = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; - $sqlOrder = 'ORDER BY ' . implode(', ', $orders); - - $sqlLimit = ''; - if (! \is_null($limit)) { - $binds[':limit'] = $limit; - $sqlLimit = 'LIMIT :limit'; - } - - if (! \is_null($offset)) { - $binds[':offset'] = $offset; - $sqlLimit .= ' OFFSET :offset'; - } - - $selections = $this->getAttributeSelections($queries); - - $sql = " - SELECT {$this->getAttributeProjection($selections, $alias, $spatialAttributes)} - FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} - {$sqlWhere} - {$sqlOrder} - {$sqlLimit}; - "; - - $sql = $this->trigger(Database::EVENT_DOCUMENT_FIND, $sql); - - try { - $stmt = $this->getPDO()->prepare($sql); - - foreach ($binds as $key => $value) { - if (gettype($value) === 'double') { - $stmt->bindValue($key, $this->getFloatPrecision($value), PDO::PARAM_STR); - } else { - $stmt->bindValue($key, $value, $this->getPDOType($value)); - } - } - $this->execute($stmt); - } catch (PDOException $e) { - throw $this->processException($e); - } - - $results = $stmt->fetchAll(); - $stmt->closeCursor(); - - foreach ($results as $index => $document) { - if (\array_key_exists('_uid', $document)) { - $results[$index]['$id'] = $document['_uid']; - unset($results[$index]['_uid']); - } - if (\array_key_exists('_id', $document)) { - $results[$index]['$sequence'] = $document['_id']; - unset($results[$index]['_id']); - } - if (\array_key_exists('_tenant', $document)) { - $results[$index]['$tenant'] = $document['_tenant']; - unset($results[$index]['_tenant']); - } - if (\array_key_exists('_createdAt', $document)) { - $results[$index]['$createdAt'] = $document['_createdAt']; - unset($results[$index]['_createdAt']); - } - if (\array_key_exists('_updatedAt', $document)) { - $results[$index]['$updatedAt'] = $document['_updatedAt']; - unset($results[$index]['_updatedAt']); - } - if (\array_key_exists('_permissions', $document)) { - $results[$index]['$permissions'] = \json_decode($document['_permissions'] ?? '[]', true); - unset($results[$index]['_permissions']); - } - - $results[$index] = new Document($results[$index]); - } - - if ($cursorDirection === Database::CURSOR_BEFORE) { - $results = \array_reverse($results); - } - - return $results; - } - - /** - * Count Documents - * @param Document $collection - * @param array $queries - * @param int|null $max - * @return int - * @throws Exception - * @throws PDOException + * @return string */ - public function count(Document $collection, array $queries = [], ?int $max = null): int + public function getConnectionId(): string { - $collectionAttributes = $collection->getAttribute("attributes", []); - $collection = $collection->getId(); - $name = $this->filter($collection); - $roles = Authorization::getRoles(); - $binds = []; - $where = []; - $alias = Query::DEFAULT_ALIAS; - - $limit = ''; - if (! \is_null($max)) { - $binds[':limit'] = $max; - $limit = 'LIMIT :limit'; - } - - $queries = array_map(fn ($query) => clone $query, $queries); - - $conditions = $this->getSQLConditions($queries, $binds, attributes:$collectionAttributes); - if (!empty($conditions)) { - $where[] = $conditions; - } - - if (Authorization::$status) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias); - } - - if ($this->sharedTables) { - $binds[':_tenant'] = $this->tenant; - $where[] = "{$this->getTenantQuery($collection, $alias, condition: '')}"; - } - - $sqlWhere = !empty($where) - ? 'WHERE ' . \implode(' AND ', $where) - : ''; - - $sql = " - SELECT COUNT(1) as sum FROM ( - SELECT 1 - FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} - {$sqlWhere} - {$limit} - ) table_count - "; - - - $sql = $this->trigger(Database::EVENT_DOCUMENT_COUNT, $sql); - - $stmt = $this->getPDO()->prepare($sql); - - foreach ($binds as $key => $value) { - $stmt->bindValue($key, $value, $this->getPDOType($value)); - } - - $this->execute($stmt); - - $result = $stmt->fetchAll(); - $stmt->closeCursor(); - if (!empty($result)) { - $result = $result[0]; - } - - return $result['sum'] ?? 0; + $stmt = $this->getPDO()->query("SELECT pg_backend_pid();"); + return $stmt->fetchColumn(); } /** - * Sum an Attribute + * Handle distance spatial queries * - * @param Document $collection + * @param Query $query + * @param array $binds * @param string $attribute - * @param array $queries - * @param int|null $max - * @return int|float - * @throws Exception - * @throws PDOException - */ - public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): int|float + * @param string $alias + * @param string $placeholder + * @return string + */ + protected function handleDistanceSpatialQueries(Query $query, array &$binds, string $attribute, string $alias, string $placeholder): string { - $collectionAttributes = $collection->getAttribute("attributes", []); - $collection = $collection->getId(); - $name = $this->filter($collection); - $roles = Authorization::getRoles(); - $where = []; - $alias = Query::DEFAULT_ALIAS; - $binds = []; - - $limit = ''; - if (! \is_null($max)) { - $binds[':limit'] = $max; - $limit = 'LIMIT :limit'; - } - - $queries = array_map(fn ($query) => clone $query, $queries); - - $conditions = $this->getSQLConditions($queries, $binds, attributes:$collectionAttributes); - if (!empty($conditions)) { - $where[] = $conditions; - } - - if (Authorization::$status) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias); - } - - if ($this->sharedTables) { - $binds[':_tenant'] = $this->tenant; - $where[] = "{$this->getTenantQuery($collection, $alias, condition: '')}"; - } - - $sqlWhere = !empty($where) - ? 'WHERE ' . \implode(' AND ', $where) - : ''; - - $sql = " - SELECT SUM({$this->quote($attribute)}) as sum FROM ( - SELECT {$this->quote($attribute)} - FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} - {$sqlWhere} - {$limit} - ) table_count - "; - - $sql = $this->trigger(Database::EVENT_DOCUMENT_SUM, $sql); + $distanceParams = $query->getValues()[0]; + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); + $binds[":{$placeholder}_1"] = $distanceParams[1]; - $stmt = $this->getPDO()->prepare($sql); + $meters = isset($distanceParams[2]) && $distanceParams[2] === true; - foreach ($binds as $key => $value) { - $stmt->bindValue($key, $value, $this->getPDOType($value)); + switch ($query->getMethod()) { + case Query::TYPE_DISTANCE_EQUAL: + $operator = '='; + break; + case Query::TYPE_DISTANCE_NOT_EQUAL: + $operator = '!='; + break; + case Query::TYPE_DISTANCE_GREATER_THAN: + $operator = '>'; + break; + case Query::TYPE_DISTANCE_LESS_THAN: + $operator = '<'; + break; + default: + throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); } - $this->execute($stmt); - - $result = $stmt->fetchAll(); - $stmt->closeCursor(); - if (!empty($result)) { - $result = $result[0]; + if ($meters) { + $attr = "({$alias}.{$attribute}::geography)"; + $geom = "ST_SetSRID(" . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ", " . Database::SRID . ")::geography"; + return "ST_Distance({$attr}, {$geom}) {$operator} :{$placeholder}_1"; } - return $result['sum'] ?? 0; + // Without meters, use the original SRID (e.g., 4326) + return "ST_Distance({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ") {$operator} :{$placeholder}_1"; } - /** - * @return string - */ - public function getConnectionId(): string - { - $stmt = $this->getPDO()->query("SELECT pg_backend_pid();"); - return $stmt->fetchColumn(); - } /** * Handle spatial queries @@ -1805,67 +1514,48 @@ protected function handleSpatialQueries(Query $query, array &$binds, string $att switch ($query->getMethod()) { case Query::TYPE_CROSSES: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Crosses({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "ST_Crosses({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$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))"; + return "NOT ST_Crosses({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$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"; - + return $this->handleDistanceSpatialQueries($query, $binds, $attribute, $alias, $placeholder); case Query::TYPE_EQUAL: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Equals({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "ST_Equals({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$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))"; + return "NOT ST_Equals({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ")"; case Query::TYPE_INTERSECTS: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Intersects({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "ST_Intersects({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$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))"; + return "NOT ST_Intersects({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ")"; case Query::TYPE_OVERLAPS: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Overlaps({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "ST_Overlaps({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$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))"; + return "NOT ST_Overlaps({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ")"; case Query::TYPE_TOUCHES: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Touches({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "ST_Touches({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$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))"; + return "NOT ST_Touches({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ")"; case Query::TYPE_CONTAINS: case Query::TYPE_NOT_CONTAINS: @@ -1874,8 +1564,8 @@ protected function handleSpatialQueries(Query $query, array &$binds, string $att $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))"; + ? "NOT ST_Covers({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ")" + : "ST_Covers({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ")"; default: throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); @@ -2054,15 +1744,15 @@ protected function getSQLType(string $type, int $size, bool $signed = true, bool case Database::VAR_DATETIME: return 'TIMESTAMP(3)'; - + // in all other DB engines, 4326 is the default SRID case Database::VAR_POINT: - return 'GEOMETRY(POINT)'; + return 'GEOMETRY(POINT,' . Database::SRID . ')'; case Database::VAR_LINESTRING: - return 'GEOMETRY(LINESTRING)'; + return 'GEOMETRY(LINESTRING,' . Database::SRID . ')'; case Database::VAR_POLYGON: - return 'GEOMETRY(POLYGON)'; + return 'GEOMETRY(POLYGON,' . Database::SRID . ')'; 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 . ', ' . Database::VAR_POINT . ', ' . Database::VAR_LINESTRING . ', ' . Database::VAR_POLYGON); @@ -2292,4 +1982,24 @@ public function getSupportForSpatialIndexOrder(): bool { return false; } + + /** + * Does the adapter support calculating distance(in meters) between multidimension geometry(line, polygon,etc)? + * + * @return bool + */ + public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool + { + return true; + } + + /** + * Does the adapter support spatial axis order specification? + * + * @return bool + */ + public function getSupportForSpatialAxisOrder(): bool + { + return false; + } } diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 3e81fc704..363107aa0 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -11,9 +11,10 @@ use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\NotFound as NotFoundException; +use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Transaction as TransactionException; use Utopia\Database\Query; -use Utopia\Database\Validator\Spatial; +use Utopia\Database\Validator\Authorization; abstract class SQL extends Adapter { @@ -66,19 +67,17 @@ public function startTransaction(): bool $this->getPDO()->prepare('ROLLBACK')->execute(); } - $result = $this->getPDO()->beginTransaction(); + $this->getPDO()->beginTransaction(); + } else { - $result = $this->getPDO()->exec('SAVEPOINT transaction' . $this->inTransaction); + $this->getPDO()->exec('SAVEPOINT transaction' . $this->inTransaction); } } catch (PDOException $e) { throw new TransactionException('Failed to start transaction: ' . $e->getMessage(), $e->getCode(), $e); } - if (!$result) { - throw new TransactionException('Failed to start transaction'); - } - $this->inTransaction++; + return true; } @@ -124,21 +123,17 @@ public function rollbackTransaction(): bool try { if ($this->inTransaction > 1) { - $result = $this->getPDO()->exec('ROLLBACK TO transaction' . ($this->inTransaction - 1)); + $this->getPDO()->exec('ROLLBACK TO transaction' . ($this->inTransaction - 1)); $this->inTransaction--; } else { - $result = $this->getPDO()->rollBack(); + $this->getPDO()->rollBack(); $this->inTransaction = 0; } } catch (PDOException $e) { throw new DatabaseException('Failed to rollback transaction: ' . $e->getMessage(), $e->getCode(), $e); } - if (!$result) { - throw new TransactionException('Failed to rollback transaction'); - } - - return $result; + return true; } /** @@ -488,7 +483,7 @@ public function updateDocuments(Document $collection, Document $updates, array $ $column = $this->filter($attribute); if (in_array($attribute, $spatialAttributes)) { - $columns .= "{$this->quote($column)} = ST_GeomFromText(:key_{$bindIndex})"; + $columns .= "{$this->quote($column)} = " . $this->getSpatialGeomFromText(":key_{$bindIndex}"); } else { $columns .= "{$this->quote($column)} = :key_{$bindIndex}"; } @@ -1548,6 +1543,47 @@ public function castingAfter(Document $collection, Document $document): Document return $document; } + /** + * Does the adapter support spatial axis order specification? + * + * @return bool + */ + public function getSupportForSpatialAxisOrder(): bool + { + return false; + } + + /** + * Generate ST_GeomFromText call with proper SRID and axis order support + * + * @param string $wktPlaceholder + * @param int|null $srid + * @return string + */ + protected function getSpatialGeomFromText(string $wktPlaceholder, ?int $srid = null): string + { + $srid = $srid ?? Database::SRID; + $geomFromText = "ST_GeomFromText({$wktPlaceholder}, {$srid}"; + + if ($this->getSupportForSpatialAxisOrder()) { + $geomFromText .= ", " . $this->getSpatialAxisOrderSpec(); + } + + $geomFromText .= ")"; + + return $geomFromText; + } + + /** + * Get the spatial axis order specification string + * + * @return string + */ + protected function getSpatialAxisOrderSpec(): string + { + return "'axis-order=long-lat'"; + } + /** * @param string $tableName * @param string $columns @@ -1896,7 +1932,8 @@ protected function getAttributeProjection(array $selections, string $prefix, arr foreach ($spatialAttributes as $spatialAttr) { $filteredAttr = $this->filter($spatialAttr); $quotedAttr = $this->quote($filteredAttr); - $projections[] = "ST_AsText({$this->quote($prefix)}.{$quotedAttr}) AS {$quotedAttr}"; + $axisOrder = $this->getSupportForSpatialAxisOrder() ? ', ' . $this->getSpatialAxisOrderSpec() : ''; + $projections[] = "ST_AsText({$this->quote($prefix)}.{$quotedAttr} {$axisOrder} ) AS {$quotedAttr}"; } @@ -1924,7 +1961,8 @@ protected function getAttributeProjection(array $selections, string $prefix, arr $quotedSelection = $this->quote($filteredSelection); if (in_array($selection, $spatialAttributes)) { - $projections[] = "ST_AsText({$this->quote($prefix)}.{$quotedSelection}) AS {$quotedSelection}"; + $axisOrder = $this->getSupportForSpatialAxisOrder() ? ', ' . $this->getSpatialAxisOrderSpec() : ''; + $projections[] = "ST_AsText({$this->quote($prefix)}.{$quotedSelection} {$axisOrder}) AS {$quotedSelection}"; } else { $projections[] = "{$this->quote($prefix)}.{$quotedSelection}"; } @@ -2054,7 +2092,7 @@ public function createDocuments(Document $collection, array $documents): array } if (in_array($key, $spatialAttributes)) { $bindKey = 'key_' . $bindIndex; - $bindKeys[] = "ST_GeomFromText(:" . $bindKey . ")"; + $bindKeys[] = $this->getSpatialGeomFromText(":" . $bindKey); } else { $value = (\is_bool($value)) ? (int)$value : $value; $bindKey = 'key_' . $bindIndex; @@ -2125,7 +2163,7 @@ public function createDocuments(Document $collection, array $documents): array * @return array * @throws DatabaseException */ - public function createOrUpdateDocuments( + public function upsertDocuments( Document $collection, string $attribute, array $changes @@ -2180,7 +2218,7 @@ public function createOrUpdateDocuments( if (in_array($attributeKey, $spatialAttributes)) { $bindKey = 'key_' . $bindIndex; - $bindKeys[] = "ST_GeomFromText(:" . $bindKey . ")"; + $bindKeys[] = $this->getSpatialGeomFromText(":" . $bindKey); } else { $attrValue = (\is_bool($attrValue)) ? (int)$attrValue : $attrValue; $bindKey = 'key_' . $bindIndex; @@ -2351,4 +2389,355 @@ protected function getAttributeType(string $attributeName, array $attributes): ? } return null; } + + /** + * Find Documents + * + * @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 DatabaseException + * @throws TimeoutException + * @throws Exception + */ + public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array + { + $spatialAttributes = $this->getSpatialAttributes($collection); + $attributes = $collection->getAttribute('attributes', []); + + $collection = $collection->getId(); + $name = $this->filter($collection); + $roles = Authorization::getRoles(); + $where = []; + $orders = []; + $alias = Query::DEFAULT_ALIAS; + $binds = []; + + $queries = array_map(fn ($query) => clone $query, $queries); + + $cursorWhere = []; + + foreach ($orderAttributes as $i => $originalAttribute) { + $attribute = $this->getInternalKeyForAttribute($originalAttribute); + $attribute = $this->filter($attribute); + + $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); + $direction = $orderType; + + if ($cursorDirection === Database::CURSOR_BEFORE) { + $direction = ($direction === Database::ORDER_ASC) + ? Database::ORDER_DESC + : Database::ORDER_ASC; + } + + $orders[] = "{$this->quote($attribute)} {$direction}"; + + // Build pagination WHERE clause only if we have a cursor + if (!empty($cursor)) { + // Special case: No tie breaks. only 1 attribute and it's a unique primary key + if (count($orderAttributes) === 1 && $i === 0 && $originalAttribute === '$sequence') { + $operator = ($direction === Database::ORDER_DESC) + ? Query::TYPE_LESSER + : Query::TYPE_GREATER; + + $bindName = ":cursor_pk"; + $binds[$bindName] = $cursor[$originalAttribute]; + + $cursorWhere[] = "{$this->quote($alias)}.{$this->quote($attribute)} {$this->getSQLOperator($operator)} {$bindName}"; + break; + } + + $conditions = []; + + // Add equality conditions for previous attributes + for ($j = 0; $j < $i; $j++) { + $prevOriginal = $orderAttributes[$j]; + $prevAttr = $this->filter($this->getInternalKeyForAttribute($prevOriginal)); + + $bindName = ":cursor_{$j}"; + $binds[$bindName] = $cursor[$prevOriginal]; + + $conditions[] = "{$this->quote($alias)}.{$this->quote($prevAttr)} = {$bindName}"; + } + + // Add comparison for current attribute + $operator = ($direction === Database::ORDER_DESC) + ? Query::TYPE_LESSER + : Query::TYPE_GREATER; + + $bindName = ":cursor_{$i}"; + $binds[$bindName] = $cursor[$originalAttribute]; + + $conditions[] = "{$this->quote($alias)}.{$this->quote($attribute)} {$this->getSQLOperator($operator)} {$bindName}"; + + $cursorWhere[] = '(' . implode(' AND ', $conditions) . ')'; + } + } + + if (!empty($cursorWhere)) { + $where[] = '(' . implode(' OR ', $cursorWhere) . ')'; + } + + $conditions = $this->getSQLConditions($queries, $binds, attributes:$attributes); + if (!empty($conditions)) { + $where[] = $conditions; + } + + if (Authorization::$status) { + $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias, $forPermission); + } + + if ($this->sharedTables) { + $binds[':_tenant'] = $this->tenant; + $where[] = "{$this->getTenantQuery($collection, $alias, condition: '')}"; + } + + $sqlWhere = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + $sqlOrder = 'ORDER BY ' . implode(', ', $orders); + + $sqlLimit = ''; + if (! \is_null($limit)) { + $binds[':limit'] = $limit; + $sqlLimit = 'LIMIT :limit'; + } + + if (! \is_null($offset)) { + $binds[':offset'] = $offset; + $sqlLimit .= ' OFFSET :offset'; + } + + $selections = $this->getAttributeSelections($queries); + + + $sql = " + SELECT {$this->getAttributeProjection($selections, $alias, $spatialAttributes)} + FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} + {$sqlWhere} + {$sqlOrder} + {$sqlLimit}; + "; + + $sql = $this->trigger(Database::EVENT_DOCUMENT_FIND, $sql); + + try { + $stmt = $this->getPDO()->prepare($sql); + + foreach ($binds as $key => $value) { + if (gettype($value) === 'double') { + $stmt->bindValue($key, $this->getFloatPrecision($value), \PDO::PARAM_STR); + } else { + $stmt->bindValue($key, $value, $this->getPDOType($value)); + } + } + + $this->execute($stmt); + } catch (PDOException $e) { + throw $this->processException($e); + } + + $results = $stmt->fetchAll(); + $stmt->closeCursor(); + + foreach ($results as $index => $document) { + if (\array_key_exists('_uid', $document)) { + $results[$index]['$id'] = $document['_uid']; + unset($results[$index]['_uid']); + } + if (\array_key_exists('_id', $document)) { + $results[$index]['$sequence'] = $document['_id']; + unset($results[$index]['_id']); + } + if (\array_key_exists('_tenant', $document)) { + $results[$index]['$tenant'] = $document['_tenant']; + unset($results[$index]['_tenant']); + } + if (\array_key_exists('_createdAt', $document)) { + $results[$index]['$createdAt'] = $document['_createdAt']; + unset($results[$index]['_createdAt']); + } + if (\array_key_exists('_updatedAt', $document)) { + $results[$index]['$updatedAt'] = $document['_updatedAt']; + unset($results[$index]['_updatedAt']); + } + if (\array_key_exists('_permissions', $document)) { + $results[$index]['$permissions'] = \json_decode($document['_permissions'] ?? '[]', true); + unset($results[$index]['_permissions']); + } + + $results[$index] = new Document($results[$index]); + } + + if ($cursorDirection === Database::CURSOR_BEFORE) { + $results = \array_reverse($results); + } + + return $results; + } + + /** + * Count Documents + * + * @param Document $collection + * @param array $queries + * @param int|null $max + * @return int + * @throws Exception + * @throws PDOException + */ + 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 = []; + $where = []; + $alias = Query::DEFAULT_ALIAS; + + $limit = ''; + if (! \is_null($max)) { + $binds[':limit'] = $max; + $limit = 'LIMIT :limit'; + } + + $queries = array_map(fn ($query) => clone $query, $queries); + + $conditions = $this->getSQLConditions($queries, $binds, attributes:$attributes); + if (!empty($conditions)) { + $where[] = $conditions; + } + + if (Authorization::$status) { + $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias); + } + + if ($this->sharedTables) { + $binds[':_tenant'] = $this->tenant; + $where[] = "{$this->getTenantQuery($collection, $alias, condition: '')}"; + } + + $sqlWhere = !empty($where) + ? 'WHERE ' . \implode(' AND ', $where) + : ''; + + $sql = " + SELECT COUNT(1) as sum FROM ( + SELECT 1 + FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} + {$sqlWhere} + {$limit} + ) table_count + "; + + $sql = $this->trigger(Database::EVENT_DOCUMENT_COUNT, $sql); + + $stmt = $this->getPDO()->prepare($sql); + + foreach ($binds as $key => $value) { + $stmt->bindValue($key, $value, $this->getPDOType($value)); + } + + $this->execute($stmt); + + $result = $stmt->fetchAll(); + $stmt->closeCursor(); + if (!empty($result)) { + $result = $result[0]; + } + + return $result['sum'] ?? 0; + } + + /** + * Sum an Attribute + * + * @param Document $collection + * @param string $attribute + * @param array $queries + * @param int|null $max + * @return int|float + * @throws Exception + * @throws PDOException + */ + 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); + $attribute = $this->filter($attribute); + $roles = Authorization::getRoles(); + $where = []; + $alias = Query::DEFAULT_ALIAS; + $binds = []; + + $limit = ''; + if (! \is_null($max)) { + $binds[':limit'] = $max; + $limit = 'LIMIT :limit'; + } + + $queries = array_map(fn ($query) => clone $query, $queries); + + $conditions = $this->getSQLConditions($queries, $binds, attributes:$collectionAttributes); + if (!empty($conditions)) { + $where[] = $conditions; + } + + if (Authorization::$status) { + $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias); + } + + if ($this->sharedTables) { + $binds[':_tenant'] = $this->tenant; + $where[] = "{$this->getTenantQuery($collection, $alias, condition: '')}"; + } + + $sqlWhere = !empty($where) + ? 'WHERE ' . \implode(' AND ', $where) + : ''; + + $sql = " + SELECT SUM({$this->quote($attribute)}) as sum FROM ( + SELECT {$this->quote($attribute)} + FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} + {$sqlWhere} + {$limit} + ) table_count + "; + + $sql = $this->trigger(Database::EVENT_DOCUMENT_SUM, $sql); + + $stmt = $this->getPDO()->prepare($sql); + + foreach ($binds as $key => $value) { + $stmt->bindValue($key, $value, $this->getPDOType($value)); + } + + $this->execute($stmt); + + $result = $stmt->fetchAll(); + $stmt->closeCursor(); + if (!empty($result)) { + $result = $result[0]; + } + + return $result['sum'] ?? 0; + } + + public function getSpatialTypeFromWKT(string $wkt): string + { + $wkt = trim($wkt); + $pos = strpos($wkt, '('); + if ($pos === false) { + throw new DatabaseException("Invalid spatial type"); + } + return strtolower(trim(substr($wkt, 0, $pos))); + } } diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index e9a2800aa..ff0246265 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -327,11 +327,12 @@ public function analyzeCollection(string $collection): bool * @param bool $signed * @param bool $array * @param string|null $newKey + * @param bool $required * @return bool * @throws Exception * @throws PDOException */ - public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null): bool + public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool { if (!empty($newKey) && $newKey !== $id) { return $this->renameAttribute($collection, $id, $newKey); @@ -1263,4 +1264,24 @@ public function getSupportForBoundaryInclusiveContains(): bool { return false; } + + /** + * Does the adapter support calculating distance(in meters) between multidimension geometry(line, polygon,etc)? + * + * @return bool + */ + public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool + { + return false; + } + + /** + * Does the adapter support spatial axis order specification? + * + * @return bool + */ + public function getSupportForSpatialAxisOrder(): bool + { + return false; + } } diff --git a/src/Database/Database.php b/src/Database/Database.php index 85fad18ed..bcfa99509 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -49,6 +49,10 @@ class Database public const BIG_INT_MAX = PHP_INT_MAX; public const DOUBLE_MAX = PHP_FLOAT_MAX; + // Global SRID for geographic coordinates (WGS84) + public const SRID = 4326; + public const EARTH_RADIUS = 6371000; + // Relationship Types public const VAR_RELATIONSHIP = 'relationship'; @@ -57,7 +61,7 @@ class Database public const VAR_LINESTRING = 'linestring'; public const VAR_POLYGON = 'polygon'; - public const SPATIAL_TYPES = [self::VAR_POINT,self::VAR_LINESTRING, self::VAR_POLYGON]; + public const SPATIAL_TYPES = [self::VAR_POINT, self::VAR_LINESTRING, self::VAR_POLYGON]; // Index Types public const INDEX_KEY = 'key'; @@ -474,6 +478,88 @@ function (?string $value) { return DateTime::formatTz($value); } ); + + self::addFilter( + Database::VAR_POINT, + /** + * @param mixed $value + * @return mixed + */ + function (mixed $value) { + if (!is_array($value)) { + return $value; + } + try { + return self::encodeSpatialData($value, Database::VAR_POINT); + } catch (\Throwable) { + return $value; + } + }, + /** + * @param string|null $value + * @return string|null + */ + function (?string $value) { + if (!is_string($value)) { + return $value; + } + return self::decodeSpatialData($value); + } + ); + self::addFilter( + Database::VAR_LINESTRING, + /** + * @param mixed $value + * @return mixed + */ + function (mixed $value) { + if (!is_array($value)) { + return $value; + } + try { + return self::encodeSpatialData($value, Database::VAR_LINESTRING); + } catch (\Throwable) { + return $value; + } + }, + /** + * @param string|null $value + * @return string|null + */ + function (?string $value) { + if (is_null($value)) { + return $value; + } + return self::decodeSpatialData($value); + } + ); + self::addFilter( + Database::VAR_POLYGON, + /** + * @param mixed $value + * @return mixed + */ + function (mixed $value) { + if (!is_array($value)) { + return $value; + } + try { + return self::encodeSpatialData($value, Database::VAR_POLYGON); + } catch (\Throwable) { + return $value; + } + }, + /** + * @param string|null $value + * @return string|null + */ + function (?string $value) { + if (is_null($value)) { + return $value; + } + return self::decodeSpatialData($value); + } + ); } /** @@ -1239,6 +1325,19 @@ public function delete(?string $database = null): bool */ public function createCollection(string $id, array $attributes = [], array $indexes = [], ?array $permissions = null, bool $documentSecurity = true): Document { + foreach ($attributes as &$attribute) { + if (in_array($attribute['type'], Database::SPATIAL_TYPES)) { + $existingFilters = $attribute['filters'] ?? []; + if (!is_array($existingFilters)) { + $existingFilters = [$existingFilters]; + } + $attribute['filters'] = array_values( + array_unique(array_merge($existingFilters, [$attribute['type']])) + ); + } + } + unset($attribute); + $permissions ??= [ Permission::create(Role::any()), ]; @@ -1595,6 +1694,10 @@ public function createAttribute(string $collection, string $id, string $type, in if ($collection->isEmpty()) { throw new NotFoundException('Collection not found'); } + if (in_array($type, Database::SPATIAL_TYPES)) { + $filters[] = $type; + $filters = array_unique($filters); + } $attribute = $this->validateAttribute( $collection, @@ -1852,6 +1955,12 @@ private function validateAttribute( if (!$this->adapter->getSupportForSpatialAttributes()) { throw new DatabaseException('Spatial attributes are not supported'); } + if (!empty($size)) { + throw new DatabaseException('Size must be empty for spatial attributes'); + } + if (!empty($array)) { + throw new DatabaseException('Spatial attributes cannot be arrays'); + } 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 . ', ' . self::VAR_POINT . ', ' . self::VAR_LINESTRING . ', ' . self::VAR_POLYGON); @@ -1903,8 +2012,11 @@ protected function validateDefaultTypes(string $type, mixed $default): void } if ($defaultType === 'array') { - foreach ($default as $value) { - $this->validateDefaultTypes($type, $value); + // spatial types require the array itself + if (!in_array($type, Database::SPATIAL_TYPES)) { + foreach ($default as $value) { + $this->validateDefaultTypes($type, $value); + } } return; } @@ -2149,6 +2261,11 @@ public function updateAttribute(string $collection, string $id, ?string $type = $default = null; } + // we need to alter table attribute type to NOT NULL/NULL for change in required + if (!$this->adapter->getSupportForSpatialIndexNull() && in_array($type, Database::SPATIAL_TYPES)) { + $altering = true; + } + switch ($type) { case self::VAR_STRING: if (empty($size)) { @@ -2173,6 +2290,20 @@ public function updateAttribute(string $collection, string $id, ?string $type = throw new DatabaseException('Size must be empty'); } break; + + case self::VAR_POINT: + case self::VAR_LINESTRING: + case self::VAR_POLYGON: + if (!$this->adapter->getSupportForSpatialAttributes()) { + throw new DatabaseException('Spatial attributes are not supported'); + } + if (!empty($size)) { + throw new DatabaseException('Size must be empty for spatial attributes'); + } + if (!empty($array)) { + throw new DatabaseException('Spatial attributes cannot be arrays'); + } + 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); } @@ -2221,6 +2352,35 @@ public function updateAttribute(string $collection, string $id, ?string $type = throw new LimitException('Row width limit reached. Cannot update attribute.'); } + if (in_array($type, self::SPATIAL_TYPES, true) && !$this->adapter->getSupportForSpatialIndexNull()) { + $attributeMap = []; + foreach ($attributes as $attrDoc) { + $key = \strtolower($attrDoc->getAttribute('key', $attrDoc->getAttribute('$id'))); + $attributeMap[$key] = $attrDoc; + } + + $indexes = $collectionDoc->getAttribute('indexes', []); + foreach ($indexes as $index) { + if ($index->getAttribute('type') !== self::INDEX_SPATIAL) { + continue; + } + $indexAttributes = $index->getAttribute('attributes', []); + foreach ($indexAttributes as $attributeName) { + $lookup = \strtolower($attributeName); + if (!isset($attributeMap[$lookup])) { + continue; + } + $attrDoc = $attributeMap[$lookup]; + $attrType = $attrDoc->getAttribute('type'); + $attrRequired = (bool)$attrDoc->getAttribute('required', false); + + if (in_array($attrType, self::SPATIAL_TYPES, true) && !$attrRequired) { + throw new IndexException('Spatial indexes do not allow null values. Mark the attribute "' . $attributeName . '" as required or create the index on a column with no null values.'); + } + } + } + } + if ($altering) { $indexes = $collectionDoc->getAttribute('indexes'); @@ -2267,7 +2427,7 @@ public function updateAttribute(string $collection, string $id, ?string $type = } } - $updated = $this->adapter->updateAttribute($collection, $id, $type, $size, $signed, $array, $newKey); + $updated = $this->adapter->updateAttribute($collection, $id, $type, $size, $signed, $array, $newKey, $required); if (!$updated) { throw new DatabaseException('Failed to update attribute'); @@ -3162,12 +3322,12 @@ public function createIndex(string $collection, string $id, string $type, array if ($type === self::INDEX_SPATIAL) { foreach ($attributes as $attr) { if (!isset($indexAttributesWithTypes[$attr])) { - throw new DatabaseException('Attribute "' . $attr . '" not found in collection'); + throw new IndexException('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 . '"'); + throw new IndexException('Spatial index can only be created on spatial attributes (point, linestring, polygon). Attribute "' . $attr . '" is of type "' . $attributeType . '"'); } } @@ -3778,7 +3938,8 @@ public function createDocument(string $collection, Document $document): Document * @param string $collection * @param array $documents * @param int $batchSize - * @param callable|null $onNext + * @param (callable(Document): void)|null $onNext + * @param (callable(Throwable): void)|null $onError * @return int * @throws AuthorizationException * @throws StructureException @@ -3790,6 +3951,7 @@ public function createDocuments( array $documents, int $batchSize = self::INSERT_BATCH_SIZE, ?callable $onNext = null, + ?callable $onError = null, ): int { if (!$this->adapter->getSharedTables() && $this->adapter->getTenantPerDocument()) { throw new DatabaseException('Shared tables must be enabled if tenant per document is enabled.'); @@ -3869,7 +4031,13 @@ public function createDocuments( $document = $this->casting($collection, $document); $document = $this->decode($collection, $document); - $onNext && $onNext($document); + + try { + $onNext && $onNext($document); + } catch (\Throwable $e) { + $onError ? $onError($e) : throw $e; + } + $modified++; } } @@ -4230,12 +4398,15 @@ public function updateDocument(string $collection, string $id, Document $documen $old = Authorization::skip(fn () => $this->silent( fn () => $this->getDocument($collection->getId(), $id, forUpdate: true) )); + if ($old->isEmpty()) { + return new Document(); + } $skipPermissionsUpdate = true; if ($document->offsetExists('$permissions')) { $originalPermissions = $old->getPermissions(); - $currentPermissions = $document->getPermissions(); + $currentPermissions = $document->getPermissions(); sort($originalPermissions); sort($currentPermissions); @@ -4369,10 +4540,6 @@ public function updateDocument(string $collection, string $id, Document $documen } } - if ($old->isEmpty()) { - return new Document(); - } - if ($shouldUpdate) { $document->setAttribute('$updatedAt', ($newUpdatedAt === null || !$this->preserveDates) ? $time : $newUpdatedAt); } @@ -4411,6 +4578,10 @@ public function updateDocument(string $collection, string $id, Document $documen return $document; }); + if ($document->isEmpty()) { + return $document; + } + if ($this->resolveRelationships) { $document = $this->silent(fn () => $this->populateDocumentRelationships($collection, $document)); } @@ -4431,8 +4602,8 @@ public function updateDocument(string $collection, string $id, Document $documen * @param Document $updates * @param array $queries * @param int $batchSize - * @param callable|null $onNext - * @param callable|null $onError + * @param (callable(Document $updated, Document $old): void)|null $onNext + * @param (callable(Throwable): void)|null $onError * @return int * @throws AuthorizationException * @throws ConflictException @@ -4555,12 +4726,12 @@ public function updateDocuments( break; } - $currentPermissions = $updates->getPermissions(); + $old = array_map(fn ($doc) => clone $doc, $batch); + $currentPermissions = $updates->getPermissions(); sort($currentPermissions); $this->withTransaction(function () use ($collection, $updates, &$batch, $currentPermissions) { foreach ($batch as $index => $document) { - $skipPermissionsUpdate = true; if ($updates->offsetExists('$permissions')) { @@ -4594,7 +4765,6 @@ public function updateDocuments( if (!is_null($this->timestamp) && $oldUpdatedAt > $this->timestamp) { throw new ConflictException('Document was updated after the request timestamp'); } - $batch[$index] = $this->encode($collection, $document); $batch[$index] = $this->adapter->castingBefore($collection, $document); } @@ -4609,14 +4779,13 @@ public function updateDocuments( $updates = $this->adapter->castingBefore($collection, $updates); - foreach ($batch as $doc) { + foreach ($batch as $index => $doc) { $doc = $this->adapter->castingAfter($collection, $doc); $doc->removeAttribute('$skipPermissionsUpdate'); - $this->purgeCachedDocument($collection->getId(), $doc->getId()); $doc = $this->decode($collection, $doc); try { - $onNext && $onNext($doc); + $onNext && $onNext($doc, $old[$index]); } catch (Throwable $th) { $onError ? $onError($th) : throw $th; } @@ -5032,28 +5201,62 @@ private function getJunctionCollection(Document $collection, Document $relatedCo : '_' . $relatedCollection->getSequence() . '_' . $collection->getSequence(); } + /** + * Create or update a document. + * + * @param string $collection + * @param Document $document + * @return Document + * @throws StructureException + * @throws Throwable + */ + public function upsertDocument( + string $collection, + Document $document, + ): Document { + $result = null; + + $this->upsertDocumentsWithIncrease( + $collection, + '', + [$document], + function (Document $doc, ?Document $_old = null) use (&$result) { + $result = $doc; + } + ); + + if ($result === null) { + // No-op (unchanged): return the current persisted doc + $result = $this->getDocument($collection, $document->getId()); + } + return $result; + } + /** * Create or update documents. * * @param string $collection * @param array $documents * @param int $batchSize - * @param callable|null $onNext + * @param (callable(Document, ?Document): void)|null $onNext + * @param (callable(Throwable): void)|null $onError * @return int * @throws StructureException * @throws \Throwable */ - public function createOrUpdateDocuments( + public function upsertDocuments( string $collection, array $documents, int $batchSize = self::INSERT_BATCH_SIZE, ?callable $onNext = null, + ?callable $onError = null ): int { - return $this->createOrUpdateDocumentsWithIncrease( + return $this->upsertDocumentsWithIncrease( $collection, '', $documents, $onNext, + $onError, $batchSize ); } @@ -5064,18 +5267,20 @@ public function createOrUpdateDocuments( * @param string $collection * @param string $attribute * @param array $documents - * @param callable|null $onNext + * @param (callable(Document, ?Document): void)|null $onNext + * @param (callable(Throwable): void)|null $onError * @param int $batchSize * @return int * @throws StructureException * @throws \Throwable * @throws Exception */ - public function createOrUpdateDocumentsWithIncrease( + public function upsertDocumentsWithIncrease( string $collection, string $attribute, array $documents, ?callable $onNext = null, + ?callable $onError = null, int $batchSize = self::INSERT_BATCH_SIZE ): int { if (empty($documents)) { @@ -5107,7 +5312,7 @@ public function createOrUpdateDocumentsWithIncrease( if ($document->offsetExists('$permissions')) { $originalPermissions = $old->getPermissions(); - $currentPermissions = $document->getPermissions(); + $currentPermissions = $document->getPermissions(); sort($originalPermissions); sort($currentPermissions); @@ -5240,7 +5445,7 @@ public function createOrUpdateDocumentsWithIncrease( /** * @var array $chunk */ - $batch = $this->withTransaction(fn () => Authorization::skip(fn () => $this->adapter->createOrUpdateDocuments( + $batch = $this->withTransaction(fn () => Authorization::skip(fn () => $this->adapter->upsertDocuments( $collection, $attribute, $chunk @@ -5256,7 +5461,7 @@ public function createOrUpdateDocumentsWithIncrease( } } - foreach ($batch as $doc) { + foreach ($batch as $index => $doc) { $doc = $this->adapter->castingAfter($collection, $doc); @@ -5274,7 +5479,13 @@ public function createOrUpdateDocumentsWithIncrease( $this->purgeCachedDocument($collection->getId(), $doc->getId()); } - $onNext && $onNext($doc); + $old = $chunk[$index]->getOld(); + + try { + $onNext && $onNext($doc, $old->isEmpty() ? null : $old); + } catch (\Throwable $th) { + $onError ? $onError($th) : throw $th; + } } } @@ -5545,7 +5756,9 @@ public function deleteDocument(string $collection, string $id): bool return $result; }); - $this->trigger(self::EVENT_DOCUMENT_DELETE, $document); + if ($deleted) { + $this->trigger(self::EVENT_DOCUMENT_DELETE, $document); + } return $deleted; } @@ -5939,8 +6152,8 @@ private function deleteCascade(Document $collection, Document $relatedCollection * @param string $collection * @param array $queries * @param int $batchSize - * @param callable|null $onNext - * @param callable|null $onError + * @param (callable(Document, Document): void)|null $onNext + * @param (callable(Throwable): void)|null $onError * @return int * @throws AuthorizationException * @throws DatabaseException @@ -6032,6 +6245,7 @@ public function deleteDocuments( break; } + $old = array_map(fn ($doc) => clone $doc, $batch); $sequences = []; $permissionIds = []; @@ -6068,7 +6282,7 @@ public function deleteDocuments( ); }); - foreach ($batch as $document) { + foreach ($batch as $index => $document) { if ($this->getSharedTables() && $this->getTenantPerDocument()) { $this->withTenant($document->getTenant(), function () use ($collection, $document) { $this->purgeCachedDocument($collection->getId(), $document->getId()); @@ -6077,7 +6291,7 @@ public function deleteDocuments( $this->purgeCachedDocument($collection->getId(), $document->getId()); } try { - $onNext && $onNext($document); + $onNext && $onNext($document, $old[$index]); } catch (Throwable $th) { $onError ? $onError($th) : throw $th; } @@ -6237,11 +6451,13 @@ public function find(string $collection, array $queries = [], string $forPermiss } if (!empty($cursor)) { + $cursor = $this->encode($collection, $cursor); $cursor = $this->adapter->castingBefore($collection, $cursor); + $cursor = $cursor->getArrayCopy(); + } else { + $cursor = []; } - $cursor = empty($cursor) ? [] : $this->encode($collection, $cursor)->getArrayCopy(); - /** @var array $queries */ $queries = \array_merge( $selects, @@ -6494,7 +6710,7 @@ public static function addFilter(string $name, callable $encode, callable $decod public function encode(Document $collection, Document $document): Document { $attributes = $collection->getAttribute('attributes', []); - $internalDateAttributes = ['$createdAt','$updatedAt']; + $internalDateAttributes = ['$createdAt', '$updatedAt']; foreach ($this->getInternalAttributes() as $attribute) { $attributes[] = $attribute; } @@ -6531,14 +6747,6 @@ 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); } @@ -6617,9 +6825,6 @@ 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); @@ -6911,7 +7116,7 @@ public function convertQuery(Document $collection, Query $query): Query } } - if (! $attribute->isEmpty()) { + if (!$attribute->isEmpty()) { $query->setOnArray($attribute->getAttribute('array', false)); if ($attribute->getAttribute('type') == Database::VAR_DATETIME) { @@ -7060,7 +7265,12 @@ private function processRelationshipQueries( // 'foo.bar.baz' becomes 'bar.baz' $nestingPath = \implode('.', $nesting); - $nestedSelections[$selectedKey][] = Query::select([$nestingPath]); + // If nestingPath is empty, it means we want all fields (*) for this relationship + if (empty($nestingPath)) { + $nestedSelections[$selectedKey][] = Query::select(['*']); + } else { + $nestedSelections[$selectedKey][] = Query::select([$nestingPath]); + } $type = $relationship->getAttribute('options')['relationType']; $side = $relationship->getAttribute('options')['side']; @@ -7089,7 +7299,13 @@ private function processRelationshipQueries( } } - $query->setValues(\array_values($values)); + $finalValues = \array_values($values); + if ($query->getMethod() === Query::TYPE_SELECT) { + if (empty($finalValues)) { + $finalValues = ['*']; + } + } + $query->setValues($finalValues); } return $nestedSelections; @@ -7106,7 +7322,9 @@ private function processRelationshipQueries( protected function encodeSpatialData(mixed $value, string $type): string { $validator = new Spatial($type); - $validator->isValid($value); + if (!$validator->isValid($value)) { + throw new StructureException($validator->getDescription()); + } switch ($type) { case self::VAR_POINT: @@ -7122,7 +7340,7 @@ protected function encodeSpatialData(mixed $value, string $type): string 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]); + 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 @@ -7158,7 +7376,7 @@ public function decodeSpatialData(string $wkt): array // POINT(x y) if (str_starts_with($upper, 'POINT(')) { $start = strpos($wkt, '(') + 1; - $end = strrpos($wkt, ')'); + $end = strrpos($wkt, ')'); $inside = substr($wkt, $start, $end - $start); $coords = explode(' ', trim($inside)); @@ -7168,7 +7386,7 @@ public function decodeSpatialData(string $wkt): array // LINESTRING(x1 y1, x2 y2, ...) if (str_starts_with($upper, 'LINESTRING(')) { $start = strpos($wkt, '(') + 1; - $end = strrpos($wkt, ')'); + $end = strrpos($wkt, ')'); $inside = substr($wkt, $start, $end - $start); $points = explode(',', $inside); @@ -7181,7 +7399,7 @@ public function decodeSpatialData(string $wkt): array // POLYGON((x1,y1),(x2,y2)) if (str_starts_with($upper, 'POLYGON((')) { $start = strpos($wkt, '((') + 2; - $end = strrpos($wkt, '))'); + $end = strrpos($wkt, '))'); $inside = substr($wkt, $start, $end - $start); $rings = explode('),(', $inside); diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index 25df6c888..dac23a063 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -589,17 +589,14 @@ public function createDocuments( array $documents, int $batchSize = self::INSERT_BATCH_SIZE, ?callable $onNext = null, + ?callable $onError = null, ): int { - $modified = 0; - - $this->source->createDocuments( + $modified = $this->source->createDocuments( $collection, $documents, $batchSize, - function ($doc) use ($onNext, &$modified) { - $onNext && $onNext($doc); - $modified++; - }, + $onNext, + $onError, ); if ( @@ -638,7 +635,6 @@ function ($doc) use ($onNext, &$modified) { $collection, $clones, $batchSize, - null, ) ); @@ -715,18 +711,13 @@ public function updateDocuments( ?callable $onNext = null, ?callable $onError = null, ): int { - $modified = 0; - - $this->source->updateDocuments( + $modified = $this->source->updateDocuments( $collection, $updates, $queries, $batchSize, - function ($doc) use ($onNext, &$modified) { - $onNext && $onNext($doc); - $modified++; - }, - $onError + $onNext, + $onError, ); if ( @@ -754,14 +745,13 @@ function ($doc) use ($onNext, &$modified) { ); } - $modified = $this->destination->withPreserveDates( + $this->destination->withPreserveDates( fn () => $this->destination->updateDocuments( $collection, $clone, $queries, $batchSize, - null, ) ); @@ -781,17 +771,19 @@ 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( + public function upsertDocuments( + string $collection, + array $documents, + int $batchSize = Database::INSERT_BATCH_SIZE, + ?callable $onNext = null, + ?callable $onError = null, + ): int { + $modified = $this->source->upsertDocuments( $collection, $documents, $batchSize, - function ($doc) use ($onNext, &$modified) { - $onNext && $onNext($doc); - $modified++; - } + $onNext, + $onError, ); if ( @@ -824,13 +816,12 @@ function ($doc) use ($onNext, &$modified) { $clones[] = $clone; } - $modified = $this->destination->withPreserveDates( + $this->destination->withPreserveDates( fn () => - $this->destination->createOrUpdateDocuments( + $this->destination->upsertDocuments( $collection, $clones, $batchSize, - null, ) ); @@ -845,8 +836,9 @@ function ($doc) use ($onNext, &$modified) { } } } catch (\Throwable $err) { - $this->logError('createDocuments', $err); + $this->logError('upsertDocuments', $err); } + return $modified; } @@ -900,17 +892,12 @@ public function deleteDocuments( ?callable $onNext = null, ?callable $onError = null, ): int { - $modified = 0; - - $this->source->deleteDocuments( + $modified = $this->source->deleteDocuments( $collection, $queries, $batchSize, - function ($doc) use (&$modified, $onNext) { - $onNext && $onNext($doc); - $modified++; - }, - $onError + $onNext, + $onError, ); if ( @@ -935,11 +922,10 @@ function ($doc) use (&$modified, $onNext) { ); } - $modified = $this->destination->deleteDocuments( + $this->destination->deleteDocuments( $collection, $queries, $batchSize, - null, ); foreach ($this->writeFilters as $filter) { diff --git a/src/Database/Query.php b/src/Database/Query.php index 24f40eece..d8f1557d9 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -730,6 +730,30 @@ public static function updatedAfter(string $value): self return self::greaterThan('$updatedAt', $value); } + /** + * Helper method to create Query for documents created between two dates + * + * @param string $start + * @param string $end + * @return Query + */ + public static function createdBetween(string $start, string $end): self + { + return self::between('$createdAt', $start, $end); + } + + /** + * Helper method to create Query for documents updated between two dates + * + * @param string $start + * @param string $end + * @return Query + */ + public static function updatedBetween(string $start, string $end): self + { + return self::between('$updatedAt', $start, $end); + } + /** * @param array $queries * @return Query @@ -903,11 +927,12 @@ public function setOnArray(bool $bool): void * @param string $attribute * @param array $values * @param int|float $distance + * @param bool $meters * @return Query */ - public static function distanceEqual(string $attribute, array $values, int|float $distance): self + public static function distanceEqual(string $attribute, array $values, int|float $distance, bool $meters = false): self { - return new self(self::TYPE_DISTANCE_EQUAL, $attribute, [[$values,$distance]]); + return new self(self::TYPE_DISTANCE_EQUAL, $attribute, [[$values,$distance,$meters]]); } /** @@ -916,11 +941,12 @@ public static function distanceEqual(string $attribute, array $values, int|float * @param string $attribute * @param array $values * @param int|float $distance + * @param bool $meters * @return Query */ - public static function distanceNotEqual(string $attribute, array $values, int|float $distance): self + public static function distanceNotEqual(string $attribute, array $values, int|float $distance, bool $meters = false): self { - return new self(self::TYPE_DISTANCE_NOT_EQUAL, $attribute, [[$values,$distance]]); + return new self(self::TYPE_DISTANCE_NOT_EQUAL, $attribute, [[$values,$distance,$meters]]); } /** @@ -929,11 +955,12 @@ public static function distanceNotEqual(string $attribute, array $values, int|fl * @param string $attribute * @param array $values * @param int|float $distance + * @param bool $meters * @return Query */ - public static function distanceGreaterThan(string $attribute, array $values, int|float $distance): self + public static function distanceGreaterThan(string $attribute, array $values, int|float $distance, bool $meters = false): self { - return new self(self::TYPE_DISTANCE_GREATER_THAN, $attribute, [[$values,$distance]]); + return new self(self::TYPE_DISTANCE_GREATER_THAN, $attribute, [[$values,$distance, $meters]]); } /** @@ -942,11 +969,12 @@ public static function distanceGreaterThan(string $attribute, array $values, int * @param string $attribute * @param array $values * @param int|float $distance + * @param bool $meters * @return Query */ - public static function distanceLessThan(string $attribute, array $values, int|float $distance): self + public static function distanceLessThan(string $attribute, array $values, int|float $distance, bool $meters = false): self { - return new self(self::TYPE_DISTANCE_LESS_THAN, $attribute, [[$values,$distance]]); + return new self(self::TYPE_DISTANCE_LESS_THAN, $attribute, [[$values,$distance,$meters]]); } /** @@ -958,7 +986,7 @@ public static function distanceLessThan(string $attribute, array $values, int|fl */ public static function intersects(string $attribute, array $values): self { - return new self(self::TYPE_INTERSECTS, $attribute, $values); + return new self(self::TYPE_INTERSECTS, $attribute, [$values]); } /** @@ -970,7 +998,7 @@ public static function intersects(string $attribute, array $values): self */ public static function notIntersects(string $attribute, array $values): self { - return new self(self::TYPE_NOT_INTERSECTS, $attribute, $values); + return new self(self::TYPE_NOT_INTERSECTS, $attribute, [$values]); } /** @@ -982,7 +1010,7 @@ public static function notIntersects(string $attribute, array $values): self */ public static function crosses(string $attribute, array $values): self { - return new self(self::TYPE_CROSSES, $attribute, $values); + return new self(self::TYPE_CROSSES, $attribute, [$values]); } /** @@ -994,7 +1022,7 @@ public static function crosses(string $attribute, array $values): self */ public static function notCrosses(string $attribute, array $values): self { - return new self(self::TYPE_NOT_CROSSES, $attribute, $values); + return new self(self::TYPE_NOT_CROSSES, $attribute, [$values]); } /** @@ -1006,7 +1034,7 @@ public static function notCrosses(string $attribute, array $values): self */ public static function overlaps(string $attribute, array $values): self { - return new self(self::TYPE_OVERLAPS, $attribute, $values); + return new self(self::TYPE_OVERLAPS, $attribute, [$values]); } /** @@ -1018,7 +1046,7 @@ public static function overlaps(string $attribute, array $values): self */ public static function notOverlaps(string $attribute, array $values): self { - return new self(self::TYPE_NOT_OVERLAPS, $attribute, $values); + return new self(self::TYPE_NOT_OVERLAPS, $attribute, [$values]); } /** @@ -1030,7 +1058,7 @@ public static function notOverlaps(string $attribute, array $values): self */ public static function touches(string $attribute, array $values): self { - return new self(self::TYPE_TOUCHES, $attribute, $values); + return new self(self::TYPE_TOUCHES, $attribute, [$values]); } /** @@ -1042,6 +1070,6 @@ public static function touches(string $attribute, array $values): self */ public static function notTouches(string $attribute, array $values): self { - return new self(self::TYPE_NOT_TOUCHES, $attribute, $values); + return new self(self::TYPE_NOT_TOUCHES, $attribute, [$values]); } } diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index 87fa51e78..bab80c173 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -339,14 +339,6 @@ public function getType(): string 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', []); @@ -356,22 +348,36 @@ public function checkSpatialIndex(Document $index): bool $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 . '"'; + continue; + } + + if (!$this->spatialIndexSupport) { + $this->message = 'Spatial indexes are not supported'; return false; } + if (count($attributes) !== 1) { + $this->message = 'Spatial index can be created on a single spatial attribute'; + return false; + } + + if ($type !== Database::INDEX_SPATIAL) { + $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; + 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/Key.php b/src/Database/Validator/Key.php index 18ed1dd02..920ff7b92 100644 --- a/src/Database/Validator/Key.php +++ b/src/Database/Validator/Key.php @@ -76,8 +76,8 @@ public function isValid($value): bool if (\preg_match('/[^A-Za-z0-9\_\-\.]/', $value)) { return false; } - // Updated to 100 to suport mongodb ids - if (\mb_strlen($value) > 100) { + // At most 36 chars + if (\mb_strlen($value) > 36) { return false; } diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 9f331d871..9c60f551c 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -263,7 +263,7 @@ public function isValid($value): bool 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) { + if (count($value->getValues()) !== 1 || !is_array($value->getValues()[0]) || count($value->getValues()[0]) !== 3) { $this->message = 'Distance query requires [[geometry, distance]] parameters'; return false; } diff --git a/src/Database/Validator/Spatial.php b/src/Database/Validator/Spatial.php index 30026dfe2..912f05b2b 100644 --- a/src/Database/Validator/Spatial.php +++ b/src/Database/Validator/Spatial.php @@ -3,87 +3,66 @@ namespace Utopia\Database\Validator; use Utopia\Database\Database; -use Utopia\Database\Exception; use Utopia\Validator; class Spatial extends Validator { private string $spatialType; + protected string $message = ''; public function __construct(string $spatialType) { $this->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 + * @param array $value * @return bool - * @throws Exception */ - protected static function validatePoint(array $value): bool + protected function validatePoint(array $value): bool { if (count($value) !== 2) { - throw new Exception('Point must be an array of two numeric values [x, y]'); + $this->message = 'Point must be an array of two numeric values [x, y]'; + return false; } if (!is_numeric($value[0]) || !is_numeric($value[1])) { - throw new Exception('Point coordinates must be numeric values'); + $this->message = 'Point coordinates must be numeric values'; + return false; } - return true; + return $this->isValidCoordinate((float)$value[0], (float) $value[1]); } /** * Validate LINESTRING data * - * @param array $value + * @param array $value * @return bool - * @throws Exception */ - protected static function validateLineString(array $value): bool + protected function validateLineString(array $value): bool { if (count($value) < 2) { - throw new Exception('LineString must contain at least one point'); + $this->message = 'LineString must contain at least two points'; + return false; } - foreach ($value as $point) { + foreach ($value as $pointIndex => $point) { if (!is_array($point) || count($point) !== 2) { - throw new Exception('Each point in LineString must be an array of two values [x, y]'); + $this->message = 'Each point in LineString must be an array of two values [x, y]'; + return false; } if (!is_numeric($point[0]) || !is_numeric($point[1])) { - throw new Exception('Each point in LineString must have numeric coordinates'); + $this->message = 'Each point in LineString must have numeric coordinates'; + return false; + } + + if (!$this->isValidCoordinate((float)$point[0], (float)$point[1])) { + $this->message = "Invalid coordinates at point #{$pointIndex}: {$this->message}"; + return false; } } @@ -93,37 +72,58 @@ protected static function validateLineString(array $value): bool /** * Validate POLYGON data * - * @param array $value + * @param array $value * @return bool - * @throws Exception */ - protected static function validatePolygon(array $value): bool + protected function validatePolygon(array $value): bool { if (empty($value)) { - throw new Exception('Polygon must contain at least one ring'); + $this->message = 'Polygon must contain at least one ring'; + return false; } - // 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]); + 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 + $value = [$value]; } - foreach ($value as $ring) { + foreach ($value as $ringIndex => $ring) { if (!is_array($ring) || empty($ring)) { - throw new Exception('Each ring in Polygon must be an array of points'); + $this->message = "Ring #{$ringIndex} must be an array of points"; + return false; + } + + if (count($ring) < 4) { + $this->message = "Ring #{$ringIndex} must contain at least 4 points to form a closed polygon"; + return false; } - foreach ($ring as $point) { + foreach ($ring as $pointIndex => $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]'); + $this->message = "Point #{$pointIndex} in ring #{$ringIndex} must be an array of two values [x, y]"; + return false; } + if (!is_numeric($point[0]) || !is_numeric($point[1])) { - throw new Exception('Each point in Polygon ring must have numeric coordinates'); + $this->message = "Coordinates of point #{$pointIndex} in ring #{$ringIndex} must be numeric"; + return false; + } + + if (!$this->isValidCoordinate((float)$point[0], (float)$point[1])) { + $this->message = "Invalid coordinates at point #{$pointIndex} in ring #{$ringIndex}: {$this->message}"; + return false; } } + + // Check that the ring is closed (first point == last point) + if ($ring[0] !== $ring[count($ring) - 1]) { + $this->message = "Ring #{$ringIndex} must be closed (first point must equal last point)"; + return false; + } } return true; @@ -131,9 +131,6 @@ protected static function validatePolygon(array $value): bool /** * Check if a value is valid WKT string - * - * @param string $value - * @return bool */ public static function isWKTString(string $value): bool { @@ -141,41 +138,28 @@ public static function isWKTString(string $value): bool 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)'; + return 'Value must be a valid ' . $this->spatialType . ": {$this->message}"; } - /** - * Is array - * - * @return bool - */ public function isArray(): bool { return false; } - /** - * Get Type - * - * @return string - */ public function getType(): string { - return 'spatial'; + return self::TYPE_ARRAY; + } + + public function getSpatialType(): string + { + return $this->spatialType; } /** - * Is valid - * - * @param mixed $value - * @return bool + * Main validation entrypoint */ public function isValid($value): bool { @@ -184,20 +168,42 @@ public function isValid($value): bool } 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; + switch ($this->spatialType) { + case Database::VAR_POINT: + return $this->validatePoint($value); + + case Database::VAR_LINESTRING: + return $this->validateLineString($value); + + case Database::VAR_POLYGON: + return $this->validatePolygon($value); + + default: + $this->message = 'Unknown spatial type: ' . $this->spatialType; + return false; } } + $this->message = 'Spatial value must be array or WKT string'; return false; } + + private function isValidCoordinate(int|float $x, int|float $y): bool + { + if ($x < -180 || $x > 180) { + $this->message = "Longitude (x) must be between -180 and 180, got {$x}"; + return false; + } + + if ($y < -90 || $y > 90) { + $this->message = "Latitude (y) must be between -90 and 90, got {$y}"; + return false; + } + + return true; + } } diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 2ddad7e49..24e3b173a 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -557,7 +557,7 @@ public function testSkipPermissions(): void Authorization::disable(); $results = []; - $count = $database->createOrUpdateDocuments( + $count = $database->upsertDocuments( __FUNCTION__, $documents, onNext: function ($doc) use (&$results) { @@ -619,7 +619,7 @@ public function testUpsertDocuments(): void ]; $results = []; - $count = $database->createOrUpdateDocuments( + $count = $database->upsertDocuments( __FUNCTION__, $documents, onNext: function ($doc) use (&$results) { @@ -662,7 +662,7 @@ public function testUpsertDocuments(): void $documents[1]->setAttribute('integer', 10); $results = []; - $count = $database->createOrUpdateDocuments(__FUNCTION__, $documents, onNext: function ($doc) use (&$results) { + $count = $database->upsertDocuments(__FUNCTION__, $documents, onNext: function ($doc) use (&$results) { $results[] = $doc; }); @@ -739,7 +739,7 @@ public function testUpsertDocumentsInc(): void $documents[0]->setAttribute('integer', 1); $documents[1]->setAttribute('integer', 1); - $database->createOrUpdateDocumentsWithIncrease( + $database->upsertDocumentsWithIncrease( collection: __FUNCTION__, attribute: 'integer', documents: $documents @@ -754,7 +754,7 @@ public function testUpsertDocumentsInc(): void $documents[0]->setAttribute('integer', -1); $documents[1]->setAttribute('integer', -1); - $database->createOrUpdateDocumentsWithIncrease( + $database->upsertDocumentsWithIncrease( collection: __FUNCTION__, attribute: 'integer', documents: $documents @@ -789,10 +789,10 @@ public function testUpsertDocumentsPermissions(): void ], ]); - $database->createOrUpdateDocuments(__FUNCTION__, [$document]); + $database->upsertDocuments(__FUNCTION__, [$document]); try { - $database->createOrUpdateDocuments(__FUNCTION__, [$document->setAttribute('string', 'updated')]); + $database->upsertDocuments(__FUNCTION__, [$document->setAttribute('string', 'updated')]); $this->fail('Failed to throw exception'); } catch (Exception $e) { $this->assertInstanceOf(AuthorizationException::class, $e); @@ -807,10 +807,10 @@ public function testUpsertDocumentsPermissions(): void ], ]); - $database->createOrUpdateDocuments(__FUNCTION__, [$document]); + $database->upsertDocuments(__FUNCTION__, [$document]); $results = []; - $count = $database->createOrUpdateDocuments( + $count = $database->upsertDocuments( __FUNCTION__, [$document->setAttribute('string', 'updated')], onNext: function ($doc) use (&$results) { @@ -831,7 +831,7 @@ public function testUpsertDocumentsPermissions(): void ], ]); - $database->createOrUpdateDocuments(__FUNCTION__, [$document]); + $database->upsertDocuments(__FUNCTION__, [$document]); $newPermissions = [ Permission::read(Role::any()), @@ -840,7 +840,7 @@ public function testUpsertDocumentsPermissions(): void ]; $results = []; - $count = $database->createOrUpdateDocuments( + $count = $database->upsertDocuments( __FUNCTION__, [$document->setAttribute('$permissions', $newPermissions)], onNext: function ($doc) use (&$results) { @@ -887,7 +887,7 @@ public function testUpsertDocumentsAttributeMismatch(): void ]); // Ensure missing optionals on new document is allowed - $docs = $database->createOrUpdateDocuments(__FUNCTION__, [ + $docs = $database->upsertDocuments(__FUNCTION__, [ $existingDocument->setAttribute('first', 'updated'), $newDocument, ]); @@ -899,7 +899,7 @@ public function testUpsertDocumentsAttributeMismatch(): void $this->assertEquals('', $newDocument->getAttribute('last')); try { - $database->createOrUpdateDocuments(__FUNCTION__, [ + $database->upsertDocuments(__FUNCTION__, [ $existingDocument->removeAttribute('first'), $newDocument ]); @@ -909,7 +909,7 @@ public function testUpsertDocumentsAttributeMismatch(): void } // Ensure missing optionals on existing document is allowed - $docs = $database->createOrUpdateDocuments(__FUNCTION__, [ + $docs = $database->upsertDocuments(__FUNCTION__, [ $existingDocument ->setAttribute('first', 'first') ->removeAttribute('last'), @@ -924,7 +924,7 @@ public function testUpsertDocumentsAttributeMismatch(): void $this->assertEquals('last', $newDocument->getAttribute('last')); // Ensure set null on existing document is allowed - $docs = $database->createOrUpdateDocuments(__FUNCTION__, [ + $docs = $database->upsertDocuments(__FUNCTION__, [ $existingDocument ->setAttribute('first', 'first') ->setAttribute('last', null), @@ -951,7 +951,7 @@ public function testUpsertDocumentsAttributeMismatch(): void ]); // Ensure mismatch of attribute orders is allowed - $docs = $database->createOrUpdateDocuments(__FUNCTION__, [ + $docs = $database->upsertDocuments(__FUNCTION__, [ $doc3, $doc4 ]); @@ -992,11 +992,11 @@ public function testUpsertDocumentsNoop(): void ], ]); - $count = static::getDatabase()->createOrUpdateDocuments(__FUNCTION__, [$document]); + $count = static::getDatabase()->upsertDocuments(__FUNCTION__, [$document]); $this->assertEquals(1, $count); // No changes, should return 0 - $count = static::getDatabase()->createOrUpdateDocuments(__FUNCTION__, [$document]); + $count = static::getDatabase()->upsertDocuments(__FUNCTION__, [$document]); $this->assertEquals(0, $count); } @@ -1015,7 +1015,7 @@ public function testUpsertDuplicateIds(): void $doc2 = new Document(['$id' => 'dup', 'num' => 2]); try { - $db->createOrUpdateDocuments(__FUNCTION__, [$doc1, $doc2]); + $db->upsertDocuments(__FUNCTION__, [$doc1, $doc2]); $this->fail('Failed to throw exception'); } catch (\Throwable $e) { $this->assertInstanceOf(DuplicateException::class, $e, $e->getMessage()); @@ -1057,7 +1057,7 @@ public function testUpsertMixedPermissionDelta(): void Permission::read(Role::any()) ]); - $db->createOrUpdateDocuments(__FUNCTION__, [$d1, $d2]); + $db->upsertDocuments(__FUNCTION__, [$d1, $d2]); $this->assertEquals([ Permission::read(Role::any()), @@ -2770,6 +2770,98 @@ public function testFindUpdatedAfter(): void $this->assertEquals(0, count($documents)); } + public function testFindCreatedBetween(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + /** + * Test Query::createdBetween wrapper + */ + $pastDate = '1900-01-01T00:00:00.000Z'; + $futureDate = '2050-01-01T00:00:00.000Z'; + $recentPastDate = '2020-01-01T00:00:00.000Z'; + $nearFutureDate = '2025-01-01T00:00:00.000Z'; + + // All documents should be between past and future + $documents = $database->find('movies', [ + Query::createdBetween($pastDate, $futureDate), + Query::limit(25) + ]); + + $this->assertGreaterThan(0, count($documents)); + + // No documents should exist in this range + $documents = $database->find('movies', [ + Query::createdBetween($pastDate, $pastDate), + Query::limit(25) + ]); + + $this->assertEquals(0, count($documents)); + + // Documents created between recent past and near future + $documents = $database->find('movies', [ + Query::createdBetween($recentPastDate, $nearFutureDate), + Query::limit(25) + ]); + + $count = count($documents); + + // Same count should be returned with expanded range + $documents = $database->find('movies', [ + Query::createdBetween($pastDate, $nearFutureDate), + Query::limit(25) + ]); + + $this->assertGreaterThanOrEqual($count, count($documents)); + } + + public function testFindUpdatedBetween(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + /** + * Test Query::updatedBetween wrapper + */ + $pastDate = '1900-01-01T00:00:00.000Z'; + $futureDate = '2050-01-01T00:00:00.000Z'; + $recentPastDate = '2020-01-01T00:00:00.000Z'; + $nearFutureDate = '2025-01-01T00:00:00.000Z'; + + // All documents should be between past and future + $documents = $database->find('movies', [ + Query::updatedBetween($pastDate, $futureDate), + Query::limit(25) + ]); + + $this->assertGreaterThan(0, count($documents)); + + // No documents should exist in this range + $documents = $database->find('movies', [ + Query::updatedBetween($pastDate, $pastDate), + Query::limit(25) + ]); + + $this->assertEquals(0, count($documents)); + + // Documents updated between recent past and near future + $documents = $database->find('movies', [ + Query::updatedBetween($recentPastDate, $nearFutureDate), + Query::limit(25) + ]); + + $count = count($documents); + + // Same count should be returned with expanded range + $documents = $database->find('movies', [ + Query::updatedBetween($pastDate, $nearFutureDate), + Query::limit(25) + ]); + + $this->assertGreaterThanOrEqual($count, count($documents)); + } + public function testFindLimit(): void { /** @var Database $database */ @@ -3392,79 +3484,79 @@ public function testFindNotContains(): void // $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 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 { @@ -5577,7 +5669,7 @@ public function testUpsertDateOperations(): void // Test 1: Upsert new document with custom createdAt $upsertResults = []; - $database->createOrUpdateDocuments($collection, [ + $database->upsertDocuments($collection, [ new Document([ '$id' => 'upsert1', '$permissions' => $permissions, @@ -5596,7 +5688,7 @@ public function testUpsertDateOperations(): void $upsertDoc1->setAttribute('string', 'upsert1_updated'); $upsertDoc1->setAttribute('$updatedAt', $updateDate); $updatedUpsertResults = []; - $database->createOrUpdateDocuments($collection, [$upsertDoc1], onNext: function ($doc) use (&$updatedUpsertResults) { + $database->upsertDocuments($collection, [$upsertDoc1], onNext: function ($doc) use (&$updatedUpsertResults) { $updatedUpsertResults[] = $doc; }); $updatedUpsertDoc1 = $updatedUpsertResults[0]; @@ -5606,7 +5698,7 @@ public function testUpsertDateOperations(): void // Test 3: Upsert new document with both custom dates $upsertResults2 = []; - $database->createOrUpdateDocuments($collection, [ + $database->upsertDocuments($collection, [ new Document([ '$id' => 'upsert2', '$permissions' => $permissions, @@ -5627,7 +5719,7 @@ public function testUpsertDateOperations(): void $upsertDoc2->setAttribute('$createdAt', $date3); $upsertDoc2->setAttribute('$updatedAt', $date3); $updatedUpsertResults2 = []; - $database->createOrUpdateDocuments($collection, [$upsertDoc2], onNext: function ($doc) use (&$updatedUpsertResults2) { + $database->upsertDocuments($collection, [$upsertDoc2], onNext: function ($doc) use (&$updatedUpsertResults2) { $updatedUpsertResults2[] = $doc; }); $updatedUpsertDoc2 = $updatedUpsertResults2[0]; @@ -5640,7 +5732,7 @@ public function testUpsertDateOperations(): void $customDate = '2000-01-01T10:00:00.000+00:00'; $upsertResults3 = []; - $database->createOrUpdateDocuments($collection, [ + $database->upsertDocuments($collection, [ new Document([ '$id' => 'upsert3', '$permissions' => $permissions, @@ -5661,7 +5753,7 @@ public function testUpsertDateOperations(): void $upsertDoc3->setAttribute('$createdAt', $customDate); $upsertDoc3->setAttribute('$updatedAt', $customDate); $updatedUpsertResults3 = []; - $database->createOrUpdateDocuments($collection, [$upsertDoc3], onNext: function ($doc) use (&$updatedUpsertResults3) { + $database->upsertDocuments($collection, [$upsertDoc3], onNext: function ($doc) use (&$updatedUpsertResults3) { $updatedUpsertResults3[] = $doc; }); $updatedUpsertDoc3 = $updatedUpsertResults3[0]; @@ -5701,7 +5793,7 @@ public function testUpsertDateOperations(): void ]; $bulkUpsertResults = []; - $database->createOrUpdateDocuments($collection, $upsertDocuments, onNext: function ($doc) use (&$bulkUpsertResults) { + $database->upsertDocuments($collection, $upsertDocuments, onNext: function ($doc) use (&$bulkUpsertResults) { $bulkUpsertResults[] = $doc; }); @@ -5768,7 +5860,7 @@ public function testUpsertDateOperations(): void $this->assertNotEmpty($doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); } - // Test 11: Bulk upsert operations with createOrUpdateDocuments + // Test 11: Bulk upsert operations with upsertDocuments $upsertUpdateDocuments = []; foreach ($upsertDocuments as $doc) { $updatedDoc = clone $doc; @@ -5779,7 +5871,7 @@ public function testUpsertDateOperations(): void } $upsertUpdateResults = []; - $countUpsertUpdate = $database->createOrUpdateDocuments($collection, $upsertUpdateDocuments, onNext: function ($doc) use (&$upsertUpdateResults) { + $countUpsertUpdate = $database->upsertDocuments($collection, $upsertUpdateDocuments, onNext: function ($doc) use (&$upsertUpdateResults) { $upsertUpdateResults[] = $doc; }); $this->assertEquals(4, $countUpsertUpdate); @@ -5804,7 +5896,7 @@ public function testUpsertDateOperations(): void } $upsertDisabledResults = []; - $countUpsertDisabled = $database->createOrUpdateDocuments($collection, $upsertDisabledDocuments, onNext: function ($doc) use (&$upsertDisabledResults) { + $countUpsertDisabled = $database->upsertDocuments($collection, $upsertDisabledDocuments, onNext: function ($doc) use (&$upsertDisabledResults) { $upsertDisabledResults[] = $doc; }); $this->assertEquals(4, $countUpsertDisabled); @@ -5859,7 +5951,7 @@ public function testUpdateDocumentsCount(): void ]) ]; $upsertUpdateResults = []; - $count = $database->createOrUpdateDocuments($collectionName, $docs, onNext: function ($doc) use (&$upsertUpdateResults) { + $count = $database->upsertDocuments($collectionName, $docs, onNext: function ($doc) use (&$upsertUpdateResults) { $upsertUpdateResults[] = $doc; }); $this->assertCount(4, $upsertUpdateResults); diff --git a/tests/e2e/Adapter/Scopes/GeneralTests.php b/tests/e2e/Adapter/Scopes/GeneralTests.php index 795f4d096..1664273c1 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -512,7 +512,7 @@ public function testSharedTablesTenantPerDocument(): void $database ->setTenant(null) ->setTenantPerDocument(true) - ->createOrUpdateDocuments(__FUNCTION__, [new Document([ + ->upsertDocuments(__FUNCTION__, [new Document([ '$id' => $doc3Id, '$tenant' => 3, 'name' => 'Superman3', @@ -550,7 +550,7 @@ public function testSharedTablesTenantPerDocument(): void $database ->setTenant(null) ->setTenantPerDocument(true) - ->createOrUpdateDocuments(__FUNCTION__, [new Document([ + ->upsertDocuments(__FUNCTION__, [new Document([ '$id' => $doc4Id, '$tenant' => 4, 'name' => 'Superman4', @@ -582,7 +582,7 @@ public function testSharedTablesTenantPerDocument(): void $database ->setTenant(null) ->setTenantPerDocument(true) - ->createOrUpdateDocuments(__FUNCTION__, [new Document([ + ->upsertDocuments(__FUNCTION__, [new Document([ '$id' => $doc4Id, '$tenant' => 4, 'name' => 'Superman4 updated', diff --git a/tests/e2e/Adapter/Scopes/SpatialTests.php b/tests/e2e/Adapter/Scopes/SpatialTests.php index 8f6c2898b..92ebd2c7e 100644 --- a/tests/e2e/Adapter/Scopes/SpatialTests.php +++ b/tests/e2e/Adapter/Scopes/SpatialTests.php @@ -4,6 +4,10 @@ use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception; +use Utopia\Database\Exception\Index as IndexException; +use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; @@ -114,7 +118,15 @@ public function testSpatialTypeDocuments(): void '$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]]], + 'polyAttr' => [ + [ + [0.0, 0.0], + [0.0, 10.0], + [10.0, 10.0], + [10.0, 0.0], + [0.0, 0.0] + ] + ], '$permissions' => [Permission::update(Role::any()), Permission::read(Role::any())] ]); $createdDoc = $database->createDocument($collectionName, $doc1); @@ -124,8 +136,10 @@ public function testSpatialTypeDocuments(): void // 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 = [ @@ -133,8 +147,8 @@ public function testSpatialTypeDocuments(): void '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]]) + 'intersects' => Query::intersects('pointAttr', [6.0, 6.0]), + 'notIntersects' => Query::notIntersects('pointAttr', [1.0, 1.0]) ]; foreach ($pointQueries as $queryType => $query) { @@ -149,8 +163,8 @@ public function testSpatialTypeDocuments(): void '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 + '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) { @@ -180,12 +194,20 @@ public function testSpatialTypeDocuments(): void $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 + 'intersects' => Query::intersects('polyAttr', [0.0, 0.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], + [10.0, 0.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 + '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) { @@ -211,7 +233,7 @@ public function testSpatialTypeDocuments(): void $this->assertEquals('doc1', $result[0]->getId(), sprintf('Incorrect document for distance %s on polyAttr', $queryType)); } } finally { - // $database->deleteCollection($collectionName); + $database->deleteCollection($collectionName); } } @@ -340,7 +362,12 @@ public function testSpatialAttributes(): void // 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'])); + if ($database->getAdapter()->getSupportForSpatialIndexNull()) { + $this->assertEquals(true, $database->createIndex($collectionName, 'idx_line', Database::INDEX_SPATIAL, ['lineAttr'])); + } else { + // Attribute was created as required above; directly create index once + $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); @@ -833,6 +860,53 @@ public function testSpatialIndex(): void } finally { $database->deleteCollection($collNullIndex); } + + $collUpdateNull = 'spatial_idx_req'; + try { + $database->createCollection($collUpdateNull); + + $database->createAttribute($collUpdateNull, 'loc', Database::VAR_POINT, 0, false); + if (!$nullSupported) { + try { + $database->createIndex($collUpdateNull, 'idx_loc_required', Database::INDEX_SPATIAL, ['loc']); + $this->fail('Expected exception when creating spatial index on NULL-able attribute'); + } catch (\Throwable $e) { + $this->assertInstanceOf(Exception::class, $e); + } + } else { + $this->assertTrue($database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_SPATIAL, ['loc'])); + } + + $database->updateAttribute($collUpdateNull, 'loc', required: true); + + $this->assertTrue($database->createIndex($collUpdateNull, 'idx_loc_req', Database::INDEX_SPATIAL, ['loc'])); + } finally { + $database->deleteCollection($collUpdateNull); + } + + + $collUpdateNull = 'spatial_idx_index_null_required_true'; + try { + $database->createCollection($collUpdateNull); + + $database->createAttribute($collUpdateNull, 'loc', Database::VAR_POINT, 0, false); + if (!$nullSupported) { + try { + $database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_SPATIAL, ['loc']); + $this->fail('Expected exception when creating spatial index on NULL-able attribute'); + } catch (\Throwable $e) { + $this->assertInstanceOf(Exception::class, $e); + } + } else { + $this->assertTrue($database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_SPATIAL, ['loc'])); + } + + $database->updateAttribute($collUpdateNull, 'loc', required: true); + + $this->assertTrue($database->createIndex($collUpdateNull, 'new index', Database::INDEX_SPATIAL, ['loc'])); + } finally { + $database->deleteCollection($collUpdateNull); + } } public function testComplexGeometricShapes(): void @@ -928,8 +1002,8 @@ public function testComplexGeometricShapes(): void // 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]]]) + 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); @@ -1035,7 +1109,7 @@ public function testComplexGeometricShapes(): void ], Database::PERMISSION_READ); } else { $exactSquare = $database->find($collectionName, [ - Query::intersects('square', [[[5, 5], [5, 15], [15, 15], [15, 5], [5, 5]]]) + Query::intersects('square', [[5, 5], [5, 15], [15, 15], [15, 5], [5, 5]]) ], Database::PERMISSION_READ); } $this->assertNotEmpty($exactSquare); @@ -1082,13 +1156,13 @@ public function testComplexGeometricShapes(): void // Test triangle intersects with point $intersectingTriangle = $database->find($collectionName, [ - Query::intersects('triangle', [[25, 10]]) // Point inside triangle should intersect + 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 + Query::notIntersects('triangle', [10, 10]) // Distant point should not intersect ], Database::PERMISSION_READ); $this->assertNotEmpty($nonIntersectingTriangle); @@ -1152,7 +1226,7 @@ public function testComplexGeometricShapes(): void // Test complex polygon intersects with line $intersectingLine = $database->find($collectionName, [ - Query::intersects('complex_polygon', [[[0, 10], [20, 10]]]) // Horizontal line through L-shape + Query::intersects('complex_polygon', [[0, 10], [20, 10]]) // Horizontal line through L-shape ], Database::PERMISSION_READ); $this->assertNotEmpty($intersectingLine); @@ -1174,13 +1248,13 @@ public function testComplexGeometricShapes(): void // Test linestring intersects with point $intersectingPoint = $database->find($collectionName, [ - Query::intersects('multi_linestring', [[10, 10]]) // Point on diagonal line + 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]]]) + Query::intersects('multi_linestring', [[0, 20], [20, 20]]) ], Database::PERMISSION_READ); $this->assertNotEmpty($touchingLine); @@ -1414,7 +1488,6 @@ public function testSpatialBulkOperation(): void 'required' => true, 'signed' => true, 'array' => false, - 'filters' => [], ]), new Document([ '$id' => ID::custom('location'), @@ -1423,7 +1496,6 @@ public function testSpatialBulkOperation(): void 'required' => true, 'signed' => true, 'array' => false, - 'filters' => [], ]), new Document([ '$id' => ID::custom('area'), @@ -1432,7 +1504,6 @@ public function testSpatialBulkOperation(): void 'required' => false, 'signed' => true, 'array' => false, - 'filters' => [], ]) ]; @@ -1546,6 +1617,19 @@ public function testSpatialBulkOperation(): void $updateResults[] = $doc; }); + // should fail due to invalid structure + try { + $database->updateDocuments($collectionName, new Document([ + 'name' => 'Updated Location', + 'location' => [15.0, 25.0], + 'area' => [15.0, 25.0] // invalid polygon + ])); + $this->fail("fail to throw structure exception for the invalid spatial type"); + } catch (\Throwable $th) { + $this->assertInstanceOf(StructureException::class, $th); + + } + $this->assertGreaterThan(0, $updateCount); // Verify updated documents @@ -1561,7 +1645,7 @@ public function testSpatialBulkOperation(): void ]], $document->getAttribute('area')); } - // Test 3: createOrUpdateDocuments with spatial data + // Test 3: upsertDocuments with spatial data $upsertDocuments = [ new Document([ '$id' => 'upsert1', @@ -1602,7 +1686,7 @@ public function testSpatialBulkOperation(): void ]; $upsertResults = []; - $upsertCount = $database->createOrUpdateDocuments($collectionName, $upsertDocuments, onNext: function ($doc) use (&$upsertResults) { + $upsertCount = $database->upsertDocuments($collectionName, $upsertDocuments, onNext: function ($doc) use (&$upsertResults) { $upsertResults[] = $doc; }); @@ -1773,4 +1857,894 @@ public function testSptialAggregation(): void $database->deleteCollection($collectionName); } } + + public function testUpdateSpatialAttributes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->markTestSkipped('Adapter does not support spatial attributes'); + } + + $collectionName = 'spatial_update_attrs_'; + try { + $database->createCollection($collectionName); + + // 0) Disallow creation of spatial attributes with size or array + try { + $database->createAttribute($collectionName, 'geom_bad_size', Database::VAR_POINT, 10, true); + $this->fail('Expected DatabaseException when creating spatial attribute with non-zero size'); + } catch (\Throwable $e) { + $this->assertInstanceOf(Exception::class, $e); + } + + try { + $database->createAttribute($collectionName, 'geom_bad_array', Database::VAR_POINT, 0, true, array: true); + $this->fail('Expected DatabaseException when creating spatial attribute with array=true'); + } catch (\Throwable $e) { + $this->assertInstanceOf(Exception::class, $e); + } + + // Create a single spatial attribute (required=true) + $this->assertEquals(true, $database->createAttribute($collectionName, 'geom', Database::VAR_POINT, 0, true)); + $this->assertEquals(true, $database->createIndex($collectionName, 'idx_geom', Database::INDEX_SPATIAL, ['geom'])); + + // 1) Disallow size and array updates on spatial attributes: expect DatabaseException + try { + $database->updateAttribute($collectionName, 'geom', size: 10); + $this->fail('Expected DatabaseException when updating size on spatial attribute'); + } catch (\Throwable $e) { + $this->assertInstanceOf(Exception::class, $e); + } + + try { + $database->updateAttribute($collectionName, 'geom', array: true); + $this->fail('Expected DatabaseException when updating array on spatial attribute'); + } catch (\Throwable $e) { + $this->assertInstanceOf(Exception::class, $e); + } + + // 2) required=true -> create index -> update required=false + $nullSupported = $database->getAdapter()->getSupportForSpatialIndexNull(); + if ($nullSupported) { + // Should succeed on adapters that allow nullable spatial indexes + $database->updateAttribute($collectionName, 'geom', required: false); + $meta = $database->getCollection($collectionName); + $this->assertEquals(false, $meta->getAttribute('attributes')[0]['required']); + } else { + // Should error (index constraint) when making required=false while spatial index exists + $threw = false; + try { + $database->updateAttribute($collectionName, 'geom', required: false); + } catch (\Throwable $e) { + $threw = true; + } + $this->assertTrue($threw, 'Expected error when setting required=false with existing spatial index and adapter not supporting nullable indexes'); + // Ensure attribute remains required + $meta = $database->getCollection($collectionName); + $this->assertEquals(true, $meta->getAttribute('attributes')[0]['required']); + } + + // 3) Spatial index order support: providing orders should fail if not supported + $orderSupported = $database->getAdapter()->getSupportForSpatialIndexOrder(); + if ($orderSupported) { + $this->assertTrue($database->createIndex($collectionName, 'idx_geom_desc', Database::INDEX_SPATIAL, ['geom'], [], [Database::ORDER_DESC])); + // cleanup + $this->assertTrue($database->deleteIndex($collectionName, 'idx_geom_desc')); + } else { + try { + $database->createIndex($collectionName, 'idx_geom_desc', Database::INDEX_SPATIAL, ['geom'], [], ['DESC']); + $this->fail('Expected error when providing orders for spatial index on adapter without order support'); + } catch (\Throwable $e) { + $this->assertTrue(true); + } + } + } finally { + $database->deleteCollection($collectionName); + } + } + + public function testSpatialAttributeDefaults(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->markTestSkipped('Adapter does not support spatial attributes'); + } + + $collectionName = 'spatial_defaults_'; + try { + $database->createCollection($collectionName); + + // Create spatial attributes with defaults and no indexes to avoid nullability/index constraints + $this->assertEquals(true, $database->createAttribute($collectionName, 'pt', Database::VAR_POINT, 0, false, [1.0, 2.0])); + $this->assertEquals(true, $database->createAttribute($collectionName, 'ln', Database::VAR_LINESTRING, 0, false, [[0.0, 0.0], [1.0, 1.0]])); + $this->assertEquals(true, $database->createAttribute($collectionName, 'pg', Database::VAR_POLYGON, 0, false, [[[0.0, 0.0], [0.0, 2.0], [2.0, 2.0], [0.0, 0.0]]])); + + // Create non-spatial attributes (mix of defaults and no defaults) + $this->assertEquals(true, $database->createAttribute($collectionName, 'title', Database::VAR_STRING, 255, false, 'Untitled')); + $this->assertEquals(true, $database->createAttribute($collectionName, 'count', Database::VAR_INTEGER, 0, false, 0)); + $this->assertEquals(true, $database->createAttribute($collectionName, 'rating', Database::VAR_FLOAT, 0, false)); // no default + $this->assertEquals(true, $database->createAttribute($collectionName, 'active', Database::VAR_BOOLEAN, 0, false, true)); + + // Create document without providing spatial values, expect defaults applied + $doc = $database->createDocument($collectionName, new Document([ + '$id' => ID::custom('d1'), + '$permissions' => [Permission::read(Role::any())] + ])); + $this->assertInstanceOf(Document::class, $doc); + $this->assertEquals([1.0, 2.0], $doc->getAttribute('pt')); + $this->assertEquals([[0.0, 0.0], [1.0, 1.0]], $doc->getAttribute('ln')); + $this->assertEquals([[[0.0, 0.0], [0.0, 2.0], [2.0, 2.0], [0.0, 0.0]]], $doc->getAttribute('pg')); + // Non-spatial defaults + $this->assertEquals('Untitled', $doc->getAttribute('title')); + $this->assertEquals(0, $doc->getAttribute('count')); + $this->assertNull($doc->getAttribute('rating')); + $this->assertTrue($doc->getAttribute('active')); + + // Create document overriding defaults + $doc2 = $database->createDocument($collectionName, new Document([ + '$id' => ID::custom('d2'), + '$permissions' => [Permission::read(Role::any())], + 'pt' => [9.0, 9.0], + 'ln' => [[2.0, 2.0], [3.0, 3.0]], + 'pg' => [[[1.0, 1.0], [1.0, 3.0], [3.0, 3.0], [1.0, 1.0]]], + 'title' => 'Custom', + 'count' => 5, + 'rating' => 4.5, + 'active' => false + ])); + $this->assertInstanceOf(Document::class, $doc2); + $this->assertEquals([9.0, 9.0], $doc2->getAttribute('pt')); + $this->assertEquals([[2.0, 2.0], [3.0, 3.0]], $doc2->getAttribute('ln')); + $this->assertEquals([[[1.0, 1.0], [1.0, 3.0], [3.0, 3.0], [1.0, 1.0]]], $doc2->getAttribute('pg')); + $this->assertEquals('Custom', $doc2->getAttribute('title')); + $this->assertEquals(5, $doc2->getAttribute('count')); + $this->assertEquals(4.5, $doc2->getAttribute('rating')); + $this->assertFalse($doc2->getAttribute('active')); + + // Update defaults and ensure they are applied for new documents + $database->updateAttributeDefault($collectionName, 'pt', [5.0, 6.0]); + $database->updateAttributeDefault($collectionName, 'ln', [[10.0, 10.0], [20.0, 20.0]]); + $database->updateAttributeDefault($collectionName, 'pg', [[[5.0, 5.0], [5.0, 7.0], [7.0, 7.0], [5.0, 5.0]]]); + $database->updateAttributeDefault($collectionName, 'title', 'Updated'); + $database->updateAttributeDefault($collectionName, 'count', 10); + $database->updateAttributeDefault($collectionName, 'active', false); + + $doc3 = $database->createDocument($collectionName, new Document([ + '$id' => ID::custom('d3'), + '$permissions' => [Permission::read(Role::any())] + ])); + $this->assertInstanceOf(Document::class, $doc3); + $this->assertEquals([5.0, 6.0], $doc3->getAttribute('pt')); + $this->assertEquals([[10.0, 10.0], [20.0, 20.0]], $doc3->getAttribute('ln')); + $this->assertEquals([[[5.0, 5.0], [5.0, 7.0], [7.0, 7.0], [5.0, 5.0]]], $doc3->getAttribute('pg')); + $this->assertEquals('Updated', $doc3->getAttribute('title')); + $this->assertEquals(10, $doc3->getAttribute('count')); + $this->assertNull($doc3->getAttribute('rating')); + $this->assertFalse($doc3->getAttribute('active')); + + // Invalid defaults should raise errors + try { + $database->updateAttributeDefault($collectionName, 'pt', [[1.0, 2.0]]); // wrong dimensionality + $this->fail('Expected exception for invalid point default shape'); + } catch (\Throwable $e) { + $this->assertTrue(true); + } + try { + $database->updateAttributeDefault($collectionName, 'ln', [1.0, 2.0]); // wrong dimensionality + $this->fail('Expected exception for invalid linestring default shape'); + } catch (\Throwable $e) { + $this->assertTrue(true); + } + try { + $database->updateAttributeDefault($collectionName, 'pg', [[1.0, 2.0]]); // wrong dimensionality + $this->fail('Expected exception for invalid polygon default shape'); + } catch (\Throwable $e) { + $this->assertTrue(true); + } + } finally { + $database->deleteCollection($collectionName); + } + } + + public function testInvalidSpatialTypes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->markTestSkipped('Adapter does not support spatial attributes'); + } + + $collectionName = 'test_invalid_spatial_types'; + + $attributes = [ + new Document([ + '$id' => ID::custom('pointAttr'), + 'type' => Database::VAR_POINT, + 'size' => 0, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => ID::custom('lineAttr'), + 'type' => Database::VAR_LINESTRING, + 'size' => 0, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => ID::custom('polyAttr'), + 'type' => Database::VAR_POLYGON, + 'size' => 0, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]) + ]; + + $database->createCollection($collectionName, $attributes); + + // Invalid Point (must be [x, y]) + try { + $database->createDocument($collectionName, new Document([ + 'pointAttr' => [10.0], // only 1 coordinate + ])); + $this->fail("Expected StructureException for invalid point"); + } catch (\Throwable $th) { + $this->assertInstanceOf(StructureException::class, $th); + } + + // Invalid LineString (must be [[x,y],[x,y],...], at least 2 points) + try { + $database->createDocument($collectionName, new Document([ + 'lineAttr' => [[10.0, 20.0]], // only one point + ])); + $this->fail("Expected StructureException for invalid line"); + } catch (\Throwable $th) { + $this->assertInstanceOf(StructureException::class, $th); + } + + try { + $database->createDocument($collectionName, new Document([ + 'lineAttr' => [10.0, 20.0], // not an array of arrays + ])); + $this->fail("Expected StructureException for invalid line structure"); + } catch (\Throwable $th) { + $this->assertInstanceOf(StructureException::class, $th); + } + + try { + $database->createDocument($collectionName, new Document([ + 'polyAttr' => [10.0, 20.0] // not an array of arrays + ])); + $this->fail("Expected StructureException for invalid polygon structure"); + } catch (\Throwable $th) { + $this->assertInstanceOf(StructureException::class, $th); + } + + $invalidPolygons = [ + [[0,0],[1,1],[0,1]], + [[0,0],['a',1],[1,1],[0,0]], + [[0,0],[1,0],[1,1],[0,1]], + [], + [[0,0,5],[1,0,5],[1,1,5],[0,0,5]], + [ + [[0,0],[2,0],[2,2],[0,0]], // valid + [[0,0,1],[1,0,1],[1,1,1],[0,0,1]] // invalid 3D + ] + ]; + foreach ($invalidPolygons as $invalidPolygon) { + try { + $database->createDocument($collectionName, new Document([ + 'polyAttr' => $invalidPolygon + ])); + $this->fail("Expected StructureException for invalid polygon structure"); + } catch (\Throwable $th) { + $this->assertInstanceOf(StructureException::class, $th); + } + } + // Cleanup + $database->deleteCollection($collectionName); + } + + public function testSpatialDistanceInMeter(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->markTestSkipped('Adapter does not support spatial attributes'); + } + + $collectionName = 'spatial_distance_meters_'; + try { + $database->createCollection($collectionName); + $this->assertEquals(true, $database->createAttribute($collectionName, 'loc', Database::VAR_POINT, 0, true)); + $this->assertEquals(true, $database->createIndex($collectionName, 'idx_loc', Database::INDEX_SPATIAL, ['loc'])); + + // Two points roughly ~1000 meters apart by latitude delta (~0.009 deg ≈ 1km) + $p0 = $database->createDocument($collectionName, new Document([ + '$id' => 'p0', + 'loc' => [0.0000, 0.0000], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ])); + $p1 = $database->createDocument($collectionName, new Document([ + '$id' => 'p1', + 'loc' => [0.0090, 0.0000], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ])); + + $this->assertInstanceOf(Document::class, $p0); + $this->assertInstanceOf(Document::class, $p1); + + // distanceLessThan with meters=true: within 1500m should include both + $within1_5km = $database->find($collectionName, [ + Query::distanceLessThan('loc', [0.0000, 0.0000], 1500, true) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($within1_5km); + $this->assertCount(2, $within1_5km); + + // Within 500m should include only p0 (exact point) + $within500m = $database->find($collectionName, [ + Query::distanceLessThan('loc', [0.0000, 0.0000], 500, true) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($within500m); + $this->assertCount(1, $within500m); + $this->assertEquals('p0', $within500m[0]->getId()); + + // distanceGreaterThan 500m should include only p1 + $greater500m = $database->find($collectionName, [ + Query::distanceGreaterThan('loc', [0.0000, 0.0000], 500, true) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($greater500m); + $this->assertCount(1, $greater500m); + $this->assertEquals('p1', $greater500m[0]->getId()); + + // distanceEqual with 0m should return exact match p0 + $equalZero = $database->find($collectionName, [ + Query::distanceEqual('loc', [0.0000, 0.0000], 0, true) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($equalZero); + $this->assertEquals('p0', $equalZero[0]->getId()); + + // distanceNotEqual with 0m should return p1 + $notEqualZero = $database->find($collectionName, [ + Query::distanceNotEqual('loc', [0.0000, 0.0000], 0, true) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($notEqualZero); + $this->assertEquals('p1', $notEqualZero[0]->getId()); + } finally { + $database->deleteCollection($collectionName); + } + } + + public function testSpatialDistanceInMeterForMultiDimensionGeometry(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->markTestSkipped('Adapter does not support spatial attributes'); + } + + if (!$database->getAdapter()->getSupportForDistanceBetweenMultiDimensionGeometryInMeters()) { + $this->markTestSkipped('Adapter does not support spatial distance(in meter) for multidimension'); + } + + $multiCollection = 'spatial_distance_meters_multi_'; + try { + $database->createCollection($multiCollection); + + // Create spatial attributes + $this->assertEquals(true, $database->createAttribute($multiCollection, 'loc', Database::VAR_POINT, 0, true)); + $this->assertEquals(true, $database->createAttribute($multiCollection, 'line', Database::VAR_LINESTRING, 0, true)); + $this->assertEquals(true, $database->createAttribute($multiCollection, 'poly', Database::VAR_POLYGON, 0, true)); + + // Create indexes + $this->assertEquals(true, $database->createIndex($multiCollection, 'idx_loc', Database::INDEX_SPATIAL, ['loc'])); + $this->assertEquals(true, $database->createIndex($multiCollection, 'idx_line', Database::INDEX_SPATIAL, ['line'])); + $this->assertEquals(true, $database->createIndex($multiCollection, 'idx_poly', Database::INDEX_SPATIAL, ['poly'])); + + // Geometry sets: near origin and far east + $docNear = $database->createDocument($multiCollection, new Document([ + '$id' => 'near', + 'loc' => [0.0000, 0.0000], + 'line' => [[0.0000, 0.0000], [0.0010, 0.0000]], // ~111m + 'poly' => [[ + [-0.0010, -0.0010], + [-0.0010, 0.0010], + [ 0.0010, 0.0010], + [ 0.0010, -0.0010], + [-0.0010, -0.0010] // closed + ]], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ])); + + $docFar = $database->createDocument($multiCollection, new Document([ + '$id' => 'far', + 'loc' => [0.2000, 0.0000], // ~22 km east + 'line' => [[0.2000, 0.0000], [0.2020, 0.0000]], + 'poly' => [[ + [0.1980, -0.0020], + [0.1980, 0.0020], + [0.2020, 0.0020], + [0.2020, -0.0020], + [0.1980, -0.0020] // closed + ]], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ])); + + $this->assertInstanceOf(Document::class, $docNear); + $this->assertInstanceOf(Document::class, $docFar); + + // polygon vs polygon (~1 km from near, ~22 km from far) + $polyPolyWithin3km = $database->find($multiCollection, [ + Query::distanceLessThan('poly', [[ + [0.0080, -0.0010], + [0.0080, 0.0010], + [0.0110, 0.0010], + [0.0110, -0.0010], + [0.0080, -0.0010] // closed + ]], 3000, true) + ], Database::PERMISSION_READ); + $this->assertCount(1, $polyPolyWithin3km); + $this->assertEquals('near', $polyPolyWithin3km[0]->getId()); + + $polyPolyGreater3km = $database->find($multiCollection, [ + Query::distanceGreaterThan('poly', [[ + [0.0080, -0.0010], + [0.0080, 0.0010], + [0.0110, 0.0010], + [0.0110, -0.0010], + [0.0080, -0.0010] // closed + ]], 3000, true) + ], Database::PERMISSION_READ); + $this->assertCount(1, $polyPolyGreater3km); + $this->assertEquals('far', $polyPolyGreater3km[0]->getId()); + + // point vs polygon (~0 km near, ~22 km far) + $ptPolyWithin500 = $database->find($multiCollection, [ + Query::distanceLessThan('loc', [[ + [-0.0010, -0.0010], + [-0.0010, 0.0020], + [ 0.0020, 0.0020], + [-0.0010, -0.0010] + ]], 500, true) + ], Database::PERMISSION_READ); + $this->assertCount(1, $ptPolyWithin500); + $this->assertEquals('near', $ptPolyWithin500[0]->getId()); + + $ptPolyGreater500 = $database->find($multiCollection, [ + Query::distanceGreaterThan('loc', [[ + [-0.0010, -0.0010], + [-0.0010, 0.0020], + [ 0.0020, 0.0020], + [-0.0010, -0.0010] + ]], 500, true) + ], Database::PERMISSION_READ); + $this->assertCount(1, $ptPolyGreater500); + $this->assertEquals('far', $ptPolyGreater500[0]->getId()); + + // Zero-distance checks + $lineEqualZero = $database->find($multiCollection, [ + Query::distanceEqual('line', [[0.0000, 0.0000], [0.0010, 0.0000]], 0, true) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($lineEqualZero); + $this->assertEquals('near', $lineEqualZero[0]->getId()); + + $polyEqualZero = $database->find($multiCollection, [ + Query::distanceEqual('poly', [[ + [-0.0010, -0.0010], + [-0.0010, 0.0010], + [ 0.0010, 0.0010], + [ 0.0010, -0.0010], + [-0.0010, -0.0010] + ]], 0, true) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($polyEqualZero); + $this->assertEquals('near', $polyEqualZero[0]->getId()); + + } finally { + $database->deleteCollection($multiCollection); + } + } + + public function testSpatialDistanceInMeterError(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->markTestSkipped('Adapter does not support spatial attributes'); + } + + if ($database->getAdapter()->getSupportForDistanceBetweenMultiDimensionGeometryInMeters()) { + $this->markTestSkipped('Adapter supports spatial distance (in meter) for multidimension geometries'); + } + + $collection = 'spatial_distance_error_test'; + $database->createCollection($collection); + $this->assertEquals(true, $database->createAttribute($collection, 'loc', Database::VAR_POINT, 0, true)); + $this->assertEquals(true, $database->createAttribute($collection, 'line', Database::VAR_LINESTRING, 0, true)); + $this->assertEquals(true, $database->createAttribute($collection, 'poly', Database::VAR_POLYGON, 0, true)); + + $doc = $database->createDocument($collection, new Document([ + '$id' => 'doc1', + 'loc' => [0.0, 0.0], + 'line' => [[0.0, 0.0], [0.001, 0.0]], + 'poly' => [[[ -0.001, -0.001 ], [ -0.001, 0.001 ], [ 0.001, 0.001 ], [ -0.001, -0.001 ]]], + '$permissions' => [] + ])); + $this->assertInstanceOf(Document::class, $doc); + + // Invalid geometry pairs + $cases = [ + ['attr' => 'line', 'geom' => [0.002, 0.0], 'expected' => ['linestring', 'point']], + ['attr' => 'poly', 'geom' => [0.002, 0.0], 'expected' => ['polygon', 'point']], + ['attr' => 'loc', 'geom' => [[0.0, 0.0], [0.001, 0.001]], 'expected' => ['point', 'linestring']], + ['attr' => 'poly', 'geom' => [[0.0, 0.0], [0.001, 0.001]], 'expected' => ['polygon', 'linestring']], + ['attr' => 'loc', 'geom' => [[[0.0, 0.0], [0.001, 0.0], [0.001, 0.001], [0.0, 0.0]]], 'expected' => ['point', 'polygon']], + ['attr' => 'line', 'geom' => [[[0.0, 0.0], [0.001, 0.0], [0.001, 0.001], [0.0, 0.0]]], 'expected' => ['linestring', 'polygon']], + ['attr' => 'poly', 'geom' => [[[0.002, -0.001], [0.002, 0.001], [0.004, 0.001], [0.002, -0.001]]], 'expected' => ['polygon', 'polygon']], + ['attr' => 'line', 'geom' => [[0.002, 0.0], [0.003, 0.0]], 'expected' => ['linestring', 'linestring']], + ]; + + foreach ($cases as $case) { + try { + $database->find($collection, [ + Query::distanceLessThan($case['attr'], $case['geom'], 1000, true) + ]); + $this->fail('Expected Exception not thrown for ' . implode(' vs ', $case['expected'])); + } catch (\Exception $e) { + $this->assertInstanceOf(QueryException::class, $e); + + // Validate exception message contains correct type names + $msg = strtolower($e->getMessage()); + $this->assertStringContainsString($case['expected'][0], $msg, 'Attr type missing in exception'); + $this->assertStringContainsString($case['expected'][1], $msg, 'Geom type missing in exception'); + } + } + } + public function testSpatialEncodeDecode(): void + { + $collection = new Document([ + '$collection' => ID::custom(Database::METADATA), + '$id' => ID::custom('users'), + 'name' => 'Users', + 'attributes' => [ + [ + '$id' => ID::custom('point'), + 'type' => Database::VAR_POINT, + 'required' => false, + 'filters' => [Database::VAR_POINT], + ], + [ + '$id' => ID::custom('line'), + 'type' => Database::VAR_LINESTRING, + 'format' => '', + 'required' => false, + 'filters' => [Database::VAR_LINESTRING], + ], + [ + '$id' => ID::custom('poly'), + 'type' => Database::VAR_POLYGON, + 'format' => '', + 'required' => false, + 'filters' => [Database::VAR_POLYGON], + ] + ] + ]); + + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->markTestSkipped('Adapter does not support spatial attributes'); + } + $point = "POINT(1 2)"; + $line = "LINESTRING(1 2, 1 2)"; + $poly = "POLYGON((0 0, 0 10, 10 10, 0 0))"; + + $pointArr = [1,2]; + $lineArr = [[1,2],[1,2]]; + $polyArr = [[[0.0, 0.0], [0.0, 10.0], [10.0, 10.0], [0.0, 0.0]]]; + $doc = new Document(['point' => $pointArr ,'line' => $lineArr, 'poly' => $polyArr]); + + $result = $database->encode($collection, $doc); + + $this->assertEquals($result->getAttribute('point'), $point); + $this->assertEquals($result->getAttribute('line'), $line); + $this->assertEquals($result->getAttribute('poly'), $poly); + + + $result = $database->decode($collection, $doc); + $this->assertEquals($result->getAttribute('point'), $pointArr); + $this->assertEquals($result->getAttribute('line'), $lineArr); + $this->assertEquals($result->getAttribute('poly'), $polyArr); + + $stringDoc = new Document(['point' => $point,'line' => $line, 'poly' => $poly]); + $result = $database->decode($collection, $stringDoc); + $this->assertEquals($result->getAttribute('point'), $pointArr); + $this->assertEquals($result->getAttribute('line'), $lineArr); + $this->assertEquals($result->getAttribute('poly'), $polyArr); + + $nullDoc = new Document(['point' => null,'line' => null, 'poly' => null]); + $result = $database->decode($collection, $nullDoc); + $this->assertEquals($result->getAttribute('point'), null); + $this->assertEquals($result->getAttribute('line'), null); + $this->assertEquals($result->getAttribute('poly'), null); + } + + public function testSpatialIndexSingleAttributeOnly(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionName = 'spatial_idx_single_attr_' . uniqid(); + try { + $database->createCollection($collectionName); + + // Create a spatial attribute + $database->createAttribute($collectionName, 'loc', Database::VAR_POINT, 0, true); + $database->createAttribute($collectionName, 'loc2', Database::VAR_POINT, 0, true); + $database->createAttribute($collectionName, 'title', Database::VAR_STRING, 255, true); + + // Case 1: Valid spatial index on a single spatial attribute + $this->assertTrue( + $database->createIndex($collectionName, 'idx_loc', Database::INDEX_SPATIAL, ['loc']) + ); + + // Case 2: Fail when trying to create spatial index with multiple attributes + try { + $database->createIndex($collectionName, 'idx_multi', Database::INDEX_SPATIAL, ['loc', 'loc2']); + $this->fail('Expected exception when creating spatial index on multiple attributes'); + } catch (\Throwable $e) { + $this->assertInstanceOf(IndexException::class, $e); + } + + // Case 3: Fail when trying to create non-spatial index on a spatial attribute + try { + $database->createIndex($collectionName, 'idx_wrong_type', Database::INDEX_KEY, ['loc']); + $this->fail('Expected exception when creating non-spatial index on spatial attribute'); + } catch (\Throwable $e) { + $this->assertInstanceOf(IndexException::class, $e); + } + + // Case 4: Fail when trying to mix spatial + non-spatial attributes in a spatial index + try { + $database->createIndex($collectionName, 'idx_mix', Database::INDEX_SPATIAL, ['loc', 'title']); + $this->fail('Expected exception when creating spatial index with mixed attribute types'); + } catch (\Throwable $e) { + $this->assertInstanceOf(IndexException::class, $e); + } + + } finally { + $database->deleteCollection($collectionName); + } + } + + public function testSpatialIndexRequiredToggling(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + if ($database->getAdapter()->getSupportForSpatialIndexNull()) { + $this->expectNotToPerformAssertions(); + return; + } + + try { + $collUpdateNull = 'spatial_idx_toggle'; + $database->createCollection($collUpdateNull); + + $database->createAttribute($collUpdateNull, 'loc', Database::VAR_POINT, 0, false); + try { + $database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_SPATIAL, ['loc']); + $this->fail('Expected exception when creating spatial index on NULL-able attribute'); + } catch (\Throwable $e) { + $this->assertInstanceOf(Exception::class, $e); + } + $database->updateAttribute($collUpdateNull, 'loc', required: true); + $this->assertTrue($database->createIndex($collUpdateNull, 'new index', Database::INDEX_SPATIAL, ['loc'])); + $this->assertTrue($database->deleteIndex($collUpdateNull, 'new index')); + $database->updateAttribute($collUpdateNull, 'loc', required: false); + + $database->createDocument($collUpdateNull, new Document(['loc' => null])); + } finally { + $database->deleteCollection($collUpdateNull); + } + } + + public function testSpatialIndexOnNonSpatial(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + try { + $collUpdateNull = 'spatial_idx_toggle'; + $database->createCollection($collUpdateNull); + + $database->createAttribute($collUpdateNull, 'loc', Database::VAR_POINT, 0, true); + $database->createAttribute($collUpdateNull, 'name', Database::VAR_STRING, 4, true); + try { + $database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_SPATIAL, ['name']); + $this->fail('Expected exception when creating spatial index on NULL-able attribute'); + } catch (\Throwable $e) { + $this->assertInstanceOf(IndexException::class, $e); + } + + try { + $database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_KEY, ['loc']); + $this->fail('Expected exception when creating non spatial index on spatial attribute'); + } catch (\Throwable $e) { + $this->assertInstanceOf(IndexException::class, $e); + } + + try { + $database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_KEY, ['loc,name']); + $this->fail('Expected exception when creating index'); + } catch (\Throwable $e) { + $this->assertInstanceOf(IndexException::class, $e); + } + + try { + $database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_KEY, ['name,loc']); + $this->fail('Expected exception when creating index'); + } catch (\Throwable $e) { + $this->assertInstanceOf(IndexException::class, $e); + } + + try { + $database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_SPATIAL, ['name,loc']); + $this->fail('Expected exception when creating index'); + } catch (\Throwable $e) { + $this->assertInstanceOf(IndexException::class, $e); + } + + try { + $database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_SPATIAL, ['loc,name']); + $this->fail('Expected exception when creating index'); + } catch (\Throwable $e) { + $this->assertInstanceOf(IndexException::class, $e); + } + + } finally { + $database->deleteCollection($collUpdateNull); + } + } + + public function testSpatialDocOrder(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->markTestSkipped('Adapter does not support spatial attributes'); + } + + $collectionName = 'test_spatial_order_axis'; + // 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)); + + // Create test document + $doc1 = new Document( + [ + '$id' => 'doc1', + 'pointAttr' => [5.0, 5.5], + '$permissions' => [Permission::update(Role::any()), Permission::read(Role::any())] + ] + ); + $database->createDocument($collectionName, $doc1); + + $result = $database->getDocument($collectionName, 'doc1'); + $this->assertEquals($result->getAttribute('pointAttr')[0], 5.0); + $this->assertEquals($result->getAttribute('pointAttr')[1], 5.5); + $database->deleteCollection($collectionName); + } + + public function testInvalidCoordinateDocuments(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->markTestSkipped('Adapter does not support spatial attributes'); + } + + $collectionName = 'test_invalid_coord_'; + try { + $database->createCollection($collectionName); + + $database->createAttribute($collectionName, 'pointAttr', Database::VAR_POINT, 0, true); + $database->createAttribute($collectionName, 'lineAttr', Database::VAR_LINESTRING, 0, true); + $database->createAttribute($collectionName, 'polyAttr', Database::VAR_POLYGON, 0, true); + + $invalidDocs = [ + // Invalid POINT (longitude > 180) + [ + '$id' => 'invalidDoc1', + 'pointAttr' => [200.0, 20.0], + 'lineAttr' => [[1.0, 2.0], [3.0, 4.0]], + 'polyAttr' => [ + [ + [0.0, 0.0], + [0.0, 10.0], + [10.0, 10.0], + [10.0, 0.0], + [0.0, 0.0] + ] + ] + ], + // Invalid POINT (latitude < -90) + [ + '$id' => 'invalidDoc2', + 'pointAttr' => [50.0, -100.0], + 'lineAttr' => [[1.0, 2.0], [3.0, 4.0]], + 'polyAttr' => [ + [ + [0.0, 0.0], + [0.0, 10.0], + [10.0, 10.0], + [10.0, 0.0], + [0.0, 0.0] + ] + ] + ], + // Invalid LINESTRING (point outside valid range) + [ + '$id' => 'invalidDoc3', + 'pointAttr' => [50.0, 20.0], + 'lineAttr' => [[1.0, 2.0], [300.0, 4.0]], // invalid longitude in line + 'polyAttr' => [ + [ + [0.0, 0.0], + [0.0, 10.0], + [10.0, 10.0], + [10.0, 0.0], + [0.0, 0.0] + ] + ] + ], + // Invalid POLYGON (point outside valid range) + [ + '$id' => 'invalidDoc4', + 'pointAttr' => [50.0, 20.0], + 'lineAttr' => [[1.0, 2.0], [3.0, 4.0]], + 'polyAttr' => [ + [ + [0.0, 0.0], + [0.0, 10.0], + [190.0, 10.0], // invalid longitude + [10.0, 0.0], + [0.0, 0.0] + ] + ] + ], + ]; + foreach ($invalidDocs as $docData) { + $this->expectException(StructureException::class); + $docData['$permissions'] = [Permission::update(Role::any()), Permission::read(Role::any())]; + $doc = new Document($docData); + $database->createDocument($collectionName, $doc); + } + + + } finally { + $database->deleteCollection($collectionName); + } + } } diff --git a/tests/unit/QueryTest.php b/tests/unit/QueryTest.php index 3084abaa0..c48755cb2 100644 --- a/tests/unit/QueryTest.php +++ b/tests/unit/QueryTest.php @@ -141,6 +141,18 @@ public function testCreate(): void $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()); $this->assertEquals('$updatedAt', $query->getAttribute()); $this->assertEquals(['2023-12-31T23:59:59.999Z'], $query->getValues()); + + $query = Query::createdBetween('2023-01-01T00:00:00.000Z', '2023-12-31T23:59:59.999Z'); + + $this->assertEquals(Query::TYPE_BETWEEN, $query->getMethod()); + $this->assertEquals('$createdAt', $query->getAttribute()); + $this->assertEquals(['2023-01-01T00:00:00.000Z', '2023-12-31T23:59:59.999Z'], $query->getValues()); + + $query = Query::updatedBetween('2023-01-01T00:00:00.000Z', '2023-12-31T23:59:59.999Z'); + + $this->assertEquals(Query::TYPE_BETWEEN, $query->getMethod()); + $this->assertEquals('$updatedAt', $query->getAttribute()); + $this->assertEquals(['2023-01-01T00:00:00.000Z', '2023-12-31T23:59:59.999Z'], $query->getValues()); } /** @@ -271,6 +283,16 @@ public function testParse(): void $this->assertEquals('$updatedAt', $query->getAttribute()); $this->assertEquals(['2023-12-31T23:59:59.999Z'], $query->getValues()); + $query = Query::parse(Query::createdBetween('2023-01-01T00:00:00.000Z', '2023-12-31T23:59:59.999Z')->toString()); + $this->assertEquals('between', $query->getMethod()); + $this->assertEquals('$createdAt', $query->getAttribute()); + $this->assertEquals(['2023-01-01T00:00:00.000Z', '2023-12-31T23:59:59.999Z'], $query->getValues()); + + $query = Query::parse(Query::updatedBetween('2023-01-01T00:00:00.000Z', '2023-12-31T23:59:59.999Z')->toString()); + $this->assertEquals('between', $query->getMethod()); + $this->assertEquals('$updatedAt', $query->getAttribute()); + $this->assertEquals(['2023-01-01T00:00:00.000Z', '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()); diff --git a/tests/unit/Validator/SpatialTest.php b/tests/unit/Validator/SpatialTest.php new file mode 100644 index 000000000..e8df4d3d1 --- /dev/null +++ b/tests/unit/Validator/SpatialTest.php @@ -0,0 +1,112 @@ +assertTrue($validator->isValid([10, 20])); + $this->assertTrue($validator->isValid([0, 0])); + $this->assertTrue($validator->isValid([-180.0, 90.0])); + + // Invalid cases + $this->assertFalse($validator->isValid([10])); // Only one coordinate + $this->assertFalse($validator->isValid([10, 'a'])); // Non-numeric + $this->assertFalse($validator->isValid([[10, 20]])); // Nested array + } + + public function testValidLineString(): void + { + $validator = new Spatial(Database::VAR_LINESTRING); + + $this->assertTrue($validator->isValid([[0, 0], [1, 1]])); + + $this->assertTrue($validator->isValid([[10, 10], [20, 20], [30, 30]])); + + // Invalid cases + $this->assertFalse($validator->isValid([[10, 10]])); // Only one point + $this->assertFalse($validator->isValid([[10, 10], [20]])); // Malformed point + $this->assertFalse($validator->isValid([[10, 10], ['x', 'y']])); // Non-numeric + } + + public function testValidPolygon(): void + { + $validator = new Spatial(Database::VAR_POLYGON); + + // Single ring polygon (closed) + $this->assertTrue($validator->isValid([ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + [0, 0] + ])); + + // Multi-ring polygon + $this->assertTrue($validator->isValid([ + [ // Outer ring + [0, 0], [0, 4], [4, 4], [4, 0], [0, 0] + ], + [ // Hole + [1, 1], [1, 2], [2, 2], [2, 1], [1, 1] + ] + ])); + + // Invalid polygons + $this->assertFalse($validator->isValid([])); // Empty + $this->assertFalse($validator->isValid([ + [0, 0], [1, 1], [2, 2] // Not closed, less than 4 points + ])); + $this->assertFalse($validator->isValid([ + [[0, 0], [1, 1], [1, 0]] // Not closed + ])); + $this->assertFalse($validator->isValid([ + [[0, 0], [1, 1], [1, 'a'], [0, 0]] // Non-numeric + ])); + } + + public function testWKTStrings(): void + { + $this->assertTrue(Spatial::isWKTString('POINT(1 2)')); + $this->assertTrue(Spatial::isWKTString('LINESTRING(0 0,1 1)')); + $this->assertTrue(Spatial::isWKTString('POLYGON((0 0,1 0,1 1,0 1,0 0))')); + + $this->assertFalse(Spatial::isWKTString('CIRCLE(0 0,1)')); + $this->assertFalse(Spatial::isWKTString('POINT1(1 2)')); + } + + public function testInvalidCoordinate(): void + { + // Point with invalid longitude + $validator = new Spatial(Database::VAR_POINT); + $this->assertFalse($validator->isValid([200, 10])); // longitude > 180 + $this->assertStringContainsString('Longitude', $validator->getDescription()); + + // Point with invalid latitude + $validator = new Spatial(Database::VAR_POINT); + $this->assertFalse($validator->isValid([10, -100])); // latitude < -90 + $this->assertStringContainsString('Latitude', $validator->getDescription()); + + // LineString with invalid coordinates + $validator = new Spatial(Database::VAR_LINESTRING); + $this->assertFalse($validator->isValid([ + [0, 0], + [181, 45] // invalid longitude + ])); + $this->assertStringContainsString('Invalid coordinates', $validator->getDescription()); + + // Polygon with invalid coordinates + $validator = new Spatial(Database::VAR_POLYGON); + $this->assertFalse($validator->isValid([ + [[0, 0], [1, 1], [190, 5], [0, 0]] // invalid longitude in ring + ])); + $this->assertStringContainsString('Invalid coordinates', $validator->getDescription()); + } +}