From 3815396291abbd0fdf3a4c33366e40afe8b5ca0d Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 16 Mar 2025 13:28:03 +0200 Subject: [PATCH 1/9] fallback internalId --- src/Database/Query.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Database/Query.php b/src/Database/Query.php index 7388453b2..010d37e8d 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -86,6 +86,10 @@ class Query */ public function __construct(string $method, string $attribute = '', array $values = []) { + if (in_array($method, [Query::TYPE_ORDER_ASC, Query::TYPE_ORDER_DESC]) && $attribute === '') { + $attribute = '$internalId'; + } + $this->method = $method; $this->attribute = $attribute; $this->values = $values; From e25c504eba07e903d587630c17fe8eff78e76e75 Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 16 Mar 2025 13:32:49 +0200 Subject: [PATCH 2/9] reverse check --- src/Database/Query.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Query.php b/src/Database/Query.php index 010d37e8d..3e9684bb1 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -86,7 +86,7 @@ class Query */ public function __construct(string $method, string $attribute = '', array $values = []) { - if (in_array($method, [Query::TYPE_ORDER_ASC, Query::TYPE_ORDER_DESC]) && $attribute === '') { + if ($attribute === '' && \in_array($method, [Query::TYPE_ORDER_ASC, Query::TYPE_ORDER_DESC])) { $attribute = '$internalId'; } From 16f2ffcbc1a791aa435d86bc2afd9024bd58f9d0 Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 16 Mar 2025 16:52:34 +0200 Subject: [PATCH 3/9] Fix Mongo --- src/Database/Adapter/Mongo.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index fc1f7da32..a5059c9e6 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1102,7 +1102,12 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, } // orders + $hasIdAttribute = false; foreach ($orderAttributes as $i => $attribute) { + if (\in_array($attribute, ['_uid', '_id'])) { + $hasIdAttribute = true; + } + $attribute = $this->filter($attribute); $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); @@ -1118,7 +1123,9 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $options['sort'][$attribute] = $this->getOrder($orderType); } - $options['sort']['_id'] = $this->getOrder($cursorDirection === Database::CURSOR_AFTER ? Database::ORDER_ASC : Database::ORDER_DESC); + if (!$hasIdAttribute) { + $options['sort']['_id'] = $this->getOrder($cursorDirection === Database::CURSOR_AFTER ? Database::ORDER_ASC : Database::ORDER_DESC); + } // queries From f97a9b2508bdbcb6afb2f7e6d2c71a42009c3ebf Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 16 Mar 2025 17:18:45 +0200 Subject: [PATCH 4/9] Fix Mongo --- src/Database/Adapter/Mongo.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index a5059c9e6..f83cf219d 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1104,9 +1104,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, // orders $hasIdAttribute = false; foreach ($orderAttributes as $i => $attribute) { - if (\in_array($attribute, ['_uid', '_id'])) { - $hasIdAttribute = true; - } + var_dump($attribute); $attribute = $this->filter($attribute); $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); @@ -1120,6 +1118,10 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $attribute = $attribute == 'createdAt' ? '_createdAt' : $attribute; $attribute = $attribute == 'updatedAt' ? '_updatedAt' : $attribute; + if (\in_array($attribute, ['_uid', '_id'])) { + $hasIdAttribute = true; + } + $options['sort'][$attribute] = $this->getOrder($orderType); } From 60acf75592134aee4968322bbe8edcab632be62e Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 16 Mar 2025 17:34:44 +0200 Subject: [PATCH 5/9] Fix Mongo revert --- src/Database/Adapter/Mongo.php | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index f83cf219d..fc1f7da32 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1102,10 +1102,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, } // orders - $hasIdAttribute = false; foreach ($orderAttributes as $i => $attribute) { - var_dump($attribute); - $attribute = $this->filter($attribute); $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); @@ -1118,16 +1115,10 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $attribute = $attribute == 'createdAt' ? '_createdAt' : $attribute; $attribute = $attribute == 'updatedAt' ? '_updatedAt' : $attribute; - if (\in_array($attribute, ['_uid', '_id'])) { - $hasIdAttribute = true; - } - $options['sort'][$attribute] = $this->getOrder($orderType); } - if (!$hasIdAttribute) { - $options['sort']['_id'] = $this->getOrder($cursorDirection === Database::CURSOR_AFTER ? Database::ORDER_ASC : Database::ORDER_DESC); - } + $options['sort']['_id'] = $this->getOrder($cursorDirection === Database::CURSOR_AFTER ? Database::ORDER_ASC : Database::ORDER_DESC); // queries From dfb49c0cc7319ba75eb71fd8ff9b935fc754f23b Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 17 Mar 2025 09:01:14 +0200 Subject: [PATCH 6/9] Remove Mongo --- .github/workflows/tests.yml | 2 - composer.json | 7 +- composer.lock | 302 +---- src/Database/Adapter/Mongo.php | 2021 ----------------------------- tests/e2e/Adapter/Base.php | 11 + tests/e2e/Adapter/MongoDBTest.php | 108 -- 6 files changed, 30 insertions(+), 2421 deletions(-) delete mode 100644 src/Database/Adapter/Mongo.php delete mode 100644 tests/e2e/Adapter/MongoDBTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7b9f672af..d6a4c8033 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -76,13 +76,11 @@ jobs: MySQL, Postgres, SQLite, - MongoDB, Mirror, SharedTables/MariaDB, SharedTables/MySQL, SharedTables/Postgres, SharedTables/SQLite, - SharedTables/MongoDB, ] steps: diff --git a/composer.json b/composer.json index 7d2b311e4..1be5215cd 100755 --- a/composer.json +++ b/composer.json @@ -37,8 +37,7 @@ "ext-pdo": "*", "ext-mbstring": "*", "utopia-php/framework": "0.33.*", - "utopia-php/cache": "0.12.*", - "utopia-php/mongo": "0.3.*" + "utopia-php/cache": "0.12.*" }, "require-dev": { "fakerphp/faker": "1.23.*", @@ -51,10 +50,8 @@ "rregeer/phpunit-coverage-check": "0.3.*" }, "suggests": { - "ext-mongodb": "Needed to support MongoDB Database Adapter", "ext-redis": "Needed to support Redis Cache Adapter", - "ext-pdo": "Needed to support MariaDB, MySQL or SQLite Database Adapter", - "mongodb/mongodb": "Needed to support MongoDB Database Adapter" + "ext-pdo": "Needed to support MariaDB, MySQL or SQLite Database Adapter" }, "config": { "allow-plugins": { diff --git a/composer.lock b/composer.lock index 0f01edf61..ec648d2fc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4d41b8991988a9c03d57f8038103e006", + "content-hash": "d1e1cb1921161014c22372feccc945f9", "packages": [ { "name": "brick/math", @@ -149,16 +149,16 @@ }, { "name": "google/protobuf", - "version": "v4.30.0", + "version": "v4.30.1", "source": { "type": "git", "url": "https://github.com/protocolbuffers/protobuf-php.git", - "reference": "e1d66682f6836aa87820400f0aa07d9eb566feb6" + "reference": "f29ba8a30dfd940efb3a8a75dc44446539101f24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/e1d66682f6836aa87820400f0aa07d9eb566feb6", - "reference": "e1d66682f6836aa87820400f0aa07d9eb566feb6", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/f29ba8a30dfd940efb3a8a75dc44446539101f24", + "reference": "f29ba8a30dfd940efb3a8a75dc44446539101f24", "shasum": "" }, "require": { @@ -187,137 +187,9 @@ "proto" ], "support": { - "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.30.0" + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.30.1" }, - "time": "2025-03-04T22:54:49+00:00" - }, - { - "name": "jean85/pretty-package-versions", - "version": "2.1.0", - "source": { - "type": "git", - "url": "https://github.com/Jean85/pretty-package-versions.git", - "reference": "3c4e5f62ba8d7de1734312e4fff32f67a8daaf10" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/3c4e5f62ba8d7de1734312e4fff32f67a8daaf10", - "reference": "3c4e5f62ba8d7de1734312e4fff32f67a8daaf10", - "shasum": "" - }, - "require": { - "composer-runtime-api": "^2.1.0", - "php": "^7.4|^8.0" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^3.2", - "jean85/composer-provided-replaced-stub-package": "^1.0", - "phpstan/phpstan": "^1.4", - "phpunit/phpunit": "^7.5|^8.5|^9.6", - "vimeo/psalm": "^4.3 || ^5.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Jean85\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Alessandro Lai", - "email": "alessandro.lai85@gmail.com" - } - ], - "description": "A library to get pretty versions strings of installed dependencies", - "keywords": [ - "composer", - "package", - "release", - "versions" - ], - "support": { - "issues": "https://github.com/Jean85/pretty-package-versions/issues", - "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.0" - }, - "time": "2024-11-18T16:19:46+00:00" - }, - { - "name": "mongodb/mongodb", - "version": "1.10.0", - "source": { - "type": "git", - "url": "https://github.com/mongodb/mongo-php-library.git", - "reference": "b0bbd657f84219212487d01a8ffe93a789e1e488" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/b0bbd657f84219212487d01a8ffe93a789e1e488", - "reference": "b0bbd657f84219212487d01a8ffe93a789e1e488", - "shasum": "" - }, - "require": { - "ext-hash": "*", - "ext-json": "*", - "ext-mongodb": "^1.11.0", - "jean85/pretty-package-versions": "^1.2 || ^2.0.1", - "php": "^7.1 || ^8.0", - "symfony/polyfill-php80": "^1.19" - }, - "require-dev": { - "doctrine/coding-standard": "^9.0", - "squizlabs/php_codesniffer": "^3.6", - "symfony/phpunit-bridge": "^5.2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.10.x-dev" - } - }, - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "MongoDB\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Andreas Braun", - "email": "andreas.braun@mongodb.com" - }, - { - "name": "Jeremy Mikola", - "email": "jmikola@gmail.com" - } - ], - "description": "MongoDB driver library", - "homepage": "https://jira.mongodb.org/browse/PHPLIB", - "keywords": [ - "database", - "driver", - "mongodb", - "persistence" - ], - "support": { - "issues": "https://github.com/mongodb/mongo-php-library/issues", - "source": "https://github.com/mongodb/mongo-php-library/tree/1.10.0" - }, - "time": "2021-10-20T22:22:37+00:00" + "time": "2025-03-13T21:08:17+00:00" }, { "name": "nyholm/psr7", @@ -1696,86 +1568,6 @@ ], "time": "2024-09-09T11:45:10+00:00" }, - { - "name": "symfony/polyfill-php80", - "version": "v1.31.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "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\\Php80\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-09T11:45:10+00:00" - }, { "name": "symfony/polyfill-php82", "version": "v1.31.0", @@ -2131,66 +1923,6 @@ }, "time": "2025-03-06T11:37:49+00:00" }, - { - "name": "utopia-php/mongo", - "version": "0.3.1", - "source": { - "type": "git", - "url": "https://github.com/utopia-php/mongo.git", - "reference": "52326a9a43e2d27ff0c15c48ba746dacbe9a7aee" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/52326a9a43e2d27ff0c15c48ba746dacbe9a7aee", - "reference": "52326a9a43e2d27ff0c15c48ba746dacbe9a7aee", - "shasum": "" - }, - "require": { - "ext-mongodb": "*", - "mongodb/mongodb": "1.10.0", - "php": ">=8.0" - }, - "require-dev": { - "fakerphp/faker": "^1.14", - "laravel/pint": "1.2.*", - "phpstan/phpstan": "1.8.*", - "phpunit/phpunit": "^9.4", - "swoole/ide-helper": "4.8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Utopia\\Mongo\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Eldad Fux", - "email": "eldad@appwrite.io" - }, - { - "name": "Wess", - "email": "wess@appwrite.io" - } - ], - "description": "A simple library to manage Mongo database", - "keywords": [ - "database", - "mongo", - "php", - "upf", - "utopia" - ], - "support": { - "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/0.3.1" - }, - "time": "2023-09-01T17:25:28+00:00" - }, { "name": "utopia-php/telemetry", "version": "0.1.0", @@ -2378,16 +2110,16 @@ }, { "name": "laravel/pint", - "version": "v1.21.1", + "version": "v1.21.2", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "c44bffbb2334e90fba560933c45948fa4a3f3e86" + "reference": "370772e7d9e9da087678a0edf2b11b6960e40558" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/c44bffbb2334e90fba560933c45948fa4a3f3e86", - "reference": "c44bffbb2334e90fba560933c45948fa4a3f3e86", + "url": "https://api.github.com/repos/laravel/pint/zipball/370772e7d9e9da087678a0edf2b11b6960e40558", + "reference": "370772e7d9e9da087678a0edf2b11b6960e40558", "shasum": "" }, "require": { @@ -2398,9 +2130,9 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.70.2", - "illuminate/view": "^11.44.1", - "larastan/larastan": "^3.1.0", + "friendsofphp/php-cs-fixer": "^3.72.0", + "illuminate/view": "^11.44.2", + "larastan/larastan": "^3.2.0", "laravel-zero/framework": "^11.36.1", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.3", @@ -2440,7 +2172,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-03-11T03:22:21+00:00" + "time": "2025-03-14T22:31:42+00:00" }, { "name": "myclabs/deep-copy", @@ -4337,7 +4069,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -4345,6 +4077,6 @@ "ext-pdo": "*", "ext-mbstring": "*" }, - "platform-dev": {}, + "platform-dev": [], "plugin-api-version": "2.6.0" } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php deleted file mode 100644 index fc1f7da32..000000000 --- a/src/Database/Adapter/Mongo.php +++ /dev/null @@ -1,2021 +0,0 @@ - - */ - private array $operators = [ - '$eq', - '$ne', - '$lt', - '$lte', - '$gt', - '$gte', - '$in', - '$text', - '$search', - '$or', - '$and', - '$match', - '$regex', - ]; - - protected Client $client; - - protected ?int $timeout = null; - - /** - * Constructor. - * - * Set connection and settings - * - * @param Client $client - * @throws MongoException - */ - public function __construct(Client $client) - { - $this->client = $client; - $this->client->connect(); - } - - public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void - { - if (!$this->getSupportForTimeouts()) { - return; - } - - $this->timeout = $milliseconds; - } - - public function clearTimeout(string $event): void - { - parent::clearTimeout($event); - - $this->timeout = null; - } - - public function startTransaction(): bool - { - return true; - } - - public function commitTransaction(): bool - { - return true; - } - - public function rollbackTransaction(): bool - { - return true; - } - - /** - * Ping Database - * - * @return bool - * @throws Exception - * @throws MongoException - */ - public function ping(): bool - { - return $this->getClient()->query(['ping' => 1])->ok ?? false; - } - - public function reconnect(): void - { - $this->client->connect(); - } - - /** - * Create Database - * - * @param string $name - * - * @return bool - */ - public function create(string $name): bool - { - return true; - } - - /** - * Check if database exists - * Optionally check if collection exists in database - * - * @param string $database database name - * @param string|null $collection (optional) collection name - * - * @return bool - * @throws Exception - */ - public function exists(string $database, ?string $collection = null): bool - { - if (!\is_null($collection)) { - $collection = $this->getNamespace() . "_" . $collection; - $list = $this->flattenArray($this->listCollections())[0]->firstBatch; - foreach ($list as $obj) { - if (\is_object($obj) - && isset($obj->name) - && $obj->name === $collection - ) { - return true; - } - } - - return false; - } - - return $this->getClient()->selectDatabase() != null; - } - - /** - * List Databases - * - * @return array - * @throws Exception - */ - public function list(): array - { - $list = []; - - foreach ((array)$this->getClient()->listDatabaseNames() as $value) { - $list[] = $value; - } - - return $list; - } - - /** - * Delete Database - * - * @param string $name - * - * @return bool - * @throws Exception - */ - public function delete(string $name): bool - { - $this->getClient()->dropDatabase([], $name); - - return true; - } - - /** - * Create Collection - * - * @param string $name - * @param array $attributes - * @param array $indexes - * @return bool - * @throws Exception - */ - public function createCollection(string $name, array $attributes = [], array $indexes = []): bool - { - $id = $this->getNamespace() . '_' . $this->filter($name); - - if ($name === Database::METADATA && $this->exists($this->getNamespace(), $name)) { - return true; - } - - // Returns an array/object with the result document - try { - $this->getClient()->createCollection($id); - } catch (MongoException $e) { - throw new Duplicate($e->getMessage(), $e->getCode(), $e); - } - - $indexesCreated = $this->client->createIndexes($id, [[ - 'key' => ['_uid' => $this->getOrder(Database::ORDER_DESC)], - 'name' => '_uid', - 'unique' => true, - 'collation' => [ // https://docs.mongodb.com/manual/core/index-case-insensitive/#create-a-case-insensitive-index - 'locale' => 'en', - 'strength' => 1, - ] - ], [ - 'key' => ['_permissions' => $this->getOrder(Database::ORDER_DESC)], - 'name' => '_permissions', - ]]); - - if (!$indexesCreated) { - return false; - } - - // Since attributes are not used by this adapter - // Only act when $indexes is provided - if (!empty($indexes)) { - /** - * Each new index has format ['key' => [$attribute => $order], 'name' => $name, 'unique' => $unique] - */ - $newIndexes = []; - - // using $i and $j as counters to distinguish from $key - foreach ($indexes as $i => $index) { - $key = []; - $unique = false; - $attributes = $index->getAttribute('attributes'); - $orders = $index->getAttribute('orders'); - - foreach ($attributes as $attribute) { - $attribute = $this->filter($attribute); - - switch ($index->getAttribute('type')) { - case Database::INDEX_KEY: - $order = $this->getOrder($this->filter($orders[$i] ?? Database::ORDER_ASC)); - break; - case Database::INDEX_FULLTEXT: - // MongoDB fulltext index is just 'text' - // Not using Database::INDEX_KEY for clarity - $order = 'text'; - break; - case Database::INDEX_UNIQUE: - $order = $this->getOrder($this->filter($orders[$i] ?? Database::ORDER_ASC)); - $unique = true; - break; - default: - // index not supported - return false; - } - - $key[$attribute] = $order; - } - - $newIndexes[$i] = ['key' => $key, 'name' => $this->filter($index->getId()), 'unique' => $unique]; - } - - if (!$this->getClient()->createIndexes($id, $newIndexes)) { - return false; - } - } - - return true; - } - - /** - * List Collections - * - * @return array - * @throws Exception - */ - public function listCollections(): array - { - $list = []; - - foreach ((array)$this->getClient()->listCollectionNames() as $value) { - $list[] = $value; - } - - return $list; - } - - /** - * Get Collection Size on disk - * @param string $collection - * @return int - * @throws DatabaseException - */ - public function getSizeOfCollectionOnDisk(string $collection): int - { - return $this->getSizeOfCollection($collection); - } - - /** - * Get Collection Size of raw data - * @param string $collection - * @return int - * @throws DatabaseException - */ - public function getSizeOfCollection(string $collection): int - { - $namespace = $this->getNamespace(); - $collection = $this->filter($collection); - $collection = $namespace. '_' . $collection; - - $command = [ - 'collStats' => $collection, - 'scale' => 1 - ]; - - try { - $result = $this->getClient()->query($command); - if (is_object($result)) { - return $result->totalSize; - } else { - throw new DatabaseException('No size found'); - } - } catch (Exception $e) { - throw new DatabaseException('Failed to get collection size: ' . $e->getMessage()); - } - } - - /** - * Delete Collection - * - * @param string $id - * @return bool - * @throws Exception - */ - public function deleteCollection(string $id): bool - { - $id = $this->getNamespace() . '_' . $this->filter($id); - - return (!!$this->getClient()->dropCollection($id)); - } - - /** - * Analyze a collection updating it's metadata on the database engine - * - * @param string $collection - * @return bool - */ - public function analyzeCollection(string $collection): bool - { - return false; - } - - /** - * Create Attribute - * - * @param string $collection - * @param string $id - * @param string $type - * @param int $size - * @param bool $signed - * @param bool $array - * - * @return bool - */ - public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false): bool - { - return true; - } - - /** - * Delete Attribute - * - * @param string $collection - * @param string $id - * - * @return bool - */ - public function deleteAttribute(string $collection, string $id): bool - { - $collection = $this->getNamespace() . '_' . $this->filter($collection); - - $this->getClient()->update( - $collection, - [], - ['$unset' => [$id => '']], - multi: true - ); - - return true; - } - - /** - * Rename Attribute. - * - * @param string $collection - * @param string $id - * @param string $name - * @return bool - */ - public function renameAttribute(string $collection, string $id, string $name): bool - { - $collection = $this->getNamespace() . '_' . $this->filter($collection); - - $this->getClient()->update( - $collection, - [], - ['$rename' => [$id => $name]], - multi: true - ); - - return true; - } - - /** - * @param string $collection - * @param string $relatedCollection - * @param string $type - * @param bool $twoWay - * @param string $id - * @param string $twoWayKey - * @return bool - */ - public function createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, string $id = '', string $twoWayKey = ''): bool - { - return true; - } - - /** - * @param string $collection - * @param string $relatedCollection - * @param string $type - * @param bool $twoWay - * @param string $key - * @param string $twoWayKey - * @param string $side - * @param string|null $newKey - * @param string|null $newTwoWayKey - * @return bool - * @throws DatabaseException - * @throws MongoException - */ - public function updateRelationship( - string $collection, - string $relatedCollection, - string $type, - bool $twoWay, - string $key, - string $twoWayKey, - string $side, - ?string $newKey = null, - ?string $newTwoWayKey = null - ): bool { - $collection = $this->getNamespace() . '_' . $this->filter($collection); - $relatedCollection = $this->getNamespace() . '_' . $this->filter($relatedCollection); - - $renameKey = [ - '$rename' => [ - $key => $newKey, - ] - ]; - - $renameTwoWayKey = [ - '$rename' => [ - $twoWayKey => $newTwoWayKey, - ] - ]; - - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - if (!\is_null($newKey)) { - $this->getClient()->update($collection, updates: $renameKey, multi: true); - } - if ($twoWay && !\is_null($newTwoWayKey)) { - $this->getClient()->update($relatedCollection, updates: $renameTwoWayKey, multi: true); - } - break; - case Database::RELATION_ONE_TO_MANY: - if ($twoWay && !\is_null($newTwoWayKey)) { - $this->getClient()->update($relatedCollection, updates: $renameTwoWayKey, multi: true); - } - break; - case Database::RELATION_MANY_TO_ONE: - if (!\is_null($newKey)) { - $this->getClient()->update($collection, updates: $renameKey, multi: true); - } - break; - case Database::RELATION_MANY_TO_MANY: - $collection = $this->getDocument(Database::METADATA, $collection); - $relatedCollection = $this->getDocument(Database::METADATA, $relatedCollection); - - $junction = $this->getNamespace() . '_' . $this->filter('_' . $collection->getInternalId() . '_' . $relatedCollection->getInternalId()); - - if (!\is_null($newKey)) { - $this->getClient()->update($junction, updates: $renameKey, multi: true); - } - if ($twoWay && !\is_null($newTwoWayKey)) { - $this->getClient()->update($junction, updates: $renameTwoWayKey, multi: true); - } - break; - default: - throw new DatabaseException('Invalid relationship type'); - } - - return true; - } - - /** - * @param string $collection - * @param string $relatedCollection - * @param string $type - * @param bool $twoWay - * @param string $key - * @param string $twoWayKey - * @param string $side - * @return bool - * @throws MongoException - * @throws Exception - */ - public function deleteRelationship( - string $collection, - string $relatedCollection, - string $type, - bool $twoWay, - string $key, - string $twoWayKey, - string $side - ): bool { - $junction = $this->getNamespace() . '_' . $this->filter('_' . $collection . '_' . $relatedCollection); - $collection = $this->getNamespace() . '_' . $this->filter($collection); - $relatedCollection = $this->getNamespace() . '_' . $this->filter($relatedCollection); - - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - $this->getClient()->update($collection, [], ['$unset' => [$key => '']], multi: true); - if ($twoWay) { - $this->getClient()->update($relatedCollection, [], ['$unset' => [$twoWayKey => '']], multi: true); - } - break; - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_PARENT) { - $this->getClient()->update($collection, [], ['$unset' => [$key => '']], multi: true); - } else { - $this->getClient()->update($relatedCollection, [], ['$unset' => [$twoWayKey => '']], multi: true); - } - break; - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_CHILD) { - $this->getClient()->update($collection, [], ['$unset' => [$key => '']], multi: true); - } else { - $this->getClient()->update($relatedCollection, [], ['$unset' => [$twoWayKey => '']], multi: true); - } - break; - case Database::RELATION_MANY_TO_MANY: - $this->getClient()->dropCollection($junction); - break; - default: - throw new DatabaseException('Invalid relationship type'); - } - - return true; - } - - /** - * Create Index - * - * @param string $collection - * @param string $id - * @param string $type - * @param array $attributes - * @param array $lengths - * @param array $orders - * @param array $collation - * @return bool - * @throws Exception - */ - public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $collation = []): bool - { - $name = $this->getNamespace() . '_' . $this->filter($collection); - $id = $this->filter($id); - - $indexes = []; - $options = []; - - // pass in custom index name - $indexes['name'] = $id; - - foreach ($attributes as $i => $attribute) { - $attribute = $this->filter($attribute); - - $orderType = $this->getOrder($this->filter($orders[$i] ?? Database::ORDER_ASC)); - $indexes['key'][$attribute] = $orderType; - - switch ($type) { - case Database::INDEX_KEY: - break; - case Database::INDEX_FULLTEXT: - $indexes['key'][$attribute] = 'text'; - break; - case Database::INDEX_UNIQUE: - $indexes['unique'] = true; - break; - default: - return false; - } - } - - if (!empty($collation)) { - $options['collation'] = $collation; - } - - return $this->client->createIndexes($name, [$indexes], $options); - } - - /** - * Rename Index. - * - * @param string $collection - * @param string $old - * @param string $new - * - * @return bool - * @throws Exception - */ - public function renameIndex(string $collection, string $old, string $new): bool - { - $collection = $this->filter($collection); - $collectionDocument = $this->getDocument(Database::METADATA, $collection); - $old = $this->filter($old); - $new = $this->filter($new); - $indexes = json_decode($collectionDocument['indexes'], true); - $index = null; - - foreach ($indexes as $node) { - if ($node['key'] === $old) { - $index = $node; - break; - } - } - - if ($index - && $this->deleteIndex($collection, $old) - && $this->createIndex( - $collection, - $new, - $index['type'], - $index['attributes'], - $index['lengths'] ?? [], - $index['orders'] ?? [], - )) { - return true; - } - - return false; - } - - /** - * Delete Index - * - * @param string $collection - * @param string $id - * - * @return bool - * @throws Exception - */ - public function deleteIndex(string $collection, string $id): bool - { - $name = $this->getNamespace() . '_' . $this->filter($collection); - $id = $this->filter($id); - $this->getClient()->dropIndexes($name, [$id]); - - return true; - } - - /** - * Get Document - * - * @param string $collection - * @param string $id - * @param Query[] $queries - * @return Document - * @throws MongoException - */ - public function getDocument(string $collection, string $id, array $queries = [], bool $forUpdate = false): Document - { - $name = $this->getNamespace() . '_' . $this->filter($collection); - - $filters = ['_uid' => $id]; - - if ($this->sharedTables) { - $filters['_tenant'] = (string)$this->getTenant(); - } - - $options = []; - - $selections = $this->getAttributeSelections($queries); - - if (!empty($selections) && !\in_array('*', $selections)) { - $options['projection'] = $this->getAttributeProjection($selections); - } - - $result = $this->client->find($name, $filters, $options)->cursor->firstBatch; - - if (empty($result)) { - return new Document([]); - } - - $result = $this->replaceChars('_', '$', (array)$result[0]); - $result = $this->timeToDocument($result); - - return new Document($result); - } - - /** - * Create Document - * - * @param string $collection - * @param Document $document - * - * @return Document - * @throws Exception - */ - public function createDocument(string $collection, Document $document): Document - { - $name = $this->getNamespace() . '_' . $this->filter($collection); - $internalId = $document->getInternalId(); - - $document->removeAttribute('$internalId'); - - if ($this->sharedTables) { - $document->setAttribute('$tenant', (string)$this->getTenant()); - } - - $record = $this->replaceChars('$', '_', (array)$document); - $record = $this->timeToMongo($record); - - // Insert manual id if set - if (!empty($internalId)) { - $record['_id'] = $internalId; - } - - $result = $this->insertDocument($name, $this->removeNullKeys($record)); - $result = $this->replaceChars('_', '$', $result); - $result = $this->timeToDocument($result); - - return new Document($result); - } - - /** - * Create Documents in batches - * - * @param string $collection - * @param array $documents - * - * @return array - * - * @throws Duplicate - */ - public function createDocuments(string $collection, array $documents): array - { - $name = $this->getNamespace() . '_' . $this->filter($collection); - - $records = []; - $hasInternalId = null; - $documents = array_map(fn ($doc) => clone $doc, $documents); - - foreach ($documents as $document) { - $internalId = $document->getInternalId(); - - if ($hasInternalId === null) { - $hasInternalId = !empty($internalId); - } elseif ($hasInternalId == empty($internalId)) { - throw new DatabaseException('All documents must have an internalId if one is set'); - } - - $document->removeAttribute('$internalId'); - - if ($this->sharedTables) { - $document->setAttribute('$tenant', (string)$this->getTenant()); - } - - $record = $this->replaceChars('$', '_', (array)$document); - $record = $this->timeToMongo($record); - - if (!empty($internalId)) { - $record['_id'] = $internalId; - } - - $records[] = $this->removeNullKeys($record); - } - - $documents = $this->client->insertMany($name, $records); - - foreach ($documents as $index => $document) { - $documents[$index] = $this->replaceChars('_', '$', $this->client->toArray($document)); - $documents[$index] = $this->timeToDocument($documents[$index]); - - $documents[$index] = new Document($documents[$index]); - } - - return $documents; - } - - /** - * - * @param string $name - * @param array $document - * - * @return array - * @throws Duplicate - */ - private function insertDocument(string $name, array $document): array - { - try { - $this->client->insert($name, $document); - - $filters = []; - $filters['_uid'] = $document['_uid']; - - if ($this->sharedTables) { - $filters['_tenant'] = (string)$this->getTenant(); - } - - $result = $this->client->find( - $name, - $filters, - ['limit' => 1] - )->cursor->firstBatch[0]; - - return $this->client->toArray($result); - } catch (MongoException $e) { - throw new Duplicate($e->getMessage()); - } - } - - /** - * Update Document - * - * @param string $collection - * @param string $id - * @param Document $document - * - * @return Document - * @throws Exception - */ - public function updateDocument(string $collection, string $id, Document $document): Document - { - $name = $this->getNamespace() . '_' . $this->filter($collection); - - $record = $document->getArrayCopy(); - $record = $this->replaceChars('$', '_', $record); - $record = $this->timeToMongo($record); - - $filters = []; - $filters['_uid'] = $id; - if ($this->sharedTables) { - $filters['_tenant'] = (string)$this->getTenant(); - } - - try { - $this->client->update($name, $filters, $record); - } catch (MongoException $e) { - throw new Duplicate($e->getMessage()); - } - - return $document; - } - - /** - * Update documents - * - * Updates all documents which match the given query. - * - * @param string $collection - * @param Document $updates - * @param array $documents - * - * @return int - * - * @throws DatabaseException - */ - public function updateDocuments(string $collection, Document $updates, array $documents): int - { - $name = $this->getNamespace() . '_' . $this->filter($collection); - - $queries = [ - Query::equal('$id', array_map(fn ($document) => $document->getId(), $documents)) - ]; - - $filters = $this->buildFilters($queries); - if ($this->sharedTables) { - $filters['_tenant'] = (string)$this->getTenant(); - } - - $record = $updates->getArrayCopy(); - $record = $this->replaceChars('$', '_', $record); - $record = $this->timeToMongo($record); - - $updateQuery = [ - '$set' => $record, - ]; - - try { - $this->client->update($name, $filters, $updateQuery, multi: true); - } catch (MongoException $e) { - throw new Duplicate($e->getMessage()); - } - - return 1; - } - - /** - * @param string $collection - * @param string $attribute - * @param array $documents - * @return array - */ - public function createOrUpdateDocuments(string $collection, string $attribute, array $documents): array - { - return $documents; - } - - /** - * Increase or decrease an attribute value - * - * @param string $collection - * @param string $id - * @param string $attribute - * @param int|float $value - * @param string $updatedAt - * @param int|float|null $min - * @param int|float|null $max - * @return bool - * @throws DatabaseException - * @throws MongoException - * @throws Exception - */ - public function increaseDocumentAttribute(string $collection, string $id, string $attribute, int|float $value, string $updatedAt, int|float|null $min = null, int|float|null $max = null): bool - { - $attribute = $this->filter($attribute); - $filters = ['_uid' => $id]; - - if ($this->sharedTables) { - $filters['_tenant'] = (string)$this->getTenant(); - } - - if ($max) { - $filters[$attribute] = ['$lte' => $max]; - } - - if ($min) { - $filters[$attribute] = ['$gte' => $min]; - } - - $this->client->update( - $this->getNamespace() . '_' . $this->filter($collection), - $filters, - [ - '$inc' => [$attribute => $value], - '$set' => ['_updatedAt' => $this->toMongoDatetime($updatedAt)], - ], - ); - - return true; - } - - /** - * Delete Document - * - * @param string $collection - * @param string $id - * - * @return bool - * @throws Exception - */ - public function deleteDocument(string $collection, string $id): bool - { - $name = $this->getNamespace() . '_' . $this->filter($collection); - - $filters = []; - $filters['_uid'] = $id; - if ($this->sharedTables) { - $filters['_tenant'] = (string)$this->getTenant(); - } - - $result = $this->client->delete($name, $filters); - - return (!!$result); - } - - /** - * Delete Documents - * - * @param string $collection - * @param array $ids - * - * @return int - */ - public function deleteDocuments(string $collection, array $ids): int - { - $name = $this->getNamespace() . '_' . $this->filter($collection); - - $filters = $this->buildFilters([new Query(Query::TYPE_EQUAL, '_uid', $ids)]); - - if ($this->sharedTables) { - $filters['_tenant'] = (string)$this->getTenant(); - } - - $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators); - $filters = $this->timeFilter($filters); - - $options = []; - - try { - $count = $this->client->delete( - collection: $name, - filters: $filters, - options: $options, - limit: 0 - ); - } catch (MongoException $e) { - $this->processException($e); - } - - return $count ?? 0; - } - - /** - * Update Attribute. - * @param string $collection - * @param string $id - * @param string $type - * @param int $size - * @param bool $signed - * @param bool $array - * @param string $newKey - * - * @return bool - */ - public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null): bool - { - if (!empty($newKey) && $newKey !== $id) { - return $this->renameAttribute($collection, $id, $newKey); - } - - return true; - } - - /** - * Find Documents - * - * Find data sets using chosen queries - * - * @param string $collection - * @param array $queries - * @param int|null $limit - * @param int|null $offset - * @param array $orderAttributes - * @param array $orderTypes - * @param array $cursor - * @param string $cursorDirection - * @param string $forPermission - * - * @return array - * @throws Exception - * @throws Timeout - */ - public function find(string $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array - { - $name = $this->getNamespace() . '_' . $this->filter($collection); - $queries = array_map(fn ($query) => clone $query, $queries); - - $filters = $this->buildFilters($queries); - - if ($this->sharedTables) { - $filters['_tenant'] = (string)$this->getTenant(); - } - - // permissions - if (Authorization::$status) { - $roles = \implode('|', Authorization::getRoles()); - $filters['_permissions']['$in'] = [new Regex("{$forPermission}\(\".*(?:{$roles}).*\"\)", 'i')]; - } - - $options = []; - if (!\is_null($limit)) { - $options['limit'] = $limit; - } - if (!\is_null($offset)) { - $options['skip'] = $offset; - } - - if ($this->timeout) { - $options['maxTimeMS'] = $this->timeout; - } - - $selections = $this->getAttributeSelections($queries); - - if (!empty($selections) && !\in_array('*', $selections)) { - $options['projection'] = $this->getAttributeProjection($selections); - } - - // orders - foreach ($orderAttributes as $i => $attribute) { - $attribute = $this->filter($attribute); - $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); - - if ($cursorDirection === Database::CURSOR_BEFORE) { - $orderType = $orderType === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; - } - - $attribute = $attribute == 'id' ? '_uid' : $attribute; - $attribute = $attribute == 'internalId' ? '_id' : $attribute; - $attribute = $attribute == 'createdAt' ? '_createdAt' : $attribute; - $attribute = $attribute == 'updatedAt' ? '_updatedAt' : $attribute; - - $options['sort'][$attribute] = $this->getOrder($orderType); - } - - $options['sort']['_id'] = $this->getOrder($cursorDirection === Database::CURSOR_AFTER ? Database::ORDER_ASC : Database::ORDER_DESC); - - // queries - - if (empty($orderAttributes)) { - // Allow after pagination without any order - if (!empty($cursor)) { - $orderType = $orderTypes[0] ?? Database::ORDER_ASC; - $orderOperator = $cursorDirection === Database::CURSOR_AFTER - ? ($orderType === Database::ORDER_DESC ? Query::TYPE_LESSER : Query::TYPE_GREATER) - : ($orderType === Database::ORDER_DESC ? Query::TYPE_GREATER : Query::TYPE_LESSER); - - $filters = array_merge($filters, [ - '_id' => [ - $this->getQueryOperator($orderOperator) => new ObjectId($cursor['$internalId']) - ] - ]); - } - // Allow order type without any order attribute, fallback to the natural order (_id) - if (!empty($orderTypes)) { - $orderType = $this->filter($orderTypes[0] ?? Database::ORDER_ASC); - if ($cursorDirection === Database::CURSOR_BEFORE) { - $orderType = $orderType === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; - } - - $options['sort']['_id'] = $this->getOrder($orderType); - } - } - - if (!empty($cursor) && !empty($orderAttributes) && array_key_exists(0, $orderAttributes)) { - $attribute = $orderAttributes[0]; - - if (is_null($cursor[$attribute] ?? null)) { - throw new DatabaseException("Order attribute '{$attribute}' is empty"); - } - - $orderOperatorInternalId = Query::TYPE_GREATER; - $orderType = $this->filter($orderTypes[0] ?? Database::ORDER_ASC); - $orderOperator = $orderType === Database::ORDER_DESC ? Query::TYPE_LESSER : Query::TYPE_GREATER; - - if ($cursorDirection === Database::CURSOR_BEFORE) { - $orderType = $orderType === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; - $orderOperatorInternalId = $orderType === Database::ORDER_ASC ? Query::TYPE_LESSER : Query::TYPE_GREATER; - $orderOperator = $orderType === Database::ORDER_DESC ? Query::TYPE_LESSER : Query::TYPE_GREATER; - } - - $cursorFilters = [ - [ - $attribute => [ - $this->getQueryOperator($orderOperator) => $cursor[$attribute] - ] - ], - [ - $attribute => $cursor[$attribute], - '_id' => [ - $this->getQueryOperator($orderOperatorInternalId) => new ObjectId($cursor['$internalId']) - ] - ], - ]; - - $filters = [ - '$and' => [$filters, ['$or' => $cursorFilters]] - ]; - } - - $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators); - $filters = $this->timeFilter($filters); - /** - * @var array - */ - $found = []; - - try { - $results = $this->client->find($name, $filters, $options)->cursor->firstBatch ?? []; - } catch (MongoException $e) { - throw $this->processException($e); - } - - if (empty($results)) { - return $found; - } - - foreach ($this->client->toArray($results) as $result) { - $record = $this->replaceChars('_', '$', (array)$result); - $record = $this->timeToDocument($record); - - $found[] = new Document($record); - } - - if ($cursorDirection === Database::CURSOR_BEFORE) { - $found = array_reverse($found); - } - - return $found; - } - - /** - * Recursive function to convert timestamps/datetime - * to BSON based UTCDatetime type for Mongo filter/query. - * - * @param array $filters - * - * @return array - * @throws Exception - */ - private function timeFilter(array $filters): array - { - $results = $filters; - - foreach ($filters as $k => $v) { - if ($k === '_createdAt' || $k == '_updatedAt') { - if (is_array($v)) { - foreach ($v as $sk => $sv) { - $results[$k][$sk] = $this->toMongoDatetime($sv); - } - } else { - $results[$k] = $this->toMongoDatetime($v); - } - } else { - if (is_array($v)) { - $results[$k] = $this->timeFilter($v); - } - } - } - - return $results; - } - - /** - * Converts timestamp base fields to Utopia\Document format. - * - * @param array $record - * - * @return array - */ - private function timeToDocument(array $record): array - { - $record['$createdAt'] = DateTime::format($record['$createdAt']->toDateTime()); - $record['$updatedAt'] = DateTime::format($record['$updatedAt']->toDateTime()); - - return $record; - } - - /** - * Converts timestamp base fields to Mongo\BSON datetime format. - * - * @param array $record - * - * @return array - * @throws Exception - */ - private function timeToMongo(array $record): array - { - if (isset($record['_createdAt'])) { - $record['_createdAt'] = $this->toMongoDatetime($record['_createdAt']); - } - - if (isset($record['_updatedAt'])) { - $record['_updatedAt'] = $this->toMongoDatetime($record['_updatedAt']); - } - - return $record; - } - - /** - * Converts timestamp to Mongo\BSON datetime format. - * - * @param string $dt - * @return UTCDateTime - * @throws Exception - */ - private function toMongoDatetime(string $dt): UTCDateTime - { - return new UTCDateTime(new \DateTime($dt)); - } - - /** - * Recursive function to replace chars in array keys, while - * skipping any that are explicitly excluded. - * - * @param array $array - * @param string $from - * @param string $to - * @param array $exclude - * @return array - */ - private function replaceInternalIdsKeys(array $array, string $from, string $to, array $exclude = []): array - { - $result = []; - - foreach ($array as $key => $value) { - if (!in_array($key, $exclude)) { - $key = str_replace($from, $to, $key); - } - - $result[$key] = is_array($value) - ? $this->replaceInternalIdsKeys($value, $from, $to, $exclude) - : $value; - } - - return $result; - } - - - /** - * Count Documents - * - * @param string $collection - * @param array $queries - * @param int|null $max - * - * @return int - * @throws Exception - */ - public function count(string $collection, array $queries = [], ?int $max = null): int - { - $name = $this->getNamespace() . '_' . $this->filter($collection); - - $queries = array_map(fn ($query) => clone $query, $queries); - - $filters = []; - $options = []; - - // set max limit - if ($max > 0) { - $options['limit'] = $max; - } - - if ($this->timeout) { - $options['maxTimeMS'] = $this->timeout; - } - - // queries - $filters = $this->buildFilters($queries); - - // permissions - if (Authorization::$status) { // skip if authorization is disabled - $roles = \implode('|', Authorization::getRoles()); - $filters['_permissions']['$in'] = [new Regex("read\(\".*(?:{$roles}).*\"\)", 'i')]; - } - - return $this->client->count($name, $filters, $options); - } - - /** - * Sum an attribute - * - * @param string $collection - * @param string $attribute - * @param array $queries - * @param int|null $max - * - * @return int|float - * @throws Exception - */ - public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null): float|int - { - $name = $this->getNamespace() . '_' . $this->filter($collection); - - // queries - $queries = array_map(fn ($query) => clone $query, $queries); - $filters = $this->buildFilters($queries); - - // permissions - if (Authorization::$status) { // skip if authorization is disabled - $roles = \implode('|', Authorization::getRoles()); - $filters['_permissions']['$in'] = [new Regex("read\(\".*(?:{$roles}).*\"\)", 'i')]; - } - - // using aggregation to get sum an attribute as described in - // https://docs.mongodb.com/manual/reference/method/db.collection.aggregate/ - // Pipeline consists of stages to aggregation, so first we set $match - // that will load only documents that matches the filters provided and passes to the next stage - // then we set $limit (if $max is provided) so that only $max documents will be passed to the next stage - // finally we use $group stage to sum the provided attribute that matches the given filters and max - // We pass the $pipeline to the aggregate method, which returns a cursor, then we get - // the array of results from the cursor, and we return the total sum of the attribute - $pipeline = []; - if (!empty($filters)) { - $pipeline[] = ['$match' => $filters]; - } - if (!empty($max)) { - $pipeline[] = ['$limit' => $max]; - } - $pipeline[] = [ - '$group' => [ - '_id' => null, - 'total' => ['$sum' => '$' . $attribute], - ], - ]; - - return $this->client->aggregate($name, $pipeline)->cursor->firstBatch[0]->total ?? 0; - } - - /** - * @return Client - * - * @throws Exception - */ - protected function getClient(): Client - { - return $this->client; - } - - /** - * Keys cannot begin with $ in MongoDB - * Convert $ prefix to _ on $id, $permissions, and $collection - * - * @param string $from - * @param string $to - * @param array $array - * @return array - */ - protected function replaceChars(string $from, string $to, array $array): array - { - $filter = [ - 'permissions', - 'createdAt', - 'updatedAt', - 'collection' - ]; - - $result = []; - foreach ($array as $k => $v) { - $clean_key = str_replace($from, "", $k); - $key = in_array($clean_key, $filter) ? str_replace($from, $to, $k) : $k; - - $result[$key] = is_array($v) ? $this->replaceChars($from, $to, $v) : $v; - } - - if ($from === '_') { - if (array_key_exists('_id', $array)) { - $result['$internalId'] = (string)$array['_id']; - unset($result['_id']); - } - if (array_key_exists('_uid', $array)) { - $result['$id'] = $array['_uid']; - unset($result['_uid']); - } - if (array_key_exists('_tenant', $array)) { - $result['$tenant'] = $array['_tenant']; - unset($result['_tenant']); - } - } elseif ($from === '$') { - if (array_key_exists('$id', $array)) { - $result['_uid'] = $array['$id']; - unset($result['$id']); - } - if (array_key_exists('$internalId', $array)) { - $result['_id'] = new ObjectId($array['$internalId']); - unset($result['$internalId']); - } - if (array_key_exists('$tenant', $array)) { - $result['_tenant'] = $array['$tenant']; - unset($result['$tenant']); - } - } - - return $result; - } - - /** - * @param array $queries - * @param string $separator - * @return array - * @throws Exception - */ - protected function buildFilters(array $queries, string $separator = '$and'): array - { - $filters = []; - $queries = Query::groupByType($queries)['filters']; - foreach ($queries as $query) { - /* @var $query Query */ - if ($query->isNested()) { - $operator = $this->getQueryOperator($query->getMethod()); - $filters[$separator][] = $this->buildFilters($query->getValues(), $operator); - } else { - $filters[$separator][] = $this->buildFilter($query); - } - } - - return $filters; - } - - /** - * @param Query $query - * @return array - * @throws Exception - */ - protected function buildFilter(Query $query): array - { - if ($query->getAttribute() === '$id') { - $query->setAttribute('_uid'); - } elseif ($query->getAttribute() === '$internalId') { - $query->setAttribute('_id'); - $values = $query->getValues(); - foreach ($values as $k => $v) { - $values[$k] = new ObjectId($v); - } - $query->setValues($values); - } elseif ($query->getAttribute() === '$createdAt') { - $query->setAttribute('_createdAt'); - } elseif ($query->getAttribute() === '$updatedAt') { - $query->setAttribute('_updatedAt'); - } - - $attribute = $query->getAttribute(); - $operator = $this->getQueryOperator($query->getMethod()); - - $value = match ($query->getMethod()) { - Query::TYPE_IS_NULL, - Query::TYPE_IS_NOT_NULL => null, - default => $this->getQueryValue( - $query->getMethod(), - count($query->getValues()) > 1 - ? $query->getValues() - : $query->getValues()[0] - ), - }; - - $filter = []; - - if ($operator == '$eq' && \is_array($value)) { - $filter[$attribute]['$in'] = $value; - } elseif ($operator == '$ne' && \is_array($value)) { - $filter[$attribute]['$nin'] = $value; - } elseif ($operator == '$in') { - if ($query->getMethod() === Query::TYPE_CONTAINS && !$query->onArray()) { - $filter[$attribute]['$regex'] = new Regex(".*{$this->escapeWildcards($value)}.*", 'i'); - } else { - $filter[$attribute]['$in'] = $query->getValues(); - } - } elseif ($operator == '$search') { - $filter['$text'][$operator] = $value; - } elseif ($operator === Query::TYPE_BETWEEN) { - $filter[$attribute]['$lte'] = $value[1]; - $filter[$attribute]['$gte'] = $value[0]; - } else { - $filter[$attribute][$operator] = $value; - } - - return $filter; - } - - /** - * Get Query Operator - * - * @param string $operator - * - * @return string - * @throws Exception - */ - protected function getQueryOperator(string $operator): string - { - return match ($operator) { - Query::TYPE_EQUAL, - Query::TYPE_IS_NULL => '$eq', - Query::TYPE_NOT_EQUAL, - Query::TYPE_IS_NOT_NULL => '$ne', - Query::TYPE_LESSER => '$lt', - Query::TYPE_LESSER_EQUAL => '$lte', - Query::TYPE_GREATER => '$gt', - Query::TYPE_GREATER_EQUAL => '$gte', - Query::TYPE_CONTAINS => '$in', - Query::TYPE_SEARCH => '$search', - Query::TYPE_BETWEEN => 'between', - Query::TYPE_STARTS_WITH, - Query::TYPE_ENDS_WITH => '$regex', - Query::TYPE_OR => '$or', - Query::TYPE_AND => '$and', - default => throw new DatabaseException('Unknown operator:' . $operator . '. Must be one of ' . Query::TYPE_EQUAL . ', ' . Query::TYPE_NOT_EQUAL . ', ' . Query::TYPE_LESSER . ', ' . Query::TYPE_LESSER_EQUAL . ', ' . Query::TYPE_GREATER . ', ' . Query::TYPE_GREATER_EQUAL . ', ' . Query::TYPE_IS_NULL . ', ' . Query::TYPE_IS_NOT_NULL . ', ' . Query::TYPE_BETWEEN . ', ' . Query::TYPE_CONTAINS . ', ' . Query::TYPE_SEARCH . ', ' . Query::TYPE_SELECT), - }; - } - - protected function getQueryValue(string $method, mixed $value): mixed - { - switch ($method) { - case Query::TYPE_STARTS_WITH: - $value = $this->escapeWildcards($value); - return $value.'.*'; - case Query::TYPE_ENDS_WITH: - $value = $this->escapeWildcards($value); - return '.*'.$value; - default: - return $value; - } - } - - /** - * Get Mongo Order - * - * @param string $order - * - * @return int - * @throws Exception - */ - protected function getOrder(string $order): int - { - return match ($order) { - Database::ORDER_ASC => 1, - Database::ORDER_DESC => -1, - default => throw new DatabaseException('Unknown sort order:' . $order . '. Must be one of ' . Database::ORDER_ASC . ', ' . Database::ORDER_DESC), - }; - } - - /** - * @param array $selections - * @param string $prefix - * @return mixed - */ - protected function getAttributeProjection(array $selections, string $prefix = ''): mixed - { - $projection = []; - - $internalKeys = \array_map( - fn ($attr) => $attr['$id'], - Database::INTERNAL_ATTRIBUTES - ); - - foreach ($selections as $selection) { - // Skip internal attributes since all are selected by default - if (\in_array($selection, $internalKeys)) { - continue; - } - - $projection[$selection] = 1; - } - - $projection['_uid'] = 1; - $projection['_id'] = 1; - $projection['_createdAt'] = 1; - $projection['_updatedAt'] = 1; - $projection['_permissions'] = 1; - - return $projection; - } - - /** - * Get max STRING limit - * - * @return int - */ - public function getLimitForString(): int - { - return 2147483647; - } - - /** - * Get max INT limit - * - * @return int - */ - public function getLimitForInt(): int - { - // Mongo does not handle integers directly, so using MariaDB limit for now - return 4294967295; - } - - /** - * Get maximum column limit. - * Returns 0 to indicate no limit - * - * @return int - */ - public function getLimitForAttributes(): int - { - return 0; - } - - /** - * Get maximum index limit. - * https://docs.mongodb.com/manual/reference/limits/#mongodb-limit-Number-of-Indexes-per-Collection - * - * @return int - */ - public function getLimitForIndexes(): int - { - return 64; - } - - public function getMinDateTime(): \DateTime - { - return new \DateTime('-9999-01-01 00:00:00'); - } - - /** - * Is schemas supported? - * - * @return bool - */ - public function getSupportForSchemas(): bool - { - return false; - } - - /** - * Are attributes supported? - * - * @return bool - */ - public function getSupportForAttributes(): bool - { - return false; - } - - /** - * Is index supported? - * - * @return bool - */ - public function getSupportForIndex(): bool - { - return true; - } - - /** - * Is unique index supported? - * - * @return bool - */ - public function getSupportForUniqueIndex(): bool - { - return true; - } - - /** - * Is fulltext index supported? - * - * @return bool - */ - public function getSupportForFulltextIndex(): bool - { - return true; - } - - /** - * Is fulltext Wildcard index supported? - * - * @return bool - */ - public function getSupportForFulltextWildcardIndex(): bool - { - return false; - } - - /** - * Does the adapter handle Query Array Contains? - * - * @return bool - */ - public function getSupportForQueryContains(): bool - { - return true; - } - - /** - * Are timeouts supported? - * - * @return bool - */ - public function getSupportForTimeouts(): bool - { - return true; - } - - public function getSupportForRelationships(): bool - { - return false; - } - - public function getSupportForUpdateLock(): bool - { - return false; - } - - public function getSupportForAttributeResizing(): bool - { - return false; - } - - /** - * Are batch operations supported? - * - * @return bool - */ - public function getSupportForBatchOperations(): bool - { - return false; - } - - /** - * Is get connection id supported? - * - * @return bool - */ - public function getSupportForGetConnectionId(): bool - { - return false; - } - - /** - * Is cache fallback supported? - * - * @return bool - */ - public function getSupportForCacheSkipOnFailure(): bool - { - return false; - } - - /** - * Is get schema attributes supported? - * - * @return bool - */ - public function getSupportForSchemaAttributes(): bool - { - return false; - } - - public function getSupportForCastIndexArray(): bool - { - return false; - } - - public function getSupportForUpserts(): bool - { - return false; - } - - public function getSupportForReconnection(): bool - { - return false; - } - - /** - * Get current attribute count from collection document - * - * @param Document $collection - * @return int - */ - public function getCountOfAttributes(Document $collection): int - { - $attributes = \count($collection->getAttribute('attributes') ?? []); - - return $attributes + static::getCountOfDefaultAttributes(); - } - - /** - * Get current index count from collection document - * - * @param Document $collection - * @return int - */ - public function getCountOfIndexes(Document $collection): int - { - $indexes = \count($collection->getAttribute('indexes') ?? []); - - return $indexes + static::getCountOfDefaultIndexes(); - } - - /** - * Returns number of attributes used by default. - *p - * @return int - */ - public static function getCountOfDefaultAttributes(): int - { - return \count(Database::INTERNAL_ATTRIBUTES); - } - - /** - * Returns number of indexes used by default. - * - * @return int - */ - public static function getCountOfDefaultIndexes(): int - { - return \count(Database::INTERNAL_INDEXES); - } - - /** - * Get maximum width, in bytes, allowed for a SQL row - * Return 0 when no restrictions apply - * - * @return int - */ - public static function getDocumentSizeLimit(): int - { - return 0; - } - - /** - * Estimate maximum number of bytes required to store a document in $collection. - * Byte requirement varies based on column type and size. - * Needed to satisfy MariaDB/MySQL row width limit. - * Return 0 when no restrictions apply to row width - * - * @param Document $collection - * @return int - */ - public function getAttributeWidth(Document $collection): int - { - return 0; - } - - /** - * Is casting supported? - * - * @return bool - */ - public function getSupportForCasting(): bool - { - return true; - } - - /** - * Flattens the array. - * - * @param mixed $list - * @return array - */ - protected function flattenArray(mixed $list): array - { - if (!is_array($list)) { - // make sure the input is an array - return array($list); - } - - $newArray = []; - - foreach ($list as $value) { - $newArray = array_merge($newArray, $this->flattenArray($value)); - } - - return $newArray; - } - - /** - * @param array|Document $target - * @return array - */ - protected function removeNullKeys(array|Document $target): array - { - $target = \is_array($target) ? $target : $target->getArrayCopy(); - $cleaned = []; - - foreach ($target as $key => $value) { - if (\is_null($value)) { - continue; - } - - $cleaned[$key] = $value; - } - - - return $cleaned; - } - - public function getKeywords(): array - { - return []; - } - - protected function processException(Exception $e): \Exception - { - if ($e->getCode() === 50) { - return new Timeout('Query timed out', $e->getCode(), $e); - } - - return $e; - } - - /** - * @return int - */ - public function getMaxIndexLength(): int - { - return 0; - } - - public function getConnectionId(): string - { - return '0'; - } - - public function getInternalIndexesKeys(): array - { - return []; - } - - public function getSchemaAttributes(string $collection): array - { - return []; - } - - public function getTenantQuery(string $collection, string $parentAlias = ''): string - { - return (string)$this->getTenant(); - } -} diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index 1a2d686ef..b7a178c01 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -4098,6 +4098,17 @@ public function testFindOrderByCursorAfter(): void Query::offset(0), ]); + $movies = static::getDatabase()->find('movies', [ + Query::limit(25), + Query::offset(0), + Query::cursorAfter($movies[1]), + Query::orderDesc('$createdAt'), + Query::orderDesc(''), + ]); + + $this->assertEquals('shmuel', 'fogel'); + + $documents = static::getDatabase()->find('movies', [ Query::limit(2), Query::offset(0), diff --git a/tests/e2e/Adapter/MongoDBTest.php b/tests/e2e/Adapter/MongoDBTest.php deleted file mode 100644 index e582aef59..000000000 --- a/tests/e2e/Adapter/MongoDBTest.php +++ /dev/null @@ -1,108 +0,0 @@ -connect('redis', 6379); - $redis->flushAll(); - $cache = new Cache(new RedisAdapter($redis)); - - $schema = 'utopiaTests'; // same as $this->testDatabase - $client = new Client( - $schema, - 'mongo', - 27017, - 'root', - 'example', - false - ); - - $database = new Database(new Mongo($client), $cache); - $database - ->setDatabase($schema) - ->setNamespace(static::$namespace = 'myapp_' . uniqid()); - - if ($database->exists()) { - $database->delete(); - } - - $database->create(); - - return self::$database = $database; - } - - /** - * @throws Exception - */ - public function testCreateExistsDelete(): void - { - // Mongo creates databases on the fly, so exists would always pass. So we override this test to remove the exists check. - $this->assertNotNull(static::getDatabase()->create()); - $this->assertEquals(true, static::getDatabase()->delete($this->testDatabase)); - $this->assertEquals(true, static::getDatabase()->create()); - $this->assertEquals(static::getDatabase(), static::getDatabase()->setDatabase($this->testDatabase)); - } - - public function testRenameAttribute(): void - { - $this->assertTrue(true); - } - - public function testRenameAttributeExisting(): void - { - $this->assertTrue(true); - } - - public function testUpdateAttributeStructure(): void - { - $this->assertTrue(true); - } - - public function testKeywords(): void - { - $this->assertTrue(true); - } - - protected static function deleteColumn(string $collection, string $column): bool - { - return true; - } - - protected static function deleteIndex(string $collection, string $index): bool - { - return true; - } -} From bb65610de5717a16668b19475528d4511f12a8d2 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 17 Mar 2025 09:04:45 +0200 Subject: [PATCH 7/9] Remove Mongo --- .../e2e/Adapter/SharedTables/MongoDBTest.php | 111 ------------------ 1 file changed, 111 deletions(-) delete mode 100644 tests/e2e/Adapter/SharedTables/MongoDBTest.php diff --git a/tests/e2e/Adapter/SharedTables/MongoDBTest.php b/tests/e2e/Adapter/SharedTables/MongoDBTest.php deleted file mode 100644 index 7d41bb711..000000000 --- a/tests/e2e/Adapter/SharedTables/MongoDBTest.php +++ /dev/null @@ -1,111 +0,0 @@ -connect('redis', 6379); - $redis->flushAll(); - $cache = new Cache(new RedisAdapter($redis)); - - $schema = 'utopiaTests'; // same as $this->testDatabase - $client = new Client( - $schema, - 'mongo', - 27017, - 'root', - 'example', - false - ); - - $database = new Database(new Mongo($client), $cache); - $database - ->setDatabase($schema) - ->setSharedTables(true) - ->setTenant(999) - ->setNamespace(static::$namespace = ''); - - if ($database->exists()) { - $database->delete(); - } - - $database->create(); - - return self::$database = $database; - } - - /** - * @throws Exception - */ - public function testCreateExistsDelete(): void - { - // Mongo creates databases on the fly, so exists would always pass. So we override this test to remove the exists check. - $this->assertNotNull(static::getDatabase()->create()); - $this->assertEquals(true, static::getDatabase()->delete($this->testDatabase)); - $this->assertEquals(true, static::getDatabase()->create()); - $this->assertEquals(static::getDatabase(), static::getDatabase()->setDatabase($this->testDatabase)); - } - - public function testRenameAttribute(): void - { - $this->assertTrue(true); - } - - public function testRenameAttributeExisting(): void - { - $this->assertTrue(true); - } - - public function testUpdateAttributeStructure(): void - { - $this->assertTrue(true); - } - - public function testKeywords(): void - { - $this->assertTrue(true); - } - - protected static function deleteColumn(string $collection, string $column): bool - { - return true; - } - - protected static function deleteIndex(string $collection, string $index): bool - { - return true; - } -} From b9de83ce5611fef99c6f55687b1b73d51479728d Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 17 Mar 2025 09:13:01 +0200 Subject: [PATCH 8/9] Revert test --- tests/e2e/Adapter/Base.php | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index b7a178c01..1a2d686ef 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -4098,17 +4098,6 @@ public function testFindOrderByCursorAfter(): void Query::offset(0), ]); - $movies = static::getDatabase()->find('movies', [ - Query::limit(25), - Query::offset(0), - Query::cursorAfter($movies[1]), - Query::orderDesc('$createdAt'), - Query::orderDesc(''), - ]); - - $this->assertEquals('shmuel', 'fogel'); - - $documents = static::getDatabase()->find('movies', [ Query::limit(2), Query::offset(0), From 4cbe055cd3e8e7e1f1f8d8164099feb0086fdcb3 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 17 Mar 2025 09:30:10 +0200 Subject: [PATCH 9/9] Empty is not valid --- src/Database/Validator/Query/Order.php | 3 --- tests/unit/Validator/Query/OrderTest.php | 6 ++++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Database/Validator/Query/Order.php b/src/Database/Validator/Query/Order.php index 196079618..f0e7f2d56 100644 --- a/src/Database/Validator/Query/Order.php +++ b/src/Database/Validator/Query/Order.php @@ -57,9 +57,6 @@ public function isValid($value): bool $attribute = $value->getAttribute(); if ($method === Query::TYPE_ORDER_ASC || $method === Query::TYPE_ORDER_DESC) { - if ($attribute === '') { - return true; - } return $this->isValidAttribute($attribute); } diff --git a/tests/unit/Validator/Query/OrderTest.php b/tests/unit/Validator/Query/OrderTest.php index 9755bdc83..3a307171f 100644 --- a/tests/unit/Validator/Query/OrderTest.php +++ b/tests/unit/Validator/Query/OrderTest.php @@ -27,6 +27,12 @@ public function setUp(): void 'type' => Database::VAR_STRING, 'array' => false, ]), + new Document([ + '$id' => '$internalId', + 'key' => '$internalId', + 'type' => Database::VAR_STRING, + 'array' => false, + ]), ], ); }